Skip to content

ТЗ — Маркетплейс/мультивендор (cms/commerce-vendor)

Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: notal (эскроу — notal/src/app/Services/), freelance (споры — freelance/project/src/app/Domains/{Escrow,Arbitration}/) Статус: ТЗ к разработке

Назначение и возможности

Надстройка мультивендора над доменной моделью коммерции: продавцы со своей витриной и складами, сплит заказа по продавцам, комиссия платформы, эскроу-удержание до подтверждения и выплаты. Использует заготовку vendor_id из доменной модели, см. /cms-v2/commerce-model, раздел «Заготовка мультивендора».

  • Продавцы (vendor): онбординг (заявка, реквизиты), модерация перед публикацией витрины
  • Товары/склады/цены продавца — те же модели каталога с vendor_id (не параллельная схема)
  • Сплит заказа по продавцам: один заказ покупателя → подзаказы (sub-order) на продавца
  • Комиссия платформы — процент/фикс поверх цены продавца, не внутри цены товара
  • Эскроу-удержание оплаты до подтверждения получения покупателем
  • Выплаты продавцам: реестр начислений, статусы (pending → approved → paid)
  • Споры между покупателем и продавцом (арбитраж) с эскалацией администратору платформы
  • Кабинет продавца — зона 3, см. /cms-v2/client-cabinets

Зависимости и выключение

requires: ядро, cms/commerce-model (каталог, склады, цены с vendor_id), cms/commerce-payments (эскроу) · suggests: cms/commerce-delivery, cms/notifications-bus

Поведение при выключении: все товары считаются принадлежащими магазину (vendor_id = null), продавцы теряют доступ к кабинету — существующие подзаказы и незавершённые выплаты требуют ручного администрирования до полного расчёта, новые сплит-заказы не создаются.

Модель данных

ТаблицаКлючевые поляПримечание
commerce_vendorsid, user_id, title, status (pending|approved|suspended), commission_percentпродавец, статус модерации, status — PHP Enum
commerce_vendor_sub_ordersid, order_id, vendor_id, status, amount, commission_amountподзаказ продавца (сплит), amount/commission_amount — integer minor units
commerce_vendor_escrow_holdsid, sub_order_id, payment_id, status (held|released|disputed), released_atэскроу-удержание по подзаказу
commerce_vendor_payoutsid, vendor_id, period_from, period_to, amount, status (pending|approved|paid)append-only реестр выплат продавцу
commerce_vendor_disputesid, sub_order_id, opened_by, status (open|escalated|resolved), resolutionспоры/арбитраж

user_id/order_id/vendor_id/sub_order_id/payment_id — FK constrained()->index(). Реестр выплат и журнал эскроу — append-only, исправление ошибочной выплаты — сторно-запись.

commerce_vendor_sub_orders дополнительно хранит снапшот commission_percent на момент создания подзаказа (не ссылку на текущую настройку продавца) — изменение комиссии площадки не должно задним числом пересчитывать уже созданные подзаказы. commerce_vendors хранит реквизиты выплаты (банковский счёт/карта/самозанятость) — ПДн-чувствительные поля, см. «Безопасность».

Входные и выходные данные

Whitelist-принцип §11 стандарта: всё, что не перечислено ниже, модуль отвергает.

Входы:

  • форма онбординга продавца (POST /api/v1/vendor/onboarding) — название магазина, контакты, реквизиты выплаты, документы верификации (ИНН/самозанятость); валидация — FormRequest, файлы — через медиатеку ядра;
  • событие PaymentSucceeded из cms/commerce-paymentspayment_id, order_id, amount; триггер постановки средств заказа на эскроу по каждому подзаказу;
  • событие ShipmentDelivered из cms/commerce-deliveryshipment_id, order_id; триггер отсчёта/условия для релиза эскроу;
  • запрос спора (POST /api/v1/vendor/disputes) — sub_order_id, причина, вложения (фото/файлы через медиатеку), от покупателя или продавца;
  • вебхук от платёжного шлюза для эскроу-операций — только через cms/webhooks-in (подпись, идемпотентность по event_id), модуль не принимает вебхуки напрямую; данные разбора попадают в модуль как факт (событие/сервис-вызов), не как сырой HTTP-запрос.

Выходы:

  • сплит-подзаказы — ответ API продавцу/покупателю (конверт {data, meta}, keyset-пагинация);
  • реестр выплат — Filament-вид и GET /api/v1/vendor/payouts (см. «API»);
  • события VendorApproved, SubOrderCreated, EscrowReleased, VendorPayoutCompleted — payload см. «События и обмен»;
  • рендер кабинета продавца (зона 3) — через сервис модуля, не прямые запросы из Blade/фронтенда кабинета.

