# Публичное 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` | Производство | Завершать производственные задания |

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

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. Постраничная выдача курсором

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

```json
{
  "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:

```json
{
  "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-ключи»; второго механизма доступа нет и заводить его не нужно.

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

```json
{
  "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:

```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: перекодировка меняет пробелы и порядок ключей, и подпись перестаёт сходиться.

```python
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`.
