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ń.
