BR 3.4 — Catalog Sync ERP ↔ PayKeeper
Статус — драфт
Требования сформированы для обсуждения. Не начинать реализацию — нужны правки и обязательное согласование с техподдержкой PayKeeper (см. §9).
Контекст
По BR 3.3 мы отправляем PK фискальную корзину прямо в invoice (service_name.cart из POST /change/invoice/preview/). PK не обращается при этом к собственному каталогу — чек печатается с теми позициями, что мы прислали в момент выставления счёта.
Это работает пока все заказы создаются в нашем POS. Но в двух сценариях обойтись без каталога в ЛК PK нельзя:
- Offline/без нашего POS. Владелец точки хочет, чтобы Касса 3в1 от PK работала самостоятельно: кассир выбирает товары прямо на терминале из ЛК PK → формирует счёт → печатает чек. Без актуального каталога кассир работает «на пустом» или вручную заводит товары в ЛК PK.
- Оплата через ЛК 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% случаев.
- Сотрудник редактирует товар в админке франшизы → Catalog Service сохраняет изменения.
- Catalog Service публикует Kafka-событие:
catalog.product.upserted— создан/изменёнcatalog.product.deletedcatalog.category.upserted/catalog.category.deletedcatalog.modifier_group.upserted/.deleted
- Paykeeper Adapter consume’ит → для каждого активного
paykeeper_accountsданнойfranchise_id→ кладёт запись вpk_outboxсop_type=upsert_product/ etc. PkOutboxWorkerберёт из outbox → вызываетPK /ims-api/...→ сохраняет/обновляет mapping вpaykeeper_products.
Латентность: ≤ 5 секунд от изменения в админке до появления в ЛК PK.
2.2 Full re-sync (cron + manual)
Safety-net на случай пропущенных webhook’ов / инцидентов.
Cron @03:00 ночью:
- Adapter идёт в Catalog Service:
GET /internal/catalog/full-snapshot?franchise_id=X. - Строит
hashкаждого товара/категории. - Сравнивает с
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_productupsert_category,delete_categoryupsert_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 отсутствует). Чтобы событийная модель заработала, нужно:
- Добавить
CatalogEventPublisherс методами:publishProductUpserted(Product)publishProductDeleted(productId, franchiseId)publishCategoryUpserted(Category)publishCategoryDeleted(categoryId, franchiseId)- аналогично для модификаторов
- Вызывать в сервисных методах
createProduct,updateProduct,deleteProductи т.д. - Добавить 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— endpointPOST /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-sync | integrations.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 не двигается.
- Какой точный URL
/ims-api/products/import/— в публичных JSON API доках его нет. - Формат payload: batch upsert? по одному товару? обязательные поля (name, price, vat)?
- Поддержка модификаторов в каталоге PK — есть ли понятие attribute/variant?
- Поддержка иерархии категорий — flat или tree?
- Возврат
pk_product_idпри upsert — синхронно в ответе или через отдельный webhook? - Rate limits — сколько товаров за запрос, какой throttle между запросами?
- Webhook обратной синхронизации (
catalog.pk_upstream_changed) — когда будет готов у PK? - Удаление товара — hard-delete vs архивирование?
- Тестовый аккаунт
koala-test.server.paykeeper.ru— включён ли там ims-api?
10. Фазинг
| Фаза | Содержание | Оценка |
|---|---|---|
| A | Catalog Service: event publisher + full-snapshot endpoint | 1 день |
| B | Adapter: миграции, ims-client, event consumer, outbox op_types, reconcile job | 2-3 дня |
| C | Admin BFF + Web: manual re-sync кнопка + журнал + статус-блок | 0.5-1 день |
| D (опционально, после ответа PK) | Reverse webhook от PK → Kafka → Catalog Service consumer | 1 день |
Итого: 4-5 дней после разблокировки вопросов §9.
11. Открытые вопросы (требуют обсуждения)
- Мульти-ЮЛ франшиза, единый каталог. Если у франшизы 3 ЮЛ с PK-аккаунтами, но каталог один — sync отправляет одну и ту же правку в 3 места. Допустимо?
- Цены per-store. Если в будущем добавим разные цены по ТТ (через прейскуранты), как это маппится в PK-каталог одного ЮЛ? Нужен ли в PK концепт «прайс-листов» на уровне терминала?
- Картинки товаров. PK поддерживает image_url? Если да — какой CDN? Можно ли оставить ссылки на наш S3 или PK скачивает себе?
- Дедупликация при первичном онбординге. Что делать если в PK уже заведены товары вручную до подключения sync’а? Политика конфликтов (skip / override / merge)?
- Полное удаление каталога в PK — если отключаем синхронизацию, нужна ли кнопка «Очистить каталог в PK»?