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 */ 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 */ 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 $shipments * @return array{updated: int, failed: int, completed: int, results: array>} */ 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, }; } }