Публичное API Золотенков МРП: руководство интегратора
Этот документ объясняет, как подключиться к публичному API Золотенков МРП и решать им обычные задачи. Машинная спецификация лежит по адресу https://mrp.zolotenkov.ru/api/external/v1/openapi.json и остаётся источником истины по полям; здесь — то, чего в ней нет: с чего начать, какие правила обязательны и как выглядит сквозной сценарий целиком.
Общий адрес: https://mrp.zolotenkov.ru/api/external/v1. Все ответы — JSON в кодировке UTF-8. Поля тел запросов и ответов именуются в стиле snake_case (item_variant_id, total_amount_with_tax), а не camelCase.
1. Начало работы
1.1. Где выпустить ключ
Ключ выпускает сам клиент в интерфейсе: «Настройки → API-ключи». Раздел доступен пользователю с правом manage_api_keys; сотруднику без этого права его не видно.
При выпуске задаются четыре вещи:
| Что | Значения | Если не выбрать |
|---|---|---|
| Название | строка до 255 символов | обязательно, пустое отвергается |
| Права | из каталога §3 | только права на чтение |
| Срок действия | 30, 90 или 180 дней либо «бессрочно» | 90 дней |
| Суточный предел документов | 50, 200 или 1000 | 200 |
Произвольного срока и произвольного предела нет намеренно: закрытый список не даёт завести ключ с датой 9999-12-31 или пределом в миллион.
1.2. Значение показывается один раз
Ответ выпуска — единственное место во всей системе, где видно значение ключа. В базе лежит только его argon2-хеш, восстановить значение нельзя ни поддержкой, ни запросом. Потеряли — выпускайте новый и отзывайте старый.
Ключ выглядит так: zol_pat_live_ плюс 32 символа из латиницы и цифр, всего 45 символов.
В списке ключей потом видна только маска — zol_pat_live_ плюс первые 12 символов, — а также дата выпуска, дата последнего обращения, дата истечения и суточный предел. Прав ключа список не показывает.
1.3. Бессрочным бывает только ключ на чтение
Ключ, у которого есть хоть одно право на запись, обязан иметь срок. Попытка выпустить бессрочный ключ с правом записи отвергается:
POST /api/settings/api-keys
{"name":"…","scopes":["write:sales.order.create"],"expiresInDays":null}
400 {"message":"Ключ с правом на изменение данных не может быть бессрочным — выберите срок 30, 90 или 180 дней","code":"VALIDATION_ERROR"}
Причина простая: бессрочный ключ с правом записи — это ключ от всего, и он не перестаёт им быть от того, что про него забыли.
Истёкший ключ перестаёт работать сам: любой запрос им получает 401 с сообщением Token revoked or expired.
1.4. Как отозвать
В том же разделе интерфейса. Отзыв действует немедленно: сразу после отзыва тот же ключ получает
401 {"error":"unauthorized","message":"Token revoked or expired","request_id":"req_kSrj1Fn5VtJPphyqOu4nrv"}
Отозванный ключ из списка исчезает. Чужой ключ и уже отозванный отвечают одинаковым 404 — существование чужой записи система не подтверждает.
1.5. Ошибки самого раздела выпуска
Раздел «Настройки → API-ключи» — часть интерфейса, а не публичного API, и ошибки отдаёт во внутреннем формате {"message", "code", "timestamp", "details"}. Формат ошибок публичного API (§4.6) другой. Не пишите один разбор ошибок на оба.
2. Где попробовать, ничем не рискуя
Пробовать запись на учёте, по которому клиент реально работает, не нужно: отдельный бесплатный аккаунт под опыты заводится за пять минут и не надо ни просить, ни настраивать.
2.1. Завести отдельный аккаунт под опыты
Регистрация — на https://zolotenkov.ru/signup/ (адрес https://mrp.zolotenkov.ru/registration ведёт туда же перенаправлением 301). Заводите отдельный аккаунт, а не тот, где ведётся настоящий учёт: ключ с правом записи, ошибка в теле запроса и повтор без Idempotency-Key в чужом рабочем аккаунте стоят дороже, чем пять минут на регистрацию.
Аккаунт, где вы будете пробовать, должен быть вашим собственным. Опыты в аккаунте клиента — это чужие документы в его учёте, даже когда всё прошло удачно.
2.2. Свежий аккаунт пуст
Готового набора записей в новом аккаунте нет: ни автоматического, ни по кнопке. Списки пустые, справочники — тоже. Входные данные под сценарий вы заводите сами, интерфейсом или тем же публичным API.
Минимальный набор под сквозные сценарии §6: единица измерения, склад, один товар с вариантом, один материал, рецепт на товар и покупатель. На нём проходят все четыре сценария §6.
2.3. Выпустить ключ с узкими правами и коротким сроком
Дальше — обычный порядок §1: «Настройки → API-ключи», минимальный набор прав под сценарий, срок 30 дней, суточный предел документов 50. Даже в аккаунте для опытов не выпускайте ключ шире, чем нужно: привычка выдавать write:*-набор «чтобы не мешало» переезжает в рабочий аккаунт вместе с кодом.
Проверено живым выпуском: ключ с правами read:catalog и read:sales, сроком 30 дней и пределом 50 отвечает 200 на GET /api/external/v1/products?limit=1.
2.4. Прогнать сценарии
Возьмите §6 целиком: посмотреть состояние заказа покупателя, проверить обеспеченность материалами, создать заказ, запустить и завершить производственное задание.
Что стоит попробовать именно здесь, а не в бою:
- повтор записи с тем же
Idempotency-Key— убедиться, что второго документа не появляется (§4.2); - запрос без нужного права — увидеть
403с именем недостающего права (§3); - исчерпание суточного предела документов на ключе с пределом 50 — увидеть
document_quota_exceededи отличить его отrate_limited(§5.2); - обрыв на середине сценария — проверить, что ваш код доводит документ до конца, а не оставляет заказ без производственного задания.
2.5. Убрать за собой
Записи, заведённые в аккаунте для опытов, удаляются обычным порядком — по одной, интерфейсом или API. Отдельной кнопки «очистить аккаунт» нет.
2.6. Во что можно упереться на бесплатном тарифе
Сразу после регистрации действует Free: 0 ₽ без срока до 30 артикулов включительно. Товары и материалы считаются вместе. Активные и архивные артикулы входят в лимит, удалённые — нет.
Когда в учёте становится 30 артикулов, начинаются 15 дней без ограничения количества. В этот период интерфейс и публичный API позволяют добавлять новые артикулы. Дата окончания видна в разделе подписки.
После 15 дней аккаунт продолжает работать, если в нём не больше 30 артикулов. При большем количестве работа останавливается до удаления лишних артикулов либо подключения Pro. Данные не удаляются. Запрос создания, который нарушает ограничение, отвечает 402 с кодом SUBSCRIPTION_LIMIT.
GET /api/subscriptions/current
200
{"plan":"free","status":"active","trialEndsAt":null,
"articleLimitSource":"live_threshold","skuCount":30,"skuLimit":30,
"limitState":"grace","isUnlimited":false,"isAccessBlocked":false,
"proMonthlyPriceRub":3290,"proYearlyPriceRub":32900}
Это внутренняя ручка интерфейса, а не публичного API: она отвечает на сессию пользователя, а не на ключ. Своё положение относительно предела смотрите ею или глазами в разделе подписки.
Счётчик меняют артикулы аккаунта: их архивирование не освобождает место, а удаление освобождает.
Предел действует одинаково в интерфейсе и в публичном API. После окончания периода запрос, который создаст 31-й артикул, отвечает 402 с кодом SUBSCRIPTION_LIMIT. Групповой запрос при таком отказе не создаёт ни одной строки.
Когда работа остановлена, ключ сохраняет всё чтение и те изменяющие операции, которыми лишний артикул убирают из учёта: удаление товара, отмена заказа покупателя и корректировка остатков, в которой все строки уменьшают остаток. Корректировка с приходной строкой отвечает 402, как и любая другая работа. Тот же состав действует и в интерфейсе: выйти из ограничения можно без оплаты, разобрав открытые заказы и остатки, которые держат лишний артикул.
GET /api/subscriptions/current вместе с skuCount отдаёт skuArchivedCount — сколько из этого числа лежит в архиве. Архивные артикулы считаются в лимите, но в каталоге показаны отдельной вкладкой, поэтому счётчик называет их долю отдельно.
Что не зависит от тарифа: частота запросов и суточный предел документов на ключ (§5). Они одинаковы для всех и настраиваются не тарифом, а параметрами ключа.
3. Права
Два правила определяют весь каталог:
- Чтение нарезано по разделу. Тот, кто читает остатки, читает их целиком; дробить это незачем.
- Запись нарезана по отдельному действию. Право «запускать производство» не приносит с собой право «создавать заказы».
Зонтичных прав (write:sales, write:*) не существует. Прав на удаление документов не существует: публичное API не убирает документы из учёта — это делает только человек в интерфейсе. Единственное право, которое что-то убирает, — write:webhook.subscription.delete: подписка на события принадлежит вашей интеграции, а не учёту, и её снятие не трогает ни одного документа. Единственная отмена, доступная ключу, — отмена заказа покупателя (write:sales.order.cancel); она документ не убирает, а переводит его в состояние «Отменён» с обязательной причиной. Снять эту отмену ключ не может — «Вернуть в работу» есть только в интерфейсе.
Полный каталог — 30 прав. Тот же список машинно отдаёт ручка GET /api/settings/api-keys/scopes.
| Право | Раздел | Что разрешает |
|---|---|---|
read:catalog | Каталог | Читать товары, категории и единицы измерения |
write:catalog.product.create | Каталог | Создавать товары |
write:catalog.product.update | Каталог | Изменять карточку товара |
read:stock | Склад | Читать остатки, движения и партии со сроками годности |
write:inventory.adjustment.create | Склад | Оформлять корректировку остатков |
write:inventory.storage_place.create | Склад | Заводить места хранения |
read:sales | Продажи | Читать заказы покупателей, возвраты покупателей, справочник покупателей и сводный отчёт продаж с себестоимостью и маржой |
write:sales.order.create | Продажи | Создавать заказы покупателей |
write:sales.order.update | Продажи | Изменять незавершённый заказ покупателя |
write:sales.order.ship | Продажи | Отгружать позиции заказа покупателя |
write:sales.order.complete | Продажи | Отгружать остаток и закрывать заказ покупателя |
write:sales.order.cancel | Продажи | Отменять заказ покупателя с указанием причины |
write:sales.return.create | Продажи | Оформлять возвраты покупателей |
write:sales.return.receive | Продажи | Принимать возвраты покупателей на склад |
write:sales.customer.create | Продажи | Заводить покупателей |
write:sales.customer.update | Продажи | Изменять карточку покупателя |
read:purchase | Закупки | Читать заказы поставщикам, приходы и поставщиков |
write:purchase.order.create | Закупки | Создавать заказы поставщикам |
write:purchase.receipt.create | Закупки | Оформлять приход по заказу поставщику |
write:purchase.supplier.create | Закупки | Заводить поставщиков |
write:purchase.supplier.update | Закупки | Изменять карточку поставщика |
read:plan | План | Читать прогноз остатков, дефициты и рекомендации к пополнению |
read:manufacturing | Производство | Читать производственные заказы, задания, спецификации и сводный отчёт выпуска с себестоимостью |
write:manufacturing.order.create | Производство | Создавать производственные задания |
write:manufacturing.order.start | Производство | Запускать производственные задания |
write:manufacturing.order.complete | Производство | Завершать производственные задания |
read:webhooks | События | Читать подписки на события |
write:webhook.subscription.create | События | Заводить подписки на события |
write:webhook.subscription.update | События | Изменять подписки на события |
write:webhook.subscription.delete | События | Удалять подписки на события |
Три следствия, о которые спотыкаются:
- Неизвестное право молча отбрасывается. Запрос ключа с правом, которого нет в каталоге, ошибки не вернёт — право просто не попадёт в ключ. Если после отбрасывания не осталось ни одного права, выпуск отвечает
400 «Выберите хотя бы одно право для ключа»— тем же текстом, что и на пустой список. - Права на запись не включают чтение. Ключу, который создаёт заказы и потом читает их, нужны оба права:
write:sales.order.createиread:sales. - Нехватку права видно сразу и точно — ответ называет недостающее право:
403 {"error":"forbidden","message":"Missing required scope 'write:sales.order.create'"}
Право проверяется раньше заголовка Idempotency-Key: запрос на запись без права и без заголовка получит 403, а не 400. Не полагайтесь на 400 как на признак того, что право есть.
4. Обязательные правила запроса
4.1. Заголовок Authorization
Authorization: Bearer zol_pat_live_<значение>
Ровно эта форма. Отсутствие заголовка — 401 «Missing Authorization header»; заголовок не по форме Bearer … — 401 «Authorization must be 'Bearer <token>'»; значение, не начинающееся с zol_pat_live_ или zol_pat_test_, — 401 «Token must start with zol_pat_live_/test_». Неверный, отозванный и истёкший ключ отвечают 401 без подробностей.
4.2. Idempotency-Key на каждой записи
Любой POST и PATCH публичного API требует заголовок Idempotency-Key. Без него — 400:
400 {"error":"validation_failed","message":"Idempotency-Key header is required"}
Ключ идемпотентности — произвольная строка до 200 символов, которую придумывает клиент (обычно UUID). Работает он так:
- Повтор с тем же ключом и тем же телом возвращает прежний ответ целиком, включая исходный код состояния, и второго документа не создаёт. Проверено: два одинаковых
POST /sales-ordersдали201и один и тот же заказ16401, тела ответов совпали дословно. - Тот же ключ с другим телом — отказ:
409 {"error":"idempotency_mismatch","message":"Idempotency-Key reused with a different request body"}
Это и есть правильное поведение при обрыве сети: повторяйте запрос с тем же ключом идемпотентности, пока не получите ответ. Новый ключ на повторе создаст второй документ.
Как отличить повтор от первичного вызова. По коду ответа — никак: повтор отдаёт код первичного вызова, поэтому у повторного POST /products в ответе снова 201, и это не второй товар. Признак повтора — заголовок ответа:
HTTP/1.1 201 Created
Idempotency-Replayed: true
Заголовок стоит только на повторе. У ответа, полученного действием прямо сейчас, его нет. Если вы ведёте счёт документов на своей стороне или показываете пользователю «создано», проверяйте этот заголовок, а не код ответа.
Ключ идемпотентности запоминается на пару «клиент + конкретная ручка». Один и тот же ключ на POST /sales-orders и на POST /manufacturing-orders друг другу не мешает.
4.3. Идентификаторы: в ответах строкой, в запросах — как удобнее
В ответах все идентификаторы — строки. "id": "15983", "item_variant_id": "20411", а не числа. Так сделано ради клиентов на JavaScript: JSON-число там читается как f64, и номер длиннее 2^53 теряет точность на обычном JSON.parse, ещё до того как клиент до него доберётся.
В телах запросов принимается и то, и другое. Оба тела ниже равносильны:
{"items": [{"item_variant_id": 20411, "quantity_delta": "50"}]}
{"items": [{"item_variant_id": "20411", "quantity_delta": "50"}]}
То есть значение, взятое из ответа, подставляется в следующий запрос как есть — приводить тип не нужно. В спецификации OpenAPI идентификатор объявлен как integer/int64: число остаётся основным представлением, строка принимается дополнительно, поэтому сгенерированный по спецификации клиент продолжает работать без правок.
Строка разбирается строго: только десятичная запись целого числа, окаймляющие пробелы отбрасываются. "20411.0", "20_411", "0x4fbb" и пустая строка отклоняются с 400:
400 {"error":"validation_failed","message":"Invalid JSON: invalid id \"20411.0\": expected an integer or its decimal string form"}
В строке запроса (?item_variant_id=20411) вопроса не возникает: там значение и так передаётся текстом.
4.4. X-Request-Id в ответе
Каждый ответ несёт заголовок X-Request-Id, и он же лежит в поле request_id тела любой ошибки. Если клиент прислал свой X-Request-Id (до 128 символов, латиница, цифры, дефис и подчёркивание), система вернёт именно его; иначе сгенерирует свой вида req_VulwVigxTmLwYFoqy41qI4.
Пишите этот идентификатор в свой журнал. Обращаясь в поддержку по конкретному сбою, называйте его — по нему находят конкретный запрос.
4.5. Постраничная выдача курсором
Все списки отдаются так:
{
"data": [ … ],
"next_cursor": "eyJ1cGRhdGVkX2F0IjoiMjAyNi0wNy0wNVQxNzo0MTowNS43OTM2NjBaIiwiaWQiOjUwNTN9"
}
limit— от 1 до 200, по умолчанию 50. За пределом —400 «limit must be between 1 and 200».cursor— непрозрачная строка. Не разбирайте её и не собирайте сами: содержимое курсора — внутреннее дело сервера и может измениться. Испорченный курсор —400 «Invalid cursor».- Следующая страница: тот же запрос плюс
?cursor=<next_cursor>. Проверено: страницы по два товара дали идентификаторы4887, 5053, затем5323, 5400. - Смещения (
offset, номера страниц) нет вовсе.
Порядок сортировки — (updated_at, id) там, где у документа есть время изменения (товары), и id там, где его нет (остатки, заказы покупателей, движения). В обоих случаях он устойчив при любом наборе фильтров.
Инкрементальная синхронизация. Списки принимают фильтры по времени (updated_after у товаров, created_after и created_before у заказов). Значение — ISO-8601: либо дата 2026-08-01 (начало суток по UTC), либо метка времени 2026-08-01T00:00:00Z. Другой формат отвергается с подсказкой:
400 {"error":"validation_failed","message":"updated_after must be ISO-8601: date `2026-08-01` (start of day, UTC) or timestamp `2026-08-01T00:00:00Z`"}
Значения фильтров-перечислений проверяются. Опечатка в статусе — не пустой список, а ошибка с перечнем допустимых значений: 400 «status must be one of: DRAFT, OPEN, DONE, CANCELLED». Это сделано нарочно: пустой ответ клиент прочитал бы как «таких заказов нет».
4.6. Формат ошибок
Один формат на все ответы 4xx и 5xx публичного API:
{
"error": "validation_failed",
"message": "customer_id references a non-existent row",
"request_id": "req_yh4Wgn4lvGkCYHQrP0OnbH",
"details": [ { "field": "customer_id", "code": "not_found" } ]
}
error— машинный код. Ветвитесь по нему, а не по тексту; точное значение берите из таблицы ниже.message— объяснение для человека. Текст может меняться без предупреждения.request_id— то же, что в заголовкеX-Request-Id.details— необязательный массив, бывает у ошибок проверки полей.
Коды, которые встречаются:
| HTTP | error | Когда |
|---|---|---|
| 400 | validation_failed | нет обязательного поля или заголовка, битый курсор, недопустимое значение фильтра, ссылка на несуществующую строку |
| 401 | unauthorized | нет ключа, ключ не по форме, неверный, отозванный или истёкший |
| 402 | SUBSCRIPTION_LIMIT | на бесплатном тарифе уже создано 30 живых товаров и материалов |
| 403 | forbidden | ключ годен, но нужного права у него нет |
| 404 | not_found | документа нет у этого клиента |
| 409 | idempotency_mismatch | тот же Idempotency-Key с другим телом |
| 409 | document_number_taken | присланный номер документа у этого клиента уже занят — номер надо изменить либо не присылать вовсе, тогда система выдаст следующий сама |
| 422 | inventory_period_closed | дата документа попала в закрытый учётный период, и запрос выходит за границу полей, которые в нём разрешено менять |
| 422 | код действия | учётный отказ: например manufacturing_order_transition_not_allowed, manufacturing_order_invalid_status |
| 429 | rate_limited | превышена частота запросов |
| 429 | document_quota_exceeded | исчерпан суточный предел документов ключа |
| 500 | internal_error | сбой на стороне сервера; подробности наружу не выдаются |
Разница между 400 и 422 содержательная: 400 — запрос неправильно составлен, 422 — запрос понят, но учёт так поступить не даёт (нет материалов, документ не в том статусе). Первое чинится исправлением запроса, второе — изменением данных.
5. Ограничения
5.1. Частота запросов
Три независимых ограничения:
| Ограничение | Порог | Что считает |
|---|---|---|
| Общее | 300 запросов за 60 секунд | пара «ключ + адрес», любые ручки |
| Строгое | 30 запросов за 60 секунд | пара «ключ + адрес», только записи и тяжёлые выборки: расчёт плана (§6.5) и сводные срезы (§6.7) |
| Страховочное | 1200 запросов за 60 секунд | один адрес, независимо от ключа |
При превышении:
429 Retry-After: <секунды>
{"error":"rate_limited","message":"Rate limit exceeded","request_id":"…"}
Заголовок Retry-After в ответе есть — ждите указанное число секунд и повторяйте. Повтор записи делайте с тем же Idempotency-Key.
Ждать отказа не обязательно: остаток квоты приезжает заголовками каждого успешного ответа, и по ним клиент тормозит себя сам.
RateLimit-Limit: 300
RateLimit-Remaining: 287
RateLimit-Reset: 60
RateLimit-Policy: "general";q=300;w=60, "expensive";q=30;w=60
Тройка Limit / Remaining / Reset описывает самое тесное из применённых к этому запросу ограничений — то, в которое вы упрётесь первым; на запись это строгое, на чтение общее. Полная раскладка по уровням — в RateLimit-Policy (q — размер квоты, w — окно в секундах). Страховочное ограничение адреса в заголовки не идёт: оно общее на всех клиентов за этим адресом, и его остаток ничего не говорит о вашем ключе.
5.2. Суточный предел документов на ключ
У каждого ключа есть предел созданных документов за скользящие сутки: 50, 200 или 1000, по умолчанию 200. У выпущенного ключа он не меняется — нужен другой предел, выпускается другой ключ.
429 Retry-After: <секунды>
{"error":"document_quota_exceeded",
"message":"Daily document quota of 200 documents per key is exhausted. The tenant owner has been notified by email.",
"request_id":"…"}
Отличайте его от rate_limited: первый говорит «повторите через секунду», второй — «на сегодня всё». Владельцу аккаунта при этом уходит письмо (не чаще раза в сутки).
Что расходует предел:
- создание товара, в том числе каждая карточка групповой загрузки каталога (§6.0.2): предел считает документы, а не запросы;
- создание заказа покупателя, заказа поставщику, прихода по нему, корректировки остатков, производственного задания;
- запуск и завершение производственного задания — каждое действие по единице. Это неочевидно: ключ с пределом 50, который только ведёт производство, проведёт 25 заданий, а не 50.
Что предел не расходует: любое чтение и любое изменение существующего документа (PATCH) — изменение документа не создаёт.
Расход списывается в той же транзакции, что и сам документ: если предел исчерпан, документа не остаётся, и «первым разом» предел не обойти.
5.3. Соединение живёт 180 секунд без запросов
Балансер закрывает соединение, по которому 180 секунд не приходило ни одного запроса. На число запросов эта уборка не смотрит: соединение, по которому идёт работа, живёт сколько угодно.
Закрытие штатное — с прощальным сообщением TLS close_notify, — поэтому нормальный HTTP-клиент переживает его незаметно: видит конец потока, открывает новое соединение и повторяет запрос сам. Специально ничего делать не нужно.
Настройка пригодится, только если ваш клиент держит соединения в пуле бессрочно и не умеет повторять запрос на оборванном соединении (так ведёт себя requests поверх urllib3 без Retry). Тогда поставьте время жизни простаивающего соединения меньше 180 секунд:
| Клиент | Что поставить |
|---|---|
Python httpx | httpx.Limits(keepalive_expiry=60) (по умолчанию 5 с — уже безопасно) |
Python requests / urllib3 | urllib3.util.Retry либо пересборка сессии |
Go net/http | Transport.IdleConnTimeout = 60 * time.Second |
Node undici | keepAliveTimeout: 60_000 |
Повторять запрос, оборвавшийся на закрытом соединении, безопасно: он не дошёл до сервера. Если полной уверенности нет — повторяйте запись с тем же Idempotency-Key (§ 4.2), тогда второго документа не появится в любом случае.
Правило одинаково действует на REST и на /mcp.
6. Сквозные сценарии
Ниже — реальные запросы и ответы, снятые на тестовом аккаунте. Идентификаторы, разумеется, будут свои.
6.0. Завести товар и сразу им воспользоваться
Нужные права: write:catalog.product.create, read:catalog.
Строки любого документа — заказа, корректировки, производственного задания — ссылаются не на товар, а на его вариант, полем item_variant_id. Карточка товара отдаёт варианты вместе с собой, поэтому ответ создания уже содержит всё нужное, чтобы оформить первый документ:
POST /api/external/v1/products
Idempotency-Key: 5f6f0c2a-9b3a-4d1e-8c77-2b0f1c9a4e10
{"title": "Сахар", "sku": "SUGAR-1", "cost_price": "42.50"}
201
{
"id": "15983", "title": "Сахар", "sku": "SUGAR-1",
…
"variants": [{"id": "20411", "title": "Сахар", "sku": "SUGAR-1", "cost_price": 42.5}]
}
variants[0].id — и есть item_variant_id; значение переносится в тело следующего запроса дословно, строкой, приводить его к числу не нужно (§4.3). У товара без атрибутов вариант ровно один; у товара с атрибутами их несколько, и порядок в списке — по возрастанию id, где первый вариант основной: именно его sku и себестоимость правит PATCH /products/{id}.
Найти уже заведённый товар, не листая каталог, помогает search — подстрока названия или артикула без учёта регистра:
GET /api/external/v1/products?search=сахар
% и _ в строке поиска — обычные символы, шаблоном они не работают. Пустая строка и строка из пробелов фильтром не считаются: вернётся весь каталог.
6.0.1. Справочники: единицы, категории, склады
Входные схемы принимают unit_id, category_id, storage_place_id и
manufacturing_location_id. Прочитать их можно тремя списками — они
постраничные, как и все остальные:
GET /api/external/v1/units # право read:catalog
GET /api/external/v1/categories # право read:catalog
GET /api/external/v1/storage-places # право read:stock
id каждой записи и есть тот идентификатор, который принимает тело запроса.
Иерархия категорий выражена полем parent_id (null у категории верхнего
уровня).
У места хранения важны три признака разрешённых на нём операций:
sales_allowed, manufacturing_allowed, purchase_allowed, плюс is_default
— основной склад учёта, который подставляется, когда склад в документе не
указан.
Пустой список складов — штатное состояние свежего учёта, а не ошибка. Пока
склад не заведён, остатки лежат без места хранения: в /stock у них
storage_place_id: null. Это значит «место хранения не указано». Никакого
неявного склада по умолчанию за этим null нет, у него нет ни id, ни названия,
и подставить его в manufacturing_location_id или в storage_place_id
невозможно — подставлять нечего.
manufacturing_allowed — ответ на отказ 422 «Нет склада с включённым производством». POST /manufacturing-orders подбирает склад именно по этому
признаку. Если во всём учёте нет ни одного склада с manufacturing_allowed: true, задание создать нельзя ни с каким телом запроса.
Завести склад можно тем же ключом — правом
write:inventory.storage_place.create:
POST /api/external/v1/storage-places
Idempotency-Key: 3a91…
{"title": "Цех"}
201
{"id": "1077", "title": "Цех", "legal_name": null, "address": null,
"sales_allowed": true, "manufacturing_allowed": true, "purchase_allowed": true,
"is_default": true, "position": 0}
Признаки операций по умолчанию включены; выключить ненужные можно, прислав
false. Первый склад учёта становится основным (is_default: true), и у
основного склада все три операции разрешены всегда — присланные признаки для
него не действуют. Это то же правило, что и в интерфейсе.
Правка и удаление склада наружу не выдаются: у склада есть остатки и документы,
и снять признак производства или снести склад под живым учётом может только
человек в разделе «Настройки → Места хранения». Признак manufacturing_allowed
у уже заведённого склада через API тоже не меняется.
6.0.2. Перенести каталог целиком: партиями по 200
Нужные права: те же, что у одиночного заведения — write:catalog.product.create.
Товар за товаром переносить каталог не нужно: строгое ограничение — 30 записей в минуту (§5.1), и тысяча позиций по одной уходила бы больше получаса. Групповая ручка принимает до 200 карточек одним запросом и расходует одну единицу того же ограничения. Каталог на тысячу позиций — пять запросов.
POST /api/external/v1/products/batch
Idempotency-Key: 7c1e0d44-6a2f-4b19-9a55-0e6c1b7f2d31
{"items": [
{"title": "Сахар", "sku": "SUGAR-1", "cost_price": "42.50"},
{"title": "Мука", "sku": "FLOUR-1", "cost_price": "31.00"}
]}
200
{
"created": 1, "failed": 1, "skipped": 0,
"results": [
{"index": 0, "title": "Сахар", "status": "created",
"product": {"id": "15983", "sku": "SUGAR-1",
"variants": [{"id": "20411", "sku": "SUGAR-1", "cost_price": 42.5}]},
"error": null},
{"index": 1, "title": "Мука", "status": "failed", "product": null,
"error": {"code": "validation_failed",
"message": "cost_price is required when can_make is false"}}
]
}
Что важно знать про эту ручку:
- Строка
items[i]описана вresults[i], и полеindexназывает её номер явно — сопоставлять с исходным файлом переноса можно по нему, не полагаясь на порядок. - Код
200говорит только о том, что партия принята и разобрана, а не о том, что легли все строки. Итог каждой строки смотрите вresults, сводку — вcreated/failed/skipped. - Отказ построчный. Одна отклонённая строка не отменяет заведённые: каждая карточка пишется своей транзакцией. Отклонённая строка не оставляет за собой половину карточки — её не создаётся вовсе.
- Статус
skippedозначает «не выполнялось». Партия останавливается, когда отказ относится ко всему учёту, а не к строке: исчерпан суточный предел документов ключа (§5.2) или лимит артикулов тарифа Free. При тарифном отказе весь групповой запрос отвечает402и не создаёт ни одной строки. При исчерпании предела документов остаток строк помечаетсяskippedс той же причиной; повторить нужно именно их. - Повтор с тем же
Idempotency-Keyи тем же телом вторых карточек не заводит — вернётся прежний ответ с заголовкомIdempotency-Replayed: true. Оборвалась сеть посреди партии — повторяйте запрос с тем же ключом. Тот же ключ с изменённым телом —409 idempotency_mismatch: чтобы дослать только непрошедшие строки, возьмите новый ключ. - Пустой
itemsи больше 200 строк —400, партия при этом не выполняется вовсе. - Суточный предел документов ключа групповая ручка не обходит: он считает
документы, а не запросы, и каждая карточка партии расходует единицу. Перенос
тысячи позиций требует ключа с пределом 1000 (§5.2) — иначе партии начнут
останавливаться на
document_quota_exceeded.
6.1. Узнать, что с заказом покупателя
Нужное право: read:sales.
Шаг 1 — найти заказ в списке. Фильтры: status, delivery_status, customer_id, created_after, created_before.
GET /api/external/v1/sales-orders?status=OPEN&limit=2
Authorization: Bearer zol_pat_live_…
200
{
"data": [
{
"id": "4821",
"number": "QA-SO-20260521113424",
"status": "OPEN",
"delivery_status": "NOT_SHIPPED",
"customer": {"id": "2809", "title": "Ателье «Стежок»", "country_code": "RU"},
"created_date": "2026-05-21T08:34:24Z",
"delivery_date": null,
"shipped_at": null,
"currency": "RUB",
"priority": 0,
"notes": null,
"total_amount": 56.0,
"tax_amount": 5.04,
"total_amount_with_tax": 61.04
}
],
"next_cursor": "eyJpZCI6NDk2OH0"
}
Шаг 2 — карточка заказа со строками:
GET /api/external/v1/sales-orders/4821
200
{
"id": "4821", "number": "QA-SO-20260521113424",
"status": "OPEN", "delivery_status": "NOT_SHIPPED",
…
"items": [
{
"id": "4886", "item_id": "4150", "item_variant_id": "4155",
"title": "ткань лён",
"quantity": 7.0, "price": 8.0, "tax_rate": 9.0,
"line_amount": 56.0, "line_tax_amount": 5.04, "line_amount_with_tax": 61.04
}
]
}
Как читать состояние: status — жизнь документа (OPEN, DONE), delivery_status — отгрузка (NOT_SHIPPED, PACKED, PARTIALLY_PACKED, PARTIALLY_SHIPPED, SHIPPED). Заказ бывает выполнен по деньгам и не отгружен, и наоборот, поэтому смотреть надо оба поля.
Суммы считаются тем же кодом, что и в интерфейсе и в печатной форме: total_amount — без НДС, tax_amount — сумма построчных, уже округлённых, величин налога, total_amount_with_tax — итог.
Несуществующий (или чужой) заказ: 404 {"error":"not_found","message":"Sales order 99999999 not found"}.
6.2. Хватит ли материалов
Нужные права: read:manufacturing, read:stock.
Шаг 1 — спецификация изделия. Она показывает, что и в каком количестве нужно на единицу выпуска:
GET /api/external/v1/recipes?limit=1
200
{"data":[{
"id": "1282", "item_id": "4884", "item_title": "Замена молнии",
"materials": [
{"item_variant_id": "4891", "sku": "ITM-4891", "title": "…Хлопок…", "quantity": 2.0},
{"item_variant_id": "4892", "sku": "ITM-4892", "title": "…Молния…", "quantity": 1.0}
],
"operations": [
{"operation_id": "766", "operation_title": "Раскрой ткани", "position": 1,
"duration": 1.5, "cost_per_hour": 400.0}
]
}], "next_cursor": "…"}
Шаг 2 — остатки:
GET /api/external/v1/stock?limit=200
200
{"data":[
{"id":"1267","item_id":"4150","item_variant_id":"4155","storage_place_id":"460",
"amount":157.0,"committed":9.0,"available":146.0,"quarantine_qty":2.0},
{"id":"1478","item_id":"4882","item_variant_id":"4887","storage_place_id":"460",
"amount":29.0,"committed":0.0,"available":29.0,"quarantine_qty":0.0}
], "next_cursor":"eyJpZCI6MTQ3OH0"}
Количеств в строке четыре, и отвечать клиенту надо не по первому из них:
| Поле | Что это |
|---|---|
amount | сколько лежит физически — вместе с обещанным и карантином |
committed | сколько уже обещано: открытые заказы покупателей и активные производственные задания |
quarantine_qty | принято физически, но выпускать нельзя (возврат по браку до проверки). Входит в amount |
available | сколько можно обещать прямо сейчас: amount − committed − quarantine_qty, не меньше нуля |
Обещать клиенту можно available, а не amount. Товар, собранный в отгрузку, физически ещё лежит на полке и входит в amount. Интегратор, который смотрит только на amount и видит «на складе 25», обещает покупателю все 25, а о нехватке узнаёт на отгрузке.
available — то же число, что показывает колонка «Доступно» в разделе «Склад → Остатки»: расчёт у интерфейса и у API общий, расхождения между ними быть не может. Резерв заказа, у которого место отгрузки не указано, раскладывается по строкам остатка варианта — поэтому committed одной строки может отличаться от ожидаемого, а сумма committed по всем строкам варианта всегда равна сумме открытых обещаний.
Два предупреждения, оба стоили времени при проверке:
- Спрашивайте остаток фильтром, а не обходом.
GET /stock?item_variant_id=4155вернёт остаток одного варианта,?item_id=4150— все варианты одного товара; заданные вместе, условия складываются. Листать раздел целиком, чтобы найти одну строку, не нужно. - Остаток привязан к месту хранения (
storage_place_id), а производственное задание считает доступность материалов по своей локации (manufacturing_location_id). Товар, лежащий на другом складе, для задания не существует. Складывая доступное количество, складывайте по нужному месту хранения, а не по всем.
Достоверный ответ на вопрос «хватит ли» даёт всё-таки не расчёт, а сама попытка запуска — см. §6.4.
6.3. Создать заказ покупателя
Заказ оформляется на покупателя, поэтому сначала нужен его customer_id. Взять его можно двумя путями, и оба доступны ключу:
- уже заведённый контрагент —
GET /api/external/v1/customersс правомread:sales. Список постраничный,idкаждой записи и естьcustomer_id. Найти контрагента, не листая справочник, помогаетsearch— подстрока названия, ИНН или адреса электронной почты без учёта регистра (GET /api/external/v1/customers?search=ромашка); тот же фильтр есть уGET /api/external/v1/suppliers; - новый контрагент —
POST /api/external/v1/customersс правомwrite:sales.customer.createи обязательнымIdempotency-Key. Обязательно только имя:
POST /api/external/v1/customers
Authorization: Bearer zol_pat_live_…
Idempotency-Key: 9c1f0b73-5a2e-4d68-b0c1-7f4e2a9d3b51
Content-Type: application/json
{"title": "ООО «Ромашка»", "inn": "7701234567", "email": "zakaz@romashka.ru"}
201
{"id": "2811", "title": "ООО «Ромашка»", "inn": "7701234567", "email": "zakaz@romashka.ru", …}
ИНН уникален в пределах учёта: повтор отвечает 409, а не заводит второго контрагента с тем же ИНН. Опечатку в реквизитах правит PATCH /api/external/v1/customers/{id} с правом write:sales.customer.update: присланные поля перезаписываются, отсутствующие сохраняют прежнее значение.
Имя покупателя —
title, имя поставщика —name. Это унаследованная неровность контракта, и в версии 1 мы её не чиним: переименование поля сломало бы уже выпущенных клиентов. Интеграция, написанная по аналогии («у покупателя получилось — повторю то же для поставщика»), спотыкается об это первым делом. ТелоPOST /suppliers—{"name": "ООО «Поставщик»", …}, телоPOST /customers—{"title": "ООО «Ромашка»", …}. Если любая из двух ручек отвечает400, проверяйте имя поля раньше всего остального.
Дальше — сам заказ. Нужное право: write:sales.order.create (плюс read:sales, если потом читать).
POST /api/external/v1/sales-orders
Authorization: Bearer zol_pat_live_…
Idempotency-Key: 5e4b8a12-0a1e-4c7b-9f0e-2a6f3d1c7b44
Content-Type: application/json
{
"customer_id": 2809,
"items": [
{"item_variant_id": 4155, "quantity": 3, "price": 120, "tax_rate": 20}
],
"notes": "Заказ из интеграции"
}
201
{
"id": "16401",
"number": "ЗК-1",
"status": "OPEN",
"delivery_status": "NOT_SHIPPED",
"customer": {"id": "2809", "title": "Ателье «Стежок»", "country_code": "RU"},
"created_date": "2026-08-13T04:03:12.132907Z",
"notes": "Заказ из интеграции",
"items": [{
"id": "16661", "item_id": "4150", "item_variant_id": "4155",
"title": "ткань лён",
"quantity": 3.0, "price": 120.0, "tax_rate": 20.0,
"line_amount": 360.0, "line_tax_amount": 72.0, "line_amount_with_tax": 432.0
}],
"total_amount": 360.0, "tax_amount": 72.0, "total_amount_with_tax": 432.0
}
Что нужно знать:
- Строка заказа — плоская: вариант товара, количество, цена без НДС и, по желанию, ставка НДС в процентах и место хранения. Отсутствие
tax_rateи ноль означают «налога нет». - Заказ без строк не создаётся:
400 «items must contain at least one line». Заготовка без товара — не документ. - Номер выдаётся системой, тем же счётчиком, что и в интерфейсе, если не прислать
numberявно. Дата документа безcreated_date— «сейчас». - Ссылка на чужую или несуществующую строку отвергается с указанием поля:
400 {"error":"validation_failed","message":"customer_id references a non-existent row","details":[{"field":"customer_id","code":"not_found"}]}.
Изменение заказа — PATCH /api/external/v1/sales-orders/{id} с правом write:sales.order.update, тоже с Idempotency-Key. Менять можно только заказ в статусе OPEN: завершённый уже списал остатки. Правила частичного тела:
- присланное поле заменяет значение, отсутствующее — сохраняется как есть;
- снять уже заполненное поле нельзя:
nullи отсутствие поля означают здесь одно и то же — «не трогать»; itemsзаменяет набор строк целиком: строки, которых нет в присланном массиве, из заказа удаляются. Не хотите трогать строки — не присылайтеitemsвовсе.
Закрытый учётный период сужает и заведение, и правку. Учётный период закрывается в «Настройках» и по умолчанию сдвигается сам 1-го числа каждого месяца так, что открытыми остаются текущий и предыдущий месяц. Правила одни и те же в интерфейсе и в API:
- завести заказ с
created_dateв закрытом периоде нельзя —422 inventory_period_closed, документа не остаётся; - у уже заведённого заказа, чья
created_dateпопала в закрытый период, снаружи меняются толькоdelivery_date,priorityиnotes. Это плановые и справочные поля: на остатки и себестоимость они не влияют, а длинная B2B-партия живёт месяцами и переживает границу периода, оставаясь в работе; customer_id,currencyиitemsу такого заказа заморожены. Запрос с любым из них отклоняется целиком —422 inventory_period_closed— и разрешённые поля из того же тела тоже не применяются: частичное применение вернуло бы200на запрос, сделавший не то, о чём просили;- отгрузка и отмена заказа этой границей не задеты: они датируются днём события, а не датой документа, и упрутся в период только если закрыт сам сегодняшний день.
Отгрузка заказа. Заведённый заказ сам по себе остатки не двигает: пока он не отгружен, товар числится на складе. Провести продажу можно двумя ручками, каждая со своим правом и своим Idempotency-Key.
Построчная отгрузка — POST /api/external/v1/sales-orders/{id}/ship, право write:sales.order.ship:
POST /api/external/v1/sales-orders/4821/ship
Idempotency-Key: 7c1e0f4a-2b55-4a0e-9d31-0f7a2c8b6e41
{
"event_type": "DELIVERED",
"items": [{ "sales_order_item_id": 9312, "quantity": "2" }]
}
sales_order_item_id— этоitems[].idиз карточки заказа, а неitem_variant_id: одна и та же позиция может стоять в заказе дважды с разных складов, и отгрузка адресуется строке.event_type:DELIVERED— товар ушёл, остаток уменьшается;PACKED— позиция собрана и зарезервирована, склад не трогается. Поле можно не присылать, тогдаDELIVERED.- Отгрузить больше неотгруженного остатка строки нельзя —
422 sales_order_shipment_not_allowed. - Когда отгружены все строки, заказ закрывается сам: отдельно вызывать закрытие не нужно.
Отгрузка остатка с закрытием заказа — POST /api/external/v1/sales-orders/{id}/complete, право write:sales.order.complete, тело пустое:
POST /api/external/v1/sales-orders/4821/complete
Idempotency-Key: 3f9d5c71-8a02-4c6b-b1e7-5d40a9c2f8b3
{}
Списывается только то, что ещё не отгружено: уже отгруженное вторым списанием не повторяется. Ответ обеих ручек — карточка заказа целиком, по ней и сверяйте status и delivery_status.
Учётный период обе ручки проверяют по дню отгрузки, а не по дате документа: заказ, заведённый в июле и отгружаемый в сентябре, отгружается — движение датируется сегодняшним числом. Отказ 422 inventory_period_closed приходит, только если закрыт сам сегодняшний день. Из-под гейта выведены две команды, которые склад не двигают вовсе: /ship с event_type = PACKED (сборка — это резерв, а не движение) и /complete у заказа, весь остаток которого уже отгружён построчно (такая команда только закрывает документ).
Две одновременные команды на один и тот же остаток второй отгрузки не создают: остаток перечитывается под блокировкой строки заказа, и опоздавшая команда отвечает 409 conflict с текстом про изменившийся остаток. Повторять такой запрос вслепую не надо — сначала перечитайте заказ.
Оба права нарезаны по действию сознательно: ключ, которому поручено подтверждать отгрузку, не должен уметь заводить заказы, и наоборот. Отгружать можно только заказ в статусе OPEN — закрытый отвечает 422 sales_order_invalid_status.
Вернуть закрытый заказ в работу ключом нельзя — как и снять отмену. Это действие есть только в интерфейсе, права на него не существует, и появляться оно не планируется: возврат в работу удаляет уже проведённые складские движения, а такое решение принимает человек. В интерфейсе оно проверяет учётный период по датам удаляемых движений: если хотя бы одно из них попало в закрытый период, команда отказывает тем же inventory_period_closed и не удаляет ничего. Отказ отгрузки внутри интерфейса и снаружи — один и тот же код и один и тот же текст: правило живёт в учётном слое, а не в ручке.
6.3.1. Отменить заказ покупателя
Покупатель отказался от сделки — POST /api/external/v1/sales-orders/{id}/cancel, право write:sales.order.cancel. Причина обязательна:
POST /api/external/v1/sales-orders/4821/cancel
Idempotency-Key: 9b2e5a10-7c34-4f8d-91a6-0e7b3c5d2f14
{ "reason": "Покупатель отказался от остатка" }
Заказ переходит в status = CANCELLED, в нём сохраняются cancel_reason, cancelled_at, cancelled_by_user_id и cancelled_by_user_name. Автором считается пользователь, выпустивший ключ: действие агента остаётся связано с человеком, от имени которого ключ был выдан. Такой заказ выпадает из потребностей, резервов и подбора под производство, но из учёта не исчезает и виден фильтром ?status=CANCELLED.
Отмена не трогает склад — ничем и никак. Ни одного движения товара, ни одной снятой партии. Уже отгруженное остаётся отгруженным: delivery_status сохраняется как был, и «отгружено 3 из 10» по отменённому заказу — правда, а не сбой. Если товар физически едет назад, это отдельный документ «Возврат покупателя» — он оформляется ключом, см. §6.3.2. Тем отмена и отличается от удаления, которого у ключа нет: удаление означает «документа не должно было быть» и откатывает отгрузку, возвращая товар на остаток.
Отменённый заказ не отгружается и не правится: /ship, /complete и PATCH отвечают 422 с текстом про отмену. Повторная отмена ничего не меняет — причина, дата и автор первой отмены сохраняются, а не переписываются присланными.
Снять отмену ключом нельзя. «Вернуть в работу» есть только в интерфейсе, и права на это действие не существует. Асимметрия намеренная: агент вправе остановить сделку, воскресить её может только человек. Удаления документов у публичного API по-прежнему нет — ошибочно заведённый заказ удаляет человек в интерфейсе.
6.3.2. Оформить возврат покупателя
Товар физически едет назад — это документ «Возврат покупателя», а не отмена заказа (§6.3.1 объясняет разницу: отмена склад не трогает вообще). Права два, нарезаны по действию:
write:sales.return.create— оформить документ. Остаток при этом не двигается: коробка ещё в пути.write:sales.return.receive— принять товар на склад. Вот здесь остаток и растёт.
Отдельного права на чтение возвратов нет: GET /sales-returns, GET /sales-returns/{id} и справочник причин GET /sales-return-reasons открывают то же read:sales, что и заказы покупателей. Возврат — часть истории сделки, и делить их чтение между двумя правами незачем.
Разделены они не ради симметрии с интерфейсом: между заявкой покупателя и приездом коробки проходят дни, и всё это время документ обязан существовать, не завышая остаток. Ключу, который заводит возвраты по заявкам, право приходовать товар, которого никто не видел, не нужно.
Причины возврата берутся из системного справочника — он общий для всех учётов, и своих значений в нём не бывает:
GET /api/external/v1/sales-return-reasons
200
{"data": [
{"id": 1, "name": "Не подошёл размер"},
{"id": 8, "name": "Не подошёл цвет"},
{"id": 9, "name": "Не подошёл фасон"},
{"id": 2, "name": "Брак или повреждение"},
{"id": 3, "name": "Не тот товар"},
{"id": 4, "name": "Не соответствует описанию"},
{"id": 5, "name": "Передумал, не понравилось"},
{"id": 6, "name": "Гарантия"},
{"id": 7, "name": "Другое"}
]}
Номера значений фиксированы и одинаковы на всех окружениях, поэтому их можно зашивать в интеграцию. Порядок значений в ответе — порядок справочника, а не порядок номеров: близкие по смыслу причины стоят рядом, и новое значение появляется в середине списка, а не в конце. Записи в справочник наружу нет: новое значение появляется только вместе с обновлением системы.
Шаг 1 — оформить возврат по заказу. Строка адресуется строке заказа (items[].id из карточки заказа), а не варианту товара:
POST /api/external/v1/sales-returns
Authorization: Bearer zol_pat_live_…
Idempotency-Key: 1d7b3c92-5f40-4a11-8c6e-73b0f2a9d514
Content-Type: application/json
{
"sales_order_id": 4821,
"storage_place_id": 1077,
"refund_amount": "200.00",
"items": [
{"sales_order_item_id": 9312, "quantity": "2", "reason_id": 9, "reason_note": "велико в бёдрах"}
]
}
201
{
"sales_return": {
"id": "5104", "number": "В-7", "status": "NOT_RETURNED", "source_type": "SALES_ORDER",
"sales_order_id": "4821", "sales_order_number": "ЗК-1", "customer_id": "2809",
"created_date": "2026-08-24T09:12:44.108Z", "return_date": null,
"storage_place_id": "1077", "refund_amount": 200.00
},
"items": [{
"id": "8811", "sales_order_item_id": "9312", "item_variant_id": "4155",
"sku": "ITM-4155", "title": "ткань лён",
"quantity": 2.0, "unit_price": 120.0,
"reason": "Не подошёл фасон", "reason_id": 9, "reason_note": "велико в бёдрах",
"batch_id": null, "storage_place_id": null, "quarantine": false
}]
}
Что нужно знать про оформление:
item_variant_idприсылать не нужно: у возврата от заказа он берётся из строки заказа. Прислать его можно, но тогда за расхождение с заказом отвечаете вы.- Цена строки берётся из заказа, если не прислан
unit_price. По ней считается сумма возврата. quarantine: trueкладёт вещь на склад, но не в продажу: такое количество не попадает вavailableраздела/stock(§6.2). Так принимают возврат по браку до проверки.- Причина строки выбирается из справочника.
reason_id— номер значения изGET /api/external/v1/sales-return-reasons,reason_note— свободное уточнение своими словами, до 500 символов. Уточнение без выбранной причины не сохраняется: у возврата без заказа это ошибка обязательности, у возврата по заказу снятая причина уносит уточнение с собой. - Строковый
reasonна запись устарел, но работает. Прислали только его — текст ложится вreason_note, а причиной ставится «Другое» (reason_id= 7). Ошибкой это не является, версию API мы ради этого не поднимали. Прислали иreason_id, и строку — выигрываетreason_id, строка игнорируется. - На чтение
reasonостался строкой и вычисляется: это имя причины из справочника,null— причина не выбрана. Ваш дословный текст живёт вreason_note. - Возврат без заказа — маркетплейс, розница, «покупатель пришёл с коробкой без номера». Тогда
sales_order_idне присылается вовсе, зато обязательныcustomer_id,item_variant_idв каждой строке и причина по каждой строке (reason_id): у документа без заказа нет ничего, что объяснило бы его появление через месяц. Вид источника задаётся полемsource_type:MANUAL,MARKETPLACE_PVZилиRETAIL.
Шаг 2 — принять товар на склад, когда он приехал. Тело пустое: что и сколько приходовать, ручка берёт из строк самого документа.
POST /api/external/v1/sales-returns/5104/receive
Idempotency-Key: 6a2c9f13-4b8e-4d05-9a71-0c3e8b5d7f26
{}
Ответ — та же карточка возврата: status становится RETURNED, заполняется return_date, а остаток по варианту растёт на возвращённое количество. Движение видно в журнале: GET /stock-movements?entity_type=SALES_RETURN, entity_id — id возврата.
Что нужно знать про приёмку:
- Повторный вызов с тем же
Idempotency-Keyвозвращает прежний ответ и второй раз товар не приходует. - Уже принятый возврат отвечает
422 sales_return_invalid_status— по этому коду агент отличает «уже сделано» от «сделать нельзя». - Место хранения берётся из шапки (
storage_place_id) либо из строки. Не указано нигде —422 sales_return_receive_not_allowed: класть товар некуда. - Закрытый учётный период приёмку запрещает, как и любое другое движение по складу.
Отменить, изменить или удалить возврат ключом нельзя. Ошибочно оформленный документ и откат приёмки — работа человека в интерфейсе, как и у остальных документов (§1).
6.4. Запустить производственное задание
Нужные права: write:manufacturing.order.create, write:manufacturing.order.start, write:manufacturing.order.complete — каждое отдельно.
Шаг 0 — убедиться, что в учёте есть склад с включённым производством. На свежем учёте складов нет вовсе, и задание отвечает:
422
{"error":"unprocessable_entity",
"message":"Нет склада с включённым производством. Склад производственного задания берётся из мест хранения с признаком «Производство». Включите этот признак у места хранения в разделе «Настройки → Места хранения»; если мест хранения нет вовсе, заведите первое там же либо через публичное API — POST /api/external/v1/storage-places с правом write:inventory.storage_place.create"}
Проверка и лечение — одним ключом, §6.0.1: GET /storage-places показывает
manufacturing_allowed у каждого склада, POST /storage-places заводит первый.
Руками остаётся ровно один случай: склады есть, но производство выключено на
всех — снять этот запрет может только человек в разделе «Настройки → Места
хранения», потому что признак у заведённого склада через API не меняется.
Шаг 1 — создать задание. Материалы система соберёт сама по спецификации изделия, присылать их не нужно:
POST /api/external/v1/manufacturing-orders
Idempotency-Key: 7c1f…
{"item_variant_id": 4893, "quantity": 1}
201
{
"id": "9205", "number": "ПЗ-49", "status": "NOT_STARTED", "type": "MAKE_TO_STOCK",
"item_variant": {"id": "4893", "sku": "ITM-4893", "title": "Юбка миди трикотажная"},
"quantity": 1.0, "actual_quantity": null,
"manufacturing_location_id": "1077",
"items": [
{"id": "21841", "item_variant_id": "4894", "sku": "ITM-4894", "title": "ткань хлопок", "quantity": 2.0},
{"id": "21842", "item_variant_id": "4895", "sku": "ITM-4895", "title": "краска для ткани", "quantity": 1.0},
{"id": "21843", "item_variant_id": "5325", "sku": "ITM-5325", "title": "нитки армированные 45ЛЛ", "quantity": 1.0}
],
"started_at": null, "completed_at": null
}
Шаг 2 — запустить. Тело запроса — пустой объект {}, у этих ручек полей нет: они меняют только статус, читая задание из базы сами.
POST /api/external/v1/manufacturing-orders/9205/start
Idempotency-Key: 7c20…
{}
Если материалов не хватает, ответ называет каждый дефицит поимённо:
422
{"error":"manufacturing_order_transition_not_allowed",
"message":"Недостаточно материалов:\nткань хлопок: нужно 2.00, доступно 0\nкраска для ткани: нужно 1.00, доступно 0\nнитки армированные 45ЛЛ: нужно 1.00, доступно 0"}
Это и есть самый честный ответ на вопрос «хватит ли материалов». Обратите внимание: доступность считается по локации задания (manufacturing_location_id), поэтому материал, лежащий на другом складе, показывается как доступно 0.
Поправить нехватку можно корректировкой остатков — правом write:inventory.adjustment.create, явно назвав место хранения:
POST /api/external/v1/stock-adjustments
Idempotency-Key: 7c21…
{"title": "Приход материалов в цех",
"items": [
{"item_variant_id": 4894, "storage_place_id": 1077, "quantity_delta": 4},
{"item_variant_id": 4895, "storage_place_id": 1077, "quantity_delta": 2},
{"item_variant_id": 5325, "storage_place_id": 1077, "quantity_delta": 2}
]}
201
{"adjustment": {"id": "747", "title": "Приход материалов в цех", "date": "2026-08-13T04:04:20.323440Z", "reason": null},
"items": [
{"item_variant_id": "4894", "storage_place_id": "1077", "quantity_delta": 4.0, "amount_after": 4.0},
{"item_variant_id": "4895", "storage_place_id": "1077", "quantity_delta": 2.0, "amount_after": 2.0},
{"item_variant_id": "5325", "storage_place_id": "1077", "quantity_delta": 2.0, "amount_after": 2.0}
]}
Величина знаковая: недостача отрицательна, излишек положителен, ноль отвергается (400 «items[0].quantity_delta must not be zero»). Ответ сразу отдаёт остаток после применения (amount_after), так что перечитывать /stock не нужно. Без storage_place_id корректировка ложится в место хранения по умолчанию, а оно может не совпадать с локацией задания — тогда запуск по-прежнему скажет «доступно 0». Эта ловушка встретилась при проверке буквально.
После корректировки запуск проходит:
POST /api/external/v1/manufacturing-orders/9205/start → 200
{… "status": "IN_PROGRESS", "started_at": "2026-08-13T04:04:20.569618Z" …}
Повторный запуск уже запущенного задания — учётный отказ с названием текущего статуса:
422 {"error":"manufacturing_order_invalid_status",
"message":"Задание ПЗ-49 сейчас в статусе IN_PROGRESS; это действие допустимо из NOT_STARTED или BLOCKED."}
Шаг 3 — завершить:
POST /api/external/v1/manufacturing-orders/9205/complete
Idempotency-Key: 7c22…
{}
200
{… "status": "DONE", "started_at": "2026-08-13T04:04:20.569618Z", "completed_at": "2026-08-13T04:04:24.426069Z" …}
Списание материалов и оприходование выпуска навешены на смену статуса — тем же кодом, которым это делает интерфейс. Отдельного вызова «списать материалы» нет и не нужно.
Завершение фиксирует фактическую себестоимость выпуска, и она приезжает тем же ответом — в поле actual_cost. Это же поле лежит в карточке GET /manufacturing-orders/{id} и в строках списка, так что дочитать число можно и позже:
{… "status": "DONE",
"actual_quantity": 10.000,
"actual_cost": {"materials": 2000.00, "subassemblies": 500.00,
"operations": 4500.00, "total": 7000.00} …}
Из чего состоит число:
materials— сумма «фактически списанное количество × цена строки на момент завершения» по строкам материалов, у которых нет собственной спецификации;subassemblies— то же по строкам, у которых спецификация есть, то есть по полуфабрикатам собственного изготовления;operations— сумма фактической стоимости операций задания, а где её не вводили — плановой (ставка часа × норма времени × количество выпуска);total— их сумма. Это стоимость всего выпуска, а не единицы: делить наactual_quantityнужно у себя.
Число — снимок на момент завершения, а не текущий расчёт: цены и ставки потом меняются, а исторический выпуск от этого дорожать не должен. Пересчитывать себестоимость самостоятельно по списаниям не нужно — свой расчёт разойдётся с тем, что показывает продукт.
actual_cost равен null, пока задание не завершено. У завершённого задания null может оказаться отдельное слагаемое — это значит «посчитать не удалось»: у строки материала нет зафиксированной цены либо у операции нет ни фактической, ни плановой стоимости. Отсутствие самих строк или операций даёт ноль, а не null. Если null хотя бы одно слагаемое, total тоже null.
Ход работ по операциям виден отдельной ручкой:
GET /api/external/v1/manufacturing-orders/9205/tasks
200
{"data":[{"id":"11017","operation_id":"766","operation_title":"Раскрой ткани",
"operation_type":"PROCESS","status":"NOT_STARTED","position":1,
"duration":1.5,"cost_per_hour":400.0,"total_cost":1200.0,
"resource_id":null,"actual_time_minutes":null,"actual_cost_cents":null}]}
6.5. Что заказать, чтобы не встать
Нужное право: read:plan.
Это ответ на вопрос «чего не хватит и что с этим делать», посчитанный продуктом. Считать дефицит самостоятельно — по остаткам, открытым заказам и истории отгрузок — не нужно и вредно: собственный расчёт разойдётся с тем, что видит человек в разделе «План», и интеграция начнёт заказывать не то и не тогда.
Основная ручка одна:
GET /api/external/v1/plan/replenishment?scope=materials
200
{"data":[{
"item_id":"4884","item_variant_id":"4891","code":"ITM-4891",
"name":"Хлопок, белый","kind":"material","unit":"м","allows_fractional":true,
"storage_place":{"id":"460","title":"Основной склад"},
"supplier":{"id":"312","name":"ООО «Ткани Оптом»","inn":"7701234567","kpp":null},
"below_safety_date":"2026-09-04T00:00:00+00:00",
"forecasted_stock":-18.0,"recommended_qty":60.0,
"expected_qty":40.0,"expected_overdue_qty":0.0,
"expected_orders":[{"purchase_order_id":"1791","number":"ЗП-179",
"eta":"2026-09-10T00:00:00+00:00","qty":40.0,"overdue":false}],
"delayed_by_days":6,
"purchase_cost":976.95,"unit_price":16.28,"currency":"RUB",
"lead_date":"2026-08-28T00:00:00+00:00","lead_time_days":7,
"urgency":"asap-red","action":"buy",
"transfer_source":null,"has_recipe":false
}],
"planning_horizon_weeks":16,"forecast_history_weeks":12,
"total":1,"truncated":false}
Как это читать:
- Строка приходит только на позицию, которой не хватит. Всё, чего хватает, в ответе отсутствует — пустой
dataозначает «дефицитов в горизонте нет», а не «расчёт не сработал». actionговорит, что делать:buy— оформить заказ поставщику (POST /purchase-orders),make— запустить производственное задание (§6.4),transfer— переместить с другого места хранения; откуда именно, названо вtransfer_source. Перемещения через API нет, его делает человек.recommended_qtyравный нулю — не ошибка. Дефицит реален, но уже целиком покрыт открытым заказом изexpected_orders. Заказывать по такой строке ничего не надо; строка остаётся, чтобы было видно, чем именно он покрыт.urgencyрасставляет очередь:asap-red— срок размещения заказа уже упущен,asap-yellow— упустите в ближайшую неделю,scheduled— по плану. Строки отсортированы по срочности, самые горящие идут первыми.lead_date— крайний срок разместить заказ, аbelow_safety_date— день, когда остаток провалится ниже страхового запаса. Разница между ними и есть срок поставкиlead_time_daysиз карточки позиции.expected_overdue_qtyиз рекомендации не вычитается. Поставка, чей срок прошёл без приёмки, остатком не считается: иначе рекомендация занижалась бы на товар, которого нет.action: "make"сhas_recipe: falseозначает «выпустить нечем»: у изделия нет активной спецификации, и завести её через API нельзя (§9). Пока её не заполнит человек, производственное задание соберётся без материалов.
Полезные сужения: ?storage_place_id=460 — дефициты одного склада, ?supplier_search=ткани — всё, что берётся у одного контрагента, одной поставкой, ?horizon=8 — только ближайшие восемь недель, ?forecast_demand=false — только подтверждённые документы, без статистического прогноза по истории отгрузок.
Вторая ручка нужна реже — когда важен не итог, а сама динамика:
GET /api/external/v1/plan/forecast?scope=materials&granularity=weekly
200
{"data":[{"item_variant_id":"4891","code":"ITM-4891","name":"Хлопок, белый",
"kind":"material","stock":12.0,"stock_unit":"м","safety_stock":50.0,
"projected_stock":[12.0,-6.0,-24.0],"receipts":[0.0,0.0,0.0],
"spends":[0.0,18.0,18.0],"overdue_receipts":0.0}],
"buckets":[{"index":0,"label":"34 нед.","starts_at":"…","ends_at":"…"}],
"current_week":34,"granularity":"weekly","total":1,"truncated":false}
projected_stock[i] — расчётный остаток на конец периода buckets[i], накопительно; receipts и spends показывают движение внутри периода отдельно, потому что равные приход и расход в одном периоде гасят друг друга в балансе. Период, где projected_stock опустился ниже safety_stock, и есть нехватка.
Две особенности обеих ручек:
- Курсора здесь нет. Это снимок расчёта на момент запроса, а не выборка строк таблицы: между страницами он поменялся бы целиком. Размер ответа задаёт
limit(по умолчанию 200, максимум 1000), а полеtruncatedчестно скажет, что показано не всё; сужайте фильтрами, а не листайте. - Расчёт тяжёлый — он обходит заказы, производство и историю отгрузок целиком, поэтому обе ручки расходуют строгую квоту дорогих операций (§5.1). Дёргать их в цикле по позициям не нужно: один запрос отдаёт весь перечень сразу.
6.6. Что просрочится в ближайший месяц
Нужное право: read:stock.
GET /stock отвечает на вопрос «сколько лежит», но не на вопрос «чему из этого скоро конец». Количество в разрезе партий и сроков годности отдаёт отдельная ручка:
GET /api/external/v1/batches?expires_before=2026-09-30&limit=200
200
{"data":[{
"id":"3391","item_id":"4882","item_variant_id":"4887",
"batch_number":"PO-2026-08-14-001",
"manufacture_date":null,"expiry_date":"2026-09-30","received_date":"2026-08-14",
"quantity":4.0,"initial_quantity":10.0,
"source_type":"PURCHASE_ORDER","source_id":"11894","locked_out":false
}], "next_cursor":null}
Фильтры: expires_before и expires_after задают окно срока годности, item_id и item_variant_id сужают до товара или варианта, batch_number ищет партию по точному номеру. Заданные вместе, условия складываются.
Четыре вещи, о которые спотыкаются:
- По умолчанию отдаются только партии с ненулевым остатком. Израсходованная партия остатком не является, и в ответе её нет. Полную историю партий, включая расходованные, возвращает
include_depleted=true. - Партия без срока годности в окно не попадает.
expiry_date: null— это не «просрочено» и не «истекает завтра», а «срока годности не существует»: у ткани партия означает крашение, у метизов — плавку, и портиться там нечему. Ниexpires_before, ниexpires_afterтакие партии не выбирают; чтобы увидеть их, спрашивайте без фильтра по дате. - Партии заводятся только у товара с включённым партионным учётом. Флаг ставит человек в карточке товара; у обычного товара приход партию не создаёт, и
batch_numberсexpiry_date, отправленные в приёмку, в учёт не попадут. Проверить просто: оформили приход, аGET /batches?item_variant_id=…пуст — значит, у товара выключен партионный учёт. - Партия заведена на вариант изделия, а не на полку. Места хранения в этом ответе нет; количество по местам хранения отдаёт
GET /stock. Сумма по партиям варианта и сумма по его местам хранения сходятся, а разложить одну по другой публичное API пока не даёт.
Поле locked_out: true означает, что партия уже выведена из оборота — просрочена и в расход не уйдёт. Такая партия числится остатком, но производству и отгрузке недоступна.
6.7. Собрать отчёт за период одним запросом
Нужные права: read:sales для продаж, read:manufacturing для выпуска.
Ключ с этими правами видит себестоимость и маржу. Срез продаж отдаёт cogs и profit, срез выпуска — materials_cost и unit_cost, и всё это выводится из закупочной себестоимости варианта. Выдавая ключ с read:sales или read:manufacturing стороннему сервису — витрине магазина, маркетплейсу, подрядчику, — вы отдаёте ему и себестоимость. Если этого не нужно, права не выдавайте: сузить одно право до «выручка без себестоимости» пока нельзя.
Листание списков отвечает на вопрос «какие были документы», но не на вопрос «сколько всего продали за месяц». Собирать итог вычиткой всех заказов постранично дорого: страница отдаёт до 200 записей, а строгая квота — 30 запросов в минуту, и на большом учёте отчёт за год упирается в неё раньше, чем досчитывается. Для этого есть два сводных среза: они считают итог на нашей стороне и отдают его одним ответом.
GET /api/external/v1/reports/sales-summary?from=2026-01-01&to=2026-06-30&group_by=month
200
{"data":[{
"period_start":"2026-01-01","period_end":"2026-01-31",
"item_id":"4891","item_name":"Платье льняное",
"quantity":18.000,"revenue":154706.00,"cogs":91200.00,"profit":63506.00,
"returned_quantity":2.000,"returned_revenue":9600.00
}],
"totals":{"quantity":18.000,"revenue":154706.00,"cogs":91200.00,"profit":63506.00,
"returned_quantity":2.000,"returned_revenue":9600.00},
"from":"2026-01-01","to":"2026-06-30","group_by":"month","total":1,"truncated":false}
GET /api/external/v1/reports/production-summary?from=2026-01-01&to=2026-06-30&group_by=total
200
{"data":[{
"period_start":"2026-01-01","period_end":"2026-06-30",
"item_id":"4891","item_name":"Платье льняное",
"orders":9,"quantity":23.000,
"materials_cost":84000.00,"operations_cost":27600.00,
"total_cost":111600.00,"unit_cost":4852.17,"minutes":1380.0
}],
"totals":{"orders":9,"quantity":23.000,"materials_cost":84000.00,
"operations_cost":27600.00,"total_cost":111600.00,
"unit_cost":4852.17,"minutes":1380.0},
"from":"2026-01-01","to":"2026-06-30","group_by":"total","total":1,"truncated":false}
Общее у обоих срезов:
fromиto— календарные даты, обе включительно, зона Europe/Moscow. Отметку времени срез не принимает: границы периода трактуются сутками, и обещать точность до часа было бы враньём. Период — не длиннее 366 суток считая обе границы (полный високосный год проходит целиком); более длинный отклоняется с400, а не урезается молча.group_byзадаёт нарезку:month(по умолчанию),week,dayилиtotal— весь период одной строкой на товар. Строка всегда описывает один товар в одном периоде, аperiod_startиperiod_endназывают его границы. Первый и последний периоды среза обрезаются по запрошенному окну.- Разрез идёт по товару, а не по варианту. Варианты одного товара сложены в одну строку — тот же уровень, на котором построены разделы аналитики продукта.
totalsсчитается по всему срезу, до обрезки поlimit. Поэтому итог остаётся верным и в усечённом ответе;truncated: trueговорит, что показаны не все строки, аtotal— сколько их всего.- Числа совпадают с разделами аналитики продукта на том же учёте за тот же период: срез считается по тем же данным, а не пересчитывается отдельно. Расхождение — это дефект, о котором стоит написать в поддержку.
- Оба среза — дорогие операции. Они обходят все документы периода и расходуют строгую квоту (§5.1), а не общую. Дёргать их в цикле по товарам не нужно: разрез по товарам уже внутри ответа.
- У обхода есть предел времени — 15 секунд. Срез, не уложившийся в него, отвечает
400с просьбой сузить выборку, а не висит. Помогают короткий период,group_by=monthилиtotalвместоdayи фильтры по товару либо категории.
Чем срезы отличаются:
- Продажи считаются по дате отгрузки, и в них попадают только завершённые заказы. Проведённый возврат входит в свой период со знаком минус, поэтому
revenueиquantityуже очищены от возвратов, аreturned_revenueиreturned_quantityпоказывают, сколько именно вычтено. Фильтры:storage_place_id,item_id,category_id,country_code(коды стран через запятую, ISO-3166-1 alpha-2, до 50 штук; всё, что не две латинские буквы, отклоняется с400, а не превращается в пустой отчёт). - Выпуск считается по дате завершения производственного задания.
materials_cost— фактическое списание материалов,operations_cost— фактическая стоимость операций, а при незаполненном факте — ставка часа на длительность.unit_costравенnull, если выпуск нулевой. Фильтры:manufacturing_location_id,item_id,category_id,order_type.
7. Подключение ИИ-агента по MCP
Тому же ключу отвечает сервер MCP (Model Context Protocol) — по нему подключаются ИИ-агенты, не умеющие REST: Claude Code, Codex CLI, Gemini CLI, редакторы Cursor и VS Code с Copilot, приложения Claude, самостоятельные агенты вроде OpenClaw и OpenHands, собственные агенты на любом SDK этого протокола. Программист на стороне клиента для подключения не нужен.
Адрес: https://mrp.zolotenkov.ru/api/external/v1/mcp. Транспорт — streamable HTTP: агент ходит по адресу и ничего у себя не запускает. Аутентификация — тот же заголовок Authorization: Bearer zol_pat_live_… и тот же ключ из «Настроек → API-ключи»; второго механизма доступа нет и заводить его не нужно.
7.1. Рецепты по клиентам
Сервер один и тот же для всех клиентов — различаются только место настройки и имена полей. Возьмите рецепт своего клиента; расположение файла и поддержка заголовка Authorization меняются от версии к версии, поэтому при расхождении верьте документации своего клиента.
Claude Code. Этот путь мы проходим целиком сами, от команды до подключённого сервера. Выполняется в терминале в любой папке:
read -rs ZOLOTENKOV_MRP_TOKEN && export ZOLOTENKOV_MRP_TOKEN
claude mcp add --transport http zolotenkov-mrp --scope user \
https://mrp.zolotenkov.ru/api/external/v1/mcp \
--header "Authorization: Bearer $ZOLOTENKOV_MRP_TOKEN"
Первая строка просит ключ и кладёт его в переменную окружения: значение не отображается на экране и не попадает в историю команд. Вторая записывает сервер и подставляет ключ в заголовок. Успех подтверждается строкой Added zolotenkov-mrp; она означает, что настройка записана, а не что связь проверена. Флаг --scope user кладёт сервер в личную настройку ~/.claude.json — вне репозитория и во всех ваших проектах; альтернативный --scope project пишет в файл .mcp.json в корне проекта, а этот файл уезжает в git всей команде вместе с ключом.
Codex CLI. Ключ остаётся в окружении, в файл настроек попадает только имя переменной:
read -rs ZOLOTENKOV_MRP_TOKEN && export ZOLOTENKOV_MRP_TOKEN
codex mcp add zolotenkov-mrp \
--url https://mrp.zolotenkov.ru/api/external/v1/mcp \
--bearer-token-env-var ZOLOTENKOV_MRP_TOKEN
Команда пишет в ~/.codex/config.toml блок [mcp_servers.zolotenkov-mrp] с полями url и bearer_token_env_var; его же можно завести руками. Обратная сторона: переменная должна быть в окружении той оболочки, из которой запускается codex.
Cursor. Файл ~/.cursor/mcp.json для всех проектов либо .cursor/mcp.json — только для одного. Подстановка ${env:…} берёт ключ из переменной окружения; проектный файл уезжает в git, поэтому готового значения в нём быть не должно.
{
"mcpServers": {
"zolotenkov-mrp": {
"url": "https://mrp.zolotenkov.ru/api/external/v1/mcp",
"headers": { "Authorization": "Bearer ${env:ZOLOTENKOV_MRP_TOKEN}" }
}
}
}
VS Code с Copilot. Файл .vscode/mcp.json в проекте либо личный: палитра команд → «MCP: Open User Configuration». Ключ верхнего уровня здесь servers, а не mcpServers. Подстановка ${input:…} заставит редактор спросить ключ один раз и сохранить его в своём хранилище секретов, в файл значение не попадёт.
{
"inputs": [
{ "type": "promptString", "id": "zolotenkov-mrp-token", "description": "Ключ Золотенков МРП", "password": true }
],
"servers": {
"zolotenkov-mrp": {
"type": "http",
"url": "https://mrp.zolotenkov.ru/api/external/v1/mcp",
"headers": { "Authorization": "Bearer ${input:zolotenkov-mrp-token}" }
}
}
}
Gemini CLI. Файл ~/.gemini/settings.json. Поле адреса здесь называется httpUrl: поле url в этом клиенте означает другой транспорт (SSE), и наш сервер по нему не отвечает. Запись $ZOLOTENKOV_MRP_TOKEN подставляется из окружения при чтении настроек.
{
"mcpServers": {
"zolotenkov-mrp": {
"httpUrl": "https://mrp.zolotenkov.ru/api/external/v1/mcp",
"headers": { "Authorization": "Bearer $ZOLOTENKOV_MRP_TOKEN" }
}
}
}
OpenClaw. Самостоятельный агент на вашем сервере. Настройка — одна команда, она пишет блок в ~/.openclaw/openclaw.json, раздел mcp.servers:
openclaw mcp set zolotenkov-mrp '{
"url": "https://mrp.zolotenkov.ru/api/external/v1/mcp",
"transport": "streamable-http",
"headers": { "Authorization": "Bearer zol_pat_live_ВАШ_КЛЮЧ" }
}'
Транспорт называется словом streamable-http — это тот же протокол, что http у соседних клиентов; значение http клиент тоже примет и приведёт к каноническому имени. Тот же блок можно вписать в файл руками, тогда ключ не попадёт в историю оболочки. Связь проверяется командой openclaw mcp doctor zolotenkov-mrp --probe.
OpenHands. Тоже самостоятельный агент; настройка складывается в ~/.openhands/mcp.json. Адрес идёт последним аргументом, после флагов:
read -rs ZOLOTENKOV_MRP_TOKEN && export ZOLOTENKOV_MRP_TOKEN
openhands mcp add zolotenkov-mrp --transport http \
--header "Authorization: Bearer $ZOLOTENKOV_MRP_TOKEN" \
https://mrp.zolotenkov.ru/api/external/v1/mcp
Значение заголовка подставляет оболочка, поэтому в файле ключ окажется готовым значением — держите этот файл как файл с секретом. Обоим агентам права выдавайте по одному сценарию: человека за экраном у них нет, и запись они применят без вопроса.
Claude Desktop и claude.ai. Файла настроек нет: «Настройки → Коннекторы → Добавить свой коннектор», адрес сервера в поле коннектора, а ключ — в разделе заголовков запроса (имя Authorization, значение Bearer и ключ через пробел). Раздел заголовков раскатан не на все аккаунты; если его в окне нет, подключить сервер по ключу оттуда не получится.
Общее правило для всех: настройка, лежащая внутри проекта, попадает в git. Готовый ключ нельзя коммитить и нельзя присылать сообщением самому агенту — у каждого человека свой ключ и свои права.
7.2. Проверка подключения
Записанная настройка — ещё не подключённый сервер, поэтому после шага 7.1:
- Перечитайте конфигурацию. Клиент читает её при запуске: закройте сессию или окно редактора и откройте заново. У Claude Code это новая сессия командой
claude. - Посмотрите состояние серверов. Команда своя у каждого клиента:
claude mcp listи/mcpу Claude Code,codex mcp listу Codex CLI,/mcpу Gemini CLI, «MCP: List Servers» у VS Code, раздел MCP в настройках у Cursor,openclaw mcp listиopenhands mcp listу самостоятельных агентов. Рядом сzolotenkov-mrpдолжно стоятьConnectedили местный эквивалент. - Проверьте чтение. Попросите агента прочитать список заказов покупателей или остатки. Успешный ответ с вашими данными и есть подтверждение подключения.
Две неполадки здесь легко перепутать, а лечатся они по-разному:
- Сервер не подключился, в ответе
401. Ключ неверный, отозван или истёк — либо потерялась переменная окружения и в заголовок ушла пустая строка. Выпустите ключ заново и повторите 7.1. - Сервер подключён, но нужного инструмента нет в списке. Это не ошибка связи, а отсутствие права у ключа. Добавьте право в «Настройках → API-ключи».
7.3. Клиент, которого нет в списке
Тот же сервер описывается блоком JSON — с него начинают все клиенты, не названные в 7.1, и собственные агенты на любом SDK этого протокола. Это не универсальный формат: имя файла, его расположение и поддержка заголовка Authorization у каждого клиента свои — сверяйтесь с его документацией. Клиент без поддержки удалённых серверов по HTTP так подключить нельзя.
{
"mcpServers": {
"zolotenkov-mrp": {
"type": "http",
"url": "https://mrp.zolotenkov.ru/api/external/v1/mcp",
"headers": { "Authorization": "Bearer zol_pat_live_ВАШ_КЛЮЧ" }
}
}
}
7.4. Что видит агент
Состав инструментов зависит от прав ключа. Ключ без права write:sales.order.create не увидит инструмента создания заказа — не «увидит и получит отказ», а не увидит вовсе. Это главная причина выдавать агенту узкий ключ: агент выбирает действие по списку инструментов, и лишняя строка в списке рано или поздно будет им испробована. Ключ только на чтение — безопасный способ дать агенту разобраться в учёте, ничего в нём не меняя.
Инструменты называются по методу и пути публичного API: get_sales_orders, get_sales_orders_by_id, post_sales_orders, patch_sales_orders_by_id, post_manufacturing_orders_by_id_start и так далее. Список собирается из той же спецификации openapi.json, поэтому расходиться с ней он не может: новая ручка публичного API появляется инструментом сама.
Аргументы инструмента — это параметры пути и фильтры ручки под своими именами плюс:
body— тело документа, по схеме публичного API (у операций записи);idempotency_key— обязательная строка у каждой операции записи. Это тот жеIdempotency-Keyиз §4: при обрыве связи агент повторяет вызов с тем же значением и второго документа не создаёт.
Повтор агент видит явно. Заголовков ответа по MCP не видно вовсе, поэтому признак повтора едет в самом результате вызова: поле _meta.idempotencyReplayed: true и приписка словами в конце содержимого — «Это повтор: действие уже было выполнено прежним вызовом…». Сам документ остаётся первым содержимым и не меняется. Без этого повтор приходил бы агенту тем же документом с тем же id и был бы неотличим от второго заведённого.
Неизвестный аргумент отклоняется, а не выбрасывается молча: агент, опечатавшийся в имени фильтра, иначе прочитал бы полный список как отфильтрованный.
Ограничения раздела действуют без изменений и на этом подключении: права ключа, разделение клиентов, частота обращений, суточный предел документов, журнал обращений. Один вызов инструмента — одна строка журнала и одна единица квоты, как у обычного HTTP-запроса; удаления документов здесь нет, как и в REST, а отмена заказа покупателя доступна ровно на тех же условиях (право write:sales.order.cancel, причина обязательна, снятие отмены — только в интерфейсе).
Строка журнала называет вызванную ручку, а не адрес подключения: в разделе «Настройки → API-ключи → Журнал» вызов выглядит как GET /api/external/v1/stock [mcp:get_stock]. Путь остаётся шаблоном — значение аргумента едет отдельной колонкой «Документ», а не внутри пути. Так чтения агента видно поимённо: при уводе ключа разбирают именно их.
Отказ ручки (нет права, документ не найден, исчерпан предел, бизнес-правило) приходит агенту результатом вызова с признаком ошибки и телом ответа целиком — с полем error и message, — чтобы агент прочитал причину и исправился, а не увидел обрыв соединения.
8. Уведомления о событиях: подписка вместо опроса
Перечитывать списки по расписанию, чтобы заметить новый заказ, не нужно. Подписка — это адрес на вашей стороне, куда МРП сама присылает короткое уведомление в тот момент, когда событие произошло.
8.1. Как подписаться
Подписка заводится двумя способами — ключом через API и человеком в «Настройках → API-ключи». Указываются одни и те же три вещи:
- адрес получателя — только
https, только публичный адрес в интернете, порт 443 или выше 1024; - набор событий — что присылать;
- описание — свободная строка «куда это уходит», чтобы через полгода не гадать.
В ответ показывается секрет подписки вида whsec_…. Он виден один раз, ровно как значение ключа: сохраните сразу. Восстановить его нельзя — можно только завести подписку заново.
Подписок у одного клиента не больше десяти.
curl -X POST https://mrp.zolotenkov.ru/api/external/v1/webhook-subscriptions \
-H "Authorization: Bearer $ZOL_PAT" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/zolotenkov",
"events": ["sales_order.created", "stock.changed"],
"description": "приёмник интеграции с 1С"
}'
{
"id": "37",
"url": "https://hooks.example.com/zolotenkov",
"events": ["sales_order.created", "stock.changed"],
"enabled": true,
"status": "active",
"consecutive_failures": 0,
"description": "приёмник интеграции с 1С",
"created_at": "2026-08-23T11:02:44.108Z",
"secret": "whsec_9f2b…"
}
Поле secret есть только в этом ответе: ни список, ни изменение его не отдают.
8.1.1. Управление подпиской ключом
Интеграции, которая ставится клиенту автоматически, интерфейс для подключения к событиям не нужен: весь жизненный цикл подписки проходится ключом.
| Действие | Запрос | Право |
|---|---|---|
| перечислить подписки | GET /webhook-subscriptions | read:webhooks |
| завести подписку | POST /webhook-subscriptions | write:webhook.subscription.create |
| изменить подписку | PATCH /webhook-subscriptions/{id} | write:webhook.subscription.update |
| снять подписку | DELETE /webhook-subscriptions/{id} | write:webhook.subscription.delete |
Права нарезаны по действию, как и вся остальная запись (§3): ключ, заводящий подписку, не умеет её снимать, пока это право ему не выдано явно.
Что стоит знать до первого запроса:
Idempotency-Keyобязателен на всех трёх записях, включаяDELETE. Повтор с тем же ключом возвращает прежний ответ: повторное заведение не создаст второй подписки и отдаст тот же секрет, повторное снятие ответит прежним телом, а не404. Поэтому у снятия ответ200 {"id": "37", "deleted": true}, а не пустой204.- В
PATCHедет только то, что меняется. Отсутствующее поле сохраняет прежнее значение;eventsприсылается набором целиком, а не добавкой к прежнему. - Адрес получателя не меняется. Под другой адрес заводится другая подписка — иначе журнал доставок перестаёт отвечать на вопрос «куда это уходило».
{"enabled": true}возвращает в строй сбойную подписку: пометкаfailingснимается, счётчик неудач обнуляется (§8.6).- Чужая подписка отвечает
404, а не403:403подтверждал бы, что она существует. - Одиннадцатая подписка отвечает
422 subscription_limit_reached— снимите ненужную.
Восстановить потерянный секрет нельзя ни ключом, ни в интерфейсе: заведите новую подписку и снимите старую.
8.2. Какие события бывают
| Тип события | Когда приходит | objectType |
|---|---|---|
sales_order.created | создан заказ покупателя | sales_order |
sales_order.updated | заказ покупателя изменён, в том числе отменён или возвращён в работу | sales_order |
purchase_order.created | создан заказ поставщику | purchase_order |
purchase_order.updated | заказ поставщику изменён, в том числе отменён или возвращён в работу | purchase_order |
purchase_receipt.created | оформлен приход по заказу поставщику | purchase_order |
manufacturing_order.created | создано производственное задание | manufacturing_order |
manufacturing_order.updated | производственное задание изменено, в том числе отменено или возвращено в работу | manufacturing_order |
manufacturing_order.started | производственное задание запущено | manufacturing_order |
manufacturing_order.completed | производственное задание завершено | manufacturing_order |
stock.changed | изменились остатки товара | item_variant |
stock.changed приходит по каждому затронутому варианту товара отдельно: один приход на пять позиций даёт пять уведомлений об остатках плюс одно о самом приходе.
Черновики заказов поставщику в списке по умолчанию не видны. Черновик — заказ, который человек завёл, но ещё не подтвердил: status у него DRAFT, ожидаемым приходом он не считается и принять по нему товар нельзя (POST …/receipts отвечает 409). GET /api/external/v1/purchase-orders без фильтра черновики не отдаёт — их видно только по ?status=DRAFT, а по идентификатору черновик читается как обычный заказ. Событие purchase_order.created о черновике не приходит. Когда человек подтверждает черновик или возвращает открытый заказ в черновик, приходит purchase_order.updated, и новый status вы узнаёте, дочитав заказ. Ключу оба действия недоступны, а заказ, созданный через API, сразу открыт.
Отдельного события об отмене нет, и искать *.cancelled не нужно. Отмена любого из трёх документов и обратное «Вернуть в работу» приходят как *.updated — тем же событием, что и любая другая правка. Состав типов событий закрыт: он уходит получателю в теле уведомления и хранится у нас, поэтому новые значения добавляются, а существующие не переименовываются. Что именно случилось с документом, вы узнаёте, дочитав его обычным запросом к API (§8.3): у отменённого заказа поставщику и производственного задания status равен CANCELLED. Причину отмены наружу отдаёт только заказ покупателя (cancel_reason, cancelled_at) — у двух других документов в ответе есть сам факт отмены, но не её причина.
Отмена заказа поставщику и производственного задания — действие человека в интерфейсе: ключу она недоступна, в отличие от отмены заказа покупателя (§3). Подписка на purchase_order.updated и manufacturing_order.updated — единственный способ узнать о ней, не опрашивая документы.
Тот же перечень описан машинно — в разделе webhooks спецификации GET /api/external/v1/openapi.json: у каждого события там состав полезной нагрузки, заголовки запроса и правила ответа. Генератор клиента по OpenAPI 3.1 разбирает этот раздел сам, приёмник по тексту руководства писать не нужно.
8.3. Что приходит
Обычный POST с телом JSON:
{
"eventId": "0f7c1f1e-6c2a-4a2f-9e4a-9a6f2b1c8d33",
"event": "sales_order.created",
"objectType": "sales_order",
"objectId": 1042,
"occurredAt": "2026-08-13T09:14:22.481Z",
"attempt": 1
}
Данных самого документа в теле нет, и это сделано намеренно. Получив уведомление, вы дочитываете документ обычным запросом к API своим ключом — GET /api/external/v1/sales-orders/1042. Тогда состав полей, права ключа и разделение клиентов проверяются ровно один раз, на чтении, а не двумя разными способами; и вы всегда видите актуальное состояние документа, а не то, каким оно было в момент отправки.
Заголовки запроса:
| Заголовок | Что в нём |
|---|---|
X-Zolotenkov-Event | тип события |
X-Zolotenkov-Event-Id | идентификатор события, тот же, что в теле |
X-Zolotenkov-Timestamp | время подписи, unix-секунды |
X-Zolotenkov-Signature | подпись вида v1=<hex> |
Отвечайте любым кодом 2xx — тело ответа мы не читаем и не храним. Отвечайте сразу, не дожидаясь, пока обработаете событие: на ответ отводится 10 секунд, после чего попытка считается неудачной. Правильный приёмник кладёт уведомление в свою очередь и отвечает 204.
Перенаправления (301, 302) не выполняются: адрес получателя — тот, что записан в подписке.
8.4. Как проверить подпись
Подпись — HMAC-SHA256 от строки <время>.<тело запроса как есть>, ключ — секрет подписки, результат в шестнадцатеричном виде с префиксом v1=.
Проверять надо сырое тело запроса, до разбора JSON: перекодировка меняет пробелы и порядок ключей, и подпись перестаёт сходиться.
import hashlib, hmac, time
def verify(raw_body: bytes, timestamp_header: str, signature_header: str, secret: str) -> bool:
# Старое уведомление не принимаем: иначе перехваченный запрос можно
# повторять сколько угодно, и подпись всё это время будет сходиться.
if abs(time.time() - int(timestamp_header)) > 300:
return False
expected = "v1=" + hmac.new(
secret.encode(),
f"{timestamp_header}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
# Сравнение постоянного времени: обычное `==` по времени отказа выдаёт,
# сколько первых символов подписи угаданы.
return hmac.compare_digest(expected, signature_header)
Время входит в подпись специально — заголовок X-Zolotenkov-Timestamp подменить, не сломав подпись, нельзя.
8.5. Повторные попытки и один и тот же eventId
Если получатель ответил не 2xx, не ответил за 10 секунд или оказался недоступен, доставка повторяется с нарастающей паузой: через минуту, через 5 минут, через 15 минут, через час, через 3 часа. Всего попыток шесть, номер текущей — в поле attempt.
Одно и то же событие может прийти дважды. Так бывает, когда получатель обработал уведомление, но ответ не доехал до нас. Поэтому:
- ведите у себя таблицу обработанных
eventIdи повтор с уже виденным значением игнорируйте; - делайте обработчик безопасным к повтору по существу — «поставить заказу состояние X» вместо «прибавить единицу к счётчику».
Порядок доставки не гарантирован: sales_order.updated может прийти раньше sales_order.created. Опирайтесь на состояние документа, дочитанное из API, а не на последовательность уведомлений.
8.6. Когда подписка ломается
Если исчерпаны все шесть попыток, подписка помечается сбойной и доставка по ней останавливается — иначе неработающий приёмник заставлял бы нас стучаться в него вечно. Сбойная подписка видна в «Настройках» с причиной последней неудачи и временем.
Починив приёмник, включите подписку обратно — в «Настройках» или запросом PATCH /webhook-subscriptions/{id} с телом {"enabled": true} (§8.1.1). Счётчик неудач обнулится. События, накопившиеся за время простоя, повторно не рассылаются: догоняйте пропущенное обычным чтением списков за нужный период.
Рядом с каждой подпиской лежит журнал доставок: время, событие, номер попытки, код ответа вашего сервера и длительность запроса. Тело ответа получателя мы не храним.
8.7. Ограничения
- Адрес получателя — только
httpsи только публичный. Адрес, записанный числом (127.0.0.1,10.*,192.168.*,169.254.169.254и прочие частные диапазоны), отклоняется сразу при сохранении подписки — разрешать в нём нечего. - Адрес, записанный именем узла, при сохранении не отклоняется: имя разрешается в адрес перед каждой отправкой, и уже там уведомление во внутреннюю сеть не уходит. Так честнее — имя, указывавшее наружу вчера, сегодня может указывать внутрь, и однократная проверка при сохранении ничего бы не гарантировала. Практическое следствие: подписка на
https://localhost/hookили на внутреннее имя контейнера сохранится успешно, но ни одно уведомление по ней не уйдёт. Причину видно в журнале доставок (errorKind: blocked_address) и в списке подписок. - Имя пользователя и пароль в адресе не принимаются — секрет подписки для того и есть.
- Не больше десяти подписок на клиента.
- Ответ получателя ждём 10 секунд.
- Журнал доставок хранится 30 дней.
Уведомления уходят только по подпискам того клиента, чьё событие произошло, — как и всё остальное в этом API.
9. Что API сегодня умеет
Чтение: товары с их вариантами (с поиском по названию и артикулу), остатки (с фильтрами по товару и варианту), партии со сроками годности и окном истечения (§6.6), движения по складу, заказы покупателей (список и карточка), возвраты покупателей (список и карточка) со справочником причин возврата, покупатели и поставщики (оба справочника с поиском), заказы поставщикам с приходами, производственные задания с операциями, спецификации изделий, справочник операций, справочники единиц измерения, категорий и мест хранения, свои подписки на события (§8.1.1), раздел «План» — прогноз остатков по периодам, дефициты и рекомендации к пополнению (§6.5), а также сводные срезы за период — продажи и выпуск, сложенные по товару и периоду (§6.7).
Запись: заведение, изменение и снятие подписки на события (§8.1.1), создание и изменение товара, групповое заведение до 200 карточек одним запросом (§6.0.2), заведение и изменение покупателя, заведение и изменение поставщика, заведение места хранения, корректировка остатков, создание и изменение заказа покупателя, отгрузка заказа покупателя — построчная и с закрытием, создание заказа поставщику и прихода по нему, оформление возврата покупателя и приёмка его на склад, создание, запуск и завершение производственного задания.
Чего нет: удаления документов, снятия отмены, отката принятого возврата, фильтра по месту хранения у /stock, разреза партий по местам хранения, перемещения между складами, записи в технологию и правки справочников. Отмена заказа покупателя есть — см. §6.3.1, возврат покупателя — см. §6.3.2.
Про технологию и справочники подробнее, потому что об них спотыкаются:
- Спецификации и операции (
/recipes,/operations) — только чтение. Завести состав материалов товару, созданному через API, снаружи нельзя. Пока спецификацию не заполнит человек в разделе «Производство → Спецификации», у производственного задания на такой товар список материалов будет пуст. Это ограничение версии 1, а не дефект. - Единицы и категории читаются, но не создаются и не правятся. Заводит их человек в настройках; API отдаёт их идентификаторы, чтобы было что подставить в тело запроса (§6.0.1).
- Место хранения заводится ключом, но не правится и не удаляется.
POST /storage-placesзакрывает тупик пустого учёта: без единого склада производственное задание отвечает422, а поправить это снаружи было нечем. Снять признак производства у уже заведённого склада или снести склад под живыми остатками по-прежнему может только человек в настройках.
И одна унаследованная неровность контракта, которую в версии 1 не чиним, потому что переименование поля сломало бы уже выпущенных клиентов: имя контрагента у покупателя называется title, а у поставщика — name. Проверьте это первым делом, если POST /customers или POST /suppliers отвечает 400.
Обе ветки на пустом аккаунте открываются ключом целиком: покупатель заводится через POST /customers (§6.3), поставщик — через POST /suppliers, и на него сразу оформляются заказ поставщику и приход. Опечатку в реквизитах правят PATCH /customers/{id} и PATCH /suppliers/{id}: присланные поля перезаписываются, отсутствующие сохраняют прежнее значение, а пустая строка очищает реквизит. ИНН уникален в пределах учёта — повтор отвечает 409. Банковских реквизитов в контракте нет ни на чтение, ни на запись: их заполняет человек в интерфейсе.
Точный перечень полей каждой ручки — в машинной спецификации GET /api/external/v1/openapi.json. Она открыта без ключа. События описаны в ней же, разделом webhooks.
10. Короткий чек-лист подключения
- Отладить сценарий на отдельном бесплатном аккаунте, а не на учёте клиента (§2).
- Выпустить ключ в «Настройках → API-ключи», выбрав минимальный набор прав и срок; значение сохранить сразу — второй раз его не покажут.
- Класть в каждый запрос
Authorization: Bearer …, а в каждую запись ещё иIdempotency-Key. - Записывать
X-Request-Idиз ответа в свой журнал. - Повторять оборвавшийся запрос с тем же ключом идемпотентности, а не с новым.
- Ветвиться по полю
error, а не по текстуmessage. - Уважать
Retry-Afterпри429и различатьrate_limitedиdocument_quota_exceeded. - Листать списки только курсором, не разбирая его содержимое.
- Помнить, что суточный предел документов расходуют и запуск, и завершение производственного задания.
- Переносить каталог групповой ручкой партиями по 200 позиций (§6.0.2), а не по одному товару.
- Вместо опроса списков по расписанию завести подписку на события (§8): проверять подпись по сырому телу и игнорировать повтор уже виденного
eventId.
11. Журнал изменений
Здесь отмечаются изменения, которые видит уже написанная интеграция: новые коды ошибок, сузившиеся правила, снятые ограничения. Расширения — новые ручки и новые необязательные поля — сюда не попадают: они старый код не ломают.
2 сентября 2026 — отгрузка заказа из закрытого периода снова разрешена
Учётный период у /ship и /complete проверяется по дню отгрузки, а не по
дате документа (§6.3).
Что заработало. Заказ, заведённый в закрытом периоде, отгружается и
закрывается: складское движение датируется сегодняшним числом. Раньше внешний
путь проверял по «сейчас» своей отдельной копией правила, и отказ приходил
422 с пустым error.
Что перестало работать. Ничего.
Что делать интеграции. Отказ 422 inventory_period_closed от /ship и
/complete теперь всегда назван кодом из таблицы ошибок и означает ровно одно:
закрыт сегодняшний день. Ветвиться по коду, а не по тексту.
2 сентября 2026 — заказ покупателя в закрытом учётном периоде
Заведение и правка заказа покупателя теперь подчиняются закрытому учётному периоду так же, как в интерфейсе (§6.3). Раньше эта граница в API не проверялась вовсе.
Что перестало работать. POST /sales-orders с created_date в закрытом
периоде и PATCH /sales-orders/{id} с customer_id, currency или items у
заказа, чья created_date в закрытом периоде, отвечают 422 с кодом
inventory_period_closed.
Что продолжает работать. Правка delivery_date, priority и notes у
такого заказа, а также отгрузка и отмена.
Что делать интеграции. Ветвиться по коду inventory_period_closed и не
считать его временным отказом: повтор того же запроса результата не изменит.
Документ закрытого периода правится либо в разрешённой границе полей, либо не
правится вовсе.