feat(128): polkurier shipment service + tracking + UI prepare

PolkurierApiClient rozszerzony do pelnego kontraktu (7 metod):
createShipment/getLabel/getStatus/cancelOrder/getAvailableCarriers/
getInpostParcelMachines/getCourierPoints. Wspolny call() parsuje
envelope {status, response}. Kontrakt zweryfikowany na oficjalnej
dokumentacji PDF v1.11.

PolkurierShipmentService (implements ShipmentProviderInterface)
orchestruje pelen flow: normalizeShipmentType (lowercase), split
ulicy, build recipient/sender/pickup, COD z bank account z
company_settings, extractOrderNumber/extractTrackingNumber
priorytetujace SDK Order entity (number, waybills[0].number).

PolkurierTrackingService (implements ShipmentTrackingInterface)
mapuje statusy O/P/A/WP/D/Z/W przez delivery_status_mappings.

UI panel polkurier w prepare.php z dynamiczna lista uslug z
available_carriers. Bez dedykowanego selektora punktu — operator
wpisuje receiver_point_id w istniejace pole w sekcji Adres odbiorcy.

Migracja 20260514_000115 seedujaca 7 wpisow delivery_status_mappings
z oficjalnej tabeli ORDER_STATUS (O/P/A/WP/D/Z/W).

Live test #114/#115 zakonczony sukcesem po 4 iteracjach
(ReferenceError -> uppercase shipmenttype -> orderno parsing ->
A4/A6 etykieta). Rozmiar etykiety A4/A6 sterowany w panelu klienta
polkurier.pl, NIE przez API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 12:56:36 +02:00
parent 3443879f59
commit c78ac335ee
19 changed files with 5011 additions and 102 deletions

View File

@@ -35,6 +35,8 @@ use App\Modules\Settings\EmailMailboxRepository;
use App\Modules\Settings\EmailTemplateRepository;
use App\Modules\Settings\InpostIntegrationRepository;
use App\Modules\Settings\IntegrationSecretCipher;
use App\Modules\Settings\PolkurierApiClient;
use App\Modules\Settings\PolkurierIntegrationRepository;
use App\Modules\Settings\ReceiptConfigRepository;
use App\Modules\Settings\ShopproApiClient;
use App\Modules\Settings\ShopproIntegrationsRepository;
@@ -50,6 +52,7 @@ use App\Modules\Shipments\AllegroTrackingService;
use App\Modules\Shipments\ApaczkaTrackingService;
use App\Modules\Shipments\DeliveryStatusMappingRepository;
use App\Modules\Shipments\InpostTrackingService;
use App\Modules\Shipments\PolkurierTrackingService;
use App\Modules\Shipments\ShipmentPackageRepository;
use App\Modules\Shipments\ShipmentTrackingRegistry;
use PDO;
@@ -173,6 +176,11 @@ final class CronHandlerFactory
new AllegroTrackingService(
new InpostIntegrationRepository($this->db, $this->integrationSecret)
),
new PolkurierTrackingService(
new PolkurierApiClient(),
new PolkurierIntegrationRepository($this->db, $this->integrationSecret),
new DeliveryStatusMappingRepository($this->db)
),
]),
new ShipmentPackageRepository($this->db),
$automationService,

View File

