Zolotenkov

Эта страница собирается из того же файла, который поддерживает команда продукта.

Публичное API МРП: руководство интегратора

Этот документ объясняет, как подключиться к публичному API Золотенков МРП и решать им обычные задачи. Машинная спецификация лежит по адресу https://mrp.zolotenkov.ru/api/external/v1/openapi.json и остаётся источником истины по полям; здесь — то, чего в ней нет: с чего начать, какие правила обязательны и как выглядит сквозной сценарий целиком.

Основание: ZOL-10450, раздел §2 — ZOL-10471. Каждое утверждение ниже проверено живыми запросами к https://mrp.zolotenkov.ru 13.08.2026 либо чтением кода — где именно, сказано в тексте. Ключ проверки выпускался на QA-тенанте и отозван в том же прогоне.

Общий адрес: https://mrp.zolotenkov.ru/api/external/v1. Все ответы — JSON в кодировке UTF-8. Поля тел запросов и ответов именуются в змеином регистре (item_variant_id, total_amount_with_tax), а не в верблюжьем.


1. Начало работы

1.1. Где выпустить ключ

Ключ выпускает сам клиент в интерфейсе: «Настройки → API-ключи». Раздел доступен пользователю с правом manage_api_keys; сотруднику без этого права его не видно.

При выпуске задаются четыре вещи:

ЧтоЗначенияЕсли не выбрать
Названиестрока до 255 символовобязательно, пустое отвергается
Праваиз каталога §3только права на чтение
Срок действия30, 90 или 180 дней либо «бессрочно»90 дней
Суточный предел документов50, 200 или 1000200

Произвольного срока и произвольного предела нет намеренно: закрытый список не даёт завести ключ с датой 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.5) другой. Не пишите один разбор ошибок на оба.


2. Где попробовать, ничем не рискуя

Пробовать запись на учёте, по которому клиент реально работает, не нужно: отдельная площадка для опытов уже есть, её не надо ни просить, ни настраивать. Регистрация бесплатна, а свежий аккаунт приходит с готовым набором демо-данных — заказами, товарами, материалами, рецептами и производственными заданиями. Их можно ломать как угодно и удалить одной кнопкой.

Весь путь ниже пройден 13.08.2026; что именно проверено живым запросом, а что чтением кода, сказано по шагам.

2.1. Завести отдельный аккаунт под опыты

Регистрация — на https://zolotenkov.ru/signup/ (адрес https://mrp.zolotenkov.ru/registration ведёт туда же перенаправлением 301, проверено). Заводите отдельный аккаунт, а не тот, где ведётся настоящий учёт: ключ с правом записи, ошибка в теле запроса и повтор без Idempotency-Key в чужом рабочем аккаунте стоят дороже, чем пять минут на регистрацию.

Аккаунт, где вы будете пробовать, должен быть вашим собственным. Опыты в аккаунте клиента — это чужие документы в его учёте, даже когда всё прошло удачно.

2.2. Демо-данные наливаются сами

Отдельного действия «налить демо» делать не нужно: набор создаётся при регистрации, в той же операции, что и сам аккаунт (backend/src/services/registration_service.rs — вызов DemoDataService::seed сразу после создания владельца). Кнопки «залить демо» в интерфейсе поэтому и нет.

Что видно после входа: на экране приветствия — карточка «Начните с демо-заказа» со ссылкой на готовый заказ SO-1, а в списках — записи с приставкой «Демо:» в названии. Приставка и есть признак: всё, что ей помечено, принадлежит демо-набору и уйдёт при удалении.

Размер набора — замер на тестовом тенанте 13.08.2026 (GET /api/demo-data/status):

ЧтоСколько
Материалы15
Товары15
Варианты товаров32
Рецепты (спецификации)10
Покупатели20
Поставщики10
Заказы покупателей36
Заказы поставщикам14
Производственные задания20
Строки остатков20

Набор связный: у заказов есть покупатели, у товаров — рецепты и остатки, у производственных заданий — операции. Поэтому сквозные сценарии §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), остаётся — это закреплено интеграционными тестами backend/tests/it/demo_data/delete/, в том числе на случаях, когда ваша запись ссылается на демо-запись. После удаления экран приветствия снова показывает первый шаг: аккаунт возвращается в состояние «данных нет».

