GET /internal/catalog/menu — вычисляемое меню для POS

Назначение

Возвращает полностью собранное меню для конкретной ТТ за один HTTP-запрос: категории, товары с актуальными ценами и стоп-листом, модификаторы. Эта ручка существует, чтобы POS-терминал (через POS BFF) не делал три отдельных запроса к Catalog Service (/categories, /products, /price-lists/:id), а получал готовый снапшот меню одним вызовом.

Кто и как дёргает

  • POS-терминалPOS BFFGET /internal/catalog/menu (Catalog Service, S2S)
  • Это internal-эндпоинт, защищён сервисным токеном (X-Service-Token), не Bearer JWT
  • Прямого доступа к нему с фронта/терминала нет — только через BFF

Контракт

ПараметрЗначение
MethodGET
Path/internal/catalog/menu
AuthService token (X-Service-Token)
Content-Type— (GET без тела)
Response Content-Typeapplication/json

Query Parameters

ПараметрТипОбязательныйОписание
franchise_iduuidДаTenant scope. Любая ошибка/отсутствие → 400
price_list_iduuidНетКонкретный прейскурант. Если не передан — берётся дефолтный прейскурант франшизы. Если у франшизы нет дефолтного — price_list_id = null и все товары вернутся с price = null
store_iduuidНетID торговой точки. Если передан — категории и товары из стоп-листа этой ТТ исключаются. Если не передан — возвращается полный каталог франшизы без фильтрации стоп-листами

Логика сборки ответа (как формируется)

  1. Резолв прейскуранта. Если price_list_id не передан → ищется price_list где franchise_id = ? и is_default = true. Если не нашлось — null
  2. Загрузка стоп-листов (только если передан store_id):
    • product_stop_list по store_id → set товаров в стопе
    • category_stop_list по store_id → set категорий в стопе
  3. Категории франшизы — все активные (is_active = true), отсортированные по display_order
  4. Товары франшизы — все активные (status = 'active')
  5. Фильтрация стоп-листами:
    • Категории, попавшие в category_stop_list → выкидываются
    • Товары, попавшие в product_stop_list → выкидываются
    • Товары, чья категория попала в category_stop_list → тоже выкидываются (даже если самого товара в product-стопе нет)
  6. Цены товаровprice_list_items по price_list_id (Map<product_id, price>)
  7. Цены опций модификаторовprice_list_modifier_items по price_list_id (Map<modifier_option_id, price>)
  8. Модификаторы — для каждого товара батчем грузятся product_modifiers, затем по каждому биндингу — modifier_groups и modifier_options группы
  9. Сборка ответа в порядке: price_list_id, categories[], products[]

Open-price товары

Если у товара is_open_price = true — поле price в ответе всегда null, независимо от того, есть ли он в прейскуранте. Кассир будет вводить цену вручную на терминале (свободная цена).

Структура ответа

{
  "data": {
    "price_list_id": "uuid | null",
    "categories": [
      {
        "id": "uuid",
        "name": "string",
        "parent_id": "uuid | null",   // для подкатегорий
        "sort_order": "integer"
      }
    ],
    "products": [
      {
        "id": "uuid",
        "name": "string",
        "category_id": "uuid",
        "image_url": "string | null",
        "is_open_price": "boolean",
        "price": "decimal | null",     // null если open_price=true ИЛИ нет в прейскуранте
        "modifiers": [
          {
            "group_id": "uuid",
            "group_name": "string",
            "binding_type": "free | structural",
            "min": "integer",          // effective: override из product_modifiers, иначе default группы
            "max": "integer",          // effective: override из product_modifiers, иначе default группы
            "options": [
              {
                "id": "uuid",
                "name": "string",
                "price": "decimal | null"  // ненулевая только для binding_type=free
              }
            ]
          }
        ]
      }
    ]
  }
}

Описание полей по слоям

Корневой объект data

ПолеТипЧто значит
price_list_iduuid | nullКакой прейскурант реально применился. null означает «прейскуранта нет — все цены товаров будут null»
categoriesarrayАктивные категории франшизы (после фильтрации стоп-листами ТТ)
productsarrayАктивные товары франшизы (после фильтрации стоп-листами ТТ)

