- 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.
451 lines
17 KiB
PHP
451 lines
17 KiB
PHP
<?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));
|
||
}
|
||
}
|