@hubex/mcp 0.4.0 → 0.5.0

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.
@@ -5,7 +5,7 @@
5
5
  "lookup": "WORK:GET:/TaskTypes"
6
6
  },
7
7
  "requestMethodID": {
8
- "description": "Способ подачи заявки. Для интеграций используется значение 4.",
8
+ "description": "Способ подачи заявки. Значение пер-тенантное выбери из справочника, не подставляй число из примеров.",
9
9
  "required": true,
10
10
  "lookup": "WORK:GET:/RequestMethods"
11
11
  },
package/dist/http.js CHANGED
@@ -3,6 +3,18 @@
3
3
  * injects auth + `X-Application-ID`, and normalizes responses and errors.
4
4
  */
5
5
  import { maskPii } from "./pii/mask.js";
6
+ /**
7
+ * Заголовки, которые клиент проставляет сам. Подмена `Authorization` или
8
+ * `X-Application-ID` сменила бы того, от чьего имени идёт запрос, поэтому
9
+ * переопределение отклоняется, а не тихо игнорируется.
10
+ */
11
+ const RESERVED_HEADERS = new Set(["authorization", "x-application-id"]);
12
+ /**
13
+ * Заголовки ответа, которые несут данные, а не транспортную обвязку.
14
+ * `Content-Range` — единственный источник общего числа записей при
15
+ * постраничной выборке через `Range`, без него цикл по страницам не построить.
16
+ */
17
+ const EXPOSED_RESPONSE_HEADERS = ["content-range", "accept-ranges"];
6
18
  export class HubexApiError extends Error {
7
19
  status;
8
20
  service;
@@ -65,6 +77,13 @@ export class HubexClient {
65
77
  "X-Application-ID": this.config.applicationId,
66
78
  Accept: "application/json",
67
79
  };
80
+ for (const [name, value] of Object.entries(req.headers ?? {})) {
81
+ if (RESERVED_HEADERS.has(name.toLowerCase())) {
82
+ throw new Error(`Заголовок ${name} подставляет сам сервер и переопределить его нельзя. ` +
83
+ `Убери его из headers.`);
84
+ }
85
+ headers[name] = String(value);
86
+ }
68
87
  const init = { method: req.method, headers };
69
88
  if (req.body !== undefined && req.method !== "GET" && req.method !== "HEAD") {
70
89
  headers["Content-Type"] = "application/json";
@@ -85,9 +104,15 @@ export class HubexClient {
85
104
  // потребители клиента, включая текст ошибок. Авторизация ходит мимо
86
105
  // HubexClient (src/auth.ts), поэтому токены сессии не задеваются.
87
106
  const visible = this.config.maskPii ? maskPii(data) : data;
107
+ const exposed = {};
108
+ for (const name of EXPOSED_RESPONSE_HEADERS) {
109
+ const value = res.headers.get(name);
110
+ if (value !== null)
111
+ exposed[name] = value;
112
+ }
88
113
  if (!res.ok) {
89
114
  throw new HubexApiError(res.status, req.service, req.path, visible ?? text);
90
115
  }
91
- return { status: res.status, data: visible };
116
+ return { status: res.status, data: visible, headers: exposed };
92
117
  }
93
118
  }
@@ -57,6 +57,21 @@ function resolvePath(entry, pathParams) {
57
57
  }
58
58
  return path;
59
59
  }
60
+ /**
61
+ * Заголовки ответа, которые стоит показать вызывающему. `Content-Range` несёт
62
+ * общее число записей и нужен всегда; `Accept-Ranges` — только там, где ответ и
63
+ * так обёрнут (HEAD), чтобы не менять форму обычных ответов.
64
+ */
65
+ function headerMeta(res, withAcceptRanges = false) {
66
+ const out = {};
67
+ const contentRange = res.headers?.["content-range"];
68
+ if (contentRange)
69
+ out.contentRange = contentRange;
70
+ const acceptRanges = res.headers?.["accept-ranges"];
71
+ if (withAcceptRanges && acceptRanges)
72
+ out.acceptRanges = acceptRanges;
73
+ return out;
74
+ }
60
75
  function requestTool(group, deps) {
61
76
  const isWrite = group === "write";
62
77
  // DELETE в HubEx нередко удаляет пачкой и принимает список в теле запроса.
@@ -71,6 +86,15 @@ function requestTool(group, deps) {
71
86
  description: "Значения параметров пути, например { \"taskID\": 42 }.",
72
87
  },
73
88
  query: { type: "object", description: "Query-параметры запроса." },
89
+ headers: {
90
+ type: "object",
91
+ description: "Дополнительные HTTP-заголовки — там, где query-параметр не подходит. " +
92
+ "`Range: items=0-4999` даёт постраничную выборку больших справочников: " +
93
+ "общее число записей вернётся в `Content-Range` (`items=1-4999/22721`), " +
94
+ "он попадёт в результат рядом с data. `X-Concurrency-Stamp` (Guid) даёт " +
95
+ "идемпотентность создания заявки. Authorization и X-Application-ID " +
96
+ "подставляет сервер, переопределить их нельзя.",
97
+ },
74
98
  };