Настройки (группа commerce-vendor)

КлючТипДефолтaffectsPageCacheОписание
commerce-vendor.default_commission_percentfloat10.0нетКомиссия платформы по умолчанию
commerce-vendor.escrow_hold_daysint14нетСрок удержания эскроу до автоматического релиза
commerce-vendor.moderation_requiredbooltrueнетОбязательная модерация продавца перед публикацией
commerce-vendor.payout_period_daysint14нетПериодичность формирования реестра выплат
commerce-vendor.max_products_unverifiedint20нетЛимит и квоты: максимум товаров у продавца без пройденной верификации (документы)
commerce-vendor.payout_request_max_per_dayint1нетЛимит и квоты: максимум заявок продавца на внеплановую выплату в сутки
commerce-vendor.auto_escrow_release_enabledbooltrueнетKill-switch: аварийная остановка автоматического релиза эскроу по сроку без выключения модуля (ручные и по ShipmentDelivered релизы продолжают работать)

API

МетодПутьДоступНазначение
POST/api/v1/vendor/onboardingauthЗаявка на регистрацию продавца
GET/api/v1/vendor/sub-ordersauth (продавец)Подзаказы продавца со статусами (keyset-пагинация)
GET/api/v1/vendor/payoutsauth (продавец)Реестр начислений продавца, статус эскроу по подзаказу
POST/api/v1/vendor/disputesauth (продавец/покупатель)Открытие спора по подзаказу
GET/api/v1/admin/vendorsadmin (commerce-vendor.view)Список продавцов с фильтром по статусу модерации
POST/api/v1/admin/vendor-payouts/{id}/approveadmin (commerce-vendor.manage)Подтверждение выплаты продавцу
POST/api/v1/admin/vendor-disputes/{id}/resolveadmin (commerce-vendor.manage)Разрешение спора администратором

Компоненты

Виджеты: кабинет продавца (подзаказы, выплаты, споры) — зона 3 клиентских кабинетов. Filament: очередь модерации продавцов (пустое состояние «новых заявок нет», массовое одобрение выделенных), реестр выплат, канбан споров. Команды: cms:commerce-vendor:release-escrow --json (автоматический релиз эскроу по сроку), cms:commerce-vendor:build-payouts --json (формирование реестра выплат за период).

Демо-контент: сидер commerce-vendor:demo создаёт 3–5 демо-продавцов с товарами (через vendor_id на существующих демо-товарах commerce-catalog), демо-подзаказами в разных статусах и одним разрешённым спором — витрина кабинета продавца и Filament-виджеты показываются в /_gallery/playground без ручного ввода.

События и обмен

СобытиеКогдаPayload
VendorApprovedпродавец прошёл модерациюvendor_id
SubOrderCreatedзаказ разбит на подзаказы по продавцамorder_id, vendor_id, sub_order_id
EscrowReleasedэскроу-удержание снято, средства доступны к выплатеsub_order_id, amount
VendorPayoutCompletedвыплата продавцу проведенаpayout_id, vendor_id, amount

Слушает: PaymentSucceeded из cms/commerce-payments (постановка средств на эскроу), ShipmentDelivered из cms/commerce-delivery (триггер отсчёта до релиза эскроу).

Взаимодействия:

Сущность/модульКаналНаправлениеЧто происходит
cms/commerce-payments1 (событие)входящееPaymentSucceeded → создание commerce_vendor_escrow_holds на каждый подзаказ, идемпотентно по payment_id
cms/commerce-payments4 (requires)исходящееэскроу-операции (постановка/релиз средств) вызывают публичный сервис платежей — сам модуль деньгами не управляет
cms/commerce-delivery1 (событие)входящееShipmentDelivered → триггер отсчёта/условия релиза эскроу по подзаказу
cms/commerce-receipts3 (provides-потребитель, через commerce-payments)исходящеесплит чека по продавцам — суммы подзаказа передаются владельцу платежа/чека, commerce-vendor не формирует чек сам (54-ФЗ, сплит по продавцам)
client-cabinets (зона 3)внешний REST-каналисходящеекабинет продавца читает подзаказы/выплаты/споры через /api/v1/vendor/… как внешний потребитель, не напрямую из БД
cms/commerce-model (каталог)4 (requires)двустороннеетовары/склады/цены продавца — те же модели с vendor_id, не параллельная схема

Фоновая работа

