Skip to content

ТЗ — Доставка (cms/commerce-delivery)

Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке

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

Абстракция доставки поверх заказа: контракт DeliveryProvider, методы доставки (курьер/ПВЗ/самовывоз), зоны и правила расчёта стоимости. Конкретные транспортные компании — коннекторы отдельным модулем, см. /cms-v2/integrations.

  • Контракт DeliveryProvider (calculate/createShipment/track/pickupPoints)
  • Методы доставки: курьер, пункт выдачи (ПВЗ), самовывоз со склада
  • Зоны доставки и правила стоимости (по сумме заказа/весу/городу)
  • Расчёт стоимости и сроков на checkout по выбранному методу и адресу
  • Трек-номера отправлений с обновлением статуса от провайдера
  • Сплит составного заказа на несколько отправлений при товарах с разных складов
  • Интеграция стоимости доставки в rule-engine скидок (cms/commerce-promo) для действия «бесплатная доставка»

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

requires: ядро, cms/commerce-model (заказы, warehouse_id позиций) · suggests:cms/commerce-promo · provides: delivery-provider (контракт реализуют коннекторы cms/delivery-ru)

Поведение при выключении: checkout деградирует до единственного способа получения — самовывоз без расчёта стоимости, оформление заказа продолжает работать без вкладки доставки.

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

ТаблицаКлючевые поляПримечание
commerce_delivery_methodsid, code, title, type (courier|pickup_point|self_pickup), provider_code (nullable), lock_versionметоды доставки, type — PHP Enum
commerce_delivery_zonesid, city_id (nullable), region_pattern, method_id, priority, is_active, lock_versionзоны действия метода
commerce_delivery_rulesid, zone_id, condition (json — сумма/вес), price, free_from_amount (nullable)правила стоимости, condition — JSONB + GIN
commerce_shipmentsid, order_id, method_id, warehouse_id (nullable), tracking_number, status, provider_status_raw (nullable), address (json)отправление заказа

order_id/method_id/zone_id/warehouse_id — FK constrained()->index(); warehouse_id ссылается на первичный id commerce_warehouses (владелец — cms/commerce-stock, только чтение через сервис — см. обмен данными). price/free_from_amount — integer minor units + currency_code, float запрещён. commerce_delivery_methods/zones несут lock_version (optimistic lock, §4 стандарта) — конкурентная правка справочника двумя админами в Filament отдаёт 409, не тихую перезапись. priority на зоне разрешает пересекающиеся зоны одного метода (например «весь город» и «центр города» с более дешёвым тарифом) — при расчёте побеждает зона с наибольшей специфичностью/приоритетом, не первая найденная.

ПДн-паспорт (матрица v2.2): commerce_shipments.address (JSON) и tracking_number — персональные данные (152-ФЗ). Хранятся, пока жив заказ, плюс commerce-delivery.address_retention_days после доставки (плановая анонимизация джобой по расписанию: address схлопывается до {city, zone_id}, tracking_number не удаляется — сам по себе не идентифицирует человека вне связки с адресом). Хук ядра «забыть по запросу» обнуляет address немедленно по запросу субъекта; заказ и финансовые записи (домен commerce-model) при этом не трогаются — доставка не финансовый журнал и вправе анонимизироваться раньше. «Выгрузить всё по субъекту» — адрес и трек-номера всех отправлений владельца заказов.

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

Входы

ИсточникДанные/поляЧем валидируется
Форма checkout (адрес/город)city_id, region_pattern-совместимый адрес, method_idFormRequest whitelist, city_id — из RequestContext/справочника городов, не свободный текст
API POST /api/v1/checkout/delivery-calculatemethod_id, city_id, address, cart_amount, weight (nullable)FormRequest whitelist; cart_amount/weight — из сервиса корзины, не принимаются от клиента как истина без сверки
API GET /api/v1/checkout/delivery-methodscity_idwhitelist, city_id берётся из RequestContext, не парсится из query вручную
Вебхук трек-статуса от провайдераtracking_number, status_code, event_idграница cms/webhooks-in (подпись, идемпотентность по event_id), затем FormRequest на структуру payload
Импорт зон/тарифов (Filament/CLI)zone, method, condition, price построчноcms:commerce-delivery:import-legacy — маппинг + валидация схемы, см. «Донорский код»
Событие OrderPaid/оформление заказа (cms/commerce-model)order_id, items[] (с warehouse_id), method_id, addressвнутренний, из EventBus/сервис-вызова, не пользовательский вход

