Своя служба доставки с внешним API - регистрация, расчёт, статусы
Подключаем службу доставки, у которой есть своё API и нет готового обработчика: регистрируем класс, считаем стоимость по чужому тарифу и забираем статусы посылок обратно в заказы.
Механика
Служба доставки в платформе - это класс, а не настройка. Платформа спрашивает у модулей список доступных классов, показывает их в списке служб и дальше работает с вашим кодом так же, как со штатным: даёт ему настройки, ограничения и профили.
У службы два уровня. Родительский класс отвечает за подключение к сервису и общие настройки, а профили - за конкретные тарифы: до пункта выдачи, курьером, срочно. Профиль наследуется от родителя и отличается от него параметрами расчёта.
Расчёт всегда идёт против отгрузки, а не против заказа. Отгрузка знает вес, габариты, состав и адрес, и этого достаточно, чтобы спросить у чужого сервиса цену. Результат расчёта - объект, а не число: кроме цены он несёт срок доставки и описание ошибки, если тариф не посчитался.
Ограничения решают, показывать службу покупателю или нет. Они живут отдельно от расчёта, настраиваются в интерфейсе и работают одинаково для штатных и своих служб. Своя проверка «мы туда не возим» - это ограничение, а не выброшенное исключение внутри расчёта.
Отдельный слой - обратная связь от сервиса. Трек-номер, статус посылки и факт вручения приходят не в момент расчёта, а днями позже, и забирает их отдельное задание по расписанию. Из-за этого своя служба доставки почти всегда состоит из двух половин: синхронного расчёта и фонового обмена статусами.
Шаги
- Завести класс службы и класс профиля в своём модуле или в каталоге проекта.
- Зарегистрировать классы обработчиком события, которое собирает их список.
- Описать настройки службы: ключ API, адрес сервиса, режим проверки.
- Реализовать расчёт: собрать параметры из отгрузки и спросить цену у сервиса.
- Закрыть расчёт кэшем и таймаутом, чтобы чужая недоступность не роняла оформление.
- Завести задание, которое забирает трек-номера и статусы посылок обратно.
Код
Каждый файл ниже отвечает за свою часть задачи: регистрация живёт в общем файле обработчиков, классы службы и профиля - в каталоге проекта, обращение к чужому сервису вынесено отдельно, чтобы его можно было проверить скриптом без оформления заказа.
Регистрируем классы службы:
use Bitrix\Main\EventManager;
EventManager::getInstance()->addEventHandler('sale', 'onSaleDeliveryHandlersClassNamesBuildList', function () { return new \Bitrix\Main\EventResult(\Bitrix\Main\EventResult::SUCCESS, [ '\\Local\\Delivery\\PvzHandler', '\\Local\\Delivery\\PvzProfile', ]); });Платформа узнаёт о службе только из этого списка. Класс, лежащий в проекте без регистрации, в списке служб не появится, и разбор обычно начинается именно с проверки этой строки.
Описываем службу и её настройки:
namespace Local\Delivery;
use Bitrix\Sale\Delivery\Services\Base;
class PvzHandler extends Base{ public static function getClassTitle() { return 'Доставка до пунктов выдачи'; } public static function getChildrenClassNames() { return ['\\Local\\Delivery\\PvzProfile']; }
public function getConfigStructure() { return ['MAIN' => ['TITLE' => 'Доступ к сервису', 'ITEMS' => [ 'API_KEY' => ['TYPE' => 'STRING', 'NAME' => 'Ключ API'], 'TEST' => ['TYPE' => 'Y/N', 'NAME' => 'Тестовый режим'], ]]]; }}Ключи и адреса сервиса живут в настройках службы, а не в коде. Тогда тестовый контур на стенде и боевые ключи на сайте отличаются настройкой, а не разными версиями одного файла.
Считаем стоимость по отгрузке:
public function calculateConcrete(\Bitrix\Sale\Shipment $shipment){ $result = new \Bitrix\Sale\Delivery\CalculationResult(); $weight = $shipment->getWeight(); $to = $this->getCityFromShipment($shipment);
$tariff = Api::calc($this->getParentService()->getConfigValues(), $weight, $to); if (!$tariff) { $result->addError(new \Bitrix\Main\Error('Тариф не рассчитан')); return $result; // служба просто не покажется покупателю } $result->setDeliveryPrice($tariff['price']); $result->setPeriodDescription($tariff['period']); return $result;}Расчёт возвращает объект результата, а не число. Ошибка внутри него - штатная ситуация: платформа уберёт службу из списка, но оформление заказа продолжится с остальными.
Закрываем чужой сервис кэшем и таймаутом:
$cache = \Bitrix\Main\Data\Cache::createInstance();if ($cache->initCache(3600, 'pvz_' . md5($to . $weight), '/local/delivery')) { return $cache->getVars(); // тариф меняется редко, ответ можно держать}$http = new \Bitrix\Main\Web\HttpClient(['socketTimeout' => 3, 'streamTimeout' => 5]);$response = $http->post($url, $params);// без таймаутов зависший сервис держит оформление заказа до предела времени PHPЧужой сервис однажды перестанет отвечать, и это нужно пережить. Короткий таймаут и кэш на одинаковые запросы превращают его недоступность в отсутствие одной службы в списке вместо зависшего оформления.
Ограничиваем показ службы:
// ограничения задают в интерфейсе службы, а не в коде расчётаuse Bitrix\Sale\Delivery\Restrictions;
foreach (Restrictions\Manager::getRestrictionsList($serviceId) as $rule) { printf("%s %s\n", $rule['CLASS_NAME'], json_encode($rule['PARAMS']));}// служба показывается покупателю, только если прошла все свои ограниченияОграничения проверяются до расчёта и стоят дёшево. Отсечь заказы весом за пределами тарифа ограничением честнее, чем возвращать ошибку из расчёта: во втором случае обращение к чужому сервису происходит впустую.
Забираем статусы посылок:
// /local/lib/Delivery/StatusAgent.php - вызывается по расписаниюforeach ($shipments as $shipment) { $status = Api::track($shipment->getField('TRACKING_NUMBER')); if ($status && $status !== $shipment->getField('TRACKING_STATUS')) { $shipment->setField('TRACKING_STATUS', $status); $shipment->getCollection()->getOrder()->save(); }}// опрашивают только незавершённые отгрузки, а не весь архив заказовСтатусы приносит отдельное задание, а не расчёт. Опрашивать сервис в момент показа страницы нельзя: покупатель ждёт ответа чужого API, а лимит запросов кончается за час.
Трек-номер полезно показывать покупателю в личном кабинете сразу, как он появился. Вопрос «где моя посылка» - самый частый в поддержке магазина, и ссылка на отслеживание снимает его без участия менеджера. Само отслеживание при этом ведёт на сайт службы: держать свою страницу статусов имеет смысл только там, где служб несколько и покупателю нужен один вид для всех.
Ограничения
Своя служба доступна в редакциях с модулем интернет-магазина; в младших редакциях списка служб нет вовсе. Классы служб живут в актуальной версии модуля продаж, и на проектах со старым ядром сначала обновляют модуль, а потом пишут обработчик.
Расчёт выполняется синхронно, прямо во время оформления заказа. Сервисы с медленным API это переносят плохо: помогает кэш, а при совсем долгих ответах - переход на приблизительный расчёт с уточнением после оформления.
Ограничения по адресу, весу и сумме настраивают в интерфейсе, а не в коде расчёта. Исключение, брошенное внутри расчёта вместо ошибки в результате, роняет не службу, а всю страницу оформления.
Лимит запросов к чужому сервису обычно оказывается ниже, чем кажется на старте. Каталог с активной посещаемостью пересчитывает доставку сотни раз в час, и без кэша дневной лимит заканчивается к обеду. Это стоит выяснить у сервиса до того, как расчёт уйдёт на боевой сайт.
Обновление модуля продаж иногда меняет состав методов базового класса. Свой обработчик стоит проверять после каждого крупного обновления продукта: он живёт не в чужом каталоге, но опирается на его устройство.
Типичные проблемы
Служба не появилась в списке доставок.
Класс не зарегистрирован обработчиком события со списком классов. Платформа берёт службы только из этого списка.
Оформление заказа зависает на минуту.
Расчёт ходит в чужой сервис без таймаута и кэша. Оба ограничения ставят до боевого запуска.
Страница оформления падает с ошибкой.
Внутри расчёта брошено исключение вместо ошибки в объекте результата. Ошибку возвращают результатом, и служба просто не показывается.
На стенде расчёт идёт по боевым тарифам.
Ключ и режим прописаны в коде, а не в настройках службы. Настройки у стенда и боевого сайта разные.
Статусы посылок обновляются раз в неделю.
Задание опрашивает весь архив отгрузок и не успевает за отведённое время. Опрашивают только незавершённые отгрузки.
Частые вопросы
Чем профиль отличается от самой службы?
Служба отвечает за подключение к сервису, профиль - за конкретный тариф. Профилей у одной службы может быть несколько.
Где хранить ключ API службы?
В настройках службы в административной части. В коде и в репозитории ключам не место.
Как проверить расчёт до боевого запуска?
Тестовым контуром сервиса и оформлением заказа на стенде. Расчёт удобно вызывать и напрямую, скриптом по отгрузке.
Что делать, если сервис отвечает по десять секунд?
Показывать приблизительную цену и уточнять её после оформления. Держать покупателя в ожидании чужого API нельзя.
Нужен ли свой обработчик, если есть готовый модуль?
Обычно нет: готовый получает исправления и обновления. Свой пишут, когда готового для этого сервиса не существует.
Смежное
- Доставка и оплата - оглавление подтемы
- Рабочий календарь: график, праздники, расчёт срока - расчёт срока в рабочих днях
- Служба доставки и платёжная система: настройка и свой обработчик - настройка штатных служб
- Свой обработчик оплаты: форма, уведомление, отметка платежа - соседняя задача с той же механикой
- Свой расчёт в оформлении: цена позиции и доставка - как данные с формы доезжают до расчёта
- Своё ограничение доставки и оплаты: класс, параметры, регистрация - когда правило - не расчёт, а видимость
- Вызов внешнего сервиса: запрос, таймаут, повторы - как ходить в чужое API
- Каталог и продажи - устройство магазина целиком