GET /internal/catalog/menu — вычисляемое меню для POS
Назначение
Возвращает полностью собранное меню для конкретной ТТ за один HTTP-запрос: категории, товары с актуальными ценами и стоп-листом, модификаторы. Эта ручка существует, чтобы POS-терминал (через POS BFF) не делал три отдельных запроса к Catalog Service (/categories, /products, /price-lists/:id), а получал готовый снапшот меню одним вызовом.
Кто и как дёргает
- POS-терминал → POS BFF →
GET /internal/catalog/menu(Catalog Service, S2S)- Это internal-эндпоинт, защищён сервисным токеном (
X-Service-Token), не Bearer JWT- Прямого доступа к нему с фронта/терминала нет — только через BFF
Контракт
| Параметр | Значение |
|---|---|
| Method | GET |
| Path | /internal/catalog/menu |
| Auth | Service token (X-Service-Token) |
| Content-Type | — (GET без тела) |
| Response Content-Type | application/json |
Query Parameters
| Параметр | Тип | Обязательный | Описание |
|---|---|---|---|
franchise_id | uuid | Да | Tenant scope. Любая ошибка/отсутствие → 400 |
price_list_id | uuid | Нет | Конкретный прейскурант. Если не передан — берётся дефолтный прейскурант франшизы. Если у франшизы нет дефолтного — price_list_id = null и все товары вернутся с price = null |
store_id | uuid | Нет | ID торговой точки. Если передан — категории и товары из стоп-листа этой ТТ исключаются. Если не передан — возвращается полный каталог франшизы без фильтрации стоп-листами |
Логика сборки ответа (как формируется)
- Резолв прейскуранта. Если
price_list_idне передан → ищетсяprice_listгдеfranchise_id = ?иis_default = true. Если не нашлось —null - Загрузка стоп-листов (только если передан
store_id):product_stop_listпоstore_id→ set товаров в стопеcategory_stop_listпоstore_id→ set категорий в стопе
- Категории франшизы — все активные (
is_active = true), отсортированные поdisplay_order - Товары франшизы — все активные (
status = 'active') - Фильтрация стоп-листами:
- Категории, попавшие в
category_stop_list→ выкидываются - Товары, попавшие в
product_stop_list→ выкидываются - Товары, чья категория попала в
category_stop_list→ тоже выкидываются (даже если самого товара в product-стопе нет)
- Категории, попавшие в
- Цены товаров —
price_list_itemsпоprice_list_id(Map<product_id, price>) - Цены опций модификаторов —
price_list_modifier_itemsпоprice_list_id(Map<modifier_option_id, price>) - Модификаторы — для каждого товара батчем грузятся
product_modifiers, затем по каждому биндингу —modifier_groupsиmodifier_optionsгруппы - Сборка ответа в порядке:
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_id | uuid | null | Какой прейскурант реально применился. null означает «прейскуранта нет — все цены товаров будут null» |
categories | array | Активные категории франшизы (после фильтрации стоп-листами ТТ) |
products | array | Активные товары франшизы (после фильтрации стоп-листами ТТ) |
categories[]
| Поле | Тип | Что значит |
|---|---|---|
id | uuid | Идентификатор категории |
name | string | Название категории |
parent_id | uuid | null | Родительская категория (если иерархия). null для корневых |
sort_order | integer | Порядок отображения в UI (меньше → выше) |
products[]
| Поле | Тип | Что значит |
|---|---|---|
id | uuid | Идентификатор товара |
name | string | Название товара |
category_id | uuid | К какой категории относится |
image_url | string | null | Полный URL картинки в S3, либо null |
is_open_price | boolean | true — товар со свободной ценой (кассир вводит на терминале) |
price | decimal | null | Цена из прейскуранта. null если: (а) is_open_price=true; (б) товар отсутствует в прейскуранте; (в) price_list_id не определён |
modifiers | array | Все привязанные группы модификаторов |
products[].modifiers[]
| Поле | Тип | Что значит |
|---|---|---|
group_id | uuid | Идентификатор группы модификаторов |
group_name | string | Название группы (отображается над опциями) |
binding_type | enum | free или structural — см. ниже |
min | integer | Минимум опций, которые должен выбрать кассир. 0 = опционально |
max | integer | Максимум опций. Effective = override_* из product_modifiers, иначе *_amount группы |
options | array | Опции этой группы |
binding_type— два режима модификаторов
structural— структурная вариация товара (например «Размер: S/M/L» для пиццы). Опции возвращаются без цены (price: null), потому что цена варианта зашита в сам товар, не в опциюfree— добавка с собственной ценой (например «Сироп: ваниль/карамель» к латте). Цена опции берётся изprice_list_modifier_itemsпо выбранному прейскуранту
products[].modifiers[].options[]
| Поле | Тип | Что значит |
|---|---|---|
id | uuid | Идентификатор опции |
name | string | Название опции |
price | decimal | 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, кассир введёт сумму на терминале
Ошибки
| HTTP | error.code | Когда |
|---|---|---|
| 400 | VALIDATION_ERROR | Не передан franchise_id или невалидный UUID |
| 401 | UNAUTHORIZED | Отсутствует/неверный X-Service-Token |
| 500 | INTERNAL_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