Skip to content

ТЗ — Возвраты (RMA) (cms/commerce-rma)

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

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

Заявка на возврат товара из личного кабинета с workflow согласования, возвратом денег через платёжный модуль и возвратом остатков на склад. Референс процесса споров/арбитража взят из фриланс-платформы студии как модель workflow с промежуточными статусами.

  • Заявка на возврат из ЛК: выбор позиций заказа, причина, приложение фото
  • Workflow: requested → approved/rejected → received → refunded
  • Частичный возврат (не все позиции заказа) и полный возврат
  • Возврат денег через cms/commerce-payments (частичный refund по сумме одобренных позиций)
  • Возврат остатков на склад через cms/commerce-stock (move с причиной return)
  • Формирование чека возврата через cms/commerce-receipts при одобрении
  • Уведомление покупателя на каждом переходе статуса заявки

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

requires: ядро, cms/commerce-model (заказы), cms/commerce-payments · suggests: cms/commerce-stock, cms/commerce-receipts, cms/notifications-bus

Поведение при выключении: покупатели не могут оформить заявку на возврат через ЛК — существующие заявки в работе не блокируются (доводятся вручную администратором), прямой возврат денег без заявки RMA остаётся доступен через cms/commerce-payments.

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

ТаблицаКлючевые поляПримечание
commerce_rma_requestsid, number (ULID, публичный), order_id, user_id, status, reason, comment, created_at, lock_versionзаявка на возврат, status — PHP Enum; number — публичный идентификатор вместо id; lock_version — optimistic lock (§4 стандарта)
commerce_rma_itemsrma_request_id, order_item_id, quantity, condition_noteпозиции заявки (частичный возврат); уникальность по (order_item_id) с накопленным «уже возвращено» — см. «Крайние случаи»
commerce_rma_photosrma_request_id, path, uploaded_atприложенные фото, файлы — через медиатеку
commerce_rma_status_logrma_request_id, from_status, to_status, actor_id, comment, created_atappend-only журнал переходов workflow, BRIN по created_at

order_id/user_id/rma_request_id/order_item_id — FK constrained()->index().

Статусная машина

Переходы разрешены только по явно заданным дугам:

Из статусаВ статусУсловие
requestedapprovedменеджер одобряет заявку (все или часть позиций)
requestedrejectedменеджер отклоняет с обязательной причиной
approvedreceivedтовар физически получен обратно на склад
receivedrefundedвозврат денег выполнен (авто или вручную)
rejected / refundedтерминальные статусы

Попытка перехода вне таблицы (например requested → received, минуя approved) — 409 с code: status_transition_not_allowed.

ПДн-паспорт

ПолеТаблицаСрок храненияУчастие в 152-ФЗ
comment заявителя, condition_notecommerce_rma_requests/commerce_rma_itemsтот же срок, что у связанного заказа (commerce-orders.pii_retention_years)«выгрузить всё по субъекту» — да; «забыть по запросу» — псевдонимизация после истечения срока
фото (commerce_rma_photos)медиатека ядратот же срокда; удаление файла — через хук медиатеки, не прямое обращение к диску

⚠️ Заявка RMA связана с заказом (финансовый документ) — та же оговорка о псевдонимизации вместо удаления действует и здесь (см. commerce-orders: ПДн-паспорт).

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

Входы (whitelist — всё не перечисленное отвергается FormRequest):

ОткудаКаналПоляВалидация
Форма заявки (ЛК)POST /api/v1/rma/requestsorder_number, items[]{order_item_id, quantity, condition_note}, reason, comment?, photos[]?RmaRequestRequest: whitelist полей, принадлежность order_number заявителю (владелец либо guest_token), return_window_days от OrderCompleted, «уже возвращённое» количество по позиции
Форма перехода (админка)POST /api/v1/admin/rma/requests/{number}/transitionaction ∈ {approve, reject, receive, refund}, comment?AdminTransitionRequest: action — whitelist допустимых дуг, reject требует comment (причина для покупателя), право .manage
Событиеслушает OrderCompleted от cms/commerce-ordersorder_id, user_idфиксирует дату начала отсчёта return_window_days
Событиеслушает RefundCompleted от cms/commerce-paymentspayment_id, amountподтверждение факта возврата денег, перевод заявки в refunded
Событие ядраслушает UserMerged / UserDeletedсм. commerce-ordersперенос/псевдонимизация заявок гостя
Импортcms:commerce-rma:import-legacy --source=<профиль>маппинг старых заявок/споров (см. «Донорский код»)тот же контракт полей, --dry-run

