Внешние меню
Источники требований
- BR 4.1 — External Menu Builder и монитор
- BR 4.2 — продолжение (override модификаторов, push в Yandex.Eda и Коалу) — в backlog’е, в этой спеке только заложен hook
Отличие от вычисляемого меню каталога
Базовое меню для POS/KDS/склада — это вычисляемая сущность каталога (Каталог · Вычисляемое меню): «активные товары + прейскурант + модификаторы − стопы». Оно всегда актуально, не настраивается.
Внешние меню — это отдельная управляемая сущность для каналов вне ERP (рекламный монитор в зале, JSON для интеграций, в P1 — Yandex.Eda и Коала). Куратор сам выбирает товары, переопределяет имена/цены/видимость, публикует. Связь с каталогом сохраняется через якорный
product_id.
Конструктор внешних меню. Владелец франшизы выбирает товары из каталога, при необходимости переопределяет поля под конкретный канал, публикует — получает live URL для монитора либо JSON-экспорт. Привязка к торговой точке опциональна (можно меню на всю сеть, можно на одну ТТ). Меню можно создавать сколько угодно: один товар может быть в N меню одновременно.
В P0 поддерживаем два канала:
tv_screen— рекламный монитор над кассой (digital menu board) с live HTML-страницей в Chromium kiosk-modejson— универсальный JSON-экспорт для любых будущих интеграций
Каналы для агрегаторов (yandex_eda, koala) добавляются в BR 4.2.
Сущности
External Menu
Заголовок внешнего меню — то что владелец видит в списке /external-menus.
| Поле | Обязательность | Описание |
|---|---|---|
| Имя | Обязательно | «Бар — основной экран», «Универсальный JSON для 1С». Уникально в рамках франшизы (с учётом архива — нет; в активных — да) |
| Канал | Обязательно | tv_screen / json. В BR 4.2 добавятся yandex_eda, koala |
| Привязка к ТТ | Опционально | NULL — на всю сеть; иначе конкретная ТТ. Влияет на применение прейскуранта и стоп-листа |
| Шаблон рендера | Обязательно для tv_screen | grid / slider / list |
| Slug | Обязательно для tv_screen | URL-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.
Бизнес-правила
- Якорная связь обязательна. Нельзя создать
external_menu_itemбез существующегоproduct_id. Никаких «свободных» товаров. - Один товар может быть в N меню одновременно. Никаких UNIQUE на
product_id. Изменение в каталоге отражается на всех меню (если override не задан). - Иерархия цены:
override_price > price_list_price > product.price. Гдеprice_list_price— цена из прейскуранта для ТТ, к которой привязано меню. Если меню не привязано к ТТ (store_id IS NULL) — берётся базовая цена каталога. - Stop-list скрывает автоматически. При рендере фильтруем по текущему стопу ТТ. Раскрытие из стопа не предусмотрено в P0 — упрощение. Если меню
store_id IS NULL— стоп не применяется (показываем всё активное). - Удаление товара в каталоге → каскадно
status=orphanво всехexternal_menu_item. Не удаляем физически — для аудита и возможности восстановления. На рендере orphan-items скрываются. В админке появляется badge «Удалён в каталоге». - Восстановление товара. Если в каталоге удалённый товар восстановили, владелец должен явно нажать «Восстановить» в
external_menu_item. Авто-восстановления нет — защита от подмены товара. - Категории. Могут быть либо связаны с каталог-категорией (тогда имя наследуется при пустом override), либо кастомными (например, «Хиты продаж» — собранные руками для монитора).
- Slug. Авто-генерируется при создании (формат
menu-{8-char-id}), уникален глобально. Владелец может изменить через настройки меню. Валидация:^[a-z0-9-]{3,40}$, уникальность среди всехexternal_menu. - Live-обновление монитора ≤30 сек. При изменении меню (override, видимость, добавление/удаление item, попадание в стоп) монитор должен подхватить изменение в течение 30 секунд (через WebSocket / polling fallback).
- Soft delete. Удалённое меню переходит в статус
archivedи хранится 30 дней. В этот период доступно во вкладке «Корзина» с кнопкой «Восстановить». По истечении — cron физически удаляет (вместе с категориями и items). Live URL архивного меню сразу перестаёт работать (404). - Публикация. Меню в статусе
draftне доступно через live URL и не отдаётся в JSON-export — возвращает 404. Толькоpublishedрендерится. - Бесконечное число меню. Никаких лимитов на количество 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 шагов:
- Базовая информация
- Имя (обязательно)
- Канал (
tv_screen/json) - Если
tv_screen— выбор шаблона (grid/slider/list) - ТТ-привязка (опционально, single-select из ТТ scope’а пользователя)
- Slug (только для
tv_screen)- Поле slug pre-filled
menu-{8-char-id}— можно изменить - Валидация уникальности при потере фокуса
- Поле slug pre-filled
После сохранения — переход в редактор (см. ниже) с пустым меню в статусе 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 URLexternal_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_screenpublished) - Копировать 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+
- ❌ Несколько шаблонов одновременно — одно меню = один шаблон. Нужен другой → копировать меню
- ❌ Раскрытие товара из стоп-листа — нет, скрытие автоматическое и финальное