Внешние меню — AI Parser Integration
Назначение документа
Контракт для интеграции внешнего AI-сервиса, который получает на вход «сырой каталог» франшизы и возвращает «красивое внешнее меню» (с категориями, переименованиями, описаниями) для рекламного монитора (
channel=tv_screen) или JSON-экспорта.
Архитектура интеграции
┌──────────────────────┐
┌────────────────►│ AI Parser Service │────────────┐
│ GET catalog │ (внешний микро- │ POST menu │
│ (товары+ │ сервис, ваша │ (готовая │
│ категории) │ зона ответст- │ структура)│
│ │ венности) │ │
│ └──────────────────────┘ │
│ ▼
┌──┴───────────────────────────────────────────────────────┐
│ ERP catalog-service │
│ (https://erp-test.nirbi.ru/api/v1/admin/...) │
└───────────────────────────────────────────────────────────┘
AI-сервис вызывается оператором (владельцем франшизы) явно — например, кнопка «Сгенерировать меню для монитора» в редакторе External Menu. ERP не делает callback в AI-сервис — это AI-сервис стучит в ERP.
Авторизация: Bearer JWT владельца франшизы. Permissions нужны:
menu.read(чтение каталога)external_menus.edit(создание меню)
Шаг 1. Получение каталога (input для AI)
1.1 Список товаров
GET /api/v1/admin/catalog/products?per_page=200&status=active
Headers: Authorization: Bearer <JWT>
Query params:
per_page— рекомендую200(максимум, чтоб одним запросом)status=active— только активные (без soft-deleted и paused)type=good— опционально, исключить ингредиенты/полуфабрикатыcategory_id=<uuid>— опционально, только из конкретной категорииsearch— текстовый поиск (для отладки)
Ответ:
{
"data": [
{
"id": "b1ab62dd-4cd5-429d-a366-2d8b55ab04cf",
"name": "Аква Минерале",
"type": "good",
"status": "active",
"sku": null,
"unit_of_measure": "piece",
"category_id": "8dd6e5cf-e7e9-46bf-9bc6-3880f199c79c",
"category_name": "Минералка",
"sort_order": 0,
"is_alcohol": false,
"image_url": null
}
],
"meta": { "page": 1, "per_page": 200, "total": 47 }
}Поле
descriptionотсутствует в спискеListItem не возвращает
descriptionтовара. Если AI нужно увидеть исходные описания (для рерайтинга), нужно либо:
- Запрашивать каждый товар отдельно через
GET /api/v1/admin/catalog/products/{id}(дастdescription),- Либо запросить добавление
descriptionв ListItem (сейчас — feature gap).
1.2 Детали товара (с описанием)
GET /api/v1/admin/catalog/products/{id}
Ответ (только важные для AI поля):
{
"data": {
"id": "b1ab62dd-...",
"name": "Аква Минерале",
"description": "Минеральная вода, 0.5л, негазированная",
"category_id": "8dd6e5cf-...",
"image_url": "https://erp-test.nirbi.ru/s3/...",
"status": "active",
"type": "good",
"is_alcohol": false
}
}1.3 Список категорий каталога
GET /api/v1/admin/catalog/categories
Ответ:
{
"data": [
{ "id": "8dd6e5cf-...", "name": "Минералка", "sort_order": 0, "deleted_at": null },
{ "id": "ea04845c-...", "name": "Газировка", "sort_order": 1, "deleted_at": null }
]
}AI использует это, если хочет связать новые категории внешнего меню с существующими каталог-категориями (поле
original_category_id). Это необязательно — AI может создавать совершенно новые категории, отличные от каталога.
Шаг 2. Создание готового меню (output AI → ERP)
Нет batch endpoint'а
На текущий момент создание меню — это N последовательных REST-вызовов (создать меню, потом каждую категорию, потом каждый item). Для типичного меню из 4 категорий и 30 товаров — ~65 запросов. Подробнее в § Рекомендация P1: bulk-create.
2.1 Создать пустое меню
POST /api/v1/admin/external-menus
{
"name": "AI-сгенерированное меню для бара",
"channel": "tv_screen",
"store_id": "a1e704cf-6524-44f4-bac4-c3c380dd471d",
"template": "grid",
"slug": "bar-screen-001"
}Поля:
name— отображаемое имя (для админа)channel—tv_screen(рекламный монитор) илиjson(JSON-экспорт)store_id— UUID торговой точки илиnull(тогда стопы не применяются)template—grid/slider/list(только для tv_screen)slug— опционально, если не указан — генерируется автоматически (menu-{8 hex})
Ответ (201 Created):
{
"data": {
"id": "a1181bb5-86d4-4056-a1c4-d0630fe0a108",
"name": "...",
"slug": "bar-screen-001",
"status": "draft",
"live_url": null,
"categories": []
}
}Запоминаем id — он нужен для всех следующих шагов.
2.2 Создать категорию меню
POST /api/v1/admin/external-menus/{menuId}/categories
{
"name": "🍹 Авторские коктейли",
"original_category_id": "8dd6e5cf-..."
}Поля:
name— имя категории на витрине (AI может ставить эмоджи, эмодзи-префиксы и т.д.)original_category_id— UUID каталог-категории (опционально). Если задано, фронт показывает связь.icon_url— URL иконки категории (опционально)
Ответ: 201 + { data: { id, name, items: [] } }. Запоминаем id категории.
2.3 Добавить товар в категорию (якорная связь)
POST /api/v1/admin/external-menus/{menuId}/items
{
"product_id": "b1ab62dd-4cd5-429d-a366-2d8b55ab04cf",
"category_id": "{categoryId из шага 2.2}"
}Якорная связь обязательна
Каждый item обязан ссылаться на существующий
product_idиз каталога. AI не может «выдумать» товар — только маппить на существующие. Если каталог-товар будет удалён, item автоматически перейдёт вstatus=orphan.
Ответ: 201 + { data: { id, product_id, product_name, effective_price, ... } }. Запоминаем id item’а — нужен для override.
2.4 Override полей товара (имя/описание/цена/видимость)
PATCH /api/v1/admin/external-menus/{menuId}/items/{itemId}
{
"override_name": "Айс Латте «Тропики» 🌴",
"override_description": "Освежающий охлаждённый кофе с нотками манго и кокоса",
"override_price": 350.00,
"visible": true
}Поля (все опциональные):
override_name— переопределить имя товара на витринеoverride_description— описание (его нет в каталоге для обычной выдачи!)override_price— цена для этого канала (рублей; null — использовать цену каталога/прейскуранта)visible—falseспрячет товар на витрине (но он останется в меню)category_id— переместить item в другую категорию
Иерархия цены при рендере
override_price > price_list_price (если меню привязано к ТТ) > catalog_price. То естьoverride_priceимеет наивысший приоритет.
2.5 Опубликовать меню
POST /api/v1/admin/external-menus/{menuId}/publish
После этого status=published, live_url становится доступным.
Шаг 3. Проверка результата
После публикации меню доступно по live URL:
https://erp-test.nirbi.ru/r/{slug}
Это HTML-страница (Chromium kiosk-mode совместимая) с встроенным <script id="menu-data" type="application/json">{...}</script> — JSON меню целиком. AI-сервис может через WebSocket получать live-обновления (см. 09-Frontend Specs/Админка Франшизы/Внешние меню — Монитор.md).
Также доступен JSON-экспорт:
GET /api/v1/admin/external-menus/{menuId}/export.json
Подходит ли API под AI-задачу
✅ Что работает хорошо
| Что | Почему важно для AI |
|---|---|
| Чтение каталога — обычный REST с пагинацией | AI получает структурированный input одним запросом (per_page=200) |
Якорная связь product_id обязательна | AI не может «выдумать» товар → структурный invariant, легко проверять |
| Override-поля на уровне item’а | AI может «улучшать» имя/описание/цену не меняя каталог |
effective_price рассчитывается на сервере | AI не должен дублировать логику цен (price_list, override) |
Soft delete + status orphan | AI знает, что делать, если товар каталога удалён после генерации |
| Slug автогенерируется + кастомизируется | AI может задать читаемый slug (bar-summer-2026) или оставить дефолт |
⚠ Ограничения / gap’ы
| Проблема | Воркараунд | Постоянный фикс (P1) |
|---|---|---|
| Нет batch endpoint для создания меню — N+M+1 запросов | AI делает запросы последовательно, ~1с на 60 endpoint’ов через keep-alive | Добавить POST /external-menus/bulk-create (см. ниже) |
description отсутствует в GET /products (list) | AI делает GET /{id} для каждого товара (N запросов) | Добавить description в ProductListItem |
| Нет endpoint’а «получить меню как plain JSON-структуру для перегенерации» | AI хранит ответ от ERP в своём кэше | Не критично |
| Нет webhook’а «меню изменилось — перегенерируй» | AI должен опрашивать или работать только on-demand | Не критично для P0 |
Категория не имеет description | Если AI хочет писать description к категории — нужно добавить поле | P2 |
| Нет API «удалить все категории/items одним вызовом» (для перегенерации) | DELETE по одной категории (cascade удалит items) | Добавить POST /{id}/reset |
❌ Что не подходит
Сейчас критичных блокеров нет. AI-парсер можно интегрировать как есть, но строго ожидать N+M+1 RTT.
Рекомендация P1: добавить bulk-create
Это feature request для будущей BR
Чтобы AI-сервис мог одним вызовом создавать меню целиком — добавить:
POST /api/v1/admin/external-menus/bulk-create
{
"name": "AI-сгенерированное меню",
"channel": "tv_screen",
"store_id": "<uuid>",
"template": "grid",
"slug": "bar-screen-ai-001",
"categories": [
{
"name": "🍹 Коктейли",
"original_category_id": "<uuid|null>",
"items": [
{
"product_id": "<uuid из каталога>",
"override_name": "Текила Санрайз 🌅",
"override_description": "Классический коктейль...",
"override_price": 450.00,
"visible": true,
"display_order": 0
}
]
}
]
}Ответ: 201 Created + { data: ExternalMenuDetailsResponse } (полная структура созданного меню).
Транзакция: всё или ничего. Если хоть один product_id невалидный — 400 без частичной записи.
Эстимейт: 1–2 часа для бэка (Spring @Transactional + валидация), 1 час фронтенд для опционального использования.
Пример workflow AI-парсера (текущий API, без bulk-create)
# Псевдокод
# 1. Получить каталог
products = GET("/api/v1/admin/catalog/products?per_page=200&status=active&type=good")
# Если нужны description — для каждого товара отдельный запрос (плохо, но работает)
products_full = [GET(f"/api/v1/admin/catalog/products/{p.id}") for p in products.data]
# 2. AI обрабатывает входные данные → возвращает желаемую структуру
ai_result = ai_service.generate_menu(products_full)
# ai_result = { name, channel, store_id, template, categories: [{ name, items: [...] }] }
# 3. Создать пустое меню
menu = POST("/api/v1/admin/external-menus", {
"name": ai_result.name,
"channel": ai_result.channel,
"store_id": ai_result.store_id,
"template": ai_result.template,
})
# 4. Цикл по категориям и items
for cat in ai_result.categories:
created_cat = POST(f"/api/v1/admin/external-menus/{menu.id}/categories", {
"name": cat.name,
"original_category_id": cat.original_category_id,
})
for item in cat.items:
created_item = POST(f"/api/v1/admin/external-menus/{menu.id}/items", {
"product_id": item.product_id,
"category_id": created_cat.id,
})
# Override
if any([item.override_name, item.override_description, item.override_price]):
PATCH(f"/api/v1/admin/external-menus/{menu.id}/items/{created_item.id}", {
"override_name": item.override_name,
"override_description": item.override_description,
"override_price": item.override_price,
"visible": item.visible,
})
# 5. Опубликовать
POST(f"/api/v1/admin/external-menus/{menu.id}/publish")
# 6. Готово — live URL: https://erp-test.nirbi.ru/r/{menu.slug}Тестовый аккаунт для разработки
- URL:
https://erp-test.nirbi.ru - Email:
demo@nirbi.ru - Password:
admin123
JWT получается через POST /api/v1/admin/auth/login ({ email, password }), token живёт 15 минут, refresh token — 7 дней.
Ссылки
- Внешние меню — бизнес-спецификация модуля
- Catalog Service API — полный контракт
- Каталог — спецификация каталога товаров
- Стоп-листы — стоп-листы (применяются автоматически на рендере)