PHP / Magento Dev Blog

  • Publikacje
  • O autorze
  • Kontakt

Własny carrier wysyłkowy – collectRates, śledzenie paczek, generowanie etykiet

by Henryk Tews / wtorek, 16 lipca 2024 / Opublikowano w Magento 2

Integracja z polskimi kurierami (InPost, DPD, DHL, Poczta Polska) często wymaga napisania własnego carrieru zamiast polegania na gotowych modułach. Pokazuję jak zbudować pełny moduł dostawy od zera: dynamiczne stawki z API kuriera, wybór punktu odbioru, generowanie etykiet i śledzenie statusu paczki.

Architektura carrieru w Magento 2

Carrier w Magento to klasa dziedzicząca po AbstractCarrier która implementuje CarrierInterface. Magento wywołuje metodę collectRates() przy obliczaniu kosztów dostawy – tu trafia logika pobierania stawek z API lub własnych reguł.

Główna klasa carrieru

<?php

declare(strict_types=1);

namespace Vendor\Carrier\Model\Carrier;

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

class MyCarrier extends AbstractCarrier implements CarrierInterface
{
    protected $_code = 'vendor_carrier'; // unikalny kod - używany w konfiguracji
    protected $_isFixed = false;          // false = dynamiczne stawki

    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,
        private \Vendor\Carrier\Model\Api\CarrierApiClient $apiClient,
        private \Vendor\Carrier\Model\Config $config,
        array $data = []
    ) {
        parent::__construct($scopeConfig, $rateErrorFactory, $logger, $data);
        $this->_rateResultFactory = $rateResultFactory;
        $this->_rateMethodFactory = $rateMethodFactory;
    }

    // Magento wywołuje tę metodę dla każdego carrieru przy obliczaniu kosztów
    public function collectRates(RateRequest $request): Result|bool
    {
        if (!$this->getConfigFlag('active')) {
            return false;
        }

        // Sprawdź minimalną wagę i wartość koszyka
        if ($request->getPackageWeight() > $this->config->getMaxWeight()) {
            return false;
        }

        $result = $this->_rateResultFactory->create();

        // Darmowa wysyłka powyżej limitu
        $freeShippingThreshold = $this->config->getFreeShippingThreshold();
        if ($freeShippingThreshold > 0 && $request->getPackageValue() >= $freeShippingThreshold) {
            $method = $this->createRateMethod('free', 'Dostawa gratis', 0.0);
            $result->append($method);
            return $result;
        }

        try {
            // Pobierz dynamiczne stawki z API kuriera
            $rates = $this->apiClient->getRates([
                'weight'      => $request->getPackageWeight(),
                'dest_zip'    => $request->getDestPostcode(),
                'dest_city'   => $request->getDestCity(),
                'dest_country' => $request->getDestCountryId(),
                'package_value' => $request->getPackageValue(),
            ]);

            foreach ($rates as $rate) {
                $method = $this->createRateMethod(
                    $rate['service_code'],
                    $rate['service_name'],
                    (float) $rate['price']
                );
                $result->append($method);
            }
        } catch (\Exception $e) {
            $this->_logger->error('Carrier API error', ['error' => $e->getMessage()]);

            // Fallback do stawki stałej gdy API niedostępne
            if ($this->config->getFallbackPrice() > 0) {
                $result->append(
                    $this->createRateMethod('standard', 'Dostawa standardowa', $this->config->getFallbackPrice())
                );
            }
        }

        return $result;
    }

    // Dostępne metody dostawy (wyświetlane w konfiguracji admina)
    public function getAllowedMethods(): array
    {
        return [
            'standard'   => 'Dostawa standardowa',
            'express'    => 'Dostawa ekspresowa',
            'parcel_box' => 'Paczkomat InPost',
            'free'       => 'Dostawa gratis',
        ];
    }

    // Czy carrier obsługuje śledzenie paczek
    public function isTrackingAvailable(): bool
    {
        return true;
    }

    // Pobierz status śledzenia paczki
    public function getTrackingInfo(string $trackingNumber): \Magento\Shipping\Model\Tracking\Result\Status
    {
        $status = $this->_trackFactory->create();

        try {
            $trackData = $this->apiClient->getTrackingInfo($trackingNumber);

            $status->setCarrierTitle($this->getConfigData('title'));
            $status->setTracking($trackingNumber);
            $status->setStatus($trackData['status']);
            $status->setDeliverydate($trackData['delivery_date'] ?? '');
            $status->setDeliverytime($trackData['delivery_time'] ?? '');

            // Historia zdarzeń paczki
            if (!empty($trackData['events'])) {
                $progressDetails = [];
                foreach ($trackData['events'] as $event) {
                    $progressDetails[] = [
                        'deliverylocation' => $event['location'],
                        'deliverydate'     => $event['date'],
                        'deliverytime'     => $event['time'],
                        'activity'         => $event['description'],
                    ];
                }
                $status->setProgressdetail($progressDetails);
            }
        } catch (\Exception $e) {
            $this->_logger->error('Tracking API error', [
                'tracking' => $trackingNumber,
                'error'    => $e->getMessage(),
            ]);
            $status->setStatus('Błąd pobierania statusu');
        }

        return $status;
    }

    private function createRateMethod(
        string $methodCode,
        string $methodTitle,
        float $price
    ): \Magento\Quote\Model\Quote\Address\RateResult\Method {
        $method = $this->_rateMethodFactory->create();
        $method->setCarrier($this->_code);
        $method->setCarrierTitle($this->getConfigData('title'));
        $method->setMethod($methodCode);
        $method->setMethodTitle($methodTitle);
        $method->setPrice($price);
        $method->setCost($price);
        return $method;
    }
}