75
99
  if (acceptsBody) {
76
100
  properties.body = {
@@ -141,23 +165,31 @@ function requestTool(group, deps) {
141
165
  path,
142
166
  query,
143
167
  body,
168
+ headers: (args.headers ?? undefined),
144
169
  });
170
+ // HEAD и так отдаёт обёртку — там показываем все собранные заголовки,
171
+ // включая `Accept-Ranges`: HEAD с `Range` — дешёвый способ узнать размер
172
+ // коллекции, не выкачивая её.
145
173
  if (group === "head") {
146
- return okResult({ status: res.status, endpointId: entry.id });
174
+ return okResult({ status: res.status, endpointId: entry.id, ...headerMeta(res, true) });
147
175
  }
148
176
  if (applied.length > 0) {
149
177
  return okResult({
150
178
  appliedDefaults: applied,
151
179
  status: res.status,
152
180
  data: res.data ?? null,
181
+ ...headerMeta(res),
153
182
  });
154
183
  }
155
184
  // Пустое тело ответа (204/200 без содержимого) — валидный успех:
156
185
  // отдаём статус, иначе результат инструмента оказался бы пустым.
157
186
  if (res.data === undefined) {
158
- return okResult({ status: res.status, endpointId: entry.id, data: null });
187
+ return okResult({ status: res.status, endpointId: entry.id, data: null, ...headerMeta(res) });
159
188
  }
160
- return okResult(res.data);
189
+ // Обычный ответ отдаём как есть: обёртка появляется только когда пришёл
190
+ // `Content-Range`, то есть когда вызывающий сам просил страницу.
191
+ const meta = headerMeta(res);
192
+ return okResult("contentRange" in meta ? { ...meta, data: res.data } : res.data);
161
193
  }
162
194
  catch (err) {
163
195
  return failResult(err);
@@ -82,7 +82,100 @@ GET COMMON:GET:/Attributes/{attributeID}/listOfValues
82
82
  порядку), `value` — то, что видит пользователь. Запрос заменяет список
83
83
  значений целиком: чтобы удалить значение, отправь набор без него.
84
84
 
85
- ## 3. Где ещё используются атрибуты
85
+ ## 3. Как передаётся значение атрибута
86
+
87
+ Одно и то же поле в разных сущностях заполняется **по-разному**. Это главный
88
+ источник ошибок с атрибутами: код, отлаженный на заявке, на выполненной работе
89
+ молча не сработает. Общее только одно — значение всегда ссылается на `key` из
90
+ `listOfValues`, а не на отображаемый текст.
91
+
92
+ | Сущность | Метод записи | Форма `value` | Мультисписок |
93
+ | --- | --- | --- | --- |
94
+ | Заявка | `WORK:POST:/TaskAttributes`, тело `[{ taskID, data: [...] }]` | строка, обязательна | `"12\|15"` |
95
+ | Объект | `ES:POST:/AssetAttributes/v2`, тело `[{ assetID, data: [...] }]` | строка, обязательна | `"12\|15"` |
96
+ | Выполненная работа | `WORK:PUT:/Tasks/{taskID}/completedWorks/{completedWorkID}/attributes`, тело — **плоский** массив | **массив строк**, необязателен | `["12", "15"]` |
97
+ | Пункт чек-листа | `WORK:PUT:/Tasks/{taskID}/checkLists/{taskCheckListID}/results/v2` | **массив строк** в поле `values` | `["12", "15"]` |
98
+
99
+ Отсюда три следствия:
100
+
101
+ - **Скаляр в выполненной работе и чек-листе — тоже массив.** Строка, число,
102
+ дата и одиночный `Select` передаются как массив из одного элемента:
103
+ `{ "attributeID": 10, "value": ["3"] }`. Отправишь голую строку — получишь
104
+ ошибку типа, а не молчаливое сохранение.
105
+ - **Ключ элемента разный.** У заявки и объекта элемент адресуется `attributeID`,
106
+ у чек-листа — `id` **пункта** чек-листа (не атрибута), и рядом обязателен
107
+ `isChecked`.
108
+ - **Поле чтения тоже разное.** У заявки и объекта заполненное значение приходит
109
+ в `value` строкой, у выполненной работы и чек-листа — в `values` массивом.
110
+ Пустое у заявки — отсутствующий ключ, у выполненной работы — пустой массив
111
+ `values: []`.
112
+
113
+ ### Как выглядит прочитанное значение
114
+
115
+ Проверено на заявке с заполненными полями всех типов: значение всегда строка
116
+ (в заявке и объекте) или элемент массива строк (в выполненной работе и
117
+ чек-листе) — своих типов у API нет ни для чисел, ни для флажков.
118
+
119
+ ```json
120
+ { "attributeID": 12, "value": "39|50|67" } // MultiSelect: ключи по возрастанию
121
+ { "attributeID": 10, "value": "10" } // Select: один ключ
122
+ { "attributeID": 13, "value": "true" } // Switch: строка, не булево
123
+ { "attributeID": 16, "value": "123" } // Int
124
+ { "attributeID": 9, "value": "12.3323" } // Decimal
125
+ { "attributeID": 7, "value": "2026-09-09T12:00:00" } // Date: без таймзоны
126
+ { "attributeID": 8, "value": "2026-09-09T20:59:00.000Z" } // Datetime: UTC с Z и мс
127
+ { "attributeID": 14, "value": "" } // незаполненный Drawing
128
+ ```
129
+
130
+ Два подвоха:
131
+
132
+ - **`Date` и `Datetime` — разные форматы.** Первый без таймзоны, второй в UTC с
133
+ `Z` и миллисекундами. Обработаешь одинаково — получишь сдвиг на часовой пояс
134
+ без всякой ошибки от сервера.
135
+ - **Пустое значение приходит по-разному.** Если доп. поля заявки ни разу не
136
+ сохраняли, ключа `value` в объекте нет вовсе; после сохранения формы
137
+ незаполненные поля приходят пустой строкой `""`. Проверяй оба случая.
138
+
139
+ ### Как надёжно определить мультисписок
140
+
141
+ Признак зависит от того, откуда читаешь, и одного универсального нет:
142
+
143
+ | Источник | Что есть | Признак |
144
+ | --- | --- | --- |
145
+ | `WORK:GET:/Tasks/{taskID}/attributes` | `attributeType.code` | `code === "MultiSelect"` |
146
+ | `WORK:GET:/TaskAttributes` (query `taskID`) | типа нет вообще | только непустой `listOfValues` |
147
+ | `.../completedWorks/{completedWorkID}/attributes` | `attributeType.code` и `selectionMode` | `selectionMode.id === 2` («Мультивыбор») |
148
+ | `WORK:GET:/Tasks/{taskID}/meta` | `attributeType.code` | `code === "MultiSelect"` |
149
+
150
+ Коды типов — CamelCase, ровно как их отдаёт `COMMON:GET:/AttributeTypes`:
151
+ `String`, `Int`, `Decimal`, `Date`, `Datetime`, `Select`, `MultiSelect`, `Text`,
152
+ `Switch`, `Attachment`, `Drawing`. Сравнение с `"MULTISELECT"` не совпадёт
153
+ никогда и при этом не упадёт. `selectionMode` — самый явный признак, но он есть
154
+ только у доп. полей выполненной работы; там же `Drawing` помечен мультивыбором,
155
+ хотя списком значений не является.
156
+
157
+ ### Атрибуты-справочники (`domain`)
158
+
159
+ У атрибута может быть `domain` — тогда это не свой список значений, а ссылка на
160
+ сущность системы: `1` — объекты, `2` — компании, `3` — сотрудники, `4` —
161
+ заказчики, `5` — материалы. Отличия от обычного списка:
162
+
163
+ - ключи `listOfValues` — это id сущностей, а не порядковые номера значений;
164
+ - сами значения приходят **JSON-строками**, а не готовыми подписями:
165
+ `{"Name": "..."}` у объектов, компаний и материалов, `{"FirstName": "...",
166
+ "LastName": "...", "MiddleName": "..."}` у сотрудников и заказчиков. Чтобы
167
+ показать значение пользователю, эту строку надо распарсить;
168
+ - формат записи при этом обычный — те же ключи через `|` или массив, в
169
+ зависимости от сущности из таблицы выше.
170
+
171
+ ### Очистка значения
172
+
173
+ Схема `WORK:POST:/TaskAttributes` объявляет `value` обязательной непустой
174
+ строкой, но для снятия значения клиенты передают `null` — пустая строка не
175
+ подходит. У выполненной работы `value` объявлен nullable, у чек-листа очистка —
176
+ это `isChecked: false` вместе с `values: []`.
177
+
178
+ ## 4. Где ещё используются атрибуты
86
179
 
