Тема
ТЗ — Заказы (cms/commerce-orders)
Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: masha, notal Статус: ТЗ к разработке
Назначение и возможности
Оформление заказа (checkout) с шагами для гостя и авторизованного пользователя, снапшот позиций на момент заказа (независимость от последующих изменений каталога) и настраиваемая статус-машина с каноническими состояниями (см. /cms-v2/commerce-model).
- Checkout: пошаговое оформление (доставка → оплата → подтверждение), гость и пользователь
- Снапшот позиций заказа: цена, ставка НДС, название товара на момент заказа
- Статус-машина: настраиваемые статусы поверх канонических состояний
new/paid/shipped/done/cancelled - Резервирование остатков через
cms/commerce-stockпри переходе в оплаченный статус - История изменений статуса заказа (кто, когда, с какого на какой)
- Комментарии менеджера к заказу (внутренние, не видны клиенту)
- Список заказов в личном кабинете пользователя с деталями и статусом
Зависимости и выключение
requires: cms/commerce-cart, cms/commerce-pricing · suggests: cms/commerce-stock, cms/loyalty, cms/commerce-invoices
Поведение при выключении: базовый модуль контура покупки — выключение останавливает оформление заказов целиком, корзина продолжает работать в режиме просмотра без чекаута.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
commerce_orders | id, number (ULID, публичный), user_id (nullable), guest_token (nullable, hash), status, checkout_step, total_minor, currency_code, contact_phone, contact_email, delivery_address_snapshot (json), lock_version | заказ, канонический статус; status/checkout_step — PHP Enum, FK user_id — constrained()->index(), number — уникальный индекс, публичный идентификатор вместо id, lock_version — optimistic lock (§4 стандарта) |
commerce_order_items | id, order_id, variant_id, title_snapshot, price_minor_snapshot, vat_rate_snapshot, qty | снапшот позиции на момент заказа; FK order_id/variant_id — constrained()->index() |
commerce_order_status_history | id, order_id, from_status, to_status, changed_by, changed_at | история переходов статуса, append-only; BRIN по changed_at, партиционирование по диапазону дат при росте объёма |
commerce_order_manager_comments | id, order_id, manager_id, comment, created_at | внутренние комментарии менеджера, не видны клиенту |
number — ULID, генерируется при создании заказа, используется во всех публичных URL/API вместо инкрементного id (§4 стандарта: автоинкремент в публичном URL запрещён). guest_token — подписанный токен, выдаётся гостю при оформлении и вшивается в ссылку подтверждения/письмо; заменяет аутентификацию для доступа гостя к своему заказу.
Статусная машина
Переходы разрешены только по явно заданным дугам (конфиг commerce-orders.default_statuses описывает список, дуги — контрактная константа модуля, не выводятся произвольно):
| Из статуса | В статус | Условие |
|---|---|---|
new | paid | подтверждение оплаты (событие PaymentSucceeded от cms/commerce-payments) |
new | cancelled | покупатель/менеджер отменяет неоплаченный заказ |
paid | shipped | заказ передан в доставку |
paid | cancelled | отмена оплаченного заказа — только с возвратом резерва/денег (см. «Крайние случаи») |
shipped | done | подтверждён факт получения (выкуп) |
shipped | cancelled | нештатная отмена в пути — редкий кейс, требует комментария |
done | — | терминальный статус заказа; возврат товара — отдельным процессом cms/commerce-rma, не откатом статуса заказа |
cancelled | — | терминальный статус |
Попытка перехода вне таблицы — 409 с code: status_transition_not_allowed и человеческим сообщением в админке. Любая настраиваемая надстройка статусов (default_statuses) обязана мапиться на одно из канонических состояний — не вводит свои дуги в обход этой таблицы.
ПДн-паспорт
| Поле | Таблица | Срок хранения | Участие в 152-ФЗ |
|---|---|---|---|
contact_phone, contact_email | commerce_orders | до истечения срока хранения финансовых документов (commerce-orders.pii_retention_years, дефолт по бухгалтерским требованиям) | «выгрузить всё по субъекту» — да; «забыть по запросу» — псевдонимизация полей после истечения срока, не удаление строки |
delivery_address_snapshot | commerce_orders | тот же срок | то же |
comment менеджера | commerce_order_manager_comments | тот же срок ретеншна журналов | не выгружается покупателю (не его ПДн), подпадает под общий ретеншн |
⚠️ Заказ — финансовый документ; полное удаление по запросу «забыть» может противоречить требованиям бухучёта. Разрешение: по достижении pii_retention_years джоба ретеншна псевдонимизирует contact_phone, contact_email, delivery_address_snapshot (замена на хеш/«удалено»), финансовые поля (total_minor, status, история) сохраняются без изменений.
Входные и выходные данные
Входы (whitelist — всё не перечисленное отвергается FormRequest):
| Откуда | Канал | Поля | Валидация |
|---|---|---|---|
| Форма checkout (фронт) | POST /api/v1/orders/checkout | cart_id, delivery_method, delivery_address{city,street,house,flat,comment}, contact_phone, contact_email, payment_method, comment? | CheckoutRequest: whitelist полей, телефон/email — формат, delivery_method/payment_method — enum из настроек, cart_id — принадлежность текущей сессии/пользователю |
| Форма смены статуса (админка) | POST /api/v1/admin/orders/{number}/status | to_status, comment? | AdminStatusChangeRequest: to_status — whitelist допустимых дуг статусной машины, право .manage |
| Событие ядра | слушает UserMerged | primary_user_id, secondary_user_id | контракт события ядра, core.md |
| Событие ядра | слушает UserDeleted / хук «забыть по запросу» | user_id | хук ядра 152-ФЗ, см. ПДн-паспорт выше |
| Импорт | cms:commerce-orders:import-legacy --source=<профиль> | маппинг старых заказов и позиций (см. «Донорский код») | тот же контракт полей, что CheckoutRequest; --dry-run с отчётом расхождений |
Клиент не передаёт итоговую сумму заказа — только cart_id; total_minor пересчитывается на сервере через cms/commerce-pricing (защита от подмены суммы, см. «Безопасность»).
Выходы:
| Куда | Формат | Содержимое |
|---|---|---|
| Ответ API checkout / деталей заказа | конверт data/meta | заказ + снапшот позиций, number, статус, история для владельца |
| Ответ API списка заказов (ЛК/админка) | конверт data/meta, keyset-пагинация | краткие карточки заказов без деталей позиций |
Событие OrderPlaced / OrderStatusChanged / OrderCompleted / OrderCancelled | payload события | см. «События и обмен» |
| Виджет «Мои заказы» (ЛК) | рендер через OrdersService | список последних заказов, без прямых SQL из шаблона |
| Экспорт списка заказов (админка) | CSV по текущему фильтру | те же поля, что список админки; выгрузка объёмом свыше лимита настройки — job в очереди |
Настройки (группа commerce-orders)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
commerce-orders.guest_checkout | bool | true | нет | Разрешён ли checkout без регистрации |
commerce-orders.default_statuses | array | [new,paid,shipped,done,cancelled] | нет | Настраиваемый список статусов поверх канонических |
commerce-orders.reserve_on_status | string | paid | нет | Статус, при котором резервируются остатки |
commerce-orders.checkout_draft_ttl_minutes | int | 60 | нет | Через сколько минут неоплаченный черновик считается «застрявшим» для cancel-stale |
commerce-orders.max_items_per_order | int | 200 | нет | Лимит позиций в одном заказе — квота, достижение = понятная ошибка 422, не 500 |
commerce-orders.pii_retention_years | int | 5 | нет | Срок хранения ПДн заказа до псевдонимизации (бухгалтерский дефолт) |
commerce-orders.stock_reserve_kill_switch | bool | false | нет | Kill-switch: аварийно отключает резервирование остатков при сбое cms/commerce-stock, не останавливая приём заказов |
commerce-orders.new_orders_paused | bool | false | нет | Kill-switch: аварийный стоп приёма НОВЫХ заказов (сбой оплаты/склада) без выключения модуля — существующие заказы обрабатываются штатно |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| POST | /api/v1/orders/checkout | public/auth | Оформление заказа из корзины |
| GET | /api/v1/orders | auth | Список заказов пользователя в ЛК (keyset-пагинация) |
| GET | /api/v1/orders/{number} | auth (владелец) или гость по guest_token | Детали заказа со снапшотом позиций |
| GET | /api/v1/admin/orders | admin (commerce-orders.view) | Список заказов для админки (keyset-пагинация) |
| POST | /api/v1/admin/orders/{number}/status | admin (commerce-orders.manage) | Смена статуса заказа |
Checkout и смена статуса — денежные мутации с внешним эффектом, обязателен Idempotency-Key (повторный запрос с тем же ключом возвращает уже созданный заказ/уже применённый переход, не создаёт дубль и не откатывает статус).
Компоненты
Filament: список заказов с фильтром по статусу, массовые действия на списке (смена статуса пачкой, экспорт CSV), карточка заказа с историей и комментариями менеджера. Команды: cms:commerce-orders:cancel-stale --json. Демо-сидер: OrdersDemoSeeder — заказы во всех канонических статусах для галереи виджета «Мои заказы» и playground админки.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
OrderPlaced | заказ оформлен из корзины | order_id, number, user_id, total_minor |
OrderStatusChanged | изменён статус заказа | order_id, from_status, to_status |
OrderCompleted | заказ переведён в состояние done (выкуп) | order_id, user_id, total_minor |
OrderCancelled | заказ отменён | order_id, reason |
Слушает: UserMerged (ядро, ревизия 14.07.2026) — переносит заказы гостевого/дублирующего user_id на основной аккаунт; UserDeleted / хук «забыть по запросу» — псевдонимизация ПДн заказа (см. ПДн-паспорт); PaymentSucceeded от cms/commerce-payments — перевод в paid.
Provides-контракты: не предоставляет. FilterBus: не использует напрямую.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/commerce-cart | 4 (requires) | orders → cart | получение состава корзины при checkout |
cms/commerce-pricing | 4 (requires) | orders → pricing | пересчёт итоговой суммы на сервере (защита от подмены) |
cms/commerce-stock | 4 (requires) | orders → stock | резервирование/снятие резерва при достижении reserve_on_status |
cms/commerce-payments | 1 (событие) | payments → orders | PaymentSucceeded переводит заказ в paid |
cms/commerce-invoices | 1 (событие) | orders → invoices | OrderPlaced/OrderCompleted → счёт/фискализация |
cms/loyalty | 1 (событие) | orders → loyalty | OrderCompleted → начисление бонусов |
cms/commerce-rma | 1 (событие) | orders → rma | OrderCompleted открывает окно возврата |
| ядро (пользователи) | 1 (событие) | ядро → orders | UserMerged/UserDeleted — перенос/псевдонимизация |
cms/notifications-bus | 3 (provides notification-channel) | orders → notifications | письмо/смс о смене статуса |
Фоновая работа
Очередь commerce-orders: cancel-stale по расписанию через ScheduleRegistrar (отмена незавершённого checkout старше checkout_draft_ttl_minutes, освобождение съеденного номера не требуется — см. «Крайние случаи»). Резервирование остатков — синхронно в транзакции смены статуса (не в очереди, т.к. требует немедленного отказа при нехватке).
Эксплуатация (ранбук). Метрики: заказов/час по статусам, доля cancel-stale, количество заказов, «застрявших» в paid дольше X часов без перехода в shipped (алерт в cms/health). Инциденты:
| Симптом | Что проверить | Команда |
|---|---|---|
Заказы не переходят в paid после оплаты | очередь событий PaymentSucceeded, failed jobs | queue:failed, health-чек cms/commerce-payments |
Заказы «зависли» в paid часами | отставание очереди отгрузки/интеграции склада | cms:commerce-orders:cancel-stale --dry-run --json для диагностики |
| Резерв остатков не снимается при отмене | журнал commerce_stock_moves без обратной записи | cms:commerce-stock:reconcile (см. модуль остатков) |
| Расхождение суммы заказа и чека | ручная правка позиций в обход доп.соглашения | сверка commerce_order_status_history + commerce-invoices |
Бэкап/рестор: в бэкап попадают все таблицы модуля целиком (заказы — финансовые документы, исключений нет); после рестора ничего не пересчитывается автоматически — денормализованных агрегатов модуль не хранит.
Производительность и кеш
Объёмы — от тысяч до миллионов заказов за жизнь магазина; commerce_order_status_history растёт пропорционально числу переходов — партиционирование по диапазону changed_at при превышении порога (аналогично журналам cms/commerce-model).
Горячие пути: список заказов ЛК (per-user, keyset), список заказов админки (keyset + filter[status]), карточка заказа админки (заказ + позиции + история + комментарии одним ->with(), без N+1 — контрактный тест). Индексы: составной (user_id, created_at) и (status, created_at) на commerce_orders, (order_id) на commerce_order_items / commerce_order_status_history / commerce_order_manager_comments.
Тег кеша commerce-orders:order:{id}, инвалидация — событиями OrderStatusChanged, OrderCompleted, OrderCancelled. Заказы — персональные данные (адрес, телефон, состав покупки): список и карточка заказа не попадают в общий page-cache ни в каком виде, включая сегментированный — это персональные данные конкретного пользователя, а не сегмент.
Безопасность
Checkout и смена статуса — FormRequest-whitelist + Idempotency-Key; резервирование остатков — транзакционно (см. cms/commerce-stock), не допускает двойного списания при повторном событии.
IDOR: публичный идентификатор заказа — number (ULID), не инкрементный id; доступ к деталям — владелец (user_id) либо гость по guest_token (подписан, выдан при оформлении). Перебор number не даёт доступа к чужому заказу без токена/аутентификации.
Подмена суммы: клиент передаёт только cart_id; total_minor и цены позиций пересчитываются на сервере через cms/commerce-pricing в момент checkout — клиентский итог игнорируется как источник истины.
Матрица ролей:
| Действие | Покупатель (владелец) | Менеджер | Редактор | Studio |
|---|---|---|---|---|
| Просмотр своих заказов | да | — | — | — |
Просмотр списка/карточки в админке (.view) | — | да | нет | да |
Смена статуса заказа (.manage) | нет | да (по разрешённым дугам) | нет | да |
| Комментарии менеджера — просмотр | нет (не видит) | да | нет | да |
| Редактирование позиций/суммы оплаченного заказа напрямую | нет | нет | нет | нет (только доп.соглашение, см. «Крайние случаи») |
| Отмена оплаченного заказа | нет | да, с подтверждением | нет | да |
Права: commerce-orders.view, commerce-orders.manage.
UX-требования
Покупатель: checkout без регистрации не теряет введённые данные при ошибке любого шага — адрес доставки и контакты сохраняются в состоянии формы, если платёж отклонён на последнем шаге, покупатель не вводит адрес заново; понятные причины отказа оплаты (не «ошибка», а «банк отклонил операцию, попробуйте другую карту»).
Админ: пустое состояние списка заказов с фильтром — «нет заказов со статусом N» (не пустая таблица без пояснения); массовые действия на списке (смена статуса пачкой, экспорт); попытка недопустимого перехода статуса — человеческое сообщение («нельзя вернуть выполненный заказ в новый — используйте возврат»), не сырой код ошибки; отмена оплаченного заказа — модальное подтверждение с явным описанием последствий («резерв остатков будет снят, деньги не возвращаются автоматически — оформите возврат отдельно»); форма checkout и карточка заказа в админке доступны с клавиатуры, поля — с label (фронтенд-бюджет — компонент не тяжелее чужих блоков, тяжёлого JS/карт/видео не несёт).
Крайние случаи и типовые баги
- Двойной клик «оформить заказ» →
Idempotency-Keyдедуплицирует запрос, второй ответ возвращает тот же заказ, дубль не создаётся. - Гонка вебхука оплаты и завершения checkout →
PaymentSucceededидемпотентен поevent_id, статус меняется от текущего состояния заказа, а не от предполагаемой последовательности (см. гарантии обмена). - Недопустимый переход статуса (например
done → new) →409,code: status_transition_not_allowed, попытка логируется для аудита. - Отмена оплаченного заказа → резерв остатков снимается через журнал движений
cms/commerce-stock(обратная запись), не молчаливым занулениемreserved. - Редактирование оплаченного заказа админом напрямую → запрещено менять сумму/позиции оплаченного заказа; изменения — только через доп. операции (сторно позиции + новый платёж/возврат), иначе рассинхрон с бухгалтерией и платёжным шлюзом.
- Номер заказа с «дырками» → отменённый черновик checkout закономерно съедает значение последовательности
number; сплошная нумерация не гарантируется и не требуется. Юридические требования к сплошной нумерации (если применимы) реализуются отдельной фискальной нумерацией в чеках (cms/commerce-invoices/ОФД), не номером заказа. - Гость регистрируется после оформления заказа →
UserMerged(primary, secondary)переносит заказы гостевого/дублирующегоuser_idна основной аккаунт;guest_tokenпродолжает действовать как альтернативный путь доступа. - Сбой/выключение
cms/commerce-stockпри резервировании → checkout не деградирует тихо (риск оверселла на денежной мутации): при недоступности сервиса — понятная ошибка и ретрай, либо явный режим «остатки не проверяются», включаемыйstock_reserve_kill_switchосознанно, не по умолчанию. - Пустая корзина на checkout →
422с понятной ошибкой, пустой заказ не создаётся. - Заказ с числом позиций сверх
max_items_per_order→422при попытке оформления, лимит виден покупателю до отправки формы (валидация на клиенте — подсказка, не барьер). - Два менеджера одновременно меняют статус/комментарий одного заказа →
lock_version(optimistic lock): второй сохраняющий получает409 version_conflictс человеческим сообщением «заказ изменён другим менеджером, обновите страницу», не «последний победил».
⚠️ Противоречие: окно возврата RMA отсчитывается «с момента доставки», но статусная машина заказов не содержит отдельного статуса/события «доставлено» — есть только shipped (отправлен) и done (выкуп). Разрешение: точкой отсчёта считается переход в done (событие OrderCompleted) как наиболее близкий к факту получения товара; cms/commerce-rma слушает именно OrderCompleted, а не произвольный OrderStatusChanged.
Донорский код
| Что взять | Путь |
|---|---|
| Логика оформления заказа и статус-машины | masha (путь не выдан) |
| Практики истории статусов и комментариев менеджера | notal (путь не выдан) |
Легаси-импорт: cms:commerce-orders:import-legacy --source=<профиль> — маппинг старых заказов и позиций доноров (masha/notal) на текущую схему; идемпотентна по ключу external_id (повторный прогон обновляет, не дублирует); поддерживает --dry-run с отчётом расхождений (создано/обновлено/пропущено и почему, построчные ошибки скачиваемы).
Тесты и приёмка
- [ ] Контрактный тест: позиция заказа снапшотит цену/НДС/название, правки каталога не меняют историю
- [ ] Переход статуса фиксируется в
commerce_order_status_historyс указанием инициатора - [ ] Переход вне таблицы допустимых дуг возвращает
409сcode: status_transition_not_allowed - [ ] Резервирование остатков триггерится ровно на статусе
reserve_on_status, не дублируется при повторном событии - [ ] Отмена оплаченного заказа снимает резерв через журнал движений, не зануляет поле напрямую
- [ ]
OrderCompletedэмитится только после канонического состоянияdone, не послеpaid - [ ] Гостевой checkout работает при
guest_checkout=trueбез создания учётной записи; доступ к заказу — поguest_token - [ ] Повторный
POST /checkoutс тем жеIdempotency-Keyне создаёт второй заказ - [ ]
total_minorпересчитывается на сервере, подменённая клиентом сумма игнорируется - [ ] Доступ к
GET /orders/{number}по чужомуnumberбез токена/владения —403/404, не 200 - [ ]
UserMergedпереносит заказы гостя на основной аккаунт - [ ] Права
commerce-orders.view/.manageразграничивают чтение списка и смену статуса/комментарии; комментарии менеджера не видны покупателю - [ ] Нет N+1 при построении списка заказов ЛК и карточки заказа админки с позициями/историей
- [ ] Контрактный набор
cms-testingи testbench-изоляция зелёные, feature-тест на каждый роут - [ ] Тестовая БД только
commerce-orders_test;migrate:fresh/refresh/resetзапрещены