Add Allegro shipment service and related components

- Implement AllegroShipmentService for managing shipment creation and status checks.
- Create ShipmentController to handle shipment preparation and label downloading.
- Introduce ShipmentPackageRepository for database interactions related to shipment packages.
- Add methods for retrieving delivery services, creating shipments, checking creation status, and downloading labels.
- Implement address validation and token management for Allegro API integration.
This commit is contained in:
2026-03-06 01:06:59 +01:00
parent 9df7a63244
commit 1b5e403c31
46 changed files with 6705 additions and 133 deletions

View File

@@ -0,0 +1,437 @@
<?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
{
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
) {
}
/**
* @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) {
$cod = [
'amount' => number_format($codAmount, 2, '.', ''),
'currency' => strtoupper(trim((string) ($formData['cod_currency'] ?? 'PLN'))),
];
if (trim($company['bank_owner_name']) !== '') {
$cod['ownerName'] = $company['bank_owner_name'];
}
if (trim($company['bank_account']) !== '') {
$cod['iban'] = $company['bank_account'];
}
$apiPayload['input']['cashOnDelivery'] = $cod;
}
$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);
$relativePath = 'labels/' . $filename;
$this->packages->update($packageId, [
'status' => 'label_ready',
'label_path' => $relativePath,
]);
return [
'label_path' => $relativePath,
'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));
}
}