mivita/app/Services/DhlTrackingService.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,
};
}
}