87
180
  - **Чек-листы**: атрибут задаёт способ ответа на пункт (число, дата, список,
88
181
  вложение) — `hubex_get_guide topic="checklisttemplates"`, фильтр
@@ -123,3 +216,10 @@ hubex_request_read(endpointId: `COMMON:GET:/Attributes/{attributeID}/listOfValue
123
216
  подтверждению пользователя.
124
217
  - В списках атрибутов почти всегда нужен фильтр `isDeleted=false`: удалённые
125
218
  поля продолжают возвращаться.
219
+ - Значение списочного поля сервер не валидирует: запись отображаемого текста
220
+ вместо `key` проходит с `202` и читается обратно как есть, но в интерфейсе
221
+ поле выглядит пустым. Ключи бери из
222
+ `COMMON:GET:/Attributes/{attributeID}/listOfValues`.
223
+ - Определять списочный тип по `attributeType.code` можно не везде:
224
+ `WORK:GET:/TaskAttributes` с query `taskID` не возвращает тип вовсе, только
225
+ `listOfValues`. Универсальный признак — непустой `listOfValues`.
@@ -24,7 +24,10 @@ sources:
24
24
  жизненного цикла, срок закрытия по умолчанию и разрешённые виды работ.
25
25
  Обязателен при создании заявки.
26
26
  - **Способ подачи заявки** — `WORK:GET:/RequestMethods`. Обязателен при
27
- создании заявки; для интеграций используется значение 4.
27
+ создании заявки. Числа не значат того, что кажется: в проверенном тенанте
28
+ `4` — это «Другое», а «Импорт» — `6`; отдельного «интеграции» может не быть
29
+ вовсе. Выбирай по `name` из живого списка и уточняй у пользователя, а не
30
+ подставляй число из примеров.
28
31
  - **Статус заявки** — `WORK:GET:/TaskStatuses`. Визуальное отображение
29
32
  стадии для мобильного приложения заказчика, привязывается к стадии в
30
33
  жизненном цикле — не назначается заявке напрямую произвольным значением.
@@ -62,3 +65,42 @@ sources:
62
65
  специфичны для тенанта — перед подстановкой ID в тело запроса всегда
63
66
  сверяйся с актуальным списком через соответствующий `GET`, а не полагайся
64
67
  на значения из документации или прошлых сессий.
68
+ - Большие справочники не выбираются одним запросом. `ES:GET:/Assets` и
69
+ `WORK:GET:/WorkTypes` поддерживают заголовочную пагинацию — в
70
+ `hubex_request_read` это параметр `headers`:
71
+
72
+ ```text
73
+ hubex_request_read(endpointId: `ES:GET:/Assets`, headers: { "Range": "items=0-4999" })
74
+ → { "contentRange": "items=1-4999/22721", "data": { ... } }
75
+ ```
76
+
77
+ **Границы 1-индексные и включают оба конца.** Нумерация начинается с `1`, а
78
+ не с `0`: `items=1-10` возвращает ровно 10 записей (позиции 1–10), следующая
79
+ страница — `items=11-20`, без пропусков и повторов. Ноль в начале молча
80
+ приводится к единице, поэтому `items=0-9` вернёт **9** записей, а не 10, и
81
+ `Content-Range` честно скажет `items=1-9/70`.
82
+
83
+ Отсюда две ошибки, каждая из которых теряет данные молча:
84
+
85
+ - **«страница короче запрошенной — значит последняя»** неверно. После
86
+ `items=0-9` придёт 9 записей из 70; приняв это за конец, потеряешь 61.
87
+ Единственный признак конца — сравнение правой границы `Content-Range` с
88
+ числом после дроби.
89
+ - **следующая страница начинается с `конец + 1`**, а не с `конец`. `items=9-18`
90
+ после `items=0-9` повторит девятую запись.
91
+
92
+ Один большой `Range` на всю коллекцию сервер может обрезать без ошибки и без
93
+ признака неполноты, поэтому читай страницами и в конце сверяй итог с общим
94
+ числом — иначе получишь уверенное «такой записи нет» на записи, которая есть.
95
+
96
+ И последнее: порядок записей — **лексикографический по ключу-строке**, а не
97
+ числовой. У справочника с ключами 1…70 позиция 10 — это `"18"`, а позиция 12 —
98
+ `"2"`. Границы страниц по числовым id не восстанавливаются.
99
+ - `published` у видов работ (`WORK:GET:/WorkTypes`) и объектов
100
+ (`ES:GET:/Assets`) — это **дата** публикации (`"2024-06-17T12:55:20"`), а не
101
+ булев флаг. У неопубликованной записи ключа нет вовсе, а не `null` или
102
+ `false`: проверяй наличие ключа, а не его значение.
103
+ - Ключ словаря — это id, и внутри объекта он есть не всегда. У
104
+ `WORK:GET:/WorkTypes`, `WORK:GET:/RequestMethods`, `ES:GET:/AssetTypes` и
105
+ `COMMON:GET:/AttributeTypes` поля `id` в объекте нет — только ключ; у
106
+ `ES:GET:/Assets`, наоборот, есть. Универсально безопасно брать id из ключа.
@@ -91,8 +91,11 @@ DELETE ADM:DELETE:/PermissionsUi
91
91
  `taskTypeID` — что доступно сейчас (компоненты и дополнительные поля).
92
92
  - `TSTG:POST:/TaskStageComponents` — запись доступности:
93
93
  `[{ "taskStageID": 12, "taskTypeID": 5, "components": [{ "id": 3, "roleID": 7, "capabilityID": 2 }], "attributes": [...] }]`.
94
- - Справочник уровней доступа (только чтение / чтение и запись / скрыто) —
95
- `ADM:GET:/Capabilities`.
94
+ - Справочник уровней доступа `ADM:GET:/Capabilities`. Отдаёт пять записей с
95
+ полем `weightCoefficient`: `RO`=1 (только чтение), `RW`=2 (чтение и запись),
96
+ `RWM`=3 (обязательная запись), `RWE`=4 (эксклюзивная запись), `RWME`=5
97
+ (обязательная эксклюзивная). Кода `ROM`, который встречается в метаданных
98
+ формы, в этом справочнике нет.
96
99
  - Доступ к дополнительным полям вне контекста стадии —
97
100
  `ADM:GET:/RoleTaskPropertiesAccess/attributes`,
98
101
  `ADM:POST:/RoleTaskPropertiesAccess/attributes`,
@@ -123,3 +126,11 @@ hubex_request_read(endpointId: `ADM:GET:/Roles/{roleID}/permissionsUi`, pathPara
123
126
  - Роли и участки — разные измерения доступа: роль определяет, что можно
124
127
  делать, участок (`ADM:POST:/UserDistricts`) — с какими объектами и типами
125
128
  заявок.
129
+ - Права нескольких ролей складываются по **максимуму** веса из
130
+ `ADM:GET:/Capabilities`, то есть побеждает более строгий уровень: поле,
131
+ помеченное `RWM` хотя бы в одной роли, обязательно для каждого, у кого эта
132
+ роль есть.
133
+ - Следствие для тестирования: аккаунт со всеми ролями тенанта видит почти
134
+ каждое поле обязательным и потому непригоден для проверки обязательности.
135
+ Нужен узкий тестовый аккаунт — либо одна выделенная роль с `RWM` как чистый
136
+ однофакторный эксперимент.
@@ -20,7 +20,10 @@ HubEx REST API организован по сервисам: у каждого
20
20
  (`WORK`, `ES`, `ADM`, `AUTHZ`, `AUTHN`, `PA`, `SLA` и т.д.).
21
21
  - Каждый запрос требует **два** заголовка: `Authorization: Bearer <access_token>`
22
22
  и `X-Application-ID: <id приложения>`. Без второго заголовка сервер вернёт
23
- ошибку 403 «Не найден обязательный заголовок [X-Application-ID]».
23
+ ошибку 403 «Не найден обязательный заголовок [X-Application-ID]». Значение
24
+ различает клиента: `5` — Api/интеграции, `3` — MainWeb. Часть операций
25
+ доступна не при любом значении, поэтому подставлять произвольное число
26
+ нельзя.
24
27
  - Access-токен — JWT, действует ограниченное время (в вики указано 30 минут)
25
28
  и получается отдельным запросом по сервисному токену; для этого сервера
26
29
  обновление токена уже реализовано отдельным механизмом авторизации — вручную
@@ -69,3 +72,16 @@ HubEx REST API организован по сервисам: у каждого
69
72
  - `PUT` требует полное тело записи (все поля, включая неизменяемые в этом
70
73
  запросе), а не только то, что меняется — для частичного обновления
71
74
  используй `PATCH`, если эндпоинт его поддерживает.
75
+ - На больших справочниках (`ES:GET:/Assets`, `WORK:GET:/WorkTypes`) вместо
76
+ `fetch`/`offset` работает заголовочная пагинация: передай
77
+ `headers: { "Range": "items=1-5000" }`, общее число вернётся в поле
78
+ `contentRange` (`items=1-5000/22721`) рядом с `data`. Границы **1-индексные и
79
+ включают оба конца**: начинай с `1` (не с `0`), следующая страница — с
80
+ `конец + 1`. Признак конца — только правая граница против числа после дроби:
81
+ короткая страница последней не означает. Подробности — `hubex_get_guide
82
+ topic="dictionaries"`.
83
+ - Пустое значение почти никогда не приходит как `null`. Чаще всего это
84
+ **отсутствие ключа**: у неопубликованной записи нет поля `published`, у
85
+ заявки — нет массива `attribute`. Но не всегда: незаполненный атрибут
86
+ приходит то без ключа `value`, то пустой строкой `""` — в зависимости от
87
+ того, сохраняли ли форму. Проверяй и отсутствие ключа, и пустое значение.
@@ -44,6 +44,13 @@ sources:
44
44
  | `RWM` | можно писать, **обязательно** (M = mandatory) |
45
45
  | `ROM` | обязательно, но заполняется системой |
46
46
 
47
+ Полный справочник кодов — `ADM:GET:/Capabilities`: кроме перечисленных, он
48
+ отдаёт `RWE` и `RWME`. Если у пользователя несколько ролей, права
49
+ складываются по **максимуму** веса (`RO`=1, `RW`=2, `RWM`=3, `RWE`=4,
50
+ `RWME`=5), то есть побеждает более строгий уровень: поле, помеченное `RWM`
51
+ хотя бы в одной роли, обязательно для всех её носителей. Подробнее —
52
+ `hubex_get_guide topic="roles"`.
53
+
47
54
  Именно из `capability` берётся обязательность `companyID`, `assetID`, `workTypeID`,
48
55
  `payerCompanyID`, `notes`, `contactPerson`, `contactPhone`, `locationID`,
49
56
  `estimatedCost`, `attachments` и т.д. — она **разная у разных типов заявок и
@@ -70,8 +77,10 @@ sources:
70
77
  - `capability` = `RO`/`ROM` → **не передавай**, заполняется системой.
71
78
  4. Сопоставь обязательные поля с тем, что пользователь уже сказал. Значения, которые
72
79
  можно вывести однозначно (`requestedByUserID` = текущий пользователь,
73
- `criticalityID` = запись с `isDefault: true`, `requestMethodID` = 4 для интеграций,
74
- `companyID` и адрес = из выбранного объекта), подставляй сам и упомяни это в ответе.
80
+ `criticalityID` = запись с `isDefault: true`, `companyID` и адрес = из выбранного
81
+ объекта), подставляй сам и упомяни это в ответе. `requestMethodID` так вывести
82
+ нельзя: способ подачи для интеграций свой в каждом тенанте — бери его из
83
+ `WORK:GET:/RequestMethods`.
75
84
  5. **Одним сообщением спроси только про те обязательные поля, которые остались
76
85
  незакрытыми**, и для каждого справочного поля сразу приложи варианты из
77
86
  соответствующего справочника (шаг 1). Не задавай вопросы по одному
@@ -125,7 +134,14 @@ sources:
125
134
 
126
135
  `WORK:POST:/Tasks` — тело **один объект** (не массив, в отличие от большинства
127
136
  POST-методов HubEx). Опциональный заголовок `X-Concurrency-Stamp` (Guid) даёт
128
- идемпотентность: повтор с тем же значением вернёт 409.
137
+ идемпотентность: повтор с тем же значением вернёт 409. В `hubex_request_write`
138
+ он передаётся параметром `headers`:
139
+ `headers: { "X-Concurrency-Stamp": "<guid>" }`.
140
+
141
+ Если ответ на POST потерялся (таймаут, обрыв соединения), `409 AlreadyExists`
142
+ на повторе означает «сервер уже создал эту заявку», а не «дубликат был раньше».
143
+ Не делай вывод по коду ответа: найди запись по естественному ключу —
144
+ `WORK:GET:/Tasks` с query `taskNumber=…` — и считай непустой ответ успехом.
129
145
 
130
146
  Ответ 201: `{ "id": 12345, "number": "T-12345", "concurrencyStamp": "..." }` —
131
147
  `id` и есть `taskID` для всех последующих шагов.
@@ -133,7 +149,10 @@ POST-методов HubEx). Опциональный заголовок `X-Concu
133
149
  ### Обязательный минимум
134
150
 
135
151
  - `taskTypeID` — тип заявки;
136
- - `requestMethodID` — способ подачи (для интеграций 4, веб по умолчанию шлёт 1);
152
+ - `requestMethodID` — способ подачи. Значение пер-тенантное: где-то интеграции
153
+ идут с `4`, где-то требуется `6` («Импорт»), причём поле обязательно вопреки
154
+ схеме — без него `409 ParameterOutOfRange`. Выбирай из
155
+ `WORK:GET:/RequestMethods`, а не по памяти;
137
156
  - `criticalityID` — критичность. Берётся из `SLA:GET:/Criticalities`, дефолт —
138
157
  запись с `isDefault: true`. Формально в схеме необязательна, но клиент считает
139
158
  её обязательной всегда, и заявка без критичности не проходит по SLA.
@@ -146,7 +165,7 @@ POST-методов HubEx). Опциональный заголовок `X-Concu
146
165
  | Поле | Значение |
147
166
  | --- | --- |
148
167
  | `taskTypeID` | выбранный тип |
149
- | `requestMethodID` | способ подачи, по умолчанию 1 |
168
+ | `requestMethodID` | способ подачи; веб шлёт 1, у интеграций значение своё в каждом тенанте |
150
169
  | `criticalityID` | критичность по умолчанию |
151
170
  | `requestedByUserID` | текущий пользователь (заявитель) |
152
171
  | `faultTimestamp` | момент обнаружения неисправности; по умолчанию сегодня, 12:00, формат **без таймзоны** `YYYY-MM-DDTHH:mm:ss` |
@@ -196,9 +215,13 @@ ISO 8601 в UTC; `faultTimestamp` — локальное время без та
196
215
  2. **Дополнительные поля (атрибуты)** — `WORK:POST:/TaskAttributes`, тело массив:
197
216
  `[{ taskID, data: [{ attributeID, value, isPublic, sortOrder }] }]`.
198
217
  Состав атрибутов и их обязательность — из `attributes` в `Tasks/new/meta`.
199
- `value` всегда строка: для `SELECT`/`MULTISELECT` — id значений через `|`
200
- (`"12|15"`), для `ATTACHMENT`/`DRAWING` — id вложений через `|`,
201
- для `DATE` — `YYYY-MM-DDTHH:mm:ss`.
218
+ `value` всегда строка — и у чисел, и у флажков: для `Select`/`MultiSelect` —
219
+ ключи значений через `|` (`"39|50|67"`), для `Attachment`/`Drawing` — id
220
+ вложений через `|`, для `Date` — `YYYY-MM-DDTHH:mm:ss` без таймзоны, для
221
+ `Datetime` — UTC с `Z` (`"2026-09-09T20:59:00.000Z"`), для `Switch` —
222
+ `"true"`/`"false"`. Коды типов — CamelCase, как их отдаёт
223
+ `COMMON:GET:/AttributeTypes`. В выполненных работах и чек-листах то же
224
+ значение передаётся массивом: `hubex_get_guide topic="attributes"`.
202
225
  3. **Контакты заявки** — `WORK:POST:/TaskContacts`, тело массив:
203
226
  `[{ taskID, data: [contactID, ...] }]`. Удаление — `WORK:DELETE:/TaskContacts`
204
227
  с тем же форматом. Список контактов — из контактов объекта/компании (шаг 1, п. 9).
@@ -258,3 +281,7 @@ ISO 8601 в UTC; `faultTimestamp` — локальное время без та
258
281
  - **Заявка без объекта и компании создастся**, но будет бесполезна: по ней не
259
282
  подберётся SLA, договор и исполнитель. Для осмысленного теста указывай
260
283
  `companyID` + `assetID` + `workTypeID`.
284
+ - **Сетевая ошибка ≠ «ничего не создано».** Скрипт, упавший между
285
+ `POST /Tasks` и `POST /TaskAssignmentHistory`, оставляет заявку созданной, но
286
+ без исполнителя. Перед повтором любого шага перечитывай фактическое состояние,
287
+ а не начинай последовательность заново.
@@ -39,6 +39,31 @@ sources:
39
39
  | `RWM` | поле доступно и обязательно; после изменения не оставляй его пустым |
40
40
  | `ROM` | обязательное системное поле, не передавай его вручную |
41
41
 
42
+ Полный справочник кодов — `ADM:GET:/Capabilities`: кроме перечисленных, есть
43
+ `RWE` и `RWME`. Если у пользователя несколько ролей, права складываются по
44
+ **максимуму** веса (`RO`=1, `RW`=2, `RWM`=3, `RWE`=4, `RWME`=5) — обязательность,
45
+ заданная в одной роли, действует на всех её носителей.
46
+
47
+ `meta` — источник **прав**, но не значений: значения в нём не приходят вовсе
48
+ (поле `value` либо отсутствует, либо `null`), в том числе у заведомо заполненных
49
+ полей. Читать значения оттуда нельзя — каждое поле покажется пустым, ошибка не
50
+ проявится ничем, и агент затрёт чужие данные, «дозаполняя» уже заполненное.
51
+ Заодно не рассчитывай, что `capability` там будет всегда: в части тенантов
52
+ `components` приходит словарём пустых объектов `{}`, и обязательность из `meta`
53
+ не выводится совсем.
54
+
55
+ Значения дополнительных полей бери из `WORK:GET:/Tasks/{taskID}/attributes` или
56
+ `WORK:GET:/TaskAttributes` с query `taskID`. Оба возвращают **массив** (не
57
+ словарь по id, вопреки общему правилу гайда `start`) и перечисляют все атрибуты
58
+ типа заявки, включая незаполненные. Пустое значение приходит **двумя разными
59
+ способами**: у заявки, где доп. поля ни разу не сохраняли, ключа `value` нет
60
+ вовсе; после сохранения формы незаполненные поля приходят пустой строкой `""`.
61
+ Проверяй оба случая сразу, иначе «пусто» примешь за «заполнено».
62
+
63
+ Карточка заявки для чтения доп. полей не годится: на проверенном тенанте
64
+ `WORK:GET:/Tasks/{taskID}` не содержит массива `attribute` вообще — ни когда
65
+ значения пустые, ни когда они заполнены. Всегда читай их отдельным запросом.
66
+
42
67
  Не используй права, прочитанные до смены стадии: после перехода обязательно
43
68
  повтори `WORK:GET:/Tasks/{taskID}/meta`.
44
69
 
@@ -153,10 +178,38 @@ MainApp отправляет новое назначение, когда изм
153
178
  ]
154
179
  ```