Удаление и заливка демо доступны только владельцу аккаунта — сотруднику, приглашённому в тот же аккаунт, эти операции запрещены.

Две особенности, о которые спотыкаются:

  • Повторно налить удалённый набор из интерфейса нельзя: кнопки «вернуть демо» нет. Нужен новый набор — заводите новый аккаунт для опытов.
  • Пока в аккаунте есть ваши собственные товары, контрагенты или заказы, переключение отраслевого профиля демо (/demo?demo_seed=… со страниц сайта) отвечает 422: система не станет подмешивать 36 чужих демо-заказов к вашей работе.

2.6. Во что можно упереться на бесплатном тарифе

Первые 14 дней после регистрации ограничений по объёму нет. Дальше аккаунт переходит на бесплатный тариф, и в нём действует предел: 30 SKU — товары и материалы вместе, без учёта архивных и удалённых. Значения взяты из кода (Subscription::FREE_SKU_LIMIT, TRIAL_DAYS) и подтверждены живым ответом:

GET /api/subscriptions/current

200
{"plan":"free","status":"active","trialEndsAt":null,
 "skuCount":62,"skuLimit":30,"isUnlimited":false,
 "proMonthlyPriceRub":3290,"proYearlyPriceRub":32900}

Это внутренняя ручка интерфейса, а не публичного API: она отвечает на сессию пользователя, а не на ключ. Своё положение относительно предела смотрите ею или глазами в разделе подписки.

Практическое следствие, которое стоит знать заранее: демо-набор — это 15 товаров и 15 материалов, то есть ровно 30 SKU, весь бесплатный предел целиком. Пока демо на месте, места под собственные товары на бесплатном тарифе не остаётся. Отсюда порядок: сначала прогнать сценарии на демо-данных, потом удалить демо и заводить своё.

Предел действует одинаково в интерфейсе и в публичном API. Если живых товаров и материалов уже 30, новый POST /api/external/v1/products отвечает 402 с кодом SUBSCRIPTION_LIMIT. Уже созданные сверх предела записи остаются доступны: ограничение применяется только к следующему созданию.

Что не зависит от тарифа: частота запросов и суточный предел документов на ключ (§5). Они одинаковы для всех и настраиваются не тарифом, а параметрами ключа.


3. Права

Два правила определяют весь каталог:

  • Чтение нарезано по разделу. Тот, кто читает остатки, читает их целиком; дробить это незачем.
  • Запись нарезана по отдельному действию. Право «запускать производство» не приносит с собой право «создавать заказы».

Зонтичных прав (write:sales, write:*) не существует. Прав на удаление и на отмену документов не существует тоже: публичное API не убирает документы из учёта — это делает только человек в интерфейсе.

Полный каталог — 15 прав. Источник: backend/src/api/external/scopes.rs, сверено с живым ответом GET /api/settings/api-keys/scopes 13.08.2026.

ПравоРазделЧто разрешает
read:catalogКаталогЧитать товары, категории и единицы измерения
write:catalog.product.createКаталогСоздавать товары
write:catalog.product.updateКаталогИзменять карточку товара
read:stockСкладЧитать остатки и движения
write:inventory.adjustment.createСкладОформлять корректировку остатков
read:salesПродажиЧитать заказы покупателей
write:sales.order.createПродажиСоздавать заказы покупателей
write:sales.order.updateПродажиИзменять незавершённый заказ покупателя
read:purchaseЗакупкиЧитать заказы поставщикам, приходы и поставщиков
write:purchase.order.createЗакупкиСоздавать заказы поставщикам
write:purchase.receipt.createЗакупкиОформлять приход по заказу поставщику
read:manufacturingПроизводствоЧитать производственные заказы, задания и спецификации
write:manufacturing.order.createПроизводствоСоздавать производственные задания
write:manufacturing.order.startПроизводствоЗапускать производственные задания
write:manufacturing.order.completeПроизводствоЗавершать производственные задания