Выходы

ПотребительДанныеФормат
Checkout-формаответ POST /delivery-calculate{data: {price, eta_days, method_id}, meta: {degraded}}
Блок «Выбор метода доставки»список доступных методов для адреса/городарендер через сервис модуля, не запрос из шаблона
Событие ShipmentCreated/ShipmentStatusChanged/ShipmentDeliveredфакт по отправлениюподписчики (уведомления, кабинет клиента, аудит)
cms/commerce-promo (FilterBus)стоимость доставки для пересчёта корзинызначение в общем пайплайне пересчёта, не отдельный API
Filament (админ)список отправлений, статус трека, справочникитаблицы/формы UI
Импорт-отчёт import-legacyсколько зон/правил прочитано/создано/обновлено/пропущеноскачиваемый отчёт, построчные ошибки

Whitelist-принцип: параметр расчёта или импорта вне описанных «Входов» (незарегистрированный method_id, произвольное поле в condition импорта) — отвергается 422 с кодом unknown_field, не молчаливый игнор.

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

КлючТипДефолтaffectsPageCacheОписание
commerce-delivery.default_methodstringself_pickupнетМетод доставки по умолчанию
commerce-delivery.free_delivery_thresholdintnullнетПорог суммы заказа для бесплатной доставки (в minor units)
commerce-delivery.tracking_update_interval_hoursint6нетПериодичность опроса статуса у провайдера
commerce-delivery.calculation_provider_enabledbooltrueнетKill-switch: аварийное отключение внешнего провайдера расчёта (форс-fallback без выключения модуля)
commerce-delivery.fallback_behaviorstring enum (fallback_tariff|manual_quote)manual_quoteнетПоведение при недоступном провайдере или включённом kill-switch
commerce-delivery.fallback_tariff_amountintnullнетСумма фиксированного тарифа при fallback_behavior=fallback_tariff (minor units)
commerce-delivery.calc_provider_timeout_msint2000нетТаймаут синхронного вызова провайдера расчёта на checkout (≤2 с по §9/§10 стандарта)
commerce-delivery.price_recalc_toleranceint0нетДопустимое расхождение (minor units) между расчётом на витрине и на checkout без запроса подтверждения
commerce-delivery.max_zones_per_methodint200нетЛимит количества зон на метод доставки
commerce-delivery.max_rules_per_zoneint20нетЛимит правил стоимости на зону
commerce-delivery.address_retention_daysint365нетСрок хранения адреса доставки после ShipmentDelivered до плановой анонимизации
commerce-delivery.calc_failure_alert_thresholdint (%)20нетПорог доли неуспешных расчётов, после которого алертит cms/health

Достижение max_zones_per_method/max_rules_per_zone — понятная ошибка в Filament («Достигнут лимит зон для метода, обратитесь к студии для увеличения»), не 500 и не тихое обрезание списка.

API

МетодПутьДоступНазначение
GET/api/v1/checkout/delivery-methodspublicДоступные методы доставки для адреса/города
POST/api/v1/checkout/delivery-calculatepublicРасчёт стоимости и сроков доставки
GET/api/v1/orders/{id}/shipmentauth (владелец заказа)Статус и трек-номер отправления(-ий)
POST/api/v1/admin/delivery-zonesadmin (commerce-delivery.manage)Создание/правка зоны и правил стоимости
GET/api/v1/admin/shipmentsadmin (commerce-delivery.view)Список отправлений с фильтром по статусу (keyset-пагинация)
POST/api/v1/admin/shipments/sync-trackingadmin (commerce-delivery.manage)Массовая синхронизация треков (постановка в очередь)