Именованная очередь commerce-vendor: cms:commerce-vendor:release-escrow по расписанию (автоматический релиз по истечении escrow_hold_days, идемпотентно — повторный запуск не освобождает уже освобождённый эскроу; уважает kill-switch commerce-vendor.auto_escrow_release_enabled), build-payouts за период (payout_period_days). Выплата продавцу инициируется из очереди, не синхронно из HTTP.

Эксплуатация (ранбук). Метрики: число открытых споров дольше N дней (алерт — эскалация зависла), число эскроу-удержаний на грани автовыдачи (< 24 ч до релиза, есть ли активный спор), длительность выполнения build-payouts, размер очереди commerce-vendor в Pulse.

СимптомЧто проверитьЧем чинить
Эскроу не релизится по срокуauto_escrow_release_enabled (не выключен kill-switch), лог release-escrow --dry-runвключить настройку либо запустить release-escrow вручную после проверки причины отключения
Подзаказ не создался после оплатыдоставлено ли PaymentSucceeded (Horizon failed jobs), идемпотентность по payment_idcms:commerce-vendor:release-escrow --dry-run, при расхождении — ручной повтор обработчика события
Выплата зависла в pendingподтверждена ли администратором (vendor-payouts/{id}/approve), не заблокирована ли спором подзаказаразрешить спор или подтвердить выплату через API/Filament
Реестр выплат задваивает суммыповторный запуск build-payouts без идемпотентного ключа периодапроверить уникальность (vendor_id, period_from, period_to), при дубле — сторно-запись, не удаление
Продавец не видит кабинет после VendorApprovedинвалидация тега commerce-vendor:vendor:<id>, доставка событиявручную сбросить тег, проверить очередь событий

Бэкап/рестор: в бэкап попадают все таблицы модуля (commerce_vendors, commerce_vendor_sub_orders, commerce_vendor_escrow_holds, commerce_vendor_payouts, commerce_vendor_disputes) как финансово значимые — исключений нет. После рестора пересоздания не требуется (нет денормализованных проекций/поисковых индексов модуля); кеш-теги прогреваются лениво по первому запросу.

Производительность и кеш

Объёмы растут с ростом маркетплейса — тысячи подзаказов и записей реестра выплат в месяц, десятки тысяч эскроу-удержаний (highload-требования каталога применимы к объёму сопутствующих товаров продавцов). Горячий путь — витрина маркетплейса с фильтром по продавцу: использует общий кеш commerce-catalog, commerce-vendor только добавляет тег vendor_id к существующим ключам, не держит отдельный кеш листинга.

Индексы: составной vendor_id + status на commerce_vendor_sub_orders (списки кабинета продавца, очередь модерации), vendor_id + period_from + period_to на commerce_vendor_payouts. При росте — партиционирование commerce_vendor_payouts и commerce_vendor_escrow_holds по диапазону дат (append-only журнальные таблицы, тот же принцип, что stock_moves в каталоге). Инвалидация — батчами по тегу vendor_id при массовых операциях (build-payouts за период), не по-подзаказная лавина.

Безопасность

Продавец без статуса approved не публикует товары в общей витрине (проверка на уровне сервиса каталога, не только UI). Открытие спора блокирует автоматический релиз эскроу до разрешения администратором. Комиссия и суммы выплат — только через commerce-vendor.manage, изменения логируются в cms/audit. Модерация продавцов (commerce-vendor.moderate) отделена от операционного управления (commerce-vendor.manage).

Матрица ролей:

ДействиеАдмин платформыМодераторПродавецПокупатель
Просмотр списка продавцов/подзаказов (commerce-vendor.view)своисвоё как участник заказа
Модерация онбординга продавца (commerce-vendor.moderate)
Изменение комиссии, подтверждение выплат (commerce-vendor.manage)
Приостановка/блокировка продавца
Открытие спораэскалацияэскалация✅ (по своему подзаказу)✅ (по своему заказу)
Разрешение спораэскалирует администратору
Просмотр своих реквизитов/выплат

Опасные действия (изменение комиссии задним числом запрещено логикой снапшота, а не только правом; приостановка продавца с активными заказами) — только commerce-vendor.manage с подтверждением в UI и записью в cms/audit.