Три следствия, о которые спотыкаются:

  1. Неизвестное право молча отбрасывается. Запрос ключа с правом, которого нет в каталоге, ошибки не вернёт — право просто не попадёт в ключ. Если после отбрасывания не осталось ни одного права, выпуск отвечает 400 «Выберите хотя бы одно право для ключа» — тем же текстом, что и на пустой список.
  2. Права на запись не включают чтение. Ключу, который создаёт заказы и потом читает их, нужны оба права: write:sales.order.create и read:sales.
  3. Нехватку права видно сразу и точно — ответ называет недостающее право:
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 /sales-orders и на POST /manufacturing-orders друг другу не мешает.

4.3. X-Request-Id в ответе

Каждый ответ несёт заголовок X-Request-Id, и он же лежит в поле request_id тела любой ошибки. Если клиент прислал свой X-Request-Id (до 128 символов, латиница, цифры, дефис и подчёркивание), система вернёт именно его; иначе сгенерирует свой вида req_VulwVigxTmLwYFoqy41qI4.

Пишите этот идентификатор в свой журнал. Обращаясь в поддержку по конкретному сбою, называйте его — по нему находят конкретный запрос.

4.4. Постраничная выдача курсором

Все списки отдаются так:

{
  "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: OPEN, DONE, CANCELLED». Это сделано нарочно: пустой ответ клиент прочитал бы как «таких заказов нет».

4.5. Формат ошибок

Один формат на все ответы 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 — необязательный массив, бывает у ошибок проверки полей.

Коды, которые встречаются:

HTTPerrorКогда
400validation_failedнет обязательного поля или заголовка, битый курсор, недопустимое значение фильтра, ссылка на несуществующую строку
401unauthorizedнет ключа, ключ не по форме, неверный, отозванный или истёкший
402SUBSCRIPTION_LIMITна бесплатном тарифе уже создано 30 живых товаров и материалов
403forbiddenключ годен, но нужного права у него нет
404not_foundдокумента нет у этого клиента
409idempotency_mismatchтот же Idempotency-Key с другим телом
422код действияучётный отказ: например manufacturing_order_transition_not_allowed, manufacturing_order_invalid_status
429rate_limitedпревышена частота запросов
429document_quota_exceededисчерпан суточный предел документов ключа
500internal_errorсбой на стороне сервера; подробности наружу не выдаются

Разница между 400 и 422 содержательная: 400 — запрос неправильно составлен, 422 — запрос понят, но учёт так поступить не даёт (нет материалов, документ не в том статусе). Первое чинится исправлением запроса, второе — изменением данных.


5. Ограничения

5.1. Частота запросов

Три независимых ограничения (значения по умолчанию из backend/src/config/rate_limit.rs; в проде могут быть переопределены):

ОграничениеПорогЧто считает
Общее300 запросов за 60 секундпара «ключ + адрес», любые ручки
Строгое30 запросов за 60 секундпара «ключ + адрес», только записи и тяжёлые выборки
Страховочное1200 запросов за 60 секундодин адрес, независимо от ключа

При превышении:

429 Retry-After: <секунды>
{"error":"rate_limited","message":"Rate limit exceeded","request_id":"…"}

Заголовок Retry-After в ответе есть — ждите указанное число секунд и повторяйте. Повтор записи делайте с тем же Idempotency-Key.

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: первый говорит «повторите через секунду», второй — «на сегодня всё». Владельцу тенанта при этом уходит письмо (не чаще раза в сутки).

Что расходует предел (по коду, сверено 13.08.2026):

  • создание товара, заказа покупателя, заказа поставщику, прихода по нему, корректировки остатков, производственного задания;
  • запуск и завершение производственного задания — каждое действие по единице. Это неочевидно: ключ с пределом 50, который только ведёт производство, проведёт 25 заданий, а не 50.

Что предел не расходует: любое чтение и любое изменение существующего документа (PATCH) — изменение документа не создаёт.

Расход списывается в той же транзакции, что и сам документ: если предел исчерпан, документа не остаётся, и «первым разом» предел не обойти.


6. Сквозные сценарии

Ниже — реальные запросы и ответы, снятые на тестовом тенанте 13.08.2026. Идентификаторы, разумеется, будут свои.

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},
  {"id":"1478","item_id":"4882","item_variant_id":"4887","storage_place_id":"460","amount":29.0}
], "next_cursor":"eyJpZCI6MTQ3OH0"}