Ответ delivery-calculate — всегда 200; при недоступном провайдере или активном kill-switch — meta.degraded: true + meta.degraded_reason (ревизия ядра 14.07.2026), не 5xx. Адрес вне зоны действия всех методов — тоже 200 с data: [] (пустой список доступных методов, как пустая выдача поиска), не 404: это легитимный результат расчёта, а не ошибка запроса. Конфликт версии при правке зоны (два админа) — 409 с кодом version_conflict. Повторное оформление заказа с устаревшей ценой доставки — 409 с кодом price_changed и актуальной ценой в теле ответа (см. «Крайние случаи»). POST /admin/shipments/sync-tracking принимает Idempotency-Key — повторный запрос с тем же ключом возвращает статус уже идущей синхронизации, а не запускает вторую.

Компоненты

Блоки (BlockRegistry): выбор метода доставки на checkout, карта ПВЗ — карта подгружается лениво (не в критическом пути первой отрисовки), контейнер резервирует высоту (без CLS), список методов доступен с клавиатуры. Filament: справочник методов и зон доставки (с валидацией пересечения зон при сохранении — предупреждение, не жёсткий запрет, приоритет решает конфликт на расчёте), правила стоимости, список отправлений с треком и массовым действием «синхронизировать треки выбранных». Демо-сидер (матрица v2.2): демо-зоны («по городу», «самовывоз») и демо-методы для галереи блоков и playground без ручного ввода.

Команды: cms:commerce-delivery:sync-tracking --json, cms:commerce-delivery:repair-shipments --json (см. «Фоновая работа»), cms:commerce-delivery:import-legacy --source=<профиль> --dry-run --json (см. «Донорский код»).

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

СобытиеКогдаPayload
ShipmentCreatedотправление создано у провайдераshipment_id, order_id, method_id, warehouse_id
ShipmentStatusChangedобновился статус трекаshipment_id, status, tracking_number
ShipmentDeliveredотправление доставленоshipment_id, order_id

Слушает: событие оформления заказа/OrderPaid из cms/commerce-model (создание отправлений, со сплитом по warehouse_id позиций). FilterBus: участвует в пересчёте корзины/заказа действием «бесплатная доставка» из cms/commerce-promo (порядок: скидки → бонусы → стоимость доставки → итог).

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

Сущность/модульКаналНаправлениеЧто происходит
cms/commerce-model (заказы)4 (requires, сервис-вызов)outМодуль запрашивает состав позиций заказа (включая warehouse_id) для формирования отправлений и подтверждения адреса/суммы
delivery-provider (коннекторы cms/delivery-ru)3 (provides-контракт)inРезолв конкретной реализации DeliveryProvider через DI: расчёт, создание отправления, трек, список ПВЗ
cms/commerce-promo2 (FilterBus)inПравило «бесплатная доставка» переопределяет/обнуляет стоимость, рассчитанную этим модулем, в общем пересчёте корзины
RequestContext (ядро)4 (сервис-вызов, core-contracts)outГород/сайт/локаль резолвятся ядром до контроллера; модуль берёт их из контекста, не парсит URL/заголовки сам
Внешний провайдер транспортной компаниивнешний → 5 (job после приёма)inВебхук трек-статуса принимает cms/webhooks-in (подпись, идемпотентность по event_id), дальше — job в очередь commerce-delivery
ShipmentCreated/StatusChanged/Delivered1 (событие)outФакты для подписчиков (уведомления, кабинет клиента, аудит) — модуль не знает, кто слушает

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

Именованная очередь commerce-delivery: создание отправления у провайдера, опрос статуса трека с периодичностью tracking_update_interval_hours, применение вебхук-события трек-статуса. Все внешние вызовы к транспортной компании — только из очереди (кроме синхронного расчёта на checkout с таймаутом calc_provider_timeout_ms и graceful fallback), идемпотентно (повторный опрос/повторный вебхук с тем же статусом не создаёт дублирующее событие), ретраи с backoff при недоступности провайдера.

Эксплуатация (матрица v2.2, §15 стандарта). Метрики в Pulse: доля неуспешных расчётов (calc_failure_rate, алерт по calc_failure_alert_threshold), отставание опроса треков (tracking_poll_lag_hours — факт минус tracking_update_interval_hours), доля отправлений с незамапленным статусом провайдера (unmapped_status_rate).

