BR 3.4 — Catalog Sync ERP ↔ PayKeeper

Статус — драфт

Требования сформированы для обсуждения. Не начинать реализацию — нужны правки и обязательное согласование с техподдержкой PayKeeper (см. §9).

Контекст

По BR 3.3 мы отправляем PK фискальную корзину прямо в invoice (service_name.cart из POST /change/invoice/preview/). PK не обращается при этом к собственному каталогу — чек печатается с теми позициями, что мы прислали в момент выставления счёта.

Это работает пока все заказы создаются в нашем POS. Но в двух сценариях обойтись без каталога в ЛК PK нельзя:

  1. Offline/без нашего POS. Владелец точки хочет, чтобы Касса 3в1 от PK работала самостоятельно: кассир выбирает товары прямо на терминале из ЛК PK → формирует счёт → печатает чек. Без актуального каталога кассир работает «на пустом» или вручную заводит товары в ЛК PK.
  2. Оплата через ЛК PK / embed-форма. Клиент (интернет-заказ, выставленная ссылка) выбирает товары сам через интерфейс PK. Для этого в ЛК PK должны быть наши товары с актуальными ценами.

Цель BR — держать каталог PK в синхронизированном состоянии с каталогом ERP: при любом изменении товара/категории/модификатора в админке ERP → изменение автоматически доезжает до всех подключённых PK-аккаунтов.

Привязка ЛК PayKeeper

ЛК PK = на уровне юридического лица (см. paykeeper_accounts.legal_entity_id в Data Model). Один аккаунт, один логин/пароль/informer_seed, один каталог в ЛК PK. У одного ЮЛ может быть N физических терминалов (Касса 3в1) — все видят один и тот же каталог. Если у франшизы несколько ЮЛ (головная + партнёры-франчайзи), у каждого свой PK-кабинет и свой каталог. Наш каталог в ERP — на уровне франшизы, то есть одно и то же меню надо тиражировать в N PK-аккаунтов.


1. Скоуп

Синхронизируется

СущностьПоля
Товары (products)name, price, category, vat, unit_of_measure, is_marked, is_open_price, image_url
Категории (categories)name, parent_id, sort_order, color
Группы модификаторов (modifier_groups)name, min, max, binding_type
Опции модификаторов (modifier_options)name, price, group
Удаление / архивированиеsoft-delete состояния в PK

НЕ синхронизируется

  • Техкарты, ингредиенты, склад — внутренние справочники ERP, для PK не нужны.
  • Стоп-листы per-store — это отдельный механизм PK на терминале, в этой BR не решаем (может быть BR 3.5).
  • Персональные цены / программы лояльности / скидки — sync только базового прайса.
  • Прейскуранты с разными ценами на разных ТТ — если в рамках одного ЮЛ есть вариация цен per-store, отдельная задача. Для первой итерации считаем одну цену на товар в пределах ЮЛ.

2. Архитектура потока

flowchart LR
  CS[Catalog Service] -->|Kafka: catalog.*.upserted/deleted| AD[Paykeeper Adapter]
  AD -->|HTTP POST /ims-api/products/import/| PK[PayKeeper]
  PK -->|webhook /pk-webhooks/catalog/:account_id| AD
  AD -->|Kafka: catalog.pk_upstream_changed| CS

2.1 Delta sync (event-driven)

Базовый механизм, покрывает 90% случаев.

  1. Сотрудник редактирует товар в админке франшизы → Catalog Service сохраняет изменения.
  2. Catalog Service публикует Kafka-событие:
    • catalog.product.upserted — создан/изменён
    • catalog.product.deleted
    • catalog.category.upserted / catalog.category.deleted
    • catalog.modifier_group.upserted / .deleted
  3. Paykeeper Adapter consume’ит → для каждого активного paykeeper_accounts данной franchise_id → кладёт запись в pk_outbox с op_type=upsert_product / etc.
  4. PkOutboxWorker берёт из outbox → вызывает PK /ims-api/... → сохраняет/обновляет mapping в paykeeper_products.

Латентность: ≤ 5 секунд от изменения в админке до появления в ЛК PK.

2.2 Full re-sync (cron + manual)

Safety-net на случай пропущенных webhook’ов / инцидентов.