Два предупреждения, оба стоили времени при проверке:

  • У /stock нет фильтров, только limit и cursor. Спросить остаток одного варианта нельзя — надо листать раздел целиком и сопоставлять по item_variant_id у себя. Это ограничение сегодняшней версии, а не ошибка вызова.
  • Остаток привязан к месту хранения (storage_place_id), а производственное задание считает доступность материалов по своей локации (manufacturing_location_id). Товар, лежащий на другом складе, для задания не существует. Складывая доступное количество, складывайте по нужному месту хранения, а не по всем.

Достоверный ответ на вопрос «хватит ли» даёт всё-таки не расчёт, а сама попытка запуска — см. §6.4.

6.3. Создать заказ покупателя

Нужное право: 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 вовсе.

Отмены заказа в публичном API нет и не будет: убрать документ из учёта может только человек в интерфейсе.

6.4. Запустить производственное задание

Нужные права: write:manufacturing.order.create, write:manufacturing.order.start, write:manufacturing.order.complete — каждое отдельно.

Шаг 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" …}

Списание материалов и оприходование выпуска навешены на смену статуса — тем же кодом, которым это делает интерфейс. Отдельного вызова «списать материалы» нет и не нужно.

Ход работ по операциям виден отдельной ручкой:

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}]}

7. Подключение ИИ-агента по MCP

Тому же ключу отвечает сервер MCP (Model Context Protocol) — по нему подключаются ИИ-агенты, не умеющие REST: Claude Desktop и Claude Code, редакторы с поддержкой MCP, собственные агенты на любом SDK этого протокола. Программист на стороне клиента для подключения не нужен.

Адрес: https://mrp.zolotenkov.ru/api/external/v1/mcp. Транспорт — streamable HTTP: агент ходит по адресу и ничего у себя не запускает. Аутентификация — тот же заголовок Authorization: Bearer zol_pat_live_… и тот же ключ из «Настроек → API-ключи»; второго механизма доступа нет и заводить его не нужно.

Настройка в клиенте выглядит так:

{
  "mcpServers": {
    "zolotenkov-mrp": {
      "type": "http",
      "url": "https://mrp.zolotenkov.ru/api/external/v1/mcp",
      "headers": { "Authorization": "Bearer zol_pat_live_ВАШ_КЛЮЧ" }
    }
  }
}

Состав инструментов зависит от прав ключа. Ключ без права 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: при обрыве связи агент повторяет вызов с тем же значением и второго документа не создаёт.

Неизвестный аргумент отклоняется, а не выбрасывается молча: агент, опечатавшийся в имени фильтра, иначе прочитал бы полный список как отфильтрованный.

Ограничения раздела действуют без изменений и на этом подключении: права ключа, разделение клиентов, частота обращений, суточный предел документов, журнал обращений. Один вызов инструмента — одна строка журнала и одна единица квоты, как у обычного HTTP-запроса; удаления и отмены документов здесь нет, как и в REST.

Отказ ручки (нет права, документ не найден, исчерпан предел, бизнес-правило) приходит агенту результатом вызова с признаком ошибки и телом ответа целиком — с полем error и message, — чтобы агент прочитал причину и исправился, а не увидел обрыв соединения.


8. Уведомления о событиях: подписка вместо опроса

Перечитывать списки по расписанию, чтобы заметить новый заказ, не нужно. Подписка — это адрес на вашей стороне, куда МРП сама присылает короткое уведомление в тот момент, когда событие произошло.

8.1. Как подписаться

Подписка заводится человеком в «Настройках → API-ключи», рядом с ключами. Указываются три вещи:

  • адрес получателя — только https, только публичный адрес в интернете, порт 443 или выше 1024;
  • набор событий — что присылать;
  • описание — свободная строка «куда это уходит», чтобы через полгода не гадать.

