Доработка BR 3.4 — Категории как теги + доп. поля товара

Контекст

После первого выката BR 3.4 выявили что PayKeeper ims-api поддерживает теги товаров (GET /tags, POST /tags, PATCH/DELETE /tag/{id}) — это закрывает пробел «категории префиксом в имени». Параллельно вскрылись недосинхронизированные поля товара: measure, price_can_be_changed, возможно неверный item_type.

Решения

  1. Категории → теги (multi-tag). Товар получает тег на каждый уровень пути. Пример: товар в «Пицца / Сырные» → tag_ids: [tag_пицца, tag_сырные]. При коллизиях имён (если «Сырные» в двух местах) — смотрим на реальном меню; если мешает, переезжаем на полный путь в имени тега.
  2. Префикс в имени товара убираем. Имя товара теперь = имя товара (без category_path).
  3. Системный тег _no_tag_ (id=32398) — не трогаем, не создаём, не удаляем.
  4. Маппинг хранится per-account в новой таблице pk_tags — PK раздаёт id per-аккаунт.

API PK (подтверждено DevTools)

МетодPathBodyВозврат
GET/tags{data: [{tag: {id, name, ...}, products_count, ...}]}
POST/tags{"name": "...", "description": null}{data: {id, name, description, sort_position}}
PATCH/tag/{id}{"name": "..."}{data: {...}}
DELETE/tag/{id}{data: null}

В payload товара:

  • tag_ids: [53866, 53904] — top-level, авторитетное
  • product.tags: [{id, name, ...}] — UI-эхо, дублируем для надёжности

Фаза A — Catalog Service (~2 часа)

Репо: erp-catalog-service.

A.1 События категории

  • Новые топики Kafka: catalog.category.upserted, catalog.category.deleted (3 partitions, retention 7 days)
  • В CatalogEventPublisher добавить:
    • publishCategoryUpserted(Category, parentPath) — payload: {category_id, franchise_id, name, parent_id, path: ["Пицца", "Сырные"]}
    • publishCategoryDeleted(UUID categoryId, UUID franchiseId, LocalDateTime)
  • Хуки в CategoryService.createCategory, updateCategory, deleteCategory
  • При переименовании/перемещении категории → также перепубликовать затронутые товары (как сейчас), но теперь adapter пересобирает tag_ids автоматически

A.2 ProductExpansionService — отдать category_ids

  • В VirtualProduct dto → вместо categoryPath поле List<UUID> categoryAncestorIds (текущая + все родители) и List<String> categoryAncestorNames (для референса)
  • В /expand endpoint — отдаём этот массив
  • Имя товара больше не включает префикс категории
  • В /full-snapshot endpoint — аналогично

A.3 KafkaConfig

  • Добавить 2 новых NewTopic bean

Фаза B — Paykeeper Adapter (~4-5 часов)

Репо: erp-paykeeper-adapter.

B.1 Миграция 005 — pk_tags

005-br-3-4-tags.xml:

pk_tags (
  id uuid PK,
  account_id uuid NOT NULL,
  erp_category_id uuid NOT NULL,
  pk_tag_id int NOT NULL,
  pk_tag_name varchar(255) NOT NULL,
  last_synced_at timestamp NOT NULL,
  created_at timestamp NOT NULL default now()
)
UNIQUE (account_id, erp_category_id)
UNIQUE (account_id, pk_tag_id)
INDEX (account_id)

FK на paykeeper_accounts ON DELETE CASCADE.

B.2 Entities + Repository

  • com.erp.paykeeper.entity.PkTag
  • PkTagRepository:
    • findByAccountIdAndErpCategoryId(...)
    • findAllByAccountId(...)
    • findByAccountIdAndPkTagId(...)

B.3 PayKeeperImsClient — +4 метода тегов

  • List<Map<String,Object>> listTags(PaykeeperAccount)GET /tags
  • Map<String,Object> createTag(PaykeeperAccount, String name)POST /tags body {"name": name}
  • Map<String,Object> updateTag(PaykeeperAccount, Integer tagId, String name)PATCH /tag/{id} body {"name": name}
  • void deleteTag(PaykeeperAccount, Integer tagId)DELETE /tag/{id}
  • Те же retry/backoff/throttle что и для продуктов
  • Парсинг: ответ flat {id, name, description, sort_position} — НЕ обёрнут в {tag: {...}} как в GET list

B.4 TagSyncService (новый)

Центральная логика работы с тегами. Методы:

  • Integer resolveTagId(PaykeeperAccount, UUID erpCategoryId, String erpCategoryName) — lookup в pk_tags, если нет → создать в PK, сохранить маппинг, вернуть pk_tag_id
  • void renameTag(PaykeeperAccount, UUID erpCategoryId, String newName) — lookup → PATCH → update mapping
  • void deleteTag(PaykeeperAccount, UUID erpCategoryId) — lookup → DELETE → remove mapping (но что делать если тег ещё используется другими товарами? — проверка через products_count из GET /tags)
  • List<Integer> resolveTagIdsForCategoryPath(PaykeeperAccount, List<UUID> ancestorIds, List<String> ancestorNames) — для multi-tag: вернуть все tag_id цепочки

B.5 CategoryEventConsumer (новый)

@KafkaListener на catalog.category.upserted, catalog.category.deleted, groupId paykeeper-adapter-catalog.

  • upserted: для каждого active аккаунта резолвим franchise → через TagSyncService.resolveTagId создаём/проверяем тег в PK; если имя изменилось — PATCH. Затем триггерим пересинк товаров этой категории (через resync-products-in-category — см. B.6)
  • deleted: если у тега products_count == 0 → DELETE в PK + удаление mapping. Иначе — skip с warning (товары сначала должны переехать в другую категорию или удалиться)
  • Dedup через event_id в Caffeine

