@ryuzaki13/react-foundation-lib 1.4.1 → 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +154 -100
  2. package/dist/chunks/{formatters-CBgrBeSA.js → formatters-D33Uh3si.js} +7 -8
  3. package/dist/chunks/{formatters-CBgrBeSA.js.map → formatters-D33Uh3si.js.map} +1 -1
  4. package/dist/chunks/odata-service-CWkHASdw.js +2211 -0
  5. package/dist/chunks/odata-service-CWkHASdw.js.map +1 -0
  6. package/dist/chunks/{validate-BW-7BZy_.js → validate-DyzojM0w.js} +2 -2
  7. package/dist/chunks/{validate-BW-7BZy_.js.map → validate-DyzojM0w.js.map} +1 -1
  8. package/dist/error-report/index.js +2 -2
  9. package/dist/excel/index.js +20 -21
  10. package/dist/excel/index.js.map +1 -1
  11. package/dist/formatters/index.js +4 -5
  12. package/dist/{odata → odata-service}/buildSeparatedArrays.d.ts +1 -1
  13. package/dist/odata-service/buildSeparatedArrays.d.ts.map +1 -0
  14. package/dist/{odata/types.d.ts → odata-service/collectionTypes.d.ts} +1 -1
  15. package/dist/odata-service/collectionTypes.d.ts.map +1 -0
  16. package/dist/{odata → odata-service}/dateUtils.d.ts +1 -1
  17. package/dist/odata-service/dateUtils.d.ts.map +1 -0
  18. package/dist/{odata → odata-service}/findCollectionPairs.d.ts +1 -1
  19. package/dist/odata-service/findCollectionPairs.d.ts.map +1 -0
  20. package/dist/odata-service/index.d.ts +5 -0
  21. package/dist/odata-service/index.d.ts.map +1 -1
  22. package/dist/odata-service/index.js +2 -2
  23. package/dist/{odata/store.d.ts → odata-service/odataCollectionStore.d.ts} +1 -1
  24. package/dist/odata-service/odataCollectionStore.d.ts.map +1 -0
  25. package/dist/query-client/index.js.map +1 -1
  26. package/dist/query-client/queryClient.d.ts +1 -3
  27. package/dist/query-client/queryClient.d.ts.map +1 -1
  28. package/package.json +1 -5
  29. package/src/bounded-copy-stack/README.mdx +257 -0
  30. package/src/context-menu/README.mdx +326 -0
  31. package/src/currency/README.mdx +155 -0
  32. package/src/date-segments/README.mdx +319 -0
  33. package/src/error/README.mdx +95 -0
  34. package/src/error-report/README.mdx +612 -0
  35. package/src/excel/README.mdx +449 -0
  36. package/src/fingerprint/README.mdx +100 -0
  37. package/src/form/README.mdx +101 -0
  38. package/src/foundationLib.mdx +70 -54
  39. package/src/hooks/README.mdx +988 -0
  40. package/src/http/README.mdx +126 -0
  41. package/src/media/README.mdx +1853 -0
  42. package/src/notifications/README.mdx +323 -0
  43. package/src/number-scale/README.mdx +318 -0
  44. package/src/odata-service/README.mdx +2916 -0
  45. package/src/presets/README.mdx +136 -0
  46. package/src/pwa/README.mdx +394 -0
  47. package/src/query-client/README.mdx +2500 -0
  48. package/src/range-output/README.mdx +161 -0
  49. package/src/seo/README.mdx +279 -0
  50. package/src/session-storage/README.mdx +125 -0
  51. package/src/string-comparison/README.mdx +96 -0
  52. package/src/table/README.mdx +3106 -0
  53. package/src/three-scene/README.mdx +248 -0
  54. package/src/types/README.mdx +172 -0
  55. package/src/utils/README.mdx +1724 -0
  56. package/src/validators/README.mdx +239 -0
  57. package/src/virtualizer/README.mdx +138 -0
  58. package/src/xml/README.mdx +149 -0
  59. package/dist/chunks/dateUtils-B5V5aXB-.js +0 -847
  60. package/dist/chunks/dateUtils-B5V5aXB-.js.map +0 -1
  61. package/dist/chunks/odata-service-B6TxBSLm.js +0 -1277
  62. package/dist/chunks/odata-service-B6TxBSLm.js.map +0 -1
  63. package/dist/odata/buildSeparatedArrays.d.ts.map +0 -1
  64. package/dist/odata/dateUtils.d.ts.map +0 -1
  65. package/dist/odata/findCollectionPairs.d.ts.map +0 -1
  66. package/dist/odata/index.d.ts +0 -6
  67. package/dist/odata/index.d.ts.map +0 -1
  68. package/dist/odata/index.js +0 -102
  69. package/dist/odata/index.js.map +0 -1
  70. package/dist/odata/store.d.ts.map +0 -1
  71. package/dist/odata/types.d.ts.map +0 -1
  72. package/src/pwa/README.md +0 -638
  73. package/src/query-client/README.md +0 -565