Выходы:

КудаФорматСодержимое
Ответ API заявки / спискаконверт data/meta, keyset для списказаявка со статусом, позициями, историей переходов
Событие RmaRequested/RmaApproved/RmaRejected/RmaReceived/RefundApprovedpayload событиясм. «События и обмен»
Виджет «Мои возвраты» (ЛК)рендер через RmaServiceсписок заявок пользователя со статусами, без прямых SQL из шаблона
Экспорт очереди заявок (админка)CSV по фильтруте же поля, что список админки

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

КлючТипДефолтaffectsPageCacheОписание
commerce-rma.enabledbooltrueнетДоступность оформления заявки в ЛК — kill-switch без выключения модуля
commerce-rma.return_window_daysint14нетСрок с момента доставки (OrderCompleted), в течение которого разрешён возврат — юридический дефолт по ст. 25 закона о защите прав потребителей (14 дней не считая дня покупки), не хардкод даты
commerce-rma.require_photoboolfalseнетОбязательность фото при подаче заявки
commerce-rma.auto_refund_on_receivedbooltrueнетАвтоматический запуск возврата денег при статусе received — только если способ оплаты поддерживает программный refund
commerce-rma.max_refund_percent_of_paidint100нетЛимит: доля суммы заказа, доступная к возврату по одной заявке
commerce-rma.max_photos_per_requestint5нетЛимит числа фото на заявку — квота, достижение = понятная ошибка, не 500
commerce-rma.allow_manual_override_windowboolfalseнетРазрешить менеджеру одобрить возврат вне return_window_days вручную (с обязательным комментарием, фиксируется в аудите)

API

МетодПутьДоступНазначение
POST/api/v1/rma/requestsauth или гость по guest_token заказаОформление заявки на возврат
GET/api/v1/rma/requests/myauth или гость по guest_tokenСписок заявок пользователя со статусами
GET/api/v1/admin/rma/requestsadmin (commerce-rma.view)Список заявок с фильтром по статусу (keyset-пагинация)
POST/api/v1/admin/rma/requests/{number}/transitionadmin (commerce-rma.manage)Смена статуса заявки (approve/reject/receive/refund)

Смена статуса — денежная мутация с внешним эффектом на шаге refund, обязателен Idempotency-Key.

Компоненты

Виджеты: форма заявки на возврат и список заявок в личном кабинете. Filament: очередь заявок RMA с канбан-статусами workflow, карточка заявки с фото и историей переходов, массовые действия на списке (пакетный экспорт; пакетное одобрение — не предусмотрено умышленно, каждое одобрение подтверждается индивидуально, см. «UX-требования»). Команды: cms:commerce-rma:expire-window --json (автозакрытие заявок вне окна возврата). Демо-сидер: RmaDemoSeeder — заявки во всех статусах workflow для галереи виджета и playground админки.

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

СобытиеКогдаPayload
RmaRequestedзаявка на возврат созданаrma_request_id, number, order_id, user_id
RmaApprovedзаявка одобрена администраторомrma_request_id
RmaRejectedзаявка отклонена администраторомrma_request_id, reason
RmaReceivedтовар получен обратно на складrma_request_id
RefundApprovedодобрен возврат денег по заявкеrma_request_id, payment_id, amount

Слушает: OrderCompleted от cms/commerce-orders (точка отсчёта return_window_days, см. противоречие в commerce-orders); RefundCompleted от cms/commerce-payments; UserMerged/UserDeleted от ядра. Прямой сервис-вызов PaymentsService::refund() (по requires) и складового движения cms/commerce-stock при переходе в received.

Таблица взаимодействий:

Сущность/модульКаналНаправлениеЧто происходит
cms/commerce-orders4 (requires) + 1 (событие)rma ↔ ordersпроверка принадлежности заказа/позиций; слушает OrderCompleted
cms/commerce-payments4 (requires) + 1 (событие)rma ↔ paymentsвызов refund() исходным методом оплаты; слушает RefundCompleted
cms/commerce-stock4 (requires)rma → stockскладовое движение возврата (reason: return) при received
cms/commerce-receipts4 (requires, suggests)rma → receiptsформирование чека возврата, ссылающегося на исходный чек
ядро (пользователи)1 (событие)ядро → rmaUserMerged/UserDeleted — перенос/псевдонимизация заявок
cms/notifications-bus3 (provides notification-channel)rma → notificationsуведомление покупателя на каждом переходе

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

Именованная очередь commerce-rma: cms:commerce-rma:expire-window по расписанию (автозакрытие заявок вне return_window_days), уведомление покупателя на каждом переходе статуса (через cms/notifications-bus, асинхронно). Запуск возврата денег при auto_refund_on_received=true — из очереди, идемпотентно (повторный переход в received не создаёт второй refund); при сбое платёжного шлюза — ретрай с backoff, после исчерпания — failed job + алерт cms/health, заявка остаётся в received с флагом «требует ручного возврата», не теряется молча.

Эксплуатация (ранбук). Метрики: заявок/час по статусам, доля отклонённых, заявки, «застрявшие» в approved дольше X дней без received (алерт). Инциденты:

СимптомЧто проверитьКоманда
Заявки не переходят в refundedfailed jobs автовозврата, доступность cms/commerce-paymentsqueue:failed, health-чек платёжного шлюза
Возврат остатков не создаётся при receivedжурнал commerce_stock_movescms:commerce-rma:expire-window --dry-run --json для сверки статусов
Заявка «зависла» в requestedне обработана менеджеромсписок с фильтром по статусу в Filament
Чек возврата не сформированcms/commerce-receipts выключен/недоступенhealth-чек модуля чеков, ручная фискализация

Бэкап/рестор: все таблицы модуля попадают в бэкап целиком (заявки связаны с заказами — финансовым документом); после рестора ничего не пересчитывается — денормализованных агрегатов модуль не хранит.

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

Объёмы существенно меньше заказов (доля от общего числа), но журнал commerce_rma_status_log растёт пропорционально переходам — BRIN по created_at, партиционирование при росте. Горячие пути: список заявок пользователя в ЛК (keyset, per-user), очередь заявок админки (keyset + filter[status]), карточка заявки (заявка + позиции + фото + история одним ->with(), без N+1). Индексы: (user_id, created_at) и (status, created_at) на commerce_rma_requests, (rma_request_id) на дочерних таблицах.

Теги commerce-rma:request:<id>. Инвалидация — событиями жизненного цикла заявки (RmaRequested/Approved/Rejected/Received/RefundApproved). Заявки и фото — персональные данные (состав возврата, причина, фото товара), список и карточка заявки не попадают в общий page-cache.

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

Заявка доступна только владельцу заказа (auth, проверка принадлежности order_id) либо гостю по guest_token заказа. Фото — через медиатеку ядра (не прямая загрузка на диск), с ограничением типа/размера и max_photos_per_request. Смена статуса — только под правом commerce-rma.manage, каждый переход фиксируется в журнале с actor_id.

IDOR: публичный идентификатор заявки — number (ULID), не инкрементный id.

Деньги возвращаются только исходным платёжным методом (связь с cms/commerce-payments по requires): возврат на реквизиты, отличные от способа оплаты, программно запрещён — только ручное вмешательство администратора с отдельным подтверждением и записью в аудит.

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

ДействиеПокупатель (владелец)МенеджерРедакторStudio
Оформление заявкида
Просмотр очереди заявок (.view)данетда
Одобрение/отклонение заявки (.manage)нетданетда
Ручной возврат на реквизиты, отличные от способа оплатынетнетнетда, с отдельным подтверждением
Одобрение вне return_window_days (allow_manual_override_window)нетда, с обязательным комментариемнетда

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

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

