Files
orderPRO/src/Modules/Shipments/AllegroShipmentService.php
Jacek Pyziak 176d740578 feat(50-allegro-shipment-waybill-push): push waybill to allegro checkout form
Phase 50 complete:

- add conditional waybill push for allegro orders only

- add retry on ALLEGRO_HTTP_401 and non-critical failure handling

- add unit tests and update architecture/changelog docs
2026-03-28 15:32:34 +01:00

475 lines
18 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Shipments;
use App\Core\Exceptions\IntegrationConfigException;
use App\Core\Exceptions\ShipmentException;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\AllegroApiClient;
use App\Modules\Settings\AllegroTokenManager;
use App\Modules\Settings\CompanySettingsRepository;
use RuntimeException;
use Throwable;
final class AllegroShipmentService implements ShipmentProviderInterface
{
public function __construct(
private readonly AllegroTokenManager $tokenManager,
private readonly AllegroApiClient $apiClient,
private readonly ShipmentPackageRepository $packages,
private readonly CompanySettingsRepository $companySettings,
private readonly OrdersRepository $ordersRepository
) {
}
public function code(): string
{
return 'allegro_wza';
}
/**
* @return array<int, array<string, mixed>>
*/
public function getDeliveryServices(): array
{
[$accessToken, $env] = $this->tokenManager->resolveToken();
$response = $this->apiClient->getDeliveryServices($env, $accessToken);
return is_array($response['services'] ?? null) ? $response['services'] : [];
}
/**
* @param array<string, mixed> $formData
* @return array<string, mixed>
*/
public function createShipment(int $orderId, array $formData): array
{
$order = $this->ordersRepository->findDetails($orderId);
if ($order === null) {
throw new ShipmentException('Zamowienie nie znalezione.');
}
$company = $this->companySettings->getSettings();
$sender = $this->companySettings->getSenderAddress();
$this->validateSenderAddress($sender);
$deliveryMethodId = trim((string) ($formData['delivery_method_id'] ?? ''));
if ($deliveryMethodId === '') {
throw new ShipmentException('Nie podano metody dostawy.');
}
$receiverAddress = $this->buildReceiverAddress($order, $formData);
$senderAddress = $sender;
if (trim((string) ($formData['sender_point_id'] ?? '')) !== '') {
$senderAddress['point'] = trim((string) $formData['sender_point_id']);
}
$packageType = strtoupper(trim((string) ($formData['package_type'] ?? 'PACKAGE')));
$lengthCm = (float) ($formData['length_cm'] ?? $company['default_package_length_cm']);
$widthCm = (float) ($formData['width_cm'] ?? $company['default_package_width_cm']);
$heightCm = (float) ($formData['height_cm'] ?? $company['default_package_height_cm']);
$weightKg = (float) ($formData['weight_kg'] ?? $company['default_package_weight_kg']);
$labelFormat = strtoupper(trim((string) ($formData['label_format'] ?? $company['default_label_format'])));
if (!in_array($labelFormat, ['PDF', 'ZPL'], true)) {
$labelFormat = 'PDF';
}
$orderData = is_array($order['order'] ?? null) ? $order['order'] : [];
$sourceOrderId = trim((string) ($orderData['source_order_id'] ?? ''));
$commandId = $this->generateUuid();
$apiPayload = [
'commandId' => $commandId,
'input' => [
'deliveryMethodId' => $deliveryMethodId,
'sender' => $senderAddress,
'receiver' => $receiverAddress,
'referenceNumber' => $sourceOrderId !== '' ? $sourceOrderId : (string) $orderId,
'packages' => [[
'type' => $packageType,
'length' => ['value' => $lengthCm, 'unit' => 'CENTIMETER'],
'width' => ['value' => $widthCm, 'unit' => 'CENTIMETER'],
'height' => ['value' => $heightCm, 'unit' => 'CENTIMETER'],
'weight' => ['value' => $weightKg, 'unit' => 'KILOGRAMS'],
]],
'labelFormat' => $labelFormat,
],
];
$insuranceAmount = (float) ($formData['insurance_amount'] ?? 0);
if ($insuranceAmount > 0) {
$apiPayload['input']['insurance'] = [
'amount' => number_format($insuranceAmount, 2, '.', ''),
'currency' => strtoupper(trim((string) ($formData['insurance_currency'] ?? 'PLN'))),
];
}
$codAmount = (float) ($formData['cod_amount'] ?? 0);
if ($codAmount > 0) {
// Allegro WZA manages COD funds internally - iban/ownerName are not accepted.
$apiPayload['input']['cashOnDelivery'] = [
'amount' => number_format($codAmount, 2, '.', ''),
'currency' => strtoupper(trim((string) ($formData['cod_currency'] ?? 'PLN'))),
];
}
$credentialsId = trim((string) ($formData['credentials_id'] ?? ''));
if ($credentialsId !== '') {
$apiPayload['input']['credentialsId'] = $credentialsId;
}
$packageId = $this->packages->create([
'order_id' => $orderId,
'provider' => 'allegro_wza',
'delivery_method_id' => $deliveryMethodId,
'credentials_id' => $credentialsId !== '' ? $credentialsId : null,
'command_id' => $commandId,
'status' => 'pending',
'carrier_id' => trim((string) ($formData['carrier_id'] ?? '')),
'package_type' => $packageType,
'weight_kg' => $weightKg,
'length_cm' => $lengthCm,
'width_cm' => $widthCm,
'height_cm' => $heightCm,
'insurance_amount' => $insuranceAmount > 0 ? $insuranceAmount : null,
'insurance_currency' => $insuranceAmount > 0 ? strtoupper(trim((string) ($formData['insurance_currency'] ?? 'PLN'))) : null,
'cod_amount' => $codAmount > 0 ? $codAmount : null,
'cod_currency' => $codAmount > 0 ? strtoupper(trim((string) ($formData['cod_currency'] ?? 'PLN'))) : null,
'label_format' => $labelFormat,
'receiver_point_id' => trim((string) ($formData['receiver_point_id'] ?? '')),
'sender_point_id' => trim((string) ($formData['sender_point_id'] ?? '')),
'reference_number' => $sourceOrderId !== '' ? $sourceOrderId : (string) $orderId,
'payload_json' => $apiPayload,
]);
[$accessToken, $env] = $this->tokenManager->resolveToken();
try {
$response = $this->apiClient->createShipment($env, $accessToken, $apiPayload);
} catch (RuntimeException $exception) {
if (trim($exception->getMessage()) === 'ALLEGRO_HTTP_401') {
[$accessToken, $env] = $this->tokenManager->resolveToken();
$response = $this->apiClient->createShipment($env, $accessToken, $apiPayload);
} else {
$this->packages->update($packageId, [
'status' => 'error',
'error_message' => $exception->getMessage(),
]);
throw $exception;
}
}
$returnedCommandId = trim((string) ($response['commandId'] ?? $commandId));
$this->packages->update($packageId, [
'command_id' => $returnedCommandId,
'payload_json' => $response,
]);
return [
'package_id' => $packageId,
'command_id' => $returnedCommandId,
];
}
/**
* @return array<string, mixed>
*/
public function checkCreationStatus(int $packageId): array
{
$package = $this->packages->findById($packageId);
if ($package === null) {
throw new ShipmentException('Paczka nie znaleziona.');
}
$commandId = trim((string) ($package['command_id'] ?? ''));
if ($commandId === '') {
throw new ShipmentException('Brak command_id dla tej paczki.');
}
[$accessToken, $env] = $this->tokenManager->resolveToken();
$response = $this->apiClient->getShipmentCreationStatus($env, $accessToken, $commandId);
$status = strtoupper(trim((string) ($response['status'] ?? '')));
$shipmentId = trim((string) ($response['shipmentId'] ?? ''));
if ($status === 'SUCCESS' && $shipmentId !== '') {
$details = $this->apiClient->getShipmentDetails($env, $accessToken, $shipmentId);
$trackingNumber = trim((string) ($details['waybill'] ?? ''));
$carrierId = trim((string) ($package['carrier_id'] ?? ''));
if ($carrierId === '') {
$carrierId = trim((string) ($details['carrierId'] ?? ''));
}
$this->packages->update($packageId, [
'status' => 'created',
'shipment_id' => $shipmentId,
'tracking_number' => $trackingNumber !== '' ? $trackingNumber : null,
'carrier_id' => $carrierId !== '' ? $carrierId : ($package['carrier_id'] ?? null),
'payload_json' => $details,
]);
$this->pushWaybillToAllegroCheckoutForm(
(int) ($package['order_id'] ?? 0),
$trackingNumber,
$carrierId,
$accessToken,
$env
);
return [
'status' => 'created',
'shipment_id' => $shipmentId,
'tracking_number' => $trackingNumber,
];
}
if ($status === 'ERROR') {
$errors = is_array($response['errors'] ?? null) ? $response['errors'] : [];
$messages = [];
foreach ($errors as $err) {
if (is_array($err)) {
$messages[] = trim((string) ($err['message'] ?? ($err['userMessage'] ?? '')));
}
}
$errorMsg = implode('; ', array_filter($messages)) ?: 'Blad tworzenia przesylki.';
$this->packages->update($packageId, [
'status' => 'error',
'error_message' => $errorMsg,
'payload_json' => $response,
]);
return ['status' => 'error', 'error' => $errorMsg];
}
return ['status' => 'in_progress'];
}
/**
* @return array<string, mixed>
*/
public function downloadLabel(int $packageId, string $storagePath): array
{
$package = $this->packages->findById($packageId);
if ($package === null) {
throw new ShipmentException('Paczka nie znaleziona.');
}
$shipmentId = trim((string) ($package['shipment_id'] ?? ''));
if ($shipmentId === '') {
throw new ShipmentException('Przesylka nie zostala jeszcze utworzona.');
}
[$accessToken, $env] = $this->tokenManager->resolveToken();
$labelFormat = trim((string) ($package['label_format'] ?? 'PDF'));
$pageSize = $labelFormat === 'ZPL' ? 'A6' : 'A4';
$binary = $this->apiClient->getShipmentLabel($env, $accessToken, [$shipmentId], $pageSize);
$dir = rtrim($storagePath, '/\\') . '/labels';
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
$ext = $labelFormat === 'ZPL' ? 'zpl' : 'pdf';
$filename = 'label_' . $packageId . '_' . $shipmentId . '.' . $ext;
$filePath = $dir . '/' . $filename;
file_put_contents($filePath, $binary);
$updateFields = [
'status' => 'label_ready',
'label_path' => 'labels/' . $filename,
];
// Refresh tracking number if not yet saved (may not have been available at creation time).
if (trim((string) ($package['tracking_number'] ?? '')) === '') {
try {
$details = $this->apiClient->getShipmentDetails($env, $accessToken, $shipmentId);
$trackingNumber = trim((string) ($details['waybill'] ?? ''));
$carrierId = trim((string) ($package['carrier_id'] ?? ''));
if ($carrierId === '') {
$carrierId = trim((string) ($details['carrierId'] ?? ''));
}
if ($trackingNumber !== '') {
$updateFields['tracking_number'] = $trackingNumber;
if ($carrierId !== '') {
$updateFields['carrier_id'] = $carrierId;
}
$this->pushWaybillToAllegroCheckoutForm(
(int) ($package['order_id'] ?? 0),
$trackingNumber,
$carrierId,
$accessToken,
$env
);
}
} catch (Throwable) {
// non-critical - label is still saved
}
}
$this->packages->update($packageId, $updateFields);
return [
'label_path' => 'labels/' . $filename,
'full_path' => $filePath,
];
}
/**
* @param array<string, mixed>|null $orderDetails
* @param array<string, mixed> $formData
* @return array<string, mixed>
*/
private function buildReceiverAddress(?array $orderDetails, array $formData): array
{
$addresses = is_array($orderDetails['addresses'] ?? null) ? $orderDetails['addresses'] : [];
$deliveryAddr = null;
$customerAddr = null;
foreach ($addresses as $addr) {
$type = (string) ($addr['address_type'] ?? '');
if ($type === 'delivery') {
$deliveryAddr = $addr;
}
if ($type === 'customer') {
$customerAddr = $addr;
}
}
$addr = $deliveryAddr ?? $customerAddr ?? [];
$email = trim((string) ($formData['receiver_email'] ?? ($addr['email'] ?? '')));
$receiver = [
'name' => trim((string) ($formData['receiver_name'] ?? ($addr['name'] ?? ''))),
'street' => trim((string) ($formData['receiver_street'] ?? ($addr['street_name'] ?? ''))),
'city' => trim((string) ($formData['receiver_city'] ?? ($addr['city'] ?? ''))),
'postalCode' => trim((string) ($formData['receiver_postal_code'] ?? ($addr['zip_code'] ?? ''))),
'countryCode' => strtoupper(trim((string) ($formData['receiver_country_code'] ?? ($addr['country'] ?? 'PL')))),
'phone' => trim((string) ($formData['receiver_phone'] ?? ($addr['phone'] ?? ''))),
'email' => $email,
];
$buyerEmail = trim((string) ($customerAddr['email'] ?? $email));
if ($buyerEmail !== '') {
$receiver['hashedMail'] = hash('sha256', strtolower($buyerEmail));
}
$company = trim((string) ($formData['receiver_company'] ?? ($addr['company_name'] ?? '')));
if ($company !== '') {
$receiver['company'] = $company;
}
$pointId = trim((string) ($formData['receiver_point_id'] ?? ($addr['parcel_external_id'] ?? '')));
if ($pointId !== '') {
$receiver['point'] = $pointId;
}
return $receiver;
}
/**
* @param array<string, mixed> $sender
*/
private function validateSenderAddress(array $sender): void
{
$required = ['street', 'city', 'postalCode', 'phone', 'email'];
foreach ($required as $field) {
if (trim((string) ($sender[$field] ?? '')) === '') {
throw new IntegrationConfigException('Uzupelnij dane nadawcy w Ustawienia > Dane firmy (brak: ' . $field . ').');
}
}
$name = trim((string) ($sender['name'] ?? ''));
$company = trim((string) ($sender['company'] ?? ''));
if ($name === '' && $company === '') {
throw new IntegrationConfigException('Uzupelnij dane nadawcy w Ustawienia > Dane firmy (brak nazwy/firmy).');
}
}
private function pushWaybillToAllegroCheckoutForm(
int $orderId,
string $trackingNumber,
string $carrierId,
string $accessToken,
string $environment
): void {
if ($orderId <= 0) {
return;
}
$waybill = trim($trackingNumber);
if ($waybill === '') {
return;
}
$orderDetails = $this->ordersRepository->findDetails($orderId);
if ($orderDetails === null) {
return;
}
$order = is_array($orderDetails['order'] ?? null) ? $orderDetails['order'] : [];
$source = strtolower(trim((string) ($order['source'] ?? '')));
if ($source !== 'allegro') {
return;
}
$checkoutFormId = trim((string) ($order['source_order_id'] ?? ''));
if ($checkoutFormId === '') {
return;
}
$normalizedCarrierId = trim($carrierId);
if ($normalizedCarrierId === '') {
return;
}
$carrierName = (string) ($this->packages->resolveCarrierName('allegro_wza', $normalizedCarrierId) ?? '');
$carrierName = trim($carrierName);
if ($carrierName === '') {
$carrierName = $normalizedCarrierId;
}
try {
$this->apiClient->addShipmentToOrder(
$environment,
$accessToken,
$checkoutFormId,
$waybill,
$normalizedCarrierId,
$carrierName
);
} catch (RuntimeException $exception) {
if (trim($exception->getMessage()) !== 'ALLEGRO_HTTP_401') {
return;
}
try {
[$refreshedToken, $refreshedEnvironment] = $this->tokenManager->resolveToken();
$this->apiClient->addShipmentToOrder(
$refreshedEnvironment,
$refreshedToken,
$checkoutFormId,
$waybill,
$normalizedCarrierId,
$carrierName
);
} catch (Throwable) {
// non-critical - local shipment remains created
}
} catch (Throwable) {
// non-critical - local shipment remains created
}
}
private function generateUuid(): string
{
$data = random_bytes(16);
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
}