Доставка: устройство ApiShip и СДЭК
apps/backend/src/modules/shipping-provider-registry, providers/fulfillment-cdek,
плагин @gorgo/medusa-fulfillment-apiship.
Реестр провайдеров хранит только флаг видимости в чекауте (is_visible) для ApiShip — сама
интеграция и её реквизиты живут в самом плагине. СДЭК реализован как полноценный
fulfillment-provider проекта, со своими реквизитами и бизнес-логикой.
В этом же реестре хранятся габариты и вес коробки магазина (save-shipping-store-package,
admin/shipping-providers/package) — единственная упаковка, которую использует расчёт тарифов
СДЭК: без сохранённой коробки (getStorePackage) calculateAvailableTariffs бросает ошибку и
тарифы не считаются вовсе (providers/fulfillment-cdek/service.ts:92).
Синхронизация адреса доставки корзины (workflows/sync-catalog-shipping-address.ts) —
отдельный workflow, вызываемый при выборе города/адреса в чекауте: пишет город и, для курьерской
доставки (не ПВЗ), улицу/дом/квартиру в cart.shipping_address через штатный updateCartWorkflow
core. Для котировки по ПВЗ синхронизируется только город — сама котировка адрес не использует.
Задание массива providers в конфигурации @medusajs/medusa/fulfillment заменяет дефолты
Medusa, а не дополняет их (medusa-config.ts:158). Поэтому встроенный manual-провайдер — тот,
что используется для всех способов доставки, не относящихся к ApiShip/СДЭК — зарегистрирован в
medusa-config.ts явно, рядом с ApiShip и СДЭК. Убрать его из списка означало бы сломать все
такие способы доставки разом.
Автоматическое создание отправления при оформлении
subscribers/create-apiship-fulfillment.ts слушает событие order.placed и создаёт отправление
ApiShip или СДЭК автоматически, сразу после оформления заказа. Ошибка подписчика только
логируется — уже созданный заказ не откатывается и не отменяется. Это значит, что заказ без
отправления — штатная, хоть и требующая ручного вмешательства ситуация (см.
«Как обработать заказ», шаг 4), а не повод откатывать сам заказ.
ApiShip
Готовый плагин @gorgo/medusa-fulfillment-apiship (provider id apiship_apiship, DI-ключ
fp_apiship_apiship). Подключается во всех окружениях, кроме NODE_ENV=test
(medusa-config.ts:44) — то есть загружается и в development, не только в production — и добавляет
собственные /store/apiship/*, /admin/apiship/* и раздел настроек.
- Создание отправления, этикетка, повтор при ошибке, отмена, опрос статуса.
- Статус доставки не имеет фиксированного enum у самого ApiShip — маппится эвристикой по
ключевым словам в упрощённый набор:
placed/preparing/in_transit/delivered/cancelled. Принятое допущение до появления реального доступа к ApiShip. - Статус не может регрессировать — только вперёд, кроме терминальной отмены
(
workflows/refresh-apiship-order-tracking-status.ts:61). - Повторный запрос статуса в течение 60 секунд возвращает сохранённый результат без
обращения к перевозчику; параллельные проверки одного заказа используют общий запрос
(
:173,253). - Незавершённые отправления опрашиваются каждый час (
jobs/poll-apiship-tracking-status.ts:48); ошибка обновления одного заказа логируется и не мешает обработать остальные — изоляция сбоя. - Административный запрос этикетки прекращает ожидание через 15 секунд и предлагает повторить
позже (
workflows/get-apiship-fulfillment-label.ts:15). - Известные баги апстрима патчатся через
patch-package(patches/@gorgo+medusa-fulfillment-apiship+*.patch, переустанавливаются наnpm install) — новый баг того же рода чинится так же, а не форком пакета.
(*) Цена доставки не перепроверяется при оформленииprepareCatalogCheckoutShippingOptionsStep (workflows/steps/catalog-checkout.ts:424) при
финализации заказа повторно проверяет только видимость провайдера, но не запрашивает у ApiShip
свежий тариф — используется ранее рассчитанная и показанная покупателю цена. Если тариф у
перевозчика изменился между расчётом и оформлением, заказ всё равно уйдёт по старой цене.
Другие грабли реального ApiShip API — docs/known-quirks.md (тестовый режим — три независимых
переключателя, кэш ПВЗ не смотрит на filter, дублирующиеся тарифы, улица режет тариф ПВЗ).
СДЭК
Собственная интеграция проекта, без ApiShip как посредника.
- Конфигурация, тарифы по договору, отправления (в том числе этикетка файлом), повтор, вебхук, отслеживание, пункты выдачи.
- Реквизиты хранятся зашифрованными: SHA-256-хеш ключа, AES-256-GCM с IV и auth tag
(
modules/shipping-provider-registry/credentials.ts:12). БезSHIPPING_PROVIDER_CONFIG_ENCRYPTION_KEYсохранить реквизиты СДЭК невозможно. CDEK_BASE_URL(алиасCDEK_API_BASE_URL) переключает адрес API — для тестового контура (cdek-client.ts:111).- Авторизация и запрос тарифов ограничены таймаутом ~8 секунд (
cdek-client.ts:121). - Включение СДЭК автоматически создаёт нужные Medusa shipping options, выключение — удаляет их
(
steps/ensure-cdek-shipping-options.ts).
Идемпотентность создания отправления и этикетки
Создание отправления сериализуется блокировкой по заказу (до 20 секунд ожидания,
create-cdek-order-fulfillment.ts:27). После возможного успешного POST и потери ответа повтор
сначала ищет заказ в СДЭК по стабильному номеру, не создавая дубль
(providers/fulfillment-cdek/service.ts:233). Отправление получает статус created только
после проверки, что поставленный в очередь заказ не отклонён СДЭК (:354).
Запрос этикетки идемпотентен — переиспользует ранее сгенерированный UUID запроса, блокировка по
заказу до 15 секунд (get-cdek-fulfillment-label.ts:37).
(*) createReturnFulfillment — заглушкаСтандартный метод Medusa fulfillment provider createReturnFulfillment у СДЭК-провайдера
(providers/fulfillment-cdek/service.ts:417) возвращает пустые данные без реального создания
обратной отправки. Настоящий возврат идёт через отдельный workflow
resolve-shipping-return-request.ts — см. ниже. Не вызывайте стандартный Medusa return flow для
СДЭК напрямую, он ничего не создаст.
Вебхук СДЭК
workflows/process-cdek-webhook.ts:
- Подпись — HMAC-SHA256 в постоянном времени против сохранённого секрета (
:33). - Блокировка на трёх уровнях сразу: по UUID события, по отправлению и по заказу — предотвращает
гонки разных событий одной доставки (
:162). - Журнал вебхуков идемпотентен: UUID события уникален, запись фиксирует тип, время, отправление
и факт применения (
modules/shipping-provider-registry/models/shipping-webhook-event.ts). - Устаревшие, неизвестные и регрессирующие статусы игнорируются, терминальные состояния не
перезаписываются (
:98). - Событие
PRINT_FORMсохраняет URL готовой этикетки в отправлении (:250); событие обратной доставки записывает статус и трек-номер прямо в заявку на возврат (:224).
Отправление СДЭК хранит отдельные состояния creating/created/failed, текст ошибки и
признак необходимости сверки (models/shipping-shipment.ts:7). Частичный уникальный индекс не
даёт продублировать активное отправление одного перевозчика на один заказ (:26).
Заявки на возврат (только СДЭК)
workflows/request-catalog-shipping-return.ts, resolve-shipping-return-request.ts.
- Доступна только для существующей CDEK-отправки после стадии
placedи до отмены (:50). - Частичный уникальный индекс запрещает две одновременно ожидающие заявки по одному заказу
(
models/shipping-return-request.ts:29); параллельные заявки одного заказа дополнительно защищены lock-блокировкой до 15 секунд (:25). - Перед созданием обратного заказа СДЭК workflow ищет его по стабильному номеру
return-<request_id>и переиспользует найденный — идемпотентное восстановление (resolve-shipping-return-request.ts:136). - Повтор того же решения по заявке — no-op, смена уже принятого решения запрещена (
:65). - Размеры коробки для обратной отправки переводятся из мм в см с округлением вверх, вес
распределяется между позициями поровну, минимум 1 г на позицию (
:157).
Если отправление СДЭК уже прошло стадию placed, cancel-catalog-customer-order не отменяет
заказ — вместо этого автоматически создаётся заявка на возврат с причиной «вместо недоступной
отмены» (workflows/cancel-catalog-customer-order.ts:92).
Отслеживание в кабинете покупателя: СДЭК и ApiShip устроены по-разному
GET /store/account/orders/[id]/tracking (api/store/account/orders/[id]/tracking/route.ts:17):
- СДЭК: endpoint отдаёт сохранённый webhook-статус и
trackingUrl: null, не обращаясь к перевозчику синхронно — вся свежесть данных зависит от того, как быстро дошёл и обработался вебхук. - ApiShip: endpoint опрашивает перевозчика напрямую при каждом запросе (с учётом кэша на 60 секунд, см. выше).
Если статус СДЭК выглядит устаревшим — это не баг endpoint'а, а ожидаемое поведение: ищите проблему в доставке вебхука, а не добавляйте синхронный запрос к перевозчику в этот роут.
Публичный API catalog/shipping/*
catalog/shipping/utils.ts:
- Тарифы доставки рассчитываются через
Promise.allSettled— сбойный провайдер не валит запрос целиком, доступные тарифы остальных сохраняются (:191). - Одинаковые нормализованные тарифы от одного перевозчика дедуплицируются — остаётся самый
дешёвый вариант (
:139). GET /catalog/shipping/servicesвозвращает только включённые подключения, дубли поprovider_keyустраняются (:108).- За один запрос читается не более 5000 подходящих пунктов выдачи СДЭК (
:318). - Выбор пункта выдачи отклоняется, если префикс
point_idпринадлежит не тому провайдеру, что выбранный тариф (:443) — защита от подмены ПВЗ одного перевозчика тарифом другого. - Кэш пунктов ApiShip раздельный по городу и перевозчику: ключ включает нормализованные город и
carrier, чтобы не выдать чужую службу из кэша (
:301). GET /catalog/shipping/pointsдополнительно принимаетaddress— фильтр пунктов СДЭК по части адреса (catalog/validators.ts:181).- Пункты выдачи ApiShip отфильтровываются до тех, что способны выдавать отправление покупателю
(
:249).
Пункты выдачи СДЭК
Справочник обновляется ежедневно в 03:00 (jobs/sync-cdek-pickup-points.ts:9). Новые ПВЗ
создаются, изменившиеся обновляются, исчезнувшие soft-delete'ятся, вернувшиеся восстанавливаются
(workflows/sync-cdek-pickup-points.ts:66). Задание не обращается к СДЭК, если провайдер скрыт
или реквизиты не сохранены (:44).
Ответ пунктов выдачи ApiShip и СДЭК включает широту и долготу наряду с адресом и расписанием
(api/catalog/shipping/utils.ts:222) — на текущей витрине карта не используется, но данные для
неё уже есть в ответе API.
Ограничение уровня core
Medusa вырезает улицу из контекста ценообразования доставки ещё до вызова
fulfillment-провайдеров (fieldsForPricingContext/filterObjectByKeys удаляют
shipping_address.address_1/address_2). Калькулятор видит только city, country_code,
province, postal_code. Это общее поведение core, а не специфика ApiShip или СДЭК —
учитывайте в любой будущей интеграции с расчётом доставки. Подробнее — Известные особенности.