@@ -7,17 +7,17 @@ use App\Core\Http\SslCertificateResolver;
use RuntimeException;
/**
* polkurier.pl Web Service API client (Phase 127).
* polkurier.pl Web Service API client.
*
* Kontrakt API zweryfikowany na podstawie oficjalnego SDK (https://github.com/Polkurier/polkurier-sdk):
* - Base URL: https://api.polkurier.pl/ (single endpoint, brak path per metoda)
* - HTTP POST, Content-Type: application/json
* - Body: {"authorization": {"login": "...", "token": "..."}, "apimetod": "<method_name>", "data": {...}}
* - Test polaczenia: apimetod = "test_auth_api"
* - Sukces: top-level "status" === "success" (ResponseStatus::SUCCESS w SDK), authorization w polu "response"
* - HTTP POST, Content-Type: application/json (DOKLADNIE bez parametru charset)
* - Body: {"authorization": {"login", "token"}, "apimetod": "<method_name>", "data": {...}}
* - Sukces: top-level "status" === "success", tresc odpowiedzi w polu "response"
* - Blad: top-level "status" !== "success"; tresc bledu w polu "response" (string lub tablica)
*
* Stuby createShipment/getLabel/getStatus/cancelOrder dolozone w kolejnych fazach.
* Phase 127: testConnection (apimetod=test_auth_api) - zweryfikowany na zywym koncie.
* Phase 128: createShipment/getLabel/getStatus/cancelOrder/getAvailableCarriers/getParcelMachines.
*/
final class PolkurierApiClient
{
@@ -25,7 +25,7 @@ final class PolkurierApiClient
private const PLATFORM = 'orderPRO';
private const PLATFORM_VERSION = '1.0';
public function __construct(private readonly int $timeoutSeconds = 15)
public function __construct(private readonly int $timeoutSeconds = 30)
{
}
@@ -33,117 +33,280 @@ final class PolkurierApiClient
* @return array{ok: bool, http_code: int, message: string}
*/
public function testConnection(string $login, string $apiToken): array
{
try {
$response = $this->call('test_auth_api', [
'platform' => self::PLATFORM,
'platform_version' => self::PLATFORM_VERSION,
], $login, $apiToken);
} catch (RuntimeException $exception) {
return [
'ok' => false,
'http_code' => $this->lastHttpCode,
'message' => $exception->getMessage(),
];
}
$authorization = '';
if (is_array($response) && isset($response['authorization'])) {
$authorization = trim((string) $response['authorization']);
}
$message = $authorization !== ''
? 'Autoryzacja: ' . $authorization
: 'Polaczenie OK (HTTP ' . $this->lastHttpCode . ').';
return [
'ok' => true,
'http_code' => $this->lastHttpCode,
'message' => $message,
];
}
/**
* Zwraca liste dostepnych przewoznikow z konta polkurier.
* apimetod=available_carriers; brak wymaganych parametrow.
*
* @return array<int, array<string, mixed>> Lista przewoznikow z polami:
* - servicecode (string) - kod uslugi do uzycia w 'courier' przy create_order
* - name (string) - czytelna nazwa
* - additional_data (array) - dodatkowe wymagania
* - foreign_shipments (bool)
*/
public function getAvailableCarriers(string $login, string $apiToken): array
{
$response = $this->call('available_carriers', [], $login, $apiToken);
if (is_array($response)) {
// Polkurier moze zwracac liste bezposrednio jako array lub pod kluczem 'carriers'
if (isset($response[0]) && is_array($response[0])) {
return $response;
}
if (isset($response['carriers']) && is_array($response['carriers'])) {
return $response['carriers'];
}
}
return [];
}
/**
* Tworzy zamowienie (paczke) w polkurier.
* apimetod=create_order.
*
* @param array<string, mixed> $payload Struktura zgodna z SDK polkurier-sdk/CreateOrder:
* - shipmenttype (string, np. 'BOX')
* - courier (string, servicecode z available_carriers)
* - description (string)
* - sender (array: company, person, street, housenumber, flatnumber, postcode, city, email, phone, country, point_id)
* - recipient (array: same shape as sender)
* - packs (array of {length, width, height, weight, amount, type})
* - pickup (array: pickupdate, pickuptimefrom, pickuptimeto, nocourierorder)
* - COD (array: codtype, codamount, codbankaccount, return_cod) - optional
* - insurance (float) - optional
* - courierservice (array) - optional additional services
*
* @return array<string, mixed> Tresc z pola 'response' API. Typowe pola: 'orderno', 'cost', etc.
*/
public function createShipment(string $login, string $apiToken, array $payload): array
{
$response = $this->call('create_order', $payload, $login, $apiToken);
return is_array($response) ? $response : [];
}
/**
* Pobiera etykiete dla zamowienia.
* apimetod=get_label; wymagane: orderno (string lub array).
*
* @return array<string, mixed> Tresc z 'response'. Etykieta zwykle base64 w polu 'label' albo 'pdf'.
*/
/**
* Pobiera etykiete dla zamowienia.
* API polkurier (apimetod=get_label) przyjmuje wylacznie `orderno: Array<String>`.
* Rozmiar etykiety (A4 vs A6) jest sterowany w panelu klienta polkurier.pl
* (Ustawienia konta -> Preferencje etykiet), nie przez parametry API.
*
* @return array<string, mixed> Tresc z 'response'. Etykieta base64 w polu 'file'.
*/
public function getLabel(string $login, string $apiToken, string $orderno): array
{
$response = $this->call('get_label', [
'orderno' => [$orderno],
], $login, $apiToken);
return is_array($response) ? $response : [];
}
/**
* Pobiera status zamowienia.
* apimetod=get_status; wymagane: orderno.
*
* @return array<string, mixed> Tresc z 'response'. Pola: status, statuscode, statusdate, deliverydate, url.
*/
public function getStatus(string $login, string $apiToken, string $orderno): array
{
$response = $this->call('get_status', [
'orderno' => $orderno,
], $login, $apiToken);
return is_array($response) ? $response : [];
}
/**
* Anuluje zamowienie.
* apimetod=cancel_order; wymagane: orderno.
*
* @return array<string, mixed>
*/
public function cancelOrder(string $login, string $apiToken, string $orderno): array
{
$response = $this->call('cancel_order', [
'orderno' => $orderno,
], $login, $apiToken);
return is_array($response) ? $response : [];
}
/**
* Pobiera liste paczkomatow InPost.
* apimetod=inpost_parcel_machines (deprecated wg SDK, ale nadal dziala — alternatywa: get_courier_point).
*
* @return array<int, array<string, mixed>>
*/
public function getInpostParcelMachines(string $login, string $apiToken, bool $codAvailable = false): array
{
$response = $this->call('inpost_parcel_machines', [
'cod_available' => $codAvailable,
'parcel_send' => false,
], $login, $apiToken);
if (is_array($response)) {
if (isset($response[0]) && is_array($response[0])) {
return $response;
}
if (isset($response['machines']) && is_array($response['machines'])) {
return $response['machines'];
}
}
return [];
}
/**
* Generyczny lookup punktow odbioru per courier.
* apimetod=get_courier_point (SDK nowsze API).
*
* @return array<int, array<string, mixed>>
*/
public function getCourierPoints(string $login, string $apiToken, string $courier, ?string $postcode = null): array
{
$data = ['courier' => $courier];
if ($postcode !== null && $postcode !== '') {
$data['postcode'] = $postcode;
}
$response = $this->call('get_courier_point', $data, $login, $apiToken);
if (is_array($response)) {
if (isset($response[0]) && is_array($response[0])) {
return $response;
}
if (isset($response['points']) && is_array($response['points'])) {
return $response['points'];
}
}
return [];
}
/**
* Wspolny wrapper dla wszystkich apimetod.
* Sukces -> zwraca tresc pola 'response'. Blad -> rzuca RuntimeException z trescia z 'response'.
*
* @param array<string, mixed> $data
* @return mixed Tresc pola 'response' z API.
*/
private function call(string $apimetod, array $data, string $login, string $apiToken): mixed
{
$payload = [
'authorization' => [
'login' => trim($login),
'token' => trim($apiToken),
],
'apimetod' => 'test_auth_api',
'data' => [
'platform' => self::PLATFORM,
'platform_version' => self::PLATFORM_VERSION,
],
'apimetod' => $apimetod,
'data' => $data,
];
[$body, $httpCode, $curlError] = $this->postJson($payload);
$this->lastHttpCode = $httpCode;
if ($curlError !== null) {
return [
'ok' => false,
'http_code' => $httpCode,
'message' => 'Blad polaczenia: ' . $curlError,
];
throw new RuntimeException('Blad polaczenia z polkurier: ' . $curlError);
}
$decoded = json_decode(ltrim($body, "\xEF\xBB\xBF \t\n\r\0\x0B"), true);
if (!is_array($decoded)) {
return [
'ok' => false,
'http_code' => $httpCode,
'message' => 'Niepoprawna odpowiedz JSON polkurier: ' . substr(trim(strip_tags($body)), 0, 180),
];
$snippet = substr(trim(strip_tags($body)), 0, 240);
throw new RuntimeException('Niepoprawna odpowiedz JSON polkurier (HTTP ' . $httpCode . '): ' . $snippet);
}
$status = strtolower(trim((string) ($decoded['status'] ?? '')));
$responseField = $decoded['response'] ?? null;
if ($httpCode >= 200 && $httpCode < 300 && $status === 'success') {
$authorization = '';
if (is_array($responseField)) {
$authorization = trim((string) ($responseField['authorization'] ?? ''));
}
$message = $authorization !== ''
? 'Autoryzacja: ' . $authorization
: 'Polaczenie OK (HTTP ' . $httpCode . ').';
return [
'ok' => true,
'http_code' => $httpCode,
'message' => $message,
];
if ($status === 'success' && $httpCode >= 200 && $httpCode < 300) {
return $responseField;
}
// Error path: w SDK polkuriera (PolkurierWebService.php) gdy status != 'success',
// tresc bledu jest rzucana z $response->get('response') — czyli pole 'response' z envelope JSON.
// Pole 'response' moze byc stringiem (tekst bledu), tablica (struktura) lub null.
$errorMessage = '';
// Error path
$errorMessage = $this->extractErrorMessage($responseField, $decoded, $status, $httpCode);
throw new RuntimeException($errorMessage);
}
private int $lastHttpCode = 0;
/**
* @param mixed $responseField
* @param array<string, mixed> $decoded
*/
private function extractErrorMessage(mixed $responseField, array $decoded, string $status, int $httpCode): string
{
if (is_string($responseField)) {
$errorMessage = trim($responseField);
} elseif (is_array($responseField)) {
$errorMessage = trim((string) (
$msg = trim($responseField);
if ($msg !== '') {
return $msg;
}
}
if (is_array($responseField)) {
$msg = trim((string) (
$responseField['error_message']
?? $responseField['errorMessage']
?? $responseField['message']
?? $responseField['error']
?? ''
));
if ($errorMessage === '') {
if ($msg === '') {
$jsonDump = json_encode($responseField, JSON_UNESCAPED_UNICODE);
if (is_string($jsonDump)) {
$errorMessage = substr($jsonDump, 0, 240);
$msg = substr($jsonDump, 0, 280);
}
}
if ($msg !== '') {
return $msg;
}
}
if ($errorMessage === '') {
$errorMessage = trim((string) (
$decoded['error_message']
?? $decoded['errorMessage']
?? $decoded['message']
?? $decoded['error']
?? ''
));
$msg = trim((string) (
$decoded['error_message']
?? $decoded['errorMessage']
?? $decoded['message']
?? $decoded['error']
?? ''
));
if ($msg !== '') {
return $msg;
}
if ($errorMessage === '') {
$errorMessage = $status !== '' ? 'Status: ' . $status . ' (HTTP ' . $httpCode . ')' : 'HTTP ' . $httpCode;
}
return [
'ok' => false,
'http_code' => $httpCode,
'message' => $errorMessage,
];
}
public function createShipment(): never
{
throw new RuntimeException('PolkurierApiClient::createShipment not implemented in Phase 127.');
}
public function getLabel(): never
{
throw new RuntimeException('PolkurierApiClient::getLabel not implemented in Phase 127.');
}
public function getStatus(): never
{
throw new RuntimeException('PolkurierApiClient::getStatus not implemented in Phase 127.');
}
public function cancelOrder(): never
{
throw new RuntimeException('PolkurierApiClient::cancelOrder not implemented in Phase 127.');
return $status !== ''
? 'polkurier status: ' . $status . ' (HTTP ' . $httpCode . ')'
: 'polkurier HTTP ' . $httpCode;
}
/**

View File

@@ -554,6 +554,10 @@ final class DeliveryStatus
return 'https://allegro.pl/allegrodelivery/sledzenie-paczki?numer=' . $encoded;
}
if ($provider === 'polkurier') {
return 'https://polkurier.pl/sledz-paczke/' . $encoded;
}
return 'https://www.google.com/search?q=' . $encoded . '+sledzenie+przesylki';
}

View File

@@ -0,0 +1,760 @@
<?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\CompanySettingsRepository;
use App\Modules\Settings\PolkurierApiClient;
use App\Modules\Settings\PolkurierIntegrationRepository;
use Throwable;
/**
* polkurier.pl ShipmentProvider (Phase 128).
*
* Tworzy paczki, pobiera etykiety i wystawia dostepne uslugi przewoznicze przez API polkurier.
* Payload zgodny z SDK polkurier-sdk (zweryfikowany na Sender/Recipient/Pack/Pickup/COD entity klasach).
*/
final class PolkurierShipmentService implements ShipmentProviderInterface
{
/** @var array<int, array<string, mixed>>|null */
private ?array $carriersCache = null;
public function __construct(
private readonly PolkurierIntegrationRepository $integrationRepository,
private readonly PolkurierApiClient $apiClient,
private readonly ShipmentPackageRepository $packages,
private readonly CompanySettingsRepository $companySettings,
private readonly OrdersRepository $ordersRepository
) {
}
public function code(): string
{
return 'polkurier';
}
/**
* @return array<int, array<string, mixed>>
*/
public function getDeliveryServices(): array
{
if ($this->carriersCache !== null) {
return $this->carriersCache;
}
$credentials = $this->integrationRepository->getCredentials();
if ($credentials === null) {
return $this->carriersCache = [];
}
try {
$carriers = $this->apiClient->getAvailableCarriers(
$credentials['login'],
$credentials['api_token']
);
} catch (Throwable) {
return $this->carriersCache = [];
}
// Normalizacja: ujednolicony shape `{id, name, supports_pickup_point, foreign_shipments}`
$normalized = [];
foreach ($carriers as $carrier) {
if (!is_array($carrier)) {
continue;
}
$code = trim((string) ($carrier['servicecode'] ?? $carrier['code'] ?? ''));
$name = trim((string) ($carrier['name'] ?? $code));
if ($code === '') {
continue;
}
$supportsPoint = $this->detectPickupPointSupport($code, $carrier);
$normalized[] = [
'id' => $code,
'name' => $name !== '' ? $name : $code,
'supports_pickup_point' => $supportsPoint,
'point_courier' => $supportsPoint ? $this->resolvePointCourierKey($code) : null,
'foreign_shipments' => !empty($carrier['foreign_shipments']),
'raw' => $carrier,
];
}
usort($normalized, static fn ($a, $b) => strcasecmp((string) $a['name'], (string) $b['name']));
return $this->carriersCache = $normalized;
}
/**
* @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.');
}
$credentials = $this->requireCredentials();
$sender = $this->companySettings->getSenderAddress();
$this->validateSender($sender);
$courierCode = strtoupper(trim((string) (
$formData['service_code']
?? $formData['delivery_method_id']
?? ''
)));
if ($courierCode === '') {
throw new ShipmentException('Nie wybrano uslugi polkurier (servicecode).');
}
$orderData = is_array($order['order'] ?? null) ? $order['order'] : [];
$sourceOrderId = trim((string) ($orderData['source_order_id'] ?? ($orderData['external_order_number'] ?? '')));
$description = 'orderPRO ' . ($sourceOrderId !== '' ? $sourceOrderId : (string) $orderId);
$weightKg = max(0.001, (float) ($formData['weight_kg'] ?? 1.0));
$lengthCm = max(1.0, (float) ($formData['length_cm'] ?? 25.0));
$widthCm = max(1.0, (float) ($formData['width_cm'] ?? 20.0));
$heightCm = max(1.0, (float) ($formData['height_cm'] ?? 8.0));
$shipmentType = $this->normalizeShipmentType((string) ($formData['package_type'] ?? 'BOX'));
$receiverPointId = trim((string) ($formData['receiver_point_id'] ?? ''));
$recipient = $this->buildRecipient($order, $formData, $receiverPointId);
$senderPayload = $this->buildSenderPayload($sender);
$packs = [[
'length' => (int) round($lengthCm),
'width' => (int) round($widthCm),
'height' => (int) round($heightCm),
'weight' => round($weightKg, 3),
'amount' => 1,
'type' => $shipmentType,
]];
$pickup = $this->buildPickup($formData);
$apiPayload = [
'shipmenttype' => $shipmentType,
'courier' => $courierCode,
'description' => $description,
'sender' => $senderPayload,
'recipient' => $recipient,
'packs' => $packs,
'pickup' => $pickup,
];
$insurance = max(0.0, (float) ($formData['insurance_amount'] ?? 0));
if ($insurance > 0) {
$apiPayload['insurance'] = round($insurance, 2);
}
$cod = max(0.0, (float) ($formData['cod_amount'] ?? 0));
if ($cod > 0) {
$companySettings = $this->companySettings->getSettings();
$bankAccount = preg_replace('/[^0-9]/', '', (string) ($companySettings['bank_account'] ?? '')) ?? '';
if ($bankAccount === '') {
throw new ShipmentException('Przesylka COD wymaga numeru konta bankowego. Uzupelnij go w Ustawienia > Firma.');
}
$apiPayload['COD'] = [
'codtype' => 'transfer',
'codamount' => round($cod, 2),
'codbankaccount' => $bankAccount,
'return_cod' => 'transfer',
];
}
$carrierLabel = $this->resolveCarrierLabel($courierCode);
$labelFormat = strtoupper(trim((string) ($formData['label_format'] ?? $credentials['default_label_format'] ?? 'PDF')));
if (!in_array($labelFormat, ['PDF', 'ZPL', 'EPL'], true)) {
$labelFormat = 'PDF';
}
$packageId = $this->packages->create([
'order_id' => $orderId,
'provider' => 'polkurier',
'delivery_method_id' => $courierCode,
'credentials_id' => null,
'command_id' => null,
'status' => 'pending',
'carrier_id' => $carrierLabel,
'package_type' => $shipmentType,
'weight_kg' => $weightKg,
'length_cm' => $lengthCm,
'width_cm' => $widthCm,
'height_cm' => $heightCm,
'insurance_amount' => $insurance > 0 ? $insurance : null,
'insurance_currency' => 'PLN',
'cod_amount' => $cod > 0 ? $cod : null,
'cod_currency' => $cod > 0 ? 'PLN' : null,
'label_format' => $labelFormat,
'receiver_point_id' => $receiverPointId,
'sender_point_id' => trim((string) ($formData['sender_point_id'] ?? '')),
'reference_number' => $sourceOrderId !== '' ? $sourceOrderId : (string) $orderId,
'payload_json' => $apiPayload,
]);
try {
$response = $this->apiClient->createShipment(
$credentials['login'],
$credentials['api_token'],
$apiPayload
);
} catch (Throwable $exception) {
$message = 'polkurier create_order: ' . $exception->getMessage();
$this->packages->update($packageId, [
'status' => 'error',
'error_message' => $message,
]);
throw new ShipmentException($message, 0, $exception);
}
$orderno = $this->extractOrderNumber($response);
$tracking = $this->extractTrackingNumber($response, $orderno);
if ($orderno === '') {
// Diagnostyka — polkurier zwrocil odpowiedz ale bez rozpoznawalnego pola order number.
// Zapisujemy fragment odpowiedzi do error_message zeby operator/dev zobaczyl shape.
$dump = json_encode($response, JSON_UNESCAPED_UNICODE);
if (is_string($dump)) {
$dump = substr($dump, 0, 400);
} else {
$dump = '(brak czytelnej odpowiedzi)';
}
$this->packages->update($packageId, [
'status' => 'pending',
'error_message' => 'polkurier: utworzono w API ale nie znaleziono order number w odpowiedzi. Fragment: ' . $dump,
]);
} else {
$this->packages->update($packageId, [
'status' => 'created',
'shipment_id' => $orderno,
'command_id' => $orderno,
'tracking_number' => $tracking !== '' ? $tracking : null,
]);
}
// Sprobuj odrazu pobrac etykiete (synchronously). Niekrytyczne — operator moze pobrac pozniej.
if ($orderno !== '') {
try {
$this->downloadLabel($packageId, $this->resolveStorageRoot());
} catch (Throwable) {
// ignore — etykieta jeszcze nie gotowa po stronie polkuriera, operator klikni "Pobierz" pozniej
}
}
return [
'package_id' => $packageId,
'command_id' => $orderno !== '' ? $orderno : null,
'tracking_number' => $tracking,
];
}
/**
* @return array<string, mixed>
*/
public function checkCreationStatus(int $packageId): array
{
$package = $this->packages->findById($packageId);
if ($package === null) {
throw new ShipmentException('Paczka nie znaleziona.');
}
$orderno = trim((string) ($package['shipment_id'] ?? $package['command_id'] ?? ''));
if ($orderno === '') {
return ['status' => 'in_progress'];
}
$credentials = $this->requireCredentials();
try {
$statusResp = $this->apiClient->getStatus(
$credentials['login'],
$credentials['api_token'],
$orderno
);
} catch (Throwable) {
return [
'status' => 'created',
'shipment_id' => $orderno,
'tracking_number' => trim((string) ($package['tracking_number'] ?? '')),
];
}
return [
'status' => 'created',
'shipment_id' => $orderno,
'tracking_number' => trim((string) ($package['tracking_number'] ?? '')),
'raw_status' => trim((string) ($statusResp['status'] ?? $statusResp['statuscode'] ?? '')),
];
}
/**
* @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.');
}
$orderno = trim((string) ($package['shipment_id'] ?? $package['command_id'] ?? ''));
if ($orderno === '') {
throw new ShipmentException('Przesylka polkurier nie zostala jeszcze utworzona.');
}
$credentials = $this->requireCredentials();
$format = strtoupper(trim((string) ($package['label_format'] ?? 'PDF')));
if (!in_array($format, ['PDF', 'ZPL', 'EPL'], true)) {
$format = 'PDF';
}
try {
$response = $this->apiClient->getLabel(
$credentials['login'],
$credentials['api_token'],
$orderno
);
} catch (Throwable $exception) {
throw new ShipmentException('polkurier get_label: ' . $exception->getMessage(), 0, $exception);
}
$base64 = $this->extractLabelBase64($response);
if ($base64 === '') {
throw new ShipmentException('polkurier nie zwrocil danych etykiety.');
}
$binary = base64_decode($base64, true);
if (!is_string($binary) || $binary === '') {
throw new ShipmentException('Nie mozna odczytac etykiety polkurier.');
}
$extension = strtolower($format);
$dir = rtrim($storagePath, '/\\') . '/labels';
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
$filename = 'polkurier_' . $packageId . '_' . preg_replace('/[^A-Za-z0-9_-]/', '', $orderno) . '.' . $extension;
$filePath = $dir . '/' . $filename;
file_put_contents($filePath, $binary);
$this->packages->update($packageId, [
'status' => 'label_ready',
'label_path' => 'labels/' . $filename,
]);
return [
'label_path' => 'labels/' . $filename,
'full_path' => $filePath,
];
}
/**
* polkurier get_label zwraca base64 PDF pod kluczem 'file' (zweryfikowane w SDK GetLabel.php).
*/
private function extractLabelBase64(mixed $response): string
{
if (is_string($response)) {
return trim($response);
}
if (is_array($response)) {
foreach (['file', 'label', 'pdf', 'data', 'content', 'zpl', 'epl'] as $key) {
if (isset($response[$key]) && is_string($response[$key])) {
$candidate = trim((string) $response[$key]);
if ($candidate !== '') {
return $candidate;
}
}
}
if (isset($response[0]) && is_array($response[0])) {
return $this->extractLabelBase64($response[0]);
}
}
return '';
}
/**
* polkurier create_order zwraca Order entity. Numer zamowienia jest w polu 'number'
* (zmapowane z setNumber() w SDK Order.php). Fallbacki dla mozliwych wariantow.
*
* @param array<string, mixed> $response
*/
private function extractOrderNumber(array $response): string
{
// Czasami polkurier opakowuje w {order: {...}} lub zwraca liste
if (isset($response['order']) && is_array($response['order'])) {
$inner = $this->extractOrderNumber($response['order']);
if ($inner !== '') {
return $inner;
}
}
if (isset($response[0]) && is_array($response[0])) {
$inner = $this->extractOrderNumber($response[0]);
if ($inner !== '') {
return $inner;
}
}
foreach (['number', 'orderno', 'order_no', 'order_number', 'order_id', 'id'] as $key) {
$value = trim((string) ($response[$key] ?? ''));
if ($value !== '') {
return $value;
}
}
return '';
}
/**
* Waybill polkurier zazwyczaj w `waybills[0].number` (OrderWaybill entity).
* Fallbacki dla starszych wariantow odpowiedzi.
*
* @param array<string, mixed> $response
*/
private function extractTrackingNumber(array $response, string $orderno): string
{
if (isset($response['order']) && is_array($response['order'])) {
$inner = $this->extractTrackingNumber($response['order'], $orderno);
if ($inner !== '') {
return $inner;
}
}
$waybills = $response['waybills'] ?? $response['waybill'] ?? null;
if (is_array($waybills)) {
// Lista OrderWaybill
if (isset($waybills[0]) && is_array($waybills[0])) {
foreach (['number', 'waybillno', 'waybill_number', 'tracking_number'] as $key) {
$value = trim((string) ($waybills[0][$key] ?? ''));
if ($value !== '') {
return $value;
}
}
}
// Pojedynczy obiekt
foreach (['number', 'waybillno', 'waybill_number', 'tracking_number'] as $key) {
$value = trim((string) ($waybills[$key] ?? ''));
if ($value !== '') {
return $value;
}
}
}
foreach (['waybillno', 'waybill_number', 'parcel_number', 'tracking_number'] as $key) {
$value = trim((string) ($response[$key] ?? ''));
if ($value !== '') {
return $value;
}
}
return $orderno;
}
private function resolveStorageRoot(): string
{
$root = dirname(__DIR__, 3) . '/storage';
return is_dir($root) ? $root : sys_get_temp_dir();
}
/**
* @return array{login: string, api_token: string, default_label_format: string, integration_id: int}
*/
private function requireCredentials(): array
{
$credentials = $this->integrationRepository->getCredentials();
if ($credentials === null) {
throw new IntegrationConfigException('Brak konfiguracji polkurier (login/Token API/aktywnosc).');
}
return $credentials;
}
/**
* @param array<string, mixed> $sender
*/
private function validateSender(array $sender): void
{
$required = ['street', 'city', 'postalCode'];
foreach ($required as $key) {
if (trim((string) ($sender[$key] ?? '')) === '') {
throw new ShipmentException('Niekompletne dane nadawcy w Ustawieniach firmy (ulica/miasto/kod pocztowy).');
}
}
$name = trim((string) ($sender['name'] ?? ''));
$company = trim((string) ($sender['company'] ?? ''));
if ($name === '' && $company === '') {
throw new ShipmentException('Niekompletne dane nadawcy w Ustawieniach firmy (imie/nazwisko lub nazwa firmy).');
}
}
/**
* @param array<string, mixed> $sender
* @return array<string, mixed>
*/
private function buildSenderPayload(array $sender): array
{
$street = trim((string) ($sender['street'] ?? ''));
$parsed = $this->splitStreetAndNumber($street);
return [
'company' => trim((string) ($sender['company'] ?? '')),
'person' => trim((string) ($sender['contactPerson'] ?? $sender['name'] ?? '')),
'street' => $parsed['street'],
'housenumber' => $parsed['house'],
'flatnumber' => $parsed['flat'],
'postcode' => trim((string) ($sender['postalCode'] ?? '')),
'city' => trim((string) ($sender['city'] ?? '')),
'email' => trim((string) ($sender['email'] ?? '')),
'phone' => $this->normalizePhone((string) ($sender['phone'] ?? '')),
'country' => strtoupper(trim((string) ($sender['countryCode'] ?? 'PL'))) ?: 'PL',
];
}
/**
* @param array<string, mixed> $order
* @param array<string, mixed> $formData
* @return array<string, mixed>
*/
private function buildRecipient(array $order, array $formData, string $receiverPointId): array
{
$addresses = is_array($order['addresses'] ?? null) ? $order['addresses'] : [];
$delivery = [];
$customer = [];
foreach ($addresses as $addr) {
$type = (string) ($addr['address_type'] ?? '');
if ($type === 'delivery') {
$delivery = is_array($addr) ? $addr : [];
} elseif ($type === 'customer') {
$customer = is_array($addr) ? $addr : [];
}
}
$name = $this->firstNonEmpty([
$formData['receiver_name'] ?? null,
$delivery['name'] ?? null,
$customer['name'] ?? null,
$delivery['company_name'] ?? null,
$customer['company_name'] ?? null,
'Klient',
]);
$company = $this->firstNonEmpty([
$formData['receiver_company'] ?? null,
$delivery['company_name'] ?? null,
$customer['company_name'] ?? null,
]);
$streetLine = $this->firstNonEmpty([
$formData['receiver_street'] ?? null,
$this->composeStreet($delivery),
$this->composeStreet($customer),
]);
$parsed = $this->splitStreetAndNumber($streetLine);
$postcode = $this->firstNonEmpty([
$formData['receiver_postal_code'] ?? null,
$delivery['zip_code'] ?? null,
$customer['zip_code'] ?? null,
]);
$city = $this->firstNonEmpty([
$formData['receiver_city'] ?? null,
$delivery['city'] ?? null,
$customer['city'] ?? null,
]);
$country = strtoupper($this->firstNonEmpty([
$formData['receiver_country_code'] ?? null,
$delivery['country'] ?? null,
$customer['country'] ?? null,
'PL',
]));
$phone = $this->firstNonEmpty([
$formData['receiver_phone'] ?? null,
$delivery['phone'] ?? null,
$customer['phone'] ?? null,
]);
$email = $this->firstNonEmpty([
$formData['receiver_email'] ?? null,
$delivery['email'] ?? null,
$customer['email'] ?? null,
]);
if ($name === '' || $postcode === '' || $city === '') {
throw new ShipmentException('Brak wymaganych danych adresowych odbiorcy (imie/kod pocztowy/miasto).');
}
if ($receiverPointId === '' && $parsed['street'] === '') {
throw new ShipmentException('Brak ulicy odbiorcy (wymagana dla uslug kurierskich).');
}
return [
'company' => $company,
'person' => $name,
'street' => $parsed['street'],
'housenumber' => $parsed['house'],
'flatnumber' => $parsed['flat'],
'postcode' => $postcode,
'city' => $city,
'email' => $email,
'phone' => $this->normalizePhone($phone),
'country' => $country !== '' ? $country : 'PL',
'point_id' => $receiverPointId,
];
}
/**
* @param array<string, mixed> $formData
* @return array<string, mixed>
*/
private function buildPickup(array $formData): array
{
$date = trim((string) ($formData['pickup_date'] ?? ''));
if ($date === '' || preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
$date = $this->nextBusinessDay();
}
$from = trim((string) ($formData['pickup_time_from'] ?? '10:00'));
$to = trim((string) ($formData['pickup_time_to'] ?? '16:00'));
$noCourierOrder = !empty($formData['no_courier_order']);
return [
'pickupdate' => $date,
'pickuptimefrom' => $from,
'pickuptimeto' => $to,
'nocourierorder' => $noCourierOrder,
];
}
private function nextBusinessDay(): string
{
$ts = time();
do {
$ts = strtotime('+1 day', $ts);
$dow = (int) date('N', $ts ?: time());
} while ($dow >= 6);
return date('Y-m-d', $ts ?: time());
}
/**
* @return array{street: string, house: string, flat: string}
*/
private function splitStreetAndNumber(string $streetLine): array
{
$street = trim($streetLine);
if ($street === '') {
return ['street' => '', 'house' => '', 'flat' => ''];
}
// Wzorce: "Marszalkowska 10/5", "Marszalkowska 10 m. 5", "Marszalkowska 10A"
if (preg_match('/^(.*?)\s+(\d+[A-Za-z]?)(?:\s*[\/\-]\s*|\s*m\.?\s*)?(\d+[A-Za-z]?)?$/u', $street, $matches) === 1) {
return [
'street' => trim((string) $matches[1]),
'house' => trim((string) ($matches[2] ?? '')),
'flat' => trim((string) ($matches[3] ?? '')),
];
}
return ['street' => $street, 'house' => '', 'flat' => ''];
}
/**
* @param array<string, mixed> $address
*/
private function composeStreet(array $address): string
{
$street = trim((string) ($address['street_name'] ?? ''));
$number = trim((string) ($address['street_number'] ?? ''));
if ($street === '') {
return '';
}
return $number !== '' ? trim($street . ' ' . $number) : $street;
}
/**
* @param array<int, mixed> $candidates
*/
private function firstNonEmpty(array $candidates): string
{
foreach ($candidates as $candidate) {
$value = trim((string) $candidate);
if ($value !== '') {
return $value;
}
}
return '';
}
private function normalizePhone(string $phone): string
{
$digits = preg_replace('/\D+/', '', $phone) ?? '';
if ($digits === '') {
return '';
}
// Polkurier akceptuje cyfry. Usun prefiks 48 jezeli jest podwojny.
if (str_starts_with($digits, '48') && strlen($digits) === 11) {
$digits = substr($digits, 2);
}
return $digits;
}
/**
* @param array<string, mixed> $carrier
*/
private function detectPickupPointSupport(string $code, array $carrier): bool
{
$haystack = strtolower($code . ' ' . trim((string) ($carrier['name'] ?? '')));
return str_contains($haystack, 'paczkomat')
|| str_contains($haystack, 'parcel')
|| str_contains($haystack, 'inpost')
|| str_contains($haystack, 'orlen')
|| str_contains($haystack, 'pocztex')
|| str_contains($haystack, 'kurier48')
|| str_contains($haystack, 'punkt');
}
private function resolvePointCourierKey(string $code): ?string
{
$lower = strtolower($code);
if (str_contains($lower, 'inpost') || str_contains($lower, 'paczkomat')) {
return 'inpost';
}
if (str_contains($lower, 'orlen')) {
return 'orlen';
}
if (str_contains($lower, 'pocztex')) {
return 'pocztex';
}
if (str_contains($lower, 'kurier48')) {
return 'kurier48';
}
return null;
}
/**
* polkurier API wymaga lowercase z dozwolonego zbioru:
* [box, envelope, palette, small_parcel, parcel_size_20].
* Mapuje istniejace orderPRO wartosci (PACKAGE/BOX/ENVELOPE/...) na format polkurier.
*/
private function normalizeShipmentType(string $input): string
{
$raw = strtolower(trim($input));
$allowed = ['box', 'envelope', 'palette', 'small_parcel', 'parcel_size_20'];
if (in_array($raw, $allowed, true)) {
return $raw;
}
$aliases = [
'package' => 'box',
'parcel' => 'box',
'paczka' => 'box',
'koperta' => 'envelope',
'paleta' => 'palette',
'mala_paczka' => 'small_parcel',
'small' => 'small_parcel',
];
return $aliases[$raw] ?? 'box';
}
private function resolveCarrierLabel(string $courierCode): string
{
foreach ($this->getDeliveryServices() as $service) {
if (strcasecmp((string) ($service['id'] ?? ''), $courierCode) === 0) {
return (string) ($service['name'] ?? $courierCode);
}
}
return $courierCode;
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Modules\Shipments;
use App\Modules\Settings\PolkurierApiClient;
use App\Modules\Settings\PolkurierIntegrationRepository;
use Throwable;
/**
* polkurier.pl Tracking service (Phase 128).
*
* Cron pinguje API polkurier po status zamowienia (apimetod=get_status) i zwraca dane do
* `ShipmentTrackingHandler`, ktory zapisuje znormalizowany status do `shipment_packages.delivery_status`
* przez `delivery_status_mappings(provider='polkurier')`. Wpisy mappings sa seedowane w migracji Phase 128
* po obserwacji realnych statusow z live testu na #114/#115.
*/
final class PolkurierTrackingService implements ShipmentTrackingInterface
{
public function __construct(
private readonly PolkurierApiClient $apiClient,
private readonly PolkurierIntegrationRepository $integrationRepository,
private readonly DeliveryStatusMappingRepository $mappingRepository
) {
}
public function supports(string $provider): bool
{
return strtolower(trim($provider)) === 'polkurier';
}
/**
* @param array<string, mixed> $package
* @return array{status: string, status_raw: string, description: string}|null
*/
public function getDeliveryStatus(array $package): ?array
{
$orderno = trim((string) ($package['shipment_id'] ?? $package['command_id'] ?? ''));
if ($orderno === '') {
return null;
}
$credentials = $this->resolveCredentials();
if ($credentials === null) {
return null;
}
try {
$response = $this->apiClient->getStatus($credentials['login'], $credentials['api_token'], $orderno);
} catch (Throwable) {
return null;
}
$rawStatus = $this->extractRawStatus($response);
$statusName = trim((string) (
$response['status']
?? $response['status_name']
?? $response['statusname']
?? $rawStatus
));
if ($rawStatus === '') {
return null;
}
$normalized = $this->normalizeStatus($rawStatus);
return [
'status' => $normalized,
'status_raw' => $rawStatus,
'description' => $statusName !== '' ? $statusName : $rawStatus,
];
}
/**
* @param array<string, mixed>|mixed $response
*/
private function extractRawStatus(mixed $response): string
{
if (!is_array($response)) {
return '';
}
// Polkurier potrafi zwracac liste statusow (per orderno z get_label-style query)
if (isset($response[0]) && is_array($response[0])) {
$response = $response[0];
}
$candidate = trim((string) (
$response['statuscode']
?? $response['status_code']
?? $response['statusCode']
?? $response['status']
?? ''
));
return $candidate;
}
private function normalizeStatus(string $rawStatus): string
{
try {
$overrides = $this->mappingRepository->getAllOverrides();
} catch (Throwable) {
$overrides = [];
}
return DeliveryStatus::normalizeWithOverrides('polkurier', $rawStatus, $overrides);
}
/**
* @return array{login: string, api_token: string, default_label_format: string, integration_id: int}|null
*/
private function resolveCredentials(): ?array
{
try {
return $this->integrationRepository->getCredentials();
} catch (Throwable) {
return null;
}
}
}

View File

@@ -89,6 +89,18 @@ final class ShipmentController
}
}
$polkurierServices = [];
$polkurierProvider = $this->providerRegistry->get('polkurier');
if ($polkurierProvider !== null) {
try {
$polkurierServices = $polkurierProvider->getDeliveryServices();
} catch (Throwable $exception) {
if ($deliveryServicesError === '') {
$deliveryServicesError = $exception->getMessage();
}
}
}
$inpostServices = array_values(array_filter(
$deliveryServices,
static fn(array $svc) => stripos((string) ($svc['carrierId'] ?? ''), 'inpost') !== false
@@ -142,6 +154,7 @@ final class ShipmentController
'company' => $company,
'deliveryServices' => $deliveryServices,
'apaczkaServices' => $apaczkaServices,
'polkurierServices' => $polkurierServices,
'deliveryServicesError' => $deliveryServicesError,
'existingPackages' => $existingPackages,
'flashSuccess' => $flashSuccess,
@@ -205,6 +218,10 @@ final class ShipmentController
'receiver_point_id' => (string) $request->input('receiver_point_id', ''),
'sender_point_id' => (string) $request->input('sender_point_id', ''),
'weekend_delivery' => (string) $request->input('weekend_delivery', ''),
'service_code' => (string) $request->input('service_code', ''),
'pickup_date' => (string) $request->input('pickup_date', ''),
'pickup_time_from' => (string) $request->input('pickup_time_from', ''),
'pickup_time_to' => (string) $request->input('pickup_time_to', ''),
]);
$packageId = (int) ($result['package_id'] ?? 0);
@@ -511,4 +528,5 @@ final class ShipmentController
$parcelName = trim((string) ($deliveryAddr['parcel_name'] ?? ''));
return $parcelId !== '' || $parcelName !== '';
}
}