155
180
 
156
- `value` — строка: для `SELECT`/`MULTISELECT` это id значений через `|`, для
157
- `DATE` — `YYYY-MM-DDTHH:mm:ss`, для `ATTACHMENT`/`DRAWING` — id вложений через
158
- `|`. Новые вложения сначала загрузить, затем передать их id. Не стирай
159
- обязательный (`RWM`) атрибут.
181
+ `value` — всегда строка, даже у чисел и флажков:
182
+
183
+ | Тип | Как выглядит значение |
184
+ | --- | --- |
185
+ | `Select` | один ключ: `"10"` |
186
+ | `MultiSelect` | ключи через `\|` по возрастанию: `"39\|50\|67"` |
187
+ | `Attachment`, `Drawing` | id вложений через `\|` |
188
+ | `Date` | `"2026-09-09T12:00:00"` — **без** таймзоны |
189
+ | `Datetime` | `"2026-09-09T20:59:00.000Z"` — UTC, с `Z` и миллисекундами |
190
+ | `Switch` | строка `"true"` / `"false"`, не булево |
191
+ | `Int`, `Decimal` | `"123"`, `"12.3323"` — строкой |
192
+
193
+ `Date` и `Datetime` возвращаются в разных форматах: считать их одинаковыми —
194
+ готовый сдвиг на часовой пояс. Новые вложения сначала загрузить, затем передать
195
+ их id. Не стирай обязательный (`RWM`) атрибут. Коды типов пишутся именно так, в
196
+ CamelCase (`COMMON:GET:/AttributeTypes`): сравнение с `"SELECT"` не совпадёт
197
+ никогда. У выполненной работы и чек-листа то же значение передаётся иначе —
198
+ сводная таблица в `hubex_get_guide topic="attributes"`.
199
+
200
+ Значение `Select`/`MultiSelect` сервер не проверяет: `WORK:POST:/TaskAttributes`
201
+ с отображаемым текстом вместо ключа вернёт `202`, следующий `GET` отдаст этот
202
+ текст обратно без изменений — а в интерфейсе поле будет выглядеть пустым, потому
203
+ что текст не совпал ни с одним ключом. Ошибку видно только сплошной
204
+ перепроверкой постфактум, поэтому ключи бери из `listOfValues`, а не из того,
205
+ что показывает интерфейс.
206
+
207
+ Как понять, что поле списочное: `WORK:GET:/Tasks/{taskID}/attributes` возвращает
208
+ `attributeType` с `code`, а `WORK:GET:/TaskAttributes` с query `taskID` — не
209
+ возвращает его вовсе, только `attributeName` и `listOfValues`. Надёжный признак,
210
+ работающий на обоих, — **непустой `listOfValues`**; проверка
211
+ `attributeType.code === "Select"` на втором эндпоинте не срабатывает никогда и
212
+ при этом не падает.
160
213
 
