Тема
ТЗ — Доставка РФ (СДЭК/Почта/Boxberry) (cms/delivery-ru)
Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Коннекторы российских служб доставки (СДЭК, Почта России, Boxberry) поверх cms/integrations-bus: реализуют контракт DeliveryProvider из cms/commerce-delivery, дают checkout расчёт стоимости/сроков и карту ПВЗ без привязки к конкретной службе.
- Коннекторы СДЭК, Почта России, Boxberry:
provides: delivery-provider - Расчёт стоимости и сроков доставки по адресу/габаритам заказа
- Виджет карты ПВЗ на checkout с выбором пункта выдачи
- Создание отправления и получение номера для отслеживания
- Трек-статусы — опрос по расписанию из очереди, не по запросу пользователя
- Печать этикеток отправления из Filament
Зависимости и выключение
requires: cms/integrations-bus, cms/commerce-delivery · provides: delivery-provider
Поведение при выключении: соответствующие службы пропадают из списка активных способов доставки на checkout, ранее созданные отправления продолжают отслеживаться до финального статуса — новые заказы используют оставшиеся активные способы доставки.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_delivery_ru_shipments | id, order_id, provider_code, tracking_number, status, label_url | отправление, привязанное к заказу |
cms_delivery_ru_pickup_points | id, provider_code, external_id, address, coordinates (json) | кеш ПВЗ для виджета карты |
Индексы: FK order_id — constrained() + index(); status — PHP Enum; coordinates — json() + cast array; индекс на provider_code+external_id (поиск ПВЗ при синхронизации); индекс status на cms_delivery_ru_shipments (выборка «нужен опрос» пачками — см. «Производительность и кеш»).
cms_delivery_ru_shipments не хранит ФИО/адрес/телефон получателя отдельными столбцами — при создании отправления они читаются из заказа (через cms/commerce-delivery) и передаются провайдеру транзитом; локально сохраняются только служебные идентификаторы (tracking_number, label_url) — см. ПДн-паспорт в «Безопасность».
Входные и выходные данные
Принцип: всё, что не перечислено во входах ниже, модуль обязан отвергать — неизвестные параметры фильтров/сортировок, вебхуки без валидной подписи, provider_code, отсутствующий в enabled_providers.
| Вход | Источник | Канал | Поля | Проверка |
|---|---|---|---|---|
| Расчёт стоимости/сроков | checkout (auth/guest) | API POST /checkout/delivery/estimate | адрес/индекс, вес/габариты заказа, provider_code? | FormRequest whitelist; адрес нормализуется через dadata (suggest-provider), если включён (см. «Крайние случаи») |
| Список ПВЗ для карты | checkout (auth/guest) | API GET /checkout/delivery/pickup-points | адрес/координаты/радиус, provider_code? | whitelist параметров, читается из кеша справочника |
| Создание отправления | cms/commerce-delivery (после подтверждения заказа) | канал 4 (requires), ShipmentService::create() | order_id, provider_code, вес/габариты, адрес/ПВЗ | сверка с данными заказа |
| Печать этикетки | admin | API POST /shipments/{id}/label | id | delivery-ru.manage, проверка активного отправления, Idempotency-Key |
| Вебхук трек-статуса | внешняя служба (СДЭК/Почта/Boxberry) | cms/webhooks-in → cms/integrations-bus | tracking_number, provider_code, status, timestamp | подпись (HMAC, секрет per-провайдер из .env) обязательна; протухший timestamp отклоняется; неизвестный tracking_number логируется и отклоняется |
| Ответ синхронизации ПВЗ | внешний API провайдера (по расписанию) | канал 5 (очередь) + integrations-bus | список точек (координаты, адрес, external_id) | whitelist полей, координаты валидируются перед записью |
| Выход | Получатель | Формат |
|---|---|---|
| Ответ расчёта доставки | checkout | {data, meta}, meta.degraded: true при фолбэке (см. «Крайние случаи») |
| Ответ списка ПВЗ | checkout | {data, meta}, keyset |
DeliveryShipmentCreated / DeliveryStatusChanged / DeliveryPickupPointsSynced | подписчики (канал 1) | события ядра |
| Этикетка отправления | admin (печать/скачивание) | label_url (файл провайдера, PDF/PNG) |
Настройки (группа delivery-ru)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
delivery-ru.enabled_providers | array | [] | нет | Активные коды служб доставки |
delivery-ru.pickup_points_sync_hours | int | 24 | нет | Периодичность синхронизации справочника ПВЗ |
delivery-ru.tracking_poll_minutes | int | 60 | нет | Периодичность опроса трек-статусов |
delivery-ru.tracking_poll_batch_size | int | 200 | нет | Размер пачки опроса за один прогон — лимит против rate-limit провайдера |
delivery-ru.disabled_providers | array | [] | нет | Kill-switch: коды служб, временно исключённых из checkout без выключения модуля |
delivery-ru.estimate_timeout_ms | int | 2000 | нет | Таймаут синхронного расчёта стоимости на checkout (бюджет ≤2 с, стандарт §9) |
delivery-ru.estimate_fallback_policy | enum (defer|fixed_rate|hide) | defer | нет | Политика при недоступности расчёта — см. «Крайние случаи» |
delivery-ru.fixed_rate_amount | int (minor units) | null | нет | Сумма фолбэка при политике fixed_rate |
delivery-ru.pickup_points_max_age_hours | int | 72 | нет | Порог возраста кеша ПВЗ для предупреждения в админке |
delivery-ru.price_per_request | decimal | null | нет | Справочная стоимость API-вызова у провайдера (видимость расхода) |
Секреты доступа к API служб доставки — только .env.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| POST | /api/v1/checkout/delivery/estimate | auth/guest | Расчёт стоимости и сроков по адресу |
| GET | /api/v1/checkout/delivery/pickup-points | auth/guest | Список ПВЗ для виджета карты |
| GET | /api/v1/admin/delivery-ru/shipments | admin (delivery-ru.view) | Список отправлений с трек-статусами |
| POST | /api/v1/admin/delivery-ru/shipments/{id}/label | admin (delivery-ru.manage) | Печать этикетки отправления |
Список ПВЗ и отправлений — keyset-пагинация, не OFFSET. {id} в пути печати этикетки — публичный идентификатор отправления (ULID/UUID первичного ключа), не автоинкремент — см. «Крайние случаи» (⚠️ Противоречие со стандартом §4, если реализация выберет обычный id). Создание отправления и печать этикетки — мутации с внешним эффектом: заголовок Idempotency-Key обязателен (стандарт §7).
Компоненты
Блоки: виджет карты ПВЗ на checkout. Filament: ресурс отправлений с трек-статусами, печать этикеток. Команды: cms:delivery-ru:sync-pickup-points --json, cms:delivery-ru:poll-tracking --json.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
DeliveryShipmentCreated | отправление создано у службы доставки | order_id, provider_code, tracking_number |
DeliveryStatusChanged | изменился трек-статус отправления | shipment_id, old_status, new_status |
DeliveryPickupPointsSynced | справочник ПВЗ обновлён | provider_code, points_count |
Реализует provides: delivery-provider — потребитель cms/commerce-delivery. Обмен со службами доставки — через cms/integrations-bus.
Взаимодействия
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/commerce-delivery | 3 (provides: delivery-provider) | commerce-delivery → delivery-ru | резолв реализации DeliveryProvider на checkout |
cms/commerce-delivery | 4 (requires) | delivery-ru → commerce-delivery | чтение данных заказа/веса при создании отправления |
| checkout (публичный API) | внешний канал (REST) | checkout → delivery-ru | синхронный расчёт стоимости/сроков, список ПВЗ |
| Служба доставки (внешняя) | 5 (очередь) + cms/integrations-bus | delivery-ru → внешний API | создание отправления, синхронизация ПВЗ, опрос трек-статусов |
| Служба доставки → delivery-ru (вебхук) | внешний канал (webhooks-in → integrations-bus) | внешний → delivery-ru | смена трек-статуса, подпись обязательна |
cms/notifications-bus (если включён) | 3 (контракт) | delivery-ru → notifications | DeliveryStatusChanged → письмо/пуш клиенту |
dadata (suggest-provider, если включён) | 3 (сервисный контракт) | delivery-ru → dadata | нормализация адреса перед расчётом (см. «Крайние случаи») |
cms/health | health-чек + событие 1 | delivery-ru → health | недоступность провайдера, устаревание справочника ПВЗ |
Любая связь вне этой таблицы — скрытая зависимость, анти-паттерн (стандарт §5).
Фоновая работа
Синхронизация справочника ПВЗ — по расписанию (pickup_points_sync_hours), именованная очередь delivery-ru. Опрос трек-статусов — по расписанию (tracking_poll_minutes), пачками по tracking_poll_batch_size, не по запросу пользователя. Расчёт стоимости на checkout — синхронный вызов с таймаутом estimate_timeout_ms и graceful fallback (не блокирует оформление). Ретраи с backoff, circuit breaker на провайдера. Восстановительные команды (sync-pickup-points, poll-tracking) — идемпотентны, --json, допустимы к ручному прогону на живом сайте (стандарт §15).
Производительность и кеш
Ожидаемые объёмы (оценочно): интернет-магазин среднего размера — сотни–первые тысячи отправлений/сутки; опрос трек-статусов — на порядок больше вызовов (несколько статусов на отправление за жизненный цикл). Уточняется под конкретный проект на этапе разработки.
Горячие пути: расчёт стоимости на checkout — единственный синхронный внешний вызов модуля, бюджет estimate_timeout_ms (дефолт 2000 мс, соответствует общему лимиту стандарта «≤2 с + graceful fallback») и явная политика фолбэка (estimate_fallback_policy); список ПВЗ — из кеша delivery-ru:pickup-points, 0 внешних вызовов на горячем пути.
Индексы: cms_delivery_ru_shipments — FK order_id, индекс status (пачки для опроса); cms_delivery_ru_pickup_points — составной provider_code, external_id; при фильтрации списка ПВЗ по гео-радиусу — пространственный/составной индекс под coordinates (уточняется при реализации под конкретную БД).
Внешние вызовы к службе доставки — только из очереди (создание отправления, синхронизация ПВЗ, опрос трек-статусов) — кроме синхронного расчёта стоимости на checkout, для которого явно зафиксированы таймаут и graceful fallback выше: это единственное разрешённое исключение из общего правила (стандарт §9/анти-паттерны), а не случайное отступление.
Тег delivery-ru:pickup-points — кеш справочника ПВЗ, инвалидируется событием DeliveryPickupPointsSynced.
Безопасность
- Опрос трек-статусов идемпотентен — повторный опрос не создаёт дублирующее событие (идемпотентность по
tracking_number+status+timestamp/id события провайдера: проверка текущего состояния перед записью новой строки журнала переходов, не слепой инсерт). - Печать этикетки недоступна без активного отправления (проверка состояния перед вызовом).
- Вебхуки трек-статуса — подпись HMAC (секрет per-провайдер из
.env) + проверкаtimestampпротив replay (аналогично интеграциям). - Секреты доступа к API служб доставки — только
.env. - Права:
delivery-ru.view,delivery-ru.manage.
ПДн-паспорт: провайдеру (службе доставки) при создании отправления передаются ФИО, адрес и телефон получателя — минимально необходимый набор для доставки; модуль не хранит их отдельной копией сверх заказа (см. «Модель данных»). Согласие на передачу — часть согласия на обработку заказа (форма заказа ядра), отдельного согласия модуль не запрашивает.
Матрица ролей:
| Действие | админ | менеджер | studio |
|---|---|---|---|
Просмотр отправлений/ПВЗ (delivery-ru.view) | ✅ | ✅ | ✅ |
Печать этикетки (delivery-ru.manage) | ✅ | ✅ | ✅ |
| Kill-switch способа доставки, лимиты, fallback-политика | ✅ | — | ✅ |
| Ручной запуск синхронизации ПВЗ вне расписания | ✅ | — | ✅ |
Стоимость API-вызова (если провайдер тарифицирует по числу запросов) — видна в админке через delivery-ru.price_per_request, справочно, без автоматического биллинга ядра.
UX-требования
Админ:
- Статус синхронизации: время последнего успешного обновления справочника ПВЗ, возраст кеша с предупреждением при превышении
pickup_points_max_age_hours; отставание очереди опроса трек-статусов — на дашборде модуля. - Журнал обмена с провайдером (запросы/ответы) — отдельно от бизнес-лога, для разбора инцидентов.
- Ошибки на человеческом языке: «СДЭК не отвечает, расчёт доставки временно недоступен» вместо технической ошибки/
Error 500; «Boxberry недоступен — повторите печать позже». - Подтверждение при повторной печати этикетки для уже существующего
label_url(предупреждение о риске дубля у оператора, не молчаливый повтор).
Покупатель:
- Виджет карты ПВЗ при недоступности справочника — показывает последнюю закешированную версию без блокировки выбора; полностью пустой список — с формулировкой «нет пунктов выдачи в этом районе, выберите курьерскую доставку», не пустую карту без пояснения.
- Расчёт доставки на checkout укладывается в воспринимаемый бюджет
estimate_timeout_ms; при фолбэке пользователь видит понятную формулировку способа доставки («стоимость уточним после оформления» / фиксированная ставка), не индикатор ошибки.
Крайние случаи и типовые баги
- Расчёт стоимости недоступен (таймаут/ошибка провайдера) на checkout → политика
estimate_fallback_policy: дефолтdefer— способ доставки показывается с пометкой «стоимость уточним после оформления», заказ не блокируется; альтернативыfixed_rate(суммаfixed_rate_amount) иhide(скрыть способ) — включаются явно настройкой, не выбираются кодом наугад. Ответ API —200сmeta.degraded: true(конвенция ядра, ревизия 14.07.2026), не 5xx. - Трек-статус не входит в известный маппинг (новый/неизвестный статус от провайдера) → сохраняется как
status = unknownс оригинальным кодом в логе для последующего маппинга; не роняет обработку вебхука и не блокирует показ остального трека покупателю. - Справочник ПВЗ устарел (синхронизация упала) → используется закешированная версия (тег
delivery-ru:pickup-pointsне инвалидируется без успешной синхронизации), админ видит предупреждение о возрасте кеша при превышенииpickup_points_max_age_hours. - Адрес не бьётся с ФИАС/для расчёта (некорректный/неполный адрес) → dadata (
suggest-provider, если включён) нормализует адрес перед расчётом; при выключенном/недоступном dadata — расчёт по минимально введённым данным (город/индекс) либо явная ошибка пользователю с указанием уточнить адрес, не молчаливо неверный расчёт. - Дубль вебхука трек-статуса → идемпотентность по
tracking_number+status+timestamp(или provider-специфичному id события): повторный вебхук не создаёт повторное событиеDeliveryStatusChangedи не пишет вторую строку журнала переходов (углубление формулировки из «Безопасность»). - Недоступность провайдера при печати этикетки → понятная ошибка администратору («Boxberry временно недоступен, повторите позже»), повтор запроса не создаёт дублирующее отправление — печать идемпотентна относительно уже созданного
shipment_id. - Расхождение весогабаритов на этапе создания отправления vs расчёта на checkout → провайдер может пересчитать итоговую стоимость при создании отправления; расхождение с ранее показанной суммой логируется и алертится (
cms/health/аудит), не проходит втихую — заказ не блокируется, менеджер видит расхождение для ручной сверки. - Rate-limit провайдера при массовом опросе трек-статусов → батчинг пачками (
tracking_poll_batch_size) и троттлинг между пачками; ответ 429 от провайдера — backoff, не параллельный шторм повторных запросов. - Выключение/kill-switch провайдера доставки посреди checkout-сессии → способ, выбранный пользователем до выключения, при финализации заказа переоценивается; если способ более недоступен, checkout требует повторного выбора с понятным сообщением, не падает 500.
- Пустой список ПВЗ для адреса → виджет карты — состояние «нет пунктов выдачи поблизости», не пустая карта без пояснения (см. UX-требования).
Мини-ранбук (эксплуатация):
| Симптом | Что проверить | Команда |
|---|---|---|
| Расчёт доставки на checkout не отвечает / частый фолбэк | доступность провайдера, estimate_timeout_ms, circuit breaker | cms:delivery-ru:doctor --json |
| Справочник ПВЗ не обновляется | последний успешный запуск синхронизации, доступность API провайдера, pickup_points_max_age_hours | cms:delivery-ru:sync-pickup-points --json (ручной прогон) |
| Трек-статусы не обновляются | отставание очереди delivery-ru, rate-limit провайдера, журнал вебхуков | cms:delivery-ru:poll-tracking --json |
Донорский код
Донор: — (новая разработка). Легаси-импорт (стандарт §16) не применим — исторических отправлений у донора нет. При появлении переноса из внешней системы логистики — тот же паттерн команды cms:delivery-ru:import-legacy --source=<профиль>, идемпотентной по external_id/tracking_number, с --dry-run.
Тесты и приёмка
- [ ] Мок API службы доставки: тест расчёта стоимости, создания отправления, опроса трек-статуса
- [ ] Опрос трек-статусов идемпотентен — повторный опрос не создаёт дублирующее событие
- [ ] При недоступности провайдера checkout деградирует до оставшихся активных способов доставки
- [ ] Таймаут расчёта стоимости на checkout не блокирует оформление заказа (graceful fallback)
- [ ] Печать этикетки недоступна без активного отправления (проверка состояния)
- [ ] Права
delivery-ru.manageразграничивают просмотр отправлений и печать этикеток - [ ] Вебхук трек-статуса: подпись проверяется, невалидная подпись/протухший timestamp отклоняются
- [ ] Дубль вебхука не создаёт повторное событие
DeliveryStatusChanged - [ ] Контрактный тест деградации расчёта: недоступность провайдера →
200 + meta.degraded, политикаestimate_fallback_policyотрабатывает согласно настройке - [ ] Неизвестный трек-статус не роняет обработку вебхука, попадает в
unknownс логированием - [ ] Устаревание справочника ПВЗ отображается предупреждением в админке (
pickup_points_max_age_hours) - [ ] Rate-limit провайдера (мок 429) при опросе трек-статусов не приводит к шторму запросов
- [ ] Матрица ролей:
delivery-ru.view/delivery-ru.manageпроверены тестами доступа - [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут; тестовая БД только
delivery_ru_test,migrate:freshзапрещён