@@ -0,0 +1,2916 @@
1
+ import { Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ <Meta title="Foundation Lib/API/OData Service" />
4
+
5
+ # OData Service
6
+
7
+ Модуль `@ryuzaki13/react-foundation-lib/odata-service` — единая библиотечная точка входа для работы с техническими контрактами OData V2:
8
+
9
+ - чтения XML metadata сервиса;
10
+ - описания сущностей, колонок, параметров и Function Import;
11
+ - преобразования JavaScript-значений в OData literals и обратно;
12
+ - построения path для EntitySet и Function Import;
13
+ - построения `$filter` и `$orderby`;
14
+ - нормализации конфигурации пользовательских фильтров;
15
+ - поиска пар «код + текст» в справочных коллекциях;
16
+ - хранения общих настроек OData-справочников.
17
+
18
+ README рассчитан на разработчика без доступа к исходному коду и с минимальным знанием JavaScript/TypeScript. В примерах объясняется не только вызов функции, но и смысл входных данных, результат, ограничения и типичные ошибки.
19
+
20
+ > Ранее helpers справочных коллекций публиковались через отдельный subpath `@ryuzaki13/react-foundation-lib/odata`. Теперь этот код входит в `odata-service`. Старый subpath удалён без compatibility alias.
21
+
22
+ ## Содержание
23
+
24
+ - [Что делает и чего не делает модуль](#что-делает-и-чего-не-делает-модуль)
25
+ - [Установка и импорт](#установка-и-импорт)
26
+ - [Зависимости и среда выполнения](#зависимости-и-среда-выполнения)
27
+ - [OData V2 простыми словами](#odata-v2-простыми-словами)
28
+ - [Быстрый старт](#быстрый-старт)
29
+ - [Общий маршрут данных](#общий-маршрут-данных)
30
+ - [Базовые типы](#базовые-типы)
31
+ - [Чтение metadata](#чтение-metadata)
32
+ - [Типы результата metadata](#типы-результата-metadata)
33
+ - [Форматирование OData-значений](#форматирование-odata-значений)
34
+ - [Парсинг значений](#парсинг-значений)
35
+ - [Значения по умолчанию и formatter registry](#значения-по-умолчанию-и-formatter-registry)
36
+ - [Wrapped и unwrapped параметры](#wrapped-и-unwrapped-параметры)
37
+ - [Построение параметров и path](#построение-параметров-и-path)
38
+ - [Простые OData-фильтры](#простые-odata-фильтры)
39
+ - [Compiled filter definitions](#compiled-filter-definitions)
40
+ - [Сортировка и `$orderby`](#сортировка-и-orderby)
41
+ - [SQL-like фильтры](#sql-like-фильтры)
42
+ - [Пары «код + текст»](#пары-код--текст)
43
+ - [Настройки OData-коллекций](#настройки-odata-коллекций)
44
+ - [Маленькие helpers](#маленькие-helpers)
45
+ - [Ошибки, безопасность и ограничения](#ошибки-безопасность-и-ограничения)
46
+ - [SSR, браузер и React](#ssr-браузер-и-react)
47
+ - [Тестирование consumer-кода](#тестирование-consumer-кода)
48
+ - [Миграция с `/odata`](#миграция-с-odata)
49
+ - [Полная карта API](#полная-карта-api)
50
+ - [Частые вопросы](#частые-вопросы)
51
+
52
+ ## Что делает и чего не делает модуль
53
+
54
+ ### Делает
55
+
56
+ Модуль выполняет локальные, предсказуемые преобразования:
57
+
58
+ ```text
59
+ XML metadata ──> ServiceMetadata
60
+ JS value ──> OData literal
61
+ params ──> operation path
62
+ filter tree ──> строка $filter
63
+ sort state ──> строка $orderby
64
+ rows ──> пары code/text
65
+ ```
66
+
67
+ Большая часть функций не обращается к сети и не знает адрес backend.
68
+
69
+ ### Не делает
70
+
71
+ Модуль не выполняет HTTP-запросы и не управляет транспортом:
72
+
73
+ - не вызывает `fetch`;
74
+ - не добавляет SAP SSO credentials;
75
+ - не получает и не обновляет X-CSRF token;
76
+ - не формирует TanStack Query keys;
77
+ - не хранит server response cache;
78
+ - не выполняет mutations;
79
+ - не знает бизнес-правила конкретной таблицы или feature.
80
+
81
+ Для транспорта используйте отдельный пакет:
82
+
83
+ ```ts
84
+ import {} from /* transport API */ "@ryuzaki13/react-foundation-api/odata";
85
+ ```
86
+
87
+ Граница пакетов выглядит так:
88
+
89
+ ```text
90
+ react-foundation-lib/odata-service
91
+ metadata, types, values, path, filter, sort
92
+ │
93
+ ▼
94
+ react-foundation-api/odata
95
+ HTTP, SAP Gateway, SSO, X-CSRF, Query/Mutation
96
+ │
97
+ ▼
98
+ host entity / feature / widget
99
+ бизнес-правила, экран, пользовательский сценарий
100
+ ```
101
+
102
+ ## Установка и импорт
103
+
104
+ Установите пакет:
105
+
106
+ ```bash
107
+ npm install @ryuzaki13/react-foundation-lib
108
+ ```
109
+
110
+ Для полного entrypoint также разрешите совместимые peer dependencies, если package manager не сделал этого автоматически:
111
+
112
+ ```bash
113
+ npm install zod zustand immer @tanstack/react-query
114
+ ```
115
+
116
+ Импортируйте API только из опубликованного subpath:
117
+
118
+ ```ts
119
+ import {
120
+ buildEntityOperationPath,
121
+ buildODataFilter,
122
+ buildODataOrderBy,
123
+ odataFormatValue,
124
+ parseServiceMetadata
125
+ } from "@ryuzaki13/react-foundation-lib/odata-service";
126
+
127
+ import type {
128
+ EntityMetadata,
129
+ FilterExpression,
130
+ ServiceMetadata,
131
+ WrappedODataParameters
132
+ } from "@ryuzaki13/react-foundation-lib/odata-service";
133
+ ```
134
+
135
+ Не используйте корневой или приватный импорт:
136
+
137
+ ```ts
138
+ // Неверно: корневой entrypoint не опубликован.
139
+ import { buildODataFilter } from "@ryuzaki13/react-foundation-lib";
140
+
141
+ // Неверно: внутреннее расположение файлов не является публичным контрактом.
142
+ import { buildODataFilter } from "@ryuzaki13/react-foundation-lib/dist/odata-service/filters";
143
+
144
+ // Неверно: прежний subpath удалён после объединения модулей.
145
+ import { findCollectionPairs } from "@ryuzaki13/react-foundation-lib/odata-service";
146
+ ```
147
+
148
+ ### `import type` для TypeScript-типов
149
+
150
+ TypeScript-типы исчезают из JavaScript после сборки. Для них предпочтителен `import type`:
151
+
152
+ ```ts
153
+ import type { ServiceMetadata } from "@ryuzaki13/react-foundation-lib/odata-service";
154
+ ```
155
+
156
+ Обычные функции и constants требуют обычного `import`:
157
+
158
+ ```ts
159
+ import { parseServiceMetadata } from "@ryuzaki13/react-foundation-lib/odata-service";
160
+ ```
161
+
162
+ ## Зависимости и среда выполнения
163
+
164
+ После объединения один entrypoint содержит и pure helpers, и Zustand store. Поэтому host, который импортирует `odata-service`, должен уметь разрешить runtime dependencies entrypoint.
165
+
166
+ | Зависимость | Зачем нужна |
167
+ | ----------------------- | ------------------------------------------------------------------------- |
168
+ | `zod` | Runtime-проверка OData primitive values и аргументов formatter factories. |
169
+ | `react` | Runtime для React-oriented Zustand hook API. |
170
+ | `zustand` | Глобальный store настроек OData-коллекций. |
171
+ | `immer` | Middleware для обновления Zustand store. |
172
+ | `@tanstack/react-query` | Тип и helper `unwrapODataQueryResult`. |
173
+
174
+ Среда выполнения различается по API:
175
+
176
+ | Группа | Где можно использовать |
177
+ | -------------------------------------------------- | ------------------------------------------------------------------------- |
178
+ | Форматирование, filters, sorts, builders | Browser и Node.js, если не вызывается browser-only API. |
179
+ | `parseServiceMetadata` | Среда с глобальным `DOMParser`; обычно browser или DOM-полифил в Node.js. |
180
+ | `useODataCollectionStore`, `odataCollectionConfig` | React/browser runtime; selector hooks вызываются по правилам React hooks. |
181
+
182
+ Пакет распространяется как ESM. Используйте `import`, а не CommonJS `require`.
183
+
184
+ ## OData V2 простыми словами
185
+
186
+ ### Сервис
187
+
188
+ OData service — набор HTTP endpoints, объединённых общей схемой. Например:
189
+
190
+ ```text
191
+ /sap/opu/odata/sap/REPORT_SRV
192
+ ```
193
+
194
+ В `ODataServiceConfig` имя сервиса хранится без предположений о полном origin:
195
+
196
+ ```ts
197
+ const config = {
198
+ service: "REPORT_SRV",
199
+ target: "Sales"
200
+ };
201
+ ```
202
+
203
+ ### Metadata
204
+
205
+ Metadata — XML-документ, описывающий структуру сервиса:
206
+
207
+ - какие сущности существуют;
208
+ - какие поля есть у сущности;
209
+ - какой тип у каждого поля;
210
+ - какие параметры обязательны;
211
+ - какие Function Import доступны;
212
+ - какое поле является текстом для code-поля.
213
+
214
+ Обычно metadata получает transport-слой, а `parseServiceMetadata` превращает XML в удобный JavaScript-объект.
215
+
216
+ ### EntitySet и EntityType
217
+
218
+ `EntityType` описывает форму записи, например поля `ID`, `NAME`, `AMOUNT`.
219
+
220
+ `EntitySet` — адресуемая коллекция записей этого типа, например `Sales`.
221
+
222
+ В config свойство `target` обычно содержит имя EntitySet.
223
+
224
+ ### Function Import
225
+
226
+ Function Import — операция OData V2, похожая на именованный backend-метод. Она может принимать query parameters и иметь HTTP method из metadata.
227
+
228
+ ```text
229
+ /REPORT_SRV/Recalculate?p_year=2026
230
+ ```
231
+
232
+ ### Параметр и literal
233
+
234
+ JavaScript-строку нельзя бездумно вставлять в OData URL. Backend ожидает OData literal:
235
+
236
+ ```text
237
+ JavaScript: O'Reilly
238
+ OData: 'O''Reilly'
239
+
240
+ JavaScript: true
241
+ OData ABAP: 'X'
242
+
243
+ JavaScript: 12.5
244
+ Edm.Decimal: 12.5M
245
+ ```
246
+
247
+ `odataFormatValue` знает эти правила, а builder применяет их в metadata-порядке.
248
+
249
+ ### `$filter` и `$orderby`
250
+
251
+ `$filter` ограничивает записи:
252
+
253
+ ```text
254
+ STATUS eq 'A' and AMOUNT ge 100M
255
+ ```
256
+
257
+ `$orderby` задаёт сортировку:
258
+
259
+ ```text
260
+ YEAR asc,AMOUNT desc
261
+ ```
262
+
263
+ Модуль строит только значение query option. Передачу в HTTP request выполняет transport.
264
+
265
+ ### Code/text pair
266
+
267
+ SAP-сервисы часто возвращают технический код и читаемый текст разными полями:
268
+
269
+ ```ts
270
+ {
271
+ COMPANY: "1000",
272
+ COMPANY_txt: "Основная организация"
273
+ }
274
+ ```
275
+
276
+ `findCollectionPairs` находит такие пары, а `buildSeparatedArrays` создаёт из rows отдельные справочные массивы.
277
+
278
+ ## Быстрый старт
279
+
280
+ Представим, что transport уже получил XML metadata как строку.
281
+
282
+ ```ts
283
+ import {
284
+ buildEntityOperationPath,
285
+ buildODataFilter,
286
+ buildODataOrderBy,
287
+ createFilterEqual,
288
+ parseServiceMetadata,
289
+ wrapODataParams
290
+ } from "@ryuzaki13/react-foundation-lib/odata-service";
291
+
292
+ type SalesRow = {
293
+ COMPANY: string;
294
+ YEAR: number;
295
+ AMOUNT: number;
296
+ };
297
+
298
+ const serviceMetadata = parseServiceMetadata(metadataXml);
299
+ const salesMetadata = serviceMetadata.entities.Sales;
300
+
301
+ if (!salesMetadata) {
302
+ throw new Error("В metadata отсутствует EntitySet Sales");
303
+ }
304
+
305
+ const params = wrapODataParams({
306
+ p_company: "1000",
307
+ p_year: 2026
308
+ });
309
+
310
+ const path = buildEntityOperationPath(salesMetadata, { service: "REPORT_SRV", target: "Sales" }, params, "query");
311
+
312
+ const filter = buildODataFilter(createFilterEqual<SalesRow, string>("COMPANY", "1000"));
313
+
314
+ const orderBy = buildODataOrderBy<SalesRow>([], [{ key: "AMOUNT", desc: true }]);
315
+
316
+ console.log(path);
317
+ console.log(filter); // COMPANY eq '1000'
318
+ console.log(orderBy); // AMOUNT desc
319
+ ```
320
+
321
+ Transport затем использует `path`, `filter` и `orderBy` при создании request. Сам `odata-service` request не отправляет.
322
+
323
+ ## Общий маршрут данных
324
+
325
+ Для большинства экранов полезен такой порядок:
326
+
327
+ 1. Transport загружает metadata XML.
328
+ 2. `parseServiceMetadata` создаёт `ServiceMetadata`.
329
+ 3. Consumer выбирает `entities[target]` или `functionImports[target]`.
330
+ 4. Значения формы оборачиваются через `wrapODataParams` или сразу создаются как `{ value }`.
331
+ 5. Builder проверяет параметры по metadata и создаёт path.
332
+ 6. Filter helpers создают `$filter`.
333
+ 7. Sort helpers создают `$orderby`.
334
+ 8. API-пакет выполняет HTTP request.
335
+ 9. UI форматирует ответ через `formatters`/table pipeline, а не через path builder.
336
+
337
+ ```text
338
+ metadata XML
339
+ │ parseServiceMetadata
340
+ ▼
341
+ ServiceMetadata ──────────────┐
342
+ │
343
+ form values ─ wrap params ───┼─> path builder ─┐
344
+ │ │
345
+ filter values ─ compile ─────┼─> $filter ──────┼─> API transport
346
+ │ │
347
+ sort state ──────────────────┴─> $orderby ─────┘
348
+ ```
349
+
350
+ ## Базовые типы
351
+
352
+ ### `BaseMetaType`
353
+
354
+ UI работает с четырьмя укрупнёнными типами:
355
+
356
+ ```ts
357
+ type BaseMetaType = "string" | "number" | "boolean" | "date";
358
+ ```
359
+
360
+ Constants и helpers:
361
+
362
+ ```ts
363
+ import {
364
+ BASE_META_TYPES,
365
+ BASE_META_TYPE_LABELS,
366
+ isBaseMetaType,
367
+ resolveBaseTypeLabel
368
+ } from "@ryuzaki13/react-foundation-lib/odata-service";
369
+
370
+ console.log(BASE_META_TYPES);
371
+ // ["string", "number", "boolean", "date"]
372
+
373
+ console.log(BASE_META_TYPE_LABELS.number); // "число"
374
+ console.log(isBaseMetaType("date")); // true
375
+ console.log(isBaseMetaType("datetime")); // false
376
+ console.log(resolveBaseTypeLabel("boolean")); // "boolean"
377
+ ```
378
+
379
+ `datetime` — OData type, а `date` — укрупнённый UI type. Это разные уровни модели.
380
+
381
+ ### `ODataMetaType`
382
+
383
+ Поддерживаемые нормализованные OData-типы:
384
+
385
+ | Группа | Значения TypeScript |
386
+ | ----------- | --------------------------------------------------------------- |
387
+ | String-like | `"string"`, `"guid"`, `"binary"` |
388
+ | Numeric | `"byte"`, `"int"`, `"long"`, `"float"`, `"decimal"`, `"double"` |
389
+ | Boolean | `"boolean"` |
390
+ | Date/time | `"datetime"`, `"datetimeOffset"`, `"time"` |
391
+
392
+ Связанные типы:
393
+
394
+ ```ts
395
+ type ODataBooleanType = "boolean";
396
+ type ODataNumericType = "byte" | "int" | "long" | "float" | "decimal" | "double";
397
+ type ODataIntegerType = "byte" | "int" | "long";
398
+ type ODataDateType = "datetime" | "datetimeOffset" | "time";
399
+ type ODataStringType = "string" | "guid" | "binary";
400
+ ```
401
+
402
+ Type guards не проверяют значение поля. Они проверяют только строку типа:
403
+
404
+ ```ts
405
+ isODataBooleanType("boolean"); // true
406
+ isODataNumericType("decimal"); // true
407
+ isODataIntegerType("float"); // false
408
+ isODataDateType("datetime"); // true
409
+ ```
410
+
411
+ ### `getBaseTypeFromODataType`
412
+
413
+ Преобразует подробный OData type в укрупнённый UI type:
414
+
415
+ ```ts
416
+ getBaseTypeFromODataType("guid"); // "string"
417
+ getBaseTypeFromODataType("decimal"); // "number"
418
+ getBaseTypeFromODataType("boolean"); // "boolean"
419
+ getBaseTypeFromODataType("datetimeOffset"); // "date"
420
+ ```
421
+
422
+ ### HTTP и operation types
423
+
424
+ ```ts
425
+ type BaseMethod = "GET" | "POST" | "PUT" | "DELETE";
426
+
427
+ type ODataOperationMethod = "create" | "update" | "delete" | "read" | "query" | "fi";
428
+ ```
429
+
430
+ `ODataOperationMethod` описывает намерение consumer-кода. Это не готовый HTTP method. Например, Function Import отдельно хранит `httpMethod` из metadata.
431
+
432
+ ## Чтение metadata
433
+
434
+ ### `parseServiceMetadata`
435
+
436
+ ```ts
437
+ function parseServiceMetadata(xmlText: string): ServiceMetadata;
438
+ ```
439
+
440
+ Функция принимает полный EDMX/XML текст и возвращает:
441
+
442
+ ```ts
443
+ type ServiceMetadata = {
444
+ entities: Record<string, EntityMetadata>;
445
+ functionImports: Record<string, FunctionImportMetadata>;
446
+ };
447
+ ```
448
+
449
+ Пример:
450
+
451
+ ```ts
452
+ const metadata = parseServiceMetadata(xmlText);
453
+
454
+ const entity = metadata.entities.Sales;
455
+ const action = metadata.functionImports.Recalculate;
456
+ ```
457
+
458
+ Если имя неизвестно, результат обращения по ключу будет `undefined`. Проверяйте это явно до использования:
459
+
460
+ ```ts
461
+ const entity = metadata.entities[target];
462
+
463
+ if (!entity) {
464
+ throw new Error(`EntitySet ${target} отсутствует в metadata`);
465
+ }
466
+ ```
467
+
468
+ ### Какие XML-типы поддерживаются
469
+
470
+ Parser нормализует следующие EDM-типы:
471
+
472
+ | EDMX | `ODataMetaType` |
473
+ | ------------------------ | ---------------- |
474
+ | `Edm.String` | `string` |
475
+ | `Edm.Guid` | `guid` |
476
+ | `Edm.Boolean` | `boolean` |
477
+ | `Edm.Byte` | `byte` |
478
+ | `Edm.Int16`, `Edm.Int32` | `int` |
479
+ | `Edm.Int64` | `long` |
480
+ | `Edm.Single` | `float` |
481
+ | `Edm.Decimal` | `decimal` |
482
+ | `Edm.Double` | `double` |
483
+ | `Edm.DateTime` | `datetime` |
484
+ | `Edm.DateTimeOffset` | `datetimeOffset` |
485
+ | `Edm.Time` | `time` |
486
+ | `Edm.Binary` | `binary` |
487
+
488
+ Неподдерживаемый scalar EDM type приводит к ошибке. Это намеренное поведение: неизвестный тип нельзя безопасно сериализовать как строку по догадке.
489
+
490
+ ### Nullable, mandatory и параметры
491
+
492
+ Parser учитывает несколько источников обязательности:
493
+
494
+ - `Nullable="false"`;
495
+ - SAP attribute `sap:parameter="mandatory"`;
496
+ - key-поля параметризованной сущности;
497
+ - action-for key для Function Import.
498
+
499
+ Для Function Import поддерживаются входные scalar параметры. Параметры с неподдерживаемым mode или не-scalar типом отклоняются.
500
+
501
+ ### ABAP boolean-like string
502
+
503
+ Строковое поле `Edm.String` с `MaxLength="1"` помечается:
504
+
505
+ ```ts
506
+ abapBooleanLike: true;
507
+ ```
508
+
509
+ Это не превращает metadata type в `boolean`. Маркер нужен `odataParseValueByMetadata`, чтобы значения `"X"`, `" "` и `""` читать как boolean.
510
+
511
+ ### Колонки, роли и code/text
512
+
513
+ Parser определяет:
514
+
515
+ - `label` из SAP metadata с fallback на id;
516
+ - `sortable` и `filterable`;
517
+ - аналитическую роль `measure` или `dimension`;
518
+ - семантику `none`, `code` или `text`;
519
+ - `linkedColumnId` для пары code/text.
520
+
521
+ Связь code/text ищется по SAP attribute и распространённым suffixes `_txt`, `_text`, `_t`. Дополнительно существуют проектные forced ids:
522
+
523
+ ```ts
524
+ FORCED_CODE_TEXT_IDS; // Set с "ZDIV" и "ZCFO1"
525
+ ```
526
+
527
+ Helpers:
528
+
529
+ ```ts
530
+ resolveMetadataColumnLabel({ id: "BUKRS", label: "" }); // "BUKRS"
531
+ isForcedCodeTextId("ZDIV"); // true
532
+ isForcedCodeTextFamilyId("zdiv_txt"); // true
533
+ ```
534
+
535
+ Сравнение forced family выполняется без учёта регистра.
536
+
537
+ ### Filterable columns
538
+
539
+ ```ts
540
+ const columns = collectFilterableColumns(entity.columns);
541
+ const ids = collectFilterableColumnsIds(entity.columns);
542
+
543
+ console.log(ids.has("COMPANY"));
544
+ ```
545
+
546
+ `collectFilterableColumns` возвращает новый массив только с `filterable: true`.
547
+
548
+ `collectFilterableColumnsIds` возвращает readonly `Set<string>`, удобный для быстрых проверок.
549
+
550
+ ### Важные ограничения parser
551
+
552
+ - Нужен глобальный `DOMParser`.
553
+ - Функция не загружает metadata по URL.
554
+ - Функция не кеширует результат.
555
+ - Функция не проверяет server data rows по metadata.
556
+ - Complex types не превращаются в обычные filterable scalar columns.
557
+ - Служебные result-wrapper сущности могут быть скрыты из итоговой карты, а их columns перенесены к публичной parameterized entity.
558
+ - Entity type с именем, заканчивающимся на `Type`, без result navigation не считается автоматически параметризованным.
559
+
560
+ ## Типы результата metadata
561
+
562
+ ### `EntityParameterProperty`
563
+
564
+ ```ts
565
+ type EntityParameterProperty = {
566
+ id: string;
567
+ label: string;
568
+ type: ODataMetaType;
569
+ originalType: string;
570
+ abapBooleanLike?: true;
571
+ nullable?: true;
572
+ mandatory?: true;
573
+ maxLength?: number;
574
+ precision?: number;
575
+ scale?: number;
576
+ parameter?: string;
577
+ };
578
+ ```
579
+
580
+ Обратите внимание на optional flags: отсутствие `mandatory` означает «не отмечен обязательным», а не обязательно явное `false`.
581
+
582
+ ### `EntityColumnProperty`
583
+
584
+ Колонка содержит основную информацию параметра и UI/runtime metadata:
585
+
586
+ ```ts
587
+ type EntityColumnProperty = {
588
+ id: string;
589
+ label: string;
590
+ type: ODataMetaType;
591
+ originalType: string;
592
+ abapBooleanLike?: true;
593
+ maxLength?: number;
594
+ precision?: number;
595
+ scale?: number;
596
+ parameter?: string;
597
+ linkedColumnId?: string;
598
+ semanticType: "none" | "code" | "text";
599
+ sortable: boolean;
600
+ filterable: boolean;
601
+ role: "measure" | "dimension";
602
+ };
603
+ ```
604
+
605
+ ### `EntityMetadata`
606
+
607
+ ```ts
608
+ type EntityMetadata = {
609
+ title: string;
610
+ columns: EntityColumnProperty[];
611
+ parameters?: EntityParameterProperty[];
612
+ result?: "Set" | "Results";
613
+ };
614
+ ```
615
+
616
+ `result` задаёт navigation segment для параметризованной сущности.
617
+
618
+ ### `FunctionImportMetadata`
619
+
620
+ ```ts
621
+ type FunctionImportMetadata = {
622
+ name: string;
623
+ title: string;
624
+ httpMethod?: "GET" | "POST" | "PUT" | "DELETE";
625
+ parameters?: EntityParameterProperty[];
626
+ returnType: string;
627
+ entitySet?: string;
628
+ actionFor?: string;
629
+ resultEntity?: string;
630
+ };
631
+ ```
632
+
633
+ ### Остальные metadata-типы
634
+
635
+ ```ts
636
+ type ServiceEntities = Record<string, EntityMetadata>;
637
+ type ServiceFunctionImports = Record<string, FunctionImportMetadata>;
638
+ type ODataTargetMetadata = EntityMetadata | FunctionImportMetadata;
639
+
640
+ type EntityPropertyBase = {
641
+ id: string;
642
+ label: string;
643
+ };
644
+
645
+ type EntityPropertyBaseWithType = EntityPropertyBase & {
646
+ type: ODataMetaType;
647
+ };
648
+ ```
649
+
650
+ `ODataTargetMetadata` удобен для generic UI, который умеет показывать как entity, так и Function Import.
651
+
652
+ ## Форматирование OData-значений
653
+
654
+ ### `odataFormatValue`
655
+
656
+ ```ts
657
+ function odataFormatValue(type: ODataMetaType, value: unknown): string;
658
+ ```
659
+
660
+ Функция сначала проверяет значение через Zod schema соответствующего типа, затем создаёт OData literal.
661
+
662
+ ```ts
663
+ odataFormatValue("string", "O'Reilly");
664
+ // 'O''Reilly'
665
+
666
+ odataFormatValue("boolean", true);
667
+ // 'X'
668
+
669
+ odataFormatValue("decimal", 12.5);
670
+ // 12.5M
671
+ ```
672
+
673
+ ### Таблица сериализации
674
+
675
+ | Тип | Допустимое JavaScript-значение | Пример результата |
676
+ | ---------------- | ------------------------------------- | -------------------------------------------- |
677
+ | `string` | `string` | `'Текст'` |
678
+ | `guid` | UUID или строка из hex-символов и `-` | `guid'550e8400-e29b-41d4-a716-446655440000'` |
679
+ | `boolean` | `boolean` | `true` → `'X'`, `false` → `' '` |
680
+ | `byte` | integer `0..255` | `255` |
681
+ | `int` | integer | `42` |
682
+ | `long` | integer | `42L` |
683
+ | `float` | `number` | `12.5F` |
684
+ | `decimal` | `number` | `12.5M` |
685
+ | `double` | `number` | `12.5D` |
686
+ | `datetime` | valid `Date` | `datetime'2026-08-18T10:30:00'` |
687
+ | `datetimeOffset` | valid `Date` | `datetimeoffset'2026-08-18T05:30:00.000Z'` |
688
+ | `time` | valid `Date` | `time'PT10H30M00S'` |
689
+ | `binary` | `string` | `binary'BASE64...'` |
690
+
691
+ ### Строки и одинарные кавычки
692
+
693
+ Одинарная кавычка внутри OData string literal удваивается:
694
+
695
+ ```ts
696
+ odataFormatValue("string", "Иванов 'Тест'");
697
+ // 'Иванов ''Тест'''
698
+ ```
699
+
700
+ Не экранируйте строку вручную перед вызовом. Иначе кавычки будут удвоены повторно.
701
+
702
+ ### Boolean в SAP/ABAP форме
703
+
704
+ `boolean` сериализуется не как `true`/`false`, а как ABAP-like char:
705
+
706
+ ```ts
707
+ odataFormatValue("boolean", true); // "'X'"
708
+ odataFormatValue("boolean", false); // "' '"
709
+ ```
710
+
711
+ Если конкретный endpoint ждёт `0`/`1`, выберите custom formatter из registry или передайте собственный formatter в wrapped parameter.
712
+
713
+ ### Даты и timezone
714
+
715
+ `ODataDateFormat` предоставляет три formatter-а:
716
+
717
+ ```ts
718
+ ODataDateFormat.datetime(date);
719
+ ODataDateFormat.datetimeOffset(date);
720
+ ODataDateFormat.time(date);
721
+ ```
722
+
723
+ Их семантика различается:
724
+
725
+ - `datetime` использует видимые локальные календарные компоненты `Date` без suffix timezone;
726
+ - `datetimeOffset` вызывает `date.toISOString()` и поэтому сериализует UTC-время с `Z`;
727
+ - `time` использует локальные компоненты времени как ISO duration `PT...`.
728
+
729
+ Не заменяйте один тип другим только потому, что оба принимают `Date`. Выбирайте тип из metadata endpoint.
730
+
731
+ ### Runtime validation
732
+
733
+ Некорректное значение приводит к `Error`:
734
+
735
+ ```ts
736
+ odataFormatValue("int", 1.5);
737
+ // Error: значение не является integer
738
+
739
+ odataFormatValue("byte", 300);
740
+ // Error: значение больше 255
741
+
742
+ odataFormatValue("datetime", "2026-08-18");
743
+ // Error: ожидался Date, а не string
744
+ ```
745
+
746
+ Основные schemas доступны напрямую:
747
+
748
+ ```ts
749
+ const result = odataTypeSchemas.decimal.safeParse(12.5);
750
+
751
+ if (!result.success) {
752
+ console.error(result.error);
753
+ }
754
+ ```
755
+
756
+ `guid` принимает canonical UUID и более мягкую строку, содержащую только hex-символы и дефисы. Не считайте этот schema полной бизнес-проверкой UUID, если backend требует конкретную версию/форму.
757
+
758
+ ### Primitive guards
759
+
760
+ ```ts
761
+ isStringSafe("A"); // true
762
+ isNumberSafe(10); // true
763
+ isNumberSafe("10"); // false
764
+ isBooleanSafe(false); // true
765
+ isDateSafe(new Date()); // true для valid Date
766
+ ```
767
+
768
+ Guards ничего не преобразуют. Например, `isNumberSafe("10")` не парсит строку.
769
+
770
+ ## Парсинг значений
771
+
772
+ ### `odataParseValue`
773
+
774
+ Преобразует raw server/control value в JavaScript-представление по нормализованному OData type.
775
+
776
+ ```ts
777
+ odataParseValue("boolean", "X"); // true
778
+ odataParseValue("boolean", " "); // false
779
+ odataParseValue("int", "1 234"); // 1234
780
+ odataParseValue("datetime", "2026-08-18"); // Date или null по правилам date parser
781
+ odataParseValue("string", 123); // "123"
782
+ ```
783
+
784
+ ### Boolean parsing
785
+
786
+ Распознаются:
787
+
788
+ | Input | Result |
789
+ | --------------------- | ------------- |
790
+ | `true`, `false` | то же boolean |
791
+ | `1`, `"1"`, `"true"` | `true` |
792
+ | `0`, `"0"`, `"false"` | `false` |
793
+ | `"X"` | `true` |
794
+ | `" "`, `""` | `false` |
795
+
796
+ Регистр учитывается только для ABAP `"X"`: именно uppercase `X` распознаётся напрямую. Обычные `true`/`false` приводятся к lower case после `trim`.
797
+
798
+ Если значение не распознано, функция возвращает исходное значение:
799
+
800
+ ```ts
801
+ odataParseValue("boolean", "maybe"); // "maybe"
802
+ odataParseValue("boolean", 2); // 2
803
+ ```
804
+
805
+ Поэтому результат нельзя без проверки безусловно присвоить переменной `boolean`.
806
+
807
+ ### Numbers и dates
808
+
809
+ Numeric types делегируют parsing общему `parseNumber`, а date/time types — общему `parseDate` из `formatters`.
810
+
811
+ Это означает:
812
+
813
+ - строковые числа могут быть преобразованы;
814
+ - невалидное число не обязано приводить к exception;
815
+ - результат даты может быть `Date`, `null` или исходным/промежуточным значением согласно date parser contract.
816
+
817
+ Для форматирования данных в UI используйте `@ryuzaki13/react-foundation-lib/formatters`, а не `odataParseValue` как display formatter.
818
+
819
+ ### String-like types
820
+
821
+ `string`, `guid` и `binary` используют `String(value)`:
822
+
823
+ ```ts
824
+ odataParseValue("string", null); // "null"
825
+ odataParseValue("string", undefined); // "undefined"
826
+ ```
827
+
828
+ Если `null` должен остаться `null`, отфильтруйте его до вызова.
829
+
830
+ ### `odataParseValueByMetadata`
831
+
832
+ ```ts
833
+ odataParseValueByMetadata({ type: "string", abapBooleanLike: true }, "X"); // true
834
+ ```
835
+
836
+ Дополнительные правила для `abapBooleanLike`:
837
+
838
+ - `null` и `undefined` → `false`;
839
+ - `"X"` → `true`;
840
+ - `" "` и `""` → `false`;
841
+ - другое строковое значение проходит обычный parsing типа;
842
+ - non-string значение не считается ABAP char и тоже проходит обычный parsing.
843
+
844
+ Например, boolean `true` при descriptor type `string` в итоге станет строкой `"true"`.
845
+
846
+ ## Значения по умолчанию и formatter registry
847
+
848
+ ### `defaultODataTypeValue`
849
+
850
+ ```ts
851
+ defaultODataTypeValue("boolean"); // false
852
+ defaultODataTypeValue("decimal"); // 0
853
+ defaultODataTypeValue("datetime"); // новый Date
854
+ defaultODataTypeValue("string"); // ""
855
+ ```
856
+
857
+ Для каждого вызова date/time создаётся новый `Date` с текущим моментом. Не используйте функцию как стабильную constant.
858
+
859
+ ### `defaultControlTypeValue`
860
+
861
+ То же правило на уровне `BaseMetaType`:
862
+
863
+ ```ts
864
+ defaultControlTypeValue("boolean"); // false
865
+ defaultControlTypeValue("number"); // 0
866
+ defaultControlTypeValue("date"); // новый Date
867
+ defaultControlTypeValue("string"); // ""
868
+ ```
869
+
870
+ Это технический default, а не утверждение, что пустое поле пользователя должно иметь `0`, `false` или текущую дату. Решение о null/empty принадлежит форме и бизнес-сценарию.
871
+
872
+ ### Formatter description
873
+
874
+ ```ts
875
+ type ODataFormatterDescription = {
876
+ id: string;
877
+ label: string;
878
+ description: string;
879
+ fn: (...args: unknown[]) => string;
880
+ };
881
+ ```
882
+
883
+ Registry нужен UI-редактору mapping, где пользователь выбирает способ сериализации.
884
+
885
+ ```ts
886
+ const items = getFormattersFor("datetime");
887
+
888
+ for (const item of items) {
889
+ console.log(item.id, item.label);
890
+ }
891
+ ```
892
+
893
+ Доступные ids:
894
+
895
+ | Base type | Id | Назначение |
896
+ | --------- | ---------------- | ------------------------------------ |
897
+ | `date` | `base` | OData `datetime` |
898
+ | `date` | `datetimeOffset` | OData `datetimeoffset` |
899
+ | `date` | `time` | OData `time` |
900
+ | `date` | `stringAbapDate` | Строка ABAP `YYYYMMDD` |
901
+ | `boolean` | `base` | ABAP boolean `'X'`/`' '` |
902
+ | `boolean` | `asChar` | Строка `'0'`/`'1'` |
903
+ | `boolean` | `asByte` | Число `0`/`1` |
904
+ | `string` | `base` | Экранированная OData string |
905
+ | `number` | — | Встроенных registry formatter-ов нет |
906
+
907
+ API поиска:
908
+
909
+ ```ts
910
+ getFormattersFor("datetime");
911
+ getFormattersForBaseType("date");
912
+ getFormatter("boolean", "asByte");
913
+ getFormatterForBaseType("boolean", "asChar");
914
+ ```
915
+
916
+ `getFormattersFor*` возвращает массив с `id`.
917
+
918
+ `getFormatter*` возвращает внутреннее описание конкретного элемента без добавленного поля `id` либо `undefined`, если id отсутствует.
919
+
920
+ ### Formatter factories
921
+
922
+ ```ts
923
+ const joinStrings = createStringFormatter((...values) => {
924
+ return `'${values.join("-")}'`;
925
+ });
926
+
927
+ joinStrings("A", "B"); // 'A-B'
928
+ joinStrings("A", 10); // Error validation
929
+ ```
930
+
931
+ Фабрики:
932
+
933
+ - `createFormatter(schema, fn)` — общий Zod-validated wrapper;
934
+ - `createBooleanFormatter(fn)` — все аргументы обязаны быть boolean;
935
+ - `createStringFormatter(fn)` — все аргументы обязаны быть string;
936
+ - `createNumberFormatter(fn)` — все аргументы обязаны быть number;
937
+ - `createDateFormatter(fn)` — все аргументы обязаны быть valid `Date`.
938
+
939
+ Фабрика проверяет весь массив аргументов при каждом вызове. Она не проверяет, что возвращённая строка действительно является корректным OData literal; это ответственность функции `fn`.
940
+
941
+ ## Wrapped и unwrapped параметры
942
+
943
+ ### Зачем нужна обёртка `{ value }`
944
+
945
+ Плоские параметры удобны для формы:
946
+
947
+ ```ts
948
+ const flat = {
949
+ p_company: "1000",
950
+ p_year: 2026
951
+ };
952
+ ```
953
+
954
+ Builder принимает wrapped форму, потому что каждому параметру можно назначить formatter:
955
+
956
+ ```ts
957
+ const wrapped = {
958
+ p_company: { value: "1000" },
959
+ p_year: { value: 2026 },
960
+ p_date: {
961
+ value: selectedDate,
962
+ formatter: ODataDateFormat.datetime
963
+ }
964
+ };
965
+ ```
966
+
967
+ Основные типы:
968
+
969
+ ```ts
970
+ type ODataParameterValue = BaseType | BaseType[];
971
+
972
+ type ODataValue<T extends ODataParameterValue = ODataParameterValue> = {
973
+ value: T;
974
+ formatter?: (...args: unknown[]) => string;
975
+ };
976
+
977
+ type UnwrappedODataParameters = Record<string, ODataParameterValue>;
978
+ type WrappedODataParameters = Record<string, ODataValue>;
979
+ ```
980
+
981
+ `InputValue`, `UnwrappedInputParameters` и `WrappedInputParameters` — аналогичные generic UI-контракты поверх `InputType`.
982
+
983
+ ### `wrapODataParams`
984
+
985
+ ```ts
986
+ const wrapped = wrapODataParams({
987
+ p_zero: 0,
988
+ p_enabled: false,
989
+ p_empty: ""
990
+ });
991
+ ```
992
+
993
+ Результат:
994
+
995
+ ```ts
996
+ {
997
+ p_zero: { value: 0 },
998
+ p_enabled: { value: false },
999
+ p_empty: { value: "" }
1000
+ }
1001
+ ```
1002
+
1003
+ Функция сохраняет `0`, `false`, пустую строку и `null` буквально. Она не форматирует и не нормализует значения.
1004
+
1005
+ Для `null` или `undefined` вместо всего объекта возвращается пустой объект.
1006
+
1007
+ ### `unwrapODataParams`
1008
+
1009
+ ```ts
1010
+ unwrapODataParams({
1011
+ z: { value: false },
1012
+ a: { value: undefined }
1013
+ });
1014
+ ```
1015
+
1016
+ Результат:
1017
+
1018
+ ```ts
1019
+ {
1020
+ a: null,
1021
+ z: false
1022
+ }
1023
+ ```
1024
+
1025
+ Правила:
1026
+
1027
+ - ключи сортируются по имени;
1028
+ - `undefined` внутри параметра превращается в `null`;
1029
+ - `formatter` отбрасывается;
1030
+ - отсутствующий аргумент даёт `undefined`;
1031
+ - преобразование shallow: вложенные значения не клонируются.
1032
+
1033
+ Generic types `WrapODataParameters<T>` и `UnwrapODataParameters<T>` помогают сохранить имена и типы keys на этапе TypeScript.
1034
+
1035
+ ## Построение параметров и path
1036
+
1037
+ ### `buildParameterEntries`
1038
+
1039
+ ```ts
1040
+ function buildParameterEntries(
1041
+ metadata: { parameters?: EntityParameterProperty[] },
1042
+ params: WrappedODataParameters
1043
+ ): Array<[string, string]>;
1044
+ ```
1045
+
1046
+ Функция идёт по `metadata.parameters`, а не по keys объекта `params`. Поэтому:
1047
+
1048
+ - порядок результата совпадает с metadata;
1049
+ - лишние keys в `params` игнорируются;
1050
+ - отсутствующий optional параметр пропускается;
1051
+ - отсутствующий mandatory параметр приводит к `Error`.
1052
+
1053
+ ```ts
1054
+ const entries = buildParameterEntries(
1055
+ {
1056
+ parameters: [
1057
+ { id: "p_company", label: "Компания", type: "string", originalType: "Edm.String", mandatory: true },
1058
+ { id: "p_year", label: "Год", type: "int", originalType: "Edm.Int32" }
1059
+ ]
1060
+ },
1061
+ {
1062
+ p_company: { value: "1000" },
1063
+ p_year: { value: 2026 }
1064
+ }
1065
+ );
1066
+
1067
+ // [["p_company", "'1000'"], ["p_year", "2026"]]
1068
+ ```
1069
+
1070
+ ### Mandatory и optional
1071
+
1072
+ Только `null` и `undefined` считаются отсутствием:
1073
+
1074
+ ```ts
1075
+ {
1076
+ value: 0;
1077
+ } // присутствует
1078
+ {
1079
+ value: false;
1080
+ } // присутствует
1081
+ {
1082
+ value: "";
1083
+ } // присутствует и будет отформатировано как ''
1084
+ ```
1085
+
1086
+ ### Массивы
1087
+
1088
+ Обычный OData parameter является scalar. Поэтому массив разрешён только вместе с custom formatter:
1089
+
1090
+ ```ts
1091
+ const params = {
1092
+ p_ids: {
1093
+ value: ["A", "B"],
1094
+ formatter: (value: unknown) => {
1095
+ const values = value as string[];
1096
+ return `'${values.join(",")}'`;
1097
+ }
1098
+ }
1099
+ };
1100
+ ```
1101
+
1102
+ Без formatter builder выбросит ошибку «Массив значений OData-параметра требует custom formatter».
1103
+
1104
+ ### `maxLength`
1105
+
1106
+ Если parameter metadata содержит `maxLength`, builder ожидает literal в одинарных кавычках и проверяет число символов внутри них.
1107
+
1108
+ Это важно:
1109
+
1110
+ - string literal проверяется;
1111
+ - custom formatter тоже обязан вернуть `'...'`;
1112
+ - numeric literal при наличии `maxLength` не пройдёт проверку, потому что не заключён в кавычки.
1113
+
1114
+ Сообщение любой formatting-ошибки дополняется id параметра.
1115
+
1116
+ ### `buildEntityParameters`
1117
+
1118
+ ```ts
1119
+ const value = buildEntityParameters(entityMetadata, params);
1120
+ // (p_company='1000',p_year=2026)
1121
+ ```
1122
+
1123
+ Каждый уже отформатированный literal проходит через `encodeURIComponent`. Не применяйте `encodeURIComponent` к value заранее.
1124
+
1125
+ Если параметров нет, функция возвращает пустую строку.
1126
+
1127
+ ### `buildFunctionImportParameters`
1128
+
1129
+ Возвращает стандартный `URLSearchParams`:
1130
+
1131
+ ```ts
1132
+ const searchParams = buildFunctionImportParameters(functionMetadata, params);
1133
+ console.log(searchParams.toString());
1134
+ ```
1135
+
1136
+ Values сначала форматируются как OData literals, затем передаются в `URLSearchParams`. Не сравнивайте вручную raw query string, если порядок/encoding можно проверить через `searchParams.get`.
1137
+
1138
+ ### `ODataServiceConfig`
1139
+
1140
+ ```ts
1141
+ type ODataServiceConfig = {
1142
+ service: string;
1143
+ target: string;
1144
+ };
1145
+ ```
1146
+
1147
+ Функции не очищают `/` и не валидируют имена. Передавайте именно ожидаемые segments без query string.
1148
+
1149
+ ### `buildEntityPath`
1150
+
1151
+ ```ts
1152
+ buildEntityPath(metadata, config, params);
1153
+ ```
1154
+
1155
+ Формат:
1156
+
1157
+ ```text
1158
+ /{service}/{target}{parameters}/{result}
1159
+ ```
1160
+
1161
+ `/{result}` добавляется только если metadata содержит `result: "Set" | "Results"`.
1162
+
1163
+ ### `buildEntityOperationPath`
1164
+
1165
+ ```ts
1166
+ buildEntityOperationPath(metadata, config, params, method);
1167
+ ```
1168
+
1169
+ | Method | Поведение |
1170
+ | -------------------------------- | ------------------------------------------------------------- |
1171
+ | `create` | Возвращает только `/{service}/{target}`; params игнорируются. |
1172
+ | `query` для обычной entity | Возвращает base path; params игнорируются. |
1173
+ | `query` для parameterized entity | Добавляет параметры и `/Set` либо `/Results`. |
1174
+ | `read`, `update`, `delete` | Добавляет entity parameters, но не result navigation. |
1175
+
1176
+ В development-сборке `query` с params для обычной entity пишет `console.warn`, потому что target не поддерживает parameterized query.
1177
+
1178
+ ### `buildFunctionImportPath`
1179
+
1180
+ ```ts
1181
+ const path = buildFunctionImportPath(functionMetadata, config, params);
1182
+ // /REPORT_SRV/Recalculate?p_year=2026
1183
+ ```
1184
+
1185
+ Если serializable parameters нет, `?` не добавляется.
1186
+
1187
+ ### Path не является полным URL
1188
+
1189
+ Результат начинается с `/`, но не содержит:
1190
+
1191
+ - origin;
1192
+ - `/sap/opu/odata/sap`;
1193
+ - `$filter`, `$orderby`, `$select`, `$expand`;
1194
+ - HTTP method и body;
1195
+ - authentication headers.
1196
+
1197
+ Соединять path с transport base URL должен API-слой.
1198
+
1199
+ ## Простые OData-фильтры
1200
+
1201
+ ### Два этапа
1202
+
1203
+ Фильтр строится в два этапа:
1204
+
1205
+ 1. Создать типизированное дерево `FilterExpression`.
1206
+ 2. Сериализовать дерево через `buildODataFilter`.
1207
+
1208
+ ```ts
1209
+ type Row = {
1210
+ STATUS: string;
1211
+ AMOUNT: number;
1212
+ };
1213
+
1214
+ const expression = createFilterEqual<Row, string>("STATUS", "A");
1215
+ const filter = buildODataFilter(expression);
1216
+
1217
+ console.log(filter); // STATUS eq 'A'
1218
+ ```
1219
+
1220
+ Не собирайте `$filter` конкатенацией пользовательских строк. Formatter должен экранировать values.
1221
+
1222
+ ### Операции
1223
+
1224
+ ```ts
1225
+ FilterOperation.eq; // "eq"
1226
+ FilterOperation.ne; // "ne"
1227
+ FilterOperation.gt; // "gt"
1228
+ FilterOperation.ge; // "ge"
1229
+ FilterOperation.lt; // "lt"
1230
+ FilterOperation.le; // "le"
1231
+ FilterOperation.startswith; // "startswith"
1232
+ FilterOperation.endswith; // "endswith"
1233
+ FilterOperation.contains; // "contains"
1234
+ ```
1235
+
1236
+ `FILTER_OPERATIONS` — readonly массив всех значений.
1237
+
1238
+ ```ts
1239
+ isFilterOperation("contains"); // true
1240
+ isFilterOperation("in"); // false
1241
+
1242
+ resolveOperationLabel("ge"); // "больше или равно"
1243
+ ```
1244
+
1245
+ ### Структура выражения
1246
+
1247
+ Leaf содержит conditions:
1248
+
1249
+ ```ts
1250
+ type FilterCondition<T> = {
1251
+ key: keyof T;
1252
+ value: BaseType;
1253
+ operation?: FilterOperation;
1254
+ type?: ODataMetaType;
1255
+ };
1256
+
1257
+ type FilterConditionExpression<T> = {
1258
+ and?: boolean;
1259
+ conditions: FilterCondition<T>[];
1260
+ };
1261
+ ```
1262
+
1263
+ Nested node содержит другие filters:
1264
+
1265
+ ```ts
1266
+ type FilterNestedExpression<T> = {
1267
+ and?: boolean;
1268
+ filters: FilterExpression<T>[];
1269
+ };
1270
+ ```
1271
+
1272
+ Один node не может одновременно содержать `conditions` и `filters`.
1273
+
1274
+ ### Значение `and`
1275
+
1276
+ По умолчанию `and` отсутствует или равно `false`. Это означает OR:
1277
+
1278
+ ```ts
1279
+ {
1280
+ conditions: [
1281
+ { key: "STATUS", value: "A" },
1282
+ { key: "STATUS", value: "B" }
1283
+ ];
1284
+ }
1285
+ // (STATUS eq 'A' or STATUS eq 'B')
1286
+ ```
1287
+
1288
+ Для AND укажите `and: true`:
1289
+
1290
+ ```ts
1291
+ {
1292
+ and: true,
1293
+ conditions: [
1294
+ { key: "AMOUNT", operation: "ge", value: 100, type: "decimal" },
1295
+ { key: "AMOUNT", operation: "le", value: 500, type: "decimal" }
1296
+ ]
1297
+ }
1298
+ // (AMOUNT ge 100M and AMOUNT le 500M)
1299
+ ```
1300
+
1301
+ ### Вывод OData type из value
1302
+
1303
+ Если `condition.type` отсутствует, используется простое правило:
1304
+
1305
+ | JavaScript value | Выведенный OData type |
1306
+ | --------------------- | --------------------- |
1307
+ | `number` или `bigint` | `int` |
1308
+ | `boolean` | `boolean` |
1309
+ | `Date` | `datetime` |
1310
+ | остальное | `string` |
1311
+
1312
+ Для decimal, long, datetimeOffset и других точных типов всегда задавайте `type` явно:
1313
+
1314
+ ```ts
1315
+ {
1316
+ key: "AMOUNT",
1317
+ value: 12.5,
1318
+ operation: "ge",
1319
+ type: "decimal"
1320
+ }
1321
+ ```
1322
+
1323
+ Иначе `12.5` будет проверено как `int` и formatting завершится ошибкой.
1324
+
1325
+ ### Строковые операции
1326
+
1327
+ Результат соответствует OData V2 syntax:
1328
+
1329
+ ```ts
1330
+ buildODataFilter(createFilterContains<Row, string>("STATUS", "A"));
1331
+ // substringof('A',STATUS)
1332
+ ```
1333
+
1334
+ Для starts/ends:
1335
+
1336
+ ```text
1337
+ startswith(STATUS, 'A')
1338
+ endswith(STATUS, 'A')
1339
+ ```
1340
+
1341
+ ### `createFilter`
1342
+
1343
+ Общая фабрика:
1344
+
1345
+ ```ts
1346
+ createFilter<Row, number>("AMOUNT", 100, "gt");
1347
+ ```
1348
+
1349
+ `null` и `undefined` создают пустой leaf `{ conditions: [] }`.
1350
+
1351
+ Массив создаёт несколько conditions с OR по умолчанию:
1352
+
1353
+ ```ts
1354
+ createFilter<Row, string>("STATUS", ["A", "B"], "eq");
1355
+ // после serialization: (STATUS eq 'A' or STATUS eq 'B')
1356
+ ```
1357
+
1358
+ `null`/`undefined` элементы массива удаляются. Остальные falsy values сохраняются.
1359
+
1360
+ ### Фабрики
1361
+
1362
+ ```ts
1363
+ createFilterEqual("STATUS", "A");
1364
+ createFilterContains("NAME", "сталь");
1365
+ createFilterBetween("AMOUNT", [100, 500]);
1366
+ ```
1367
+
1368
+ `createFilterBetween` создаёт `ge` + `le` с AND.
1369
+
1370
+ При одной пустой границе создаётся одностороннее условие. Но текущая реализация проверяет частичную границу по truthiness. Поэтому значения `0`, `false` и `""` как единственная граница будут потеряны:
1371
+
1372
+ ```ts
1373
+ createFilterBetween("AMOUNT", [0, null]);
1374
+ // пустое выражение, а не AMOUNT ge 0
1375
+ ```
1376
+
1377
+ Если нулевая открытая граница важна, создайте condition вручную через `createFilter`.
1378
+
1379
+ ### `createFilterEqualFalsy`
1380
+
1381
+ Эта фабрика намеренно удаляет любые falsy values:
1382
+
1383
+ ```ts
1384
+ createFilterEqualFalsy("AMOUNT", 0); // пусто
1385
+ createFilterEqualFalsy("ACTIVE", false); // пусто
1386
+ createFilterEqualFalsy("TEXT", ""); // пусто
1387
+ ```
1388
+
1389
+ Используйте её только когда `0`, `false` и `""` действительно означают «фильтр не задан». В обычном случае нужен `createFilterEqual`.
1390
+
1391
+ ### `mergeFilterExpressions`
1392
+
1393
+ ```ts
1394
+ const merged = mergeFilterExpressions(left, right);
1395
+ ```
1396
+
1397
+ - если left отсутствует — возвращается right;
1398
+ - если right отсутствует — возвращается left;
1399
+ - если есть оба — создаётся nested AND;
1400
+ - если отсутствуют оба — результат `undefined`.
1401
+
1402
+ ### `buildODataFilter`
1403
+
1404
+ ```ts
1405
+ const filterText = buildODataFilter(expression);
1406
+ ```
1407
+
1408
+ Правила:
1409
+
1410
+ - `undefined` → `""`;
1411
+ - пустой leaf/node → `""`;
1412
+ - один child выводится без лишней групповой обёртки;
1413
+ - несколько children заключаются в скобки;
1414
+ - некорректная runtime-форма expression приводит к `Error`.
1415
+
1416
+ ### `buildODataFilterRecursive`
1417
+
1418
+ Альтернативный serializer использует более агрессивные скобки для вложенных выражений. Логический смысл тот же, но строка может отличаться:
1419
+
1420
+ ```ts
1421
+ const normal = buildODataFilter(expression);
1422
+ const explicit = buildODataFilterRecursive(expression);
1423
+ ```
1424
+
1425
+ Если backend/tests завязаны на точную форму строки, выберите один serializer и используйте его последовательно.
1426
+
1427
+ ### TypeScript не валидирует field id в runtime
1428
+
1429
+ `keyof T` помогает только во время компиляции:
1430
+
1431
+ ```ts
1432
+ createFilterEqual<Row, string>("STATUS", "A");
1433
+ ```
1434
+
1435
+ После `as`, JSON parse или plain JavaScript в key может попасть произвольная строка. OData filter builder не имеет runtime allowlist field ids. Если definition приходит извне, сверяйте field id с metadata columns.
1436
+
1437
+ ## Compiled filter definitions
1438
+
1439
+ Этот слой нужен сложным фильтрам, когда UI-конфигурация и текущее значение хранятся отдельно.
1440
+
1441
+ ```text
1442
+ definitions + values
1443
+ │
1444
+ ├─ sanitize
1445
+ ▼
1446
+ compileFiltersToExpression
1447
+ │
1448
+ ▼
1449
+ FilterExpression
1450
+ │
1451
+ ▼
1452
+ buildODataFilter
1453
+ ```
1454
+
1455
+ Для простого программного фильтра этот слой не обязателен. Используйте `createFilterEqual` напрямую.
1456
+
1457
+ ### Значения
1458
+
1459
+ ```ts
1460
+ type ODataFilterValue = InputType | Record<string, string[]>;
1461
+ type ODataFilterValues = Record<string, ODataFilterValue>;
1462
+ ```
1463
+
1464
+ Объект `Record<string, string[]>` представляет выбранные значения разных уровней tree filter:
1465
+
1466
+ ```ts
1467
+ const values = {
1468
+ productTree: {
1469
+ CATEGORY: ["A"],
1470
+ GROUP: ["A1", "A2"]
1471
+ }
1472
+ };
1473
+ ```
1474
+
1475
+ ### Виды definitions
1476
+
1477
+ ```ts
1478
+ type FilterDefinitionKind = "segment" | "tree" | "advanced" | "local" | "registered" | "column";
1479
+ ```
1480
+
1481
+ | Kind | Смысл |
1482
+ | ------------ | -------------------------------------------------------------------------------- |
1483
+ | `segment` | Select/multi-select, обычно загружаемый OData-справочник. |
1484
+ | `tree` | Значения распределены по нескольким физическим колонкам уровней. |
1485
+ | `local` | Локальный control с декларативным binding. |
1486
+ | `registered` | Control предоставлен внешним runtime registry; owner column может отсутствовать. |
1487
+ | `advanced` | Advanced-search выбирает массив значений owner column. |
1488
+ | `column` | Обычное scalar/multiple значение колонки. |
1489
+
1490
+ Component ids:
1491
+
1492
+ ```ts
1493
+ type FilterSegmentComponentId = "select" | "multi-select";
1494
+ type FilterTreeComponentId = "tree-select" | "tree-multi-select";
1495
+ type FilterLocalComponentId = "select" | "multi-select" | "checkbox" | "text-input" | "number-input";
1496
+ type FilterAdvancedComponentId = "advanced-search-select";
1497
+ type FilterRegisteredValueMode = "single" | "multiple";
1498
+ ```
1499
+
1500
+ `isFilterLocalComponentId(value)` проверяет только local component ids.
1501
+
1502
+ ### Segment
1503
+
1504
+ ```ts
1505
+ const segmentDefinition: ODataCompiledFilterDefinition = {
1506
+ id: "company",
1507
+ ownerColumnId: "BUKRS",
1508
+ columnIds: ["BUKRS"],
1509
+ kind: "segment",
1510
+ componentId: "multi-select",
1511
+ controlType: "string",
1512
+ dictionaryCodeKey: "BUKRS"
1513
+ };
1514
+ ```
1515
+
1516
+ Для `select` ожидается scalar. Для `multi-select` ожидается массив. Массив компилируется в OR из `eq` conditions.
1517
+
1518
+ `dictionaryCodeKey` задаёт key dependency при загрузке связанного справочника. Если он совпадает с `ownerColumnId`, sanitizer удаляет избыточное поле.
1519
+
1520
+ ### Tree
1521
+
1522
+ ```ts
1523
+ const treeDefinition: ODataCompiledFilterDefinition = {
1524
+ id: "productTree",
1525
+ ownerColumnId: "CATEGORY",
1526
+ columnIds: ["CATEGORY", "GROUP"],
1527
+ kind: "tree",
1528
+ componentId: "tree-multi-select",
1529
+ controlType: "string",
1530
+ levelOperator: "and"
1531
+ };
1532
+ ```
1533
+
1534
+ Внутри каждого уровня значения объединяются OR. Между уровнями по умолчанию используется AND. Укажите `levelOperator: "or"`, чтобы уровни объединялись OR.
1535
+
1536
+ Если `ownerColumnId` не входит в нормализованный `columnIds`, sanitizer заменяет owner на первый column id.
1537
+
1538
+ ### Condition binding
1539
+
1540
+ Один local/registered control может создавать conditions по другой физической колонке:
1541
+
1542
+ ```ts
1543
+ type ODataFilterCondition = {
1544
+ columnId?: string;
1545
+ operation: FilterOperation;
1546
+ valueSource: "static" | "input";
1547
+ value?: BaseType;
1548
+ };
1549
+
1550
+ type ODataFilterConditionGroup = {
1551
+ and?: boolean;
1552
+ conditions: ODataFilterCondition[];
1553
+ };
1554
+ ```
1555
+
1556
+ `valueSource: "static"` берёт `condition.value`.
1557
+
1558
+ `valueSource: "input"` берёт текущее значение control.
1559
+
1560
+ Если `columnId` не указан, local definition использует `ownerColumnId` как fallback. У registered definition искусственного owner нет, поэтому condition должна явно назвать column id.
1561
+
1562
+ ### List binding
1563
+
1564
+ ```ts
1565
+ const binding: ODataFilterBinding = {
1566
+ kind: "list",
1567
+ and: false,
1568
+ options: [
1569
+ {
1570
+ key: "open",
1571
+ label: "Открытые",
1572
+ filter: {
1573
+ conditions: [
1574
+ {
1575
+ columnId: "STATUS",
1576
+ operation: "eq",
1577
+ valueSource: "static",
1578
+ value: "O"
1579
+ }
1580
+ ]
1581
+ }
1582
+ }
1583
+ ]
1584
+ };
1585
+ ```
1586
+
1587
+ Выбранные options объединяются OR, если `and !== true`.
1588
+
1589
+ Для local list option без explicit filter используется fallback `ownerColumnId eq option.key`.
1590
+
1591
+ Для registered list такого fallback нет: option без filter не создаёт server filter.
1592
+
1593
+ Пустой string `option.key` разрешён и может означать отдельный пользовательский выбор. Дубли option keys sanitizer сейчас не удаляет.
1594
+
1595
+ ### Boolean binding
1596
+
1597
+ ```ts
1598
+ const binding: ODataFilterBinding = {
1599
+ kind: "boolean",
1600
+ trueFilter: {
1601
+ conditions: [{ columnId: "ACTIVE", operation: "eq", valueSource: "static", value: true }]
1602
+ }
1603
+ };
1604
+ ```
1605
+
1606
+ Filter включается только когда control value строго равно `true`.
1607
+
1608
+ Local boolean без `trueFilter` создаёт fallback `ownerColumnId eq true`.
1609
+
1610
+ Registered boolean без explicit `trueFilter` ничего не создаёт.
1611
+
1612
+ ### Value binding
1613
+
1614
+ ```ts
1615
+ const binding: ODataFilterBinding = {
1616
+ kind: "value",
1617
+ valueFilter: {
1618
+ conditions: [{ columnId: "AMOUNT", operation: "ge", valueSource: "input" }]
1619
+ }
1620
+ };
1621
+ ```
1622
+
1623
+ Local value без explicit group создаёт fallback equality по owner column.
1624
+
1625
+ Registered value без explicit group ничего не создаёт.
1626
+
1627
+ ### Registered filter без binding
1628
+
1629
+ Registered definition может быть parameter-only:
1630
+
1631
+ ```ts
1632
+ const definition: ODataCompiledFilterDefinition = {
1633
+ id: "period",
1634
+ kind: "registered",
1635
+ componentId: "period-control",
1636
+ controlType: "date",
1637
+ valueMode: "single",
1638
+ columnIds: []
1639
+ };
1640
+ ```
1641
+
1642
+ Значение такого control может использоваться другим уровнем приложения, но `compileFiltersToExpression` не добавит его в `$filter`.
1643
+
1644
+ ### `sanitizeFilterConditionGroup`
1645
+
1646
+ Нормализует condition group:
1647
+
1648
+ - trim для `columnId`;
1649
+ - невалидное/пустое static value удаляется;
1650
+ - input condition может не иметь сохранённого value;
1651
+ - пустая после очистки группа становится `undefined`;
1652
+ - `and: false` сохраняется как явный OR;
1653
+ - `and: true` сохраняется как AND.
1654
+
1655
+ ### `sanitizeFilterBinding`
1656
+
1657
+ Нормализует binding:
1658
+
1659
+ - trim для option key и label;
1660
+ - пустой key сохраняется;
1661
+ - пустой label заменяется key;
1662
+ - options не дедуплицируются;
1663
+ - list без options становится `undefined`;
1664
+ - вложенные condition groups очищаются.
1665
+
1666
+ ### `sanitizeFilterDefinitions`
1667
+
1668
+ Нормализует массив definitions без изменения входных объектов. Это важно для frozen Zustand/Immer snapshots.
1669
+
1670
+ Основные правила:
1671
+
1672
+ - trim для `id`, `ownerColumnId`, `componentId`, `columnIds`;
1673
+ - definitions без обязательного id удаляются;
1674
+ - non-registered definition без owner удаляется;
1675
+ - tree без физических `columnIds` удаляется;
1676
+ - local без валидного binding удаляется;
1677
+ - column ids дедуплицируются;
1678
+ - `segment.columnIds` пересчитывается как `[ownerColumnId]`;
1679
+ - local/registered `columnIds` выводятся из binding conditions;
1680
+ - legacy kinds `odata-segment` и `odata-tree` переводятся в `segment` и `tree`;
1681
+ - legacy `treeSelect` переводится в `tree-select`; остальное tree legacy значение становится `tree-multi-select`.
1682
+
1683
+ Это migration sanitizer сохранённых конфигураций, а не разрешение продолжать создавать legacy definitions в новом коде.
1684
+
1685
+ ### `sanitizeFilterValue`
1686
+
1687
+ Scalar правила:
1688
+
1689
+ - string обрезается через `trim`, но пустая строка сохраняется;
1690
+ - finite number сохраняется;
1691
+ - `NaN`, `Infinity`, `-Infinity` удаляются;
1692
+ - boolean сохраняется, включая `false`;
1693
+ - valid `Date` сохраняется;
1694
+ - invalid `Date` удаляется.
1695
+
1696
+ Array правила:
1697
+
1698
+ - элементы очищаются как scalar;
1699
+ - дубликаты удаляются с учётом типа;
1700
+ - результат сортируется детерминированно;
1701
+ - пустой массив становится `undefined`.
1702
+
1703
+ Tree object правила:
1704
+
1705
+ - keys колонок проходят trim;
1706
+ - принимаются только string arrays;
1707
+ - string values trim/dedupe;
1708
+ - пустые строки удаляются;
1709
+ - порядок значений tree array сохраняется, сортировка не выполняется;
1710
+ - пустые уровни удаляются.
1711
+
1712
+ ### `sanitizeFilterValues`
1713
+
1714
+ ```ts
1715
+ const normalized = sanitizeFilterValues({
1716
+ " company ": [" 1000 ", "1000", "2000"],
1717
+ broken: Number.NaN
1718
+ });
1719
+
1720
+ // { company: ["1000", "2000"] }
1721
+ ```
1722
+
1723
+ Filter ids проходят trim. Пустые/невалидные значения удаляются.
1724
+
1725
+ ### `mergeFilterValuePatch`
1726
+
1727
+ ```ts
1728
+ const next = mergeFilterValuePatch(values, "company", ["1000"]);
1729
+ ```
1730
+
1731
+ Функция сначала нормализует весь snapshot, создаёт новый object и затем:
1732
+
1733
+ - устанавливает нормализованное value;
1734
+ - либо удаляет key, если новое value стало `undefined`.
1735
+
1736
+ Входной object не мутируется.
1737
+
1738
+ ### Potential и active column ids
1739
+
1740
+ ```ts
1741
+ resolveODataFilterDefinitionColumnIds(definition);
1742
+ resolveODataFilterDefinitionActiveColumnIds(definition, value);
1743
+ ```
1744
+
1745
+ Первая функция возвращает все физические колонки, которые definition потенциально может затронуть.
1746
+
1747
+ Вторая учитывает текущее value. Например, у list binding options могут ссылаться на разные columns; active helper вернёт только columns выбранных options.
1748
+
1749
+ Это полезно при проверке совместимости filter definition с конкретным OData target.
1750
+
1751
+ ### `compileFiltersToExpression`
1752
+
1753
+ ```ts
1754
+ const expression = compileFiltersToExpression(definitions, values);
1755
+ const filter = buildODataFilter(expression);
1756
+ ```
1757
+
1758
+ Компилятор:
1759
+
1760
+ 1. санитизирует definitions;
1761
+ 2. санитизирует values;
1762
+ 3. сопоставляет value по `definition.id`;
1763
+ 4. отбрасывает definitions без активного server expression;
1764
+ 5. объединяет expressions разных definitions через AND.
1765
+
1766
+ Semantics по kind:
1767
+
1768
+ | Kind | Как компилируется |
1769
+ | ---------------------- | -------------------------------------------------------------------- |
1770
+ | `segment/select` | scalar `owner eq value` |
1771
+ | `segment/multi-select` | массив equality через OR |
1772
+ | `tree` | values каждого уровня через OR, уровни через AND или `levelOperator` |
1773
+ | `local/list` | option filters либо fallback owner equality |
1774
+ | `local/boolean` | только `true`; explicit group либо owner `eq true` |
1775
+ | `local/value` | explicit input group либо owner equality |
1776
+ | `registered` | только explicit binding conditions; owner fallback отсутствует |
1777
+ | `column` | scalar/array owner equality |
1778
+ | `advanced` | только массив owner equality |
1779
+
1780
+ Если ничего не скомпилировалось, возвращается `undefined`, а `buildODataFilter(undefined)` даст пустую строку.
1781
+
1782
+ ### Dependencies для справочников
1783
+
1784
+ ```ts
1785
+ const dependencies = flattenFilterValuesToODataDependencies(definitions, values);
1786
+ ```
1787
+
1788
+ В dependencies попадают только `segment` и `tree`:
1789
+
1790
+ - segment записывается по `dictionaryCodeKey` либо `ownerColumnId`;
1791
+ - tree раскладывает выбранные значения по физическим `columnIds`;
1792
+ - local/registered/column/advanced пропускаются.
1793
+
1794
+ Результат:
1795
+
1796
+ ```ts
1797
+ {
1798
+ BUKRS: ["1000"],
1799
+ CATEGORY: ["A"],
1800
+ GROUP: ["A1"]
1801
+ }
1802
+ ```
1803
+
1804
+ ### Цепочки OData-сегментов
1805
+
1806
+ ```ts
1807
+ type ODataChainItem = {
1808
+ codeKey: string;
1809
+ count: number;
1810
+ };
1811
+
1812
+ type ODataChainsMap = Record<string, ODataChainItem[]>;
1813
+ ```
1814
+
1815
+ `collapseChainedODataSegmentFilterValues` оставляет в каждой известной цепочке самый глубокий выбранный segment:
1816
+
1817
+ ```ts
1818
+ const collapsed = collapseChainedODataSegmentFilterValues(
1819
+ {
1820
+ category: ["A"],
1821
+ group: ["A1"],
1822
+ localStatus: "active"
1823
+ },
1824
+ ["category", "group"],
1825
+ {
1826
+ product: [
1827
+ { codeKey: "category", count: 1 },
1828
+ { codeKey: "group", count: 2 }
1829
+ ]
1830
+ }
1831
+ );
1832
+
1833
+ // { group: ["A1"], localStatus: "active" }
1834
+ ```
1835
+
1836
+ Non-segment values и values без известной chain сохраняются. Функция не компилирует filter и не проверяет, действительно ли глубокое значение согласовано с родителем.
1837
+
1838
+ ## Сортировка и `$orderby`
1839
+
1840
+ ### Тип `Sort`
1841
+
1842
+ ```ts
1843
+ interface Sort<T> {
1844
+ key: T;
1845
+ desc?: true;
1846
+ }
1847
+ ```
1848
+
1849
+ Отсутствие `desc` означает ascending. Явное `desc: false` типом не предусмотрено.
1850
+
1851
+ ```ts
1852
+ const sorting = [{ key: "YEAR" }, { key: "AMOUNT", desc: true }] satisfies Sort<"YEAR" | "AMOUNT">[];
1853
+ ```
1854
+
1855
+ ### `buildODataOrder`
1856
+
1857
+ ```ts
1858
+ buildODataOrder(sorting);
1859
+ // YEAR asc,AMOUNT desc
1860
+ ```
1861
+
1862
+ Пустой массив даёт пустую строку.
1863
+
1864
+ ### `getSortIndex`
1865
+
1866
+ ```ts
1867
+ getSortIndex(sorting, "AMOUNT"); // 1
1868
+ getSortIndex(sorting, "STATUS"); // -1
1869
+ ```
1870
+
1871
+ ### `toggleSort`
1872
+
1873
+ Цикл одного поля:
1874
+
1875
+ ```text
1876
+ нет → asc → desc → нет
1877
+ ```
1878
+
1879
+ ```ts
1880
+ let sorting: Sort<keyof Row>[] = [];
1881
+
1882
+ sorting = toggleSort(sorting, "AMOUNT", false); // asc
1883
+ sorting = toggleSort(sorting, "AMOUNT", false); // desc
1884
+ sorting = toggleSort(sorting, "AMOUNT", false); // remove
1885
+ ```
1886
+
1887
+ `multi: true` сохраняет остальные fields.
1888
+
1889
+ `multi: false` оставляет только выбранный key. Это обычный single-sort UI.
1890
+
1891
+ Функция возвращает новый массив и не мутирует входной.
1892
+
1893
+ ### Grouping fallback
1894
+
1895
+ ```ts
1896
+ resolveEffectiveSorts(grouping, sorting);
1897
+ ```
1898
+
1899
+ Правило:
1900
+
1901
+ - если пользовательская `sorting` непустая — используется только она;
1902
+ - если пользовательской сортировки нет — fields `grouping` становятся ascending sorts.
1903
+
1904
+ Grouping не дописывается к пользовательской sorting.
1905
+
1906
+ ### `buildODataOrderBy`
1907
+
1908
+ ```ts
1909
+ buildODataOrderBy<Row>(["YEAR"], []); // "YEAR asc"
1910
+ buildODataOrderBy<Row>(["YEAR"], [{ key: "AMOUNT", desc: true }]);
1911
+ // "AMOUNT desc"
1912
+
1913
+ buildODataOrderBy<Row>([], []); // undefined
1914
+ ```
1915
+
1916
+ В отличие от `buildODataOrder`, пустой эффективный список даёт `undefined`. Это удобно при создании optional query option.
1917
+
1918
+ ### `getSortIndicator`
1919
+
1920
+ ```ts
1921
+ const indicator = getSortIndicator(grouping, sorting, "YEAR");
1922
+ ```
1923
+
1924
+ Результаты:
1925
+
1926
+ ```ts
1927
+ { active: false }
1928
+
1929
+ {
1930
+ active: true,
1931
+ desc: true,
1932
+ order: 2
1933
+ }
1934
+
1935
+ {
1936
+ active: true,
1937
+ order: 1,
1938
+ lockedByGrouping: true
1939
+ }
1940
+ ```
1941
+
1942
+ `order` начинается с `1`, чтобы напрямую показывать номер при multi-sort.
1943
+
1944
+ `lockedByGrouping` появляется только для grouping fallback, когда пользовательской sorting ещё нет.
1945
+
1946
+ ## SQL-like фильтры
1947
+
1948
+ > Это не OData `$filter` и не query option SAP Gateway.
1949
+
1950
+ SQL-like API предназначен только для legacy payload, где backend ожидает текст dynamic WHERE в request body.
1951
+
1952
+ ```ts
1953
+ const sql = buildSqlFilter([createSqlFilterEqual("STATUS", "A"), createSqlFilterIn("COMPANY", ["1000", "2000"])]);
1954
+
1955
+ // STATUS='A' AND COMPANY IN ('1000', '2000')
1956
+ ```
1957
+
1958
+ ### Типы
1959
+
1960
+ ```ts
1961
+ type SqlFilterScalarValue = string | number | boolean;
1962
+ type SqlFilterOperation = "eq" | "in";
1963
+ type SqlFilterConjunction = "and" | "or";
1964
+ type SqlFilterCondition = SqlFilterEqualCondition | SqlFilterInCondition;
1965
+ type SqlFilterExpression = SqlFilterCondition | SqlFilterGroup;
1966
+ ```
1967
+
1968
+ ### Factory API
1969
+
1970
+ ```ts
1971
+ createSqlFilterEqual("STATUS", "A");
1972
+ createSqlFilterIn("COMPANY", ["1000", "2000"]);
1973
+ createSqlFilterGroup([createSqlFilterEqual("STATUS", "A"), createSqlFilterEqual("STATUS", "B")], "or");
1974
+ ```
1975
+
1976
+ Group всегда сериализуется в скобках. Выражения top-level всегда объединяются `AND`.
1977
+
1978
+ ```text
1979
+ (STATUS='A' OR STATUS='B') AND COMPANY='1000'
1980
+ ```
1981
+
1982
+ ### Field id validation
1983
+
1984
+ Разрешены только простые identifiers:
1985
+
1986
+ ```text
1987
+ ^[A-Za-z_][A-Za-z0-9_]*$
1988
+ ```
1989
+
1990
+ ```ts
1991
+ assertSqlFilterFieldId("STATUS"); // ok
1992
+ assertSqlFilterFieldId("STATUS_01"); // ok
1993
+ assertSqlFilterFieldId("A; DROP"); // Error
1994
+ assertSqlFilterFieldId("TABLE.FIELD"); // Error
1995
+ ```
1996
+
1997
+ Validation выполняется при сериализации condition с usable value. Не используйте отсутствие ошибки для empty condition как отдельную проверку field id; для этого вызывайте `assertSqlFilterFieldId` прямо.
1998
+
1999
+ ### Values
2000
+
2001
+ ```ts
2002
+ isSqlFilterScalarValue("A"); // true
2003
+ isSqlFilterScalarValue(10); // true
2004
+ isSqlFilterScalarValue(false); // true
2005
+ isSqlFilterScalarValue(null); // false
2006
+ ```
2007
+
2008
+ Все values, включая numbers и booleans, сериализуются как quoted strings:
2009
+
2010
+ ```text
2011
+ COUNT='10'
2012
+ ACTIVE='true'
2013
+ ```
2014
+
2015
+ Одинарные кавычки внутри value удваиваются.
2016
+
2017
+ Пропускаются:
2018
+
2019
+ - `null` и `undefined`;
2020
+ - пустая/whitespace-only строка;
2021
+ - `NaN` и infinity;
2022
+ - objects, arrays и другие неподдерживаемые values.
2023
+
2024
+ `Date` не пропускается молча, а приводит к ошибке. Caller обязан заранее выбрать формат даты и передать string.
2025
+
2026
+ Пустые groups и conditions не попадают в результат. Если всё пусто, `buildSqlFilter` возвращает `""`.
2027
+
2028
+ ### Почему нельзя применять для URL
2029
+
2030
+ SQL-like syntax содержит `IN`, quoted payload values и field identifiers по собственному контракту backend. Это другой язык выражений. Для OData URL используйте `createFilter*` + `buildODataFilter`.
2031
+
2032
+ ## Пары «код + текст»
2033
+
2034
+ ### Типы
2035
+
2036
+ ```ts
2037
+ type CollectionItem = {
2038
+ [key: string]: string;
2039
+ };
2040
+
2041
+ interface CollectionPair {
2042
+ codeKey: string;
2043
+ textKey: string;
2044
+ }
2045
+
2046
+ interface DependencyFilter {
2047
+ key: string;
2048
+ values: CollectionItem[];
2049
+ }
2050
+ ```
2051
+
2052
+ `CollectionItem` типизирован как string record. Если transport возвращает `null`, number или missing key, сначала нормализуйте row. Runtime helper не проверяет соответствие типу TypeScript.
2053
+
2054
+ ### `findCollectionPairs`
2055
+
2056
+ ```ts
2057
+ const result = findCollectionPairs(
2058
+ {
2059
+ code: "A",
2060
+ code_TEXT: "Альфа",
2061
+ alone: "1"
2062
+ },
2063
+ { returnUnpaired: true }
2064
+ );
2065
+ ```
2066
+
2067
+ Результат:
2068
+
2069
+ ```ts
2070
+ {
2071
+ pairs: [{ codeKey: "code", textKey: "code_TEXT" }],
2072
+ pairsMap: { code: "code_TEXT" },
2073
+ unpaired: ["alone"]
2074
+ }
2075
+ ```
2076
+
2077
+ Options:
2078
+
2079
+ ```ts
2080
+ type FindOptions = {
2081
+ textSuffixes?: string[];
2082
+ caseSensitive?: boolean;
2083
+ returnUnpaired?: boolean;
2084
+ };
2085
+ ```
2086
+
2087
+ Default suffixes:
2088
+
2089
+ ```text
2090
+ _txt, _text, _t, _Text, _TXT, _T
2091
+ ```
2092
+
2093
+ Поиск suffix по умолчанию нечувствителен к регистру. Custom suffixes экранируются как regexp text, поэтому `".label"` работает буквально:
2094
+
2095
+ ```ts
2096
+ findCollectionPairs({ material: "1", "material.label": "Материал" }, { textSuffixes: [".label"] });
2097
+ ```
2098
+
2099
+ Важная тонкость: после удаления suffix base field ищется через точное `fields.includes(baseName)`. Поэтому case-insensitive suffix не делает поиск base key полностью case-insensitive:
2100
+
2101
+ ```ts
2102
+ {
2103
+ CODE: "A",
2104
+ code_txt: "Альфа"
2105
+ }
2106
+ ```
2107
+
2108
+ Такая пара может не найтись из-за различия `CODE` и `code`. Лучше нормализовать имена полей или передавать согласованную metadata-пару.
2109
+
2110
+ Каждое поле используется максимум в одной найденной паре.
2111
+
2112
+ ### `buildSeparatedArrays`
2113
+
2114
+ Функция из общей коллекции создаёт отдельный массив для каждой code/text pair:
2115
+
2116
+ ```ts
2117
+ const rows = [
2118
+ { code: "B", text: "Бета", group: "2", groupText: "Группа 2" },
2119
+ { code: "A", text: "Альфа", group: "1", groupText: "Группа 1" },
2120
+ { code: "A", text: "Альфа", group: "1", groupText: "Группа 1" }
2121
+ ];
2122
+
2123
+ const result = buildSeparatedArrays(rows, [
2124
+ { codeKey: "code", textKey: "text" },
2125
+ { codeKey: "group", textKey: "groupText" }
2126
+ ]);
2127
+ ```
2128
+
2129
+ Результат:
2130
+
2131
+ ```ts
2132
+ {
2133
+ code: [
2134
+ { code: "A", text: "Альфа" },
2135
+ { code: "B", text: "Бета" }
2136
+ ],
2137
+ group: [
2138
+ { group: "1", groupText: "Группа 1" },
2139
+ { group: "2", groupText: "Группа 2" }
2140
+ ]
2141
+ }
2142
+ ```
2143
+
2144
+ Signature:
2145
+
2146
+ ```ts
2147
+ buildSeparatedArrays(items, pairs, (excludeEmpty = true), (sortByCode = true));
2148
+ ```
2149
+
2150
+ Правила:
2151
+
2152
+ - pair считается валидной, только если оба key есть в первом item;
2153
+ - невалидная pair всё равно получает пустой массив в result;
2154
+ - output row содержит только code и text fields;
2155
+ - одинаковая комбинация `code + "|" + text` удаляется;
2156
+ - по умолчанию rows с пустой строкой text пропускаются;
2157
+ - по умолчанию результат сортируется по code;
2158
+ - `sortByCode: false` сортирует по text через общий `compareStrings`;
2159
+ - пустой `items` создаёт пустые массивы для всех pairs.
2160
+
2161
+ ### Ограничения `buildSeparatedArrays`
2162
+
2163
+ Функция приводит значения через `String` до проверки. Поэтому invalid runtime rows могут дать неожиданный текст:
2164
+
2165
+ ```ts
2166
+ String(undefined); // "undefined"
2167
+ String(null); // "null"
2168
+ ```
2169
+
2170
+ Из-за этого missing/null в последующих rows могут попасть в справочник как строки `"undefined"` или `"null"`. `excludeEmpty` удаляет только реально пустую строку после преобразования.
2171
+
2172
+ Кроме того, структура валидируется только по первому item. Если первый row не содержит поле, которое есть в остальных rows, pair будет считаться невалидной.
2173
+
2174
+ Правильный путь для ненадёжного backend payload:
2175
+
2176
+ 1. проверить/нормализовать rows;
2177
+ 2. убедиться в одинаковой форме элементов;
2178
+ 3. затем вызвать `buildSeparatedArrays`.
2179
+
2180
+ ## Настройки OData-коллекций
2181
+
2182
+ ### Глобальный Zustand store
2183
+
2184
+ `useODataCollectionStore` — один singleton на загруженный экземпляр модуля.
2185
+
2186
+ Default state:
2187
+
2188
+ ```ts
2189
+ {
2190
+ defaultMaxVisibleItems: 200,
2191
+ defaultMinSearchCodeLength: 3,
2192
+ defaultMinSearchTextLength: 1,
2193
+ defaultSearchDebounceDelay: 100
2194
+ }
2195
+ ```
2196
+
2197
+ Значения обозначают:
2198
+
2199
+ | Field | Смысл |
2200
+ | ---------------------------- | --------------------------------------------------------- |
2201
+ | `defaultMaxVisibleItems` | Максимум показываемых элементов справочника по умолчанию. |
2202
+ | `defaultMinSearchCodeLength` | Минимальная длина поиска по коду. |
2203
+ | `defaultMinSearchTextLength` | Минимальная длина поиска по тексту. |
2204
+ | `defaultSearchDebounceDelay` | Задержка поиска в миллисекундах. |
2205
+
2206
+ ### Чтение и изменение вне React
2207
+
2208
+ ```ts
2209
+ const state = useODataCollectionStore.getState();
2210
+
2211
+ state.setDefaultMaxVisibleItems(100);
2212
+ state.setDefaultMinSearchCodeLength(2);
2213
+ state.setDefaultMinSearchTextLength(2);
2214
+ state.setDefaultSearchDebounceDelay(250);
2215
+ ```
2216
+
2217
+ Setters не валидируют числа. Отрицательное значение, дробь или `NaN` будут сохранены. Host config обязан проверить значения до записи.
2218
+
2219
+ ### Использование в React
2220
+
2221
+ ```tsx
2222
+ function CollectionInfo() {
2223
+ const maxVisible = useODataCollectionStore((state) => state.defaultMaxVisibleItems);
2224
+
2225
+ return <span>Показываем не более {maxVisible}</span>;
2226
+ }
2227
+ ```
2228
+
2229
+ Convenience selectors:
2230
+
2231
+ ```ts
2232
+ odataCollectionConfig.useMaxVisibleItems();
2233
+ odataCollectionConfig.useMinSearchCodeLength();
2234
+ odataCollectionConfig.useMinSearchTextLength();
2235
+ odataCollectionConfig.useSearchDebounce();
2236
+ ```
2237
+
2238
+ Это React hooks, несмотря на размещение внутри объекта. Вызывайте их только:
2239
+
2240
+ - на верхнем уровне React component;
2241
+ - внутри custom hook;
2242
+ - не внутри condition, loop, event handler или обычной utility function.
2243
+
2244
+ `odataCollectionConfig` экспортирует только selectors. Для setters используйте `useODataCollectionStore`.
2245
+
2246
+ ### Глобальная инициализация
2247
+
2248
+ Если host меняет defaults, сделайте это один раз в application bootstrap, а не при каждом render:
2249
+
2250
+ ```ts
2251
+ export function configureODataCollections() {
2252
+ const state = useODataCollectionStore.getState();
2253
+ state.setDefaultMaxVisibleItems(100);
2254
+ state.setDefaultSearchDebounceDelay(200);
2255
+ }
2256
+ ```
2257
+
2258
+ Store не сохраняется автоматически в localStorage/sessionStorage и не синхронизируется между browser tabs.
2259
+
2260
+ ## Маленькие helpers
2261
+
2262
+ ### `normalizeRangeValue`
2263
+
2264
+ Преобразует `InputType` в двухэлементный range:
2265
+
2266
+ ```ts
2267
+ normalizeRangeValue([10, 20]); // [10, 20]
2268
+ normalizeRangeValue([10]); // [10, null]
2269
+ normalizeRangeValue(10); // [10, null]
2270
+ normalizeRangeValue(null); // [null, null]
2271
+ normalizeRangeValue({}); // [null, null]
2272
+ ```
2273
+
2274
+ Если массив длиннее двух элементов, используются только первые два. Nested objects не поддерживаются.
2275
+
2276
+ ### `normalizeBaseValue`
2277
+
2278
+ ```ts
2279
+ normalizeBaseValue("A"); // "A"
2280
+ normalizeBaseValue(0); // 0
2281
+ normalizeBaseValue(false); // false
2282
+ normalizeBaseValue(date); // тот же Date reference
2283
+ normalizeBaseValue([1, 2]); // null
2284
+ normalizeBaseValue({ a: 1 }); // null
2285
+ ```
2286
+
2287
+ Функция не клонирует `Date`.
2288
+
2289
+ ### `isBaseValue`
2290
+
2291
+ Возвращает `true` для:
2292
+
2293
+ - `null` и `undefined`;
2294
+ - string, number, boolean;
2295
+ - любого `Date` instance.
2296
+
2297
+ Она не проверяет finite number и valid date:
2298
+
2299
+ ```ts
2300
+ isBaseValue(Number.NaN); // true
2301
+ isBaseValue(new Date("bad")); // true
2302
+ ```
2303
+
2304
+ Для строгой проверки используйте schemas/guards конкретного OData type.
2305
+
2306
+ ### `unwrapODataQueryResult`
2307
+
2308
+ Некоторые OData query functions возвращают envelope:
2309
+
2310
+ ```ts
2311
+ {
2312
+ data: rows,
2313
+ totalCount: 123
2314
+ }
2315
+ ```
2316
+
2317
+ TanStack Query result тогда имеет `query.data.data`. Helper выносит fields наружу:
2318
+
2319
+ ```ts
2320
+ const result = unwrapODataQueryResult(query);
2321
+
2322
+ result.data; // rows или undefined
2323
+ result.totalCount; // number или undefined
2324
+ result.isLoading; // остальные поля UseQueryResult сохранены
2325
+ ```
2326
+
2327
+ Тип:
2328
+
2329
+ ```ts
2330
+ type UnwrappedODataQueryResult<T> = Omit<UseQueryResult<{ data: T; totalCount?: number }>, "data"> & {
2331
+ data: T | undefined;
2332
+ totalCount?: number;
2333
+ };
2334
+ ```
2335
+
2336
+ Функция делает shallow spread query result. Она не меняет cache и не создаёт новый query.
2337
+
2338
+ ## Ошибки, безопасность и ограничения
2339
+
2340
+ ### Какие функции бросают ошибки
2341
+
2342
+ Основные error boundaries:
2343
+
2344
+ | API | Причина ошибки |
2345
+ | ------------------------- | ------------------------------------------------------------------------------------- |
2346
+ | `parseServiceMetadata` | Неподдерживаемый EDM type, mode/shape параметра или неразбираемая структура metadata. |
2347
+ | `odataFormatValue` | Значение не прошло Zod schema выбранного OData type. |
2348
+ | `create*Formatter` result | Аргументы вызова не соответствуют schema фабрики. |
2349
+ | `buildParameterEntries` | Нет mandatory value, массив без formatter, formatting error, нарушение `maxLength`. |
2350
+ | `assertSqlFilterFieldId` | Field id не соответствует безопасному pattern. |
2351
+ | `buildSqlFilter` | Попытка сериализовать `Date` без заранее выбранного формата. |
2352
+
2353
+ Filter/sanitizer helpers чаще удаляют невалидные optional values и возвращают пустое выражение, а не бросают ошибку. Это разные контракты: builder параметров строгий, sanitizer сохранённого UI snapshot терпимый.
2354
+
2355
+ ### Не скрывайте ошибку пустой строкой
2356
+
2357
+ Плохой вариант:
2358
+
2359
+ ```ts
2360
+ let path = "";
2361
+
2362
+ try {
2363
+ path = buildEntityPath(metadata, config, params);
2364
+ } catch {
2365
+ // Ошибка потеряна, request уйдёт по неправильному адресу.
2366
+ }
2367
+ ```
2368
+
2369
+ Лучше показать пользователю/разработчику контекст и не отправлять request:
2370
+
2371
+ ```ts
2372
+ try {
2373
+ return buildEntityPath(metadata, config, params);
2374
+ } catch (error) {
2375
+ throw new Error(`Не удалось подготовить OData target ${config.target}`, {
2376
+ cause: error
2377
+ });
2378
+ }
2379
+ ```
2380
+
2381
+ ### Values и field names защищаются по-разному
2382
+
2383
+ `odataFormatValue` экранирует string values и проверяет primitive type.
2384
+
2385
+ Но имена полей/сервисов не проходят общий runtime allowlist:
2386
+
2387
+ - `FilterCondition.key` вставляется как field id;
2388
+ - `ODataServiceConfig.service` и `target` вставляются как path segments;
2389
+ - metadata ids считаются доверенными server metadata;
2390
+ - custom formatter может вернуть любую строку.
2391
+
2392
+ Если field/service/target приходит из пользовательского JSON, сверяйте его с заранее известным allowlist или разобранной metadata.
2393
+
2394
+ ### Custom formatter — доверенная функция
2395
+
2396
+ ```ts
2397
+ {
2398
+ value,
2399
+ formatter: (raw) => "..."
2400
+ }
2401
+ ```
2402
+
2403
+ Builder не понимает семантику строки custom formatter. Он не может гарантировать корректное escaping и тип literal. Не создавайте formatter из произвольного пользовательского JavaScript и не используйте `eval`.
2404
+
2405
+ Formatter должен быть заранее написан и протестирован.
2406
+
2407
+ ### Не кодируйте всё URL целиком
2408
+
2409
+ Entity parameters кодируются builder-ом на уровне отдельных values. Function Import использует `URLSearchParams`.
2410
+
2411
+ Если после этого вызвать `encodeURIComponent` на всём path, `/`, `?`, `=` и уже encoded проценты будут испорчены.
2412
+
2413
+ Transport должен соединять подготовленный path с base URL через свой проверенный URL builder.
2414
+
2415
+ ### Metadata не является validation server rows
2416
+
2417
+ `ServiceMetadata` описывает ожидаемую структуру, но library не проверяет каждую запись ответа. TypeScript type assertion не заменяет runtime validation:
2418
+
2419
+ ```ts
2420
+ const rows = response as SalesRow[]; // не проверяет данные
2421
+ ```
2422
+
2423
+ Если backend payload недоверенный, валидируйте его на transport/domain boundary.
2424
+
2425
+ ### SQL-like API не делает dynamic SQL безопасным целиком
2426
+
2427
+ Library ограничивает field id и экранирует quotes в values. Но окончательная безопасность зависит от backend:
2428
+
2429
+ - backend не должен добавлять raw user fragments;
2430
+ - backend должен ограничить доступные поля;
2431
+ - лучше использовать parameterized database query;
2432
+ - request body не должен давать пользователю произвольный WHERE.
2433
+
2434
+ ## SSR, браузер и React
2435
+
2436
+ ### `DOMParser` и Node.js
2437
+
2438
+ В обычном browser `DOMParser` существует глобально. В чистом Node.js это не гарантируется.
2439
+
2440
+ ```ts
2441
+ if (typeof DOMParser === "undefined") {
2442
+ throw new Error("Для parseServiceMetadata требуется DOMParser");
2443
+ }
2444
+ ```
2445
+
2446
+ Для SSR есть три безопасных варианта:
2447
+
2448
+ 1. парсить metadata только в browser;
2449
+ 2. предоставить совместимый DOM implementation в server runtime;
2450
+ 3. парсить metadata в transport/server layer и передавать сериализуемый `ServiceMetadata` дальше.
2451
+
2452
+ Не добавляйте случайный DOM polyfill внутрь feature: настройте его один раз на границе runtime.
2453
+
2454
+ ### Zustand store на server
2455
+
2456
+ `useODataCollectionStore` является singleton module state. В долгоживущем SSR process глобальное изменение может разделяться между requests.
2457
+
2458
+ Не храните в нём пользовательские или request-specific values. Текущий store предназначен только для общих defaults приложения.
2459
+
2460
+ ### Rules of Hooks
2461
+
2462
+ Следующий код неверен:
2463
+
2464
+ ```ts
2465
+ function readConfig() {
2466
+ return odataCollectionConfig.useMaxVisibleItems();
2467
+ }
2468
+ ```
2469
+
2470
+ Обычная функция может быть вызвана вне React render.
2471
+
2472
+ Для non-React чтения используйте:
2473
+
2474
+ ```ts
2475
+ const maxVisible = useODataCollectionStore.getState().defaultMaxVisibleItems;
2476
+ ```
2477
+
2478
+ ### Server Components
2479
+
2480
+ Pure helpers можно использовать в server code при доступности их runtime dependencies. Hook selectors нужно вызывать только в client component. Помечайте component согласно framework contract, например `"use client"`, когда это требуется.
2481
+
2482
+ ## Тестирование consumer-кода
2483
+
2484
+ ### Тест value formatting
2485
+
2486
+ ```ts
2487
+ import { describe, expect, it } from "vitest";
2488
+ import { odataFormatValue } from "@ryuzaki13/react-foundation-lib/odata-service";
2489
+
2490
+ describe("OData parameter values", () => {
2491
+ it("экранирует одинарную кавычку", () => {
2492
+ expect(odataFormatValue("string", "O'Reilly")).toBe("'O''Reilly'");
2493
+ });
2494
+ });
2495
+ ```
2496
+
2497
+ ### Тест builder по metadata
2498
+
2499
+ Создавайте минимальную metadata, которая важна сценарию:
2500
+
2501
+ ```ts
2502
+ it("не позволяет пропустить обязательную компанию", () => {
2503
+ const metadata: EntityMetadata = {
2504
+ title: "Продажи",
2505
+ columns: [],
2506
+ parameters: [
2507
+ {
2508
+ id: "p_company",
2509
+ label: "Компания",
2510
+ type: "string",
2511
+ originalType: "Edm.String",
2512
+ mandatory: true
2513
+ }
2514
+ ],
2515
+ result: "Results"
2516
+ };
2517
+
2518
+ expect(() => buildEntityParameters(metadata, {})).toThrow("Отсутствует обязательный параметр: p_company");
2519
+ });
2520
+ ```
2521
+
2522
+ ### Тест filters как структуры и строки
2523
+
2524
+ Полезно проверять оба уровня:
2525
+
2526
+ ```ts
2527
+ const expression = createFilterEqual<Row, string>("STATUS", ["A", "B"]);
2528
+
2529
+ expect(expression).toEqual({
2530
+ conditions: [
2531
+ { key: "STATUS", value: "A", operation: "eq" },
2532
+ { key: "STATUS", value: "B", operation: "eq" }
2533
+ ]
2534
+ });
2535
+
2536
+ expect(buildODataFilter(expression)).toBe("(STATUS eq 'A' or STATUS eq 'B')");
2537
+ ```
2538
+
2539
+ Structure test показывает ошибку compiler-а, string test — ошибку serializer-а.
2540
+
2541
+ ### Тест compiled definitions
2542
+
2543
+ Для каждого custom filter проверяйте минимум:
2544
+
2545
+ - empty value;
2546
+ - одно значение;
2547
+ - несколько значений;
2548
+ - falsy value `0`, `false`, `""`, если допустимо;
2549
+ - невалидный сохранённый snapshot;
2550
+ - physical column ids;
2551
+ - итоговую строку `$filter`.
2552
+
2553
+ ### Тест store
2554
+
2555
+ Singleton state сбрасывайте между tests:
2556
+
2557
+ ```ts
2558
+ beforeEach(() => {
2559
+ useODataCollectionStore.setState({
2560
+ defaultMaxVisibleItems: 200,
2561
+ defaultMinSearchCodeLength: 3,
2562
+ defaultMinSearchTextLength: 1,
2563
+ defaultSearchDebounceDelay: 100
2564
+ });
2565
+ });
2566
+ ```
2567
+
2568
+ Иначе один test может влиять на следующий.
2569
+
2570
+ ### Metadata fixtures
2571
+
2572
+ Храните небольшие XML fixtures для отдельных особенностей:
2573
+
2574
+ - parameterized entity;
2575
+ - Function Import;
2576
+ - code/text link;
2577
+ - ABAP boolean-like field;
2578
+ - unsupported type/error case.
2579
+
2580
+ Один огромный production EDMX делает test сложным для понимания и обновления.
2581
+
2582
+ ## Миграция с `/odata`
2583
+
2584
+ После объединения измените все импорты code/text helpers, date formatter и collection store.
2585
+
2586
+ До:
2587
+
2588
+ ```ts
2589
+ import { buildSeparatedArrays, findCollectionPairs, ODataDateFormat, useODataCollectionStore } from "@ryuzaki13/react-foundation-lib/odata-service";
2590
+ ```
2591
+
2592
+ После:
2593
+
2594
+ ```ts
2595
+ import {
2596
+ buildSeparatedArrays,
2597
+ findCollectionPairs,
2598
+ ODataDateFormat,
2599
+ useODataCollectionStore
2600
+ } from "@ryuzaki13/react-foundation-lib/odata-service";
2601
+ ```
2602
+
2603
+ ### Что именно удалено
2604
+
2605
+ Subpath export:
2606
+
2607
+ ```text
2608
+ @ryuzaki13/react-foundation-lib/odata
2609
+ ```
2610
+
2611
+ больше не является публичным контрактом. Compatibility re-export не оставлен.
2612
+
2613
+ ### Порядок миграции нескольких пакетов
2614
+
2615
+ Если `react-foundation-api`, `react-foundation-ui` и host-приложение используют прежний subpath:
2616
+
2617
+ 1. опубликуйте новую breaking-версию `react-foundation-lib`;
2618
+ 2. обновите imports и dependency range в package-потребителе;
2619
+ 3. проверьте typecheck/build этого потребителя;
2620
+ 4. опубликуйте обновлённый зависимый package;
2621
+ 5. только затем обновите host-приложение.
2622
+
2623
+ Нельзя отдельно переключить source consumer на новые exports, пока установленная версия library ещё не содержит перенесённые symbols.
2624
+
2625
+ ### Что не нужно менять
2626
+
2627
+ Imports, которые уже использовали `/odata-service`, остаются прежними:
2628
+
2629
+ ```ts
2630
+ import { buildODataFilter } from "@ryuzaki13/react-foundation-lib/odata-service";
2631
+ ```
2632
+
2633
+ Не путайте с transport entrypoint:
2634
+
2635
+ ```ts
2636
+ // Это другой пакет; его имя не меняется из-за объединения lib-модулей.
2637
+ import {} from /* transport API */ "@ryuzaki13/react-foundation-api/odata";
2638
+ ```
2639
+
2640
+ ## Полная карта API
2641
+
2642
+ Раздел перечисляет все публичные symbols единого entrypoint. Если symbol отсутствует в этой карте, не импортируйте его через глубокий путь.
2643
+
2644
+ ### Base types и labels
2645
+
2646
+ | Symbol | Вид | Назначение |
2647
+ | ----------------------- | -------- | -------------------------------------- |
2648
+ | `BaseMetaType` | type | UI type: string/number/boolean/date. |
2649
+ | `BASE_META_TYPES` | const | Readonly список base types. |
2650
+ | `BASE_META_TYPE_LABELS` | const | Русские labels base types. |
2651
+ | `isBaseMetaType` | function | Runtime type guard base type. |
2652
+ | `resolveBaseTypeLabel` | function | Label для base type. |
2653
+ | `ODataMetaType` | type | Union всех поддерживаемых OData types. |
2654
+ | `ODataBooleanType` | type | Boolean group. |
2655
+ | `ODataNumericType` | type | Numeric group. |
2656
+ | `ODataIntegerType` | type | Integer subset. |
2657
+ | `ODataDateType` | type | Date/time group. |
2658
+ | `ODataStringType` | type | String/guid/binary group. |
2659
+ | `BaseMethod` | type | GET/POST/PUT/DELETE. |
2660
+ | `ODataOperationMethod` | type | create/update/delete/read/query/fi. |
2661
+ | `isODataBooleanType` | function | Guard boolean type name. |
2662
+ | `isODataNumericType` | function | Guard numeric type name. |
2663
+ | `isODataIntegerType` | function | Guard integer type name. |
2664
+ | `isODataDateType` | function | Guard date type name. |
2665
+
2666
+ ### Metadata contracts и parser
2667
+
2668
+ | Symbol | Вид | Назначение |
2669
+ | ----------------------------- | -------- | ----------------------------------------------- |
2670
+ | `EntityPropertyBase` | type | Id + label. |
2671
+ | `EntityPropertyBaseWithType` | type | Base property + OData type. |
2672
+ | `EntityParameterProperty` | type | Parameter metadata. |
2673
+ | `ColumnRole` | type | measure/dimension. |
2674
+ | `EntityColumnProperty` | type | Column metadata и code/text semantics. |
2675
+ | `EntityMetadata` | type | Entity columns, parameters и result navigation. |
2676
+ | `ServiceEntities` | type | Entity map. |
2677
+ | `FunctionImportMetadata` | type | Function Import metadata. |
2678
+ | `ServiceFunctionImports` | type | Function Import map. |
2679
+ | `ODataTargetMetadata` | type | Entity или Function Import. |
2680
+ | `ServiceMetadata` | type | Полный результат parser. |
2681
+ | `parseServiceMetadata` | function | EDMX XML → ServiceMetadata. |
2682
+ | `FORCED_CODE_TEXT_IDS` | const | Forced code/text base ids. |
2683
+ | `resolveMetadataColumnLabel` | function | Непустой label с fallback на id. |
2684
+ | `isForcedCodeTextId` | function | Проверка точного forced id. |
2685
+ | `isForcedCodeTextFamilyId` | function | Проверка forced base/text family. |
2686
+ | `collectFilterableColumns` | function | Filterable column objects. |
2687
+ | `collectFilterableColumnsIds` | function | Set filterable column ids. |
2688
+
2689
+ ### Values, schemas и formatter registry
2690
+
2691
+ | Symbol | Вид | Назначение |
2692
+ | --------------------------- | -------- | ---------------------------------------- |
2693
+ | `odataTypeSchemas` | const | Zod schemas OData primitive types. |
2694
+ | `isStringSafe` | function | Strict string guard. |
2695
+ | `isNumberSafe` | function | Number guard. |
2696
+ | `isBooleanSafe` | function | Boolean guard. |
2697
+ | `isDateSafe` | function | Valid Date guard. |
2698
+ | `odataFormatValue` | function | JavaScript value → OData literal. |
2699
+ | `odataParseValue` | function | Raw value → значение по OData type. |
2700
+ | `odataParseValueByMetadata` | function | Parsing с `abapBooleanLike`. |
2701
+ | `defaultODataTypeValue` | function | Default по OData type. |
2702
+ | `defaultControlTypeValue` | function | Default по base UI type. |
2703
+ | `getBaseTypeFromODataType` | function | OData type → base type. |
2704
+ | `ODataDateFormat` | const | datetime/datetimeOffset/time literals. |
2705
+ | `ODataFormatterFn` | type | Formatter function contract. |
2706
+ | `ODataFormatterDescription` | type | Formatter registry item. |
2707
+ | `createFormatter` | function | Generic Zod-validated formatter factory. |
2708
+ | `createBooleanFormatter` | function | Boolean arguments factory. |
2709
+ | `createStringFormatter` | function | String arguments factory. |
2710
+ | `createNumberFormatter` | function | Number arguments factory. |
2711
+ | `createDateFormatter` | function | Date arguments factory. |
2712
+ | `getFormattersFor` | function | Registry items по OData type. |
2713
+ | `getFormattersForBaseType` | function | Registry items по base type. |
2714
+ | `getFormatter` | function | Один formatter по OData type/id. |
2715
+ | `getFormatterForBaseType` | function | Один formatter по base type/id. |
2716
+
2717
+ ### Parameters и path
2718
+
2719
+ | Symbol | Вид | Назначение |
2720
+ | ------------------------------- | --------- | -------------------------------------- |
2721
+ | `ODataParameterValue` | type | Scalar или scalar array для formatter. |
2722
+ | `ODataValue` | type | `{ value, formatter? }`. |
2723
+ | `InputValue` | type | Wrapped generic input. |
2724
+ | `UnwrappedODataParameters` | type | Flat OData parameters. |
2725
+ | `UnwrappedInputParameters` | type | Flat input parameters. |
2726
+ | `WrappedODataParameters` | type | Wrapped OData parameters. |
2727
+ | `WrappedInputParameters` | type | Wrapped input parameters. |
2728
+ | `WrapODataParameters` | type | Generic flat → wrapped mapping. |
2729
+ | `UnwrapODataParameters` | type | Generic wrapped → flat mapping. |
2730
+ | `wrapODataParams` | function | Flat object → `{ value }` object. |
2731
+ | `unwrapODataParams` | function | Wrapped → sorted flat snapshot. |
2732
+ | `ODataServiceConfig` | interface | Service + target path segments. |
2733
+ | `buildParameterEntries` | function | Metadata-ordered formatted entries. |
2734
+ | `buildEntityParameters` | function | Entity parameter tuple. |
2735
+ | `buildFunctionImportParameters` | function | Function Import URLSearchParams. |
2736
+ | `buildEntityOperationPath` | function | Path по operation intent. |
2737
+ | `buildFunctionImportPath` | function | Function Import path + query. |
2738
+ | `buildEntityPath` | function | Parameterized entity path. |
2739
+
2740
+ ### Простые OData filters
2741
+
2742
+ | Symbol | Вид | Назначение |
2743
+ | --------------------------- | ------------ | ----------------------------------- |
2744
+ | `FilterOperation` | const и type | Operations и их union type. |
2745
+ | `FILTER_OPERATIONS` | const | Readonly operations list. |
2746
+ | `isFilterOperation` | function | Runtime operation guard. |
2747
+ | `resolveOperationLabel` | function | Русский operation label. |
2748
+ | `FilterCondition` | interface | Одно field condition. |
2749
+ | `BaseFilterExpression` | interface | Общий `and?`. |
2750
+ | `FilterConditionExpression` | interface | Leaf conditions. |
2751
+ | `FilterNestedExpression` | interface | Nested filters. |
2752
+ | `FilterExpression` | type | Union leaf/nested node. |
2753
+ | `buildODataFilter` | function | Expression → `$filter` value. |
2754
+ | `buildODataFilterRecursive` | function | Вариант с дополнительными скобками. |
2755
+ | `createFilter` | function | Общая operation factory. |
2756
+ | `createFilterBetween` | function | Range `ge`/`le`. |
2757
+ | `createFilterEqual` | function | Equality scalar/array. |
2758
+ | `createFilterEqualFalsy` | function | Equality с удалением falsy values. |
2759
+ | `createFilterContains` | function | `substringof` scalar/array. |
2760
+ | `mergeFilterExpressions` | function | Два expressions через AND. |
2761
+
2762
+ ### Compiled filter definitions
2763
+
2764
+ | Symbol | Вид | Назначение |
2765
+ | --------------------------------------------- | -------- | ------------------------------------- |
2766
+ | `ODataFilterValue` | type | Одно controlled filter value. |
2767
+ | `ODataFilterValues` | type | Values по filter id. |
2768
+ | `ODataFilterCondition` | type | Static/input condition. |
2769
+ | `ODataFilterConditionGroup` | type | Condition group. |
2770
+ | `ODataFilterOption` | type | List option + filter. |
2771
+ | `ODataFilterBinding` | type | List/boolean/value binding. |
2772
+ | `FilterDefinitionKind` | type | Supported definition kinds. |
2773
+ | `FilterRegisteredValueMode` | type | single/multiple. |
2774
+ | `FilterSegmentComponentId` | type | Segment component ids. |
2775
+ | `FilterTreeComponentId` | type | Tree component ids. |
2776
+ | `FilterLocalComponentId` | type | Local component ids. |
2777
+ | `FilterAdvancedComponentId` | type | Advanced component id. |
2778
+ | `FilterComponentId` | type | Union известных component ids. |
2779
+ | `ODataCompiledFilterDefinition` | type | Discriminated definition union. |
2780
+ | `isFilterLocalComponentId` | function | Local component guard. |
2781
+ | `resolveODataFilterDefinitionColumnIds` | function | Potential physical columns. |
2782
+ | `resolveODataFilterDefinitionActiveColumnIds` | function | Active physical columns. |
2783
+ | `normalizeDictionaryCodeKey` | function | Trim/remove redundant dictionary key. |
2784
+ | `sanitizeFilterConditionGroup` | function | Нормализация condition group. |
2785
+ | `sanitizeFilterBinding` | function | Нормализация binding. |
2786
+ | `sanitizeFilterDefinitions` | function | Нормализация definitions snapshot. |
2787
+ | `sanitizeFilterValue` | function | Нормализация одного value. |
2788
+ | `sanitizeFilterValues` | function | Нормализация values map. |
2789
+ | `mergeFilterValuePatch` | function | Immutable normalized patch. |
2790
+ | `collapseChainedODataSegmentFilterValues` | function | Самый глубокий segment chain. |
2791
+ | `compileFiltersToExpression` | function | Definitions + values → expression. |
2792
+ | `flattenFilterValuesToODataDependencies` | function | Segment/tree dependencies. |
2793
+ | `ODataChainItem` | type | Элемент dependency chain. |
2794
+ | `ODataChainsMap` | type | Карта dependency chains. |
2795
+
2796
+ ### Sorts
2797
+
2798
+ | Symbol | Вид | Назначение |
2799
+ | ----------------------- | --------- | ------------------------------------ |
2800
+ | `Sort` | interface | Key + optional descending flag. |
2801
+ | `getSortIndex` | function | Index key в sorting. |
2802
+ | `buildODataOrder` | function | Sorting → order string. |
2803
+ | `toggleSort` | function | Цикл none/asc/desc. |
2804
+ | `resolveEffectiveSorts` | function | User sorting или grouping fallback. |
2805
+ | `buildODataOrderBy` | function | Effective `$orderby` либо undefined. |
2806
+ | `getSortIndicator` | function | UI indicator state/order/lock. |
2807
+
2808
+ ### SQL-like filters
2809
+
2810
+ | Symbol | Вид | Назначение |
2811
+ | ------------------------- | -------- | ---------------------------- |
2812
+ | `SqlFilterScalarValue` | type | string/number/boolean. |
2813
+ | `SqlFilterOperation` | type | eq/in. |
2814
+ | `SqlFilterEqualCondition` | type | Equality node. |
2815
+ | `SqlFilterInCondition` | type | IN node. |
2816
+ | `SqlFilterCondition` | type | Condition union. |
2817
+ | `SqlFilterConjunction` | type | and/or. |
2818
+ | `SqlFilterGroup` | type | Nested group. |
2819
+ | `SqlFilterExpression` | type | Condition/group union. |
2820
+ | `isSqlFilterScalarValue` | function | Scalar guard. |
2821
+ | `assertSqlFilterFieldId` | function | Safe field identifier check. |
2822
+ | `createSqlFilterEqual` | function | Equality factory. |
2823
+ | `createSqlFilterIn` | function | IN factory. |
2824
+ | `createSqlFilterGroup` | function | Group factory. |
2825
+ | `buildSqlFilter` | function | Legacy WHERE string. |
2826
+
2827
+ ### Collections, store и utilities
2828
+
2829
+ | Symbol | Вид | Назначение |
2830
+ | --------------------------- | ---------- | ------------------------------------- |
2831
+ | `CollectionItem` | type | String record справочника. |
2832
+ | `CollectionPair` | interface | codeKey/textKey. |
2833
+ | `DependencyFilter` | interface | Key + collection values. |
2834
+ | `findCollectionPairs` | function | Поиск code/text pairs. |
2835
+ | `buildSeparatedArrays` | function | Разделение/dedupe/sort справочника. |
2836
+ | `useODataCollectionStore` | const/hook | Zustand state и setters defaults. |
2837
+ | `odataCollectionConfig` | const | React selector hooks. |
2838
+ | `normalizeRangeValue` | function | Input → двухэлементный range. |
2839
+ | `normalizeBaseValue` | function | Input → scalar/null. |
2840
+ | `isBaseValue` | function | Мягкий base value guard. |
2841
+ | `UnwrappedODataQueryResult` | type | TanStack result с распакованным data. |
2842
+ | `unwrapODataQueryResult` | function | Shallow unwrap query envelope. |
2843
+
2844
+ ## Частые вопросы
2845
+
2846
+ ### Какой import использовать для OData?
2847
+
2848
+ Для metadata, values, path, filters, sorts, code/text helpers и collection config:
2849
+
2850
+ ```ts
2851
+ "@ryuzaki13/react-foundation-lib/odata-service";
2852
+ ```
2853
+
2854
+ Для HTTP/SAP Gateway/TanStack Query transport:
2855
+
2856
+ ```ts
2857
+ "@ryuzaki13/react-foundation-api/odata";
2858
+ ```
2859
+
2860
+ ### Почему нельзя импортировать прежний `/odata`?
2861
+
2862
+ Модули объединены. Два entrypoint создавали искусственное разделение одного технического OData-контракта и заставляли consumer угадывать расположение helper-а.
2863
+
2864
+ ### Нужно ли парсить metadata перед каждым request?
2865
+
2866
+ Нет. Parser чисто преобразует XML, но может выполнять заметную работу. Получайте и кешируйте metadata на уровне API/query infrastructure, затем переиспользуйте `ServiceMetadata`.
2867
+
2868
+ ### Можно ли передать обычный object params?
2869
+
2870
+ Builder ожидает wrapped values. Используйте `wrapODataParams(flat)` или создайте `{ value, formatter? }` вручную.
2871
+
2872
+ ### Почему массив параметра требует formatter?
2873
+
2874
+ OData parameter metadata описывает scalar. Library не может угадать backend-specific способ сериализации массива: CSV, range, JSON, repeated query parameter или другое соглашение.
2875
+
2876
+ ### Почему число `12.5` не форматируется в фильтре автоматически?
2877
+
2878
+ Без `condition.type` number выводится как `int`. Укажите точный metadata type, например `decimal`.
2879
+
2880
+ ### Почему `createFilterBetween([0, null])` пустой?
2881
+
2882
+ Текущая фабрика partial range использует truthy-проверку. Для нулевой границы создайте explicit `createFilter(..., "ge")`.
2883
+
2884
+ ### Почему пустая строка иногда сохраняется?
2885
+
2886
+ В compiled filters пустая строка является допустимым scalar и option key. Это нужно для SAP-состояний, где empty char имеет смысл. `null`/`undefined` означают отсутствие, а `""` может быть реальным условием.
2887
+
2888
+ ### Можно ли использовать SQL-like builder для `$filter`?
2889
+
2890
+ Нет. Это разные syntaxes и разные transport boundaries.
2891
+
2892
+ ### Почему code/text pair не нашлась?
2893
+
2894
+ Проверьте:
2895
+
2896
+ - suffix text field;
2897
+ - точное совпадение регистра base key;
2898
+ - наличие обоих keys в первом row;
2899
+ - custom `textSuffixes`;
2900
+ - не использовано ли поле другой pair.
2901
+
2902
+ ### Где форматировать server value для отображения?
2903
+
2904
+ В `@ryuzaki13/react-foundation-lib/formatters` или table formatter pipeline. OData literal formatter предназначен для request, а не для UI.
2905
+
2906
+ ### Где выполнять request?
2907
+
2908
+ В `@ryuzaki13/react-foundation-api/odata` или через согласованный transport host-приложения. Этот модуль только подготавливает данные.
2909
+
2910
+ ## Связанные модули
2911
+
2912
+ - `@ryuzaki13/react-foundation-api/odata` — OData transport, queries и mutations.
2913
+ - `@ryuzaki13/react-foundation-lib/formatters` — отображение дат, чисел, строк и pipeline.
2914
+ - `@ryuzaki13/react-foundation-lib/table` — TanStack Table metadata adapter.
2915
+ - `@ryuzaki13/react-foundation-lib/range-output` — подстановка открытых границ диапазона перед внешней передачей.
2916
+ - `@ryuzaki13/react-foundation-lib/xml` — общие XML parsing/building helpers.