Покупатель: причина отказа заявки — обязательное поле для менеджера, отображается покупателю на понятном языке («товар не подходит под условия возврата: истёк срок» вместо кода статуса); статус заявки виден пошагово (как трек посылки — requested → approved → received → refunded); ошибки загрузки фото (размер/формат) — понятны сразу на форме, не после отправки заявки.

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

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

  • Повторная заявка на ту же позицию/то же количество → контрактная проверка «уже возвращённого количества» по order_item_id: сумма quantity по всем неотклонённым заявкам не может превышать qty позиции заказа, превышение — 422.
  • Частичный возврат по нескольким позициям → сумма возврата = сумма только одобренных позиций заявки, не всего заказа; если из трёх позиций одобрены две — refund считается по двум.
  • Заявка вне return_window_days → отклоняется при создании; менеджер может одобрить вручную только если allow_manual_override_window=true, с обязательным комментарием, фиксируемым в аудите.
  • Двойной клик «одобрить»/«отказать» → переход защищён Idempotency-Key/optimistic lock заявки; повторный запрос на уже обработанную заявку — 409.
  • Способ возврата денег отличен от способа оплаты → программно запрещено; только ручное вмешательство администратора с отдельным подтверждением (см. «Безопасность»).
  • Заказ частично оплачен бонусами → сумма возврата пересчитывается пропорционально: денежная часть возвращается через cms/commerce-payments, бонусная — сторно транзакции лояльности, не полностью деньгами.
  • auto_refund_on_received=true, но способ оплаты — оффлайн (наличные при получении) → автоматический refund технически невозможен; заявка остаётся в received с флагом «требует ручного возврата» и уведомлением менеджеру, а не падает молча.
  • Сбой платёжного шлюза при автовозврате → ретрай с backoff, после исчерпания — failed job + алерт cms/health, заявка не теряется (см. «Фоновая работа»).
  • cms/commerce-receipts выключен (suggests) → refund проходит, но чек возврата не формируется — заявка помечается «требует ручной фискализации», алерт администратору.
  • require_photo=true, но фото не приложено422 при подаче заявки, до создания записи.
  • Переход requested → received в обход approved → недопустимая дуга статусной машины, 409.
  • Два менеджера одновременно обрабатывают одну заявку (например один одобряет, другой отклоняет) → lock_version (optimistic lock): второй получает 409 version_conflict вместо «последний победил» — заявка не переходит в противоречивое состояние.

⚠️ Противоречие: commerce-orders поддерживает guest_checkout=true (заказ без регистрации), но исходный контракт API commerce-rma требовал auth — гость не мог оформить возврат. Разрешение (внесено этой ревизией): доступ к RMA для гостя — по тому же подписанному guest_token заказа, что и для просмотра деталей заказа; API-таблица выше уже это отражает.

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

Что взятьПуть
Референс workflow споров/арбитража (статусы, эскалация)freelance/project/src/app/Domains/Arbitration/

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

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

  • [ ] Контрактный тест: переход статуса заявки возможен только по разрешённым дугам workflow (requested → approved/rejected → received → refunded)
  • [ ] Заявка вне return_window_days от OrderCompleted не создаётся без allow_manual_override_window
  • [ ] Повторная заявка на уже возвращённое количество позиции отклоняется 422
  • [ ] Частичный возврат считает сумму только по одобренным позициям, не по всему заказу
  • [ ] Одобрение заявки с auto_refund_on_received=true запускает частичный/полный refund через cms/commerce-payments исходным способом оплаты на сумму одобренных позиций
  • [ ] Возврат на реквизиты, отличные от способа оплаты, недоступен без ручного подтверждения администратора
  • [ ] Статус received создаёт складское движение возврата через cms/commerce-stock, не дублируясь при повторном вызове
  • [ ] Возврат по заявке формирует чек возврата через cms/commerce-receipts, ссылающийся на исходный чек
  • [ ] Переход вне таблицы допустимых дуг возвращает 409 с code: status_transition_not_allowed
  • [ ] Журнал переходов статуса append-only, отражает актора и комментарий на каждом шаге
  • [ ] Гостевой доступ к оформлению и просмотру заявки работает по guest_token заказа
  • [ ] При выключении модуля новые заявки недоступны, заявки в работе доводятся администратором вручную
  • [ ] Контрактный набор cms-testing зелёный, пакет тестируется в testbench-изоляции
  • [ ] Feature-тест на каждый роут API, cms/commerce-payments/cms/commerce-stock в тестах замоканы
  • [ ] Тестовая БД — только commerce-rma_test; migrate:fresh/refresh/reset запрещены

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