161
214
  ### Файлы заявки
162
215
 
@@ -203,6 +256,30 @@ MainApp отправляет новое назначение, когда изм
203
256
  Используй их только когда пользователь меняет именно эту часть; полные формы
204
257
  тел обязательно сверяй через `hubex_describe_endpoint`.
205
258
 
259
+ Выполненные работы нарушают сразу четыре общих правила — каждое отличие легко
260
+ принять за «запись создалась неправильно» на корректных данных:
261
+
262
+ - `WORK:GET:/Tasks/{taskID}/completedWorks` возвращает **список**, а не словарь
263
+ по id — единственное известное исключение из правила гайда `start`;
264
+ - исполнителей в нём нет: они в
265
+ `WORK:GET:/Tasks/{taskID}/completedWorks/{completedWorkID}/technicians` (одна
266
+ работа) или `WORK:GET:/Tasks/{taskID}/completedWorks/technicians` (все работы
267
+ заявки, словарём по номеру работы);
268
+ - id выполненной работы нумеруется внутри заявки (1, 2, 3…), а не глобально —
269
+ именно этот номер ждёт эндпоинт исполнителей;
270
+ - `quantity` и `measurementUnit` HubEx считает сам из `started`/`finished`
271
+ (13:33→15:15 даёт `quantity: 1.7` и единицу «Час») — отправлять их не нужно;
272
+ - доп. поля работы читаются и пишутся полем `values` (**массив**), а не `value`:
273
+ `WORK:GET|PUT:/Tasks/{taskID}/completedWorks/{completedWorkID}/attributes`.
274
+ Там же приходит `selectionMode` (`1` — одно значение, `2` — мультивыбор) —
275
+ самый явный признак списочности из всех мест. Подробности и сравнение с
276
+ заявкой — `hubex_get_guide topic="attributes"`.
277
+
278
+ Естественного ключа у выполненной работы нет: слепой повтор `POST` после
279
+ таймаута молча удваивает строки, и настоящие от дублей потом не отличить. Перед
280
+ повтором прочитай `WORK:GET:/Tasks/{taskID}/completedWorks` и считай непустой
281
+ ответ успехом.
282
+
206
283
  ## 6. Чек-листы и стадия
