05. API каталога¶
Два независимых контура с разными требованиями:
| Публичный | Админский | |
|---|---|---|
| Префикс | /api/v1 |
/api/admin/v1 |
| Аудитория | сайт, мобильное приложение | админка |
| Операции | только чтение | чтение и запись |
| Авторизация | нет (rate limit по IP) | JWT + RBAC |
| Кеширование | агрессивное (снапшот, ETag, CDN) | нет |
| Совместимость | версионируется, ломать нельзя | меняется вместе с админкой |
5.1. Общие соглашения¶
Ошибки — RFC 7807 application/problem+json:
{
"type": "https://panam.ru/errors/validation",
"title": "Ошибка валидации",
"status": 422,
"detail": "Проверьте заполнение полей",
"instance": "/api/admin/v1/products/42",
"requestId": "01J8X…",
"errors": [
{ "field": "slug", "code": "duplicate", "message": "Такой slug уже используется" }
]
}
Пагинация — ?page=1&size=50, ответ:
Сортировка — ?sort=name,asc&sort=createdAt,desc.
Частичное обновление — PATCH с семантикой merge, null означает «очистить».
Конкурентность — тело содержит version, при расхождении 409 Conflict.
Идемпотентность записи — заголовок Idempotency-Key на операциях создания.
Даты — ISO-8601 с таймзоной. Деньги — число с двумя знаками (530.00), валюта RUB.
Идентификаторы — витринные сущности: long; сущности iiko: uuid/строка iiko, как в источнике.
5.2. Публичное API витрины¶
GET /api/v1/menu¶
Полное меню одной организации одним запросом. Основной метод витрины.
200 OK / 304 Not Modified, заголовки: ETag, Cache-Control: public, max-age=60.
{
"revision": 1892,
"organizationId": "1d4d1278-219d-4c72-b064-98d2f9e67d09",
"generatedAt": "2026-08-13T11:20:04Z",
"categories": [
{
"id": 3, "slug": "pizzy", "name": "Пиццы", "parentId": null, "position": 10,
"image": { "id": 812, "url": "/media/812/card.webp",
"srcset": "/media/812/card.webp 1x, /media/812/card@2x.webp 2x" },
"productIds": [101, 102, 103]
}
],
"products": [
{
"id": 101,
"slug": "panam",
"name": "Панам",
"subtitle": "Фирменная",
"description": "Пепперони, моцарелла, соус томатный",
"composition": "Пепперони, моцарелла, соус томатный",
"categoryIds": [3],
"primaryCategoryId": 3,
"badges": ["hit"],
"images": {
"main": { "id": 900, "url": "/media/900/card.webp", "srcset": "…" },
"hover": null,
"gallery": []
},
"variantAxis": { "code": "pizza_size", "name": "Размер", "uiType": "SEGMENTED" },
"variants": [
{
"id": 401,
"option": { "code": "25", "name": "25 см", "shortName": "25" },
"iikoItemId": "63ff2041-f358-4b8f-95e5-e41a84fbac23",
"sku": "219971",
"price": 530.00,
"weightGrams": 480,
"nutritionPer100g": { "proteins": 9.3, "fats": 8.6, "carbs": 19.6, "energy": 193.9 },
"available": true,
"isDefault": true,
"modifierGroupIds": ["mg_7f1c", "mg_2a04"]
},
{ "id": 402, "option": { "code": "30", "name": "30 см" }, "price": 730.00, "…": "…" }
],
"allergens": []
}
],
"modifierGroups": [
{
"id": "mg_7f1c",
"name": "Тесто",
"uiType": "RADIO",
"min": 1, "max": 1, "free": 1, "collapsed": false,
"modifiers": [
{ "id": "8c4184d3-…", "name": "Традиционное", "price": 0.00,
"image": null, "weightGrams": 0, "available": true, "isDefault": true },
{ "id": "1b844f34-…", "name": "Пышное", "price": 50.00, "available": true }
]
}
]
}
Устройство ответа — прямое следствие разбора выгрузки iiko:
- Модификаторы вынесены в словарь
modifierGroups, товары ссылаются по id. В исходном ответе iiko 21 231 повторяющаяся ссылка на 237 уникальных модификаторов даёт 19 МБ. После нормализации — сотни килобайт. - Идентификатор группы синтетический (
mg_7f1c= хеш отgroup_key+ состава + цен), потому чтоitemGroupIdв iiko бываетnullи один и тот же id имеет разный состав и разные цены у разных блюд. Группы с одинаковым составом и ценами схлопываются в одну запись словаря. - Цена — на варианте, конечная для этой организации. Витрина не считает цены сама.
availableучитывает стоп-лист и наличие цены.- Категории отдают только
productIds— товар не дублируется, находясь в нескольких категориях.
Остальные публичные методы¶
| Метод | Назначение |
|---|---|
GET /api/v1/organizations |
активные организации: id, название, адрес, координаты |
GET /api/v1/categories?organizationId= |
только дерево категорий (для навигации и SSR-меню) |
GET /api/v1/products?organizationId=&categoryId=&page= |
постраничный список (мобильное приложение, ленивая подгрузка) |
GET /api/v1/products/{slug}?organizationId= |
карточка товара со всеми модификаторами инлайн |
GET /api/v1/availability?organizationId= |
лёгкий ответ: {revision, unavailableItemIds:[…]}, опрос раз в минуту без перекачки меню |
GET /api/v1/search?q=&organizationId= |
поиск по названию/составу, полнотекстовый |
GET /api/v1/menu/revision?organizationId= |
только {revision} — дешёвая проверка на изменения |
Витрина работает так: один раз GET /api/v1/menu, дальше опрос availability раз в минуту и
menu/revision — при изменении ревизии перезапрашивается меню. Так стоп-лист приезжает почти
мгновенно, а трафик остаётся минимальным.
5.3. Админский API¶
Аутентификация¶
POST /api/admin/v1/auth/login { email, password } → { accessToken, expiresIn }
+ refreshToken в httpOnly Secure SameSite=Strict cookie
POST /api/admin/v1/auth/refresh → новый accessToken
POST /api/admin/v1/auth/logout
GET /api/admin/v1/me → { id, email, fullName, roles, permissions[] }
Категории¶
GET /api/admin/v1/categories?tree=true дерево целиком
GET /api/admin/v1/categories/{id}
POST /api/admin/v1/categories
PATCH /api/admin/v1/categories/{id}
DELETE /api/admin/v1/categories/{id} архивирование, 409 если есть товары
PUT /api/admin/v1/categories/tree массовое сохранение порядка и вложенности
PUT /api/admin/v1/categories/tree
{ "nodes": [
{ "id": 3, "parentId": null, "position": 0 },
{ "id": 7, "parentId": 3, "position": 0 },
{ "id": 8, "parentId": 3, "position": 1 }
] }
Товары¶
GET /api/admin/v1/products
?q=&categoryId=&status=&hasImage=&hasVariants=&badgeId=
&organizationId=&iikoIssue=true&page=&size=&sort=
GET /api/admin/v1/products/{id}
POST /api/admin/v1/products
PATCH /api/admin/v1/products/{id}
POST /api/admin/v1/products/{id}/publish → 422 со списком нарушенных правил
POST /api/admin/v1/products/{id}/unpublish
POST /api/admin/v1/products/{id}/archive
POST /api/admin/v1/products/bulk массовые операции
PUT /api/admin/v1/categories/{id}/products/order сортировка внутри категории
POST /api/admin/v1/products/bulk
{
"productIds": [101, 102, 103],
"action": "ADD_TO_CATEGORY", // PUBLISH | UNPUBLISH | ARCHIVE | ADD_TO_CATEGORY |
"payload": { "categoryId": 3 } // REMOVE_FROM_CATEGORY | SET_BADGES | SET_VISIBILITY
}
→ { "succeeded": [101,102], "failed": [ {"id":103, "reason":"NO_MAIN_IMAGE"} ] }
Ответ POST /products/{id}/publish при неготовности:
{
"type": "https://panam.ru/errors/publish-blocked", "status": 422,
"title": "Товар нельзя опубликовать",
"violations": [
{ "code": "NO_MAIN_IMAGE", "message": "Не загружено главное изображение" },
{ "code": "NO_PLACEMENT", "message": "Товар не размещён ни в одной активной категории" }
]
}
Варианты и привязка к iiko¶
GET /api/admin/v1/products/{id}/variants
POST /api/admin/v1/products/{id}/variants привязка позиции iiko
PATCH /api/admin/v1/variants/{variantId}
DELETE /api/admin/v1/variants/{variantId}
PUT /api/admin/v1/products/{id}/variants/order
POST /api/admin/v1/products/101/variants
{ "iikoItemId": "63ff2041-…", "iikoItemSizeId": 512,
"variantOptionId": 1, "isDefault": true }
409 Conflict → { "code": "ITEM_ALREADY_BOUND",
"boundProduct": { "id": 77, "name": "Панам классический" } }
Инциденты привязок¶
GET /api/admin/v1/binding-incidents?status=OPEN&severity=&productId=&page=
GET /api/admin/v1/binding-incidents/{id}
POST /api/admin/v1/binding-incidents/{id}/dismiss { note }
POST /api/admin/v1/binding-incidents/{id}/snooze { until }
POST /api/admin/v1/variants/{id}/rebind { iikoItemSizeId }
GET /api/admin/v1/variants/{id}/rebind-candidates
GET /api/admin/v1/binding-incidents?status=OPEN
{ "items": [
{
"id": 1204,
"type": "ITEM_MISSING", "severity": "CRITICAL", "status": "OPEN",
"detectedAt": "2026-08-12T04:30:11Z", "detectedByRunId": 8891,
"product": { "id": 101, "name": "Панам", "status": "PUBLISHED" },
"variant": { "id": 402, "optionName": "30 см" },
"lastKnown": { "iikoItemId": "a1b2…", "sku": "219972",
"name": "Панам 30 см", "price": 730.00,
"lastSeenAt": "2026-08-12T04:12:00Z" },
"candidates": [
{ "iikoItemId": "f9e8…", "iikoItemSizeId": 981, "sku": "219972",
"name": "Панам 30 см", "matchedBy": "SKU", "confidence": 0.95,
"firstSeenAt": "2026-08-12T04:30:00Z" }
]
}
] }
POST /variants/{id}/rebind меняет позицию iiko у существующего варианта: товар, изображения,
тексты, SEO и размещение сохраняются, прежний itemId дописывается в историю привязки, связанные
инциденты закрываются с резолюцией REBOUND. Это и есть ответ на случайное удаление блюда в iiko.
Позиции iiko и подсказки¶
GET /api/admin/v1/iiko/items
?q=&bound=false&type=DISH&productCategoryId=&menuCategoryId=&triage=NEW&page=
GET /api/admin/v1/iiko/items/{itemId} карточка с ценами и модификаторами
GET /api/admin/v1/iiko/items/{itemId}/variant-suggestions
POST /api/admin/v1/iiko/items/{itemId}/triage { status: IGNORED, note }
POST /api/admin/v1/iiko/items/create-product создать товар из набора позиций
GET /api/admin/v1/iiko/items/{itemId}/variant-suggestions
{
"baseName": "панам",
"detectedAxis": "pizza_size",
"candidates": [
{ "itemId": "63ff2041-…", "itemSizeId": 512, "name": "Панам 25 см", "sku": "219971",
"detectedOption": {"code": "25", "name": "25 см"}, "confidence": 0.97, "bound": false },
{ "itemId": "a1b2…", "itemSizeId": 513, "name": "Панам 30 см", "sku": "219972",
"detectedOption": {"code": "30", "name": "30 см"}, "confidence": 0.97, "bound": false }
]
}
POST /api/admin/v1/iiko/items/create-product
{
"name": "Панам",
"variantAxisCode": "pizza_size",
"categoryIds": [3],
"variants": [ { "iikoItemSizeId": 512, "variantOptionCode": "25", "isDefault": true },
{ "iikoItemSizeId": 513, "variantOptionCode": "30" } ]
}
→ 201 { "id": 101, "slug": "panam", "status": "DRAFT" }
Модификаторы (витринные профили)¶
GET /api/admin/v1/modifier-groups?q=&page= сводный список групп из iiko + профили
PATCH /api/admin/v1/modifier-groups/{groupKey} displayName, uiType, isHidden, isCollapsed
GET /api/admin/v1/modifiers?q=&groupKey=&page=
PATCH /api/admin/v1/modifiers/{iikoItemId} displayName, imageId, position, isHidden
PUT /api/admin/v1/modifier-groups/{groupKey}/order порядок модификаторов внутри группы
Медиа¶
POST /api/admin/v1/media multipart, ≤ 10 МБ, jpeg/png/webp
→ { id, url, width, height, renditions: {…} } ренditions генерируются синхронно
GET /api/admin/v1/media?folder=&q=&page=
GET /api/admin/v1/media/{id}/usages где используется
DELETE /api/admin/v1/media/{id} 409, если используется
PATCH /api/admin/v1/media/{id} alt, focalPoint
Справочники и настройки¶
GET /api/admin/v1/organizations
GET /api/admin/v1/terminals?organizationId=
GET /api/admin/v1/stop-lists?organizationId=&terminalId=
GET /api/admin/v1/external-menus
PATCH /api/admin/v1/external-menus/{id} priceCategoryId, isEnabled, organizationIds
GET /api/admin/v1/price-categories
GET /api/admin/v1/variant-axes · POST · PATCH (+ /options)
GET /api/admin/v1/badges · POST · PATCH · DELETE
Импорты¶
GET /api/admin/v1/sync/runs?job=&status=&page=
GET /api/admin/v1/sync/runs/{id}
POST /api/admin/v1/sync/run { "job": "MENU", "scope": "41188", "mode": "FULL" }
202 Accepted + { runId }, 409 если уже выполняется
GET /api/admin/v1/sync/status сводка: последний успех по каждой задаче, «свежесть» данных
Аудит и пользователи¶
GET /api/admin/v1/audit?entityType=&entityId=&userId=&from=&to=&page=
GET /api/admin/v1/users · POST · PATCH · DELETE
GET /api/admin/v1/roles · POST · PATCH
5.4. Версионирование и совместимость¶
- Публичное API версионируется в пути (
/api/v1). В рамках версии допускаются только аддитивные изменения: новые поля, новые методы. Удаление или смена типа поля — новая версия. - Админский API живёт вместе с админкой и версионируется формально (
/api/admin/v1), но меняется свободно — оба артефакта деплоятся вместе. - OpenAPI генерируется из кода, спецификация публикуется в CI; ломающее изменение публичного контракта ловится diff-проверкой спецификации и валит сборку.
5.5. Производительность и защита¶
| Механизм | Публичное API | Админское |
|---|---|---|
| Кеш | снапшот в Redis + ETag + CDN |
нет |
| Rate limit | 60 запросов/мин на IP на /menu, 600/мин на остальное |
600/мин на пользователя |
| Сжатие | gzip/br обязательно | gzip |
| Максимальный размер ответа | контроль в тесте: /menu ≤ 1 МБ несжатым |
— |
| Пагинация | обязательна везде, кроме /menu и /categories |
обязательна везде |
| CORS | список доменов витрины | домен админки |