feat(27-shipment-tracking-backend): infrastruktura sledzenia przesylek — statusy, tracking services, cron handler
Dwupoziomowy system statusow dostawy (normalized + raw z API), implementacje trackingu dla InPost ShipX, Apaczka i Allegro WZA, cron handler odpytujacy aktywne przesylki co 15 minut. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,14 @@ use App\Modules\Settings\ShopproProductImageResolver;
|
||||
use App\Modules\Settings\ShopproPaymentStatusSyncService;
|
||||
use App\Modules\Settings\ShopproStatusMappingRepository;
|
||||
use App\Modules\Settings\ShopproStatusSyncService;
|
||||
use App\Modules\Shipments\AllegroTrackingService;
|
||||
use App\Modules\Shipments\ApaczkaTrackingService;
|
||||
use App\Modules\Shipments\InpostTrackingService;
|
||||
use App\Modules\Shipments\ShipmentPackageRepository;
|
||||
use App\Modules\Shipments\ShipmentTrackingRegistry;
|
||||
use App\Modules\Settings\ApaczkaApiClient;
|
||||
use App\Modules\Settings\ApaczkaIntegrationRepository;
|
||||
use App\Modules\Settings\InpostIntegrationRepository;
|
||||
use PDO;
|
||||
|
||||
final class CronHandlerFactory
|
||||
@@ -102,6 +110,22 @@ final class CronHandlerFactory
|
||||
'shoppro_payment_status_sync' => new ShopproPaymentStatusSyncHandler(
|
||||
$shopproPaymentSyncService
|
||||
),
|
||||
'shipment_tracking_sync' => new ShipmentTrackingHandler(
|
||||
new ShipmentTrackingRegistry([
|
||||
new InpostTrackingService(
|
||||
new InpostIntegrationRepository($this->db, $this->integrationSecret)
|
||||
),
|
||||
new ApaczkaTrackingService(
|
||||
new ApaczkaApiClient(),
|
||||
new ApaczkaIntegrationRepository($this->db, $this->integrationSecret)
|
||||
),
|
||||
new AllegroTrackingService(
|
||||
$apiClient,
|
||||
$tokenManager
|
||||
),
|
||||
]),
|
||||
new ShipmentPackageRepository($this->db)
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
59
src/Modules/Cron/ShipmentTrackingHandler.php
Normal file
59
src/Modules/Cron/ShipmentTrackingHandler.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Cron;
|
||||
|
||||
use App\Modules\Shipments\ShipmentPackageRepository;
|
||||
use App\Modules\Shipments\ShipmentTrackingRegistry;
|
||||
use Throwable;
|
||||
|
||||
final class ShipmentTrackingHandler
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ShipmentTrackingRegistry $registry,
|
||||
private readonly ShipmentPackageRepository $repository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function handle(array $_payload): array
|
||||
{
|
||||
$packages = $this->repository->findActiveForTracking();
|
||||
$total = count($packages);
|
||||
$updated = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ($packages as $package) {
|
||||
$provider = trim((string) ($package['provider'] ?? ''));
|
||||
$packageId = (int) ($package['id'] ?? 0);
|
||||
|
||||
$service = $this->registry->getForProvider($provider);
|
||||
if ($service === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $service->getDeliveryStatus($package);
|
||||
if ($result !== null) {
|
||||
$this->repository->updateDeliveryStatus(
|
||||
$packageId,
|
||||
$result['status'],
|
||||
$result['status_raw']
|
||||
);
|
||||
$updated++;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'updated' => $updated,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
}
|
||||
53
src/Modules/Shipments/AllegroTrackingService.php
Normal file
53
src/Modules/Shipments/AllegroTrackingService.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Shipments;
|
||||
|
||||
use App\Modules\Settings\AllegroApiClient;
|
||||
use App\Modules\Settings\AllegroTokenManager;
|
||||
use Throwable;
|
||||
|
||||
final class AllegroTrackingService implements ShipmentTrackingInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AllegroApiClient $apiClient,
|
||||
private readonly AllegroTokenManager $tokenManager
|
||||
) {
|
||||
}
|
||||
|
||||
public function supports(string $provider): bool
|
||||
{
|
||||
return $provider === 'allegro_wza';
|
||||
}
|
||||
|
||||
public function getDeliveryStatus(array $package): ?array
|
||||
{
|
||||
$shipmentId = trim((string) ($package['shipment_id'] ?? ''));
|
||||
if ($shipmentId === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->fetchStatus($shipmentId);
|
||||
}
|
||||
|
||||
private function fetchStatus(string $shipmentId): ?array
|
||||
{
|
||||
try {
|
||||
[$accessToken, $env] = $this->tokenManager->resolveToken();
|
||||
$details = $this->apiClient->getShipmentDetails($env, $accessToken, $shipmentId);
|
||||
|
||||
$rawStatus = strtoupper(trim((string) ($details['status'] ?? '')));
|
||||
if ($rawStatus === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => DeliveryStatus::normalize('allegro_wza', $rawStatus),
|
||||
'status_raw' => $rawStatus,
|
||||
'description' => DeliveryStatus::description('allegro_wza', $rawStatus),
|
||||
];
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
77
src/Modules/Shipments/ApaczkaTrackingService.php
Normal file
77
src/Modules/Shipments/ApaczkaTrackingService.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Shipments;
|
||||
|
||||
use App\Modules\Settings\ApaczkaApiClient;
|
||||
use App\Modules\Settings\ApaczkaIntegrationRepository;
|
||||
use Throwable;
|
||||
|
||||
final class ApaczkaTrackingService implements ShipmentTrackingInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ApaczkaApiClient $apiClient,
|
||||
private readonly ApaczkaIntegrationRepository $integrationRepository
|
||||
) {
|
||||
}
|
||||
|
||||
public function supports(string $provider): bool
|
||||
{
|
||||
return $provider === 'apaczka';
|
||||
}
|
||||
|
||||
public function getDeliveryStatus(array $package): ?array
|
||||
{
|
||||
$shipmentId = trim((string) ($package['shipment_id'] ?? ''));
|
||||
if ($shipmentId === '' || !ctype_digit($shipmentId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$credentials = $this->resolveCredentials();
|
||||
if ($credentials === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->fetchStatus($shipmentId, $credentials[0], $credentials[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}|null
|
||||
*/
|
||||
private function resolveCredentials(): ?array
|
||||
{
|
||||
try {
|
||||
$credentials = $this->integrationRepository->getApiCredentials();
|
||||
if ($credentials === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$appId = trim((string) ($credentials['app_id'] ?? ''));
|
||||
$appSecret = trim((string) ($credentials['app_secret'] ?? ''));
|
||||
|
||||
return ($appId !== '' && $appSecret !== '') ? [$appId, $appSecret] : null;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function fetchStatus(string $shipmentId, string $appId, string $appSecret): ?array
|
||||
{
|
||||
try {
|
||||
$details = $this->apiClient->getOrderDetails($appId, $appSecret, (int) $shipmentId);
|
||||
$order = is_array($details['response']['order'] ?? null) ? $details['response']['order'] : [];
|
||||
$rawStatus = (string) ($order['status'] ?? '');
|
||||
if ($rawStatus === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => DeliveryStatus::normalize('apaczka', $rawStatus),
|
||||
'status_raw' => $rawStatus,
|
||||
'description' => DeliveryStatus::description('apaczka', $rawStatus),
|
||||
];
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
203
src/Modules/Shipments/DeliveryStatus.php
Normal file
203
src/Modules/Shipments/DeliveryStatus.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Shipments;
|
||||
|
||||
final class DeliveryStatus
|
||||
{
|
||||
public const UNKNOWN = 'unknown';
|
||||
public const CREATED = 'created';
|
||||
public const CONFIRMED = 'confirmed';
|
||||
public const IN_TRANSIT = 'in_transit';
|
||||
public const OUT_FOR_DELIVERY = 'out_for_delivery';
|
||||
public const READY_FOR_PICKUP = 'ready_for_pickup';
|
||||
public const DELIVERED = 'delivered';
|
||||
public const RETURNED = 'returned';
|
||||
public const CANCELLED = 'cancelled';
|
||||
public const PROBLEM = 'problem';
|
||||
|
||||
public const TERMINAL_STATUSES = [
|
||||
self::DELIVERED,
|
||||
self::RETURNED,
|
||||
self::CANCELLED,
|
||||
];
|
||||
|
||||
public const LABEL_PL = [
|
||||
self::UNKNOWN => 'Nieznany',
|
||||
self::CREATED => 'Utworzona',
|
||||
self::CONFIRMED => 'Potwierdzona',
|
||||
self::IN_TRANSIT => 'W tranzycie',
|
||||
self::OUT_FOR_DELIVERY => 'W doręczeniu',
|
||||
self::READY_FOR_PICKUP => 'Gotowa do odbioru',
|
||||
self::DELIVERED => 'Doręczona',
|
||||
self::RETURNED => 'Zwrócona',
|
||||
self::CANCELLED => 'Anulowana',
|
||||
self::PROBLEM => 'Problem',
|
||||
];
|
||||
|
||||
private const INPOST_MAP = [
|
||||
'created' => self::CREATED,
|
||||
'offers_prepared' => self::CREATED,
|
||||
'offer_selected' => self::CREATED,
|
||||
'confirmed' => self::CONFIRMED,
|
||||
'dispatched' => self::CONFIRMED,
|
||||
'collected' => self::IN_TRANSIT,
|
||||
'taken_by_courier' => self::IN_TRANSIT,
|
||||
'adopted_at_source_branch' => self::IN_TRANSIT,
|
||||
'adopted_at_sorting_center' => self::IN_TRANSIT,
|
||||
'sent_from_sorting_center' => self::IN_TRANSIT,
|
||||
'adopted_at_target_sorting_center' => self::IN_TRANSIT,
|
||||
'sent_from_target_sorting_center' => self::IN_TRANSIT,
|
||||
'adopted_at_target_branch' => self::IN_TRANSIT,
|
||||
'out_for_delivery' => self::OUT_FOR_DELIVERY,
|
||||
'ready_to_pickup' => self::READY_FOR_PICKUP,
|
||||
'ready_to_pickup_from_branch' => self::READY_FOR_PICKUP,
|
||||
'ready_to_pickup_from_pok' => self::READY_FOR_PICKUP,
|
||||
'stack_in_box_machine' => self::READY_FOR_PICKUP,
|
||||
'stack_in_customer_service_point' => self::READY_FOR_PICKUP,
|
||||
'delivered' => self::DELIVERED,
|
||||
'claimed' => self::DELIVERED,
|
||||
'returned_to_sender' => self::RETURNED,
|
||||
'undelivered' => self::RETURNED,
|
||||
'undelivered_wrong_address' => self::RETURNED,
|
||||
'undelivered_incomplete_address' => self::RETURNED,
|
||||
'undelivered_unknown_recipient' => self::RETURNED,
|
||||
'undelivered_cod_cash_receiver' => self::RETURNED,
|
||||
'cancelled' => self::CANCELLED,
|
||||
'expired' => self::CANCELLED,
|
||||
'avizo' => self::PROBLEM,
|
||||
'pickup_time_expired' => self::PROBLEM,
|
||||
'stack_parcel_pickup_time_expired' => self::PROBLEM,
|
||||
'missing' => self::PROBLEM,
|
||||
'delay_in_delivery' => self::PROBLEM,
|
||||
'oversized' => self::PROBLEM,
|
||||
'pickup_reminder_sent' => self::READY_FOR_PICKUP,
|
||||
'pickup_reminder_sent_address' => self::READY_FOR_PICKUP,
|
||||
'readdressed' => self::IN_TRANSIT,
|
||||
'redirect_to_box' => self::IN_TRANSIT,
|
||||
];
|
||||
|
||||
private const INPOST_DESCRIPTIONS = [
|
||||
'created' => 'Przesyłka utworzona',
|
||||
'offers_prepared' => 'Oferty cenowe przygotowane',
|
||||
'offer_selected' => 'Oferta wybrana',
|
||||
'confirmed' => 'Przesyłka potwierdzona',
|
||||
'dispatched' => 'Przesyłka nadana',
|
||||
'collected' => 'Odebrana od nadawcy',
|
||||
'taken_by_courier' => 'Odebrana przez kuriera',
|
||||
'adopted_at_source_branch' => 'Przyjęta w oddziale źródłowym',
|
||||
'adopted_at_sorting_center' => 'Przyjęta w centrum sortowania',
|
||||
'sent_from_sorting_center' => 'Wysłana z centrum sortowania',
|
||||
'adopted_at_target_sorting_center' => 'Przyjęta w docelowym centrum sortowania',
|
||||
'sent_from_target_sorting_center' => 'Wysłana z docelowego centrum sortowania',
|
||||
'adopted_at_target_branch' => 'Przyjęta w oddziale docelowym',
|
||||
'out_for_delivery' => 'W drodze do odbiorcy',
|
||||
'ready_to_pickup' => 'Gotowa do odbioru w paczkomacie',
|
||||
'ready_to_pickup_from_branch' => 'Gotowa do odbioru z oddziału',
|
||||
'ready_to_pickup_from_pok' => 'Gotowa do odbioru z POK',
|
||||
'stack_in_box_machine' => 'Umieszczona w paczkomacie',
|
||||
'stack_in_customer_service_point' => 'Umieszczona w punkcie obsługi',
|
||||
'delivered' => 'Doręczona',
|
||||
'claimed' => 'Odebrana po awizo',
|
||||
'returned_to_sender' => 'Zwrócona do nadawcy',
|
||||
'undelivered' => 'Niedoręczona',
|
||||
'undelivered_wrong_address' => 'Niedoręczona — błędny adres',
|
||||
'undelivered_incomplete_address' => 'Niedoręczona — niepełny adres',
|
||||
'undelivered_unknown_recipient' => 'Niedoręczona — nieznany odbiorca',
|
||||
'undelivered_cod_cash_receiver' => 'Niedoręczona — problem z pobraniem',
|
||||
'cancelled' => 'Anulowana',
|
||||
'expired' => 'Wygasła',
|
||||
'avizo' => 'Awizowana',
|
||||
'pickup_time_expired' => 'Czas odbioru upłynął',
|
||||
'stack_parcel_pickup_time_expired' => 'Czas odbioru ze stack upłynął',
|
||||
'missing' => 'Przesyłka zagubiona',
|
||||
'delay_in_delivery' => 'Opóźnienie w dostawie',
|
||||
'oversized' => 'Przesyłka ponadgabarytowa',
|
||||
'pickup_reminder_sent' => 'Wysłano przypomnienie o odbiorze',
|
||||
'pickup_reminder_sent_address' => 'Przypomnienie wysłane na adres',
|
||||
'readdressed' => 'Przekierowana na inny adres',
|
||||
'redirect_to_box' => 'Przekierowana do paczkomatu',
|
||||
];
|
||||
|
||||
private const APACZKA_MAP = [
|
||||
'0' => self::CREATED,
|
||||
'1' => self::CONFIRMED,
|
||||
'2' => self::IN_TRANSIT,
|
||||
'3' => self::IN_TRANSIT,
|
||||
'4' => self::OUT_FOR_DELIVERY,
|
||||
'5' => self::DELIVERED,
|
||||
'6' => self::RETURNED,
|
||||
'7' => self::CANCELLED,
|
||||
'8' => self::PROBLEM,
|
||||
'9' => self::READY_FOR_PICKUP,
|
||||
'10' => self::IN_TRANSIT,
|
||||
];
|
||||
|
||||
private const APACZKA_DESCRIPTIONS = [
|
||||
'0' => 'Oczekuje na przetworzenie',
|
||||
'1' => 'Zamówienie potwierdzone',
|
||||
'2' => 'Odebrana przez kuriera',
|
||||
'3' => 'W transporcie',
|
||||
'4' => 'W doręczeniu',
|
||||
'5' => 'Doręczona',
|
||||
'6' => 'Zwrócona do nadawcy',
|
||||
'7' => 'Anulowana',
|
||||
'8' => 'Błąd zamówienia',
|
||||
'9' => 'Oczekuje na odbiór w punkcie',
|
||||
'10' => 'Przekierowana',
|
||||
];
|
||||
|
||||
private const ALLEGRO_MAP = [
|
||||
'NEW' => self::CREATED,
|
||||
'READY_TO_SHIP' => self::CONFIRMED,
|
||||
'IN_TRANSIT' => self::IN_TRANSIT,
|
||||
'DELIVERED' => self::DELIVERED,
|
||||
'CANCELLED' => self::CANCELLED,
|
||||
'ERROR' => self::PROBLEM,
|
||||
'RETURNED' => self::RETURNED,
|
||||
];
|
||||
|
||||
private const ALLEGRO_DESCRIPTIONS = [
|
||||
'NEW' => 'Przesyłka utworzona',
|
||||
'READY_TO_SHIP' => 'Etykieta wygenerowana, oczekuje na nadanie',
|
||||
'IN_TRANSIT' => 'Odebrana przez przewoźnika',
|
||||
'DELIVERED' => 'Doręczona',
|
||||
'CANCELLED' => 'Anulowana',
|
||||
'ERROR' => 'Błąd przetwarzania',
|
||||
'RETURNED' => 'Zwrócona do nadawcy',
|
||||
];
|
||||
|
||||
public static function normalize(string $provider, string $rawStatus): string
|
||||
{
|
||||
$map = match ($provider) {
|
||||
'inpost' => self::INPOST_MAP,
|
||||
'apaczka' => self::APACZKA_MAP,
|
||||
'allegro_wza' => self::ALLEGRO_MAP,
|
||||
default => [],
|
||||
};
|
||||
|
||||
return $map[$rawStatus] ?? self::UNKNOWN;
|
||||
}
|
||||
|
||||
public static function description(string $provider, string $rawStatus): string
|
||||
{
|
||||
$map = match ($provider) {
|
||||
'inpost' => self::INPOST_DESCRIPTIONS,
|
||||
'apaczka' => self::APACZKA_DESCRIPTIONS,
|
||||
'allegro_wza' => self::ALLEGRO_DESCRIPTIONS,
|
||||
default => [],
|
||||
};
|
||||
|
||||
return $map[$rawStatus] ?? $rawStatus;
|
||||
}
|
||||
|
||||
public static function label(string $status): string
|
||||
{
|
||||
return self::LABEL_PL[$status] ?? 'Nieznany';
|
||||
}
|
||||
|
||||
public static function isTerminal(string $status): bool
|
||||
{
|
||||
return in_array($status, self::TERMINAL_STATUSES, true);
|
||||
}
|
||||
}
|
||||
128
src/Modules/Shipments/InpostTrackingService.php
Normal file
128
src/Modules/Shipments/InpostTrackingService.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Shipments;
|
||||
|
||||
use App\Modules\Settings\InpostIntegrationRepository;
|
||||
use Throwable;
|
||||
|
||||
final class InpostTrackingService implements ShipmentTrackingInterface
|
||||
{
|
||||
private const API_BASE_PRODUCTION = 'https://api-shipx-pl.easypack24.net/v1';
|
||||
private const API_BASE_SANDBOX = 'https://sandbox-api-shipx-pl.easypack24.net/v1';
|
||||
|
||||
public function __construct(
|
||||
private readonly InpostIntegrationRepository $inpostRepository
|
||||
) {
|
||||
}
|
||||
|
||||
public function supports(string $provider): bool
|
||||
{
|
||||
return $provider === 'inpost';
|
||||
}
|
||||
|
||||
public function getDeliveryStatus(array $package): ?array
|
||||
{
|
||||
$shipmentId = trim((string) ($package['shipment_id'] ?? ''));
|
||||
if ($shipmentId === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->fetchStatus($shipmentId);
|
||||
}
|
||||
|
||||
private function fetchStatus(string $shipmentId): ?array
|
||||
{
|
||||
try {
|
||||
$token = $this->resolveToken();
|
||||
if ($token === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$settings = $this->inpostRepository->getSettings();
|
||||
$env = (string) ($settings['environment'] ?? 'sandbox');
|
||||
$url = $this->apiBaseUrl($env) . '/shipments/' . rawurlencode($shipmentId);
|
||||
|
||||
$response = $this->apiRequest($url, $token);
|
||||
$rawStatus = strtolower(trim((string) ($response['status'] ?? '')));
|
||||
|
||||
return $rawStatus !== '' ? [
|
||||
'status' => DeliveryStatus::normalize('inpost', $rawStatus),
|
||||
'status_raw' => $rawStatus,
|
||||
'description' => DeliveryStatus::description('inpost', $rawStatus),
|
||||
] : null;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveToken(): ?string
|
||||
{
|
||||
$token = $this->inpostRepository->getDecryptedToken();
|
||||
return ($token !== null && trim($token) !== '') ? trim($token) : null;
|
||||
}
|
||||
|
||||
private function apiBaseUrl(string $environment): string
|
||||
{
|
||||
return strtolower(trim($environment)) === 'production'
|
||||
? self::API_BASE_PRODUCTION
|
||||
: self::API_BASE_SANDBOX;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function apiRequest(string $url, string $token): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$opts = [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer ' . $token,
|
||||
'Accept: application/json',
|
||||
],
|
||||
];
|
||||
|
||||
$caPath = $this->getCaBundlePath();
|
||||
if ($caPath !== null) {
|
||||
$opts[CURLOPT_CAINFO] = $caPath;
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, $opts);
|
||||
$body = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$ch = null;
|
||||
|
||||
if ($body === false || $httpCode < 200 || $httpCode >= 300) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$json = json_decode((string) $body, true);
|
||||
return is_array($json) ? $json : [];
|
||||
}
|
||||
|
||||
private function getCaBundlePath(): ?string
|
||||
{
|
||||
$candidates = [
|
||||
(string) ($_ENV['CURL_CA_BUNDLE_PATH'] ?? ''),
|
||||
(string) ini_get('curl.cainfo'),
|
||||
'C:/xampp/apache/bin/curl-ca-bundle.crt',
|
||||
'C:/xampp/php/extras/ssl/cacert.pem',
|
||||
'/etc/ssl/certs/ca-certificates.crt',
|
||||
];
|
||||
foreach ($candidates as $path) {
|
||||
if ($path !== '' && is_file($path)) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ final class ShipmentPackageRepository
|
||||
|
||||
$allowedFields = [
|
||||
'shipment_id', 'tracking_number', 'status', 'command_id',
|
||||
'label_path', 'error_message',
|
||||
'label_path', 'error_message', 'delivery_status', 'delivery_status_raw',
|
||||
];
|
||||
|
||||
foreach ($allowedFields as $field) {
|
||||
@@ -139,6 +139,40 @@ final class ShipmentPackageRepository
|
||||
return (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function findActiveForTracking(): array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
"SELECT * FROM shipment_packages
|
||||
WHERE provider != 'manual'
|
||||
AND delivery_status NOT IN ('delivered', 'returned', 'cancelled')
|
||||
AND status IN ('created', 'label_ready')
|
||||
ORDER BY delivery_status_updated_at ASC
|
||||
LIMIT 50"
|
||||
);
|
||||
$statement->execute();
|
||||
return $statement->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
|
||||
public function updateDeliveryStatus(int $id, string $deliveryStatus, ?string $deliveryStatusRaw): void
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'UPDATE shipment_packages
|
||||
SET delivery_status = :delivery_status,
|
||||
delivery_status_raw = :delivery_status_raw,
|
||||
delivery_status_updated_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = :id'
|
||||
);
|
||||
$statement->execute([
|
||||
'id' => $id,
|
||||
'delivery_status' => $deliveryStatus,
|
||||
'delivery_status_raw' => $deliveryStatusRaw,
|
||||
]);
|
||||
}
|
||||
|
||||
public function findByCommandId(string $commandId): ?array
|
||||
{
|
||||
$statement = $this->pdo->prepare('SELECT * FROM shipment_packages WHERE command_id = :command_id LIMIT 1');
|
||||
|
||||
15
src/Modules/Shipments/ShipmentTrackingInterface.php
Normal file
15
src/Modules/Shipments/ShipmentTrackingInterface.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Shipments;
|
||||
|
||||
interface ShipmentTrackingInterface
|
||||
{
|
||||
public function supports(string $provider): bool;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $package Row from shipment_packages table
|
||||
* @return array{status: string, status_raw: string, description: string}|null Null on error/unavailable
|
||||
*/
|
||||
public function getDeliveryStatus(array $package): ?array;
|
||||
}
|
||||
29
src/Modules/Shipments/ShipmentTrackingRegistry.php
Normal file
29
src/Modules/Shipments/ShipmentTrackingRegistry.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Shipments;
|
||||
|
||||
final class ShipmentTrackingRegistry
|
||||
{
|
||||
/** @var ShipmentTrackingInterface[] */
|
||||
private readonly array $services;
|
||||
|
||||
/**
|
||||
* @param ShipmentTrackingInterface[] $services
|
||||
*/
|
||||
public function __construct(array $services)
|
||||
{
|
||||
$this->services = $services;
|
||||
}
|
||||
|
||||
public function getForProvider(string $provider): ?ShipmentTrackingInterface
|
||||
{
|
||||
foreach ($this->services as $service) {
|
||||
if ($service->supports($provider)) {
|
||||
return $service;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user