Тема
ТЗ — Маркетплейсы (Ozon/WB/ЯМаркет) (cms/integration-marketplaces)
Слой: 🟡 функц-модуль · Зрелость доноров: ★★★ · Донор: masha (
masha/src/app/Domain/Wb/— Wildberries-обмен) Статус: ТЗ к разработке
Назначение и возможности
Коннекторы маркетплейсов (Ozon, Wildberries, Яндекс.Маркет) поверх cms/integrations-bus: двусторонний обмен товарами/остатками/ценами и заказами, с отдельным виртуальным складом для каждого маркетплейса.
- Коннекторы Ozon, Wildberries, Яндекс.Маркет:
provides: marketplace-connector - Батчевая выгрузка товаров/цен/остатков по расписанию (не по каждому изменению)
- Приём заказов маркетплейса и создание заказов сайта с каналом-складом из
cms/commerce-stock - Обратная отправка статусов заказа и печать этикеток отгрузки
- Настраиваемый маппинг категорий и атрибутов сайта на таксономию маркетплейса
- Отчёт расхождений: товары без маппинга, остатки не синхронизированы, цены не прошли модерацию
- Раздельные лимиты API-вызовов на маркетплейс (защита от блокировки аккаунта)
Зависимости и выключение
requires: cms/integrations-bus, cms/commerce-stock · suggests: cms/commerce-pricing, cms/import · provides: marketplace-connector
Поведение при выключении: выгрузка товаров/остатков на соответствующий маркетплейс останавливается, новые заказы с площадки не забираются — карточки на маркетплейсе продолжают жить с последним переданным состоянием до включения модуля. Исключение — остаток=0: если он был отправлен до выключения, карточка остаётся снятой с продажи (это состояние площадки, модуль его не откатывает).
marketplace-connector— канонический контракт: добавлен в реестр §2 стандарта ревизией ядра 14.07.2026 по аналогии сcrm-connector/erp-connector(домен-специфичный коннектор с несколькими реализациями).
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_integration_marketplaces_channels | id, marketplace_code, warehouse_id, is_active, price_markup_percent (nullable), price_fixed_override (nullable) | канал-склад маркетплейса, связь с cms/commerce-stock; наценка/цена per-канал (комиссии Ozon и WB различаются) |
cms_integration_marketplaces_category_mappings | id, marketplace_code, internal_category_id, external_category_id, attributes_map (json) | маппинг категорий/атрибутов |
cms_integration_marketplaces_orders | id, marketplace_code, external_order_id, order_id, status | связь заказа маркетплейса с заказом сайта |
cms_integration_marketplaces_discrepancies | id, marketplace_code, product_id, type, details (json) | отчёт расхождений: type — stock_mismatch/unmapped_category/unmapped_order_item/price_rejected |
Индексы: FK warehouse_id/order_id/product_id — constrained() + index(); status/type — PHP Enum; attributes_map/details — json() + cast array; уникальный индекс на marketplace_code+external_order_id; уникальный индекс на marketplace_code+product_id в discrepancies — одна активная запись расхождения на пару (обновляется, не дублируется при повторных прогонах сверки).
ПДн-паспорт. При приёме заказа по схеме FBS в cms_integration_marketplaces_orders (через связанный cms/commerce-orders) приходят ФИО и адрес покупателя маркетплейса — хранятся в периметре заказа сайта (не в таблицах этого модуля), срок хранения и удаление по запросу — на стороне cms/commerce-orders. Модуль сам ПДн не хранит: его таблицы содержат только идентификаторы (external_order_id, product_id) и служебные статусы.
Входные и выходные данные
Входы (всё прочее модуль отвергает — whitelist-принцип §11 стандарта):
| Вход | Источник | Канал | Проверка |
|---|---|---|---|
| Остаток канала-склада изменился | событие из cms/commerce-stock (requires) | 1 — событие / 4 — сервис-вызов | факт от владельца остатков; при значении 0 — приоритетная обработка (см. «Фоновая работа») |
| Триггер планового экспорта | расписание export_schedule_cron | внутренний | ScheduleRegistrar, не самодельный Schedule:: |
| Маппинг категорий/атрибутов (форма Filament/API) | админ | API | FormRequest, whitelist полей маппинга |
| Ручной запуск выгрузки | админ | API | permission integration-marketplaces.manage, Idempotency-Key |
| Заказ/статус заказа с маркетплейса | площадка → cms/webhooks-in (Ozon push) либо поллинг API (orders_poll_minutes) | 5 — очередь | вебхук — подпись/токен верифицируется до постановки job (401 при неверной, запись не создаётся); поллинг — авторизованный запрос по токену кабинета из .env |
Выходы:
| Выход | Получатель | Формат |
|---|---|---|
MarketplaceExportCompleted / MarketplaceOrderImported / MarketplaceDiscrepancyDetected | подписчики (канал 1) | событие, payload см. «События и обмен» |
| Карточка товара (название, атрибуты по маппингу, цена per-канал, остаток) | маркетплейс (через коннектор) | payload по активному маппингу, только замапленные атрибуты |
| Обновление статуса заказа, этикетка отгрузки | маркетплейс | payload коннектора (label — PDF/бинарные данные по API площадки) |
| Отчёт расхождений | админка | cms_integration_marketplaces_discrepancies, keyset-пагинация |
Любой атрибут карточки, не описанный в attributes_map, на маркетплейс не уходит; заказ с неизвестным marketplace_code или невалидной подписью/токеном отклоняется, не создаёт запись.
Настройки (группа integration-marketplaces)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
integration-marketplaces.enabled_channels | array | [] | нет | Активные коды маркетплейсов |
integration-marketplaces.export_schedule_cron | string | "0 * * * *" | нет | Расписание выгрузки товаров/остатков/цен |
integration-marketplaces.orders_poll_minutes | int | 15 | нет | Периодичность приёма заказов |
integration-marketplaces.rate_limit_per_minute | int | 60 | нет | Лимит вызовов API на маркетплейс |
integration-marketplaces.max_pending_queue_size | int | 2000 | нет | Порог записей в очереди выгрузки на канал; превышение — алерт cms/health, выгрузка не останавливается тихо |
integration-marketplaces.stock_zero_priority_queue | bool | true | нет | Обнуление остатка канала уходит вне общего расписания — отдельной высокоприоритетной задачей снятия с продажи |
integration-marketplaces.kill_switch | array | [] | нет | Коды маркетплейсов с аварийно приостановленной синхронизацией — защита от санкций за отмены при сбое, без выключения модуля |
Секреты доступа к API маркетплейсов (токены кабинетов) — только .env.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/admin/integration-marketplaces/channels | admin (integration-marketplaces.view) | Список каналов и их статус |
| PUT | /api/v1/admin/integration-marketplaces/mappings | admin (integration-marketplaces.manage) | Настройка маппинга категорий/атрибутов |
| GET | /api/v1/admin/integration-marketplaces/discrepancies | admin (integration-marketplaces.view) | Отчёт расхождений |
| POST | /api/v1/admin/integration-marketplaces/channels/{code}/export | admin (integration-marketplaces.manage) | Ручной запуск выгрузки |
| POST | /api/v1/admin/integration-marketplaces/channels/{code}/kill-switch | admin (integration-marketplaces.manage) | Аварийная остановка/возобновление синхронизации канала |
Отчёт расхождений — keyset-пагинация, не OFFSET. Ручной запуск выгрузки и kill-switch — мутации с внешним эффектом → обязателен заголовок Idempotency-Key (§7 стандарта); конверт ошибок несёт code (rate_limited/channel_degraded/connector_disabled).
Компоненты
Filament: ресурс каналов-складов, форма маппинга категорий, отчёт расхождений. Команды: cms:integration-marketplaces:export --json, cms:integration-marketplaces:pull-orders --json.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
MarketplaceExportCompleted | выгрузка товаров/остатков/цен завершена | marketplace_code, products_count |
MarketplaceOrderImported | заказ маркетплейса создан на сайте | marketplace_code, external_order_id, order_id |
MarketplaceDiscrepancyDetected | обнаружено расхождение (товар без маппинга и т.п.) | marketplace_code, product_id, type |
Реализует provides: marketplace-connector. Обмен с маркетплейсами — через cms/integrations-bus.
Взаимодействия
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/commerce-stock (остаток канала изменился) | 1 — событие / 4 — requires (чтение остатка) | commerce-stock → integration-marketplaces | остаток=0 триггерит немедленное снятие с продажи на канале (см. «Крайние случаи»); точное имя события — по контракту cms/commerce-stock |
cms/commerce-pricing (suggests) | 4 — requires (опционально) | integration-marketplaces → commerce-pricing | базовая цена перед применением price_markup_percent/price_fixed_override канала, если модуль подключён; иначе — исходная цена товара |
cms/webhooks-in | 5 — очередь (приём) | маркетплейс → webhooks-in → integration-marketplaces | приём подписанного вебхука заказа/статуса (Ozon), job разбора |
cms/integrations-bus, provides: marketplace-connector | 3 — provides-контракт | integration-marketplaces → шина | реализация способности «выгрузить карточку/остаток», «принять заказ», «напечатать этикетку» по marketplace_code |
cms/import (suggests) | 4 — requires (опционально) | integration-marketplaces → import | первичная загрузка карточек при подключении нового канала |
cms/health | 1 — событие MarketplaceDiscrepancyDetected | integration-marketplaces → health | алерт при превышении порога расхождений/очереди |
Фоновая работа
Выгрузка товаров/остатков/цен — по расписанию (export_schedule_cron), батчами через Bus::batch, именованная очередь integration-marketplaces. Приём заказов — по расписанию (orders_poll_minutes) и/или вебхуком там, где площадка его отдаёт (Ozon), не по запросу браузера. Rate-limit на маркетплейс (rate_limit_per_minute) соблюдается на уровне очереди — защита от блокировки аккаунта; ретраи с backoff, circuit breaker на маркетплейс (открылся — канал degraded, алерт разработчику, другие каналы не затронуты).
Компакция очереди (debounce). При частых изменениях остатка/цены одного товара (распродажа, много заказов подряд) джоб выгрузки уникален по ключу channel+product_id: повторная постановка, пока предыдущий job этого товара ещё не выполнен, не создаёт вторую запись в очереди, а обновляет payload существующей на актуальное состояние. На маркетплейс отправляется последнее состояние товара, а не каждое промежуточное изменение — иначе жёсткие API-лимиты площадки исчерпываются на дребезге.
Приоритет остатка=0. Если stock_zero_priority_queue=true, событие обнуления остатка канала обрабатывается вне общей компакции и вне расписания export_schedule_cron — отдельной высокоприоритетной задачей с минимальной задержкой (SLA — минуты, не следующий час по расписанию): карточка снимается с продажи на площадке раньше, чем туда успеет прийти заказ, который придётся отменять (см. «Крайние случаи», риск санкций).
Плановая сверка остатков. Отдельная задача по расписанию сравнивает снимок остатка сайта (на момент последней успешной выгрузки) с остатком, который сообщает API площадки; расхождение пишется в cms_integration_marketplaces_discrepancies (type=stock_mismatch, details: {internal, external, checked_at}), не исправляется автоматически — админ видит отчёт и решает (форсировать переотправку остатка на канал).
Ранбук (мини, §15 стандарта):
| Симптом | Что проверить | Команда |
|---|---|---|
Маркетплейс отклоняет обновления, 429/лимит исчерпан | rate_limit_per_minute, состояние circuit breaker канала в cms/health | cms:integration-marketplaces:doctor --json; при затяжном сбое — kill_switch на код канала, чтобы не копить неотправленные снятия с продажи |
Остатки разошлись (отчёт stock_mismatch растёт) | последний прогон плановой сверки, max_pending_queue_size в алертах | cms:integration-marketplaces:export --json точечно по каналу после устранения причины расхождения |
Производительность и кеш
Ожидаемые объёмы (оценочно): SKU на канал — от сотен до нескольких десятков тысяч (зависит от размера каталога), заказы на канал — от единиц до нескольких сотен в сутки (пики — распродажи). Горячие пути: обработка события изменения остатка (компакция по channel+product_id, ≤2 запроса на постановку/обновление job), приём заказа (поллинг/ вебхук, ≤3 запроса: поиск по external_order_id, создание заказа сайта, запись в cms_integration_marketplaces_orders), обнуление остатка (приоритетная очередь — тот же бюджет, без ожидания расписания). Плановый экспорт — батчами (см. highload-требования каталога), чанк по ~500 SKU на job Bus::batch, без N+1 при сборке карточек. Индексы — см. «Модель данных» (marketplace_code+external_order_id, marketplace_code+product_id). Тег integration-marketplaces:mappings — кеш маппинга категорий/атрибутов, инвалидируется при сохранении в Filament (0 запросов на горячем пути выгрузки). Внешние вызовы к маркетплейсу — только из очереди, включая приоритетное снятие с продажи при остатке=0 — оно быстрое по SLA, но не синхронное в потоке события.
Безопасность
- Приём заказа идемпотентен по
external_order_id— повторный приём не дублирует заказ. - Расхождение (товар без маппинга и т.п.) фиксируется, не блокируя остальную выгрузку — изоляция ошибок по товару.
- Вебхуки/поллинг: там, где площадка шлёт push (Ozon) — подпись/токен верифицируется на уровне
cms/webhooks-inдо постановки job, неверная подпись → 401, событие — вcms/audit/cms/attack-monitor; защита от replay — проверкаtimestampпротив окна ядра. Там, где приём только поллингом (WB, Я.Маркет) — авторизация исходящего запроса токеном кабинета из.env, TLS обязателен. - Секреты доступа к API маркетплейсов (токены кабинетов) — только
.env, не в БД/логах/UI. - ФИО/адрес покупателя при FBS-заказах не логируются в открытом виде (см. ПДн-паспорт в «Модель данных») — маскированно, полный адрес доступен только через сам заказ.
- Права:
integration-marketplaces.view,integration-marketplaces.manage.
Матрица ролей (permissions из манифеста):
| Permission | admin | менеджер | редактор | studio |
|---|---|---|---|---|
integration-marketplaces.view (каналы, расхождения, расход квоты API) | ✅ | ✅ | ❌ | ✅ |
integration-marketplaces.manage (маппинг категорий, ручной экспорт, kill-switch канала) | ✅ | ❌ | ❌ | ✅ |
Kill-switch канала — самое рискованное действие модуля (риск санкций площадки за последующий рассинхрон остатков) — только admin/studio, с обязательным подтверждением (см. «UX-требования»).
UX-требования
Админ:
- статус каждого канала виден на дашборде модуля (бейдж: активен / приостановлен kill-switch / degraded после breaking change API);
- отчёт расхождений с фильтрами по типу (
stock_mismatch/unmapped_category/unmapped_order_item/price_rejected) и площадке; пустое состояние — «Расхождений не обнаружено, последняя сверка: N минут назад»; - ошибки — на человеческом языке: «Ozon отклонил обновление остатков — превышен лимит запросов, повтор через N минут», «Wildberries: карточка без категории — заполните маппинг», «Товар получен в заказе без маппинга атрибута — проверьте состав вручную» (не «Error 500», не сырой JSON ответа API);
- массовые действия: переотправка группы товаров с расхождением остатка одним действием («Пересинхронизировать 15 позиций?»);
- подтверждение необратимых/рискованных операций: kill-switch канала — «Выгрузка и приём заказов с Ozon будут остановлены, карточки останутся в последнем переданном состоянии. Продолжить?»; ручной запуск полного экспорта на крупном канале — оценка длительности перед стартом.
Посетитель: не взаимодействует с модулем напрямую — сценарии применимы к витрине маркетплейса (вне зоны ответственности сайта); на самом сайте модуль не должен создавать видимых задержек оформления (обмен полностью асинхронный).
Крайние случаи и типовые баги
- Остатки разошлись с маркетплейсом (сайт — 10 шт, площадка показывает 15) → плановая сверка по расписанию сравнивает снимок остатка на момент последней выгрузки с ответом API площадки; расхождение — в
cms_integration_marketplaces_discrepancies(type=stock_mismatch,details: {internal, external, checked_at}); не исправляется автоматически — админ видит отчёт и решает. - Санкции за отмены: остаток=0 должен остановить продажу на канале заранее → это критичный сценарий: при обнулении остатка на канале-складе карточка снимается с продажи на площадке приоритетной задачей (см. «Фоновая работа»,
stock_zero_priority_queue) — до того, как площадка успеет принять заказ, который придётся отменять, а не после прихода такого заказа и штрафа за отмену. - Комиссии и цены per-маркетплейс (цена на Ozon отличается от цены на WB из-за разной комиссии) → явная настройка наценки/фиксированной цены на канал (
price_markup_percent/price_fixed_overrideвcms_integration_marketplaces_channels), не единая цена товара для всех площадок. - Жёсткие API-лимиты при частых изменениях остатков (распродажа, много заказов) → компакция очереди по
channel+product_id: на площадку уходит последнее состояние товара, а не каждое промежуточное изменение (см. «Фоновая работа»). - Заказ от маркетплейса без маппинга категории/атрибута товара → заказ всё равно принимается (деньги важнее отсутствующего маппинга), но помечается расхождением
type=unmapped_order_itemдля ручной проверки состава — не блокируется. - Недоступность маркетплейса при плановой выгрузке → откладывается на следующий цикл
export_schedule_cron, карточки не удаляются, продажи на канале не останавливаются автоматически — кроме случая, когда откладывается именно передача остатка=0 (тогда приоритетная очередь продолжает ретраить вне общего расписания, не ждёт следующего часа). - Смена схемы ответа API маркетплейса (breaking change у Ozon/WB) → circuit breaker переводит конкретный канал в degraded, алерт разработчику через
cms/health, другие подключённые каналы не затронуты. - Параллельный экспорт одного товара на канал (два job на одну пару
channel+product_id) → компакция/уникальность ключа job — идемпотентность, не двойная отправка. - Приём одного заказа маркетплейса дважды (повторный поллинг/повторный вебхук) → идемпотентность по
external_order_id— повторный приём не создаёт второй заказ сайта.
Донорский код
| Что взять | Путь |
|---|---|
| Обмен с Wildberries (выгрузка остатков/цен, приём заказов) | masha/src/app/Domain/Wb/ |
Legacy-импорт (§16 стандарта): у донора masha есть исторические карточки товаров и привязки к маркетплейсу (сопоставления SKU сайта ↔ артикул WB), накопленные до переезда на CMS v2. cms:integration-marketplaces:import-legacy --source=masha переносит эти привязки в cms_integration_marketplaces_category_mappings/связку SKU (не сами карточки — они остаются в cms/commerce-catalog), чтобы после первого включения канала модуль не считал уже сопоставленные товары «без маппинга» и не плодил расхождения на пустом месте. Идемпотентно, --dry-run с отчётом расхождений; исторический журнал заказов WB из donor'а не переносится — только актуальные незакрытые заказы (остальное — архив на стороне старой платформы, вне периметра модуля).
Тесты и приёмка
- [ ] Мок API маркетплейса: тест выгрузки товаров, приёма заказа, печати этикетки
- [ ] Приём заказа идемпотентен по
external_order_id— повторный приём не дублирует заказ - [ ] Расхождение фиксируется при отсутствии маппинга категории/атрибута, не блокируя остальную выгрузку
- [ ] Rate-limit API соблюдается на маркетплейс (тест ограничения частоты вызовов)
- [ ] При выключении канала выгрузка останавливается, ранее переданные карточки не удаляются
- [ ] Права
integration-marketplaces.manageразграничивают просмотр и запуск выгрузки/маппинг - [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут; тестовая БД только
integration_marketplaces_test,migrate:freshзапрещён - [ ] Тест деградации: недоступный/сломанный (breaking change) канал → circuit breaker открывается,
meta.degraded/алертcms/health, другие каналы работают штатно - [ ] Тест компакции очереди: N быстрых изменений остатка одного товара → одна job на выходе с последним состоянием, не N отправок в API маркетплейса
- [ ] Тест приоритета остатка=0: событие обнуления обрабатывается вне расписания и вне компакции, снятие с продажи уходит раньше следующего планового экспорта
- [ ] Тест плановой сверки: расхождение внутреннего и внешнего остатка создаёт/обновляет (не дублирует) запись в
cms_integration_marketplaces_discrepancies - [ ] Тест лимита очереди: превышение
max_pending_queue_size→ алертcms/health, выгрузка не блокируется молча - [ ] Тест kill-switch: код маркетплейса в
integration-marketplaces.kill_switch→ исходящая синхронизация канала остановлена, приём уже пришедших заказов не теряет данные