Skip to content

ТЗ — Маркетплейсы (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_channelsid, marketplace_code, warehouse_id, is_active, price_markup_percent (nullable), price_fixed_override (nullable)канал-склад маркетплейса, связь с cms/commerce-stock; наценка/цена per-канал (комиссии Ozon и WB различаются)
cms_integration_marketplaces_category_mappingsid, marketplace_code, internal_category_id, external_category_id, attributes_map (json)маппинг категорий/атрибутов
cms_integration_marketplaces_ordersid, marketplace_code, external_order_id, order_id, statusсвязь заказа маркетплейса с заказом сайта
cms_integration_marketplaces_discrepanciesid, marketplace_code, product_id, type, details (json)отчёт расхождений: typestock_mismatch/unmapped_category/unmapped_order_item/price_rejected

Индексы: FK warehouse_id/order_id/product_idconstrained() + index(); status/type — PHP Enum; attributes_map/detailsjson() + 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)админAPIFormRequest, whitelist полей маппинга
Ручной запуск выгрузкиадминAPIpermission 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_channelsarray[]нетАктивные коды маркетплейсов
integration-marketplaces.export_schedule_cronstring"0 * * * *"нетРасписание выгрузки товаров/остатков/цен
integration-marketplaces.orders_poll_minutesint15нетПериодичность приёма заказов
integration-marketplaces.rate_limit_per_minuteint60нетЛимит вызовов API на маркетплейс
integration-marketplaces.max_pending_queue_sizeint2000нетПорог записей в очереди выгрузки на канал; превышение — алерт cms/health, выгрузка не останавливается тихо
integration-marketplaces.stock_zero_priority_queuebooltrueнетОбнуление остатка канала уходит вне общего расписания — отдельной высокоприоритетной задачей снятия с продажи
integration-marketplaces.kill_switcharray[]нетКоды маркетплейсов с аварийно приостановленной синхронизацией — защита от санкций за отмены при сбое, без выключения модуля

Секреты доступа к API маркетплейсов (токены кабинетов) — только .env.

API

МетодПутьДоступНазначение
GET/api/v1/admin/integration-marketplaces/channelsadmin (integration-marketplaces.view)Список каналов и их статус
PUT/api/v1/admin/integration-marketplaces/mappingsadmin (integration-marketplaces.manage)Настройка маппинга категорий/атрибутов
GET/api/v1/admin/integration-marketplaces/discrepanciesadmin (integration-marketplaces.view)Отчёт расхождений
POST/api/v1/admin/integration-marketplaces/channels/{code}/exportadmin (integration-marketplaces.manage)Ручной запуск выгрузки
POST/api/v1/admin/integration-marketplaces/channels/{code}/kill-switchadmin (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-in5 — очередь (приём)маркетплейс → webhooks-in → integration-marketplacesприём подписанного вебхука заказа/статуса (Ozon), job разбора
cms/integrations-bus, provides: marketplace-connector3 — provides-контрактintegration-marketplaces → шинареализация способности «выгрузить карточку/остаток», «принять заказ», «напечатать этикетку» по marketplace_code
cms/import (suggests)4 — requires (опционально)integration-marketplaces → importпервичная загрузка карточек при подключении нового канала
cms/health1 — событие MarketplaceDiscrepancyDetectedintegration-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/healthcms: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 из манифеста):

Permissionadminменеджерредактор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 → исходящая синхронизация канала остановлена, приём уже пришедших заказов не теряет данные

Внутренняя база знаний студии