Внешние меню

Источники требований

Отличие от вычисляемого меню каталога

Базовое меню для POS/KDS/склада — это вычисляемая сущность каталога (Каталог · Вычисляемое меню): «активные товары + прейскурант + модификаторы − стопы». Оно всегда актуально, не настраивается.

Внешние меню — это отдельная управляемая сущность для каналов вне ERP (рекламный монитор в зале, JSON для интеграций, в P1 — Yandex.Eda и Коала). Куратор сам выбирает товары, переопределяет имена/цены/видимость, публикует. Связь с каталогом сохраняется через якорный product_id.

Конструктор внешних меню. Владелец франшизы выбирает товары из каталога, при необходимости переопределяет поля под конкретный канал, публикует — получает live URL для монитора либо JSON-экспорт. Привязка к торговой точке опциональна (можно меню на всю сеть, можно на одну ТТ). Меню можно создавать сколько угодно: один товар может быть в N меню одновременно.

В P0 поддерживаем два канала:

  • tv_screen — рекламный монитор над кассой (digital menu board) с live HTML-страницей в Chromium kiosk-mode
  • json — универсальный JSON-экспорт для любых будущих интеграций

Каналы для агрегаторов (yandex_eda, koala) добавляются в BR 4.2.


Сущности

External Menu

Заголовок внешнего меню — то что владелец видит в списке /external-menus.

ПолеОбязательностьОписание
ИмяОбязательно«Бар — основной экран», «Универсальный JSON для 1С». Уникально в рамках франшизы (с учётом архива — нет; в активных — да)
КаналОбязательноtv_screen / json. В BR 4.2 добавятся yandex_eda, koala
Привязка к ТТОпциональноNULL — на всю сеть; иначе конкретная ТТ. Влияет на применение прейскуранта и стоп-листа
Шаблон рендераОбязательно для tv_screengrid / slider / list
SlugОбязательно для tv_screenURL-friendly идентификатор для live URL /r/{slug}. Авто-генерируется при создании (menu-{8-char-id}), владелец может изменить. Уникален глобально. Regex: ^[a-z0-9-]{3,40}$
СтатусОбязательноdraft / published / archived
Дата создания / обновленияОбязательно (auto)
Дата архивацииОпциональноЗаполняется при soft-delete. После 30 дней — физическое удаление cron’ом

External Menu Category

Категории внутри одного меню — можно переименовать или создать кастомные.

ПолеОбязательностьОписание
ИмяОбязательноНазвание категории как будет показано на мониторе/в JSON
Привязка к каталог-категорииОпциональноFK на categories каталога. Если задана — имя наследуется при пустом override; если NULL — категория полностью кастомная (например «Хиты продаж»)
Порядок отображенияОбязательноПорядковый номер для рендера
ИконкаОпциональноURL иконки для шаблонов где она поддерживается

External Menu Item

Единица меню — товар каталога с возможным override-ом полей.

ПолеОбязательностьОписание
Связь с товаром каталогаОбязательноFK на products.id — якорь, не nullable. Никогда не «свободные» товары
Категория в этом менюОбязательноFK на external_menu_category
Override имениОпциональноЕсли NULL — берём product.name
Override описанияОпциональноЕсли NULL — берём product.description
Override ценыОпциональноЕсли NULL — fallback по иерархии (см. правила)
ВидимостьОбязательноtrue / false — скрыть без удаления
ПорядокОбязательноDisplay order внутри категории
СтатусОбязательно (auto)ok — нормально / orphan — оригинал удалён в каталоге

Override фотографий — не входит в BR 4.1

Картинка всегда берётся из каталога 1:1. Если потребуются «маркетинговые» фотки специально под канал — это отдельная BR.