Типовые инциденты:

СимптомЧто проверитьЧем чинится
Массовые ошибки/таймауты расчёта на checkoutcalc_failure_rate в Pulse, health-чек DeliveryProviderCheckВключить kill-switch calculation_provider_enabled=false вручную, разобраться с провайдером
Очередь commerce-delivery отстаёт по опросу трековГлубина очереди/Horizon, tracking_poll_lag_hourscms:commerce-delivery:sync-tracking --json форсированным прогоном
Часть отправлений не получает статус по вебхукуЛоги cms/webhooks-in, unmapped_status_ratecms:commerce-delivery:repair-shipments --json — переопрос провайдера для «зависших» отправлений
Расхождение зон/тарифов после правки в Filament не видно на витринеТеги кеша commerce-delivery:zones/methods, cms:cache:inspect ядраИнвалидация происходит автоматически по afterSave(); ручной прогрев — повторный запрос после инвалидации
Дублирующиеся отправления по одному заказу после сбоя воркераcommerce_shipments на дубли по order_id+method_id+warehouse_idcms:commerce-delivery:repair-shipments --json помечает дубли отменёнными, оставляет первое созданное

cms:commerce-delivery:repair-shipments — идемпотентна, поддерживает --dry-run, допустима к запуску на живом сайте без даунтайма.

Бэкап/рестор: в бэкап попадают все таблицы модуля целиком (методы, зоны, правила, отправления — исходные данные, не производные проекции). После рестора пересоздания не требуется, кроме прогрева тегов кеша commerce-delivery:zones и commerce-delivery:methods (восстанавливается штатно первым запросом); трек-статусы могут быть устаревшими на момент снятия бэкапа — ближайший плановый цикл sync-tracking подтягивает актуальные без ручных действий.

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

Ожидаемые объёмы: тысячи расчётов стоимости в день на checkout даже на среднем магазине (каждое открытие корзины/смена адреса — новый расчёт). Расчёт на checkout — горячий путь пользовательского сценария оформления заказа, обязан быть быстрым: бюджет ≤4 запросов к БД (метод + зона + правила + при необходимости остаток склада) плюс не более 1 синхронного внешнего HTTP-вызова к провайдеру, ограниченного calc_provider_timeout_ms (≤2 с) с graceful fallback по fallback_behavior (§9/§10 стандарта — не более одного синхронного внешнего вызова на горячем пути).

Индексы: составной GIN на commerce_delivery_rules(zone_id, condition) под резолв правила по условию; index() на commerce_shipments.order_id под выборку по заказу; (city_id, method_id, priority) на commerce_delivery_zones под резолв зоны по городу с учётом приоритета при пересечении.

Теги commerce-delivery:zones, commerce-delivery:methods. Инвалидация — правкой справочника зон/методов в Filament (afterSave()), событием SettingChanged для порога бесплатной доставки и лимитов. Расчёт стоимости на checkout — по актуальным правилам без кеша адреса покупателя (персональные данные не кешируются); от дублирующих параллельных вызовов при двойном сабмите защищает не кеш результата, а короткий лок дедупликации (TTL несколько секунд, ключ — хеш от method_id+city_id+ cart_amount, без хранения адреса) — см. «Крайние случаи».

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

Адрес доставки и трек-номер — персональные данные, доступны только владельцу заказа (auth) и администратору с правом commerce-delivery.view. Расчёт стоимости и создание зон — через FormRequest-whitelist, без прямого приёма произвольного JSON- условия без валидации схемы. Секреты интеграции с провайдером (API-ключ ЛК) — только .env. Разграничение прав: commerce-delivery.view, commerce-delivery.manage.

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

РольРасчёт/методы (public)Свой трек (auth)Список отправленийУправление зонами/тарифамиМассовая синхр. трековУдаление зоныKill-switch провайдера
посетитель/гость
покупатель (владелец заказа)✅ (только свой)
менеджер✅ (любой заказ)✅ (view)✅ (view)
админ✅ (manage)
studio✅ (настройка calculation_provider_enabled)

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

