Публичное 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 или 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.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 | Производство | Завершать производственные задания |
Три следствия, о которые спотыкаются:
- Неизвестное право молча отбрасывается. Запрос ключа с правом, которого нет в каталоге, ошибки не вернёт — право просто не попадёт в ключ. Если после отбрасывания не осталось ни одного права, выпуск отвечает
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 /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— необязательный массив, бывает у ошибок проверки полей.
Коды, которые встречаются:
| HTTP | error | Когда |
|---|---|---|
| 400 | validation_failed | нет обязательного поля или заголовка, битый курсор, недопустимое значение фильтра, ссылка на несуществующую строку |
| 401 | unauthorized | нет ключа, ключ не по форме, неверный, отозванный или истёкший |
| 402 | SUBSCRIPTION_LIMIT | на бесплатном тарифе уже создано 30 живых товаров и материалов |
| 403 | forbidden | ключ годен, но нужного права у него нет |
| 404 | not_found | документа нет у этого клиента |
| 409 | idempotency_mismatch | тот же Idempotency-Key с другим телом |
| 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. Частота запросов
Три независимых ограничения (значения по умолчанию из 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. Короткий чек-лист подключения
- Отладить сценарий на отдельном бесплатном аккаунте с демо-данными, а не на учёте клиента (§2).
- Выпустить ключ в «Настройках → API-ключи», выбрав минимальный набор прав и срок; значение сохранить сразу — второй раз его не покажут.
- Класть в каждый запрос
Authorization: Bearer …, а в каждую запись ещё иIdempotency-Key. - Записывать
X-Request-Idиз ответа в свой журнал. - Повторять оборвавшийся запрос с тем же ключом идемпотентности, а не с новым.
- Ветвиться по полю
error, а не по текстуmessage. - Уважать
Retry-Afterпри429и различатьrate_limitedиdocument_quota_exceeded. - Листать списки только курсором, не разбирая его содержимое.
- Помнить, что суточный предел документов расходуют и запуск, и завершение производственного задания.
- Вместо опроса списков по расписанию завести подписку на события (§8): проверять подпись по сырому телу и игнорировать повтор уже виденного
eventId.