27-05-2026 DHL Modul v2.1 / Optimierung tracking

This commit is contained in:
Kevin Adametz 2026-05-27 18:51:23 +02:00
parent 036595be94
commit 2bdc9ada3c
33 changed files with 2367 additions and 2086 deletions

View file

@ -179,7 +179,7 @@ class DhlShipmentController extends Controller
return '<span class="text-muted">N/A</span>';
})
->addColumn('customer', function ($shipment) {
return $shipment->firstname.' '.$shipment->lastname;
return e(trim($shipment->firstname.' '.$shipment->lastname));
})
->editColumn('dhl_shipment_no', function ($shipment) {
return $shipment->dhl_shipment_no ? '<code class="text-success">'.e($shipment->dhl_shipment_no).'</code>' : '<span class="text-muted">-</span>';
@ -573,11 +573,13 @@ class DhlShipmentController extends Controller
private function getBillingAddressForReturn($shippingUser, array $recipient): array
{
if (! $shippingUser) {
Log::warning('[DHL Controller] No shipping user found, using recipient data', [
'recipient' => $recipient,
Log::warning('[DHL Controller] No shipping user found, using recipient country only', [
'order_recipient_country' => $recipient['country'] ?? null,
]);
// Fallback: use recipient data but without Packstation fields
// Fallback: use recipient data but without Packstation fields.
// We keep ISO-2 country codes here so that ReturnsService /
// DhlProductResolver can normalize / validate them consistently.
return [
'name' => trim(($recipient['firstname'] ?? '').' '.($recipient['lastname'] ?? '')),
'name2' => $recipient['company'] ?? '',
@ -585,7 +587,7 @@ class DhlShipmentController extends Controller
'houseNumber' => '',
'postalCode' => $recipient['postalCode'] ?? '',
'city' => $recipient['city'] ?? '',
'country' => $recipient['country'] ?? 'DEU',
'country' => $recipient['country'] ?? DhlProductResolver::DOMESTIC_COUNTRY,
'email' => $recipient['email'] ?? '',
'phone' => $recipient['phone'] ?? '',
];
@ -609,7 +611,7 @@ class DhlShipmentController extends Controller
'houseNumber' => $houseNumber,
'postalCode' => $shippingUser->billing_zipcode ?? '',
'city' => $shippingUser->billing_city ?? '',
'country' => $shippingUser->billing_country?->code ?? 'DEU',
'country' => $shippingUser->billing_country?->code ?? DhlProductResolver::DOMESTIC_COUNTRY,
'email' => $shippingUser->billing_email ?? '',
'phone' => $shippingUser->billing_phone ?? '',
];
@ -665,7 +667,7 @@ class DhlShipmentController extends Controller
'houseNumber' => $recipient['houseNumber'] ?? '',
'postalCode' => $recipient['postalCode'] ?? '',
'city' => $recipient['city'] ?? '',
'country' => $recipient['country'] ?? 'DEU',
'country' => $recipient['country'] ?? DhlProductResolver::DOMESTIC_COUNTRY,
'email' => $recipient['email'] ?? '',
'phone' => $recipient['phone'] ?? '',
];
@ -688,7 +690,7 @@ class DhlShipmentController extends Controller
'houseNumber' => $dhlConfig['sender']['house_number'] ?? '2',
'postalCode' => $dhlConfig['sender']['postalCode'] ?? '87755',
'city' => $dhlConfig['sender']['city'] ?? 'Kirchhaslach',
'country' => $dhlConfig['sender']['country'] ?? 'DEU',
'country' => $dhlConfig['sender']['country'] ?? DhlProductResolver::DOMESTIC_COUNTRY,
'email' => $dhlConfig['sender']['email'] ?? 'versand@mivita.care',
'phone' => $dhlConfig['sender']['phone'] ?? '+49 123 456789',
],

View file

@ -172,6 +172,7 @@ class ModalController extends Controller
}
if ($data['action'] === 'create-dhl-shipment') {
$this->authorizeDhlShipmentModal();
$id = $data['id'] ?? null;
$ret = $this->handleDhlShipmentModal($id, $data);
}
@ -202,6 +203,23 @@ class ModalController extends Controller
return null;
}
/**
* Ensure the current user is allowed to use the DHL shipment modal.
*
* The DHL cockpit is an admin-only tool. Without this guard a logged-in
* CRM user could call `POST /modal/load` with `action=create-dhl-shipment`
* and an arbitrary order id and would receive that order's recipient
* name, address, e-mail and existing shipments (IDOR).
*/
private function authorizeDhlShipmentModal(): void
{
$user = \Auth::user();
if (! $user || ! method_exists($user, 'isAdmin') || ! $user->isAdmin()) {
abort(403, 'DHL shipment modal is only available for admin users.');
}
}
/**
* Handle DHL shipment modal preparation
*

View file

@ -4,11 +4,26 @@ namespace App\Http\Controllers;
use App\Models\Setting;
use App\Services\DhlProductResolver;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Session;
use Request;
class SettingController extends Controller
{
/**
* In-memory cache of the resolved DHL configuration for the lifetime
* of the current PHP process (request, queue job, CLI command).
*
* `getDhlConfig()` is invoked from several layers per shipment
* (`DhlShipmentService`, `CreateShipmentJob`, `DhlShipmentController`
* cancel/return paths, ) and each call previously triggered ~25 DB
* queries against `settings`. Caching here turns subsequent calls
* into pure in-memory lookups.
*
* @var array<string, mixed>|null
*/
private static ?array $cachedDhlConfig = null;
public function __construct()
{
$this->middleware('admin');
@ -31,6 +46,9 @@ class SettingController extends Controller
if (isset($data['settings'])) {
foreach ($data['settings'] as $key => $value) {
$value['val'] = isset($value['val']) ? $value['val'] : false;
if ($key === 'dhl_international_countries') {
$value['val'] = DhlProductResolver::normalizeCountryCodeList(is_array($value['val']) ? $value['val'] : []);
}
Setting::setContentBySlug($key, $value['val'], $value['type']);
}
}
@ -40,6 +58,9 @@ class SettingController extends Controller
$this->updateDhlConfigCache();
Session::flash('alert-save-dhl', 'DHL Konfiguration erfolgreich gespeichert!');
} else {
// Any other setting change could still affect cached config
// values (e.g. shared sender data), so invalidate just in case.
self::flushDhlConfigCache();
Session::flash('alert-save', '1');
}
}
@ -47,14 +68,34 @@ class SettingController extends Controller
return redirect(route('admin_settings'));
}
/**
* Drop the in-process DHL configuration cache.
*
* Call this whenever a DHL-related setting is changed so the next
* call to {@see getDhlConfig()} reads fresh data from the database.
*/
public static function flushDhlConfigCache(): void
{
self::$cachedDhlConfig = null;
}
/**
* Get DHL configuration merged from database settings and .env values
* Priority is controlled by DHL_CONFIG_SOURCE environment variable:
* - 'database' (default): Database settings override .env values
* - 'env': Environment/Config values override database settings
*
* The result is cached per process; use {@see flushDhlConfigCache()}
* to invalidate the cache after changing settings.
*
* @return array<string, mixed>
*/
public function getDhlConfig()
{
if (self::$cachedDhlConfig !== null) {
return self::$cachedDhlConfig;
}
// Check if we're in test/sandbox mode
$isTestMode = config('dhl.legacy.test_mode', false) || config('dhl.legacy.sandbox', false);
$baseUrl = $isTestMode ? config('dhl.sandbox_url') : config('dhl.base_url');
@ -62,7 +103,7 @@ class SettingController extends Controller
// Determine configuration priority
$useEnvPriority = config('dhl.config_source') === 'env';
return [
return self::$cachedDhlConfig = [
// API Settings
'base_url' => $isTestMode ? $baseUrl : $this->getConfigValue('dhl_base_url', $baseUrl, $useEnvPriority),
'api_key' => $this->getConfigValue('dhl_api_key', config('dhl.api_key'), $useEnvPriority),
@ -152,9 +193,14 @@ class SettingController extends Controller
{
$configCountries = config('dhl.international_countries', DhlProductResolver::DEFAULT_INTERNATIONAL_COUNTRIES);
$countries = $configCountries;
$storedCountries = Schema::hasTable('settings')
? Setting::getContentBySlug('dhl_international_countries')
: false;
if (! $useEnvPriority) {
$countries = Setting::getContentBySlug('dhl_international_countries') ?: $configCountries;
if (is_array($storedCountries)) {
$countries = $storedCountries;
} elseif (! $useEnvPriority) {
$countries = $storedCountries ?: $configCountries;
}
return DhlProductResolver::normalizeCountryCodeList(is_array($countries) ? $countries : []);
@ -165,6 +211,10 @@ class SettingController extends Controller
*/
private function updateDhlConfigCache()
{
// Drop the in-process DHL config cache so the next call rebuilds it
// from the freshly saved settings.
self::flushDhlConfigCache();
// Clear config cache to force reload from database
\Artisan::call('config:clear');

View file

@ -14,7 +14,7 @@ use Illuminate\Support\Facades\Log;
/**
* Job to create DHL shipments asynchronously
*
*
* This job handles the creation of DHL shipments in the background,
* preventing API timeouts and improving user experience.
*/
@ -37,11 +37,6 @@ class CreateShipmentJob implements ShouldQueue
*/
public $options;
/**
* @var array
*/
public $dhlConfig;
/**
* The number of times the job may be attempted.
*
@ -59,10 +54,16 @@ class CreateShipmentJob implements ShouldQueue
/**
* Create a new job instance.
*
* @param ShoppingOrder $shoppingOrder
* @param float $weight
* @param array $options
* @param array|null $dhlConfig
* IMPORTANT: We intentionally never serialize the DHL configuration into
* the queue payload because it contains the DHL API key, basic-auth
* password and billing numbers. Those values would otherwise sit
* unencrypted in Redis/the queue table. The config is reloaded inside
* {@see self::handle()} from the canonical settings source instead.
*
* The legacy `$dhlConfig` parameter is still accepted for backwards
* compatibility but is no longer persisted onto the job instance.
*
* @param array $dhlConfig Deprecated: ignored to avoid secrets in queue
*/
public function __construct(ShoppingOrder $shoppingOrder, float $weight = 1.0, array $options = [], array $dhlConfig = [])
{
@ -70,14 +71,6 @@ class CreateShipmentJob implements ShouldQueue
$this->weight = $weight;
$this->options = $options;
// Load DHL config once when creating the job
if (empty($dhlConfig)) {
$settingController = new \App\Http\Controllers\SettingController();
$this->dhlConfig = $settingController->getDhlConfig();
} else {
$this->dhlConfig = $dhlConfig;
}
// Set queue name based on priority
if (isset($options['priority']) && $options['priority'] === 'high') {
$this->onQueue('high-priority');
@ -98,18 +91,20 @@ class CreateShipmentJob implements ShouldQueue
'attempt' => $this->attempts(),
]);
// Use DHL configuration loaded in constructor
// Load DHL configuration here, never from the serialized job payload.
$dhlConfig = (new \App\Http\Controllers\SettingController)->getDhlConfig();
$dhlClient = new \Acme\Dhl\Support\DhlClient(
$this->dhlConfig['base_url'],
$this->dhlConfig['api_key'],
$this->dhlConfig['username'],
$this->dhlConfig['password']
$dhlConfig['base_url'],
$dhlConfig['api_key'],
$dhlConfig['username'],
$dhlConfig['password']
);
$shippingService = new \Acme\Dhl\Services\ShippingService($dhlClient);
// Prepare order data using helper
$orderData = DhlDataHelper::prepareOrderData($this->shoppingOrder, $this->weight, $this->options, $this->dhlConfig);
$orderData = DhlDataHelper::prepareOrderData($this->shoppingOrder, $this->weight, $this->options, $dhlConfig);
// Create the shipment using new package
$result = $shippingService->createLabel($orderData);
@ -121,9 +116,9 @@ class CreateShipmentJob implements ShouldQueue
]);
// Trigger follow-up actions if specified (if tracking number available)
if (isset($this->options['auto_track']) && $this->options['auto_track'] && !empty($result['trackingNumber'])) {
if (isset($this->options['auto_track']) && $this->options['auto_track'] && ! empty($result['trackingNumber'])) {
Log::info('[DHL Queue] Scheduling tracking update', [
'tracking_number' => $result['trackingNumber']
'tracking_number' => $result['trackingNumber'],
]);
// Note: TrackShipmentJob would need to be updated to work with tracking numbers
}
@ -149,8 +144,6 @@ class CreateShipmentJob implements ShouldQueue
/**
* Handle a job failure.
*
* @param Exception $exception
*/
public function failed(Exception $exception): void
{
@ -166,7 +159,6 @@ class CreateShipmentJob implements ShouldQueue
// - Create manual task for staff
}
/**
* Determine the time at which the job should timeout.
*

View file

@ -25,7 +25,7 @@ use Illuminate\Database\Eloquent\Model;
* @property string|null $type
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @package App\Models
*
* @method static \Illuminate\Database\Eloquent\Builder|Setting newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Setting newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Setting query()
@ -42,31 +42,32 @@ use Illuminate\Database\Eloquent\Model;
* @method static \Illuminate\Database\Eloquent\Builder|Setting whereText($value)
* @method static \Illuminate\Database\Eloquent\Builder|Setting whereType($value)
* @method static \Illuminate\Database\Eloquent\Builder|Setting whereUpdatedAt($value)
*
* @mixin \Eloquent
*/
class Setting extends Model
{
protected $table = 'settings';
protected $table = 'settings';
protected $casts = [
'referenz' => 'int',
'status' => 'int',
protected $casts = [
'referenz' => 'int',
'status' => 'int',
'int' => 'int',
'object' => 'array'
];
'object' => 'array',
];
protected $fillable = [
'identifier',
'slug',
'referenz',
'action',
'object',
'full_text',
protected $fillable = [
'identifier',
'slug',
'referenz',
'action',
'object',
'full_text',
'text',
'int',
'status',
'type'
];
'type',
];
protected static $types = [
'object' => 'Object',
@ -75,21 +76,22 @@ class Setting extends Model
'int' => 'Zahl',
'bool' => 'Bool',
];
public function sluggable() : array
public function sluggable(): array
{
return [
'slug' => [
'source' => 'name'
]
'source' => 'name',
],
];
}
public static function getContentBySlug($slug){
public static function getContentBySlug($slug)
{
$content = self::whereSlug(trim($slug))->first();
if($content){
switch ($content->type){
if ($content) {
switch ($content->type) {
case 'object':
return $content->object;
break;
@ -107,28 +109,30 @@ class Setting extends Model
break;
}
}
return false;
}
public static function setContentBySlug($slug, $value, $type = "full_text"){
public static function setContentBySlug($slug, $value, $type = 'full_text')
{
$content = self::whereSlug(trim($slug))->first();
if(!$content) {
if (! $content) {
$content = self::create([
'slug' => $slug,
'type' => $type,
]);
}
$content->type = $type;
switch ($content->type){
switch ($content->type) {
case 'object':
$content->object = $value ? $value : null;;
$content->object = is_array($value) ? $value : ($value ?: null);
break;
case 'full_text':
$content->full_text = $value ? $value : null;;
$content->full_text = $value ? $value : null;
break;
case 'text':
$content->text = $value ? $value : null;;
$content->text = $value ? $value : null;
break;
case 'int':
$content->int = (int) $value;
@ -139,6 +143,7 @@ class Setting extends Model
}
$content->save();
return $content;
}
}

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,7 @@ namespace App\Services;
use App\Http\Controllers\SettingController;
use App\Models\ShoppingOrder;
use Illuminate\Support\Facades\Log;
/**
* DHL Data Helper
@ -23,7 +24,14 @@ class DhlDataHelper
*/
public static function prepareOrderData(ShoppingOrder $order, float $weight, array $options = [], ?array $dhlConfig = null): array
{
\Log::info('prepareOrderData', $options);
Log::info('[DHL DataHelper] Preparing order data', [
'order_id' => $order->id,
'product_code' => $options['product_code'] ?? null,
'has_shipping_address' => isset($options['shipping_address']),
'has_reference' => ! empty($options['reference'] ?? $options['shipment_reference'] ?? null),
'print_only_if_codeable' => (bool) ($options['print_only_if_codeable'] ?? false),
]);
// die daten für das versandlabel werden immer aus dem Formular genommen, damit anpassungen möglich sind
if (! isset($options['shipping_address'])) {
throw new \Exception('shipping_address is required');

View file

@ -3,6 +3,7 @@
namespace App\Services;
use App\Models\Setting;
use Illuminate\Support\Facades\Schema;
use InvalidArgumentException;
class DhlProductResolver
@ -13,7 +14,7 @@ class DhlProductResolver
public const INTERNATIONAL_PRODUCT_CODE = 'V53PAK';
public const DEFAULT_INTERNATIONAL_COUNTRIES = ['AT', 'ES'];
public const DEFAULT_INTERNATIONAL_COUNTRIES = ['AT', 'ES', 'CH', 'NL', 'BE', 'FR'];
public const DHL_COUNTRY_CODES = [
'DE' => 'DEU',
@ -172,9 +173,14 @@ class DhlProductResolver
$useEnvPriority = config('dhl.config_source') === 'env';
$configCountries = config('dhl.international_countries', self::DEFAULT_INTERNATIONAL_COUNTRIES);
$countries = $configCountries;
$storedCountries = Schema::hasTable('settings')
? Setting::getContentBySlug('dhl_international_countries')
: false;
if (! $useEnvPriority) {
$countries = Setting::getContentBySlug('dhl_international_countries') ?: $configCountries;
if (is_array($storedCountries)) {
$countries = $storedCountries;
} elseif (! $useEnvPriority) {
$countries = $storedCountries ?: $configCountries;
}
return self::normalizeCountryCodeList(is_array($countries) ? $countries : []);

View file

@ -29,7 +29,7 @@ class DhlShipmentService
// Get DHL configuration
$settingController = new SettingController;
$dhlConfig = $settingController->getDhlConfig();
\Log::info('dhlConfig', $dhlConfig);
Log::info('[DHL Service] Loaded DHL configuration', self::sanitizeDhlConfigForLog($dhlConfig));
// Check if queue should be used
$useQueue = $dhlConfig['use_queue'] ?? false;
if ($useQueue && $this->requiresSynchronousAddressValidation($options, $dhlConfig)) {
@ -115,7 +115,8 @@ class DhlShipmentService
// Prepare order data using helper
$orderData = DhlDataHelper::prepareOrderData($order, $weight, $options, $dhlConfig);
Log::info('orderData', $orderData);
Log::info('[DHL Service] Order data prepared for DHL API', self::sanitizeOrderDataForLog($orderData));
// Create the shipment directly
$result = $shippingService->createLabel($orderData);
@ -361,4 +362,65 @@ class DhlShipmentService
return null;
}
/**
* Build a redacted view of the DHL configuration for safe logging.
*
* Never include `api_key`, `username`, `password`, `api_secret` or the
* full billing number itself. Only return boolean presence flags and
* non-sensitive metadata.
*
* @param array<string, mixed> $dhlConfig
* @return array<string, mixed>
*/
public static function sanitizeDhlConfigForLog(array $dhlConfig): array
{
return [
'base_url' => $dhlConfig['base_url'] ?? null,
'sandbox' => $dhlConfig['sandbox'] ?? null,
'test_mode' => $dhlConfig['test_mode'] ?? null,
'has_api_key' => ! empty($dhlConfig['api_key']),
'has_username' => ! empty($dhlConfig['username']),
'has_password' => ! empty($dhlConfig['password']),
'has_api_secret' => ! empty($dhlConfig['api_secret']),
'use_queue' => (bool) ($dhlConfig['use_queue'] ?? false),
'default_product' => $dhlConfig['default_product'] ?? null,
'label_format' => $dhlConfig['label_format'] ?? null,
'print_format' => $dhlConfig['print_format'] ?? null,
'print_only_if_codeable' => (bool) ($dhlConfig['print_only_if_codeable'] ?? false),
'international_countries' => $dhlConfig['international_countries'] ?? [],
'account_numbers_configured' => array_keys(array_filter($dhlConfig['account_numbers'] ?? [])),
];
}
/**
* Build a redacted view of the order data prepared for DHL.
*
* Strips personally identifiable information like full names, addresses,
* phone numbers and e-mail addresses. Keeps the routing-relevant fields
* needed to debug a failing label generation.
*
* @param array<string, mixed> $orderData
* @return array<string, mixed>
*/
public static function sanitizeOrderDataForLog(array $orderData): array
{
$consigneeCountry = $orderData['consignee']['country'] ?? null;
$consigneePostal = $orderData['consignee']['postalCode'] ?? null;
return [
'order_id' => $orderData['order_id'] ?? null,
'product_code' => $orderData['product_code'] ?? null,
'weight_kg' => $orderData['weight_kg'] ?? null,
'label_format' => $orderData['label_format'] ?? null,
'print_format' => $orderData['print_format'] ?? null,
'print_only_if_codeable' => (bool) ($orderData['print_only_if_codeable'] ?? false),
'consignee_country' => $consigneeCountry,
'consignee_postal_prefix' => is_string($consigneePostal) && $consigneePostal !== ''
? mb_substr($consigneePostal, 0, 2)
: null,
'consignee_has_post_number' => ! empty($orderData['consignee']['postNumber']),
'has_reference' => ! empty($orderData['reference']),
];
}
}

View file

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