Покупатель (checkout):

  • стоимость и срок доставки показываются рядом с выбором метода сразу после ввода адреса/города, без перехода на отдельный шаг;
  • если расчёт не удался (провайдер недоступен и fallback_behavior=manual_quote) — сообщение «Точную стоимость доставки уточнит менеджер после оформления» вместо технической ошибки; заказ при этом всё равно оформляется (метод помечается «цена уточняется»);
  • при ошибке валидации формы (например неполный адрес) введённые данные, включая уже выбранный метод доставки, сохраняются — повторный ввод адреса с нуля не требуется;
  • изменение стоимости между расчётом на витрине и оформлением заказа показывается явным диалогом с новой ценой и кнопкой подтверждения, а не тихой заменой суммы.

Админ:

  • пустое состояние списка отправлений («Отправлений пока нет — появятся после первого оплаченного заказа с доставкой»), пустое состояние справочника зон («Зон пока нет, добавьте первую или импортируйте из легаси-сайта»);
  • массовая синхронизация треков выбранных отправлений одним действием из списка;
  • человеческая ошибка при конфликте зон: сохранение пересекающейся зоны не блокируется жёстко, но предупреждает «Зона пересекается с «Центр города» — приоритет определит, какая сработает» вместо тихого молчаливого сохранения.

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

  • Стоимость доставки изменилась между расчётом на витрине и оформлением заказа → финальная стоимость пересчитывается на сервере в момент оформления; расхождение больше price_recalc_tolerance → заказ не оформляется по устаревшей цене без подтверждения, ответ 409 price_changed с актуальной ценой, фронт показывает диалог подтверждения (см. UX выше).
  • Провайдер расчёта стоимости недоступен → поведение определяется настройкой commerce-delivery.fallback_behavior, а не хардкодом: fallback_tariff — покупатель видит фиксированную стоимость fallback_tariff_amount; manual_quote — сообщение «менеджер уточнит стоимость», заказ оформляется без вкладки точной цены. Kill-switch calculation_provider_enabled=false форсирует тот же fallback без выключения модуля.
  • Составной заказ с товарами с разных складов → позиции заказа несут warehouse_id (cms/commerce-model), модуль группирует их по складу и создаёт отдельное отправление (commerce_shipments.warehouse_id) на каждую группу; ShipmentCreated издаётся по каждому отправлению отдельно, не одно на заказ.
  • Зоны доставки по городу сверяются с RequestContextcity_id берётся из контекста, резолвленного ядром (middleware), не парсится модулем из query/URL самостоятельно; смена города в контексте меняет доступные зоны на следующем запросе.
  • Адрес вне зоны действия ни одного метода → не ошибка ввода: GET /delivery-methods и POST /delivery-calculate отвечают 200 с пустым data, checkout показывает «Доставка по этому адресу недоступна, свяжитесь с нами» — не 404/422 (симметрично пустой выдаче поиска).
  • Трек-статус от провайдера пришёл с неизвестным кодом (маппинг не полный)provider_status_raw сохраняется всегда, внутренний status выставляется в безопасное значение «уточняется» (не падает, не молчит); unmapped_status_rate растёт и алертит через cms/health, что сигнализирует о необходимости расширить маппинг статусов провайдера.
  • Двойной сабмит формы расчёта → короткий лок дедупликации (TTL несколько секунд, ключ — хеш параметров расчёта без адреса) не даёт улететь второму синхронному вызову к провайдеру, пока первый в работе; клиент получает один и тот же результат.
  • Удаление зоны, на которую ссылаются активные заказы в пути → soft-поведение: is_active=false вместо физического удаления; существующие commerce_shipments продолжают жить и получать обновления трека, новые расчёты эту зону не видят. Жёсткое удаление доступно только когда у зоны нет отправлений в незавершённых статусах (проверка перед DELETE, иначе 409).
  • Пересекающиеся зоны одного метода в одном городе → расчёт использует зону с наивысшим priority (при равенстве — более специфичный region_pattern), не первую найденную в БД; Filament предупреждает о пересечении при сохранении, но не запрещает — оверлеп с приоритетами может быть намеренным бизнес-решением.
  • Конкурентная правка одной зоны/метода двумя админамиlock_version (optimistic lock, §4 стандарта): второй PUT без актуальной версии получает 409 version_conflict, не молчаливую перезапись.
  • ⚠️ Противоречие: кеш горячего пути расчёта vs запрет кеша ПДн-адреса. Highload-требования каталога (commerce-model.md) толкают к кешированию горячих путей; правило безопасности §11 стандарта запрещает кешировать персональные данные, а адрес покупателя — ПДн. Разрешение: кешируются только неперсональные компоненты расчёта — справочники зон/методов/правил (теги commerce-delivery:zones/methods), сам ответ по конкретному адресу не кешируется и не переиспользуется между разными запросами; производительность горячего пути обеспечивается не кешем ответа, а малым числом индексированных запросов (бюджет ≤4) и локом дедупликации от двойного сабмита, а не хранением адреса.

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