Бизнес-правила

  1. Якорная связь обязательна. Нельзя создать external_menu_item без существующего product_id. Никаких «свободных» товаров.
  2. Один товар может быть в N меню одновременно. Никаких UNIQUE на product_id. Изменение в каталоге отражается на всех меню (если override не задан).
  3. Иерархия цены: override_price > price_list_price > product.price. Где price_list_price — цена из прейскуранта для ТТ, к которой привязано меню. Если меню не привязано к ТТ (store_id IS NULL) — берётся базовая цена каталога.
  4. Stop-list скрывает автоматически. При рендере фильтруем по текущему стопу ТТ. Раскрытие из стопа не предусмотрено в P0 — упрощение. Если меню store_id IS NULL — стоп не применяется (показываем всё активное).
  5. Удаление товара в каталоге → каскадно status=orphan во всех external_menu_item. Не удаляем физически — для аудита и возможности восстановления. На рендере orphan-items скрываются. В админке появляется badge «Удалён в каталоге».
  6. Восстановление товара. Если в каталоге удалённый товар восстановили, владелец должен явно нажать «Восстановить» в external_menu_item. Авто-восстановления нет — защита от подмены товара.
  7. Категории. Могут быть либо связаны с каталог-категорией (тогда имя наследуется при пустом override), либо кастомными (например, «Хиты продаж» — собранные руками для монитора).
  8. Slug. Авто-генерируется при создании (формат menu-{8-char-id}), уникален глобально. Владелец может изменить через настройки меню. Валидация: ^[a-z0-9-]{3,40}$, уникальность среди всех external_menu.
  9. Live-обновление монитора ≤30 сек. При изменении меню (override, видимость, добавление/удаление item, попадание в стоп) монитор должен подхватить изменение в течение 30 секунд (через WebSocket / polling fallback).
  10. Soft delete. Удалённое меню переходит в статус archived и хранится 30 дней. В этот период доступно во вкладке «Корзина» с кнопкой «Восстановить». По истечении — cron физически удаляет (вместе с категориями и items). Live URL архивного меню сразу перестаёт работать (404).
  11. Публикация. Меню в статусе draft не доступно через live URL и не отдаётся в JSON-export — возвращает 404. Только published рендерится.
  12. Бесконечное число меню. Никаких лимитов на количество external_menu в рамках франшизы.

Статусы и жизненный цикл

stateDiagram-v2
    [*] --> draft: Создание
    draft --> published: Публикация
    published --> draft: Снять с публикации
    published --> archived: Удаление (soft)
    draft --> archived: Удаление (soft)
    archived --> draft: Восстановление из корзины
    archived --> [*]: Hard delete cron'ом через 30 дней
СтатусLive URL работаетВ списке менюВидимость в архиве
draft❌ 404
published
archived❌ 404✅ (вкладка «Корзина»)

CRUD / Управление

Создание

Мастер из 2 шагов:

  1. Базовая информация
    • Имя (обязательно)
    • Канал (tv_screen / json)
    • Если tv_screen — выбор шаблона (grid / slider / list)
    • ТТ-привязка (опционально, single-select из ТТ scope’а пользователя)
  2. Slug (только для tv_screen)
    • Поле slug pre-filled menu-{8-char-id} — можно изменить
    • Валидация уникальности при потере фокуса

После сохранения — переход в редактор (см. ниже) с пустым меню в статусе draft.

Редактирование

Двухпанельный редактор. Подробная фронт-спека — будет в Шаге 3 декомпозиции BR 4.1. Кратко:

  • Левая панель: каталог товаров + поиск + фильтр по категории
  • Правая панель: дерево категорий → items в этом меню
  • Drag-drop из левой в правую → создаёт external_menu_item с дефолтным override (NULL)
  • Клик на item → раскрытие override-формы (имя / описание / цена / visibility)
  • Сверху — настройки (имя, шаблон, slug, ТТ)

Публикация / Снятие с публикации

  • «Опубликовать» — кнопка в карточке меню. Меняет статус с draft на published. Live URL сразу начинает отдавать рендер.
  • «Снять с публикации» — статус с published обратно на draft. Live URL → 404.

Никаких черновиков-параллельно-с-published в P0 (нет версионирования). Если нужно «попробовать изменения, потом откатить» — копировать меню как новое, тестировать, потом подменять slug.

Удаление (soft) и восстановление

  • «Удалить» в действиях меню — модалка подтверждения «Меню будет перемещено в Корзину. Через 30 дней — окончательное удаление».
  • Меню переходит в status=archived, заполняется archived_at. Live URL сразу 404.
  • В разделе «Корзина» (отдельная вкладка вверху списка) — список архивных меню за последние 30 дней.
  • «Восстановить» в Корзине → возвращает меню в status=draft (не сразу published — владелец должен явно опубликовать снова, чтобы убедиться в корректности).
  • Cron раз в сутки удаляет физически меню с archived_at < NOW() - 30 days (вместе с категориями и items).

Каналы рендера

tv_screen — Монитор над кассой

Целевое железо: мини-ПК или встроенный SoC телевизора в Chromium kiosk-mode. Подключение через автостарт Windows: chrome --kiosk https://erp-test.nirbi.ru/r/{slug}.

Шаблоны (в P0 минимум 2):

  • grid — классическая сетка карточек (фото + имя + цена). Подходит для кофеен, бургерных
  • slider — крупный фокус на одном товаре, авто-перелистывание (~5 сек/товар). Подходит для рекламного режима
  • (опционально) list — длинный список с большой ценой справа. Для табло «бизнес-ланч»

Адаптивность: 16:9 1080p (основной), 16:9 4K, 9:16 вертикальный — через CSS clamp() и media queries.

Live-обновление:

  • WebSocket / SSE подключение от страницы к серверу
  • При изменении меню сервер шлёт событие → страница плавно перерисовывает (без F5)
  • Polling fallback каждые 30 сек если WebSocket упал

Offline ZIP:

  • Кнопка «Скачать офлайн-копию» в админке
  • Архив: index.html + style.css + assets/{images,fonts}/
  • Открывается локально без интернета (двойной клик на index.html)
  • Без auto-refresh — snapshot на момент скачивания
  • Для редких оффлайн-точек

json — Универсальный экспорт

  • Endpoint: GET /api/v1/external-menus/{id}/export.json (Bearer JWT, требует external_menus.read)
  • Структура:
    {
      "menu_id": "uuid",
      "name": "string",
      "channel": "json",
      "store_id": "uuid|null",
      "generated_at": "ISO-8601",
      "categories": [
        {
          "id": "uuid",
          "name": "string",
          "items": [
            {
              "product_id": "uuid",
              "name": "string",
              "description": "string|null",
              "price": 250.00,
              "image_url": "string|null",
              "in_stop_list": false
            }
          ]
        }
      ]
    }
  • Stop-list применён: товары в стопе либо отсутствуют в массиве items, либо помечены in_stop_list: true (поведение по умолчанию — отсутствуют).

Ролевая матрица

ДействиеВладелец франшизыВладелец партнёраМенеджер ТТКассир
Просмотр списка✅ Все меню франшизы✅ Только меню своих ЮЛ
Создание✅ (только в свои ТТ)
Редактирование✅ (свои)
Публикация / снятие✅ (свои)
Удаление в корзину✅ (свои)
Восстановление из корзины✅ (свои)
Скачать offline ZIP✅ (свои)
Открыть live URL /r/{slug}публично — кто знает URL

Permissions (новые, добавляются в Роли):

  • external_menus.read — просмотр списка, редактора (read-only), live URL
  • external_menus.edit — CRUD + публикация + удаление + восстановление + скачивание ZIP

Список меню

Страница /external-menus.

Колонки

  • Имя
  • Канал (бейдж: «Монитор» / «JSON»)
  • ТТ (агрегат: имя ТТ или «Вся сеть» если store_id NULL)
  • Шаблон (только для tv_screen)
  • Статус (бейдж: «Черновик» / «Опубликовано» / «В архиве»)
  • Дата изменения
  • Действия (меню с операциями)

Фильтры

  • По каналу (tv_screen / json / все)
  • По статусу (draft / published / все)
  • По ТТ (если у пользователя несколько в scope)

Поиск

  • По имени меню (debounce 300 ms)

Сортировка

  • По дате изменения (default desc)
  • По имени

Пагинация

  • 20 на страницу
  • Query params: page, per_page

Действия на панели

  • «Добавить меню» (кнопка primary) — открывает мастер создания
  • «Корзина» — переход на вкладку архивных меню (badge с числом если есть архивные)

Меню действий строки

  • Открыть редактор
  • Опубликовать / Снять с публикации
  • Скачать offline ZIP (только для tv_screen published)
  • Копировать live URL в буфер
  • Дублировать (создаёт копию с именем «{Имя} — копия» в draft)
  • Удалить (в корзину)

Редактор меню

URL: /external-menus/{id}/edit

Layout — двухпанельный:

┌─ Шапка ──────────────────────────────────────────────────┐
│  Имя меню (inline edit)  Канал  Шаблон  Slug   │  Сохр. │
│                                                  │  Опубл.│
├──────────────────────────────────────────────────────────┤
│ Каталог товаров    │ Категории и items этого меню         │
│ ┌─ поиск ─────┐    │ ▾ Категория «Кофе»                   │
│ │             │    │   ├─ ☰ Капучино          250 ₽  ⚙   │
│ │ ▶ Кофе      │    │   ├─ ☰ Латте              280 ₽  ⚙   │
│ │ ▶ Десерты   │    │   └─ ☰ Эспрессо          200 ₽  ⚙   │
│ │ ▶ Завтраки  │    │ ▾ Категория «Десерты»                │
│ │             │    │   └─ ☰ Чизкейк            350 ₽  ⚙   │
│ │  ── товары  │    │                                      │
│ │  • Капучино │    │ + Добавить категорию                 │
│ │  • Латте    │    │                                      │
│ │  • ...      │    │                                      │
│ └─────────────┘    │                                      │
└──────────────────────────────────────────────────────────┘

Drag-drop: товар из левой панели → правая. Создаётся external_menu_item с дефолтами (override-поля NULL). Категория — куда дропнули.

Клик на item «⚙» → раскрытие override-формы:

  • Имя (с подсказкой текущего значения из каталога)
  • Описание
  • Цена (с показом effective price из иерархии — override > price_list > catalog)
  • Видимость (toggle)
  • Удалить из меню

Превью — кнопка «Открыть превью» наверху справа. Открывает /r/{slug}?preview=1 в новой вкладке (рендер draft-версии для проверки).

Подробная фронт-спека09-Frontend Specs/Админка Франшизы/Внешние меню — Конструктор.md (создаётся в Шаге 3 декомпозиции BR 4.1).


Связи с другими модулями

  • Каталог — источник товаров и категорий. Якорная связь обязательна для всех external_menu_item. Удаление товара в каталоге каскадно меняет статус items на orphan
  • Прейскуранты — fallback цены если override не задан и меню привязано к ТТ
  • Стоп-листы — автоматическое скрытие при рендере (per-store)
  • Торговые точки — опциональная привязка меню к конкретной ТТ
  • Роли — новые permissions external_menus.read / external_menus.edit
  • BR 4.2 (будущее) — расширение этой спеки: override модификаторов на уровне групп и опций + push в yandex_eda / koala через Aggregator Service

Что НЕ входит в P0

  • Override модификаторов (на уровне групп / опций) — отдельная BR 4.2
  • Push в Yandex.Eda / Коалу — отдельная BR 4.2 через Aggregator Service
  • Override фотографий товара — фотки из каталога 1:1
  • Сезонность / расписание показа меню (завтраки 7-11, обеды 11-15) — P1+
  • Версионирование меню (история опубликованных снапшотов с откатом) — P1+
  • Маркетинговые баннеры / акции на мониторе — P1+
  • Несколько шаблонов одновременно — одно меню = один шаблон. Нужен другой → копировать меню
  • Раскрытие товара из стоп-листа — нет, скрытие автоматическое и финальное

Ссылки