207
284
 
208
285
  Добавление или снятие шаблона чек-листа с заявки выполняется через
@@ -235,6 +312,37 @@ MainApp отправляет новое назначение, когда изм
235
312
  `SignedByCustomer` (загрузить подпись). Сначала выполни их и проверь результаты,
236
313
  затем делай переход. После него перечитай карточку и `meta`.
237
314
 
315
+ Для нескольких заявок сразу есть массовый `WORK:GET:/Tasks/stages/next`. Список
316
+ заявок передаётся query-параметром **`id`** (массив); с другим именем параметра
317
+ сервер молча отвечает `204` и пустым телом, а не ошибкой. Живой ответ не
318
+ совпадает со сгенерированной схемой: это не `map<ListStagesResult>`, а список
319
+ групп по типу заявки —
320
+
321
+ ```json
322
+ [
323
+ {
324
+ "taskTypeID": 5,
325
+ "currentStage": { "id": 3, "name": "Новая" },
326
+ "nextStages": [
327
+ {
328
+ "nextStage": { "id": 9, "name": "В работе", "color": "#1E88E5" },
329
+ "linkName": "Взять в работу",
330
+ "sortOrder": 1
331
+ }
332
+ ],
333
+ "tasks": [12345, 12346],
334
+ "error": ""
335
+ }
336
+ ]
337
+ ```
338
+
339
+ Целевой `taskStageID` лежит в `nextStages[i].nextStage.id`. Сопоставлять стадию
340
+ нужно по `nextStage.name`, а не по `linkName`: `linkName` — подпись кнопки
341
+ перехода, и она регулярно отличается от названия стадии назначения (стадия
342
+ «Отказ исполнителя» с кнопкой «Отказаться от заявки», «Исполнитель назначен» с
343
+ кнопкой «Назначение исполнителя»). `sortOrder` внутри `nextStages` не уникален —
344
+ сортировать по нему как по ключу нельзя.
345
+
238
346
  ## 7. Удаление и восстановление
