Разработка кастомного плагина доставки Magento 2

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.
Разработка и обслуживание любых видов сайтов:
Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Разработка кастомного плагина доставки Magento 2
Сложная
~5 рабочих дней
Часто задаваемые вопросы
Наши компетенции:
Этапы разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    874
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    851

Разработка кастомного плагина доставки Magento 2

Magento 2 имеет развитую систему доставки — Shipping Carriers, Rates, Carriers Facade. Но встроенные методы (UPS, FedEx, DHL) настроены под западный рынок. Для работы с локальными перевозчиками, нестандартной логикой тарификации или интеграцией с внутренней WMS нужен кастомный carrier-модуль.

Архитектура Shipping Carrier в Magento 2

Модуль строится по стандартной структуре Magento 2:

app/code/Vendor/MyCourier/
├── etc/
│   ├── module.xml
│   ├── config.xml          # дефолтные значения конфига
│   ├── adminhtml/
│   │   └── system.xml      # форма настроек в Admin > Stores > Config
│   └── frontend/
│       └── routes.xml      # если нужен frontend-контроллер
├── Model/
│   └── Carrier/
│       └── MyCourier.php   # основной класс
├── registration.php
└── composer.json

Центральный класс расширяет \Magento\Shipping\Model\Carrier\AbstractCarrier:

<?php
// Model/Carrier/MyCourier.php

namespace Vendor\MyCourier\Model\Carrier;

use Magento\Quote\Model\Quote\Address\RateRequest;
use Magento\Shipping\Model\Carrier\AbstractCarrier;
use Magento\Shipping\Model\Carrier\CarrierInterface;
use Magento\Shipping\Model\Rate\Result;

class MyCourier extends AbstractCarrier implements CarrierInterface {

    protected $_code = 'mycourier';

    public function collectRates( RateRequest $request ): ?Result {
        if ( ! $this->getConfigFlag( 'active' ) ) {
            return null;
        }

        /** @var Result $result */
        $result = $this->_rateResultFactory->create();

        $rates = $this->fetchRatesFromApi( $request );

        foreach ( $rates as $rateData ) {
            /** @var \Magento\Quote\Model\Quote\Address\RateResult\Method $method */
            $method = $this->_rateMethodFactory->create();
            $method->setCarrier( $this->_code );
            $method->setCarrierTitle( $this->getConfigData( 'title' ) );
            $method->setMethod( $rateData['code'] );
            $method->setMethodTitle( $rateData['name'] );
            $method->setPrice( $rateData['price'] );
            $method->setCost( $rateData['price'] );
            $result->append( $method );
        }

        return $result;
    }

    public function getAllowedMethods(): array {
        return [ $this->_code => $this->getConfigData( 'title' ) ];
    }
}

Расчёт тарифов: запрос к API

Для работы с внешним API используем \Magento\Framework\HTTP\Client\Curl — стандартный HTTP-клиент Magento 2, не требует дополнительных зависимостей:

private function fetchRatesFromApi( RateRequest $request ): array {
    $apiKey   = $this->getConfigData( 'api_key' );
    $fromCity = $this->getConfigData( 'from_city' );
    $toCity   = $request->getDestCity();
    $postcode = $request->getDestPostcode();

    $weight = 0;
    foreach ( $request->getAllItems() as $item ) {
        if ( $item->getParentItem() ) {
            continue; // пропускаем configurable parent
        }
        $weight += $item->getWeight() * $item->getQty();
    }

    $payload = json_encode([
        'from'     => $fromCity,
        'to_city'  => $toCity,
        'postcode' => $postcode,
        'weight'   => max( 0.1, $weight ),
        'currency' => $request->getPackageCurrency()->getCurrencyCode(),
    ]);

    $this->_curl->addHeader( 'Authorization', 'Bearer ' . $apiKey );
    $this->_curl->addHeader( 'Content-Type', 'application/json' );
    $this->_curl->setTimeout( 10 );

    try {
        $this->_curl->post( 'https://api.mycourier.ru/v2/rates', $payload );
        $body   = $this->_curl->getBody();
        $status = $this->_curl->getStatus();
    } catch ( \Exception $e ) {
        $this->_logger->error( 'MyCourier API error: ' . $e->getMessage() );
        return [];
    }

    if ( $status !== 200 ) {
        return [];
    }

    $data = json_decode( $body, true );
    return $data['services'] ?? [];
}

DI в конструкторе:

public function __construct(
    \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig,
    \Magento\Quote\Model\Quote\Address\RateResult\ErrorFactory $rateErrorFactory,
    \Psr\Log\LoggerInterface $logger,
    \Magento\Shipping\Model\Rate\ResultFactory $rateResultFactory,
    \Magento\Quote\Model\Quote\Address\RateResult\MethodFactory $rateMethodFactory,
    \Magento\Framework\HTTP\Client\Curl $curl,
    array $data = []
) {
    $this->_curl = $curl;
    parent::__construct( $scopeConfig, $rateErrorFactory, $logger, $rateResultFactory, $rateMethodFactory, $data );
}

Конфигурация модуля

etc/config.xml задаёт дефолты — без этого файла поля конфига возвращают null:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd">
    <default>
        <carriers>
            <mycourier>
                <active>0</active>
                <title>MyCourier</title>
                <from_city>Москва</from_city>
                <sallowspecific>0</sallowspecific>
                <sort_order>10</sort_order>
            </mycourier>
        </carriers>
    </default>
</config>

etc/adminhtml/system.xml добавляет секцию в Stores > Configuration > Sales > Shipping Methods:

<section id="carriers">
    <group id="mycourier" translate="label" sortOrder="100" showInDefault="1" showInWebsite="1" showInStore="1">
        <label>MyCourier</label>
        <field id="active" translate="label" type="select" sortOrder="1" showInDefault="1">
            <label>Enabled</label>
            <source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
        </field>
        <field id="api_key" translate="label" type="text" sortOrder="20" showInDefault="1">
            <label>API Key</label>
            <backend_model>Magento\Config\Model\Config\Backend\Encrypted</backend_model>
        </field>
        <field id="from_city" translate="label" type="text" sortOrder="30" showInDefault="1">
            <label>From City</label>
        </field>
    </group>
</section>

Используем Encrypted backend для API-ключа — он шифруется в базе через \Magento\Framework\Encryption\EncryptorInterface.

Observer: создание отправления после оплаты

<?php
// Observer/CreateShipment.php

namespace Vendor\MyCourier\Observer;

use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Sales\Api\OrderRepositoryInterface;
use Magento\Sales\Model\Order\ShipmentFactory;

class CreateShipment implements ObserverInterface {

    public function execute( Observer $observer ): void {
        /** @var \Magento\Sales\Model\Order $order */
        $order = $observer->getEvent()->getOrder();

        if ( ! $order->canShip() ) {
            return;
        }
        if ( strpos( $order->getShippingMethod(), 'mycourier' ) === false ) {
            return;
        }

        $trackingNumber = $this->courierApi->createShipment( $order );
        if ( ! $trackingNumber ) {
            return;
        }

        // Создаём shipment в Magento
        $shipment = $this->shipmentFactory->create(
            $order,
            $this->prepareItems( $order ),
            [ [
                'carrier_code'  => 'mycourier',
                'title'         => 'MyCourier',
                'number'        => $trackingNumber,
            ] ]
        );
        $shipment->register();
        $shipment->getOrder()->setIsInProcess( true );

        $this->transaction
            ->addObject( $shipment )
            ->addObject( $shipment->getOrder() )
            ->save();
    }
}

Регистрируем observer в etc/events.xml:

<config>
    <event name="sales_order_invoice_pay">
        <observer name="mycourier_create_shipment"
                  instance="Vendor\MyCourier\Observer\CreateShipment"/>
    </event>
</config>

Плагин (Plugin) для чекаута: ПВЗ

Magento 2 позволяет встраивать кастомный UI-компонент в checkout через checkout_index_index.xml. Добавляем поле выбора ПВЗ после выбора метода доставки:

<!-- view/frontend/layout/checkout_index_index.xml -->
<referenceBlock name="checkout.root">
    <arguments>
        <argument name="jsLayout" xsi:type="array">
            <item name="components" xsi:type="array">
                <item name="checkout" xsi:type="array">
                    <item name="children" xsi:type="array">
                        <item name="steps" xsi:type="array">
                            <item name="children" xsi:type="array">
                                <item name="shipping-step" xsi:type="array">
                                    <item name="children" xsi:type="array">
                                        <item name="shippingAddress" xsi:type="array">
                                            <item name="children" xsi:type="array">
                                                <item name="mycourier-pvz" xsi:type="array">
                                                    <item name="component" xsi:type="string">
                                                        Vendor_MyCourier/js/pvz-selector
                                                    </item>
                                                    <item name="sortOrder" xsi:type="string">200</item>
                                                </item>
                                            </item>
                                        </item>
                                    </item>
                                </item>
                            </item>
                        </item>
                    </item>
                </item>
            </item>
        </argument>
    </arguments>
</referenceBlock>

Кеширование через Magento Cache

Для расчёта тарифов важно использовать кеш Magento, а не статические переменные:

use Magento\Framework\App\CacheInterface;
use Magento\Framework\Serialize\SerializerInterface;

private function getCachedRates( string $cacheKey ): ?array {
    $cached = $this->cache->load( $cacheKey );
    if ( $cached ) {
        return $this->serializer->unserialize( $cached );
    }
    return null;
}

private function saveCachedRates( string $cacheKey, array $rates ): void {
    $this->cache->save(
        $this->serializer->serialize( $rates ),
        $cacheKey,
        [ 'mycourier_rates' ],
        1800
    );
}

Тег mycourier_rates позволяет инвалидировать весь кеш тарифов командой:

bin/magento cache:clean mycourier_rates

Установка и деплой

# Копируем модуль
cp -r Vendor/MyCourier app/code/Vendor/MyCourier

# Включаем
bin/magento module:enable Vendor_MyCourier
bin/magento setup:upgrade
bin/magento setup:di:compile
bin/magento setup:static-content:deploy ru_RU en_US -f

# Для production
bin/magento deploy:mode:set production

Важно: после любого изменения DI (конструкторов, плагинов, observers) нужен setup:di:compile. Без него изменения не подтягиваются из-за кеша генерации кода.

Тестирование

Unit-тест для collectRates:

public function testCollectsRatesWhenApiReturnsData(): void {
    $this->curlMock->method( 'getStatus' )->willReturn( 200 );
    $this->curlMock->method( 'getBody' )->willReturn( json_encode([
        'services' => [
            [ 'code' => 'standard', 'name' => 'Стандарт', 'price' => 350.0 ],
        ]
    ]));

    $result = $this->carrier->collectRates( $this->createRateRequest() );

    $this->assertInstanceOf( Result::class, $result );
    $rates = $result->getAllRates();
    $this->assertCount( 1, $rates );
    $this->assertEquals( 350.0, $rates[0]->getPrice() );
}

Сроки реализации

Базовый carrier с расчётом тарифов через API и конфигурацией в админке: 3–4 дня. Добавление observer для создания отправлений, трекинга и уведомлений: плюс 2–3 дня. UI-компонент выбора ПВЗ в checkout с кастомными полями адреса: плюс 2–3 дня. Интеграция с Magento MSI (multi-source inventory) для учёта остатков по складам: плюс 3–5 дней.