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

Своя служба доставки с внешним API - регистрация, расчёт, статусы

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

Механика

Служба доставки в платформе - это класс, а не настройка. Платформа спрашивает у модулей список доступных классов, показывает их в списке служб и дальше работает с вашим кодом так же, как со штатным: даёт ему настройки, ограничения и профили.

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

Расчёт всегда идёт против отгрузки, а не против заказа. Отгрузка знает вес, габариты, состав и адрес, и этого достаточно, чтобы спросить у чужого сервиса цену. Результат расчёта - объект, а не число: кроме цены он несёт срок доставки и описание ошибки, если тариф не посчитался.

Ограничения решают, показывать службу покупателю или нет. Они живут отдельно от расчёта, настраиваются в интерфейсе и работают одинаково для штатных и своих служб. Своя проверка «мы туда не возим» - это ограничение, а не выброшенное исключение внутри расчёта.

Отдельный слой - обратная связь от сервиса. Трек-номер, статус посылки и факт вручения приходят не в момент расчёта, а днями позже, и забирает их отдельное задание по расписанию. Из-за этого своя служба доставки почти всегда состоит из двух половин: синхронного расчёта и фонового обмена статусами.

Шаги

  1. Завести класс службы и класс профиля в своём модуле или в каталоге проекта.
  2. Зарегистрировать классы обработчиком события, которое собирает их список.
  3. Описать настройки службы: ключ API, адрес сервиса, режим проверки.
  4. Реализовать расчёт: собрать параметры из отгрузки и спросить цену у сервиса.
  5. Закрыть расчёт кэшем и таймаутом, чтобы чужая недоступность не роняла оформление.
  6. Завести задание, которое забирает трек-номера и статусы посылок обратно.

Код

Каждый файл ниже отвечает за свою часть задачи: регистрация живёт в общем файле обработчиков, классы службы и профиля - в каталоге проекта, обращение к чужому сервису вынесено отдельно, чтобы его можно было проверить скриптом без оформления заказа.

Регистрируем классы службы:

/local/php_interface/init.php
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',
]);
});

Платформа узнаёт о службе только из этого списка. Класс, лежащий в проекте без регистрации, в списке служб не появится, и разбор обычно начинается именно с проверки этой строки.

Описываем службу и её настройки:

/local/lib/Delivery/PvzHandler.php
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' => 'Тестовый режим'],
]]];
}
}

Ключи и адреса сервиса живут в настройках службы, а не в коде. Тогда тестовый контур на стенде и боевые ключи на сайте отличаются настройкой, а не разными версиями одного файла.

Считаем стоимость по отгрузке:

/local/lib/Delivery/PvzProfile.php
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;
}

Расчёт возвращает объект результата, а не число. Ошибка внутри него - штатная ситуация: платформа уберёт службу из списка, но оформление заказа продолжится с остальными.

Закрываем чужой сервис кэшем и таймаутом:

/local/lib/Delivery/Api.php
$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 нельзя.

Нужен ли свой обработчик, если есть готовый модуль?

Обычно нет: готовый получает исправления и обновления. Свой пишут, когда готового для этого сервиса не существует.

Смежное

Первоисточник