mivita/app/Services/DhlTrackingService.php
2026-02-20 17:55:06 +01:00

677 lines
27 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 Exception;
use Illuminate\Support\Collection;
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
*/
class DhlTrackingService
{
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)
*/
public function trackShipment(string $trackingNumber, array $options = []): array
{
try {
Log::info('[DHL Tracking Service] Tracking shipment with Unified API', [
'tracking_number' => $trackingNumber,
'is_sandbox' => $this->isSandbox,
'has_api_key' => ! empty($this->apiKey),
]);
$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' => $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);
} catch (Exception $e) {
Log::error('[DHL Tracking Service] Unified API failed', [
'tracking_number' => $trackingNumber,
'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',
];
}
}
/**
* Track multiple shipments at once (up to 10 for Unified API)
*/
public function trackMultipleShipments(array $trackingNumbers): array
{
if (count($trackingNumbers) > 10) {
return [
'success' => false,
'message' => 'Maximal 10 Sendungen können gleichzeitig getrackt werden.',
];
}
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',
]);
if ($response->successful()) {
$data = $response->json();
$results = [];
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'] ?? [],
];
}
return [
'success' => true,
'shipments' => $results,
'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(),
]);
return [
'success' => false,
'message' => 'Fehler beim Abrufen der Tracking-Informationen: '.$e->getMessage(),
];
}
}
/**
* 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 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 = $this->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,
];
} else {
return [
'success' => false,
'message' => $result['message'] ?? 'Fehler beim Abrufen der Tracking-Informationen.',
'queued' => false,
'shipment_id' => $shipment->id,
];
}
} 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 batch of DHL shipments using the multi-tracking API.
* Processes shipments in chunks of 10 (DHL API limit) with rate-limiting pauses.
*
* @param Collection<DhlShipment> $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' => [],
];
// Process in chunks of 10 (DHL API limit)
$chunks = $shipments->chunk(10);
$chunkIndex = 0;
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;
}
$trackingNumbers = array_keys($shipmentMap);
try {
$batchResult = $this->trackMultipleShipments($trackingNumbers);
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 = $this->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,
];
}
// 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(),
];
}
}
}
Log::info('[DHL Tracking Service] Batch tracking completed', [
'total' => $shipments->count(),
'updated' => $stats['updated'],
'failed' => $stats['failed'],
'completed' => $stats['completed'],
'chunks' => $chunks->count(),
]);
return $stats;
}
/**
* Map DHL status codes to internal status
*/
private function mapDhlStatusToInternal(string $dhlStatus): string
{
$statusMap = [
'pre-transit' => 'created',
'transit' => 'in_transit',
'out-for-delivery' => 'out_for_delivery',
'delivered' => 'delivered',
'failure' => 'failed',
'returned' => 'returned',
'exception' => 'exception',
];
return $statusMap[$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,
};
}
}