B.6 Обновление CatalogEventConsumer (product upserted)

В логике diff’а:

  • Получаем expansion → для каждого virtual product собираем category_ancestor_ids из expansion
  • TagSyncService.resolveTagIdsForCategoryPath(...) → список List<Integer> tag_ids
  • В outbox payload теперь идёт:
    {
      "name": "Капучино L",        // без префикса
      "sku": "...",
      "price": 350.0,
      "tax": "vat20",
      "measure": "pcs",             // новое
      "price_can_be_changed": false, // новое
      "item_type": "goods_uncoded",  // TBD проверить
      "tag_ids": [53866, 53904],    // новое
      "hash": "..."
    }
  • Hash: добавить tag_ids в конкатенацию чтобы изменение тегов триггерило PATCH

B.7 PkOutboxWorker — upsert с тегами

  • В executeUpsertProduct(entry):
    • Из payload берём tag_ids
    • POST payload body:
      {
        "product": {
          "name": "...",
          "price": ...,
          "tax": "...",
          "sku": "...",
          "item_type": "...",
          "measure": "...",
          "price_can_be_changed": false,
          "description": null,
          "barcode": null,
          "tru_code": null,
          "item_code_is_mandatory": false,
          "tags": [{"id": N, "name": "...", "description": null, "sort_position": 0, "checked": true}]  // UI-эхо
        },
        "tag_ids": [...]  // top-level
      }
    • PATCH: те же поля, те же tag_ids (PATCH заменяет набор тегов полностью)

B.8 Поле measure

  • В Catalog Service у товара есть unit/measure (проверить поле в products таблице)
  • Маппинг ERP → PK: штpcs, кгkg, гgr, лl, млml (уточнить по UI PK)
  • Прокинуть через VirtualProduct dto → outbox payload

B.9 item_type

  • Сейчас шлём "product" — payload из UI показывает "goods_uncoded"
  • Ручная проверка: попробовать "product" на тестовом аккаунте, если PK ругается — переключаем на "goods_uncoded"
  • Если разные типы нужны (служебная товар / подакциз / маркированный) — добавить в Catalog Service enum, пока default goods_uncoded

B.10 price_can_be_changed

  • Hardcode false (мы цену диктуем)
  • Если в будущем нужен кейс «кассир может переоткрыть цену» — делаем конфиг

B.11 Обновление reconcile (ночной cron)

  • CatalogSyncService.doReconcile дополнительно:
    • Перед товарами прогнать tags: создать в PK отсутствующие, удалить осиротевшие
    • При expansion товара использовать новый resolveTagIdsForCategoryPath

Фаза C — Admin UI (~30 минут)

Репо: erp-admin.

  • В CatalogSyncBlock.tsx — опциональный счётчик «Тегов синхронизировано: N/M» (nice-to-have, можно пропустить)
  • Ничего обязательного — теги прозрачны для админа, он видит только товары

Фаза D — Тесты + спека (~1 час)

D.1 Спека

  • Обновить 08-Specs/Интеграции/PayKeeper.md:
    • убрать абзац про префикс категории в имени
    • добавить секцию «Категории → теги» с правилами: multi-tag на весь путь, PK раздаёт id per-account, синхронизируется через catalog.category.* топики
    • список полей payload обновить: +measure, +price_can_be_changed, +tag_ids
    • обновить правило hash (входит tag_ids)
  • Обновить 03-Services/Paykeeper Adapter/{Data Model,API,Events,Overview}.md:
    • Data Model: +таблица pk_tags
    • Events: +2 топика consume (category.*)
    • Overview: упомянуть TagSyncService

D.2 Тесты unit

  • PayKeeperImsClientTest — парсинг ответов тегов (flat vs обёрнутый)
  • TagSyncServiceTest — resolve-or-create, rename propagation, delete с проверкой products_count
  • CategoryEventConsumerTest — routing per account, dedup

D.3 e2e smoke

  • Создать категорию в ERP → появился тег в ЛК PK
  • Переименовать → в ЛК PK переименовался
  • Создать товар в категории → у товара 2 тега (родитель + сам)
  • Переместить категорию (другой родитель) → tag_ids у товаров пересобрались

Фаза E — Deploy

  1. Commit + push erp-catalog-service
  2. /deploy-all catalog-service
  3. Commit + push erp-paykeeper-adapter
  4. /deploy-all paykeeper-adapter
  5. Смоук по D.3 на тесте
  6. Очистка каталога в ЛК PK (через purge-кнопку) → ручной re-sync → проверить что товары приехали с правильными тегами и без префикса в имени

Порядок выполнения

Строго последовательно: A → B → D (spec) → E (deploy). Тесты (D.2) параллельно с B. Фаза C пропускается в MVP.

Оценка: 1 рабочий день (8 часов с учётом тестов + смоук + доки).


Открытые вопросы

  • Поле measure — есть ли у нас в ERP? Проверить products.unit или аналог. Если нет — hardcode pcs
  • Коллизия имён тегов на уровне пути — ждём реального меню, пока флажок (логгируем)
  • products_count > 0 при delete_category — что делать? Решение: skip + warning, потребует ручного вмешательства (админ сначала переносит товары)
  • Лимит тегов на товар в PK — не задокументирован. 5-6 должно быть ок

Ссылки