854 lines
32 KiB
PHP
854 lines
32 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
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 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;
|
|
|
|
public function __construct()
|
|
{
|
|
$settingController = new SettingController;
|
|
$dhlConfig = $settingController->getDhlConfig();
|
|
|
|
$this->apiKey = $dhlConfig['api_key'] ?? config('dhl.api_key');
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
'endpoint' => self::TRACKING_ENDPOINT,
|
|
'has_api_key' => ! empty($this->apiKey),
|
|
]);
|
|
|
|
$response = Http::withHeaders([
|
|
'DHL-API-Key' => $this->apiKey,
|
|
'Accept' => 'application/json',
|
|
])
|
|
->withOptions($this->buildHttpOptions())
|
|
->get(self::TRACKING_ENDPOINT, [
|
|
'trackingNumber' => $trackingNumber,
|
|
'requesterCountryCode' => 'DE',
|
|
'originCountryCode' => 'DE',
|
|
'language' => 'de',
|
|
]);
|
|
|
|
return $this->processSingleShipmentResponse($trackingNumber, $response);
|
|
} catch (Exception $e) {
|
|
Log::error('[DHL Tracking Service] Unified API request threw', [
|
|
'tracking_number' => $trackingNumber,
|
|
'endpoint' => self::TRACKING_ENDPOINT,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return [
|
|
'success' => false,
|
|
'message' => 'Fehler beim Abrufen der Tracking-Informationen: '.$e->getMessage(),
|
|
'tracking_number' => $trackingNumber,
|
|
'api_used' => 'unified',
|
|
'transport_error' => true,
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the standard cURL/Guzzle options used for every DHL tracking call.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildHttpOptions(): array
|
|
{
|
|
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',
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
|
|
Log::info('[DHL Tracking Service] Unified API response', [
|
|
'tracking_number' => $trackingNumber,
|
|
'status_code' => $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 returned no shipments', [
|
|
'tracking_number' => $trackingNumber,
|
|
'status_code' => $status,
|
|
]);
|
|
|
|
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,
|
|
];
|
|
}
|
|
|
|
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,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Update tracking for a DHL shipment (sync or async based on config)
|
|
*/
|
|
public function updateTracking(DhlShipment $shipment, array $options = []): array
|
|
{
|
|
// Get DHL configuration
|
|
$settingController = new SettingController;
|
|
$dhlConfig = $settingController->getDhlConfig();
|
|
|
|
// Check if queue should be used
|
|
$useQueue = $dhlConfig['use_queue'] ?? false;
|
|
|
|
if ($useQueue) {
|
|
return $this->updateTrackingAsync($shipment, $options, $dhlConfig);
|
|
} else {
|
|
return $this->updateTrackingSync($shipment, $options, $dhlConfig);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update tracking immediately, bypassing queue dispatch.
|
|
*/
|
|
public function updateTrackingNow(DhlShipment $shipment, array $options = []): array
|
|
{
|
|
$settingController = new SettingController;
|
|
|
|
return $this->updateTrackingSync($shipment, $options, $settingController->getDhlConfig());
|
|
}
|
|
|
|
/**
|
|
* Update tracking asynchronously using queue
|
|
*/
|
|
private function updateTrackingAsync(DhlShipment $shipment, array $options, array $dhlConfig): array
|
|
{
|
|
try {
|
|
// Dispatch job with pre-loaded config
|
|
TrackShipmentJob::dispatch($shipment, $options);
|
|
|
|
Log::info('[DHL Tracking Service] Tracking update dispatched to queue', [
|
|
'shipment_id' => $shipment->id,
|
|
'dhl_shipment_no' => $shipment->dhl_shipment_no,
|
|
]);
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => 'Tracking-Update wird verarbeitet. Sie erhalten eine Benachrichtigung, sobald die Informationen aktualisiert sind.',
|
|
'queued' => true,
|
|
'shipment_id' => $shipment->id,
|
|
];
|
|
} catch (Exception $e) {
|
|
Log::error('[DHL Tracking Service] Failed to dispatch tracking update', [
|
|
'error' => $e->getMessage(),
|
|
'shipment_id' => $shipment->id,
|
|
]);
|
|
|
|
return [
|
|
'success' => false,
|
|
'message' => 'Fehler beim Einreihen des Tracking-Updates: '.$e->getMessage(),
|
|
'queued' => false,
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update tracking synchronously using new DHL APIs
|
|
*/
|
|
private function updateTrackingSync(DhlShipment $shipment, array $options, array $dhlConfig): array
|
|
{
|
|
try {
|
|
Log::info('[DHL Tracking Service] Updating tracking synchronously', [
|
|
'shipment_id' => $shipment->id,
|
|
'dhl_shipment_no' => $shipment->dhl_shipment_no,
|
|
]);
|
|
|
|
// Check if shipment has tracking number
|
|
if (! $shipment->dhl_shipment_no) {
|
|
return [
|
|
'success' => false,
|
|
'message' => 'Keine DHL-Sendungsnummer verfügbar für Tracking.',
|
|
'queued' => false,
|
|
'shipment_id' => $shipment->id,
|
|
];
|
|
}
|
|
|
|
// Use new tracking API
|
|
$result = $this->trackShipment($shipment->dhl_shipment_no);
|
|
|
|
if ($result['success']) {
|
|
$internalStatus = self::mapDhlStatusToInternal($result['status']);
|
|
|
|
// Update shipment with tracking data
|
|
$updateData = [
|
|
'status' => $internalStatus,
|
|
'tracking_status' => $result['status_text'],
|
|
'last_tracked_at' => now(),
|
|
];
|
|
|
|
// Mark tracking as completed if terminal status reached
|
|
if (in_array($internalStatus, DhlShipment::TERMINAL_STATUSES)) {
|
|
$updateData['tracking_completed_at'] = now();
|
|
}
|
|
|
|
$shipment->update($updateData);
|
|
|
|
// Save tracking events
|
|
$this->saveTrackingEvents($shipment, $result['events'] ?? []);
|
|
|
|
Log::info('[DHL Tracking Service] Tracking updated successfully (sync)', [
|
|
'shipment_id' => $shipment->id,
|
|
'dhl_shipment_no' => $shipment->dhl_shipment_no,
|
|
'tracking_status' => $result['status'],
|
|
'tracking_completed' => in_array($internalStatus, DhlShipment::TERMINAL_STATUSES),
|
|
'events_count' => count($result['events'] ?? []),
|
|
'api_used' => $result['api_used'],
|
|
]);
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => 'Tracking-Informationen erfolgreich aktualisiert!',
|
|
'queued' => false,
|
|
'shipment_id' => $shipment->id,
|
|
'tracking_status' => $result['status'],
|
|
'tracking_completed' => in_array($internalStatus, DhlShipment::TERMINAL_STATUSES),
|
|
'tracking_details' => $result,
|
|
];
|
|
}
|
|
|
|
// 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,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return [
|
|
'success' => false,
|
|
'message' => 'Fehler beim Aktualisieren der Tracking-Informationen: '.$e->getMessage(),
|
|
'queued' => false,
|
|
'shipment_id' => $shipment->id,
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update tracking for a collection of DHL shipments.
|
|
*
|
|
* 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
|
|
{
|
|
$stats = [
|
|
'updated' => 0,
|
|
'failed' => 0,
|
|
'completed' => 0,
|
|
'results' => [],
|
|
];
|
|
|
|
if ($pausedUntil = self::getQuotaPausedUntil()) {
|
|
Log::warning('[DHL Tracking Service] Batch tracking skipped - quota pause active', [
|
|
'count' => $shipments->count(),
|
|
'paused_until' => $pausedUntil->toIso8601String(),
|
|
]);
|
|
|
|
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(),
|
|
];
|
|
}
|
|
|
|
return $stats;
|
|
}
|
|
|
|
$index = 0;
|
|
$sentCalls = 0;
|
|
|
|
foreach ($shipments as $shipment) {
|
|
if ($index > 0 && self::$callIntervalSeconds > 0) {
|
|
sleep(self::$callIntervalSeconds);
|
|
}
|
|
$index++;
|
|
|
|
try {
|
|
$result = $this->updateTracking($shipment, ['auto_retrack' => false]);
|
|
$sentCalls++;
|
|
|
|
if (! empty($result['success'])) {
|
|
$stats['updated']++;
|
|
if (! empty($result['tracking_completed'])) {
|
|
$stats['completed']++;
|
|
}
|
|
$stats['results'][] = [
|
|
'shipment_id' => $shipment->id,
|
|
'tracking_number' => $shipment->dhl_shipment_no,
|
|
'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,
|
|
];
|
|
}
|
|
}
|
|
|
|
Log::info('[DHL Tracking Service] Batch tracking completed', [
|
|
'total' => $shipments->count(),
|
|
'updated' => $stats['updated'],
|
|
'failed' => $stats['failed'],
|
|
'completed' => $stats['completed'],
|
|
'http_calls' => $sentCalls,
|
|
'call_interval_seconds' => self::$callIntervalSeconds,
|
|
]);
|
|
|
|
return $stats;
|
|
}
|
|
|
|
/**
|
|
* Map DHL status codes to internal status
|
|
*/
|
|
public static function mapDhlStatusToInternal(string $dhlStatus): string
|
|
{
|
|
$statusMap = [
|
|
'pre-transit' => 'created',
|
|
'pre_transit' => 'created',
|
|
'pretransit' => 'created',
|
|
'transit' => 'in_transit',
|
|
'in-transit' => 'in_transit',
|
|
'in_transit' => 'in_transit',
|
|
'out-for-delivery' => 'out_for_delivery',
|
|
'out_for_delivery' => 'out_for_delivery',
|
|
'delivered' => 'delivered',
|
|
'failure' => 'failed',
|
|
'failed' => 'failed',
|
|
'returned' => 'returned',
|
|
'exception' => 'exception',
|
|
];
|
|
|
|
return $statusMap[strtolower($dhlStatus)] ?? 'unknown';
|
|
}
|
|
|
|
/**
|
|
* Get status description in German
|
|
*/
|
|
public function getStatusDescription(string $statusCode): string
|
|
{
|
|
$descriptions = [
|
|
'pre-transit' => 'Auftrag elektronisch übermittelt',
|
|
'transit' => 'Sendung in Zustellung',
|
|
'out-for-delivery' => 'Wird heute zugestellt',
|
|
'delivered' => 'Erfolgreich zugestellt',
|
|
'failure' => 'Zustellung fehlgeschlagen',
|
|
'returned' => 'Sendung wird zurückgeschickt',
|
|
'exception' => 'Zustellausnahme',
|
|
];
|
|
|
|
return $descriptions[$statusCode] ?? 'Unbekannter Status';
|
|
}
|
|
|
|
/**
|
|
* Save tracking events from API response to database
|
|
*/
|
|
private function saveTrackingEvents(DhlShipment $shipment, array $events): void
|
|
{
|
|
if (empty($events)) {
|
|
return;
|
|
}
|
|
|
|
foreach ($events as $event) {
|
|
$eventTime = isset($event['timestamp']) ? \Carbon\Carbon::parse($event['timestamp']) : now();
|
|
|
|
// Upsert: avoid duplicates based on shipment + event_time + status_code
|
|
DhlTrackingEvent::updateOrCreate(
|
|
[
|
|
'shipment_id' => $shipment->id,
|
|
'event_time' => $eventTime,
|
|
'status_code' => $event['statusCode'] ?? ($event['status'] ?? 'unknown'),
|
|
],
|
|
[
|
|
'status_text' => $event['description'] ?? ($event['remark'] ?? 'Unbekannt'),
|
|
'location' => $event['location']['address']['addressLocality'] ?? null,
|
|
'raw' => $event,
|
|
]
|
|
);
|
|
}
|
|
|
|
Log::info('[DHL Tracking Service] Tracking events saved', [
|
|
'shipment_id' => $shipment->id,
|
|
'events_saved' => count($events),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Get SSL version constant based on configuration
|
|
*/
|
|
private function getSslVersion(): int
|
|
{
|
|
$sslVersion = config('dhl.ssl.ssl_version', 'TLSv1_2');
|
|
|
|
return match ($sslVersion) {
|
|
'TLSv1_0' => CURL_SSLVERSION_TLSv1_0,
|
|
'TLSv1_1' => CURL_SSLVERSION_TLSv1_1,
|
|
'TLSv1_2' => CURL_SSLVERSION_TLSv1_2,
|
|
'TLSv1_3' => defined('CURL_SSLVERSION_TLSv1_3') ? CURL_SSLVERSION_TLSv1_3 : CURL_SSLVERSION_TLSv1_2,
|
|
default => CURL_SSLVERSION_TLSv1_2,
|
|
};
|
|
}
|
|
}
|