27-05-2026 DHL Modul v2.1 / Optimierung tracking
This commit is contained in:
parent
036595be94
commit
2bdc9ada3c
33 changed files with 2367 additions and 2086 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -4,6 +4,7 @@ namespace App\Services;
|
|||
|
||||
use App\Http\Controllers\SettingController;
|
||||
use App\Models\ShoppingOrder;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* DHL Data Helper
|
||||
|
|
@ -23,7 +24,14 @@ class DhlDataHelper
|
|||
*/
|
||||
public static function prepareOrderData(ShoppingOrder $order, float $weight, array $options = [], ?array $dhlConfig = null): array
|
||||
{
|
||||
\Log::info('prepareOrderData', $options);
|
||||
Log::info('[DHL DataHelper] Preparing order data', [
|
||||
'order_id' => $order->id,
|
||||
'product_code' => $options['product_code'] ?? null,
|
||||
'has_shipping_address' => isset($options['shipping_address']),
|
||||
'has_reference' => ! empty($options['reference'] ?? $options['shipment_reference'] ?? null),
|
||||
'print_only_if_codeable' => (bool) ($options['print_only_if_codeable'] ?? false),
|
||||
]);
|
||||
|
||||
// die daten für das versandlabel werden immer aus dem Formular genommen, damit anpassungen möglich sind
|
||||
if (! isset($options['shipping_address'])) {
|
||||
throw new \Exception('shipping_address is required');
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Services;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class DhlProductResolver
|
||||
|
|
@ -13,7 +14,7 @@ class DhlProductResolver
|
|||
|
||||
public const INTERNATIONAL_PRODUCT_CODE = 'V53PAK';
|
||||
|
||||
public const DEFAULT_INTERNATIONAL_COUNTRIES = ['AT', 'ES'];
|
||||
public const DEFAULT_INTERNATIONAL_COUNTRIES = ['AT', 'ES', 'CH', 'NL', 'BE', 'FR'];
|
||||
|
||||
public const DHL_COUNTRY_CODES = [
|
||||
'DE' => 'DEU',
|
||||
|
|
@ -172,9 +173,14 @@ class DhlProductResolver
|
|||
$useEnvPriority = config('dhl.config_source') === 'env';
|
||||
$configCountries = config('dhl.international_countries', self::DEFAULT_INTERNATIONAL_COUNTRIES);
|
||||
$countries = $configCountries;
|
||||
$storedCountries = Schema::hasTable('settings')
|
||||
? Setting::getContentBySlug('dhl_international_countries')
|
||||
: false;
|
||||
|
||||
if (! $useEnvPriority) {
|
||||
$countries = Setting::getContentBySlug('dhl_international_countries') ?: $configCountries;
|
||||
if (is_array($storedCountries)) {
|
||||
$countries = $storedCountries;
|
||||
} elseif (! $useEnvPriority) {
|
||||
$countries = $storedCountries ?: $configCountries;
|
||||
}
|
||||
|
||||
return self::normalizeCountryCodeList(is_array($countries) ? $countries : []);
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ class DhlShipmentService
|
|||
// Get DHL configuration
|
||||
$settingController = new SettingController;
|
||||
$dhlConfig = $settingController->getDhlConfig();
|
||||
\Log::info('dhlConfig', $dhlConfig);
|
||||
Log::info('[DHL Service] Loaded DHL configuration', self::sanitizeDhlConfigForLog($dhlConfig));
|
||||
// Check if queue should be used
|
||||
$useQueue = $dhlConfig['use_queue'] ?? false;
|
||||
if ($useQueue && $this->requiresSynchronousAddressValidation($options, $dhlConfig)) {
|
||||
|
|
@ -115,7 +115,8 @@ class DhlShipmentService
|
|||
|
||||
// Prepare order data using helper
|
||||
$orderData = DhlDataHelper::prepareOrderData($order, $weight, $options, $dhlConfig);
|
||||
Log::info('orderData', $orderData);
|
||||
Log::info('[DHL Service] Order data prepared for DHL API', self::sanitizeOrderDataForLog($orderData));
|
||||
|
||||
// Create the shipment directly
|
||||
$result = $shippingService->createLabel($orderData);
|
||||
|
||||
|
|
@ -361,4 +362,65 @@ class DhlShipmentService
|
|||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a redacted view of the DHL configuration for safe logging.
|
||||
*
|
||||
* Never include `api_key`, `username`, `password`, `api_secret` or the
|
||||
* full billing number itself. Only return boolean presence flags and
|
||||
* non-sensitive metadata.
|
||||
*
|
||||
* @param array<string, mixed> $dhlConfig
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function sanitizeDhlConfigForLog(array $dhlConfig): array
|
||||
{
|
||||
return [
|
||||
'base_url' => $dhlConfig['base_url'] ?? null,
|
||||
'sandbox' => $dhlConfig['sandbox'] ?? null,
|
||||
'test_mode' => $dhlConfig['test_mode'] ?? null,
|
||||
'has_api_key' => ! empty($dhlConfig['api_key']),
|
||||
'has_username' => ! empty($dhlConfig['username']),
|
||||
'has_password' => ! empty($dhlConfig['password']),
|
||||
'has_api_secret' => ! empty($dhlConfig['api_secret']),
|
||||
'use_queue' => (bool) ($dhlConfig['use_queue'] ?? false),
|
||||
'default_product' => $dhlConfig['default_product'] ?? null,
|
||||
'label_format' => $dhlConfig['label_format'] ?? null,
|
||||
'print_format' => $dhlConfig['print_format'] ?? null,
|
||||
'print_only_if_codeable' => (bool) ($dhlConfig['print_only_if_codeable'] ?? false),
|
||||
'international_countries' => $dhlConfig['international_countries'] ?? [],
|
||||
'account_numbers_configured' => array_keys(array_filter($dhlConfig['account_numbers'] ?? [])),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a redacted view of the order data prepared for DHL.
|
||||
*
|
||||
* Strips personally identifiable information like full names, addresses,
|
||||
* phone numbers and e-mail addresses. Keeps the routing-relevant fields
|
||||
* needed to debug a failing label generation.
|
||||
*
|
||||
* @param array<string, mixed> $orderData
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function sanitizeOrderDataForLog(array $orderData): array
|
||||
{
|
||||
$consigneeCountry = $orderData['consignee']['country'] ?? null;
|
||||
$consigneePostal = $orderData['consignee']['postalCode'] ?? null;
|
||||
|
||||
return [
|
||||
'order_id' => $orderData['order_id'] ?? null,
|
||||
'product_code' => $orderData['product_code'] ?? null,
|
||||
'weight_kg' => $orderData['weight_kg'] ?? null,
|
||||
'label_format' => $orderData['label_format'] ?? null,
|
||||
'print_format' => $orderData['print_format'] ?? null,
|
||||
'print_only_if_codeable' => (bool) ($orderData['print_only_if_codeable'] ?? false),
|
||||
'consignee_country' => $consigneeCountry,
|
||||
'consignee_postal_prefix' => is_string($consigneePostal) && $consigneePostal !== ''
|
||||
? mb_substr($consigneePostal, 0, 2)
|
||||
: null,
|
||||
'consignee_has_post_number' => ! empty($orderData['consignee']['postNumber']),
|
||||
'has_reference' => ! empty($orderData['reference']),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,44 +6,110 @@ use Acme\Dhl\Models\DhlShipment;
|
|||
use Acme\Dhl\Models\DhlTrackingEvent;
|
||||
use App\Http\Controllers\SettingController;
|
||||
use App\Jobs\TrackShipmentJob;
|
||||
use Carbon\CarbonInterface;
|
||||
use Exception;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* DHL Tracking Service
|
||||
*
|
||||
* Handles DHL tracking using both Unified Tracking API and Parcel DE Tracking API
|
||||
* with support for synchronous and asynchronous tracking updates
|
||||
* Handles DHL tracking via the Unified Shipment Tracking API
|
||||
* (https://developer.dhl.com/api-reference/shipment-tracking) with support
|
||||
* for synchronous and asynchronous tracking updates. The previous
|
||||
* "Parcel DE tracking" fallback was removed because that endpoint does not
|
||||
* exist in the official DHL API catalogue.
|
||||
*/
|
||||
class DhlTrackingService
|
||||
{
|
||||
/**
|
||||
* Unified Shipment Tracking endpoint.
|
||||
*
|
||||
* According to the official DHL Developer Portal documentation
|
||||
* (https://developer.dhl.com/api-reference/shipment-tracking) this single
|
||||
* URL serves both sandbox and production. The sandbox/production routing
|
||||
* is decided by the API key itself - which subscriptions the key has.
|
||||
*/
|
||||
private const TRACKING_ENDPOINT = 'https://api-eu.dhl.com/track/shipments';
|
||||
|
||||
/**
|
||||
* Cache key for a "do not call DHL right now" gate.
|
||||
*
|
||||
* Once DHL responds with HTTP 429 (daily quota exhausted) we store an
|
||||
* absolute "paused until" timestamp here. Every subsequent tracking call
|
||||
* inside the gate window skips the HTTP request entirely - both to stop
|
||||
* wasting the small standard quota (250/day, 1 call / 5 s) on requests
|
||||
* that DHL will reject anyway, and to make the outage visible in logs
|
||||
* and in the cron summary instead of producing a stream of misleading
|
||||
* per-shipment errors.
|
||||
*/
|
||||
private const QUOTA_PAUSE_CACHE_KEY = 'dhl_tracking:quota_paused_until';
|
||||
|
||||
/**
|
||||
* Fallback pause duration (seconds) when DHL did not send a Retry-After
|
||||
* header. One hour is a deliberately conservative compromise: short
|
||||
* enough that quota recovery after a manual upgrade is picked up
|
||||
* quickly, long enough that the hourly cron does not burn ~1 call per
|
||||
* run just to confirm the quota is still exhausted.
|
||||
*/
|
||||
private const DEFAULT_QUOTA_PAUSE_SECONDS = 3600;
|
||||
|
||||
/**
|
||||
* Minimum gap between two consecutive DHL tracking calls.
|
||||
*
|
||||
* Per https://developer.dhl.com/api-reference/shipment-tracking#rate-limits
|
||||
* the standard Shipment Tracking - Unified API service level enforces
|
||||
* "a maximum of 1 call every 5 seconds". The previous "batch" path
|
||||
* pretended to bundle 10 trackings into one request, but the Unified
|
||||
* API only accepts a single `trackingNumber` parameter - the pseudo
|
||||
* batch call was always interpreted as one unknown shipment ID and
|
||||
* the code silently fell back to per-shipment calls anyway. We now do
|
||||
* one call per shipment and pause this many seconds between them so
|
||||
* the cron stays within the documented rate budget.
|
||||
*/
|
||||
private static int $callIntervalSeconds = 5;
|
||||
|
||||
private string $apiKey;
|
||||
|
||||
private string $apiSecret;
|
||||
|
||||
private bool $isSandbox;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$settingController = new SettingController;
|
||||
$dhlConfig = $settingController->getDhlConfig();
|
||||
|
||||
$this->apiKey = $dhlConfig['api_key'] ?? config('dhl.api_key');
|
||||
$this->apiSecret = $dhlConfig['api_secret'] ?? config('dhl.legacy.api_secret');
|
||||
$this->isSandbox = ($dhlConfig['sandbox'] ?? config('dhl.legacy.sandbox', true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Track shipment using DHL Unified Tracking API (recommended for international)
|
||||
* Track a single shipment via the DHL Unified Shipment Tracking API.
|
||||
*
|
||||
* The previous implementation tried a "Parcel DE tracking" endpoint as
|
||||
* fallback when the Unified API failed. That endpoint
|
||||
* (`/parcel/de/tracking/v0/shipments`) does not exist in the official
|
||||
* DHL API catalogue - the only documented German tracking surface is the
|
||||
* Unified Tracking API. The fallback only produced more 401s with a
|
||||
* misleading "Sendung nicht gefunden" message and is therefore removed.
|
||||
*/
|
||||
public function trackShipment(string $trackingNumber, array $options = []): array
|
||||
{
|
||||
if ($pausedUntil = self::getQuotaPausedUntil()) {
|
||||
Log::info('[DHL Tracking Service] Skipping single tracking - quota pause active', [
|
||||
'tracking_number' => $trackingNumber,
|
||||
'paused_until' => $pausedUntil->toIso8601String(),
|
||||
]);
|
||||
|
||||
return self::buildQuotaPausedResponse($pausedUntil) + [
|
||||
'tracking_number' => $trackingNumber,
|
||||
'api_used' => 'unified',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
Log::info('[DHL Tracking Service] Tracking shipment with Unified API', [
|
||||
'tracking_number' => $trackingNumber,
|
||||
'is_sandbox' => $this->isSandbox,
|
||||
'endpoint' => self::TRACKING_ENDPOINT,
|
||||
'has_api_key' => ! empty($this->apiKey),
|
||||
]);
|
||||
|
||||
|
|
@ -51,241 +117,362 @@ class DhlTrackingService
|
|||
'DHL-API-Key' => $this->apiKey,
|
||||
'Accept' => 'application/json',
|
||||
])
|
||||
->withOptions([
|
||||
'verify' => config('dhl.ssl.verify_peer', true),
|
||||
'http_errors' => false,
|
||||
'curl' => [
|
||||
CURLOPT_SSL_VERIFYPEER => config('dhl.ssl.verify_peer', true),
|
||||
CURLOPT_SSL_VERIFYHOST => config('dhl.ssl.verify_host', true) ? 2 : 0,
|
||||
CURLOPT_SSLVERSION => $this->getSslVersion(),
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 5,
|
||||
CURLOPT_CONNECTTIMEOUT => config('dhl.ssl.connect_timeout', 10),
|
||||
CURLOPT_TIMEOUT => config('dhl.ssl.timeout', 30),
|
||||
CURLOPT_USERAGENT => 'acme-laravel-dhl/1.0',
|
||||
],
|
||||
])
|
||||
->get('https://api-eu.dhl.com/track/shipments', [
|
||||
->withOptions($this->buildHttpOptions())
|
||||
->get(self::TRACKING_ENDPOINT, [
|
||||
'trackingNumber' => $trackingNumber,
|
||||
'requesterCountryCode' => 'DE',
|
||||
'originCountryCode' => 'DE',
|
||||
'language' => 'de',
|
||||
]);
|
||||
|
||||
Log::info('[DHL Tracking Service] Unified API response', [
|
||||
'tracking_number' => $trackingNumber,
|
||||
'status_code' => $response->status(),
|
||||
'successful' => $response->successful(),
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json();
|
||||
|
||||
if (isset($data['shipments']) && count($data['shipments']) > 0) {
|
||||
$shipment = $data['shipments'][0];
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'tracking_number' => $shipment['id'],
|
||||
'status' => $shipment['status']['statusCode'] ?? 'unknown',
|
||||
'status_text' => $shipment['status']['description'] ?? ($shipment['status']['status'] ?? 'Unbekannt'),
|
||||
'description' => $shipment['status']['remark'] ?? ($shipment['status']['description'] ?? ''),
|
||||
'last_update' => $shipment['status']['timestamp'] ?? null,
|
||||
'origin' => $shipment['origin']['address']['addressLocality'] ?? null,
|
||||
'destination' => $shipment['destination']['address']['addressLocality'] ?? null,
|
||||
'events' => $shipment['events'] ?? [],
|
||||
'api_used' => 'unified',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Log::warning('[DHL Tracking Service] Unified API did not find shipment, trying Parcel DE API', [
|
||||
'tracking_number' => $trackingNumber,
|
||||
'status_code' => $response->status(),
|
||||
'response_snippet' => mb_substr($response->body(), 0, 500),
|
||||
]);
|
||||
|
||||
// If Unified API fails, try Parcel DE API
|
||||
return $this->trackShipmentDE($trackingNumber, $options);
|
||||
return $this->processSingleShipmentResponse($trackingNumber, $response);
|
||||
} catch (Exception $e) {
|
||||
Log::error('[DHL Tracking Service] Unified API failed', [
|
||||
Log::error('[DHL Tracking Service] Unified API request threw', [
|
||||
'tracking_number' => $trackingNumber,
|
||||
'endpoint' => self::TRACKING_ENDPOINT,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
// Fallback to Parcel DE API
|
||||
return $this->trackShipmentDE($trackingNumber, $options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track shipment using DHL Parcel DE Tracking API (optimized for Germany)
|
||||
*/
|
||||
public function trackShipmentDE(string $trackingNumber, array $options = []): array
|
||||
{
|
||||
try {
|
||||
Log::info('[DHL Tracking Service] Tracking shipment with Parcel DE API', [
|
||||
'tracking_number' => $trackingNumber,
|
||||
'is_sandbox' => $this->isSandbox,
|
||||
'has_api_key' => ! empty($this->apiKey),
|
||||
'has_api_secret' => ! empty($this->apiSecret),
|
||||
]);
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'DHL-API-Key' => $this->apiKey,
|
||||
'Accept' => 'application/json',
|
||||
])
|
||||
->withOptions([
|
||||
'verify' => config('dhl.ssl.verify_peer', true),
|
||||
'http_errors' => false,
|
||||
'curl' => [
|
||||
CURLOPT_SSL_VERIFYPEER => config('dhl.ssl.verify_peer', true),
|
||||
CURLOPT_SSL_VERIFYHOST => config('dhl.ssl.verify_host', true) ? 2 : 0,
|
||||
CURLOPT_SSLVERSION => $this->getSslVersion(),
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 5,
|
||||
CURLOPT_CONNECTTIMEOUT => config('dhl.ssl.connect_timeout', 10),
|
||||
CURLOPT_TIMEOUT => config('dhl.ssl.timeout', 30),
|
||||
CURLOPT_USERAGENT => 'acme-laravel-dhl/1.0',
|
||||
],
|
||||
])
|
||||
->get('https://api-eu.dhl.com/parcel/de/tracking/v0/shipments', [
|
||||
'shipmentId' => $trackingNumber,
|
||||
'language' => 'de',
|
||||
]);
|
||||
|
||||
Log::info('[DHL Tracking Service] Parcel DE API response', [
|
||||
'tracking_number' => $trackingNumber,
|
||||
'status_code' => $response->status(),
|
||||
'successful' => $response->successful(),
|
||||
'response_body' => $response->body(),
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json();
|
||||
|
||||
if (isset($data['shipments']) && count($data['shipments']) > 0) {
|
||||
$shipment = $data['shipments'][0];
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'tracking_number' => $shipment['id'],
|
||||
'status' => $shipment['status']['statusCode'] ?? 'unknown',
|
||||
'status_text' => $shipment['status']['description'] ?? 'Unbekannt',
|
||||
'description' => $shipment['status']['description'] ?? '',
|
||||
'last_update' => $shipment['status']['timestamp'] ?? null,
|
||||
'events' => $shipment['events'] ?? [],
|
||||
'api_used' => 'parcel_de',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Log detailed error information
|
||||
Log::warning('[DHL Tracking Service] Shipment not found or not yet tracked', [
|
||||
'tracking_number' => $trackingNumber,
|
||||
'status_code' => $response->status(),
|
||||
'response_body' => $response->body(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Sendung nicht gefunden oder noch nicht im System erfasst. HTTP Status: '.$response->status(),
|
||||
'tracking_number' => $trackingNumber,
|
||||
'api_used' => 'parcel_de',
|
||||
'debug_info' => [
|
||||
'status_code' => $response->status(),
|
||||
'response' => $response->json(),
|
||||
],
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
Log::error('[DHL Tracking Service] Parcel DE API failed', [
|
||||
'tracking_number' => $trackingNumber,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Fehler beim Abrufen der Tracking-Informationen: '.$e->getMessage(),
|
||||
'tracking_number' => $trackingNumber,
|
||||
'api_used' => 'parcel_de',
|
||||
'api_used' => 'unified',
|
||||
'transport_error' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track multiple shipments at once (up to 10 for Unified API)
|
||||
* Build the standard cURL/Guzzle options used for every DHL tracking call.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function trackMultipleShipments(array $trackingNumbers): array
|
||||
private function buildHttpOptions(): array
|
||||
{
|
||||
if (count($trackingNumbers) > 10) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Maximal 10 Sendungen können gleichzeitig getrackt werden.',
|
||||
];
|
||||
}
|
||||
return [
|
||||
'verify' => config('dhl.ssl.verify_peer', true),
|
||||
'http_errors' => false,
|
||||
'curl' => [
|
||||
CURLOPT_SSL_VERIFYPEER => config('dhl.ssl.verify_peer', true),
|
||||
CURLOPT_SSL_VERIFYHOST => config('dhl.ssl.verify_host', true) ? 2 : 0,
|
||||
CURLOPT_SSLVERSION => $this->getSslVersion(),
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 5,
|
||||
CURLOPT_CONNECTTIMEOUT => config('dhl.ssl.connect_timeout', 10),
|
||||
CURLOPT_TIMEOUT => config('dhl.ssl.timeout', 30),
|
||||
CURLOPT_USERAGENT => 'acme-laravel-dhl/1.0',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'DHL-API-Key' => $this->apiKey,
|
||||
'Accept' => 'application/json',
|
||||
])
|
||||
->withOptions([
|
||||
'verify' => config('dhl.ssl.verify_peer', true),
|
||||
'http_errors' => false,
|
||||
'curl' => [
|
||||
CURLOPT_SSL_VERIFYPEER => config('dhl.ssl.verify_peer', true),
|
||||
CURLOPT_SSL_VERIFYHOST => config('dhl.ssl.verify_host', true) ? 2 : 0,
|
||||
CURLOPT_SSLVERSION => $this->getSslVersion(),
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 5,
|
||||
CURLOPT_CONNECTTIMEOUT => config('dhl.ssl.connect_timeout', 10),
|
||||
CURLOPT_TIMEOUT => config('dhl.ssl.timeout', 30),
|
||||
CURLOPT_USERAGENT => 'acme-laravel-dhl/1.0',
|
||||
],
|
||||
])
|
||||
->get('https://api-eu.dhl.com/track/shipments', [
|
||||
'trackingNumber' => implode(',', $trackingNumbers),
|
||||
'requesterCountryCode' => 'DE',
|
||||
'language' => 'de',
|
||||
]);
|
||||
/**
|
||||
* Normalize a single Unified Tracking API response into our internal
|
||||
* structure, distinguishing the three outcomes the caller actually cares
|
||||
* about: success, "not found", and "auth/transport error".
|
||||
*
|
||||
* @param \Illuminate\Http\Client\Response $response
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function processSingleShipmentResponse(string $trackingNumber, $response): array
|
||||
{
|
||||
$status = $response->status();
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json();
|
||||
$results = [];
|
||||
Log::info('[DHL Tracking Service] Unified API response', [
|
||||
'tracking_number' => $trackingNumber,
|
||||
'status_code' => $status,
|
||||
'successful' => $response->successful(),
|
||||
]);
|
||||
|
||||
foreach ($data['shipments'] ?? [] as $shipment) {
|
||||
$results[] = [
|
||||
'tracking_number' => $shipment['id'],
|
||||
'status' => $shipment['status']['statusCode'] ?? 'unknown',
|
||||
'status_text' => $shipment['status']['status'] ?? 'Unbekannt',
|
||||
'last_update' => $shipment['status']['timestamp'] ?? null,
|
||||
'events' => $shipment['events'] ?? [],
|
||||
];
|
||||
}
|
||||
if ($response->successful()) {
|
||||
$data = $response->json();
|
||||
|
||||
if (isset($data['shipments']) && count($data['shipments']) > 0) {
|
||||
$shipment = $data['shipments'][0];
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'shipments' => $results,
|
||||
'tracking_number' => $shipment['id'],
|
||||
'status' => $shipment['status']['statusCode'] ?? 'unknown',
|
||||
'status_text' => $shipment['status']['description'] ?? ($shipment['status']['status'] ?? 'Unbekannt'),
|
||||
'description' => $shipment['status']['remark'] ?? ($shipment['status']['description'] ?? ''),
|
||||
'last_update' => $shipment['status']['timestamp'] ?? null,
|
||||
'origin' => $shipment['origin']['address']['addressLocality'] ?? null,
|
||||
'destination' => $shipment['destination']['address']['addressLocality'] ?? null,
|
||||
'events' => $shipment['events'] ?? [],
|
||||
'api_used' => 'unified',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Fehler beim Abrufen der Tracking-Informationen.',
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
Log::error('[DHL Tracking Service] Multiple tracking failed', [
|
||||
'tracking_numbers' => $trackingNumbers,
|
||||
'error' => $e->getMessage(),
|
||||
Log::warning('[DHL Tracking Service] Unified API returned no shipments', [
|
||||
'tracking_number' => $trackingNumber,
|
||||
'status_code' => $status,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Fehler beim Abrufen der Tracking-Informationen: '.$e->getMessage(),
|
||||
'message' => 'Sendung nicht gefunden oder noch nicht im DHL-System erfasst.',
|
||||
'tracking_number' => $trackingNumber,
|
||||
'api_used' => 'unified',
|
||||
'http_status' => $status,
|
||||
'not_found' => true,
|
||||
];
|
||||
}
|
||||
|
||||
if (self::isAuthErrorStatus($status)) {
|
||||
Log::error('[DHL Tracking Service] Unified API authentication failed', [
|
||||
'tracking_number' => $trackingNumber,
|
||||
'status_code' => $status,
|
||||
'endpoint' => self::TRACKING_ENDPOINT,
|
||||
'has_api_key' => ! empty($this->apiKey),
|
||||
'api_key_suffix' => self::redactApiKey($this->apiKey),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => self::buildAuthErrorMessage($status),
|
||||
'tracking_number' => $trackingNumber,
|
||||
'api_used' => 'unified',
|
||||
'http_status' => $status,
|
||||
'auth_error' => true,
|
||||
];
|
||||
}
|
||||
|
||||
if ($status === 429) {
|
||||
$retryAfter = self::extractRetryAfter($response);
|
||||
|
||||
Log::error('[DHL Tracking Service] Unified API rate-limited', [
|
||||
'tracking_number' => $trackingNumber,
|
||||
'status_code' => 429,
|
||||
'endpoint' => self::TRACKING_ENDPOINT,
|
||||
'retry_after_seconds' => $retryAfter,
|
||||
'api_key_suffix' => self::redactApiKey($this->apiKey),
|
||||
]);
|
||||
|
||||
self::pauseQuota($retryAfter);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => self::buildRateLimitMessage($retryAfter),
|
||||
'tracking_number' => $trackingNumber,
|
||||
'api_used' => 'unified',
|
||||
'http_status' => 429,
|
||||
'rate_limited' => true,
|
||||
'retry_after' => $retryAfter,
|
||||
];
|
||||
}
|
||||
|
||||
if ($status === 404) {
|
||||
Log::info('[DHL Tracking Service] Unified API returned 404 for shipment', [
|
||||
'tracking_number' => $trackingNumber,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Sendung nicht gefunden oder noch nicht im DHL-System erfasst.',
|
||||
'tracking_number' => $trackingNumber,
|
||||
'api_used' => 'unified',
|
||||
'http_status' => $status,
|
||||
'not_found' => true,
|
||||
];
|
||||
}
|
||||
|
||||
Log::warning('[DHL Tracking Service] Unified API returned unexpected status', [
|
||||
'tracking_number' => $trackingNumber,
|
||||
'status_code' => $status,
|
||||
'response_snippet' => mb_substr((string) $response->body(), 0, 500),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'DHL Tracking API antwortet mit HTTP '.$status.'. Bitte API-Status und Konfiguration pruefen.',
|
||||
'tracking_number' => $trackingNumber,
|
||||
'api_used' => 'unified',
|
||||
'http_status' => $status,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when DHL signals an authentication / authorization problem.
|
||||
*/
|
||||
private static function isAuthErrorStatus(int $status): bool
|
||||
{
|
||||
return $status === 401 || $status === 403;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the user-facing message for an authentication problem.
|
||||
*/
|
||||
private static function buildAuthErrorMessage(int $status): string
|
||||
{
|
||||
return 'DHL Tracking API: Authentifizierung fehlgeschlagen (HTTP '.$status.'). '
|
||||
.'Bitte pruefen, ob der hinterlegte DHL-API-Key gueltig ist und im '
|
||||
.'DHL Developer Portal fuer "Shipment Tracking - Unified" freigeschaltet wurde.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the user-facing message for a DHL rate-limit response (HTTP 429).
|
||||
*/
|
||||
private static function buildRateLimitMessage(?int $retryAfter): string
|
||||
{
|
||||
$base = 'DHL Tracking API: Tageslimit erreicht (HTTP 429). '
|
||||
.'Die DHL-API liefert vorruebergehend keine Daten mehr. '
|
||||
.'Standard-Apps haben laut DHL-Doku 250 Aufrufe pro Tag und max. 1 Aufruf alle 5 Sekunden. '
|
||||
.'Bei Bedarf im DHL Developer Portal eine Quota-Erhoehung beantragen.';
|
||||
|
||||
if ($retryAfter !== null && $retryAfter > 0) {
|
||||
$minutes = (int) ceil($retryAfter / 60);
|
||||
|
||||
return $base.' Naechster Versuch fruehestens in '.$minutes.' Minute(n).';
|
||||
}
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a `Retry-After` header value (in seconds) if DHL sent one.
|
||||
*
|
||||
* @param \Illuminate\Http\Client\Response $response
|
||||
*/
|
||||
private static function extractRetryAfter($response): ?int
|
||||
{
|
||||
$header = $response->header('Retry-After');
|
||||
|
||||
if ($header === null || $header === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ctype_digit(trim($header))) {
|
||||
return (int) $header;
|
||||
}
|
||||
|
||||
// RFC 7231 also allows an HTTP-date; convert to seconds if so.
|
||||
$timestamp = strtotime($header);
|
||||
if ($timestamp !== false) {
|
||||
return max(0, $timestamp - time());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce an API key to a non-sensitive marker for log correlation.
|
||||
*/
|
||||
private static function redactApiKey(?string $apiKey): ?string
|
||||
{
|
||||
if ($apiKey === null || $apiKey === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return '***'.mb_substr($apiKey, -4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the tracking client currently in a "do not call DHL" window because
|
||||
* we recently received an HTTP 429?
|
||||
*/
|
||||
public static function isQuotaPaused(): bool
|
||||
{
|
||||
return self::getQuotaPausedUntil() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute "do not call DHL before" timestamp from the cache, or `null`
|
||||
* if the pause has expired (or was never set).
|
||||
*/
|
||||
public static function getQuotaPausedUntil(): ?CarbonInterface
|
||||
{
|
||||
$value = Cache::get(self::QUOTA_PAUSE_CACHE_KEY);
|
||||
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$paused = $value instanceof CarbonInterface ? $value : Carbon::parse($value);
|
||||
} catch (Exception $e) {
|
||||
Cache::forget(self::QUOTA_PAUSE_CACHE_KEY);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($paused->isPast()) {
|
||||
Cache::forget(self::QUOTA_PAUSE_CACHE_KEY);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $paused;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the DHL tracking quota as exhausted for the given number of
|
||||
* seconds. Defaults to {@see self::DEFAULT_QUOTA_PAUSE_SECONDS} when
|
||||
* DHL did not send a `Retry-After` header.
|
||||
*/
|
||||
public static function pauseQuota(?int $retryAfterSeconds = null): void
|
||||
{
|
||||
$seconds = ($retryAfterSeconds !== null && $retryAfterSeconds > 0)
|
||||
? $retryAfterSeconds
|
||||
: self::DEFAULT_QUOTA_PAUSE_SECONDS;
|
||||
|
||||
$until = Carbon::now()->addSeconds($seconds);
|
||||
|
||||
Cache::put(self::QUOTA_PAUSE_CACHE_KEY, $until, $until);
|
||||
|
||||
Log::warning('[DHL Tracking Service] Quota pause activated', [
|
||||
'paused_until' => $until->toIso8601String(),
|
||||
'seconds' => $seconds,
|
||||
'source' => $retryAfterSeconds !== null ? 'retry_after_header' : 'default',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually clear the quota pause - intended for tests and for the rare
|
||||
* case where an operator wants to retry immediately after fixing the
|
||||
* underlying account problem.
|
||||
*/
|
||||
public static function clearQuotaPause(): void
|
||||
{
|
||||
Cache::forget(self::QUOTA_PAUSE_CACHE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the inter-call throttle for tests or for environments where
|
||||
* a custom DHL service level allows faster polling than 1 call / 5 s.
|
||||
*/
|
||||
public static function setCallIntervalSeconds(int $seconds): void
|
||||
{
|
||||
self::$callIntervalSeconds = max(0, $seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Current inter-call throttle in seconds.
|
||||
*/
|
||||
public static function getCallIntervalSeconds(): int
|
||||
{
|
||||
return self::$callIntervalSeconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the "we did not call DHL because of the quota pause" response
|
||||
* used by all entry points when the pause window is active.
|
||||
*
|
||||
* @return array{success: false, message: string, http_status: 429, rate_limited: true, paused_until: string, retry_after: int}
|
||||
*/
|
||||
private static function buildQuotaPausedResponse(CarbonInterface $until): array
|
||||
{
|
||||
$retryAfter = max(1, $until->diffInSeconds(Carbon::now(), false) * -1);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => self::buildRateLimitMessage($retryAfter)
|
||||
.' (Lokale Quota-Pause aktiv bis '.$until->copy()->setTimezone(config('app.timezone'))->format('d.m.Y H:i').' Uhr.)',
|
||||
'http_status' => 429,
|
||||
'rate_limited' => true,
|
||||
'paused_until' => $until->toIso8601String(),
|
||||
'retry_after' => $retryAfter,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -413,14 +600,28 @@ class DhlTrackingService
|
|||
'tracking_completed' => in_array($internalStatus, DhlShipment::TERMINAL_STATUSES),
|
||||
'tracking_details' => $result,
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => $result['message'] ?? 'Fehler beim Abrufen der Tracking-Informationen.',
|
||||
'queued' => false,
|
||||
'shipment_id' => $shipment->id,
|
||||
];
|
||||
}
|
||||
|
||||
// No success. Only mark the shipment as "checked just now" when DHL
|
||||
// explicitly told us the shipment is unknown - so we do not retry
|
||||
// immediately for those. Auth / transport problems must NOT update
|
||||
// `last_tracked_at` because otherwise stale status data appears
|
||||
// freshly refreshed in the UI and operators stop noticing the
|
||||
// tracking outage (this was the symptom reported on production).
|
||||
if (! empty($result['not_found'])) {
|
||||
$shipment->update(['last_tracked_at' => now()]);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => $result['message'] ?? 'Fehler beim Abrufen der Tracking-Informationen.',
|
||||
'queued' => false,
|
||||
'shipment_id' => $shipment->id,
|
||||
'auth_error' => $result['auth_error'] ?? false,
|
||||
'rate_limited' => $result['rate_limited'] ?? false,
|
||||
'retry_after' => $result['retry_after'] ?? null,
|
||||
'http_status' => $result['http_status'] ?? null,
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
Log::error('[DHL Tracking Service] Tracking update failed (sync)', [
|
||||
'shipment_id' => $shipment->id,
|
||||
|
|
@ -437,11 +638,26 @@ class DhlTrackingService
|
|||
}
|
||||
|
||||
/**
|
||||
* Update tracking for a batch of DHL shipments using the multi-tracking API.
|
||||
* Processes shipments in chunks of 10 (DHL API limit) with rate-limiting pauses.
|
||||
* Update tracking for a collection of DHL shipments.
|
||||
*
|
||||
* @param Collection<DhlShipment> $shipments
|
||||
* @return array{updated: int, failed: int, completed: int, results: array}
|
||||
* Each shipment triggers exactly one DHL Unified Tracking API call
|
||||
* (`trackingNumber` is a singular parameter). Between calls we pause
|
||||
* {@see self::$callIntervalSeconds} seconds to respect the documented
|
||||
* "max 1 call every 5 seconds" rate limit.
|
||||
*
|
||||
* The loop bails out early on:
|
||||
* - a cached quota pause (no HTTP request at all),
|
||||
* - the first DHL authentication failure (401/403),
|
||||
* - the first DHL rate-limit response (429, which also activates the
|
||||
* process-wide quota pause for subsequent runs).
|
||||
*
|
||||
* `last_tracked_at` is only updated by the underlying
|
||||
* {@see self::updateTrackingSync()} on success or on an explicit
|
||||
* "not found" - never on auth/transport/rate-limit failures - so
|
||||
* stale statuses never appear freshly refreshed in the cockpit.
|
||||
*
|
||||
* @param Collection<int, DhlShipment> $shipments
|
||||
* @return array{updated: int, failed: int, completed: int, results: array<int, array<string, mixed>>}
|
||||
*/
|
||||
public function updateTrackingBatch(Collection $shipments): array
|
||||
{
|
||||
|
|
@ -452,140 +668,84 @@ class DhlTrackingService
|
|||
'results' => [],
|
||||
];
|
||||
|
||||
// Process in chunks of 10 (DHL API limit)
|
||||
$chunks = $shipments->chunk(10);
|
||||
$chunkIndex = 0;
|
||||
if ($pausedUntil = self::getQuotaPausedUntil()) {
|
||||
Log::warning('[DHL Tracking Service] Batch tracking skipped - quota pause active', [
|
||||
'count' => $shipments->count(),
|
||||
'paused_until' => $pausedUntil->toIso8601String(),
|
||||
]);
|
||||
|
||||
foreach ($chunks as $chunk) {
|
||||
// Rate limiting: pause 1 second between batch API calls
|
||||
if ($chunkIndex > 0) {
|
||||
sleep(1);
|
||||
}
|
||||
$chunkIndex++;
|
||||
|
||||
// Build tracking number => shipment mapping
|
||||
$shipmentMap = [];
|
||||
foreach ($chunk as $shipment) {
|
||||
$shipmentMap[$shipment->dhl_shipment_no] = $shipment;
|
||||
foreach ($shipments as $shipment) {
|
||||
$stats['failed']++;
|
||||
$stats['results'][] = [
|
||||
'shipment_id' => $shipment->id,
|
||||
'tracking_number' => $shipment->dhl_shipment_no,
|
||||
'success' => false,
|
||||
'message' => 'DHL-Quota-Pause aktiv bis '.$pausedUntil->copy()->setTimezone(config('app.timezone'))->format('d.m.Y H:i').' Uhr.',
|
||||
'rate_limited' => true,
|
||||
'paused_until' => $pausedUntil->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
$trackingNumbers = array_keys($shipmentMap);
|
||||
return $stats;
|
||||
}
|
||||
|
||||
$index = 0;
|
||||
$sentCalls = 0;
|
||||
|
||||
foreach ($shipments as $shipment) {
|
||||
if ($index > 0 && self::$callIntervalSeconds > 0) {
|
||||
sleep(self::$callIntervalSeconds);
|
||||
}
|
||||
$index++;
|
||||
|
||||
try {
|
||||
$batchResult = $this->trackMultipleShipments($trackingNumbers);
|
||||
$result = $this->updateTracking($shipment, ['auto_retrack' => false]);
|
||||
$sentCalls++;
|
||||
|
||||
if ($batchResult['success'] && ! empty($batchResult['shipments'])) {
|
||||
// Process each result from the batch API
|
||||
foreach ($batchResult['shipments'] as $trackingResult) {
|
||||
$trackingNo = $trackingResult['tracking_number'];
|
||||
$shipment = $shipmentMap[$trackingNo] ?? null;
|
||||
|
||||
if (! $shipment) {
|
||||
Log::warning('[DHL Tracking Service] Batch: tracking number not mapped', [
|
||||
'tracking_number' => $trackingNo,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove from map so we can detect missing ones later
|
||||
unset($shipmentMap[$trackingNo]);
|
||||
|
||||
$internalStatus = self::mapDhlStatusToInternal($trackingResult['status']);
|
||||
|
||||
$updateData = [
|
||||
'status' => $internalStatus,
|
||||
'tracking_status' => $trackingResult['status_text'],
|
||||
'last_tracked_at' => now(),
|
||||
];
|
||||
|
||||
// Mark tracking as completed if terminal status reached
|
||||
$isCompleted = in_array($internalStatus, DhlShipment::TERMINAL_STATUSES);
|
||||
if ($isCompleted) {
|
||||
$updateData['tracking_completed_at'] = now();
|
||||
$stats['completed']++;
|
||||
}
|
||||
|
||||
$shipment->update($updateData);
|
||||
|
||||
// Save tracking events
|
||||
$this->saveTrackingEvents($shipment, $trackingResult['events'] ?? []);
|
||||
|
||||
$stats['updated']++;
|
||||
$stats['results'][] = [
|
||||
'shipment_id' => $shipment->id,
|
||||
'tracking_number' => $trackingNo,
|
||||
'status' => $internalStatus,
|
||||
'completed' => $isCompleted,
|
||||
'success' => true,
|
||||
];
|
||||
if (! empty($result['success'])) {
|
||||
$stats['updated']++;
|
||||
if (! empty($result['tracking_completed'])) {
|
||||
$stats['completed']++;
|
||||
}
|
||||
|
||||
// Any remaining shipments in the map were not returned by the API
|
||||
foreach ($shipmentMap as $trackingNo => $shipment) {
|
||||
// Update last_tracked_at so we don't immediately retry
|
||||
$shipment->update(['last_tracked_at' => now()]);
|
||||
$stats['failed']++;
|
||||
$stats['results'][] = [
|
||||
'shipment_id' => $shipment->id,
|
||||
'tracking_number' => $trackingNo,
|
||||
'success' => false,
|
||||
'message' => 'Nicht in Batch-Antwort enthalten',
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// Entire batch failed - fall back to individual tracking
|
||||
Log::warning('[DHL Tracking Service] Batch tracking failed, falling back to individual tracking', [
|
||||
'tracking_numbers' => $trackingNumbers,
|
||||
'message' => $batchResult['message'] ?? 'Unknown error',
|
||||
]);
|
||||
|
||||
foreach ($chunk as $shipment) {
|
||||
try {
|
||||
$result = $this->updateTracking($shipment, ['auto_retrack' => false]);
|
||||
if ($result['success']) {
|
||||
$stats['updated']++;
|
||||
if (! empty($result['tracking_completed'])) {
|
||||
$stats['completed']++;
|
||||
}
|
||||
} else {
|
||||
$stats['failed']++;
|
||||
}
|
||||
$stats['results'][] = [
|
||||
'shipment_id' => $shipment->id,
|
||||
'tracking_number' => $shipment->dhl_shipment_no,
|
||||
'success' => $result['success'],
|
||||
'fallback' => true,
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
$stats['failed']++;
|
||||
$stats['results'][] = [
|
||||
'shipment_id' => $shipment->id,
|
||||
'tracking_number' => $shipment->dhl_shipment_no,
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
'fallback' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
Log::error('[DHL Tracking Service] Batch tracking exception', [
|
||||
'tracking_numbers' => $trackingNumbers,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
// Mark all as failed but update last_tracked_at
|
||||
foreach ($chunk as $shipment) {
|
||||
$shipment->update(['last_tracked_at' => now()]);
|
||||
$stats['failed']++;
|
||||
$stats['results'][] = [
|
||||
'shipment_id' => $shipment->id,
|
||||
'tracking_number' => $shipment->dhl_shipment_no,
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
'success' => true,
|
||||
'completed' => ! empty($result['tracking_completed']),
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$stats['failed']++;
|
||||
$stats['results'][] = [
|
||||
'shipment_id' => $shipment->id,
|
||||
'tracking_number' => $shipment->dhl_shipment_no,
|
||||
'success' => false,
|
||||
'message' => $result['message'] ?? null,
|
||||
'auth_error' => $result['auth_error'] ?? false,
|
||||
'rate_limited' => $result['rate_limited'] ?? false,
|
||||
];
|
||||
|
||||
if (! empty($result['auth_error']) || ! empty($result['rate_limited'])) {
|
||||
Log::warning('[DHL Tracking Service] Batch tracking aborted', [
|
||||
'reason' => ! empty($result['auth_error']) ? 'auth_error' : 'rate_limited',
|
||||
'processed' => $stats['updated'] + $stats['failed'],
|
||||
'remaining' => $shipments->count() - ($stats['updated'] + $stats['failed']),
|
||||
'http_status' => $result['http_status'] ?? null,
|
||||
]);
|
||||
|
||||
return $stats;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$stats['failed']++;
|
||||
$stats['results'][] = [
|
||||
'shipment_id' => $shipment->id,
|
||||
'tracking_number' => $shipment->dhl_shipment_no,
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
'transport_error' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -594,7 +754,8 @@ class DhlTrackingService
|
|||
'updated' => $stats['updated'],
|
||||
'failed' => $stats['failed'],
|
||||
'completed' => $stats['completed'],
|
||||
'chunks' => $chunks->count(),
|
||||
'http_calls' => $sentCalls,
|
||||
'call_interval_seconds' => self::$callIntervalSeconds,
|
||||
]);
|
||||
|
||||
return $stats;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue