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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
760
src/Modules/Shipments/PolkurierShipmentService.php
Normal file
760
src/Modules/Shipments/PolkurierShipmentService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
120
src/Modules/Shipments/PolkurierTrackingService.php
Normal file
120
src/Modules/Shipments/PolkurierTrackingService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 !== '';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user