Тема
ТЗ — Брошенные корзины (cms/commerce-abandoned)
Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Детект брошенных корзин по неактивности с цепочкой напоминаний и статистикой возврата пользователей к покупке.
- Детект брошенной корзины по настраиваемому таймауту неактивности
- Цепочка напоминаний с шагами по времени (например, через 1 час / 24 часа / 3 дня)
- События для
cms/email-marketingна каждый шаг цепочки напоминаний - Промокод в письме-напоминании через крючок
cms/coupons(опционально) - Статистика возврата: сколько брошенных корзин завершились заказом после напоминания
- Исключение из детекта корзин, уже оформленных в заказ
Зависимости и выключение
requires: cms/commerce-cart · suggests: cms/email-marketing, cms/coupons
Поведение при выключении: корзины не отслеживаются на брошенность, напоминания не отправляются — сама корзина и оформление заказа работают штатно, деградация только маркетинговой функции.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
commerce_abandoned_carts | id, cart_id, detected_at, reminders_sent, last_reminder_at (nullable), recovered_at (nullable) | учёт брошенной корзины и статуса возврата; FK cart_id — constrained()->index(), индекс detected_at (BRIN) под джобу отправки |
commerce_abandoned_reminder_steps | id, step_number, delay_minutes, coupon_id (nullable) | шаг цепочки напоминаний с задержкой и опц. промокодом |
last_reminder_at — для лимита частоты напоминаний (см. настройку min_reminder_interval_hours ниже). Индексы под горячий WHERE джобы send-reminders: commerce_abandoned_carts(detected_at, recovered_at) (выборка неотправленных активных).
ПДн-паспорт. Модуль не хранит email/имя сам — только cart_id (ссылка на commerce-cart, где email доступен через user_id/чекаут-контакт гостя). Срок хранения записи о брошенной корзине — до recovered_at + ретеншн-настройка (см. ниже); участие в «выгрузить всё по субъекту» — косвенное (через cart_id → commerce-cart/commerce-orders); в «забыть по запросу» — каскадное удаление своих записей по UserDeleted (через связанную корзину). Согласие на рассылку. Отправка напоминания — только при действующем согласии на маркетинговые коммуникации (проверка через cms/consents, если модуль включён; иначе согласие считается частью оформления корзины/чекаута — декларируется как открытый вопрос для конкретного сайта, если cms/consents не установлен).
Входные и выходные данные
Входы (whitelist — всё не перечисленное отклоняется 422):
| Откуда | Что приходит | Поля | Валидация |
|---|---|---|---|
Джоба detect (расписание) | детект брошенности | cart_id (внутренний, из commerce-cart) | не пользовательский ввод — только корзины без активности дольше inactivity_timeout_minutes |
Событие CartItemAdded/др. (commerce-cart) | сброс пометки брошенности | cart_id | payload чужого модуля; сброс, только если корзина уже была помечена |
Событие OrderCompleted/эквивалент оформления заказа | фиксация возврата | cart_id, order_id | payload ядра/commerce-orders; проверка «этот cart_id был в брошенных» |
| Filament: конструктор шагов | шаг цепочки | step_number, delay_minutes, coupon_id | FormRequest-whitelist; coupon_id — только активный купон при cms/coupons включён |
| Ссылка восстановления в письме | переход по ссылке | signed URL (cart_id внутри подписи, не в открытом виде) | Laravel signed route + TTL, отдельно от токена корзины (см. «Безопасность») |
Выходы:
| Куда | Формат | Содержимое |
|---|---|---|
Событие CartAbandoned | канал 1 | cart_id, detected_at |
Событие AbandonedCartReminderDue | канал 1 | cart_id, step_number, coupon_id, recovery_url (подписанная ссылка) |
Событие AbandonedCartRecovered | канал 1 | cart_id, order_id |
GET /admin/commerce-abandoned/carts | {data, meta} | список со статусом, email/имя маскированы (см. «Безопасность») |
GET /admin/commerce-abandoned/stats | {data, meta} | конверсия по шагам цепочки, агрегаты без ПДн |
| Filament-дашборд | рендер | статистика возврата |
Настройки (группа commerce-abandoned)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
commerce-abandoned.enabled | bool | true | нет | Включение детекта брошенных корзин |
commerce-abandoned.inactivity_timeout_minutes | int | 60 | нет | Таймаут неактивности до пометки корзины брошенной |
commerce-abandoned.max_reminders | int | 3 | нет | Максимум шагов цепочки напоминаний; при достижении — цепочка останавливается, корзина остаётся в статистике |
commerce-abandoned.min_reminder_interval_hours | int | 12 | нет | Минимальный интервал между напоминаниями (защита от спама, независимо от шагов цепочки) |
commerce-abandoned.recovery_link_ttl_hours | int | 72 | нет | Срок жизни подписанной ссылки восстановления корзины в письме |
commerce-abandoned.retention_days | int | 180 | нет | Срок хранения записи о брошенной корзине после recovered_at/детекта (джоба ретеншна) |
commerce-abandoned.sending_enabled | bool | true | нет | Kill-switch: отключает отправку писем-напоминаний без выключения детекта и статистики модуля |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/admin/commerce-abandoned/carts | admin (commerce-abandoned.view) | Список брошенных корзин со статусом возврата (keyset-пагинация) |
| GET | /api/v1/admin/commerce-abandoned/stats | admin (commerce-abandoned.view) | Статистика возврата по цепочке напоминаний |
Компоненты
Filament: список брошенных корзин, конструктор шагов цепочки напоминаний, дашборд статистики возврата. Команды: cms:commerce-abandoned:detect --json, cms:commerce-abandoned:send-reminders --json, cms:commerce-abandoned:prune --json (ретеншн по retention_days). Демо-сидер: 2-3 demo-записи брошенных корзин на разных шагах цепочки для галереи Filament-дашборда и playground.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
CartAbandoned | корзина помечена брошенной по таймауту | cart_id, detected_at |
AbandonedCartReminderDue | наступил срок отправки шага напоминания | cart_id, step_number, coupon_id |
AbandonedCartRecovered | брошенная корзина завершилась заказом | cart_id, order_id |
Слушает: события изменения cms/commerce-cart (CartItemAdded и др.) для сброса пометки брошенности при активности; событие оформления заказа (OrderCompleted/эквивалент из commerce-orders) для фиксации AbandonedCartRecovered и остановки цепочки.
Provides-контракты: не предоставляет. FilterBus: не использует; интеграция с cms/email-marketing и cms/coupons — событиями (AbandonedCartReminderDue), не прямым вызовом.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/commerce-cart | 4. requires, сервис-вызов | исходящее | чтение состояния корзины (позиции, updated_at) на детекте |
cms/commerce-cart | 1. событие активности корзины | входящее | сброс пометки брошенности |
cms/commerce-orders (или ядро) | 1. событие оформления заказа | входящее | фиксация возврата, остановка цепочки |
cms/email-marketing (suggests) | 1. событие AbandonedCartReminderDue | исходящее | отправка письма-напоминания по шагу |
cms/coupons (suggests) | 4. requires, сервис-вызов | исходящее | выпуск/валидация промокода для шага цепочки |
cms/consents (если есть) | 4. requires/сервис-вызов | исходящее | проверка действующего согласия перед каждой отправкой |
Фоновая работа
Очередь commerce-abandoned: detect по расписанию через ScheduleRegistrar (детект по inactivity_timeout_minutes), send-reminders — по расписанию шагов цепочки, идемпотентно (повторный прогон не дублирует отправку), prune — ретеншн по retention_days.
Метрики и алерты: число новых брошенных корзин/сутки, конверсия возврата по шагу цепочки, доля пропущенных отправок из-за отсутствия согласия, длительность send-reminders. Алерт — send-reminders не отрабатывает 2+ расписания подряд (напоминания не уходят вовсе).
Ранбук (типовые инциденты):
| Симптом | Что проверить | Команда-лечение |
|---|---|---|
| Напоминания не отправляются | commerce-abandoned.sending_enabled (kill-switch), включён ли cms/email-marketing | --dry-run на send-reminders, затем включить kill-switch обратно |
| Письмо ушло после того как заказ уже оформлен | гонка детект/оформление — проверить лог проверки «заказ не оформлен» перед отправкой | не требует лечения данных — это баг слушателя, чинится в коде (см. «Крайние случаи») |
| Статистика конверсии не сходится с email-marketing | сверить reminders_sent и события AbandonedCartReminderDue в логе шины | cms:commerce-abandoned:detect --dry-run — сверка расхождений |
| Ссылка восстановления не открывается | истёк recovery_link_ttl_hours, проверить подпись signed route | не «чинится» вручную — покупатель заходит в корзину заново; TTL — осознанный компромисс |
Бэкап/рестор: commerce_abandoned_carts/commerce_abandoned_reminder_steps — в общем бэкапе БД (шаги цепочки — конфигурация, записи брошенных корзин — рабочие данные). После рестора статистика конверсии за период рестора может быть неполной (не пересчитывается автоматически) — это ожидаемо, отдельной команды восстановления не требуется.
Производительность и кеш
- Ожидаемые объёмы: низкая частота обращения — только админка (Filament-дашборд, список), публичного горячего пути нет (кроме перехода по ссылке восстановления, который открывает обычную страницу корзины). Джобы
detect/send-reminders— фоновые, не на пути запроса. - Кеша нет — списки брошенных корзин и статистика читаются напрямую из БД.
- Бюджет запросов:
detectиsend-reminders— батчами (Bus::batch), не по одной корзине синхронным запросом; список в админке — keyset-пагинация, безOFFSET. - Индексы под горячий WHERE:
commerce_abandoned_carts(detected_at, recovered_at)(выборка активных неотправленных на джобе),commerce_abandoned_carts(cart_id)(сброс пометки по событию активности).
Безопасность
Вход в конструктор шагов и просмотр статистики — только через FormRequest-whitelist и permissions; в статистику/список не попадают персональные данные сверх необходимого (email/имя маскируются в выдаче списка, полные данные — по клику в карточку корзины). Права: commerce-abandoned.view, commerce-abandoned.manage.
Ссылка восстановления — signed URL, не голый cart_id. Письмо содержит подписанную ссылку (Laravel signed route) с TTL (recovery_link_ttl_hours), не прямой cart_id/session_token в открытом виде — иначе пересланное письмо или лог прокси дают доступ к чужой корзине (IDOR). Истёкшая или невалидная подпись — понятная страница «ссылка устарела», не 500/белый экран.
Согласие на рассылку. Отправка каждого шага цепочки проверяет действующее согласие на маркетинговые коммуникации непосредственно перед отправкой (не только на детекте) — отзыв согласия между шагами останавливает цепочку для этой корзины.
Матрица ролей:
| Роль | commerce-abandoned.view | commerce-abandoned.manage |
|---|---|---|
| Администратор | ✓ | ✓ |
| Менеджер | ✓ (статистика и список) | — |
| Редактор | — | — |
| Studio | ✓ | ✓ (конструктор цепочки) |
UX-требования
Покупатель: ссылка восстановления открывает корзину в исходном состоянии без дополнительной авторизации (если TTL не истёк); истёкшая ссылка — понятное сообщение с предложением зайти в каталог заново, не техническая ошибка.
Админ: пустой список брошенных корзин — состояние «пока нет данных, детект запускается по расписанию», не пустая таблица без пояснения; конструктор шагов цепочки — массовое включение/выключение шага без пересоздания; удаление шага цепочки, на который уже запланированы отправки, — подтверждение с предупреждением «отменит N ожидающих напоминаний»; ошибки конструктора (например, coupon_id недействующего купона) — на человеческом языке с указанием, какой шаг некорректен.
Крайние случаи и типовые баги
- Письмо о брошенной корзине после того как заказ уже оформлен (гонка детект/оформление) → проверка «заказ по этому
cart_idне оформлен» выполняется непосредственно перед отправкой каждого шага цепочки (не только при детекте) — оформление заказа между шагами останавливает дальнейшую отправку немедленно, а не только на следующем прогоне джобы. - Частота напоминаний →
min_reminder_interval_hoursограничивает отправку независимо от числа настроенных шагов; если по расчёту цепочки должно уйти два письма в течение интервала, второе откладывается до истечения интервала, не отменяется молча (лог + метрика). - Отозванное или отсутствующее согласие на рассылку → напоминание не отправляется ни на одном шаге без действующего согласия; корзина остаётся в статистике детекта, но помечается «не отправлено — нет согласия», не тихий пропуск без следа.
- Ссылка восстановления протухла (TTL истёк) → переход по ссылке отдаёт понятную страницу с предложением продолжить покупки заново, не 403/500.
- Корзина восстановлена активностью, а не переходом по ссылке (покупатель зашёл сам) → слушатель события активности
commerce-cartсбрасывает пометку брошенности так же, как и переход по ссылке — единая точка сброса, не два независимых пути с расхождением. max_remindersдостигнут → цепочка останавливается тихо для покупателя (писем больше нет), но корзина продолжает участвовать в статистике «не вернулся после N напоминаний».- Выключен
suggests-модульcms/email-marketing→AbandonedCartReminderDueиздаётся штатно (детект и цепочка работают), но письмо физически не уходит — модуль не падает, расхождение видно в метрике «событий издано vs писем отправлено». - Пустая цепочка шагов (админ не настроил ни одного шага) → детект работает, но напоминания не планируются — не ошибка конфигурации, а осознанный режим «только статистика без рассылки».
- Купон шага уже деактивирован/удалён к моменту отправки → шаг отправляется без промокода (деградация функции, не отмена всего письма), в лог — предупреждение.
- ⚠️ Противоречие:
coupon_idвcommerce_abandoned_reminder_steps— конфигурация шага, общая для всех писем, а не факт применения к конкретной корзине. Аудит применения купона (кто и когда использовал) ведётcms/couponsпо своим таблицам; этот модуль не дублирует журнал использования — только ссылается наcoupon_idшаблона.
Донорский код
Донор: — (новая разработка)
Миграция legacy-данных: не применимо — брошенные корзины детектируются заново после переезда клиента (исторические записи предыдущей платформы не переносят ценности без активных корзин под ними); cms:commerce-abandoned:import-legacy не требуется.
Тесты и приёмка
- [ ] Контрактный тест: детект помечает брошенной только корзину без активности дольше таймаута
- [ ] Оформление заказа из брошенной корзины фиксирует
AbandonedCartRecoveredи останавливает цепочку - [ ] Оформление заказа между шагами цепочки останавливает отправку следующего шага (проверка гонки на каждом шаге, не только на детекте)
- [ ] Цепочка напоминаний не отправляет писем сверх
max_reminders - [ ] Цепочка не отправляет два письма чаще
min_reminder_interval_hours - [ ] Без действующего согласия на рассылку напоминание не отправляется ни на одном шаге
- [ ] Ссылка восстановления — подписанная (signed route), не содержит голого
cart_id; истёкшая ссылка отдаёт понятную страницу, не 500 - [ ] При выключении модуля корзина и заказ работают без изменений в поведении
- [ ] При выключенном
cms/email-marketingсобытие издаётся, но отправка не падает и не блокирует детект - [ ] Права
commerce-abandoned.view/.manageразграничивают статистику и настройку цепочки - [ ] Контрактный набор
cms-testingи testbench-изоляция зелёные, feature-тест на каждый роут - [ ] Тестовая БД только
commerce-abandoned_test;migrate:fresh/refresh/resetзапрещены