Тема
ТЗ — Маркетплейс/мультивендор (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_vendors | id, user_id, title, status (pending|approved|suspended), commission_percent | продавец, статус модерации, status — PHP Enum |
commerce_vendor_sub_orders | id, order_id, vendor_id, status, amount, commission_amount | подзаказ продавца (сплит), amount/commission_amount — integer minor units |
commerce_vendor_escrow_holds | id, sub_order_id, payment_id, status (held|released|disputed), released_at | эскроу-удержание по подзаказу |
commerce_vendor_payouts | id, vendor_id, period_from, period_to, amount, status (pending|approved|paid) | append-only реестр выплат продавцу |
commerce_vendor_disputes | id, 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-payments—payment_id, order_id, amount; триггер постановки средств заказа на эскроу по каждому подзаказу; - событие
ShipmentDeliveredизcms/commerce-delivery—shipment_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_percent | float | 10.0 | нет | Комиссия платформы по умолчанию |
commerce-vendor.escrow_hold_days | int | 14 | нет | Срок удержания эскроу до автоматического релиза |
commerce-vendor.moderation_required | bool | true | нет | Обязательная модерация продавца перед публикацией |
commerce-vendor.payout_period_days | int | 14 | нет | Периодичность формирования реестра выплат |
commerce-vendor.max_products_unverified | int | 20 | нет | Лимит и квоты: максимум товаров у продавца без пройденной верификации (документы) |
commerce-vendor.payout_request_max_per_day | int | 1 | нет | Лимит и квоты: максимум заявок продавца на внеплановую выплату в сутки |
commerce-vendor.auto_escrow_release_enabled | bool | true | нет | Kill-switch: аварийная остановка автоматического релиза эскроу по сроку без выключения модуля (ручные и по ShipmentDelivered релизы продолжают работать) |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| POST | /api/v1/vendor/onboarding | auth | Заявка на регистрацию продавца |
| GET | /api/v1/vendor/sub-orders | auth (продавец) | Подзаказы продавца со статусами (keyset-пагинация) |
| GET | /api/v1/vendor/payouts | auth (продавец) | Реестр начислений продавца, статус эскроу по подзаказу |
| POST | /api/v1/vendor/disputes | auth (продавец/покупатель) | Открытие спора по подзаказу |
| GET | /api/v1/admin/vendors | admin (commerce-vendor.view) | Список продавцов с фильтром по статусу модерации |
| POST | /api/v1/admin/vendor-payouts/{id}/approve | admin (commerce-vendor.manage) | Подтверждение выплаты продавцу |
| POST | /api/v1/admin/vendor-disputes/{id}/resolve | admin (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-payments | 1 (событие) | входящее | PaymentSucceeded → создание commerce_vendor_escrow_holds на каждый подзаказ, идемпотентно по payment_id |
cms/commerce-payments | 4 (requires) | исходящее | эскроу-операции (постановка/релиз средств) вызывают публичный сервис платежей — сам модуль деньгами не управляет |
cms/commerce-delivery | 1 (событие) | входящее | ShipmentDelivered → триггер отсчёта/условия релиза эскроу по подзаказу |
cms/commerce-receipts | 3 (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_id | cms: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запрещены