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
475 lines
18 KiB
PHP
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));
|
|
}
|
|
} |