Тема
ТЗ — Возвраты (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_requests | id, 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_items | rma_request_id, order_item_id, quantity, condition_note | позиции заявки (частичный возврат); уникальность по (order_item_id) с накопленным «уже возвращено» — см. «Крайние случаи» |
commerce_rma_photos | rma_request_id, path, uploaded_at | приложенные фото, файлы — через медиатеку |
commerce_rma_status_log | rma_request_id, from_status, to_status, actor_id, comment, created_at | append-only журнал переходов workflow, BRIN по created_at |
order_id/user_id/rma_request_id/order_item_id — FK constrained()->index().
Статусная машина
Переходы разрешены только по явно заданным дугам:
| Из статуса | В статус | Условие |
|---|---|---|
requested | approved | менеджер одобряет заявку (все или часть позиций) |
requested | rejected | менеджер отклоняет с обязательной причиной |
approved | received | товар физически получен обратно на склад |
received | refunded | возврат денег выполнен (авто или вручную) |
rejected / refunded | — | терминальные статусы |
Попытка перехода вне таблицы (например requested → received, минуя approved) — 409 с code: status_transition_not_allowed.
ПДн-паспорт
| Поле | Таблица | Срок хранения | Участие в 152-ФЗ |
|---|---|---|---|
comment заявителя, condition_note | commerce_rma_requests/commerce_rma_items | тот же срок, что у связанного заказа (commerce-orders.pii_retention_years) | «выгрузить всё по субъекту» — да; «забыть по запросу» — псевдонимизация после истечения срока |
фото (commerce_rma_photos) | медиатека ядра | тот же срок | да; удаление файла — через хук медиатеки, не прямое обращение к диску |
⚠️ Заявка RMA связана с заказом (финансовый документ) — та же оговорка о псевдонимизации вместо удаления действует и здесь (см. commerce-orders: ПДн-паспорт).
Входные и выходные данные
Входы (whitelist — всё не перечисленное отвергается FormRequest):
| Откуда | Канал | Поля | Валидация |
|---|---|---|---|
| Форма заявки (ЛК) | POST /api/v1/rma/requests | order_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}/transition | action ∈ {approve, reject, receive, refund}, comment? | AdminTransitionRequest: action — whitelist допустимых дуг, reject требует comment (причина для покупателя), право .manage |
| Событие | слушает OrderCompleted от cms/commerce-orders | order_id, user_id | фиксирует дату начала отсчёта return_window_days |
| Событие | слушает RefundCompleted от cms/commerce-payments | payment_id, amount | подтверждение факта возврата денег, перевод заявки в refunded |
| Событие ядра | слушает UserMerged / UserDeleted | см. commerce-orders | перенос/псевдонимизация заявок гостя |
| Импорт | cms:commerce-rma:import-legacy --source=<профиль> | маппинг старых заявок/споров (см. «Донорский код») | тот же контракт полей, --dry-run |
Выходы:
| Куда | Формат | Содержимое |
|---|---|---|
| Ответ API заявки / списка | конверт data/meta, keyset для списка | заявка со статусом, позициями, историей переходов |
Событие RmaRequested/RmaApproved/RmaRejected/RmaReceived/RefundApproved | payload события | см. «События и обмен» |
| Виджет «Мои возвраты» (ЛК) | рендер через RmaService | список заявок пользователя со статусами, без прямых SQL из шаблона |
| Экспорт очереди заявок (админка) | CSV по фильтру | те же поля, что список админки |
Настройки (группа commerce-rma)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
commerce-rma.enabled | bool | true | нет | Доступность оформления заявки в ЛК — kill-switch без выключения модуля |
commerce-rma.return_window_days | int | 14 | нет | Срок с момента доставки (OrderCompleted), в течение которого разрешён возврат — юридический дефолт по ст. 25 закона о защите прав потребителей (14 дней не считая дня покупки), не хардкод даты |
commerce-rma.require_photo | bool | false | нет | Обязательность фото при подаче заявки |
commerce-rma.auto_refund_on_received | bool | true | нет | Автоматический запуск возврата денег при статусе received — только если способ оплаты поддерживает программный refund |
commerce-rma.max_refund_percent_of_paid | int | 100 | нет | Лимит: доля суммы заказа, доступная к возврату по одной заявке |
commerce-rma.max_photos_per_request | int | 5 | нет | Лимит числа фото на заявку — квота, достижение = понятная ошибка, не 500 |
commerce-rma.allow_manual_override_window | bool | false | нет | Разрешить менеджеру одобрить возврат вне return_window_days вручную (с обязательным комментарием, фиксируется в аудите) |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| POST | /api/v1/rma/requests | auth или гость по guest_token заказа | Оформление заявки на возврат |
| GET | /api/v1/rma/requests/my | auth или гость по guest_token | Список заявок пользователя со статусами |
| GET | /api/v1/admin/rma/requests | admin (commerce-rma.view) | Список заявок с фильтром по статусу (keyset-пагинация) |
| POST | /api/v1/admin/rma/requests/{number}/transition | admin (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-orders | 4 (requires) + 1 (событие) | rma ↔ orders | проверка принадлежности заказа/позиций; слушает OrderCompleted |
cms/commerce-payments | 4 (requires) + 1 (событие) | rma ↔ payments | вызов refund() исходным методом оплаты; слушает RefundCompleted |
cms/commerce-stock | 4 (requires) | rma → stock | складовое движение возврата (reason: return) при received |
cms/commerce-receipts | 4 (requires, suggests) | rma → receipts | формирование чека возврата, ссылающегося на исходный чек |
| ядро (пользователи) | 1 (событие) | ядро → rma | UserMerged/UserDeleted — перенос/псевдонимизация заявок |
cms/notifications-bus | 3 (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 (алерт). Инциденты:
| Симптом | Что проверить | Команда |
|---|---|---|
Заявки не переходят в refunded | failed jobs автовозврата, доступность cms/commerce-payments | queue:failed, health-чек платёжного шлюза |
Возврат остатков не создаётся при received | журнал commerce_stock_moves | cms: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запрещены