Backend реализован
Коммиты в
erp-catalog-service:9f1dfc0(миграция/entities/repos/DTO) +427a231(Service/Controller/Render/Kafka/Cron). Сборкаmvn package✅.
Catalog Service — BR 4.1
Контракты
- Data Model — 3 новые таблицы (
external_menus,external_menu_categories,external_menu_items)- API — 18 endpoint’ов в секции «External Menus»
- Events — топик
external_menu.updated+ self-consumecatalog.product.deleted- Бизнес-логика: Внешние меню
Что делаем
Миграция Liquibase
-
src/main/resources/db/changelog/0XX_external_menus.xml:external_menus— все поля по Data Model + CHECK constraints + UNIQUE на slug + UNIQUE name среди активных через partial indexexternal_menu_categories— FK CASCADE кexternal_menus, FK SET NULL кcategoriesexternal_menu_items— FK CASCADE к external_menus и external_menu_categories, FK кproductsБЕЗ CASCADE (orphan-логика на app-уровне), UNIQUE (external_menu_id, product_id)- Все индексы по Data Model
- Регистрация в
db.changelog-master.xml
Entities + Repositories
-
com.erp.catalog.entity.ExternalMenu(JPA + audit timestamps) -
com.erp.catalog.entity.ExternalMenuCategory -
com.erp.catalog.entity.ExternalMenuItem -
ExternalMenuRepository:Page<ExternalMenu> findByFranchiseIdAndStatusInOrderByUpdatedAtDesc(...)для списка активныхPage<ExternalMenu> findByFranchiseIdAndStatusOrderByArchivedAtDesc(... 'archived')для корзиныOptional<ExternalMenu> findBySlug(String slug)для рендераboolean existsByFranchiseIdAndNameAndStatusNot(...)для NAME_EXISTS checkList<ExternalMenu> findByStatusAndArchivedAtBefore(...)для cron hard-delete
-
ExternalMenuCategoryRepository:findAllByExternalMenuIdOrderByDisplayOrder(...)
-
ExternalMenuItemRepository:findAllByExternalMenuIdAndCategoryIdOrderByDisplayOrder(...)findAllByProductId(UUID)для orphan-каскадаexistsByExternalMenuIdAndProductId(...)для ITEM_DUPLICATE check
DTO
-
dto/request/:CreateExternalMenuRequest(name, channel, store_id, template, slug)UpdateExternalMenuRequest(partial)CreateExternalMenuCategoryRequest,UpdateExternalMenuCategoryRequestCreateExternalMenuItemRequest(product_id, category_id)UpdateExternalMenuItemRequest(partial overrides)ReorderRequest(массив id с display_order)
-
dto/response/:ExternalMenuResponse(краткий — для списка)ExternalMenuDetailsResponse(с категориями и items)ExternalMenuCategoryResponseExternalMenuItemResponse(включая computed fields: catalog_price, price_list_price, effective_price, in_stop_list, product_name, product_image_url)
Бизнес-логика
-
ExternalMenuService:Page<ExternalMenuResponse> list(JwtUser caller, ListFilters filters)— фильтрация по scopeExternalMenuDetailsResponse getById(UUID id, JwtUser caller)— со scope checkExternalMenuDetailsResponse create(CreateExternalMenuRequest req, JwtUser caller):- validate name uniqueness (NAME_EXISTS)
- validate slug uniqueness (SLUG_TAKEN)
- generate slug if NULL
- INSERT → return details
ExternalMenuDetailsResponse update(UUID id, UpdateExternalMenuRequest req, JwtUser caller):- check channel not changed (CHANNEL_LOCKED)
- revalidate name/slug uniqueness on change
void publish(UUID id, JwtUser caller):- check menu not empty (BUSINESS_RULE_VIOLATION)
- status=draft → published
- publish
external_menu.updatedwithchange_type=published
void unpublish(...)— обратная операцияExternalMenuDetailsResponse duplicate(UUID id, JwtUser caller)— копировать с категориями и itemsvoid softDelete(UUID id, JwtUser caller)— status=archived, archived_at=NOWExternalMenuDetailsResponse restore(UUID id, JwtUser caller):- validate name/slug uniqueness on restore
- status=draft, archived_at=NULL
ExternalMenuCategoryResponse createCategory(...),update,delete,reorderExternalMenuItemResponse addItem(UUID menuId, CreateExternalMenuItemRequest req, JwtUser caller):- validate product exists, in scope franchise (PRODUCT_NOT_IN_FRANCHISE)
- validate uniqueness (ITEM_DUPLICATE)
- INSERT
- publish
external_menu.updated(change_type=content_changed) если меню published
updateItem(overrides),deleteItem,reorderItems,restoreItem(orphan → ok)void handleProductDeleted(UUID productId)— каскад orphan’ов:- найти все items с product_id=X
- UPDATE status=‘orphan’
- publish
external_menu.updatedдля каждого затронутого меню (change_type=item_orphaned)
-
[~]
EffectivePriceCalculator:- Логика inline в
ExternalMenuService.computeEffectivePrice— сейчас иерархияoverride > 0(TODO: подключить price_list lookup, см. методgetPriceListPriceв Service) - Caffeine-кэш для batch-выдач — отложен на BR 4.1.1
- Логика inline в
-
StopListChecker:- Реализовано inline в
ExternalMenuService.isInStopList(productId, storeId, categoryId)— учитывает product-level и category-level стопы - Используется и в
getById(дляin_stop_listполя), и в render-endpoint’е
- Реализовано inline в
Render endpoint и WebSocket
-
ExternalMenuRenderController:GET /r/{slug}→ 200 HTML — self-contained страница с inline JSON в<script id="menu-data">и vanilla JS-рендером (3 шаблона: grid/list/slider)- 30-сек polling fallback через
setInterval(() => location.reload(), 30000) - validate
status=publishedиначе 404 (getPublishedBySlug) GET /r/{slug}/dataотдельным endpoint’ом не нужен — JSON встраивается прямо в HTML
- WebSocket gateway — DEFERRED to BR 4.1.1:
- В P0 sub-second push не реализован, polling fallback (30 сек) удовлетворяет требованию live-update
- На BR 4.1.1:
spring-boot-starter-websocket+ Kafka→WS bridge + security для/r/{slug}/stream
- [~] Bundle для рендера (отдельный):
- В P0 — рендер inline в Java-контроллере (
ExternalMenuRenderController.buildHtml), не Vite-проект в erp-admin/web - Можно вынести в отдельный bundle позже, но в P0 это overkill — HTML самодостаточен (~3 KB + JSON)
- В P0 — рендер inline в Java-контроллере (
Offline ZIP
- [~] OfflineZipBuilder — реализован inline в
ExternalMenuRenderController.exportZip:- ZIP содержит
index.html+data.json(без WebSocket-кода — index.html подгружает соседний data.json через fetch) - Не реализовано (BR 4.1.1): скачивание product images в
assets/images/— сейчас HTML ссылается на абсолютныеimage_url(S3 public URL), что ломает offline-режим если S3 недоступен - CSS встроен inline в HTML — отдельного
style.cssнет - Endpoint
GET /api/v1/external-menus/{id}/export.zip✅ работает с JWT
- ZIP содержит
Kafka
-
ExternalMenuEventPublisher(publisher topicexternal_menu.updated):publishUpdated(externalMenuId, changeType, affectedItemIds)- Через transactional outbox (
catalog_outboxуже есть для BR 3.4) — переиспользуем для надёжности
-
ProductDeletedConsumer— слушаетcatalog.product.deleted:@KafkaListener(topics = "catalog.product.deleted", groupId = "catalog-self-external-menu")- Вызывает
ExternalMenuService.handleProductDeleted(productId)
Cron — hard-delete архивных
-
ExternalMenuArchiveCleanupJob:@Scheduled(cron = "0 0 4 * * ?")— раз в сутки в 04:00 UTCfindByStatusAndArchivedAtBefore('archived', NOW - 30d)→ DELETE (CASCADE удалит категории и items)- Логировать:
Cleaned up N archived external menus
Permission gates
- [~] Spring Security:
/r/**— public (без JWT), новыйSecurityFilterChain✅/api/v1/external-menus/**— JWT обязателен (попадает под/api/**chain) ✅- Scope-проверка в service-слое через
loadMenuInScope(id, caller)— проверяетcaller.franchiseId == menu.franchiseId✅ - TODO BR 4.1.1: granular permission gates (
external_menus.read/external_menus.edit) черезPermissionEvaluator— сейчас любой JWT-пользователь франшизы может всё в рамках scope
Конфигурация
-
application.yml:app.webhook-base-url(WEBHOOK_BASE_URL, defaulthttp://localhost:3004) — для построения live URLapp.external-menus.cleanup-cron(default0 0 3 * * *) — daily archive cleanupspring.kafka.consumer.*— добавлен consumer config для self-consumecatalog.product.deletedzip-max-size-mb— не нужен, ZIP формируется в памяти и небольшой (~5 KB)
Тесты
- Unit-тесты
ExternalMenuService: CRUD, публикация пустого (validation), restore с конфликтом name, orphan-каскад — TODO - WireMock-тесты для render endpoint — TODO
-
@SpringBootTestдля проверки Liquibase миграции (поднимается на test-DB) — TODO - Integration test для WebSocket — после реализации в BR 4.1.1
Тесты не написаны
P0 — manual smoke на demo-coffee после деплоя (см. Verification ниже). Покрытие тестами — задача BR 4.1.1 перед боевой раскаткой.
Verification
После деплоя:
- На demo-coffee: создать меню «Demo Bar Screen» с 5 товарами через POST endpoints (curl с JWT)
- Опубликовать
- Открыть
https://erp-test.nirbi.ru/r/{slug}в браузере — должно отрендериться меню - PATCH на item → ждать ≤30 сек → меню в браузере обновилось без F5
- Поставить товар в стоп-лист каталога → исчезает с рендера
- Удалить товар в каталоге → item в меню → status=orphan, скрыт с рендера
- Восстановить товар в каталоге → нажать «Восстановить» в админке → status=ok, появился
- Soft-delete меню → live URL → 404
- Restore из корзины → меню в draft, переопубликовать → live URL работает
- Скачать ZIP → распаковать на другой машине → открыть index.html без интернета — отображается snapshot
Не делаем
- ❌ Override модификаторов на уровне группы/опции — BR 4.2
- ❌ Push в Yandex.Eda / Коалу — BR 4.2
- ❌ Сезонность / расписание показа — P1+
- ❌ Версионирование (история опубликованных snapshots) — P1+