ПДн-паспорт. commerce_vendors хранит реквизиты выплаты продавца (счёт/карта/ИНН) и контактные данные — ПДн по 152-ФЗ. Срок хранения: пока действует аккаунт продавца + контрактный срок финансовой давности (как commerce-receipts: финансовая история хранится дольше по закону, не удаляется по первому запросу). Хук «выгрузить всё по субъекту» отдаёт реквизиты и историю подзаказов/выплат продавца. Хук «забыть по запросу» обезличивает контактные данные и реквизиты продавца, но не удаляет записи commerce_vendor_payouts/ commerce_vendor_sub_orders — финансовый журнал должен пережить удаление ПДн владельца (суммы и статусы остаются, персональные поля — обезличены).

⚠️ Противоречие: обезличивание реквизитов при «забыть по запросу» не должно ломать уже сформированный, но ещё не подтверждённый реестр выплат — разрешение: обезличивание реквизитов продавца допустимо только после того, как все его подзаказы закрыты и выплаты проведены (тот же гейт, что и на блокировку продавца, см. «Крайние случаи»).

Разграничение прав: commerce-vendor.view, commerce-vendor.manage, commerce-vendor.moderate.

UX-требования

Продавец (кабинет, зона 3): видит статус каждой выплаты (pending → approved → paid) и понятную причину, если она отличается от ожидаемой (спор блокирует релиз, реквизиты не подтверждены); прозрачность расчёта — по каждому подзаказу видна цена товара, комиссия площадки (снапшот commission_percent, а не текущая ставка) и итоговая сумма к выплате; статус спора и история сообщений по нему.

Покупатель (checkout и история заказов): мультивендорность раскрывается на checkout — если заказ включает товары нескольких продавцов, это видно до оплаты (отдельные блоки «товары продавца N» со своими условиями доставки/возврата), а не только постфактум в истории заказов; после оплаты — доступ к истории покупки сохраняется даже если продавец позже заблокирован (см. «Крайние случаи»).

Админ: очередь модерации продавцов — пустое состояние с подсказкой «новых заявок нет», массовое одобрение/отклонение выделенных строк; канбан споров — карточка спора показывает срок автоматического релиза эскроу, чтобы администратор не разрешил спор параллельно с уже запущенным автовыдачей (см. крайний случай «гонка release-escrow/спор»); приостановка продавца с активными незакрытыми заказами — обязательное подтверждение (необратимая по факту операция, покупатели теряют возможность оформить новые заказы у продавца) с явным предупреждением о числе незакрытых подзаказов.

Крайние случаи и типовые баги

  • Сплит платежа между несколькими продавцами в одном заказе. Суммы и комиссии — integer minor units; при делении итоговой суммы заказа по подзаказам остаток от округления (1–2 копейки) уходит в подзаказ с наибольшей суммой (детерминированное правило, не последнему по порядку создания) — сумма всех подзаказов всегда равна сумме платежа, копейки не теряются и не задваиваются.
  • Комиссия площадки изменилась. Новая ставка default_commission_percent действует только для новых подзаказов; уже созданные commerce_vendor_sub_orders хранят снапшот commission_percent на момент создания и не пересчитываются задним числом.
  • Вывод средств продавца. Сальдо — не колонка, а агрегат по append-only commerce_vendor_payouts/commerce_vendor_escrow_holds; заявка на внеплановую выплату не может превышать доступный (уже релизнутый, не заблокированный эскроу и не включённый в предыдущий реестр) остаток — проверка перед постановкой в очередь, а не после.
  • Модерация товара продавца. Модерация продавца (commerce-vendor.moderate) не заменяет модерацию конкретной позиции: если у сайта включена премодерация карточек (cms/commerce-catalog + moderation-workflow), товар одобренного продавца всё равно проходит её отдельно — витрина не показывает товар до прохождения обоих гейтов.
  • Продавец блокируется/удаляется с активными незакрытыми заказами. Удаление аккаунта продавца заблокировано, пока есть подзаказы не в терминальном статусе (не paid/отменён со сторно) — сначала полный расчёт (эскроу разрешён, выплата или возврат проведены), доступ покупателя к истории покупки при этом не должен теряться (заказ и позиции — снапшот, не ссылка на живую карточку продавца).
  • Двойная доставка PaymentSucceeded. Постановка на эскроу идемпотентна по payment_id — повторная доставка события не создаёт вторую запись commerce_vendor_escrow_holds на тот же подзаказ.
  • Спор открыт после автоматического релиза эскроу по сроку. Гонка между release-escrow (по истечении escrow_hold_days) и открытием спора: команда релиза берёт блокировку на подзаказ (или проверяет отсутствие открытого спора в той же транзакции) непосредственно перед релизом — открытие спора после фиксации релиза не откатывает уже выданные средства, а эскалируется администратору как отдельный кейс постфактум-претензии.
  • Продавец меняет реквизиты выплаты между формированием реестра и подтверждением. Выплата уходит на реквизиты, снапшот которых сделан на момент подтверждения администратором (vendor-payouts/{id}/approve), а не на момент формирования реестра — правка реквизитов до подтверждения меняет назначение платежа предсказуемо, правка после подтверждения на уже проведённую выплату не влияет.
  • Выключен suggests-модуль cms/commerce-delivery. Триггер ShipmentDelivered недоступен — релиз эскроу для подзаказов возможен только по истечении escrow_hold_days, без досрочного релиза по факту доставки; деградация, не сбой.
  • Пустая витрина маркетплейса (ни одного одобренного продавца) — раздел «продавцы» отдаёт пустое состояние, а не 500/пустой белый экран; товары без vendor_id (магазин) продолжают продаваться как обычно.