categories[]

ПолеТипЧто значит
iduuidИдентификатор категории
namestringНазвание категории
parent_iduuid | nullРодительская категория (если иерархия). null для корневых
sort_orderintegerПорядок отображения в UI (меньше → выше)

products[]

ПолеТипЧто значит
iduuidИдентификатор товара
namestringНазвание товара
category_iduuidК какой категории относится
image_urlstring | nullПолный URL картинки в S3, либо null
is_open_pricebooleantrue — товар со свободной ценой (кассир вводит на терминале)
pricedecimal | nullЦена из прейскуранта. null если: (а) is_open_price=true; (б) товар отсутствует в прейскуранте; (в) price_list_id не определён
modifiersarrayВсе привязанные группы модификаторов

products[].modifiers[]

ПолеТипЧто значит
group_iduuidИдентификатор группы модификаторов
group_namestringНазвание группы (отображается над опциями)
binding_typeenumfree или structural — см. ниже
minintegerМинимум опций, которые должен выбрать кассир. 0 = опционально
maxintegerМаксимум опций. Effective = override_* из product_modifiers, иначе *_amount группы
optionsarrayОпции этой группы

binding_type — два режима модификаторов

  • structural — структурная вариация товара (например «Размер: S/M/L» для пиццы). Опции возвращаются без цены (price: null), потому что цена варианта зашита в сам товар, не в опцию
  • free — добавка с собственной ценой (например «Сироп: ваниль/карамель» к латте). Цена опции берётся из price_list_modifier_items по выбранному прейскуранту

products[].modifiers[].options[]

ПолеТипЧто значит
iduuidИдентификатор опции
namestringНазвание опции
pricedecimal | nullЦена опции из прейскуранта. Только для binding_type=free. Для structural всегда null

Пример вызова

GET /internal/catalog/menu?franchise_id=11111111-1111-1111-1111-111111111111&store_id=22222222-2222-2222-2222-222222222222 HTTP/1.1
Host: catalog-service:3004
X-Service-Token: <internal-token>

Пример ответа

Сценарий: кофейня. Франшиза «Bean & Bun», ТТ на ул. Ленина. У ТТ в стоп-листе вся категория «Десерты» и один сэндвич. Прейскурант — дефолтный для франшизы.

