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-consume catalog.product.deleted
  • Бизнес-логика: Внешние меню

Что делаем

Миграция Liquibase

  • src/main/resources/db/changelog/0XX_external_menus.xml:
    • external_menus — все поля по Data Model + CHECK constraints + UNIQUE на slug + UNIQUE name среди активных через partial index
    • external_menu_categories — FK CASCADE к external_menus, FK SET NULL к categories
    • external_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 check
    • List<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, UpdateExternalMenuCategoryRequest
    • CreateExternalMenuItemRequest (product_id, category_id)
    • UpdateExternalMenuItemRequest (partial overrides)
    • ReorderRequest (массив id с display_order)
  • dto/response/:
    • ExternalMenuResponse (краткий — для списка)
    • ExternalMenuDetailsResponse (с категориями и items)
    • ExternalMenuCategoryResponse
    • ExternalMenuItemResponse (включая 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) — фильтрация по scope
    • ExternalMenuDetailsResponse getById(UUID id, JwtUser caller) — со scope check
    • ExternalMenuDetailsResponse 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.updated with change_type=published
    • void unpublish(...) — обратная операция
    • ExternalMenuDetailsResponse duplicate(UUID id, JwtUser caller) — копировать с категориями и items
    • void softDelete(UUID id, JwtUser caller) — status=archived, archived_at=NOW
    • ExternalMenuDetailsResponse restore(UUID id, JwtUser caller):
      • validate name/slug uniqueness on restore
      • status=draft, archived_at=NULL
    • ExternalMenuCategoryResponse createCategory(...), update, delete, reorder
    • ExternalMenuItemResponse 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
  • StopListChecker:

    • Реализовано inline в ExternalMenuService.isInStopList(productId, storeId, categoryId) — учитывает product-level и category-level стопы
    • Используется и в getById (для in_stop_list поля), и в render-endpoint’е

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)

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

Kafka

  • ExternalMenuEventPublisher (publisher topic external_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 UTC
    • findByStatusAndArchivedAtBefore('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, default http://localhost:3004) — для построения live URL
    • app.external-menus.cleanup-cron (default 0 0 3 * * *) — daily archive cleanup
    • spring.kafka.consumer.* — добавлен consumer config для self-consume catalog.product.deleted
    • zip-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

После деплоя:

  1. На demo-coffee: создать меню «Demo Bar Screen» с 5 товарами через POST endpoints (curl с JWT)
  2. Опубликовать
  3. Открыть https://erp-test.nirbi.ru/r/{slug} в браузере — должно отрендериться меню
  4. PATCH на item → ждать ≤30 сек → меню в браузере обновилось без F5
  5. Поставить товар в стоп-лист каталога → исчезает с рендера
  6. Удалить товар в каталоге → item в меню → status=orphan, скрыт с рендера
  7. Восстановить товар в каталоге → нажать «Восстановить» в админке → status=ok, появился
  8. Soft-delete меню → live URL → 404
  9. Restore из корзины → меню в draft, переопубликовать → live URL работает
  10. Скачать ZIP → распаковать на другой машине → открыть index.html без интернета — отображается snapshot

Не делаем

  • ❌ Override модификаторов на уровне группы/опции — BR 4.2
  • ❌ Push в Yandex.Eda / Коалу — BR 4.2
  • ❌ Сезонность / расписание показа — P1+
  • ❌ Версионирование (история опубликованных snapshots) — P1+

Ссылки