Konfiguracja config.xml i system.xml

<!-- etc/config.xml -->
<?xml version="1.0"?>
<config>
    <default>
        <carriers>
            <vendor_carrier>
                <active>0</active>
                <title>Mój Kurier</title>
                <sallowspecific>0</sallowspecific>
                <model>Vendor\Carrier\Model\Carrier\MyCarrier</model>
                <api_url>https://api.mycarrier.pl/v1</api_url>
                <max_weight>30</max_weight>
                <free_shipping_threshold>299</free_shipping_threshold>
                <fallback_price>14.99</fallback_price>
            </vendor_carrier>
        </carriers>
    </default>
</config>

Generowanie etykiety wysyłkowej

<?php

declare(strict_types=1);

namespace Vendor\Carrier\Observer;

use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Sales\Api\Data\ShipmentInterface;

class CreateShipmentObserver implements ObserverInterface
{
    public function __construct(
        private \Vendor\Carrier\Model\Api\CarrierApiClient $apiClient,
        private \Magento\Sales\Api\ShipmentTrackRepositoryInterface $trackRepository,
        private \Magento\Sales\Api\Data\ShipmentTrackInterfaceFactory $trackFactory,
        private \Psr\Log\LoggerInterface $logger
    ) {}

    public function execute(Observer $observer): void
    {
        /** @var ShipmentInterface $shipment */
        $shipment = $observer->getData('shipment');
        $order    = $shipment->getOrder();

        // Tylko dla naszego carrieru
        if (!str_starts_with($order->getShippingMethod(), 'vendor_carrier_')) {
            return;
        }

        try {
            // Wyślij dane do API kuriera i pobierz numer śledzenia
            $labelData = $this->apiClient->createShipment([
                'order_id'        => $order->getIncrementId(),
                'recipient_name'  => $order->getShippingAddress()->getName(),
                'recipient_street' => implode(', ', $order->getShippingAddress()->getStreet()),
                'recipient_zip'   => $order->getShippingAddress()->getPostcode(),
                'recipient_city'  => $order->getShippingAddress()->getCity(),
                'weight'          => $this->calculateWeight($shipment),
                'service'         => str_replace('vendor_carrier_', '', $order->getShippingMethod()),
            ]);

            // Zapisz numer śledzenia do przesyłki
            $track = $this->trackFactory->create();
            $track->setParentId($shipment->getId());
            $track->setOrderId($order->getId());
            $track->setCarrierCode('vendor_carrier');
            $track->setTitle('Mój Kurier');
            $track->setTrackNumber($labelData['tracking_number']);

            $this->trackRepository->save($track);

            // Zapisz PDF etykiety jako attachment do przesyłki
            if (!empty($labelData['label_pdf'])) {
                $shipment->setShippingLabel(base64_decode($labelData['label_pdf']));
            }

        } catch (\Exception $e) {
            $this->logger->error('Failed to create shipment label', [
                'order_id' => $order->getIncrementId(),
                'error'    => $e->getMessage(),
            ]);
            // Nie rzucaj wyjątku - przesyłka zostanie zapisana bez etykiety
        }
    }