{
  "data": {
    "price_list_id": "8a3c1b00-0000-4000-8000-000000000001",
    "categories": [
      {
        "id": "c0000000-0000-4000-8000-000000000001",
        "name": "Кофе",
        "parent_id": null,
        "sort_order": 10
      },
      {
        "id": "c0000000-0000-4000-8000-000000000002",
        "name": "Чай",
        "parent_id": null,
        "sort_order": 20
      },
      {
        "id": "c0000000-0000-4000-8000-000000000003",
        "name": "Сэндвичи",
        "parent_id": null,
        "sort_order": 30
      }
    ],
    "products": [
      {
        "id": "p0000000-0000-4000-8000-000000000001",
        "name": "Эспрессо",
        "category_id": "c0000000-0000-4000-8000-000000000001",
        "image_url": "https://s3.erp-test.nirbi.ru/catalog/espresso.jpg",
        "is_open_price": false,
        "price": "120.00",
        "modifiers": [
          {
            "group_id": "g0000000-0000-4000-8000-000000000001",
            "group_name": "Размер",
            "binding_type": "structural",
            "min": 1,
            "max": 1,
            "options": [
              { "id": "o0000000-0000-4000-8000-000000000001", "name": "Одинарный", "price": null },
              { "id": "o0000000-0000-4000-8000-000000000002", "name": "Двойной", "price": null }
            ]
          }
        ]
      },
      {
        "id": "p0000000-0000-4000-8000-000000000002",
        "name": "Латте",
        "category_id": "c0000000-0000-4000-8000-000000000001",
        "image_url": "https://s3.erp-test.nirbi.ru/catalog/latte.jpg",
        "is_open_price": false,
        "price": "220.00",
        "modifiers": [
          {
            "group_id": "g0000000-0000-4000-8000-000000000002",
            "group_name": "Молоко",
            "binding_type": "structural",
            "min": 1,
            "max": 1,
            "options": [
              { "id": "o0000000-0000-4000-8000-000000000010", "name": "Коровье", "price": null },
              { "id": "o0000000-0000-4000-8000-000000000011", "name": "Овсяное", "price": null },
              { "id": "o0000000-0000-4000-8000-000000000012", "name": "Миндальное", "price": null }
            ]
          },
          {
            "group_id": "g0000000-0000-4000-8000-000000000003",
            "group_name": "Сироп",
            "binding_type": "free",
            "min": 0,
            "max": 3,
            "options": [
              { "id": "o0000000-0000-4000-8000-000000000020", "name": "Ваниль", "price": "30.00" },
              { "id": "o0000000-0000-4000-8000-000000000021", "name": "Карамель", "price": "30.00" },
              { "id": "o0000000-0000-4000-8000-000000000022", "name": "Лесной орех", "price": "40.00" }
            ]
          }
        ]
      },
      {
        "id": "p0000000-0000-4000-8000-000000000003",
        "name": "Чай чёрный",
        "category_id": "c0000000-0000-4000-8000-000000000002",
        "image_url": null,
        "is_open_price": false,
        "price": "150.00",
        "modifiers": []
      },
      {
        "id": "p0000000-0000-4000-8000-000000000004",
        "name": "Сэндвич с курицей",
        "category_id": "c0000000-0000-4000-8000-000000000003",
        "image_url": "https://s3.erp-test.nirbi.ru/catalog/sandwich-chicken.jpg",
        "is_open_price": false,
        "price": "320.00",
        "modifiers": []
      },
      {
        "id": "p0000000-0000-4000-8000-000000000005",
        "name": "Своя цена (товар на вес)",
        "category_id": "c0000000-0000-4000-8000-000000000003",
        "image_url": null,
        "is_open_price": true,
        "price": null,
        "modifiers": []
      }
    ]
  }
}

В этом примере:

  • Категория «Десерты» не вернулась — она в category_stop_list ТТ
  • Сэндвич «Сэндвич с тунцом» (для примера) не вернулся — он в product_stop_list ТТ
  • У «Эспрессо» только структурный модификатор «Размер» — цены опций null
  • У «Латте» два модификатора: структурный «Молоко» (без цен) и free «Сироп» (с ценами из прейскуранта)
  • «Чай чёрный» без модификаторов и без картинки — image_url: null, modifiers: []
  • «Своя цена» — open-price товар: is_open_price: true, price: null, кассир введёт сумму на терминале

Ошибки

HTTPerror.codeКогда
400VALIDATION_ERRORНе передан franchise_id или невалидный UUID
401UNAUTHORIZEDОтсутствует/неверный X-Service-Token
500INTERNAL_ERRORЛюбая внутренняя ошибка БД/маппинга

Что не считается ошибкой

  • Несуществующий franchise_id → 200, categories: [], products: [], price_list_id: null
  • Несуществующий price_list_id → 200, price_list_id: <переданный>, у всех товаров price: null
  • У франшизы нет дефолтного прейскуранта и price_list_id не передан → 200, price_list_id: null, у всех товаров price: null
  • Несуществующий store_id → 200, фильтрация по стоп-листам ничего не выкинет (стоп-листов просто нет)

Замечания по производительности

  • Все товары/модификаторы загружаются батчами (findByProductIdIn), без N+1 запросов
  • Внутри маппинга модификатора есть точечные обращения modifierGroupRepository.findById — потенциальный N+1 при большом количестве биндингов; в текущем объёме каталога приемлемо, в будущем переписать на single-query JOIN
  • Эндпоинт не кэшируется на стороне сервиса — каждый вызов идёт в БД. Кэш (если понадобится) ставится на стороне POS BFF

Источник кода

  • Контроллер: erp-catalog-service/src/main/java/com/erp/catalog/controller/InternalCatalogMenuController.java
  • DTO-обёртка: erp-catalog-service/src/main/java/com/erp/catalog/dto/ApiResponse.java
  • Защита: SecurityConfig#internalSecurityFilterChain — фильтр X-Service-Token

Ссылки