Доработка BR 3.4 — Категории как теги + доп. поля товара
Контекст
После первого выката BR 3.4 выявили что PayKeeper ims-api поддерживает теги товаров (GET /tags, POST /tags, PATCH/DELETE /tag/{id}) — это закрывает пробел «категории префиксом в имени». Параллельно вскрылись недосинхронизированные поля товара:
measure,price_can_be_changed, возможно неверныйitem_type.
Решения
- Категории → теги (multi-tag). Товар получает тег на каждый уровень пути. Пример: товар в «Пицца / Сырные» →
tag_ids: [tag_пицца, tag_сырные]. При коллизиях имён (если «Сырные» в двух местах) — смотрим на реальном меню; если мешает, переезжаем на полный путь в имени тега. - Префикс в имени товара убираем. Имя товара теперь = имя товара (без category_path).
- Системный тег
_no_tag_(id=32398) — не трогаем, не создаём, не удаляем. - Маппинг хранится per-account в новой таблице
pk_tags— PK раздаёт id per-аккаунт.
API PK (подтверждено DevTools)
| Метод | Path | Body | Возврат |
|---|---|---|---|
| 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
- В
VirtualProductdto → вместоcategoryPathполеList<UUID> categoryAncestorIds(текущая + все родители) иList<String> categoryAncestorNames(для референса) - В
/expandendpoint — отдаём этот массив - Имя товара больше не включает префикс категории
- В
/full-snapshotendpoint — аналогично
A.3 KafkaConfig
- Добавить 2 новых
NewTopicbean
Фаза 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.PkTagPkTagRepository:findByAccountIdAndErpCategoryId(...)findAllByAccountId(...)findByAccountIdAndPkTagId(...)
B.3 PayKeeperImsClient — +4 метода тегов
List<Map<String,Object>> listTags(PaykeeperAccount)→GET /tagsMap<String,Object> createTag(PaykeeperAccount, String name)→POST /tagsbody{"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_idvoid renameTag(PaykeeperAccount, UUID erpCategoryId, String newName)— lookup → PATCH → update mappingvoid 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 заменяет набор тегов полностью)
- Из payload берём
B.8 Поле measure
- В Catalog Service у товара есть unit/measure (проверить поле в
productsтаблице) - Маппинг ERP → PK:
шт→pcs,кг→kg,г→gr,л→l,мл→ml(уточнить по UI PK) - Прокинуть через
VirtualProductdto → 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
- Data Model: +таблица
D.2 Тесты unit
PayKeeperImsClientTest— парсинг ответов тегов (flat vs обёрнутый)TagSyncServiceTest— resolve-or-create, rename propagation, delete с проверкой products_countCategoryEventConsumerTest— routing per account, dedup
D.3 e2e smoke
- Создать категорию в ERP → появился тег в ЛК PK
- Переименовать → в ЛК PK переименовался
- Создать товар в категории → у товара 2 тега (родитель + сам)
- Переместить категорию (другой родитель) → tag_ids у товаров пересобрались
Фаза E — Deploy
- Commit + push
erp-catalog-service /deploy-all catalog-service- Commit + push
erp-paykeeper-adapter /deploy-all paykeeper-adapter- Смоук по D.3 на тесте
- Очистка каталога в ЛК PK (через purge-кнопку) → ручной re-sync → проверить что товары приехали с правильными тегами и без префикса в имени
Порядок выполнения
Строго последовательно: A → B → D (spec) → E (deploy). Тесты (D.2) параллельно с B. Фаза C пропускается в MVP.
Оценка: 1 рабочий день (8 часов с учётом тестов + смоук + доки).
Открытые вопросы
- Поле
measure— есть ли у нас в ERP? Проверитьproducts.unitили аналог. Если нет — hardcodepcs - Коллизия имён тегов на уровне пути — ждём реального меню, пока флажок (логгируем)
products_count > 0при delete_category — что делать? Решение: skip + warning, потребует ручного вмешательства (админ сначала переносит товары)- Лимит тегов на товар в PK — не задокументирован. 5-6 должно быть ок