Внешние меню — 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 нужно увидеть исходные описания (для рерайтинга), нужно либо:

  1. Запрашивать каждый товар отдельно через GET /api/v1/admin/catalog/products/{id} (даст description),
  2. Либо запросить добавление 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 — отображаемое имя (для админа)
  • channeltv_screen (рекламный монитор) или json (JSON-экспорт)
  • store_id — UUID торговой точки или null (тогда стопы не применяются)
  • templategrid / 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 — использовать цену каталога/прейскуранта)
  • visiblefalse спрячет товар на витрине (но он останется в меню)
  • 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 orphanAI знает, что делать, если товар каталога удалён после генерации
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 дней.


Ссылки