Cron @03:00 ночью:

  1. Adapter идёт в Catalog Service: GET /internal/catalog/full-snapshot?franchise_id=X.
  2. Строит hash каждого товара/категории.
  3. Сравнивает с paykeeper_products.hash. Расхождения → догружает через тот же outbox.

Manual кнопка в админке — «Пересинхронизировать каталог» на вкладке PayKeeper в карточке ЮЛ. Запускает full re-sync по требованию. Полезно после инцидентов и при онбординге нового PK-аккаунта.

2.3 Reverse sync (опционально — Фаза D)

Если владелец вручную правит цену в ЛК PK — по подтверждению PK из [[_reference/paykeeper/PK-summary-answers#6. Webhook на изменения каталога в ЛК PK|PK-summary-answers #6]] реализация обратного webhook’а «в планах». Когда доступно:

  • PK шлёт webhook POST /pk-webhooks/catalog/:account_id.
  • Adapter валидирует подпись, публикует Kafka catalog.pk_upstream_changed с payload.
  • Catalog Service consume’ит → алерт админу / лог / опционально — перезапись в ERP.

3. Data model (Adapter)

Три новых таблицы mapping’а в paykeeper_adapter_db:

paykeeper_products (
  id               uuid PK,
  account_id       uuid REFERENCES paykeeper_accounts,
  erp_product_id   uuid,
  pk_product_id    varchar(100),
  last_synced_at   timestamp,
  hash             varchar(64),      -- sha256 от сериализованного товара
  status           varchar(20),      -- 'active' | 'deleted'
  last_error       text,             -- последняя ошибка синхронизации
  created_at, updated_at
)
UNIQUE (account_id, erp_product_id)
 
paykeeper_categories (account_id, erp_category_id, pk_category_id, hash, status, ...)
paykeeper_modifier_groups (account_id, erp_group_id, pk_group_id, hash, status, ...)

Используем существующий pk_outbox для at-least-once доставки и retry. Новые значения op_type:

  • upsert_product, delete_product
  • upsert_category, delete_category
  • upsert_modifier_group, delete_modifier_group

Таблица pk_catalog_sync_runs для истории полных прогонов:

pk_catalog_sync_runs (
  id uuid PK,
  account_id uuid,
  started_at, finished_at,
  trigger varchar(20),         -- 'cron' | 'manual' | 'webhook_missed'
  products_upserted int,
  products_deleted int,
  categories_upserted int,
  errors_count int,
  status varchar(20)           -- 'success' | 'partial' | 'failed'
)

4. Catalog Service — что меняется

Сейчас Catalog Service не публикует Kafka-события (подтверждено ресёрчем 24.04.2026 — CatalogEventPublisher отсутствует). Чтобы событийная модель заработала, нужно:

  1. Добавить CatalogEventPublisher с методами:
    • publishProductUpserted(Product)
    • publishProductDeleted(productId, franchiseId)
    • publishCategoryUpserted(Category)
    • publishCategoryDeleted(categoryId, franchiseId)
    • аналогично для модификаторов
  2. Вызывать в сервисных методах createProduct, updateProduct, deleteProduct и т.д.
  3. Добавить endpoint GET /internal/catalog/full-snapshot?franchise_id=X — возвращает полный срез (products + categories + modifier_groups) для re-sync’а. Тяжёлый запрос, rate-limited.

5. Adapter — что добавляется

  • Миграции Liquibase: paykeeper_products, paykeeper_categories, paykeeper_modifier_groups, pk_catalog_sync_runs.
  • PayKeeperImsClient — HTTP-клиент для ims-api (переиспользует basic-auth + token из существующего PayKeeperClient).
  • CatalogEventConsumer — consume’ит catalog.* события, resolve’ит franchise_id → [account_ids], кладёт в outbox.
  • CatalogReconcileJob@Scheduled(cron = "0 0 3 * * ?"): pull snapshot из Catalog Service, diff по hash, apply missing deltas.
  • CatalogSyncController — endpoint POST /internal/paykeeper/accounts/{id}/resync-catalog для manual trigger.
  • Новые OP_* константы в OutboxService и обработчики в PkOutboxWorker.

6. Admin BFF + Web

6.1 Admin BFF (erp-admin/bff/src/routes/paykeeper.ts)

Новые роуты:

  • POST /api/v1/admin/paykeeper/accounts/:id/resync-catalog — запускает manual re-sync, возвращает sync_run_id.
  • GET /api/v1/admin/paykeeper/accounts/:id/catalog-sync-status — статус последнего прогона + счётчики (products_total, last_synced_at).
  • GET /api/v1/admin/paykeeper/accounts/:id/catalog-sync-runs?limit=20 — история прогонов.

6.2 Admin Web (erp-admin/web/src/pages/legal-entities/PaykeeperTab.tsx)

Новый блок в существующей вкладке PayKeeper:

┌─ Каталог в PayKeeper ─────────────────────────────┐
│ Товаров в PK: 47 / 47 (актуально)                 │
│ Последняя синхронизация: 2 мин назад              │
│ Статус: ✓ Успешно                                 │
│                                                    │
│ [Пересинхронизировать] [Журнал]                   │
└────────────────────────────────────────────────────┘

Клик «Пересинхронизировать» → disable кнопки + прогресс-бар → по завершении показать результат в toast. Клик «Журнал» → модалка со списком прогонов (pk_catalog_sync_runs).


7. Ролевой доступ

ОперацияPermission
Запустить manual re-syncintegrations.manage
Смотреть статус и журналintegrations.read
Реагировать на алерты (catalog diverged)integrations.manage

8. Метрики и алерты

  • paykeeper_catalog_sync_last_success_ts{account_id} — Prometheus gauge. Алерт если > 25 ч.
  • paykeeper_catalog_sync_errors_total{account_id, op_type} — counter.
  • paykeeper_catalog_products_diverged{account_id} — количество товаров с устаревшим hash. Алерт если > 5% от общего.
  • Ежесуточный email-алерт админу франшизы если status='failed' для их ЮЛ.

9. Блокеры на стороне PayKeeper

Обязательные вопросы к техподдержке PK до начала реализации

Ответы — в [[_reference/paykeeper/PK-catalog-questions.md]] (создать при обсуждении с PK). Без них BR не двигается.

  1. Какой точный URL /ims-api/products/import/ — в публичных JSON API доках его нет.
  2. Формат payload: batch upsert? по одному товару? обязательные поля (name, price, vat)?
  3. Поддержка модификаторов в каталоге PK — есть ли понятие attribute/variant?
  4. Поддержка иерархии категорий — flat или tree?
  5. Возврат pk_product_id при upsert — синхронно в ответе или через отдельный webhook?
  6. Rate limits — сколько товаров за запрос, какой throttle между запросами?
  7. Webhook обратной синхронизации (catalog.pk_upstream_changed) — когда будет готов у PK?
  8. Удаление товара — hard-delete vs архивирование?
  9. Тестовый аккаунт koala-test.server.paykeeper.ru — включён ли там ims-api?

10. Фазинг

ФазаСодержаниеОценка
ACatalog Service: event publisher + full-snapshot endpoint1 день
BAdapter: миграции, ims-client, event consumer, outbox op_types, reconcile job2-3 дня
CAdmin BFF + Web: manual re-sync кнопка + журнал + статус-блок0.5-1 день
D (опционально, после ответа PK)Reverse webhook от PK → Kafka → Catalog Service consumer1 день

Итого: 4-5 дней после разблокировки вопросов §9.


11. Открытые вопросы (требуют обсуждения)

  1. Мульти-ЮЛ франшиза, единый каталог. Если у франшизы 3 ЮЛ с PK-аккаунтами, но каталог один — sync отправляет одну и ту же правку в 3 места. Допустимо?
  2. Цены per-store. Если в будущем добавим разные цены по ТТ (через прейскуранты), как это маппится в PK-каталог одного ЮЛ? Нужен ли в PK концепт «прайс-листов» на уровне терминала?
  3. Картинки товаров. PK поддерживает image_url? Если да — какой CDN? Можно ли оставить ссылки на наш S3 или PK скачивает себе?
  4. Дедупликация при первичном онбординге. Что делать если в PK уже заведены товары вручную до подключения sync’а? Политика конфликтов (skip / override / merge)?
  5. Полное удаление каталога в PK — если отключаем синхронизацию, нужна ли кнопка «Очистить каталог в PK»?

Ссылки