В ответ показывается секрет подписки вида whsec_…. Он виден один раз, ровно как значение ключа: сохраните сразу. Восстановить его нельзя — можно только завести подписку заново.

Подписок у одного клиента не больше десяти. Управлять подписками через публичное API пока нельзя: подписку создаёт человек в интерфейсе.

8.2. Какие события бывают

Тип событияКогда приходитobjectType
sales_order.createdсоздан заказ покупателяsales_order
sales_order.updatedзаказ покупателя изменёнsales_order
purchase_order.createdсоздан заказ поставщикуpurchase_order
purchase_receipt.createdоформлен приход по заказу поставщикуpurchase_order
manufacturing_order.createdсоздано производственное заданиеmanufacturing_order
manufacturing_order.startedпроизводственное задание запущеноmanufacturing_order
manufacturing_order.completedпроизводственное задание завершеноmanufacturing_order
stock.changedизменились остатки товараitem_variant

stock.changed приходит по каждому затронутому варианту товара отдельно: один приход на пять позиций даёт пять уведомлений об остатках плюс одно о самом приходе.

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. Когда подписка ломается

Если исчерпаны все шесть попыток, подписка помечается сбойной и доставка по ней останавливается — иначе неработающий приёмник заставлял бы нас стучаться в него вечно. Сбойная подписка видна в «Настройках» с причиной последней неудачи и временем.

Починив приёмник, включите подписку обратно там же — счётчик неудач обнулится. События, накопившиеся за время простоя, повторно не рассылаются: догоняйте пропущенное обычным чтением списков за нужный период.

Рядом с каждой подпиской лежит журнал доставок: время, событие, номер попытки, код ответа вашего сервера и длительность запроса. Тело ответа получателя мы не храним.

8.7. Ограничения

  • Адрес получателя — только https и только публичный. Внутренние адреса (127.0.0.1, 10.*, 192.168.*, 169.254.169.254 и прочие частные диапазоны) отклоняются и при сохранении подписки, и перед каждой отправкой: имя, указывавшее наружу вчера, сегодня может указывать внутрь.
  • Имя пользователя и пароль в адресе не принимаются — секрет подписки для того и есть.
  • Не больше десяти подписок на клиента.
  • Ответ получателя ждём 10 секунд.
  • Журнал доставок хранится 30 дней.

Уведомления уходят только по подпискам того клиента, чьё событие произошло, — как и всё остальное в этом API.


9. Что API сегодня умеет

Чтение: товары, остатки, движения по складу, заказы покупателей (список и карточка), заказы поставщикам с приходами, поставщики, производственные задания с операциями, спецификации изделий, справочник операций.

Запись: создание и изменение товара, корректировка остатков, создание и изменение заказа покупателя, создание заказа поставщику и прихода по нему, создание, запуск и завершение производственного задания.

Чего нет: удаления и отмены документов, отгрузки заказа покупателя, фильтров по складу у /stock.

Точный перечень полей каждой ручки — в машинной спецификации GET /api/external/v1/openapi.json. Она открыта без ключа.


10. Короткий чек-лист подключения

  1. Отладить сценарий на отдельном бесплатном аккаунте с демо-данными, а не на учёте клиента (§2).
  2. Выпустить ключ в «Настройках → API-ключи», выбрав минимальный набор прав и срок; значение сохранить сразу — второй раз его не покажут.
  3. Класть в каждый запрос Authorization: Bearer …, а в каждую запись ещё и Idempotency-Key.
  4. Записывать X-Request-Id из ответа в свой журнал.
  5. Повторять оборвавшийся запрос с тем же ключом идемпотентности, а не с новым.
  6. Ветвиться по полю error, а не по тексту message.
  7. Уважать Retry-After при 429 и различать rate_limited и document_quota_exceeded.
  8. Листать списки только курсором, не разбирая его содержимое.
  9. Помнить, что суточный предел документов расходуют и запуск, и завершение производственного задания.
  10. Вместо опроса списков по расписанию завести подписку на события (§8): проверять подпись по сырому телу и игнорировать повтор уже виденного eventId.