    private function calculateWeight(ShipmentInterface $shipment): float
    {
        $weight = 0.0;
        foreach ($shipment->getItems() as $item) {
            $weight += ($item->getWeight() ?? 0.5) * $item->getQty();
        }
        return max(0.1, $weight);
    }
}

Rejestracja w etc/events.xml i di.xml

<!-- etc/events.xml -->
<?xml version="1.0"?>
<config>
    <event name="sales_order_shipment_save_before">
        <observer name="vendor_carrier_create_label"
                  instance="Vendor\Carrier\Observer\CreateShipmentObserver"/>
    </event>
</config>
<!-- etc/di.xml -->
<?xml version="1.0"?>
<config>
    <type name="Vendor\Carrier\Model\Carrier\MyCarrier">
        <arguments>
            <argument name="rateResultFactory" xsi:type="object">
                Magento\Shipping\Model\Rate\ResultFactory
            </argument>
            <argument name="rateMethodFactory" xsi:type="object">
                Magento\Quote\Model\Quote\Address\RateResult\MethodFactory
            </argument>
        </arguments>
    </type>
</config>

Testowanie w DDEV

# Wyczyść cache po zmianach w carrier
ddev exec bin/magento cache:flush

# Przetestuj collectRates() przez CLI
ddev exec bin/magento dev:query-log:enable
# Dodaj produkt do koszyka i sprawdź query log

# Symulacja requestu calculateRates przez API Magento
ddev exec curl -X POST https://magento2-dev.ddev.site/rest/V1/guest-carts/{cartId}/estimate-shipping-methods \
    -H 'Content-Type: application/json' \
    -d '{"address": {"region": "Mazowieckie", "postcode": "00-001", "country_id": "PL", "city": "Warszawa"}}'

Podsumowanie

Własny carrier w Magento 2 wymaga kilku plików ale schemat jest powtarzalny. Kluczowe elementy: collectRates() z logiką pobierania stawek (ze stałą lub z API), fallback na wypadek niedostępności API, śledzenie przez getTrackingInfo() i opcjonalne generowanie etykiet przez Observer. Warto zadbać o obsługę błędów – awaria API kuriera nie powinna blokować składania zamówień.

About Henryk Tews

Co możesz przeczytać następne

GraphQL – własny resolver, schemat, autoryzacja, testowanie w DDEV
Własna metoda płatności – authorize/capture/refund, JS renderer, PCI DSS
Message Queue Framework z RabbitMQ – publisher, consumer, DDEV config
  • Publikacje
  • O autorze
  • Kontakt

© 2026 Created by

GÓRA
Zarządzaj zgodą
Aby zapewnić jak najlepsze wrażenia, korzystamy z technologii, takich jak pliki cookie, do przechowywania i/lub uzyskiwania dostępu do informacji o urządzeniu. Zgoda na te technologie pozwoli nam przetwarzać dane, takie jak zachowanie podczas przeglądania lub unikalne identyfikatory na tej stronie. Brak wyrażenia zgody lub wycofanie zgody może niekorzystnie wpłynąć na niektóre cechy i funkcje.
Funkcjonalne Zawsze aktywne
Przechowywanie lub dostęp do danych technicznych jest ściśle konieczny do uzasadnionego celu umożliwienia korzystania z konkretnej usługi wyraźnie żądanej przez subskrybenta lub użytkownika, lub wyłącznie w celu przeprowadzenia transmisji komunikatu przez sieć łączności elektronicznej.
Preferencje
Przechowywanie lub dostęp techniczny jest niezbędny do uzasadnionego celu przechowywania preferencji, o które nie prosi subskrybent lub użytkownik.
Statystyka
Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do celów statystycznych. Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do anonimowych celów statystycznych. Bez wezwania do sądu, dobrowolnego podporządkowania się dostawcy usług internetowych lub dodatkowych zapisów od strony trzeciej, informacje przechowywane lub pobierane wyłącznie w tym celu zwykle nie mogą być wykorzystywane do identyfikacji użytkownika.
Marketing
Przechowywanie lub dostęp techniczny jest wymagany do tworzenia profili użytkowników w celu wysyłania reklam lub śledzenia użytkownika na stronie internetowej lub na kilku stronach internetowych w podobnych celach marketingowych.
  • Zarządzaj opcjami
  • Zarządzaj serwisami
  • Zarządzaj {vendor_count} dostawcami
  • Przeczytaj więcej o tych celach
Zobacz preferencje
  • {title}
  • {title}
  • {title}