Перейти к основному содержимому

Доставка: устройство 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. Для котировки по ПВЗ синхронизируется только город — сама котировка адрес не использует.

Стандартный manual fulfillment provider зарегистрирован явно

Задание массива 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 или СДЭК — учитывайте в любой будущей интеграции с расчётом доставки. Подробнее — Известные особенности.