Донор: — (новая разработка). Для переезда клиентов со старых сайтов (легаси-платформы без модульной CMS) — команда cms:commerce-delivery:import-legacy --source=<профиль> (матрица v2.2, §16 стандарта): маппинг старых таблиц зон/тарифов на новую схему (commerce_delivery_zones/rules), идемпотентна (повторный прогон обновляет по ключу external_id, не дублирует), поддерживает --dry-run с отчётом расхождений и скачиваемыми построчными ошибками. Прогон на копии донорских данных — часть приёмки для каждого клиентского переезда, где у клиента уже были настроены зоны/тарифы.

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

  • [ ] Контрактный тест: DeliveryProvider::calculate() возвращает стоимость и срок без создания отправления
  • [ ] Правило «бесплатная доставка от суммы» корректно применяется на checkout и в rule-engine скидок
  • [ ] Смена статуса трека идемпотентна (повторный опрос/повторный вебхук с тем же статусом не дублирует событие)
  • [ ] При выключении модуля checkout работает с единственным методом «самовывоз» без ошибок
  • [ ] Зона без подходящего правила стоимости не позволяет оформить заказ с этим методом (валидация)
  • [ ] Права commerce-delivery.manage/.view разграничивают справочники/зоны, отправления и массовую синхронизацию треков (матрица ролей)
  • [ ] Финальный пересчёт цены на оформлении заказа: расхождение сверх price_recalc_tolerance даёт 409 price_changed, не тихое списание по старой цене
  • [ ] Kill-switch calculation_provider_enabled=false и оба режима fallback_behavior покрыты тестом (fallback-тариф / «менеджер уточнит»)
  • [ ] Составной заказ с позициями на разных складах создаёт отдельные отправления по warehouse_id
  • [ ] Резолв зоны по RequestContext (не по самостоятельному парсингу города модулем)
  • [ ] Адрес вне всех зон возвращает 200 с пустым списком методов, не 404/500
  • [ ] Незамапленный статус провайдера не роняет обработку вебхука/опроса, provider_status_raw сохранён
  • [ ] Двойной сабмит формы расчёта не создаёт два параллельных вызова провайдера (лок дедупликации)
  • [ ] Удаление зоны с активными отправлениями в пути — soft (is_active=false), жёсткое удаление блокируется при незавершённых отправлениях
  • [ ] Конкурентная правка зоны/метода двумя админами даёт 409 version_conflict, не «последний победил»
  • [ ] cms:commerce-delivery:repair-shipments --dry-run находит дубли отправлений без их изменения
  • [ ] cms:commerce-delivery:import-legacy --dry-run строит отчёт расхождений на копии донорских данных, повторный прогон идемпотентен
  • [ ] «Забыть по запросу» обнуляет address отправлений субъекта, финансовые данные заказа не затрагивает
  • [ ] Контрактный набор cms-testing зелёный, пакет тестируется в testbench-изоляции
  • [ ] Feature-тест на каждый роут API, провайдер доставки в тестах замокан
  • [ ] Тестовая БД — только commerce-delivery_test; migrate:fresh/refresh/reset запрещены

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