Донорский код

Что взятьПуть
Эскроу-логика удержания и релизаnotal/src/app/Services/
Эскроу и арбитраж споровfreelance/project/src/app/Domains/{Escrow,Arbitration}/

Миграция legacy. Команда cms:commerce-vendor:import-legacy --source=<профиль> маппит продавцов/подзаказы/выплаты донора на новую схему по ключу external_id, идемпотентна (повторный прогон обновляет, не дублирует), поддерживает --dry-run с отчётом расхождений; финансовые записи (выплаты, эскроу) импортируются как есть без пересчёта комиссии задним числом (снапшот commission_percent донора переносится как есть). Прогон на копии донорских данных — часть приёмки модуля.

Тесты и приёмка

  • [ ] Контрактный тест: заказ с товарами разных продавцов создаёт корректное число подзаказов с верной суммой каждого
  • [ ] Комиссия платформы рассчитывается поверх цены продавца, не искажая цену товара для покупателя
  • [ ] Округление копейки при сплите платежа между продавцами уходит в подзаказ с наибольшей суммой, сумма подзаказов равна сумме платежа
  • [ ] Изменение default_commission_percent не пересчитывает уже созданные подзаказы (снапшот commission_percent)
  • [ ] Эскроу переходит в released только по ShipmentDelivered или истечению escrow_hold_days, не раньше
  • [ ] Открытие спора блокирует автоматический релиз эскроу до разрешения администратором; гонка release-escrow/открытие спора не допускает двойного результата
  • [ ] Заявка на внеплановую выплату не может превышать доступный (не заблокированный эскроу, не включённый в предыдущий реестр) остаток
  • [ ] Реестр выплат формируется по подтверждённым (не спорным) подзаказам за период, идемпотентно на повторный запуск команды
  • [ ] Выплата уходит на реквизиты продавца, зафиксированные на момент подтверждения администратором, а не на момент формирования реестра
  • [ ] Продавец без статуса approved не может публиковать товары в общей витрине; при включённой отдельной модерации товара — оба гейта обязательны
  • [ ] Удаление/блокировка продавца с незакрытыми подзаказами запрещены; покупатель сохраняет доступ к истории покупки после блокировки продавца
  • [ ] Постановка на эскроу идемпотентна по payment_id: повторная доставка PaymentSucceeded не создаёт вторую запись удержания
  • [ ] При выключении модуля новые сплит-заказы не создаются, существующие подзаказы и выплаты доступны для ручного завершения администратором
  • [ ] Kill-switch auto_escrow_release_enabled=false останавливает автовыдачу по сроку, не затрагивая релиз по ShipmentDelivered и не выключая модуль
  • [ ] Хук «забыть по запросу» обезличивает реквизиты/контакты продавца без потери сумм и статусов в commerce_vendor_payouts/commerce_vendor_sub_orders, и только после полного расчёта по всем подзаказам
  • [ ] Права commerce-vendor.moderate отделены от commerce-vendor.manage (модерация продавцов vs операционное управление); матрица ролей покрыта тестами доступа
  • [ ] cms:commerce-vendor:import-legacy --dry-run даёт отчёт расхождений, повторный прогон идемпотентен (обновляет по external_id, не дублирует)
  • [ ] Демо-сидер commerce-vendor:demo показывает кабинет продавца и Filament-виджеты в /_gallery/playground без ручного ввода
  • [ ] Контрактный набор cms-testing зелёный, пакет тестируется в testbench-изоляции
  • [ ] Feature-тест на каждый роут API, cms/commerce-payments/cms/commerce-delivery в тестах замоканы
  • [ ] Тестовая БД — только commerce-vendor_test; migrate:fresh/refresh/reset запрещены

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