239
347
 
240
348
  Удалить заявку: `WORK:DELETE:/Tasks/{taskID}`. Это отдельное, потенциально
@@ -263,10 +371,19 @@ PATCH основной карточки
263
371
  чек-листе, назначении или атрибуте должна быть видна до попытки финального
264
372
  перехода.
265
373
 
374
+ Проверяя результат, следи за именами полей: в ответе они не те, которыми поле
375
+ называют в задаче. У карточки заявки нет `criticality`, `deadline` и `stage` на
376
+ верхнем уровне — есть `requestedCriticality` / `actualCriticality`,
377
+ `timesheet.deadline` и `taskStage`. Не делай вывод «не сохранилось», не убедившись,
378
+ что читаешь именно то поле.
379
+
266
380
  ## Подводные камни
267
381
 
268
382
  - Права и обязательность динамические: источник правды —
269
383
  `WORK:GET:/Tasks/{taskID}/meta`, а не swagger и не значения другой заявки.
384
+ - `meta` даёт права, но **не значения**: значений там нет вообще, включая
385
+ заполненные поля. Значения — только из `WORK:GET:/Tasks/{taskID}/attributes`
386
+ или `WORK:GET:/TaskAttributes` с query `taskID`.
270
387
  - `PATCH /Tasks/{taskID}` принимает массив `{ field, value }`; почти все
271
388
  соседние методы принимают объект либо массив объектов с `taskID` и `data`.
272
389
  - Не очищай поля значением `null` по умолчанию. `null` в PATCH — это явная
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hubex/mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "MCP server for managing HubEx test data (tasks, assets, companies, users) via the HubEx REST API",
6
6
  "author": "HubEx Team",