Files
orderPRO/src/Modules/Shipments/AllegroShipmentService.php
Jacek Pyziak 2b12fde248 feat(shipments): add ShipmentProviderInterface and ShipmentProviderRegistry
- Introduced ShipmentProviderInterface to define the contract for shipment providers.
- Implemented ShipmentProviderRegistry to manage and retrieve shipment providers.
- Added a new tool for probing Apaczka order_send payload variants, enhancing debugging capabilities.
2026-03-08 23:45:10 +01:00

451 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Modules\Shipments;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\AllegroApiClient;
use App\Modules\Settings\AllegroIntegrationRepository;
use App\Modules\Settings\AllegroOAuthClient;
use App\Modules\Settings\CompanySettingsRepository;
use DateInterval;
use DateTimeImmutable;
use RuntimeException;
use Throwable;
final class AllegroShipmentService implements ShipmentProviderInterface
{
public function __construct(
private readonly AllegroIntegrationRepository $integrationRepository,
private readonly AllegroOAuthClient $oauthClient,
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->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 RuntimeException('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 RuntimeException('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->resolveToken();
try {
$response = $this->apiClient->createShipment($env, $accessToken, $apiPayload);
} catch (RuntimeException $exception) {
if (trim($exception->getMessage()) === 'ALLEGRO_HTTP_401') {
[$accessToken, $env] = $this->forceRefreshToken();
$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 RuntimeException('Paczka nie znaleziona.');
}
$commandId = trim((string) ($package['command_id'] ?? ''));
if ($commandId === '') {
throw new RuntimeException('Brak command_id dla tej paczki.');
}
[$accessToken, $env] = $this->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'] ?? ''));
$this->packages->update($packageId, [
'status' => 'created',
'shipment_id' => $shipmentId,
'tracking_number' => $trackingNumber !== '' ? $trackingNumber : null,
'payload_json' => $details,
]);
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 RuntimeException('Paczka nie znaleziona.');
}
$shipmentId = trim((string) ($package['shipment_id'] ?? ''));
if ($shipmentId === '') {
throw new RuntimeException('Przesylka nie zostala jeszcze utworzona.');
}
[$accessToken, $env] = $this->resolveToken();
$labelFormat = trim((string) ($package['label_format'] ?? 'PDF'));
$pageSize = $labelFormat === 'ZPL' ? 'A6' : 'A6';
$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'] ?? ''));
if ($trackingNumber !== '') {
$updateFields['tracking_number'] = $trackingNumber;
}
} 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 RuntimeException('Uzupelnij dane nadawcy w Ustawienia > Dane firmy (brak: ' . $field . ').');
}
}
$name = trim((string) ($sender['name'] ?? ''));
$company = trim((string) ($sender['company'] ?? ''));
if ($name === '' && $company === '') {
throw new RuntimeException('Uzupelnij dane nadawcy w Ustawienia > Dane firmy (brak nazwy/firmy).');
}
}
/**
* @return array{0: string, 1: string}
*/
private function resolveToken(): array
{
$oauth = $this->integrationRepository->getTokenCredentials();
if ($oauth === null) {
throw new RuntimeException('Brak polaczenia OAuth Allegro. Polacz konto w Ustawieniach.');
}
$env = (string) ($oauth['environment'] ?? 'sandbox');
$accessToken = trim((string) ($oauth['access_token'] ?? ''));
$tokenExpiresAt = trim((string) ($oauth['token_expires_at'] ?? ''));
if ($accessToken === '') {
return $this->forceRefreshToken();
}
if ($tokenExpiresAt !== '') {
try {
$expiresAt = new DateTimeImmutable($tokenExpiresAt);
if ($expiresAt <= (new DateTimeImmutable('now'))->add(new DateInterval('PT5M'))) {
return $this->forceRefreshToken();
}
} catch (Throwable) {
return $this->forceRefreshToken();
}
}
return [$accessToken, $env];
}
/**
* @return array{0: string, 1: string}
*/
private function forceRefreshToken(): array
{
$oauth = $this->integrationRepository->getTokenCredentials();
if ($oauth === null) {
throw new RuntimeException('Brak danych OAuth Allegro.');
}
$token = $this->oauthClient->refreshAccessToken(
(string) ($oauth['environment'] ?? 'sandbox'),
(string) ($oauth['client_id'] ?? ''),
(string) ($oauth['client_secret'] ?? ''),
(string) ($oauth['refresh_token'] ?? '')
);
$expiresAt = null;
$expiresIn = max(0, (int) ($token['expires_in'] ?? 0));
if ($expiresIn > 0) {
$expiresAt = (new DateTimeImmutable('now'))
->add(new DateInterval('PT' . $expiresIn . 'S'))
->format('Y-m-d H:i:s');
}
$refreshToken = trim((string) ($token['refresh_token'] ?? ''));
if ($refreshToken === '') {
$refreshToken = (string) ($oauth['refresh_token'] ?? '');
}
$this->integrationRepository->saveTokens(
(string) ($token['access_token'] ?? ''),
$refreshToken,
(string) ($token['token_type'] ?? ''),
(string) ($token['scope'] ?? ''),
$expiresAt
);
$updated = $this->integrationRepository->getTokenCredentials();
$newToken = trim((string) ($updated['access_token'] ?? ''));
if ($newToken === '') {
throw new RuntimeException('Nie udalo sie odswiezyc tokenu Allegro.');
}
return [$newToken, (string) ($updated['environment'] ?? 'sandbox')];
}
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));
}
}