Skip to content

ТЗ — Заказы (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_ordersid, 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_idconstrained()->index(), number — уникальный индекс, публичный идентификатор вместо id, lock_version — optimistic lock (§4 стандарта)
commerce_order_itemsid, order_id, variant_id, title_snapshot, price_minor_snapshot, vat_rate_snapshot, qtyснапшот позиции на момент заказа; FK order_id/variant_idconstrained()->index()
commerce_order_status_historyid, order_id, from_status, to_status, changed_by, changed_atистория переходов статуса, append-only; BRIN по changed_at, партиционирование по диапазону дат при росте объёма
commerce_order_manager_commentsid, order_id, manager_id, comment, created_atвнутренние комментарии менеджера, не видны клиенту

number — ULID, генерируется при создании заказа, используется во всех публичных URL/API вместо инкрементного id (§4 стандарта: автоинкремент в публичном URL запрещён). guest_token — подписанный токен, выдаётся гостю при оформлении и вшивается в ссылку подтверждения/письмо; заменяет аутентификацию для доступа гостя к своему заказу.

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

Переходы разрешены только по явно заданным дугам (конфиг commerce-orders.default_statuses описывает список, дуги — контрактная константа модуля, не выводятся произвольно):

Из статусаВ статусУсловие
newpaidподтверждение оплаты (событие PaymentSucceeded от cms/commerce-payments)
newcancelledпокупатель/менеджер отменяет неоплаченный заказ
paidshippedзаказ передан в доставку
paidcancelledотмена оплаченного заказа — только с возвратом резерва/денег (см. «Крайние случаи»)
shippeddoneподтверждён факт получения (выкуп)
shippedcancelledнештатная отмена в пути — редкий кейс, требует комментария
doneтерминальный статус заказа; возврат товара — отдельным процессом cms/commerce-rma, не откатом статуса заказа
cancelledтерминальный статус

Попытка перехода вне таблицы — 409 с code: status_transition_not_allowed и человеческим сообщением в админке. Любая настраиваемая надстройка статусов (default_statuses) обязана мапиться на одно из канонических состояний — не вводит свои дуги в обход этой таблицы.

ПДн-паспорт

ПолеТаблицаСрок храненияУчастие в 152-ФЗ
contact_phone, contact_emailcommerce_ordersдо истечения срока хранения финансовых документов (commerce-orders.pii_retention_years, дефолт по бухгалтерским требованиям)«выгрузить всё по субъекту» — да; «забыть по запросу» — псевдонимизация полей после истечения срока, не удаление строки
delivery_address_snapshotcommerce_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/checkoutcart_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}/statusto_status, comment?AdminStatusChangeRequest: to_status — whitelist допустимых дуг статусной машины, право .manage
Событие ядраслушает UserMergedprimary_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 / OrderCancelledpayload событиясм. «События и обмен»
Виджет «Мои заказы» (ЛК)рендер через OrdersServiceсписок последних заказов, без прямых SQL из шаблона
Экспорт списка заказов (админка)CSV по текущему фильтруте же поля, что список админки; выгрузка объёмом свыше лимита настройки — job в очереди

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

КлючТипДефолтaffectsPageCacheОписание
commerce-orders.guest_checkoutbooltrueнетРазрешён ли checkout без регистрации
commerce-orders.default_statusesarray[new,paid,shipped,done,cancelled]нетНастраиваемый список статусов поверх канонических
commerce-orders.reserve_on_statusstringpaidнетСтатус, при котором резервируются остатки
commerce-orders.checkout_draft_ttl_minutesint60нетЧерез сколько минут неоплаченный черновик считается «застрявшим» для cancel-stale
commerce-orders.max_items_per_orderint200нетЛимит позиций в одном заказе — квота, достижение = понятная ошибка 422, не 500
commerce-orders.pii_retention_yearsint5нетСрок хранения ПДн заказа до псевдонимизации (бухгалтерский дефолт)
commerce-orders.stock_reserve_kill_switchboolfalseнетKill-switch: аварийно отключает резервирование остатков при сбое cms/commerce-stock, не останавливая приём заказов
commerce-orders.new_orders_pausedboolfalseнетKill-switch: аварийный стоп приёма НОВЫХ заказов (сбой оплаты/склада) без выключения модуля — существующие заказы обрабатываются штатно

API

МетодПутьДоступНазначение
POST/api/v1/orders/checkoutpublic/authОформление заказа из корзины
GET/api/v1/ordersauthСписок заказов пользователя в ЛК (keyset-пагинация)
GET/api/v1/orders/{number}auth (владелец) или гость по guest_tokenДетали заказа со снапшотом позиций
GET/api/v1/admin/ordersadmin (commerce-orders.view)Список заказов для админки (keyset-пагинация)
POST/api/v1/admin/orders/{number}/statusadmin (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-cart4 (requires)orders → cartполучение состава корзины при checkout
cms/commerce-pricing4 (requires)orders → pricingпересчёт итоговой суммы на сервере (защита от подмены)
cms/commerce-stock4 (requires)orders → stockрезервирование/снятие резерва при достижении reserve_on_status
cms/commerce-payments1 (событие)payments → ordersPaymentSucceeded переводит заказ в paid
cms/commerce-invoices1 (событие)orders → invoicesOrderPlaced/OrderCompleted → счёт/фискализация
cms/loyalty1 (событие)orders → loyaltyOrderCompleted → начисление бонусов
cms/commerce-rma1 (событие)orders → rmaOrderCompleted открывает окно возврата
ядро (пользователи)1 (событие)ядро → ordersUserMerged/UserDeleted — перенос/псевдонимизация
cms/notifications-bus3 (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 jobsqueue: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 дедуплицирует запрос, второй ответ возвращает тот же заказ, дубль не создаётся.
  • Гонка вебхука оплаты и завершения checkoutPaymentSucceeded идемпотентен по 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 осознанно, не по умолчанию.
  • Пустая корзина на checkout422 с понятной ошибкой, пустой заказ не создаётся.
  • Заказ с числом позиций сверх max_items_per_order422 при попытке оформления, лимит виден покупателю до отправки формы (валидация на клиенте — подсказка, не барьер).
  • Два менеджера одновременно меняют статус/комментарий одного заказа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 запрещены

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