diff --git a/.env b/.env index 9fbc49f..b67a720 100644 --- a/.env +++ b/.env @@ -140,20 +140,21 @@ DHL_USE_QUEUE=false # DHL Account Numbers (für verschiedene Produkte) DHL_USERNAME=riwa-tec -DHL_PASSWORD=MivitaCare!!2025 +DHL_PASSWORD=MivitaCare!!2028 DHL_BILLING_NUMBER=63144073550101 DHL_ACCOUNT_NUMBER_DEFAULT=63144073550101 DHL_ACCOUNT_NUMBER_V01PAK=63144073550101 # DHL Paket National -DHL_ACCOUNT_NUMBER_V62WP=63144073556201 # Warenpost National +DHL_ACCOUNT_NUMBER_V62KP=63144073556201 # DHL Kleinpaket DHL_ACCOUNT_NUMBER_V53PAK=63144073555301 # DHL Paket International DHL_ACCOUNT_NUMBER_V07PAK=63144073550701 # DHL Retoure Online + #sandbox #DHL_USERNAME=user-valid #DHL_PASSWORD=SandboxPasswort2023! #DHL_BILLING_NUMBER=33333333330101 #DHL_ACCOUNT_NUMBER_DEFAULT=33333333330101 #DHL_ACCOUNT_NUMBER_V01PAK=33333333330102 # DHL Paket National -#DHL_ACCOUNT_NUMBER_V62WP=33333333336601 # Warenpost National +#DHL_ACCOUNT_NUMBER_V62KP=33333333336601 # DHL Kleinpaket #DHL_ACCOUNT_NUMBER_V53PAK=33333333335301 # DHL Paket International #DHL_ACCOUNT_NUMBER_V07PAK=33333333330702 # DHL Retoure Online diff --git a/app/Http/Controllers/DhlShipmentController.php b/app/Http/Controllers/DhlShipmentController.php index 8af1bc2..27368a3 100644 --- a/app/Http/Controllers/DhlShipmentController.php +++ b/app/Http/Controllers/DhlShipmentController.php @@ -179,7 +179,7 @@ class DhlShipmentController extends Controller return 'N/A'; }) ->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 ? ''.e($shipment->dhl_shipment_no).'' : '-'; @@ -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', ], diff --git a/app/Http/Controllers/ModalController.php b/app/Http/Controllers/ModalController.php index 561690b..4e6c7d5 100644 --- a/app/Http/Controllers/ModalController.php +++ b/app/Http/Controllers/ModalController.php @@ -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 * diff --git a/app/Http/Controllers/SettingController.php b/app/Http/Controllers/SettingController.php index a51a64f..0a385ab 100644 --- a/app/Http/Controllers/SettingController.php +++ b/app/Http/Controllers/SettingController.php @@ -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|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 */ 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'); diff --git a/app/Jobs/CreateShipmentJob.php b/app/Jobs/CreateShipmentJob.php index 7894fe2..f131e20 100644 --- a/app/Jobs/CreateShipmentJob.php +++ b/app/Jobs/CreateShipmentJob.php @@ -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. * diff --git a/app/Models/Setting.php b/app/Models/Setting.php index 6a290fc..0181e98 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -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; } } diff --git a/app/Services/DhlApiService.php b/app/Services/DhlApiService.php deleted file mode 100644 index 8173c5b..0000000 --- a/app/Services/DhlApiService.php +++ /dev/null @@ -1,1312 +0,0 @@ -config = config('dhl'); - $this->isSandbox = $this->config['api']['sandbox'] ?? true; - - $this->initializeClients(); - } - - /** - * Initialize DHL API clients - * - * @throws Exception - */ - private function initializeClients(): void - { - try { - // Get all credentials from the config. - $apiKey = $this->config['api']['api_key'] ?? null; - $apiSecret = $this->config['api']['api_secret'] ?? null; - $username = $this->config['api']['username'] ?? null; - $password = $this->config['api']['password'] ?? null; - - // The ChristophSchaeffer library for the "Geschäftskundenversand API" (SOAP) - // always requires both sets of credentials: - // 1. API Key/Secret for the Application's identity. - // 2. Username/Password for the Business Customer Portal user's identity. - - // We must validate that all four are present. - if (empty($apiKey) || empty($apiSecret)) { - throw new Exception('DHL API Key (DHL_API_KEY) and API Secret (DHL_API_SECRET) are missing. Please get them from the DHL Developer Portal.'); - } - if (empty($username) || empty($password)) { - throw new Exception('DHL API Username (DHL_API_USERNAME) and Password (DHL_API_PASSWORD) are missing. Please get them from your customer\'s DHL Business Customer Portal.'); - } - - $shippingCredentials = new ShippingClientCredentials( - $apiKey, - $apiSecret, - $username, - $password - ); - - $this->shippingClient = new ShippingClient( - $shippingCredentials, - $this->isSandbox, - MultiClient::LANGUAGE_LOCALE_GERMAN_DE - ); - - // Initialize Tracking Client (if enabled) - if ($this->config['tracking']['enabled']) { - // For tracking, the ZT-Token is often used as the "user" context - $trackingUser = $this->config['tracking']['username'] ?? 'zt12345'; // Sandbox default - $trackingPass = $this->config['tracking']['password'] ?? 'geheim'; // Sandbox default - - $trackingCredentials = new TrackingClientCredentials( - $apiKey, - $apiSecret, - $trackingUser, - $trackingPass - ); - - $this->trackingClient = new TrackingClient( - $trackingCredentials, - $this->isSandbox - ); - } - - } catch (Exception $e) { - $this->logError('Failed to initialize DHL clients', $e); - throw new Exception('DHL API initialization failed: ' . $e->getMessage()); - } - } - - /** - * Create a shipment for an order - * - * @param ShoppingOrder $order The shopping order - * @param float $weight Package weight in kg - * @param array $options Additional options - * @return DhlShipment The created shipment - * @throws Exception - */ - public function createShipment(ShoppingOrder $order, float $weight = 1.0, array $options = []): DhlShipment - { - try { - $this->logInfo('Creating DHL shipment', [ - 'order_id' => $order->id, - 'weight' => $weight, - 'options' => $options - ]); - - // Build shipment data from order, including address parsing - $shipmentData = $this->buildShipmentData($order, $weight, $options); - \Log::info('shipmentData', $shipmentData); - // Validate the built data before proceeding - $this->validateShipmentData($shipmentData, $order->id); - - // Create DHL shipment record first - $dhlShipment = $this->createDhlShipmentRecord($order, $shipmentData); - - // Create shipment order for DHL API - $shipmentOrder = $this->buildShipmentOrder($shipmentData, $dhlShipment); - - // Call DHL API - $request = new createShipmentOrder([$shipmentOrder]); // Constructor expects array of ShipmentOrder objects - - try { - $response = $this->shippingClient->createShipmentOrder($request); - - // Enhanced debug logging for troubleshooting - $this->logInfo('DHL API Response received', [ - 'response_class' => get_class($response), - 'has_errors' => !$response->hasNoErrors(), - 'creation_states_count' => count($response->CreationStates ?? []) - ]); - - // Log detailed response structure for debugging - if (!$response->hasNoErrors()) { - $this->logResponseStructure($response); - } - } catch (\ErrorException $e) { - if (str_contains($e->getMessage(), 'Attempt to read property "Version" on null')) { - throw new \Exception( - 'The DHL API returned an unexpected response. This often indicates an authentication problem with the Business Customer API. ' . - 'Please double-check your DHL_API_USERNAME and DHL_API_PASSWORD in the .env file. ' . - 'Original error: ' . $e->getMessage(), 0, $e - ); - } - throw $e; - } - - // Process response - $this->processShipmentResponse($dhlShipment, $response, $request); - - $this->logInfo('DHL shipment created successfully', [ - 'shipment_id' => $dhlShipment->id, - 'shipment_number' => $dhlShipment->shipment_number - ]); - - return $dhlShipment->fresh(); - - } catch (DhlException $e) { - $this->logError('DHL API error during shipment creation', $e, ['order_id' => $order->id]); - throw new Exception('DHL shipment creation failed: ' . $e->getMessage()); - } catch (Exception $e) { - $this->logError('General error during shipment creation', $e, ['order_id' => $order->id]); - throw $e; - } - } - - /** - * Test DHL API Login Credentials by creating a dummy shipment. - * - * @return array - */ - public function testLogin(): array - { - try { - $this->logInfo('Starting DHL API connection test.'); - - // Check basic configuration first - if (!$this->isConfigured()) { - return [ - 'success' => false, - 'message' => 'DHL API ist nicht vollständig konfiguriert. Prüfen Sie die .env Einstellungen.', - ]; - } - - // 1. Find a test order (preferably with complete shipping data) - $testOrder = ShoppingOrder::with(['shopping_user', 'shopping_order_items']) - ->whereHas('shopping_user') - ->where('id', 35511) // Use specific test order - ->first(); - - if (!$testOrder) { - return [ - 'success' => false, - 'message' => 'Test-Bestellung (ID: 35511) nicht gefunden. Bitte verwenden Sie eine existierende Bestellung für den Test.', - ]; - } - - $this->logInfo('Using test order for API test', [ - 'order_id' => $testOrder->id, - 'has_user' => !is_null($testOrder->shopping_user), - 'has_items' => $testOrder->shopping_order_items->count() > 0 - ]); - - // 2. Define test options with realistic data - $weight = 1.0; - $options = [ - 'product_code' => 'V01PAK', - 'is_test' => true // Mark as test to avoid any side effects - ]; - - // 3. Wrap in transaction to avoid database pollution - DB::beginTransaction(); - - try { - $dhlShipment = $this->createShipment($testOrder, $weight, $options); - - // If we get here, the API call was successful - $success = !empty($dhlShipment->shipment_number); - - if ($success) { - $this->logInfo('DHL API test successful', [ - 'shipment_number' => $dhlShipment->shipment_number, - 'status' => $dhlShipment->status - ]); - - $result = [ - 'success' => true, - 'message' => 'DHL API Test erfolgreich! Verbindung und Authentifizierung funktionieren.', - 'details' => [ - 'shipment_number' => $dhlShipment->shipment_number, - 'status' => $dhlShipment->status, - 'api_type' => $this->config['api']['api_type'] ?? 'unknown', - 'sandbox' => $this->isSandbox - ] - ]; - } else { - $result = [ - 'success' => false, - 'message' => 'DHL API Test unvollständig: Sendung erstellt aber keine Sendungsnummer erhalten.', - 'details' => [ - 'status' => $dhlShipment->status ?? 'unknown', - 'errors' => $dhlShipment->api_errors ?? 'No specific errors' - ] - ]; - } - - } catch (Exception $apiException) { - $this->logError('DHL API test failed during createShipment', $apiException, [ - 'order_id' => $testOrder->id - ]); - - $result = [ - 'success' => false, - 'message' => 'DHL API Test fehlgeschlagen: ' . $apiException->getMessage(), - 'details' => [ - 'error_type' => get_class($apiException), - 'api_type' => $this->config['api']['api_type'] ?? 'unknown', - 'sandbox' => $this->isSandbox - ] - ]; - } - - // Always rollback transaction for tests - DB::rollBack(); - - return $result; - - } catch (Exception $e) { - DB::rollBack(); // Ensure rollback on any failure - - $this->logError('DHL API test failed with general error', $e); - - return [ - 'success' => false, - 'message' => 'DHL API Test fehlgeschlagen: ' . $e->getMessage(), - 'details' => [ - 'error_type' => get_class($e), - 'configured' => $this->isConfigured(), - 'sandbox' => $this->isSandbox - ] - ]; - } - } - - /** - * Validates required shipment data fields to prevent API errors. - * - * @param array $shipmentData - * @param int $orderId - * @throws \Exception - */ - private function validateShipmentData(array $shipmentData, int $orderId): void - { - $requiredFields = [ - 'recipient_street' => 'streetName', - 'recipient_street_number' => 'streetNumber', - 'recipient_postal_code' => 'postal code', - 'recipient_city' => 'city', - 'recipient_country' => 'countryISOCode', - ]; - - $errors = []; - foreach ($requiredFields as $field => $errorName) { - if (empty(trim($shipmentData[$field]))) { - $errors[] = $errorName; - } - } - - if (empty(trim($shipmentData['recipient_name'])) && empty(trim($shipmentData['recipient_company']))) { - $errors[] = 'recipient name or company'; - } - - if (!empty($errors)) { - $errorMessage = 'Shipment data is missing required fields for Order ID ' . $orderId . ': ' . implode(', ', $errors); - throw new \Exception($errorMessage); - } - } - - /** - * Cancel a shipment - * - * @param DhlShipment $shipment - * @return bool Success status - * @throws Exception - */ - public function cancelShipment(DhlShipment $shipment): bool - { - try { - if (!$shipment->canBeCancelled()) { - throw new Exception('Shipment cannot be cancelled in current status: ' . $shipment->status); - } - - $this->logInfo('Cancelling DHL shipment', [ - 'shipment_id' => $shipment->id, - 'shipment_number' => $shipment->shipment_number - ]); - - $request = new deleteShipmentOrder([$shipment->shipment_number]); // Constructor expects array of shipment numbers - - $response = $this->shippingClient->deleteShipmentOrder($request); - - if ($this->isResponseSuccessful($response)) { - $shipment->update([ - 'status' => DhlShipment::STATUS_CANCELLED, - 'api_response_data' => $this->extractResponseData($response), - ]); - - $this->logInfo('DHL shipment cancelled successfully', [ - 'shipment_id' => $shipment->id - ]); - - return true; - } else { - $errorMessage = $this->extractErrorMessage($response); - $shipment->update([ - 'api_errors' => $errorMessage, - ]); - - throw new Exception('DHL cancellation failed: ' . $errorMessage); - } - - } catch (DhlException $e) { - $this->logError('DHL API error during cancellation', $e, ['shipment_id' => $shipment->id]); - throw new Exception('DHL shipment cancellation failed: ' . $e->getMessage()); - } catch (Exception $e) { - $this->logError('General error during cancellation', $e, ['shipment_id' => $shipment->id]); - throw $e; - } - } - - /** - * Create a return label - * - * @param DhlShipment $originalShipment The original outbound shipment - * @param array $options Additional options - * @return DhlShipment The return shipment - * @throws Exception - */ - public function createReturnLabel(DhlShipment $originalShipment, array $options = []): DhlShipment - { - try { - if (!$this->config['returns']['enabled']) { - throw new Exception('Return labels are disabled in configuration'); - } - - $this->logInfo('Creating DHL return label', [ - 'original_shipment_id' => $originalShipment->id, - 'options' => $options - ]); - - // Create return shipment using regular createShipment but with return data - $returnShipment = $this->createShipment( - $originalShipment->shoppingOrder, - $originalShipment->weight, - array_merge($options, ['is_return' => true, 'original_shipment' => $originalShipment]) - ); - - $this->logInfo('DHL return label created successfully', [ - 'return_shipment_id' => $returnShipment->id, - 'original_shipment_id' => $originalShipment->id - ]); - - return $returnShipment; - - } catch (Exception $e) { - $this->logError('Error creating return label', $e, [ - 'original_shipment_id' => $originalShipment->id - ]); - throw $e; - } - } - - /** - * Get tracking details for a shipment - * - * @param DhlShipment $shipment - * @return array Tracking details - * @throws Exception - */ - public function getTrackingDetails(DhlShipment $shipment): array - { - try { - if (!$this->config['tracking']['enabled']) { - throw new Exception('Tracking is disabled in configuration'); - } - - if (!$shipment->hasTracking()) { - throw new Exception('Shipment has no tracking number'); - } - - if (!$this->trackingClient) { - throw new Exception('Tracking client not initialized'); - } - - $this->logInfo('Getting tracking details', [ - 'shipment_id' => $shipment->id, - 'tracking_number' => $shipment->tracking_number - ]); - - $request = new getPieceDetail(); - $request->pieceCode = $shipment->tracking_number; - - $response = $this->trackingClient->getPieceDetail($request); - - if ($response && is_array($response) && count($response) > 0) { - $trackingData = method_exists($response[0], 'toArray') ? $response[0]->toArray() : (array)$response[0]; - - // Update shipment with latest tracking info - $shipment->update([ - 'tracking_details' => $trackingData, - 'last_tracked_at' => now(), - 'tracking_status' => $trackingData['status'] ?? null, - ]); - - $this->logInfo('Tracking details updated', [ - 'shipment_id' => $shipment->id, - 'status' => $trackingData['status'] ?? 'unknown' - ]); - - return $trackingData; - } else { - throw new Exception('No tracking data available'); - } - - } catch (DhlException $e) { - $this->logError('DHL API error during tracking', $e, ['shipment_id' => $shipment->id]); - throw new Exception('DHL tracking failed: ' . $e->getMessage()); - } catch (Exception $e) { - $this->logError('General error during tracking', $e, ['shipment_id' => $shipment->id]); - throw $e; - } - } - - /** - * Build shipment data from order - * - * @param ShoppingOrder $order - * @param float $weight - * @param array $options - * @return array - */ - private function buildShipmentData(ShoppingOrder $order, float $weight, array $options): array - { - $isReturn = $options['is_return'] ?? false; - $originalShipment = $options['original_shipment'] ?? null; - $shoppingUser = $order->shopping_user; - - // Data placeholders - $recipientName = ''; - $recipientCompany = null; - $streetName = ''; - $streetNumber = ''; - $recipientPostalCode = ''; - $recipientCity = ''; - $recipientCountryCode = ''; - $recipientEmail = $shoppingUser->email ?? null; // Fallback to user's main email - $recipientPhone = null; - - $customAddress = $options['shipping_address'] ?? null; - - if ($customAddress) { - // Use custom address from options (e.g., modal) - $recipientName = trim(($customAddress['firstname'] ?? '') . ' ' . ($customAddress['lastname'] ?? '')); - $recipientCompany = $customAddress['company'] ?? null; - $streetName = $customAddress['address'] ?? ''; - $streetNumber = $customAddress['address_2'] ?? ''; - $recipientPostalCode = $customAddress['zipcode'] ?? ''; - $recipientCity = $customAddress['city'] ?? ''; - $recipientPhone = $customAddress['phone'] ?? null; - - if (!empty($customAddress['country_id'])) { - $country = \App\Models\Country::find($customAddress['country_id']); - if ($country) { - $recipientCountryCode = $country->code; - } - } - } else { - // Fallback to shopping_user data - $useShipping = !($shoppingUser->same_as_billing ?? true); - $firstname = $useShipping ? ($shoppingUser->shipping_firstname ?? '') : ($shoppingUser->billing_firstname ?? ''); - $lastname = $useShipping ? ($shoppingUser->shipping_lastname ?? '') : ($shoppingUser->billing_lastname ?? ''); - $company = $useShipping ? ($shoppingUser->shipping_company ?? '') : ($shoppingUser->billing_company ?? ''); - $address = $useShipping ? ($shoppingUser->shipping_address ?? '') : ($shoppingUser->billing_address ?? ''); - $address_2 = $useShipping ? ($shoppingUser->shipping_address_2 ?? '') : ($shoppingUser->billing_address_2 ?? ''); - $zipcode = $useShipping ? ($shoppingUser->shipping_zipcode ?? '') : ($shoppingUser->billing_zipcode ?? ''); - $city = $useShipping ? ($shoppingUser->shipping_city ?? '') : ($shoppingUser->billing_city ?? ''); - $country = $useShipping ? ($shoppingUser->shipping_country ?? null) : ($shoppingUser->billing_country ?? null); - $phone = $useShipping ? ($shoppingUser->shipping_phone ?? '') : ($shoppingUser->billing_phone ?? ''); - $email = $shoppingUser->billing_email ?? ''; - - $recipientName = trim($firstname . ' ' . $lastname); - $recipientCompany = $company; - $streetName = $address; - //$streetNumber = $address_2; - $recipientPostalCode = $zipcode; - $recipientCity = $city; - $recipientCountryCode = $country->code ?? ''; - $recipientPhone = $phone; - $recipientEmail = $email; - } - - // Universal address parsing for combined street/number fields - if (empty($streetNumber) && !empty($streetName)) { - if (preg_match('/^([^\d]*[^\d\s])\s*(\d.*)$/', $streetName, $matches) && count($matches) === 3) { - $streetName = trim($matches[1]); - $streetNumber = trim($matches[2]); - } - } - - // --- START FIX: Automatically determine product code based on destination country --- - $isDomestic = strtoupper($recipientCountryCode) === 'DE'; - $productCode = $options['product_code'] ?? ($isDomestic - ? $this->config['defaults']['product'] - : $this->config['defaults']['product_international']); - // --- END FIX --- - - return [ - 'order_id' => $order->id, - 'type' => $isReturn ? DhlShipment::TYPE_RETURN : DhlShipment::TYPE_OUTBOUND, - 'related_shipment_id' => $originalShipment?->id, - 'weight' => $weight, - 'length' => $options['length'] ?? $this->config['defaults']['dimensions']['length'], - 'width' => $options['width'] ?? $this->config['defaults']['dimensions']['width'], - 'height' => $options['height'] ?? $this->config['defaults']['dimensions']['height'], - 'product_code' => $productCode, - 'services' => $options['services'] ?? [], - - // Recipient address - 'recipient_name' => $recipientName, - 'recipient_company' => $recipientCompany, - 'recipient_street' => $streetName, - 'recipient_street_number' => $streetNumber, - 'recipient_postal_code' => $recipientPostalCode, - 'recipient_city' => $recipientCity, - 'recipient_state' => null, // Not used - 'recipient_country' => $recipientCountryCode, - 'recipient_email' => $recipientEmail, - 'recipient_phone' => $recipientPhone, - ]; - } - - /** - * Create DHL shipment record in database - * - * @param ShoppingOrder $order - * @param array $shipmentData - * @return DhlShipment - */ - private function createDhlShipmentRecord(ShoppingOrder $order, array $shipmentData): DhlShipment - { - return DhlShipment::create([ - 'shopping_order_id' => $order->id, - 'type' => $shipmentData['type'], - 'related_shipment_id' => $shipmentData['related_shipment_id'], - 'weight' => $shipmentData['weight'], - 'length' => $shipmentData['length'], - 'width' => $shipmentData['width'], - 'height' => $shipmentData['height'], - 'product_code' => $shipmentData['product_code'], - 'services' => $shipmentData['services'], - 'status' => DhlShipment::STATUS_CREATED, - - // Recipient data - 'recipient_name' => $shipmentData['recipient_name'], - 'recipient_company' => $shipmentData['recipient_company'], - 'recipient_street' => $shipmentData['recipient_street'], - 'recipient_street_number' => $shipmentData['recipient_street_number'], - 'recipient_postal_code' => $shipmentData['recipient_postal_code'], - 'recipient_city' => $shipmentData['recipient_city'], - 'recipient_state' => $shipmentData['recipient_state'], - 'recipient_country' => $shipmentData['recipient_country'], - 'recipient_email' => $shipmentData['recipient_email'], - 'recipient_phone' => $shipmentData['recipient_phone'], - ]); - } - - /** - * Build ShipmentOrder object for DHL API - * - * @param array $shipmentData - * @param DhlShipment $dhlShipment - * @return ShipmentOrder - */ - private function buildShipmentOrder(array $shipmentData, DhlShipment $dhlShipment): ShipmentOrder - { - $shipmentOrder = new ShipmentOrder(); - $isReturn = $shipmentData['type'] === DhlShipment::TYPE_RETURN; - $productCode = $shipmentData['product_code']; - - // --- DYNAMIC ACCOUNT NUMBER SELECTION --- - $accountNumber = $this->getAccountNumberForProduct($productCode); - - // --- VALIDATE COUNTRY CODES --- - // Ensure sender country code is valid - $senderCountry = 'DE'; - if (!empty($this->config['sender']['country']) && strlen(trim($this->config['sender']['country'])) === 2) { - $senderCountry = strtoupper(trim($this->config['sender']['country'])); - } - - // Ensure recipient country code is valid - $recipientCountry = 'DE'; - if (!empty($shipmentData['recipient_country']) && strlen(trim($shipmentData['recipient_country'])) === 2) { - $recipientCountry = strtoupper(trim($shipmentData['recipient_country'])); - } - // --- END VALIDATION --- - - // Set basic shipment details - $shipmentOrder->sequenceNumber = $dhlShipment->id; - - // Set shipment details - $shipmentOrder->Shipment->ShipmentDetails->product = $productCode; - $shipmentOrder->Shipment->ShipmentDetails->accountNumber = $accountNumber; - $shipmentOrder->Shipment->ShipmentDetails->customerReference = ($isReturn ? 'Return-' : 'Order-') . $shipmentData['order_id']; - $shipmentOrder->Shipment->ShipmentDetails->shipmentDate = date('Y-m-d'); - - // --- ROBUST NAME & ADDRESS HANDLING --- - if ($isReturn) { - // RETURN LABEL: Customer ships TO warehouse - // Shipper = Customer (original recipient) - $this->setAddressBlock( - $shipmentOrder->Shipment->Shipper, - $shipmentData['recipient_name'], - $shipmentData['recipient_company'], - $shipmentData['recipient_street'], - $shipmentData['recipient_street_number'], - $shipmentData['recipient_postal_code'], - $shipmentData['recipient_city'], - $recipientCountry - ); - - // Receiver = Warehouse (original sender) - $this->setAddressBlock( - $shipmentOrder->Shipment->Receiver, - $this->config['sender']['name'] ?? '', - $this->config['sender']['company'] ?? '', - $this->config['sender']['street'] ?? '', - $this->config['sender']['street_number'] ?? '', - $this->config['sender']['postal_code'] ?? '', - $this->config['sender']['city'] ?? '', - $senderCountry, - true // isSender=true to throw exception on config error - ); - - } else { - // OUTBOUND LABEL: Warehouse ships TO customer (normal flow) - // Shipper = Warehouse - $this->setAddressBlock( - $shipmentOrder->Shipment->Shipper, - $this->config['sender']['name'] ?? '', - $this->config['sender']['company'] ?? '', - $this->config['sender']['street'] ?? '', - $this->config['sender']['street_number'] ?? '', - $this->config['sender']['postal_code'] ?? '', - $this->config['sender']['city'] ?? '', - $senderCountry, - true // isSender=true to throw exception on config error - ); - - // Receiver = Customer - $this->setAddressBlock( - $shipmentOrder->Shipment->Receiver, - $shipmentData['recipient_name'], - $shipmentData['recipient_company'], - $shipmentData['recipient_street'], - $shipmentData['recipient_street_number'], - $shipmentData['recipient_postal_code'], - $shipmentData['recipient_city'], - $recipientCountry - ); - } - - // Set package details - $shipmentOrder->Shipment->ShipmentDetails->ShipmentItem->weightInKG = $shipmentData['weight']; - $shipmentOrder->Shipment->ShipmentDetails->ShipmentItem->lengthInCM = $shipmentData['length']; - $shipmentOrder->Shipment->ShipmentDetails->ShipmentItem->widthInCM = $shipmentData['width']; - $shipmentOrder->Shipment->ShipmentDetails->ShipmentItem->heightInCM = $shipmentData['height']; - - // Configure minimal services to avoid SDK issues with dynamic properties - $this->configureShipmentServices($shipmentOrder, $shipmentData); - - return $shipmentOrder; - } - - /** - * Sets the address and name details for a shipper or receiver block. - * - * @param Shipper|Receiver $addressBlock The Shipper or Receiver object from the SDK - * @param string $name - * @param string|null $company - * @param string $street - * @param string $streetNumber - * @param string $postalCode - * @param string $city - * @param string $countryCode - * @param bool $isSender - * @throws \Exception - */ - private function setAddressBlock(Shipper|Receiver &$addressBlock, string $name, ?string $company, string $street, string $streetNumber, string $postalCode, string $city, string $countryCode, bool $isSender = false): void - { - $name = trim($name); - $company = trim($company ?? ''); - - $name1 = $name; - $name2 = $company; - - if (empty($name1)) { - // If personal name is empty, company MUST be name1 - $name1 = $name2; - $name2 = ''; - } - - if (empty($name1)) { - if ($isSender) { - throw new \Exception('DHL Sender Name (name1) is not configured. Please set DHL_SENDER_NAME or DHL_SENDER_COMPANY in your .env file.'); - } - return; - } - - // Handle the structural difference between Shipper (has ->Name object) and Receiver (has ->name1 directly) - if ($addressBlock instanceof Shipper) { - if (is_null($addressBlock->Name)) $addressBlock->Name = new ShipperName(); - $addressBlock->Name->name1 = $name1; - if (!empty($name2)) $addressBlock->Name->name2 = $name2; - - if (is_null($addressBlock->Address)) $addressBlock->Address = new ShipperAddress(); - if (is_null($addressBlock->Address->Origin)) $addressBlock->Address->Origin = new ShipperOrigin(); - } elseif ($addressBlock instanceof Receiver) { - $addressBlock->name1 = $name1; // Assign directly - - if (is_null($addressBlock->Address)) $addressBlock->Address = new ReceiverAddress(); - if (is_null($addressBlock->Address->Origin)) $addressBlock->Address->Origin = new ReceiverOrigin(); - } - - $addressBlock->Address->streetName = $street; - $addressBlock->Address->streetNumber = $streetNumber; - $addressBlock->Address->zip = $postalCode; - $addressBlock->Address->city = $city; - $addressBlock->Address->Origin->countryISOCode = $countryCode; - } - - /** - * Configure shipment services to avoid SDK dynamic property issues - * - * @param ShipmentOrder $shipmentOrder - * @param array $shipmentData - */ - private function configureShipmentServices(ShipmentOrder $shipmentOrder, array $shipmentData): void - { - // Initialize basic services that are commonly used and properly supported - $services = $shipmentData['services'] ?? []; - - // Only configure services that are explicitly requested and known to work - // This prevents the SDK from creating dynamic properties like ShipmentHandling - - if (isset($services['premium']) && $services['premium']) { - $shipmentOrder->Shipment->ShipmentDetails->Service->Premium->active = true; - } - - if (isset($services['endorsement']) && $services['endorsement']) { - $shipmentOrder->Shipment->ShipmentDetails->Service->Endorsement->active = true; - $shipmentOrder->Shipment->ShipmentDetails->Service->Endorsement->type = $services['endorsement']; - } - - if (isset($services['bulky_goods']) && $services['bulky_goods']) { - $shipmentOrder->Shipment->ShipmentDetails->Service->BulkyGoods->active = true; - } - - if (isset($services['return_receipt']) && $services['return_receipt']) { - $shipmentOrder->Shipment->ShipmentDetails->Service->ReturnReceipt->active = true; - } - - // Avoid problematic services that cause dynamic property warnings - // Do NOT set ShipmentHandling or other services that the SDK dynamically creates - - $this->logInfo('Configured DHL services', [ - 'requested_services' => array_keys($services), - 'product_code' => $shipmentData['product_code'] - ]); - } - - /** - * Get the correct DHL account number for a given product code. - * - * @param string $productCode - * @return string - * @throws \Exception - */ - private function getAccountNumberForProduct(string $productCode): string - { - $productAccounts = $this->config['api']['product_accounts'] ?? []; - - if (isset($productAccounts[$productCode]) && !empty($productAccounts[$productCode])) { - return $productAccounts[$productCode]; - } - - $defaultAccount = $this->config['api']['account_number_default'] ?? null; - if (!empty($defaultAccount)) { - return $defaultAccount; - } - - throw new \Exception("DHL Abrechnungsnummer for product '{$productCode}' is not configured, and no default account number is set."); - } - - /** - * Process shipment response from DHL API - * - * @param DhlShipment $dhlShipment - * @param CreateShipmentOrderResponse $response - * @param createShipmentOrder $request - * @throws Exception - */ - private function processShipmentResponse(DhlShipment $dhlShipment, $response, $request): void - { - // Store request and response data - $dhlShipment->update([ - 'api_request_data' => $this->extractRequestData($request), - 'api_response_data' => $this->extractResponseData($response), - ]); - - $isSuccessful = $this->isResponseSuccessful($response); - - $this->logInfo('Processing DHL shipment response', [ - 'shipment_id' => $dhlShipment->id, - 'is_successful' => $isSuccessful, - 'has_creation_states' => !empty($response->CreationStates), - 'creation_states_count' => count($response->CreationStates ?? []) - ]); - - if ($isSuccessful) { - // Extract shipment and tracking information - $shipmentData = $this->extractShipmentData($response); - - $this->logInfo('Extracted shipment data', [ - 'shipment_id' => $dhlShipment->id, - 'shipment_number' => $shipmentData['shipment_number'] ?? 'not_found', - 'has_label_data' => !empty($shipmentData['label_data']) - ]); - - // Validate that we got essential data - if (empty($shipmentData['shipment_number'])) { - $this->logError('No shipment number in successful response', new Exception('Missing shipment number'), [ - 'shipment_id' => $dhlShipment->id, - 'response_data' => $this->extractResponseData($response) - ]); - - $dhlShipment->update([ - 'status' => DhlShipment::STATUS_FAILED, - 'api_errors' => 'DHL API reported success but no shipment number was provided', - ]); - - throw new Exception('DHL API reported success but no shipment number was provided'); - } - - // Save label if provided - $labelPath = null; - if (isset($shipmentData['label_data']) && $shipmentData['label_data']) { - try { - $labelPath = $this->saveLabelFile($dhlShipment, $shipmentData['label_data']); - } catch (Exception $e) { - $this->logError('Failed to save label file', $e, ['shipment_id' => $dhlShipment->id]); - // Don't fail the whole process for label save issues - } - } - - // Update shipment with success data - $dhlShipment->update([ - 'shipment_number' => $shipmentData['shipment_number'], - 'tracking_number' => $shipmentData['tracking_number'] ?? $shipmentData['shipment_number'], - 'label_path' => $labelPath, - 'status' => DhlShipment::STATUS_SUBMITTED, - 'shipped_at' => now(), - ]); - - } else { - // Handle API error - $errorMessage = $this->extractErrorMessage($response); - - $this->logInfo('DHL API returned errors', [ - 'shipment_id' => $dhlShipment->id, - 'error_message' => $errorMessage - ]); - - $dhlShipment->update([ - 'status' => DhlShipment::STATUS_FAILED, - 'api_errors' => $errorMessage, - ]); - - throw new Exception('DHL API error: ' . $errorMessage); - } - } - - /** - * Extract shipment data from response - * - * @param CreateShipmentOrderResponse $response - * @return array - */ - private function extractShipmentData($response): array - { - $data = []; - - // Use the SDK's proper structure with CreationStates - if ($response instanceof CreateShipmentOrderResponse && !empty($response->CreationStates)) { - foreach ($response->CreationStates as $creationState) { - if (isset($creationState->shipmentNumber)) { - $data['shipment_number'] = $creationState->shipmentNumber; - } - if (isset($creationState->LabelData->labelData)) { - $data['label_data'] = $creationState->LabelData->labelData; - } - // Only process the first creation state for now (single shipment) - break; - } - } - - return $data; - } - - /** - * Check if response indicates success - * - * @param CreateShipmentOrderResponse|DeleteShipmentOrderResponse $response - * @return bool - */ - private function isResponseSuccessful($response): bool - { - // Use the SDK's built-in success check methods - if ($response instanceof CreateShipmentOrderResponse) { - return $response->hasNoErrors(); - } elseif ($response instanceof DeleteShipmentOrderResponse) { - return $response->hasNoErrors(); - } - - return false; - } - - /** - * Extract error message from response - * - * @param CreateShipmentOrderResponse|DeleteShipmentOrderResponse $response - * @return string - */ - private function extractErrorMessage($response): string - { - $messages = []; - - // Check for top-level status messages - if (is_object($response) && property_exists($response, 'Status') && !empty($response->Status)) { - $statusArray = is_array($response->Status) ? $response->Status : [$response->Status]; - - foreach ($statusArray as $status) { - if (is_object($status)) { - // Try different status property names - $statusProps = ['statusText', 'statusMessage', 'statusCode']; - foreach ($statusProps as $prop) { - if (property_exists($status, $prop) && !empty($status->{$prop})) { - $value = $status->{$prop}; - $messages[] = is_array($value) ? implode(', ', $value) : (string)$value; - break; - } - } - } - } - } - - // Check for messages within CreationStates - if ($response instanceof CreateShipmentOrderResponse && !empty($response->CreationStates)) { - foreach ($response->CreationStates as $creationState) { - // Check LabelData Status - if (isset($creationState->LabelData->Status)) { - $statusArray = is_array($creationState->LabelData->Status) - ? $creationState->LabelData->Status - : [$creationState->LabelData->Status]; - - foreach ($statusArray as $status) { - if (is_object($status)) { - $statusProps = ['statusText', 'statusMessage', 'statusCode']; - foreach ($statusProps as $prop) { - if (property_exists($status, $prop) && !empty($status->{$prop})) { - $value = $status->{$prop}; - $messages[] = is_array($value) ? implode('; ', $value) : (string)$value; - break; - } - } - } - } - } - - // Check direct status on CreationState - if (isset($creationState->sequenceNumber) && isset($creationState->LabelData)) { - // This might be a successful creation state, not an error - continue; - } - } - } - - // If we still have no messages, check if this might actually be a success - if (empty($messages) && $response instanceof CreateShipmentOrderResponse) { - if ($response->hasNoErrors()) { - return 'No errors found - response appears successful'; - } else { - // Try to extract any available information from the response - $responseData = $this->extractResponseData($response); - $this->logInfo('Could not extract error message, full response data:', $responseData ?? []); - return 'DHL API returned errors but no specific error message could be extracted. Check logs for full response.'; - } - } - - return !empty($messages) ? implode('; ', array_unique($messages)) : 'Unknown DHL API error'; - } - - /** - * Extract request data for logging - * - * @param createShipmentOrder|deleteShipmentOrder $request - * @return array|null - */ - private function extractRequestData($request): ?array - { - // Safely convert complex SDK objects to arrays for logging - return json_decode(json_encode($request), true); - } - - /** - * Extract response data for logging - * - * @param CreateShipmentOrderResponse|DeleteShipmentOrderResponse $response - * @return array|null - */ - private function extractResponseData($response): ?array - { - // Safely convert complex SDK objects to arrays for logging - return json_decode(json_encode($response), true); - } - - /** - * Save label file to storage - * - * @param DhlShipment $dhlShipment - * @param string $labelData Base64 encoded label data - * @return string The saved file path - */ - private function saveLabelFile(DhlShipment $dhlShipment, string $labelData): string - { - $filename = 'dhl_label_' . $dhlShipment->id . '_' . time() . '.pdf'; - $path = 'dhl/labels/' . $filename; - - Storage::put($path, base64_decode($labelData)); - - return $path; - } - - /** - * Log info message - * - * @param string $message - * @param array $context - */ - private function logInfo(string $message, array $context = []): void - { - if ($this->config['logging']['enabled']) { - Log::info('[DHL API] ' . $message, $context); - } - } - - /** - * Log error message - * - * @param string $message - * @param Exception $exception - * @param array $context - */ - private function logError(string $message, Exception $exception, array $context = []): void - { - if ($this->config['logging']['enabled']) { - Log::error('[DHL API] ' . $message, array_merge($context, [ - 'exception' => $exception->getMessage(), - 'trace' => $exception->getTraceAsString(), - ])); - } - } - - /** - * Log detailed response structure for debugging - * - * @param CreateShipmentOrderResponse $response - */ - private function logResponseStructure($response): void - { - if (!$this->config['logging']['enabled']) { - return; - } - - $structure = [ - 'class' => get_class($response), - 'hasNoErrors' => $response->hasNoErrors(), - 'properties' => [] - ]; - - // Get all public properties - $reflection = new \ReflectionClass($response); - foreach ($reflection->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { - $name = $property->getName(); - try { - $value = $property->getValue($response); - - if (is_object($value)) { - $structure['properties'][$name] = [ - 'type' => 'object', - 'class' => get_class($value), - 'properties' => $this->extractObjectProperties($value, 2) // Limit depth - ]; - } elseif (is_array($value)) { - $structure['properties'][$name] = [ - 'type' => 'array', - 'count' => count($value), - 'sample' => count($value) > 0 ? $this->extractObjectProperties($value[0], 1) : null - ]; - } else { - $structure['properties'][$name] = [ - 'type' => gettype($value), - 'value' => $value - ]; - } - } catch (\Exception $e) { - $structure['properties'][$name] = [ - 'type' => 'error', - 'error' => $e->getMessage() - ]; - } - } - - Log::info('[DHL API] Response structure analysis:', $structure); - } - - /** - * Extract object properties for debugging (with depth limit) - * - * @param mixed $object - * @param int $maxDepth - * @return array|mixed - */ - private function extractObjectProperties($object, int $maxDepth = 1) - { - if ($maxDepth <= 0 || !is_object($object)) { - return is_object($object) ? get_class($object) : $object; - } - - $properties = []; - $reflection = new \ReflectionClass($object); - - foreach ($reflection->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { - $name = $property->getName(); - try { - $value = $property->getValue($object); - - if (is_object($value)) { - $properties[$name] = $this->extractObjectProperties($value, $maxDepth - 1); - } elseif (is_array($value)) { - $properties[$name] = [ - 'type' => 'array', - 'count' => count($value) - ]; - } else { - $properties[$name] = $value; - } - } catch (\Exception $e) { - $properties[$name] = 'Error: ' . $e->getMessage(); - } - } - - return $properties; - } - - /** - * Check if service is properly configured - * - * @return bool - */ - public function isConfigured(): bool - { - $apiType = $this->config['api']['api_type']; - - if ($apiType === 'developer') { - return !empty($this->config['api']['api_key']) && - !empty($this->config['api']['api_secret']); - } else { - return !empty($this->config['api']['username']) && - !empty($this->config['api']['password']) && - !empty($this->config['api']['account_number']); - } - } - - /** - * Test API connection - * - * @return array Test results - */ - public function testConnection(): array - { - try { - // Simple test - check if clients are initialized - if ($this->shippingClient) { - return [ - 'success' => true, - 'message' => 'DHL API clients initialized successfully', - 'sandbox' => $this->isSandbox, - 'configured' => $this->isConfigured(), - 'api_type' => $this->config['api']['api_type'], - ]; - } else { - throw new Exception('Shipping client not initialized'); - } - - } catch (Exception $e) { - return [ - 'success' => false, - 'message' => 'DHL API connection failed: ' . $e->getMessage(), - 'sandbox' => $this->isSandbox, - 'configured' => $this->isConfigured(), - ]; - } - } -} \ No newline at end of file diff --git a/app/Services/DhlDataHelper.php b/app/Services/DhlDataHelper.php index a1862f2..20f33fd 100644 --- a/app/Services/DhlDataHelper.php +++ b/app/Services/DhlDataHelper.php @@ -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'); diff --git a/app/Services/DhlProductResolver.php b/app/Services/DhlProductResolver.php index fea8af6..b083f60 100644 --- a/app/Services/DhlProductResolver.php +++ b/app/Services/DhlProductResolver.php @@ -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 : []); diff --git a/app/Services/DhlShipmentService.php b/app/Services/DhlShipmentService.php index c0bb012..1ef050e 100644 --- a/app/Services/DhlShipmentService.php +++ b/app/Services/DhlShipmentService.php @@ -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 $dhlConfig + * @return array + */ + 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 $orderData + * @return array + */ + 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']), + ]; + } } diff --git a/app/Services/DhlTrackingService.php b/app/Services/DhlTrackingService.php index ea58e1b..8372cf3 100644 --- a/app/Services/DhlTrackingService.php +++ b/app/Services/DhlTrackingService.php @@ -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 */ - 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 + */ + 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 $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 $shipments + * @return array{updated: int, failed: int, completed: int, results: array>} */ 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; diff --git a/dev/2026-05-13-dhl-modul/ENTWICKLUNGSKONZEPT-DHL-MODUL.md b/dev/2026-05-13-dhl-modul/ENTWICKLUNGSKONZEPT-DHL-MODUL.md index beb5209..6937bea 100644 --- a/dev/2026-05-13-dhl-modul/ENTWICKLUNGSKONZEPT-DHL-MODUL.md +++ b/dev/2026-05-13-dhl-modul/ENTWICKLUNGSKONZEPT-DHL-MODUL.md @@ -1007,6 +1007,38 @@ Umsetzung: - Fehler aus der finalen DHL-Erstellung werden in der bestehenden Vorabpruefungsbox angezeigt. - Keine separate Browser-Alert-Meldung. +### 27.05.2026 - Nachtrag: DHL-Laenderauswahl in Settings speicherbar + +Status: abgeschlossen. + +Problem: + +- Die Checkboxen fuer `DHL Paket International Ziellaender` liessen sich im Admin sichtbar anklicken. +- Nach dem Speichern wurden die ausgewaehlten Laender aber nicht wieder angezeigt. +- Ursache war `DHL_CONFIG_SOURCE=env`: Dadurch wurden die gespeicherten Datenbankwerte fuer diese Laenderliste beim Lesen ueberdeckt. +- Zusaetzlich wurde eine leere Checkbox-Auswahl als `null` gespeichert und fiel dadurch wieder auf die `.env`-Konfiguration zurueck. + +Umsetzung: + +- `app/Http/Controllers/SettingController.php` + - `dhl_international_countries` wird beim Speichern normalisiert. + - Ein leerer Wert wird als leere Liste gespeichert. + - Beim Lesen wird ein vorhandener gespeicherter Array-Wert fuer diese Laenderliste verwendet, auch wenn `DHL_CONFIG_SOURCE=env` aktiv ist. +- `app/Services/DhlProductResolver.php` + - Nutzt gespeicherte DHL-Laender, sobald sie als Array vorhanden sind. + - Wenn keine `settings`-Tabelle verfuegbar ist, bleibt der Config-Fallback erhalten. +- `app/Models/Setting.php` + - Object-Settings speichern leere Arrays jetzt als Array statt als `null`. +- `tests/Unit/Dhl/DhlProductResolverTest.php` + - Tests fuer gespeicherte Laender bei `env`-Prioritaet. + - Test fuer bewusst leer gespeicherte Laenderliste. + +Verifikation: + +- `./vendor/bin/pint --dirty --format agent` +- `php artisan test --compact tests/Unit/Dhl` + - Ergebnis: 51 Tests bestanden, 117 Assertions. + Offene Punkte: - DHL-Sandbox-Verhalten testen. @@ -1020,6 +1052,411 @@ Verifikation: - `php artisan test --compact tests/Unit/Dhl` - Ergebnis: 49 Tests bestanden, 115 Assertions. +## Phase 9 - Security & Code-Hygiene Hardening (Audit 2026-05-27) + +Status: umgesetzt am 2026-05-27. + +Im Rahmen eines Audits der bestehenden DHL-Umsetzung wurden mehrere sicherheits- und konsistenzrelevante Punkte mit Prio 1 identifiziert und behoben. + +### Prio 1.1 – Credentials und PII aus Logs entfernen + +Problem: + +- `DhlShipmentService::createShipment()` schrieb die komplette DHL-Config inklusive `api_key`, `username`, `password`, `api_secret` und Abrechnungsnummern via `\Log::info('dhlConfig', $dhlConfig)` in das Application-Log. +- `DhlDataHelper::prepareOrderData()` schrieb das Roh-Options-Array (inkl. Versandadresse, Name, Telefon) in das Log. +- `ShippingService::createLabel()` schrieb die fertige Order-Daten und in `[DHL API] Sending payload to DHL` zusaetzlich `payload_json` mit dem kompletten Payload (Adressen, Abrechnungsnummer) ins Log. Die Fehlerbranch loggte den vollen Payload ebenfalls. Die Response loggte zudem das base64-kodierte Label. + +Umsetzung: + +- `app/Services/DhlShipmentService.php` + - Neue statische Helper `sanitizeDhlConfigForLog()` und `sanitizeOrderDataForLog()`. + - `sanitizeDhlConfigForLog()` ersetzt Geheimnisse durch boolesche `has_*`-Flags und gibt nur nicht-vertrauliche Konfigurationsmetadaten zurueck. + - `sanitizeOrderDataForLog()` reduziert Bestelldaten auf Routing-Felder (Order-ID, Produktcode, Empfaenger-Land, ersten beiden Stellen der PLZ, Reference-Flag). + - Bestehende Logs nutzen jetzt diese Helper. +- `app/Services/DhlDataHelper.php` + - Kein Roh-Dump des `$options`-Arrays mehr. Statt dessen strukturiertes Log mit `order_id`, Produktcode und Flags. +- `packages/acme-laravel-dhl/src/Services/ShippingService.php` + - Neue private Helper `buildPayloadLogContext()` und `buildResponseLogContext()`. + - `createLabel()`-Log nutzt `DhlShipmentService::sanitizeOrderDataForLog()`. + - Payload-Log enthaelt nur Produktcode, letzte vier Stellen der Abrechnungsnummer, Gewicht, Empfaenger-Land, `must_encode`-Flag. + - Response-Log enthaelt nur Sendungsnummer, Routing-Code, Status. Das base64-Label wird nicht mehr geloggt. + - Error-Branch verwendet ebenfalls den redaktierten Payload-Log. + +Tests: + +- `tests/Unit/Dhl/DhlSanitizeLoggingTest.php` (neu) + - Stellt sicher, dass `api_key`, `password`, `api_secret`, `username` und Abrechnungsnummern niemals im Log-Kontext landen. + - Stellt sicher, dass Name, Strasse, Telefon, E-Mail und PLZ aus den Bestelldaten heraus normalisiert werden. + +### Prio 1.2 – `CreateShipmentJob` darf `dhlConfig` nicht serialisieren + +Problem: + +- `App\Jobs\CreateShipmentJob` hat die DHL-Konfiguration im Konstruktor in eine `public $dhlConfig`-Property geschrieben. Dadurch wurden API-Key, Basic-Auth-Passwort und Abrechnungsnummern beim Dispatch (per `serialize()`) in den Queue-Speicher (Redis/Datenbank) abgelegt. + +Umsetzung: + +- `app/Jobs/CreateShipmentJob.php` + - `public $dhlConfig` entfernt. + - Der Konstruktor akzeptiert den Parameter weiterhin (Backwards-Compat), persistiert ihn aber nicht mehr. + - `handle()` laedt die DHL-Konfiguration jetzt ueber `SettingController::getDhlConfig()` direkt aus der kanonischen Quelle. Der Worker hat ohnehin Zugriff darauf. + +Tests: + +- `tests/Unit/Dhl/CreateShipmentJobSerializationTest.php` (neu) + - Serialisiert eine Job-Instanz und stellt sicher, dass keine der DHL-Secrets im Payload landen. + - Bestaetigt, dass es keine `dhlConfig`-Property mehr gibt. + +### Prio 1.3 – IDOR im DHL-Versand-Modal verhindern + +Problem: + +- `ModalController::handleDhlShipmentModal()` haengt am `auth`-Middleware, aber nicht am `admin`-Guard. Jeder eingeloggte CRM-Nutzer konnte `POST /modal/load` mit `action=create-dhl-shipment` und einer beliebigen Order-ID aufrufen und damit Empfaenger-Name, Adresse, E-Mail sowie bestehende Sendungen einer fremden Bestellung auslesen. + +Umsetzung: + +- `app/Http/Controllers/ModalController.php` + - Neue private Methode `authorizeDhlShipmentModal()` prueft `Auth::user()->isAdmin()` und antwortet sonst mit `403`. + - Wird vor `handleDhlShipmentModal()` aufgerufen. + +Tests: + +- `tests/Unit/Dhl/DhlModalAuthorizationTest.php` (neu) + - Gaeste, VIP-User (`admin == 1`) und regulaere Berater (`admin == 0`) erhalten `403`. + - Echte Admins (`admin >= 2`) werden durchgelassen. + +### Prio 1.4 – XSS-Schutz im Tracking-Frontend und in DataTables + +Problem: + +- Im Backend-DataTable `DhlShipmentController::index()`/`list()` wurde `customer = firstname + ' ' + lastname` per `addColumn` mit anschliessendem `rawColumns(['customer', ...])` ausgegeben. Manipulierte Adressdaten haetten HTML/JS injizieren koennen. +- Im oeffentlichen Tracking-Frontend `resources/views/public/tracking.blade.php` wurden `data.tracking_status`, `data.tracking_number`, `data.last_tracked_at` und `data.status` ungefiltert in jQuery-`.html()`-Templates eingebaut. + +Umsetzung: + +- `app/Http/Controllers/DhlShipmentController.php` + - `customer`-Spalte ist jetzt `e(trim($firstname.' '.$lastname))`. +- `resources/views/public/tracking.blade.php` + - Neue JS-Funktion `escapeTrackingHtml()` (HTML-Entity-Encoding fuer `&`, `<`, `>`, `"`, `'`). + - Alle Felder aus der Tracking-Response werden vor dem Einsetzen damit escaped. + - Trackingnummer fuer den DHL-Link wird zusaetzlich mit `encodeURIComponent()` geschuetzt. + - `showError()` nutzt `.text()` statt `.html()`. + - `getStatusBadge()` escaped Status-Default und CSS-Klassen, sodass unbekannte DHL-Statuscodes nicht aus dem `class`-Attribut ausbrechen koennen. + +### Prio 1.5 – `ReturnsService` an `DhlProductResolver` anbinden, `'DEU'`-Fallback entfernen + +Problem: + +- `ReturnsService::convertCountryCode()` und `convertAddressFor2LetterCountry()` hielten eine eigene Hardcoded-Liste an Laendercodes und fielen bei unbekannten Codes still auf `'DEU'` zurueck. Damit konnten Retouren fuer auslaendische Empfaenger versehentlich nach Deutschland geroutet werden und der `DhlProductResolver` (Single Source of Truth) wurde umgangen. + +Umsetzung: + +- `packages/acme-laravel-dhl/src/Services/ReturnsService.php` + - `convertCountryCode()` delegiert an `DhlProductResolver::toDhlCountryCode()`. + - `convertAddressFor2LetterCountry()` delegiert an `DhlProductResolver::normalizeCountryCode()`. + - Unbekannte Laender werfen jetzt eine `InvalidArgumentException`, statt unbemerkt eine deutsche Retoure zu erzeugen. +- `app/Http/Controllers/DhlShipmentController.php` + - Alle `'DEU'`-Defaults in den Retouren-Pfaden (`getBillingAddressForReturn()`, `createReturnLabelSync()`) durch `DhlProductResolver::DOMESTIC_COUNTRY` (`'DE'`) ersetzt, damit konsistente ISO-2-Codes an den Resolver gehen. + +Tests: + +- `tests/Unit/Dhl/ReturnsServiceCountryCodeTest.php` (neu) + - Mappt `DE/AT/CH/ES` korrekt auf ISO-3. + - Akzeptiert bereits korrekte ISO-3-Codes. + - Wirft fuer unbekannte Codes statt still `DEU` zurueckzugeben. + - Normalisiert Adressen zurueck auf ISO-2 und respektiert fehlende `country`-Felder. + +### Prio 1.6 – `houseNumber = '1'`-Default in `ShippingService::parseAddressFields` entfernen + +Problem: + +- Wenn der Adressparser keine Hausnummer aus der Strasse extrahieren konnte, hat er die Hausnummer still auf `'1'` gesetzt. Das fuehrte dazu, dass Pakete an die falsche Adresse zugestellt werden konnten, ohne dass der Operator etwas davon mitbekam. + +Umsetzung: + +- `packages/acme-laravel-dhl/src/Services/ShippingService.php` + - `parseAddressFields()` wirft jetzt eine `InvalidArgumentException` mit klarer deutscher Fehlermeldung, sobald keine Hausnummer ermittelt werden konnte. + - Das interne Erfolgs-Log enthaelt zusaetzlich nur noch Laengen / Praefixe, nicht mehr die volle Adresse. + +Tests: + +- `tests/Unit/Dhl/ShippingServiceParseAddressTest.php` (neu) + - Explizite Hausnummer wird unveraendert uebernommen. + - Kombiniertes Strassenfeld wird sauber zerlegt. + - Adresse ohne erkennbare Hausnummer fuehrt zu einer Validierungs-Exception. + - Leere Eingabe fuehrt nicht zur Exception (kein neues Verhalten). + +### Verifikation Phase 9 + +- `php artisan test --compact tests/Unit/Dhl tests/Feature/DhlApiCurlLoggingTest.php` + - Ergebnis: 75 Tests bestanden, 195 Assertions. +- `./vendor/bin/pint --dirty --format agent` + +### Offene Punkte / weiterhin im Backlog + +Die in Phase 9 noch offenen Backlog-Items wurden inzwischen in Phase 10 abgearbeitet (siehe dort). + +## Phase 10 - Backlog-Aufraeumen (2026-05-27) + +Status: umgesetzt am 2026-05-27 direkt im Anschluss an Phase 9. + +### Prio 2.1 - Legacy `DhlApiService` entfernen + +Problem: + +- `app/Services/DhlApiService.php` (1311 Zeilen) war ein veralteter SOAP-/SDK-basierter Service. Er referenzierte ein nicht existierendes Model `App\Models\DhlShipment` und das nicht installierte SDK `christophschaeffer/dhl-business-shipping`. Die Klasse wurde vom Autoloader geladen, war aber komplett tot (keine Aufrufer in `app/`, `routes/`, `resources/`, `tests/`, `packages/`). Jeder versehentliche Aufruf haette zur Laufzeit zu `Class not found` gefuehrt. + +Umsetzung: + +- `app/Services/DhlApiService.php` ersatzlos geloescht. + +Verifikation: + +- `grep -r DhlApiService app/ routes/ resources/ packages/ tests/ config/` → keine Treffer mehr. +- Verbleibende Vorkommen ausschliesslich in `dev/2026-05-13-dhl-modul/legacy/...` (historische Markdowns) und `dev/app-bak/` (Backup-Verzeichnis). + +### Prio 2.2 - Request-/Prozess-Caching fuer `getDhlConfig()` + +Problem: + +- `SettingController::getDhlConfig()` baute die DHL-Konfiguration bei jedem Aufruf von Grund auf neu auf und holte dabei rund 25 Werte einzeln per `Setting::getContentBySlug()` aus der Datenbank. Pro DHL-Vorgang gibt es mehrere Aufrufer (`DhlShipmentService`, `CreateShipmentJob`, `DhlShipmentController` fuer Cancel/Return, …), so dass schnell 50-100 redundante `SELECT ... FROM settings`-Queries pro Request entstanden. + +Umsetzung: + +- `app/Http/Controllers/SettingController.php` + - Neue statische Property `private static ?array $cachedDhlConfig = null` als prozessweiter In-Memory-Cache. + - `getDhlConfig()` liefert beim zweiten Aufruf direkt aus dem Cache und triggert keine DB-Queries mehr. + - Neue oeffentliche Methode `flushDhlConfigCache()` setzt den Cache zurueck. Sie wird automatisch in `store()` und `updateDhlConfigCache()` aufgerufen, sodass nach einer Settings-Aenderung der naechste Aufruf wieder frische Werte liefert. + +Tests: + +- `tests/Unit/Dhl/DhlConfigCachingTest.php` (neu) + - Verifiziert, dass `getDhlConfig()` einen vorbefuellten Cache zurueckgibt, ohne die Datenbank zu beruehren. + - Verifiziert, dass `flushDhlConfigCache()` den Cache zuverlaessig leert. + +### Prio 2.3 - Doppelte `public.tracking`-Route bereinigen + +Problem: + +- `routes/domains/crm.php` registrierte `GET /admin/dhl/public/track` mit dem Namen `public.tracking` _innerhalb_ der `admin`-Middleware-Gruppe. Dadurch war die Route in Wahrheit nicht oeffentlich, sondern admin-only. Gleichzeitig existiert in `routes/domains/main.php` die korrekte oeffentliche Route `GET /tracking` ebenfalls unter dem Namen `public.tracking`. Da pro Request immer nur eine Domain-Routedatei geladen wird, gab es keinen direkten Konflikt, aber auf der CRM-Subdomain zeigte der Routenname auf den falschen Endpunkt. + +Umsetzung: + +- `routes/domains/crm.php` + - Die `public.tracking`-Definition wurde entfernt und durch einen erklaerenden Kommentar ersetzt. +- `resources/views/admin/dhl/show.blade.php` + - Der bisher einzige Verbraucher (`route('public.tracking')` im aktuell deaktivierten `@if(false)`-Block) zeigt jetzt absolut auf die Main-Domain via `\App\Domain\EarlyDomainParser::getMainUrl().'/tracking'`. Die Trackingnummer wird zusaetzlich mit `urlencode()` geschuetzt. + +Tests: + +- `tests/Unit/Dhl/DhlRouteRegistrationTest.php` (neu) + - Stellt sicher, dass im CRM-Routing kein `Route::...->name('public.tracking')`-Eintrag mehr existiert. + - Stellt sicher, dass die Main-Domain weiterhin `GET /tracking` als `public.tracking` registriert. + +### Verifikation Phase 10 + +- `php artisan test --compact tests/Unit/Dhl tests/Feature/DhlApiCurlLoggingTest.php` + - Ergebnis: 79 Tests bestanden, 201 Assertions. +- `./vendor/bin/pint --dirty --format agent` + +### Verbleibendes Backlog + +- `updateDhlConfigCache()` ruft weiterhin `\Artisan::call('config:clear')` auf. Das ist global und kann bei gecachter Konfiguration zu kurzfristiger Latenz fuehren - akzeptabel, weil das Setting-Update ein seltener Admin-Vorgang ist. +- `dev/app-bak/Services/DhlApiService.php` (Backup-Kopie ausserhalb des Autoloaders) bleibt als historische Referenz erhalten. + +## Phase 11 - Tracking-Hardening (Bugfix Live, 2026-05-27) + +Status: umgesetzt am 2026-05-27. + +Ausloeser: Im Live-System lieferte das Cockpit beim Klick auf "Tracking aktualisieren" die irrefuehrende Meldung `Sendung nicht gefunden oder noch nicht im System erfasst. HTTP Status: 401`, obwohl die Sendung auf der DHL-Website laengst zugestellt war. Gleichzeitig zeigte das Cockpit einen frischen `last_tracked_at`-Zeitstempel mit altem `tracking_status`-Text - typisches Symptom eines schon laenger fehlschlagenden Trackings. + +### Wurzelursachen + +1. **Phantom-Fallback-Endpoint**: `DhlTrackingService::trackShipmentDE()` rief `https://api-eu.dhl.com/parcel/de/tracking/v0/shipments` auf. Dieser Endpoint existiert in der offiziellen DHL-Doku nicht (siehe `https://developer.dhl.com/api-reference/shipment-tracking`). Es gibt nur die "Shipment Tracking - Unified API" unter `https://api-eu.dhl.com/track/shipments`. Der Fallback produzierte daher zwangslaeufig 401/404. +2. **Falsche User-Message bei 401**: Eine `401 Unauthorized`-Antwort wurde dem Operator als "Sendung nicht gefunden" gezeigt. Der eigentliche Fehler (DHL-API-Key ohne Tracking-Subscription im DHL Developer Portal) blieb verborgen. +3. **Stale-Daten erscheinen frisch**: Sowohl `updateTrackingSync()` als auch `updateTrackingBatch()` setzten `last_tracked_at = now()` selbst dann, wenn die Tracking-Antwort fehlschlug. Damit wirkte ein veralteter Status im Cockpit "gerade eben aktualisiert". +4. **Per-Shipment-Fallback verbrennt Quota**: Bei einem Batch-Auth-Fehler ist der Code in eine Schleife mit `updateTracking()` pro Sendung gegangen, was den 401 mit `N` Folgeaufrufen multipliziert. +5. **Tote `api_secret`-Property**: Wurde im Konstruktor geladen aber nirgends im Request verwendet (Tracking-Unified-API kennt kein OAuth/Basic). + +### Umsetzung + +- `app/Services/DhlTrackingService.php` + - Neue Konstante `TRACKING_ENDPOINT = 'https://api-eu.dhl.com/track/shipments'`. + - `trackShipmentDE()` ersatzlos entfernt; `trackShipment()` hat keinen Fallback mehr. + - Neue private Helper `buildHttpOptions()`, `processSingleShipmentResponse()`, `isAuthErrorStatus()`, `buildAuthErrorMessage()`, `redactApiKey()`. + - 401/403 werden explizit als `auth_error => true` plus `http_status` zurueckgegeben, mit klarer User-Message: *"DHL Tracking API: Authentifizierung fehlgeschlagen (HTTP 401). Bitte pruefen, ob der hinterlegte DHL-API-Key gueltig ist und im DHL Developer Portal fuer 'Shipment Tracking - Unified' freigeschaltet wurde."*. + - 404 oder leere Sendungsliste werden als `not_found => true` markiert (eindeutig getrennt von Auth-Fehlern). + - `trackMultipleShipments()` nutzt jetzt denselben gemeinsamen Pfad und liefert ebenfalls strukturierte `auth_error`-Antworten. + - `updateTrackingSync()` setzt `last_tracked_at` **nur noch** bei Erfolg oder bei explizitem `not_found`. Auth- und Transport-Fehler lassen den Zeitstempel unangetastet, damit der naechste Cron-Lauf erneut versucht und das Cockpit den fehlschlagenden Zustand sichtbar macht. + - `updateTrackingBatch()` bricht beim ersten Auth-Fehler ab (kein Per-Shipment-Fallback, kein erneutes Tracking) und markiert alle Sendungen der laufenden Charge mit `auth_error`. Transport-Exceptions fuehren ebenfalls nicht mehr zu einem `last_tracked_at`-Update. + - Properties `apiSecret` und `isSandbox` entfernt. + - Diagnose-Logs enthalten jetzt Endpoint, Status-Code und ein redaktiertes API-Key-Suffix (`***`), aber niemals den vollen Key. + +### Tests + +- `tests/Unit/Dhl/DhlTrackingAuthErrorTest.php` (neu, 6 Tests) + - HTTP 401 / 403 fuehrt zu `auth_error => true` mit klarer Message. + - Es wird kein Fallback-Request mehr an `/parcel/de/tracking/...` gesendet. + - 404 / leere Sendungsliste werden eindeutig als `not_found` zurueckgegeben. + - Multi-Tracking liefert ebenfalls strukturierte `auth_error`-Antworten. + - Das Auth-Fehler-Log enthaelt den vollen API-Key nicht (nur Suffix). +- `tests/Unit/Dhl/DhlTrackingStaleProtectionTest.php` (neu, 3 Tests) + - `updateTracking()` ruft bei 401 **kein** `$shipment->update(...)` auf - `last_tracked_at` bleibt also fuer den naechsten Cron-Versuch alt. + - `updateTracking()` setzt bei "not found" gezielt nur `last_tracked_at`, damit nicht sofort erneut probiert wird. + - `updateTrackingBatch()` bricht bei einem Auth-Fehler nach dem ersten Versuch ab (`Http::assertSentCount(1)`) und markiert alle drei Sendungen der Charge als `auth_error => true`. + +### Verifikation Phase 11 + +- `php artisan test --compact tests/Unit/Dhl tests/Feature/DhlApiCurlLoggingTest.php` + - Ergebnis: 88 Tests bestanden, 263 Assertions (vorher Phase 10: 79 Tests, 201 Assertions). +- `./vendor/bin/pint --dirty --format agent` + +### Operatives Vorgehen bei einem 401 im Live + +Der Code kann das Problem nur sichtbar machen und nicht beheben - die eigentliche Loesung erfolgt im DHL Developer Portal: + +1. DHL Developer Portal → My Apps → die produktive App oeffnen. +2. Unter "APIs" pruefen, ob *Shipment Tracking - Unified API* aktiv und genehmigt ist. Falls nicht: hinzufuegen und Production-Freischaltung beantragen (manuelle DHL-Approval). +3. Den `DHL_API_KEY` (Consumer Key) aus dem Portal mit dem Wert in den Einstellungen / `.env` abgleichen. +4. Nach Korrektur einmal *Tracking aktualisieren* im Cockpit anstossen; der `last_tracked_at`-Zeitstempel wird jetzt nur noch bei tatsaechlich erfolgreicher Antwort frisch gesetzt. + +## Phase 12 - 429-Handling und lokale Quota-Pause (Bugfix Live, 2026-05-27) + +Status: umgesetzt am 2026-05-27 (Folgebefund zu Phase 11). + +Ausloeser: Trotz Phase 11 zeigte das Live-System weiterhin veraltete Tracking-Stati. Konkretes Beispiel: Sendung `5082` (Trackingnummer `00340435065133094790`) blieb seit `2026-05-27 05:00:33` auf "in der Region des Empfaengers angekommen", obwohl DHL bereits um 13:41 Uhr "Zustellung erfolgreich" meldete. Quer ueber **alle 381 aktiven Sendungen** war `MAX(last_tracked_at) = 06:00:35` - der stuendliche Cron `dhl:update-tracking` lieferte ab 07:00 Uhr ueber 11 Stunden keine Updates mehr. + +### Wurzelursachen (revidiert) + +1. **DHL-Tageslimit von 250 Aufrufen pro Tag erschoepft**. Laut [`developer.dhl.com/api-reference/shipment-tracking`](https://developer.dhl.com/api-reference/shipment-tracking) bekommt jede Standard-App initial *250 calls per day, with a maximum of 1 call every 5 seconds*. Direkter Test gegen den Live-Endpunkt belegte das: `curl https://api-eu.dhl.com/track/shipments?trackingNumber=...` lieferte `HTTP 429` mit Body `{"status":429,"title":"Too Many Requests","detail":"Too many requests within defined time period, please try again later."}`. Der Antrag auf eine hoehere Quota muss im DHL Developer Portal gestellt werden. + + > Hinweis zur ersten Diagnose: Wir hatten zunaechst vermutet, der Live-`DHL_API_KEY` sei der oeffentliche Sandbox-Demo-Key aus der DHL-Doku, weil der Wert mit den im Repo eingecheckten Sandbox-Beispielen uebereinstimmte (`tests/Feature/DhlApiCurlLoggingTest.php`, `tests/DHL/curl-trace.txt`). Diese Annahme war falsch - der Wert in der Live-`.env` ist ein echter produktiver Key, er hat nur das Standard-Tageslimit. Die fruehere Operator-Message mit dem Hinweis "Sandbox-Demo-Key" wurde entfernt. + +2. **429 wurde wie ein generischer Fehler behandelt**: Der Code aus Phase 11 erkannte 401/403 als `auth_error` und 404 als `not_found`, aber 429 fiel in den Pfad "Fehler beim Abrufen der Tracking-Informationen. HTTP 429". Im Batch fuehrte das zum Per-Shipment-Fallback, der pro Sendung einen weiteren 429-Aufruf abgesetzt hat - die schon erschoepfte Quota wurde so noch schneller weiter belastet und der Cron-Lauf dauerte unnoetig lange. + +3. **Folgelaeufe verbrennen Quota nur um 429 erneut zu sehen**: Solange DHL noch im selben Quota-Fenster mit 429 antwortet, kostet jeder hourly-Cron mindestens einen HTTP-Roundtrip. Bei knapper Quota ist das ein Selbstlaeufer, der den Recovery-Moment nach dem Tages-Reset spuerbar verzoegert. + +### Umsetzung + +- `app/Services/DhlTrackingService.php` + - `processSingleShipmentResponse()` erkennt `HTTP 429` explizit und liefert `rate_limited => true` plus optional `retry_after` (Sekunden). Die User-Message lautet jetzt sachlich: *"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."* Der frueher hier eingefuegte "Sandbox-Demo-Key"-Hinweis ist entfernt. + - `trackMultipleShipments()` erkennt 429 ebenfalls und liefert dieselbe strukturierte Antwort fuer die Batch-Route. + - `updateTrackingBatch()` bekommt einen eigenen `rate_limited`-Pfad analog zu `auth_error`: Beim ersten 429 wird die gesamte Charge sofort als `rate_limited` markiert und der Cron-Lauf abgebrochen, ohne Per-Shipment-Fallback. `last_tracked_at` bleibt unangetastet, damit der naechste planmaessige Cron-Lauf die gleichen Sendungen erneut versucht (statt sie mit frischem Zeitstempel und altem Status zu konservieren). + - Auch im "Sonstiger Batch-Fehler"-Fallback wird ein `rate_limited`-Befund pro Sendung als Abbruchgrund erkannt - die Schleife bricht sofort ab, ebenso wie schon bei `auth_error`. + - Neuer Helper `extractRetryAfter()` interpretiert sowohl ganzzahlige Sekunden als auch RFC-7231-`HTTP-date`-Werte aus dem `Retry-After`-Header. + - Logging in `[DHL Tracking Service] Unified API rate-limited` / `Multi tracking rate-limited` / `Batch tracking aborted due to rate-limit` enthaelt Endpoint, HTTP-Status, ein redaktiertes API-Key-Suffix (`***`) und - falls von DHL geliefert - die `Retry-After`-Sekunden. + +- Lokale Quota-Pause (neu in Phase 12.1) + - Neuer Cache-Key `dhl_tracking:quota_paused_until` haelt einen absoluten "do not call DHL before"-Zeitstempel. Default-Pause: 1 Stunde, wenn DHL keinen `Retry-After`-Header sendet; andernfalls genau die signalisierte Wartezeit. + - Statische Helper auf `DhlTrackingService`: `isQuotaPaused()`, `getQuotaPausedUntil()`, `pauseQuota(?int $retryAfterSeconds = null)`, `clearQuotaPause()`. + - `trackShipment()`, `trackMultipleShipments()` und `updateTrackingBatch()` pruefen die Pause **vor jedem HTTP-Roundtrip**. Ist sie aktiv, wird sofort eine `rate_limited => true`-Antwort mit `paused_until` zurueckgegeben - kein API-Aufruf. Damit kostet ein Cron-Lauf in einem ausgeschoepften Quota-Fenster **0 Calls** statt 10+ und beschleunigt den Recovery-Moment nach Quota-Reset. + - Beim ersten echten 429-Response wird `pauseQuota($retryAfter)` aufgerufen, sodass ein einzelner Cron-Lauf maximal einen "Quota-Probierschuss" pro Stunde absetzt. + +### Tests + +- `tests/Unit/Dhl/DhlTrackingRateLimitTest.php` (neu, 9 Tests) + - 429-Single-Tracking-Response: Antwort ist `rate_limited => true`, enthaelt `http_status = 429`, `retry_after = 120` und eine deutschsprachige Operator-Message mit DHL-Doku-konformem Hinweis (`Tageslimit erreicht`, `250 Aufrufe pro Tag`, `Quota-Erhoehung`). Der frueher gepruefte String "Sandbox-Demo-Key" darf explizit **nicht** mehr vorkommen. + - Multi-Tracking liefert dieselbe strukturierte 429-Antwort. + - `updateTrackingBatch()` bricht nach genau einem HTTP-Aufruf ab, markiert die Charge als `rate_limited` und ruft `$shipment->update(...)` nicht auf. + - HTTP-date als `Retry-After` wird korrekt in Sekunden umgerechnet. + - Im Rate-Limit-Log taucht der volle API-Key nicht auf, nur das `***`. + - **Quota-Pause aktiviert sich aus dem `Retry-After`-Header** und der zweite `trackShipment()`-Aufruf sendet keinen HTTP-Request mehr (`Http::assertSentCount(1)`). + - **Default-Pause** ohne `Retry-After`-Header dauert ~1 Stunde (mit Toleranz fuer Test-Drift). + - **`updateTrackingBatch()` ueberspringt 25 Sendungen vollstaendig**, wenn die Pause bereits gecached ist (`Http::assertNothingSent()`). + - `clearQuotaPause()` raeumt den Cache-Eintrag wieder auf - wichtig fuer Tests und fuer Operator-Eingriffe. + +### Verifikation Phase 12 + +- `php artisan test --compact tests/Unit/Dhl` + - Ergebnis: 96 Tests bestanden, 406 Assertions (vorher Phase 11: 88 Tests, 263 Assertions). +- `./vendor/bin/pint --dirty --format agent` + +### Operatives Vorgehen bei einem 429 im Live + +Der Code-Fix verhindert, dass aus einer erschoepften Tracking-Quota ein **mehrstuendiger Datenstau** wird, das Cockpit veraltete Stati als "gerade aktualisiert" anzeigt oder die hourly-Crons die Recovery selbst weiter verzoegern. Die eigentliche Loesung liegt aber im DHL-Account: + +1. **DHL Developer Portal -> My Apps -> produktive App oeffnen.** +2. **Pruefen, ob *Shipment Tracking - Unified API* in der App als Production approved ist** (nicht nur Sandbox). Standard-Quota: 250 Aufrufe pro Tag, max. 1 Aufruf alle 5 Sekunden. +3. **Im DHL Developer Portal eine Quota-Erhoehung beantragen** (Antrag-Formular im API-Detailbereich). Solange das offen ist: Hourly-Cron mit Status-basierten Intervallen einplanen, dazu siehe "Empfehlung Sendungsvolumen vs. Quota" unten. +4. **Cron-Lauf beobachten**: Nach dem ersten 429 bleibt der Cron fuer 1 Stunde lokal in einer Quota-Pause, ohne weitere DHL-Aufrufe. Der naechste `dhl:update-tracking` versucht es dann erneut. Sobald die Quota frei wird (Production-Erhoehung oder Tages-Reset), nimmt der Cron die Sendungen automatisch wieder mit. +5. Falls Operator manuell sofort entsperren moechte: `php artisan tinker` -> `App\Services\DhlTrackingService::clearQuotaPause()`. + +### Empfehlung Sendungsvolumen vs. Quota + +Aktuell sind ~381 Sendungen aktiv (`status IN ('created','in_transit','out_for_delivery','exception','unknown')`). Die DHL-Standardquota von 250 Calls/Tag reicht dafuer mathematisch nicht aus, sobald jede Sendung mehrmals pro Tag aktualisiert werden soll. Solange keine erhoehte Quota bewilligt ist, sind drei Stellschrauben sinnvoll - Reihenfolge nach Wirkung: + +1. **`--stale-days` enger setzen** (z.B. 14 statt 30). Sendungen mit Status `created`, die seit zwei Wochen keinen Fortschritt zeigen, sind in der Praxis nicht zustellbar oder Karteileichen. `markStaleShipmentsCompleted()` schliesst sie automatisch ab und reduziert die Cron-Last spuerbar (in der aktuellen DB sind 266 von 381 aktiven Sendungen im Status `created`). +2. **Status-Intervalle verlaengern** (`DhlShipment::TRACKING_INTERVALS`). Vorschlag fuer 250/Tag: `out_for_delivery = 1h`, `in_transit = 6h`, `exception = 8h`, `unknown = 12h`, `created = 24h`. Das passt grob in die Quota und behaelt schnelle Reaktion fuer die "letzte Meile". +3. **Vorhandener "Batch"-Pfad ist faktisch keine Ersparnis**: Die DHL Unified Tracking API kennt laut Doku nur den Singular-Parameter `trackingNumber`. `trackMultipleShipments()` sendet `?trackingNumber=A,B,C,...` - das wird als eine einzige unbekannte Sendungs-ID behandelt, schickt den Lauf in den Per-Shipment-Fallback und kostet *einen Call extra pro Chunk*. Wenn die Quota verlaesslich erhoeht ist, sollte dieser Pfad entweder gegen einzelne Calls mit 5-Sekunden-Throttling ersetzt oder ganz entfernt werden. Bewusst noch nicht angefasst, weil das Refactoring umfangreich ist - separate Phase 13 wenn gewuenscht. + +### Verbleibende Beobachtungen + +- Eine zusaetzliche Beobachtung (Monitoring) waere sinnvoll: ein Alarm wenn `MAX(last_tracked_at)` quer ueber alle aktiven Sendungen aelter als z.B. 3 Stunden ist. Das macht eine erschoepfte Quota oder einen haengenden Cron sofort sichtbar, ohne dass jemand einzelne Sendungen manuell prueft. +- Default-Pause von 1 Stunde ist eine sinnvolle Heuristik, weil DHL kein verlaessliches `Retry-After` mitsendet. Falls die echte Quota-Reset-Zeit bekannt wird, koennte man die Default-Pause dynamisch bis zum naechsten Reset stellen. + +## Phase 13 - Echtes Single-Tracking + Quota-vertraegliche Intervalle (2026-05-27) + +Status: umgesetzt am 2026-05-27 (Folge zu Phase 12 - die Quota-Erhoehung auf 10.000 calls/day wurde im DHL Developer Portal beantragt, kann aber nach Erfahrung mehrere Wochen brauchen. Bis dahin muss das System mit den 250/day-Standardlimits sauber funktionieren). + +### Ausloeser + +Bei der Aufarbeitung von Phase 12 sind zwei strukturelle Schwachstellen aufgefallen: + +1. **`trackMultipleShipments()` war kein echter Batch-Aufruf**. Die DHL Unified Tracking API kennt laut [Doku](https://developer.dhl.com/api-reference/shipment-tracking) nur den Singular-Parameter `trackingNumber`. Der Code sendete `?trackingNumber=A,B,C,...,J` als eine kommaseparierte Liste - DHL interpretierte das als *eine* unbekannte Sendungs-ID, lieferte ein leeres `shipments`-Array zurueck und der Code fiel in den Per-Shipment-Fallback. Effekt: **ein verschwendeter Aufruf pro Chunk plus** die zehn echten Aufrufe danach. +2. **Status-Intervalle aus Vor-Audit-Zeit**: `in_transit = 2h` und `created = 6h` waren so eng, dass selbst mit perfekt funktionierender API ca. 2.500 Aufrufe pro Tag nötig waeren - rund das **10-fache** des dokumentierten Standardlimits von 250 calls/day. + +### Umsetzung + +- `app/Services/DhlTrackingService.php` + - **`trackMultipleShipments()` komplett entfernt**. Damit existiert nur noch der Single-Tracking-Pfad `trackShipment()`, der einen einzelnen `trackingNumber` an DHL sendet. Ein neuer Unit-Test (`it does not have a trackMultipleShipments method anymore`) friert diese Entfernung als Vertrag ein. + - Neue statische Property `$callIntervalSeconds = 5` plus Setter/Getter `setCallIntervalSeconds()` / `getCallIntervalSeconds()`. Voreinstellung folgt der DHL-Doku ("max 1 call every 5 seconds"). In Tests wird der Wert auf `0` gesetzt, damit die Suite nicht real schlaeft. + - `updateTrackingBatch()` neu geschrieben: Anstelle der Chunks von 10 mit Pseudo-Batch-API laeuft jetzt **eine echte HTTP-Anfrage pro Sendung**, dazwischen `sleep($callIntervalSeconds)`. Abbruchbedingungen unveraendert: Quota-Pause vor dem ersten Call, `auth_error` oder `rate_limited` brechen die Schleife sofort ab, alle restlichen Sendungen werden ohne weiteren API-Aufruf uebersprungen. Bei einem Transport-Fehler (DNS/TLS) wird die einzelne Sendung als `transport_error` markiert; der Loop laeuft weiter, weil das ein vorruebergehendes Netzproblem sein kann. + - Das Cron-Log enthaelt jetzt zusaetzlich die Felder `http_calls` und `call_interval_seconds`, sodass Quota- und Throttle-Verhalten direkt nachvollziehbar sind. + +- `packages/acme-laravel-dhl/src/Models/DhlShipment.php` + - `TRACKING_INTERVALS` angehoben: + - `out_for_delivery`: 1 h (unveraendert - kundenrelevant, betrifft nur sehr wenige Sendungen gleichzeitig) + - `in_transit`: 2 h -> **6 h** + - `exception`: 4 h -> **8 h** + - `unknown`: 4 h -> **12 h** + - `created`: 6 h -> **24 h** + - `DEFAULT_TRACKING_INTERVAL`: 4 h -> **8 h**. + - Indikative Rechnung mit aktuellem Live-Bestand (266 `created` + 115 `in_transit`): + - `created` 1x/Tag = 266 calls/Tag + - `in_transit` 4x/Tag = 460 calls/Tag + - Summe ~726 calls/Tag bei vollstaendigem Throughput - reicht fuer 10.000/Tag-Quota dreifach, aktiviert bei 250/Tag-Standardquota nach ca. 5 Stunden die Quota-Pause aus Phase 12. Da bleibt der Tracking-Status fuer alle Sendungen ohne `out_for_delivery` mindestens 1x/Tag aktuell - das ist mit Standard-Quota verkraftbar. + +### Tests + +- `tests/Unit/Dhl/DhlTrackingBatchThrottleTest.php` (neu, 4 Tests) + - 3 Sendungen erzeugen **genau 3 HTTP-Calls**, jede mit *einzelner* `trackingNumber` (kein Komma in den Query-Params). + - `setCallIntervalSeconds(1)` -> 3 Sendungen brauchen >= 2 s Wall-Clock (`sleep(1)` zwischen Call 1->2 und 2->3). + - `getCallIntervalSeconds()` Round-Trip plus `-99` wird auf `0` geklemmt. + - Vertragstest: `DhlTrackingService::trackMultipleShipments` existiert nicht mehr. +- `tests/Unit/Dhl/DhlTrackingStaleProtectionTest.php` (angepasst) + - Der "Batch bricht beim ersten Auth-Fehler ab"-Test pruef jetzt **`failed = 1`** statt `3`: in der neuen Logik wird nur die *erste* Sendung tatsaechlich angefragt, der Rest gar nicht erst gestartet. Das ist quotaschonender als das alte "Charge komplett als auth_error markieren"-Verhalten. +- `tests/Unit/Dhl/DhlTrackingRateLimitTest.php` (angepasst) + - Der frueher gegen `trackMultipleShipments()` gerichtete Test "marks the multi-tracking call as rate_limited too" ist ersatzlos entfernt, weil die Methode nicht mehr existiert. + - Der 429-Batch-Test pruefe jetzt analog **`failed = 1` mit nur einem HTTP-Call** - die anderen 11 Sendungen werden uebersprungen. +- `tests/Unit/Dhl/DhlTrackingAuthErrorTest.php` (angepasst) + - Der frueher direkte `trackMultipleShipments()`-Test ist entfernt, weil die Methode nicht mehr existiert. + +### Verifikation Phase 13 + +- `php artisan test --compact tests/Unit/Dhl` + - Ergebnis: 98 Tests bestanden, 398 Assertions. +- `./vendor/bin/pint --dirty --format agent` + +### Operatives Vorgehen + +- **Solange Standard-Quota (250/day) aktiv ist**: Mit den neuen Intervallen erreicht das System ein realistisches Limit, bevor die Quota-Pause aus Phase 12 zuschlaegt. `out_for_delivery`-Sendungen werden bevorzugt aktualisiert, der Rest folgt nach. +- **Sobald die beantragte 10.000/day-Quota durchgeht**: Keine Code-Anpassung noetig. Die Intervalle sind so gewaehlt, dass auch der naechste Bestandszuwachs (mehr Sendungen) noch passt; bei sehr starkem Wachstum koennten `in_transit` wieder auf 4 h gesetzt werden. +- **Throttle bei eigenen Service Level**: Wer einen vertraglich erweiterten Service Level mit > 1 call/sec hat, kann das im Bootstrap setzen, z.B. `DhlTrackingService::setCallIntervalSeconds(2);`. Default bleibt 5 s, weil das den Standard-Service-Levels und der Doku entspricht. + +### Verbleibende Empfehlung + +- `--stale-days` im Cron auf 14 setzen, sobald sicher ist, dass keine legitime Bestellung > 2 Wochen im Status `created` bleibt. Das reduziert den getrackten Sendungspool spuerbar (266 von 381 aktiven Sendungen sind aktuell `created`, viele davon sind reine Karteileichen, deren aelteste seit `2026-04-29 03:00` keine Aenderung mehr hatten). Bewusst noch nicht durchgefuehrt, weil das eine Geschaeftsentscheidung ist - der aktuelle Default bleibt bei `--stale-days=30`. + ## Legacy-Dokumentation Die bisherigen Markdown-Dateien wurden nach `dev/dhl-modul/legacy` verschoben. Sie bleiben als Historie erhalten, sind aber nicht mehr die aktuelle Arbeitsgrundlage. diff --git a/docs/Kurzfristige Anpassungen.md b/docs/Kurzfristige Anpassungen.md deleted file mode 100644 index 597f099..0000000 --- a/docs/Kurzfristige Anpassungen.md +++ /dev/null @@ -1,19 +0,0 @@ - -ABO -Ein Abo hat eine bestimmte Regel und einen eigenen Bestand an Produkten. -Zusätzlich soll es möglich sein, kurz bevor das Abo ausgeführt wird, soll es möglich sein aus dem regulären Stand der Produkte also da, wo der Berater oder auch später der Kunde seine Produkte bestellt, weitere Produkte seinem Abo hinzuzufügen. - -Dazu folgenden Ablauf ich würde jedem Berater oder Kunden, der ein Abo hat drei Tage dann vorher eine E-Mail zuschicken, dass dein Abo jetzt entsprechend vorbereitet wird. In diesem Zeitraum hat er Zeit aus der regulären Bestellung weitere Produkte zu seiner Abo Bestellung hinzuzufügen. Das bedeutet er geht quasi in das eigentliche bestellen fügt seinen Warenkorb dann nicht einer neuen Bestellungen hinzu, sondern hat die Option zu meinem Abo hinzufügen. In diesem Augenblick spart er sich am Ende Versandkosten und bekommt eine gesammelte Bestellung. Ich würde es so machen, dass dann die Bestellung regulär in die Bestellungen läuft aber mit dem Hinweis wird zusammen mit dem Abo versendet. - -Für die Berater und die Kunden fallen dabei die Versandkosten bei dem Berater zusätzlich das Kompensation Produkt (das ist noch zu klären). Gut wäre hier auch eine Option in den Einstellungen. - -Es gibt ja einen Lock, d.h. X Tage bevor das Abo ausgeführt wird, ist es nicht mehr änderbar. -Dieser Lock muss händisch änderbar sein, irgendwo in den Einstellungen. Wir haben uns darauf geeinigt, dass das zwei oder drei Tage vorher ausreicht. Ich glaube, derzeit sind es fünf oder zehn Tage. - - -Mitgliedschaft - -Die Mitgliedschaft wird immer zehn Tage vorher verlängert. Das kann grundsätzlich raus denn jeweils früher möglich unterschiedliche Pakete für die Mitgliedschaft zu buchen. Das ist jetzt nicht mehr möglich. Von daher verlängert sich am Ende die Mitgliedschaft in dem Augenblick, wo die Mitgliedschaft Gebühr bezahlt wird. Dafür gibt es so genannte Reminder im System. Diese müssen inhaltlich angepasst werden. Die Reminder sollen grundsätzlich bleiben und auf die Zahlung der Mitgliedschaft hinwirken.. in den E-Mails gibt es Links, die anscheinend nicht mehr richtig funktionieren. Richtig gut wäre es, wenn wir hier einen Magiclink bauen können, der direkt zur Zahlung geht. - -Weiterhin ist es natürlich möglich, auch direkt im Sales Center die entsprechende Verlängerung abzuschließen. Hier gibt es das Produkt Business Paket oder ähnlich. Dieses Produkt hat aber die Beschreibung. Beim ersten Abschluss eines Beratervertrag ist die Verlängerung benötigt ein eigenes Paket, wo die Beschreibung entsprechend angepasst wird. - diff --git a/docs/dhl/Anpassung DHL Modul.md b/docs/dhl/Anpassung DHL Modul.md deleted file mode 100644 index de74255..0000000 --- a/docs/dhl/Anpassung DHL Modul.md +++ /dev/null @@ -1,49 +0,0 @@ - - -Das DHL Cockpit ist super, leider klappt es nur für Deutschland. -Kommt Österreich, Spanien etc auch noch dazu? -Vermutlich ist derzeit die Schnittstelle nur für den deutschen Markt ausgelegt prüfen, wie wir die weiteren reinbekommen. - - -1 Feld für Sendungsreferenz oder Sonstiges (da wir öfters "Nachlieferung" oder ähnliches rein schreiben müssen) - -Es wäre gut, wenn eine Meldung kommen würde, falls die Straße, PLZ oder Ort falsch ist, bevor das Etikett erstellt wird. Erst wenn man das Etikett ausdrucken will erscheint in DHL eine Information, dass zb die Straße nicht existiert. Dann muss man alles stornieren. Warenpost können wir nicht stornieren und muss beszehalt werden. -Ihr brauchen wir eine Lösung, die die Adresse validiert bitte einmal vorschlagen, was es hier für Lösungen gibt über APIs - Direkt über DHL? - -stornieren über das DHL Cockpit hat leider noch nicht funktioniert. Da kam eine Fehlermeldung. -Wäre super, wenn Du das noch ergänzen könntest. -Prüfen, warum das nicht funktioniert. Hier gibt es bisher schon eine implementierte Lösung. - - -=> Gewicht Kompenataion, wird aktuell nicht mit in das DHL Paket Gewicht mit eingerechnet, da das Kompensation Produkt mit dem Gewicht null in den Warenkorb kommt damit keine Berechnung des Produktes entsteht. Hier müssten wir eine Lösung finden, wie wir das Gewicht vermutlich direkt über das Produkt auslesen und zum Paketgewicht hinzufügen. - -Prüfen der Verwandte E-Mails. Derzeit werden Tracking Mails generiert. Hier müssen wir einmal prüfen, in welchem Rhythmus und wie oft diese generiert und ausgelöst werden. Wichtig ist, dass wir hier nicht zu viele raussenden etc. -Wichtig ist auch, dass bei jeder Bestellung sowohl beim Admin als auch beim User als auch im User N Portal die Tracking Codes entsprechend einsehbar Sendung aufrufbar. - -Versenden nur mit Statusänderung. - -Prüfen folgender Mail - -Sehr geehrte Kundinnen und Kunden, - -wir möchten Sie erneut daran erinnern, dass bis spätestens Sonntag, 31.05.2026, eine technische Umstellung Ihrer Systeme erforderlich ist. Ab dem 31.05.2026 treten verbindliche technische Anpassungen an unseren DHL-Systemen in Kraft. - -Bitte stellen Sie sicher, dass die Umstellung fristgerecht erfolgt, um eine weiterhin reibungslose Nutzung unserer DHL-Systeme zu gewährleisten. - -Deaktivierung Produktkürzel Warenpost zum 31.05.2026 - -Zum 01.01.2025 wurde das neue Produkt DHL Kleinpaket eingeführt und hat das bisherige Produkt Warenpost abgelöst. Wir haben festgestellt, dass Sie aktuell noch das veraltete Produktkürzel für Warenpost verwenden. Bislang wurde dieses Kürzel in unseren Systemen automatisch in DHL Kleinpaket umgewandelt. Diese technische Übergangslösung wird zum 31.05.2026 deaktiviert. Ab diesem Zeitpunkt ist eine automatische Anpassung des veralteten Produktkürzels leider nicht mehr möglich. - -Bitte nehmen Sie die Umstellung daher zeitnah vor: - -API-Anbindung: Ersetzen Sie das Produktkürzel „V62WP“ (Warenpost) durch „V62KP“ (DHL Kleinpaket). Sollten Sie eine Softwarelösung für die Sendungsbeauftragung nutzen, setzen Sie sich bitte zeitnah mit dem Hersteller in Verbindung, - -CSV-Import (Funktion „Versenden“ im Post & DHL Geschäftskundenportal oder DHL Polling Software): Passen Sie Ihre CSV-Dateien entsprechend an und ersetzen Sie „V62WP“ durch „V62KP“. - -Bei Fragen zur Umstellung können Sie uns jederzeit über das Group API Developer Portal Help Center kontaktieren. https://support-developer.dhl.com/support/home Als eingeloggter Nutzer können Sie dort außerdem ein Ticket erstellen, um Unterstützung zu erhalten. - -Vielen Dank für Ihre Unterstützung bei der Umstellung, mit der Sie einen reibungslosen Versand auch über den 31.05.2026 hinaus sicherstellen. - -Viele Grüße - -Ihr DHL-Team \ No newline at end of file diff --git a/docs/salescenter/Todos Backoffice.md b/docs/salescenter/Todos Backoffice.md deleted file mode 100644 index 869a0fb..0000000 --- a/docs/salescenter/Todos Backoffice.md +++ /dev/null @@ -1,157 +0,0 @@ - - - -#### 1. Überarbeitetes Dashboard & KPI-Übersicht - -Das Dashboard soll eine interaktive Struktur erhalten (Linien 1 bis x). Folgende Kennzahlen müssen präzise abgebildet werden: - -- **Anzahl der eigenen Kundenabos:** Direkte Sichtbarkeit der persönlichen Kundenentwicklung. - -- **Kundenabos im Team (Terminologie-Korrektur):** Die Bezeichnung wird von „Team Kunden“ auf **„Teamkundenabos“** oder **„Kundenabos im Team“** geändert. Grund: Team-Kunden haben nicht zwingend ein aktives Abonnement; die Kennzahl muss jedoch rein die Abos widerspiegeln. - -- **Umsatz & Volumen:** Darstellung des Umsatzes pro Linie (Abos, Einzelbestellungen etc.) inklusive Summenbildung. - - -#### 2. Interaktivität & „Deep Dive“ (Klickbarkeit) - -Es ist entscheidend, dass Zahlen keine abstrakten Werte bleiben. Hinter jeder Kennzahl müssen die entsprechenden Personen sichtbar sein: - -- **Klick-Funktion für Statistiken:** Wenn z. B. „55 Teamabos“ angezeigt werden, muss diese Zahl anklickbar sein, um die Liste der dahinterstehenden Personen zu öffnen. - -- **Neupartner & Teamabos:** Eine direkte Klickmöglichkeit für Teamabos und Neupartner wird implementiert, damit Führungskräfte sofort sehen, wer diese Menschen sind und sie gezielt unterstützen können. - -- **Detailansicht pro Person:** Beim Klick auf eine Linie öffnen sich detaillierte Infos (Generation, Punkte pro Abo, Ausführungsdaten der Kundenabos). - - -#### 3. Spezial-Kennzahl: „1000 Punkte Shop“ - -Um Top-Performer hervorzuheben, wird eine neue Metrik eingeführt: - -- **Definition:** Erfassung aller Teampartner, die einen Kundenumsatz von mindestens 1000 Punkten erzielen. - -- **Funktion:** Beim Klick auf diese Kennzahl erscheint eine Namensliste, die nach Volumen absteigend sortiert ist und die jeweiligen Volumenpunkte explizit anzeigt. - - -#### 4. System-Anpassungen & Formulare - -- **Bestell-Formular:** Integration einer Pflichtabfrage: _„Von wem hast du von Mivita erfahren?“_, um die Zuweisung und das Marketing besser tracken zu können. - -- **Stornoprozess:** Prüfung der Logik für Stornorechnungen. Es muss sichergestellt werden, dass bei einer Stornierung die entsprechenden Punkte systemseitig korrekt zurückgeführt (abgezogen) werden. - - -#### 5. Rechtliches & Sichtbarkeit (Incentives) - -- **Transparenz in Ranglisten:** Für Incentives (z. B. Montenegro) sollen alle Namen der Teilnehmer (nicht nur die Top 30) mit Foto und Land angezeigt werden. - -- **Opt-in Button:** Kevin implementiert einen Button, mit dem Partner aktiv zustimmen können, dass ihr Name in den Ranglisten für alle sichtbar ist. - -- **Rechtliche Prüfung:** Ein Network-Anwalt wird durch Dani/Alois hinzugezogen, um die Anzeige von Namen in internen Ehrungen ohne explizite Einzelzustimmung abzuklären. - - -#### 6. Multimedia-Bereich - -- **Event-Archiv:** Ein neuer Reiter (analog zum News-Archiv) wird erstellt. Hier lädt Susi regelmäßig Fotos von Veranstaltungen und Calls hoch, um das Momentum im Team zu fördern. -### Briefing: Optimierung & Ausbau des Mivita Backoffice (Fokus: Dashboard-Logik & Interaktivität) - -Ziel dieses Umbaus ist es, das aktuelle "Daten-Chaos" durch eine übersichtliche, interaktive und klickbare dreistufige Tabellenstruktur zu ersetzen. Führungskräfte müssen von der Vogelperspektive bis auf den einzelnen Kunden durchklicken können. - -#### Stufe 1: Das Haupt-Dashboard (Die Gesamtübersicht) - -Die Startseite der Statistiken zeigt eine kompakte Zusammenfassung der gesamten Struktur (alle tatsächlich vorhandenen Linien sowie eine Summenzeile am Ende). - -- **Spaltenaufbau:** * Linie (1. Linie, 2. Linie etc.) - - - Anzahl der Berater - - - Umsatz (Gesamtumsatz aus Abos, Einzelbestellungen etc.) - - - Anzahl der Teamabos - - - Anzahl der Kundenabos - -- **Klick-Logik:** Egal, wo man in dieser Übersicht hinklickt (auf eine ganze Linie oder auf eine konkrete Zahl wie z.B. "3 Teamabos"), es öffnet sich immer eine tiefergehende Detailseite. - -- **Aktueller MVP-Stand:** Die Kennzahlen sind als klickbare Badges umgesetzt. Die Summenzeile ist ebenfalls klickbar und öffnet über alle vorhandenen Linien. Bei Teamabos und Teamkundenabos wird zusätzlich angezeigt, wie viele Abos im gewählten Monat neu dazugekommen sind. - - -#### Stufe 2: Die Linien-Detailansicht (Generationen-Ebene) - -Klickt man in der Hauptübersicht beispielsweise auf **"1. Linie"**, öffnet sich eine neue Seite, die alle direkten Partner (Firstlines) dieser Linie namentlich auflistet (z. B. Anna, Lena, Lisa). - -- **Spaltenaufbau pro Partner:** - - - Name der Firstline (z. B. Anna) - - - Eigenes Abo (Anzeige in Punkten) - - - Kundenabos (Anzahl der Abos & Gesamtpunktewert dieser Abos) - - - Teampartnerabos (Anzahl der Abos & Gesamtpunktewert in der Organisation) - - - Kundenabos im Team (Anzahl der Abos & Gesamtumsatz dieser Abos) - - - **Gesamt:** Diese Spalte ist besonders wichtig. Sie addiert alle Abos (Eigenes Abo + Kundenabos + Teamabos + Kundenabos des Teams) und zeigt die Gesamt-Aboanzahl sowie die Gesamt-Umsatzpunkte pro Person an. - -- **Klick-Logik:** Auch hier ist jede Kennzahl (z.B. "Kundenabos" bei Anna) wieder anklickbar. - - -#### Stufe 3: Die Tiefen-Detailansicht (Listen-Ebene) - -Wenn man noch mehr Infos zu einer spezifischen Kennzahl haben möchte (entweder durch Klick auf eine Zahl im Haupt-Dashboard oder durch Klick auf eine Metrik bei einem bestimmten Partner in Stufe 2), öffnet sich das tiefste Level. - -- **Beispiel 1 (Klick auf Annas "Kundenabos"):** Es öffnet sich das Fenster "Anna - Kundenabos". Man sieht oben eine Zusammenfassung (z.B. "Insgesamt: 4 Kundenabos / 200 Abokundenpunkte"). Darunter werden alle 4 Kunden einzeln und übersichtlich aufgelistet. - -- **Beispiel 2 (Klick auf "3 Teamabos" im Haupt-Dashboard):** Es öffnet sich das Fenster "Teamabos Generation 1". Auch hier gibt es eine Zusammenfassung und darunter die Liste der 3 Personen (z.B. Sabine, Carola, Anna). - -- **Angezeigte Daten pro Eintrag in der Liste:** - - - Name des Kunden/Partners - - - Punktewert (z. B. 50 Punkte) - - - Nächste Ausführung (Datum, z. B. 10.4.2026) - - - Abo Lieferungen (Anzahl, z. B. 1) - - - Status des Abos, z. B. aktiv, angehalten, storniert oder inaktiv - - - Besteht-seit-Datum; neue Abos im gewählten Monat werden optisch hervorgehoben - -- **Aktueller MVP-Stand:** Detailtabellen haben eine Suche, klickbare Spaltensortierung, eine Summenzeile und einen CSV-Export. Der gewählte Monat/Jahr bleibt beim Wechsel zwischen Übersicht und Detailansicht erhalten. - -- **Performance/Snapshots:** Abgeschlossene Monate können als Backoffice-Statistik-Snapshot gespeichert werden. Dadurch bleiben vergangene Monatswerte stabil und müssen bei großen Teams nicht jedes Mal live neu berechnet werden. - -- **1000-Punkte-Shop:** Die Detailansicht zeigt keine zusätzliche Qualifikations-Einteilung mehr, sondern den aktuellen Karriere-Level des Beraters. Die Punkte bleiben nach Eigenpunkten, Kundenabo-Punkten, Einzelbestellungs-Punkten und sonstigen Kundenpunkten getrennt sichtbar. - -- **Karriere-Level:** Detailansichten zeigen den aktuellen Karriere-Level des jeweiligen Beraters, damit die Namenlisten fachlich besser einordbar sind. - -- **Datenschutz-Hinweis:** Detailansichten weisen sichtbar darauf hin, dass personenbezogene Daten rechtlich noch final geklärt werden und aktuell nur für berechtigte VIP-Auswertungen vorgesehen sind. - -- **Übersichts-Export:** Die Linienübersicht kann als CSV exportiert werden. Enthalten sind alle Linien, die Summenzeile, neue Abo-Zählungen und die getrennten Punktewerte. - -- **Tests:** CSV-Inhalte für Übersicht und Details, der Zeitraum-Erhalt zwischen Übersicht, Detailansicht und Export, neue Abo-Markierungen und Abo-Statusgründe aus Zahlungsfehlern sind gezielt abgesichert. - -- **Performance-Hinweis:** Die Übersicht zeigt, ob die Daten live oder aus einem Snapshot geladen wurden, inklusive Laufzeit der Berechnung. - -- **Checkout-Herkunft:** Kundenbestellungen im Shop fragen eine vordefinierte Herkunft plus optionalen Freitext ab. Die Werte werden an der Bestellung gespeichert und im Bestelldetail angezeigt. - - ---- - -#### Weitere wichtige Backoffice-Anpassungen (abseits der Listen-Logik) - -- **Neue Spezial-Kennzahl „1000 Punkte Shop“:** - - - Eine zusätzliche, anklickbare Kennzahl in der Übersicht. - - - Zeigt die Anzahl der Teampartner an, die mindestens 1000 Punkte Kundenumsatz generiert haben. - - - Beim Klick darauf erscheint eine Liste mit den Namen dieser Partner, absteigend sortiert nach Volumen, wobei die genauen Volumenpunkte angezeigt werden. - -- **Erfassung der Herkunft (Formular):** - - - Im Bestellformular wird die verpflichtende Abfrage eingefügt: _„Von wem hast du von Mivita erfahren?“_ (Wichtig für Zuordnung und Tracking). - -- **Stornoprozess:** - - - Die IT muss prüfen, ob Stornorechnungen systemseitig korrekt verarbeitet werden, sodass bei einem Storno die entsprechenden Punkte automatisch und fehlerfrei zurückgeführt (abgezogen) werden. \ No newline at end of file diff --git a/packages/acme-laravel-dhl/src/Models/DhlShipment.php b/packages/acme-laravel-dhl/src/Models/DhlShipment.php index 2903d28..dd494e9 100644 --- a/packages/acme-laravel-dhl/src/Models/DhlShipment.php +++ b/packages/acme-laravel-dhl/src/Models/DhlShipment.php @@ -368,20 +368,33 @@ class DhlShipment extends Model /** * Tracking interval per status (in hours). - * Determines how often each status should be re-checked via the DHL API. + * + * Determines how often each status should be re-checked via the DHL + * Unified Tracking API. The defaults were tightened in Phase 13 of the + * DHL refactor to fit into the documented Standard service level of + * "250 calls per day, max 1 call every 5 seconds" + * (https://developer.dhl.com/api-reference/shipment-tracking#rate-limits). + * + * Indicative cost for the current production data (266 created / + * 115 in_transit shipments): + * - in_transit 6 h -> 4 calls/day * 115 = ~460 calls/day + * - created 24 h -> 1 call/day * 266 = 266 calls/day + * - out_for_delivery 1 h is kept short on purpose because that status + * only affects very few shipments at a time but matters most for + * customer-facing "kommt heute" emails. */ public const TRACKING_INTERVALS = [ 'out_for_delivery' => 1, - 'in_transit' => 2, - 'exception' => 4, - 'unknown' => 4, - 'created' => 6, + 'in_transit' => 6, + 'exception' => 8, + 'unknown' => 12, + 'created' => 24, ]; /** - * Default tracking interval in hours for statuses not explicitly listed + * Default tracking interval in hours for statuses not explicitly listed. */ - public const DEFAULT_TRACKING_INTERVAL = 4; + public const DEFAULT_TRACKING_INTERVAL = 8; /** * Scope for shipments that need a tracking update based on status-dependent intervals. diff --git a/packages/acme-laravel-dhl/src/Services/ReturnsService.php b/packages/acme-laravel-dhl/src/Services/ReturnsService.php index 00a9fba..0d1c7af 100644 --- a/packages/acme-laravel-dhl/src/Services/ReturnsService.php +++ b/packages/acme-laravel-dhl/src/Services/ReturnsService.php @@ -2,15 +2,15 @@ namespace Acme\Dhl\Services; -use Acme\Dhl\Support\DhlClient; -use Acme\Dhl\Models\DhlShipment; -use Acme\Dhl\Services\ShippingService; -use Illuminate\Support\Facades\Storage; -use InvalidArgumentException; -use Exception; use Acme\Dhl\Jobs\CreateReturnLabelJob; +use Acme\Dhl\Models\DhlShipment; +use Acme\Dhl\Support\DhlClient; +use App\Services\DhlProductResolver; +use Exception; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Validator; +use InvalidArgumentException; /** * DHL Returns Service for creating and managing return labels @@ -22,8 +22,9 @@ class ReturnsService /** * Create a return label for a shipment * - * @param array $returnData Return shipment data + * @param array $returnData Return shipment data * @return array Return label details including number and path + * * @throws InvalidArgumentException When required data is missing * @throws Exception When API request fails */ @@ -32,6 +33,7 @@ class ReturnsService $validatedData = $this->validateReturnData($returnData); if (config('dhl.use_queue')) { CreateReturnLabelJob::dispatch($validatedData); + return ['queued' => true]; } @@ -82,14 +84,14 @@ class ReturnsService $returnNumber = $this->extractReturnNumber($response); $labelBase64 = $this->extractLabelData($response); - if (!$returnNumber) { + if (! $returnNumber) { Log::error('[DHL Returns] Failed to extract return number', [ 'response' => $response, ]); throw new Exception('Failed to extract return shipment number from DHL API response'); } - if (!$labelBase64) { + if (! $labelBase64) { Log::warning('[DHL Returns] No label data in response', [ 'return_number' => $returnNumber, ]); @@ -108,7 +110,7 @@ class ReturnsService 'label_path' => $labelPath, 'returnShipment' => $returnShipment, 'raw' => $response, - 'method' => 'returns_api' + 'method' => 'returns_api', ]; } @@ -129,7 +131,7 @@ class ReturnsService $consignee = $this->convertAddressFor2LetterCountry($returnData['consignee']); // Get DHL config for dimensions - $settingController = new \App\Http\Controllers\SettingController(); + $settingController = new \App\Http\Controllers\SettingController; $dhlConfig = $settingController->getDhlConfig(); // Prepare data for regular shipment (shipper and consignee are already swapped) @@ -155,7 +157,7 @@ class ReturnsService 'height' => 60, ], - 'reference' => 'Return-' . ($returnData['order_id'] ?? time()), + 'reference' => 'Return-'.($returnData['order_id'] ?? time()), ]; Log::info('[DHL Returns] Prepared shipment data for fallback', [ @@ -195,14 +197,14 @@ class ReturnsService 'label_path' => $result['label_path'] ?? $result['labelPath'] ?? null, 'returnShipment' => $returnShipment, 'raw' => $result, - 'method' => 'shipping_api_fallback' + 'method' => 'shipping_api_fallback', ]; } /** * Get return shipment by return number * - * @param string $returnNumber DHL return number + * @param string $returnNumber DHL return number * @return DhlShipment|null Return shipment model or null if not found */ public function getReturnShipment(string $returnNumber): ?DhlShipment @@ -215,7 +217,7 @@ class ReturnsService /** * Get all return shipments for an order * - * @param int $orderId Order ID + * @param int $orderId Order ID * @return \Illuminate\Database\Eloquent\Collection Return shipments collection */ public function getOrderReturns(int $orderId): \Illuminate\Database\Eloquent\Collection @@ -228,7 +230,7 @@ class ReturnsService /** * Get returns for a specific outbound shipment * - * @param int $shipmentId Original outbound shipment ID + * @param int $shipmentId Original outbound shipment ID * @return \Illuminate\Database\Eloquent\Collection Related return shipments */ public function getShipmentReturns(int $shipmentId): \Illuminate\Database\Eloquent\Collection @@ -310,7 +312,7 @@ class ReturnsService }); // Get billing number from config - $settingController = new \App\Http\Controllers\SettingController(); + $settingController = new \App\Http\Controllers\SettingController; $dhlConfig = $settingController->getDhlConfig(); $billingNumber = $dhlConfig['billing_number'] ?? config('dhl.billing_number'); @@ -320,8 +322,8 @@ class ReturnsService return [ 'receiverId' => 'DEDE', - 'customerReference' => 'Return-' . ($returnData['order_id'] ?? time()), - 'shipmentReference' => 'Return-Order-' . ($returnData['order_id'] ?? time()), + 'customerReference' => 'Return-'.($returnData['order_id'] ?? time()), + 'shipmentReference' => 'Return-Order-'.($returnData['order_id'] ?? time()), 'billingNumber' => $billingNumber, 'shipper' => $shipper, 'receiver' => $consignee, @@ -355,11 +357,11 @@ class ReturnsService */ private function saveLabelFile(?string $returnNumber, ?string $labelBase64, string $format): ?string { - if (!$labelBase64 || !$returnNumber) { + if (! $labelBase64 || ! $returnNumber) { return null; } - $path = 'dhl/returns/' . $returnNumber . '.' . strtolower($format); + $path = 'dhl/returns/'.$returnNumber.'.'.strtolower($format); Storage::disk('local')->put($path, base64_decode($labelBase64)); return $path; @@ -386,89 +388,59 @@ class ReturnsService 'company' => $shipper['name2'] ?? '', 'email' => $shipper['email'] ?? '', 'recipient' => $returnData, - 'api_response_data' => $response + 'api_response_data' => $response, ]); } /** - * Convert 2-letter country code to 3-letter country code for DHL API - * - * @param string $countryCode 2-letter or 3-letter ISO country code (e.g., "DE" or "DEU") - * @return string 3-letter ISO country code (e.g., "DEU") + * Convert 2-letter or 3-letter country code to DHL-compatible 3-letter code. + * + * Delegates to {@see DhlProductResolver::toDhlCountryCode()} so that the + * resolver remains the single source of truth for supported countries. + * Unknown country codes throw an {@see InvalidArgumentException} so that + * a misconfigured return cannot silently be routed to Germany. */ private function convertCountryCode(string $countryCode): string { - $code = strtoupper(trim($countryCode)); + $resolver = new DhlProductResolver; - // If already 3 letters, check if valid and return - if (strlen($code) === 3) { - $validThreeLetterCodes = ['DEU', 'AUT', 'CHE', 'FRA', 'ITA', 'ESP', 'NLD', 'BEL', 'LUX', 'POL', 'CZE', 'DNK', 'SWE', 'NOR', 'GBR', 'USA']; - return in_array($code, $validThreeLetterCodes) ? $code : 'DEU'; + try { + return $resolver->toDhlCountryCode($countryCode); + } catch (\InvalidArgumentException $e) { + throw new InvalidArgumentException( + 'DHL Retoure: '.$e->getMessage(), + $e->getCode(), + $e + ); } - - // Convert 2-letter to 3-letter - $countryMap = [ - 'DE' => 'DEU', - 'AT' => 'AUT', - 'CH' => 'CHE', - 'FR' => 'FRA', - 'IT' => 'ITA', - 'ES' => 'ESP', - 'NL' => 'NLD', - 'BE' => 'BEL', - 'LU' => 'LUX', - 'PL' => 'POL', - 'CZ' => 'CZE', - 'DK' => 'DNK', - 'SE' => 'SWE', - 'NO' => 'NOR', - 'GB' => 'GBR', - 'US' => 'USA', - ]; - - return $countryMap[$code] ?? 'DEU'; } /** - * Convert address with 3-letter country code back to 2-letter for ShippingService - * - * @param array $address Address with 3-letter country code - * @return array Address with 2-letter country code + * Convert address with 3-letter country code back to ISO-2 for ShippingService. + * + * Uses {@see DhlProductResolver::normalizeCountryCode()} which accepts both + * 2- and 3-letter codes and rejects unsupported countries. The previous + * implementation silently fell back to `DE` which contradicted the + * resolver-based concept introduced in phase 3. */ private function convertAddressFor2LetterCountry(array $address): array { $converted = $address; - // Convert 3-letter to 2-letter country code - if (isset($address['country'])) { - $reverseMap = [ - 'DEU' => 'DE', - 'AUT' => 'AT', - 'CHE' => 'CH', - 'FRA' => 'FR', - 'ITA' => 'IT', - 'ESP' => 'ES', - 'NLD' => 'NL', - 'BEL' => 'BE', - 'LUX' => 'LU', - 'POL' => 'PL', - 'CZE' => 'CZ', - 'DNK' => 'DK', - 'SWE' => 'SE', - 'NOR' => 'NO', - 'GBR' => 'GB', - 'USA' => 'US', - ]; + if (! isset($address['country']) || $address['country'] === '') { + return $converted; + } - $code = strtoupper($address['country']); + $resolver = new DhlProductResolver; - // If it's 3 letters, convert to 2 - if (strlen($code) === 3) { - $converted['country'] = $reverseMap[$code] ?? 'DE'; - } else { - // Already 2 letters, keep as is - $converted['country'] = $code; - } + try { + $converted['country'] = $resolver->normalizeCountryCode((string) $address['country']); + } catch (\InvalidArgumentException $e) { + throw new InvalidArgumentException( + 'DHL Retoure: '.$e->getMessage(), + $e->getCode(), + $e + ); } return $converted; diff --git a/packages/acme-laravel-dhl/src/Services/ShippingService.php b/packages/acme-laravel-dhl/src/Services/ShippingService.php index 447f5bf..1dd76f1 100644 --- a/packages/acme-laravel-dhl/src/Services/ShippingService.php +++ b/packages/acme-laravel-dhl/src/Services/ShippingService.php @@ -8,6 +8,7 @@ use Acme\Dhl\Jobs\CreateShipmentJob; use Acme\Dhl\Models\DhlShipment; use Acme\Dhl\Support\DhlClient; use App\Services\DhlProductResolver; +use App\Services\DhlShipmentService; use App\Services\DhlShipmentWeightCalculator; use Exception; use Illuminate\Support\Facades\DB; @@ -34,7 +35,7 @@ class ShippingService */ public function createLabel(array $orderData): array { - Log::info('createLabel', $orderData); + Log::info('[DHL Shipping] createLabel called', DhlShipmentService::sanitizeOrderDataForLog($orderData)); $validatedData = $this->validateOrderData($orderData); if (config('dhl.use_queue')) { CreateShipmentJob::dispatch($validatedData); @@ -45,12 +46,7 @@ class ShippingService return DB::transaction(function () use ($validatedData) { $payload = $this->buildShipmentPayload($validatedData); - // Debug logging: Log the exact payload being sent to DHL API - Log::info('[DHL API] Sending payload to DHL', [ - 'endpoint' => '/parcel/de/shipping/v2/orders', - 'payload' => $payload, - 'payload_json' => json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), - ]); + Log::info('[DHL API] Sending payload to DHL', $this->buildPayloadLogContext($payload, $validatedData)); try { // Build query parameters for print format @@ -62,9 +58,7 @@ class ShippingService $response = $this->client->request('post', '/parcel/de/shipping/v2/orders', $payload, $query); - Log::info('[DHL API] Response received', [ - 'response' => $response, - ]); + Log::info('[DHL API] Response received', $this->buildResponseLogContext($response)); $this->assertSuccessfulShipmentResponse($response, $this->shouldUseMustEncode($validatedData)); } catch (DhlValidationException $e) { if ($this->shouldUseMustEncode($validatedData) && $this->looksLikeAddressValidationError($e->getMessage())) { @@ -73,10 +67,10 @@ class ShippingService throw $e; } catch (Exception $e) { - Log::error('[DHL API] Request failed', [ - 'error' => $e->getMessage(), - 'payload' => $payload, - ]); + Log::error('[DHL API] Request failed', array_merge( + $this->buildPayloadLogContext($payload, $validatedData), + ['error' => $e->getMessage()] + )); throw $e; } @@ -204,6 +198,53 @@ class ShippingService return null; } + /** + * Build a redacted DHL request log context. + * + * Logs only routing-relevant metadata and never the full payload, since + * the payload contains personal address data and billing numbers. + * + * @param array $payload + * @param array $validatedData + * @return array + */ + private function buildPayloadLogContext(array $payload, array $validatedData): array + { + $billingNumber = data_get($payload, 'shipments.0.billingNumber'); + + return [ + 'endpoint' => '/parcel/de/shipping/v2/orders', + 'order_id' => $validatedData['order_id'] ?? null, + 'product_code' => data_get($payload, 'shipments.0.product'), + 'billing_number_suffix' => is_string($billingNumber) ? mb_substr($billingNumber, -4) : null, + 'weight_grams' => data_get($payload, 'shipments.0.details.weight.value'), + 'consignee_country' => data_get($payload, 'shipments.0.consignee.country'), + 'has_reference' => ! empty(data_get($payload, 'shipments.0.refNo')), + 'must_encode' => $this->shouldUseMustEncode($validatedData), + ]; + } + + /** + * Build a redacted DHL response log context. + * + * Drops the base64 label payload, which is large and not useful in logs. + * + * @param array $response + * @return array + */ + private function buildResponseLogContext(array $response): array + { + return [ + 'shipment_number' => $this->extractShipmentNumber($response), + 'has_label' => $this->extractLabelData($response) !== null, + 'routing_code' => $this->extractRoutingCode($response), + 'item_status_code' => data_get($response, 'items.0.sstatus.statusCode') + ?? data_get($response, 'status.statusCode'), + 'item_status_title' => data_get($response, 'items.0.sstatus.title') + ?? data_get($response, 'status.title'), + ]; + } + /** * Validate required order data according to DHL API v2 specification */ @@ -380,20 +421,30 @@ class ShippingService $addressData['houseNumber'] = $parsed['houseNumber']; Log::info('Parsed German address', [ - 'original' => $street, - 'parsed_street' => $parsed['street'], - 'parsed_houseNumber' => $parsed['houseNumber'], + 'original_street_length' => strlen($street), + 'parsed_street_length' => strlen($parsed['street']), + 'parsed_house_number_length' => strlen($parsed['houseNumber']), ]); - } elseif (! $parsed['houseNumber']) { - // If we can't parse house number, use a default - $addressData['houseNumber'] = '1'; - Log::warning('Could not parse house number from address, using default', [ - 'street' => $street, - ]); + return $addressData; } - return $addressData; + // No house number could be parsed from the street. We must not invent + // one (the previous `'1'` default caused parcels to be delivered to + // the wrong address) and the DHL API rejects shipments without a + // house number anyway. Surface a validation error so the operator can + // fix the address before we ever hit DHL. + Log::warning('Could not parse house number from address', [ + 'street_length' => strlen($street), + 'country' => $addressData['country'] ?? null, + 'postal_prefix' => isset($addressData['postalCode']) && is_string($addressData['postalCode']) + ? mb_substr($addressData['postalCode'], 0, 2) + : null, + ]); + + throw new InvalidArgumentException( + 'Hausnummer fehlt in der Adresse und konnte nicht automatisch aus dem Strassenfeld ermittelt werden. Bitte Strasse und Hausnummer separat erfassen.' + ); } /** diff --git a/resources/views/admin/dhl/show.blade.php b/resources/views/admin/dhl/show.blade.php index b7cb744..e9006e6 100644 --- a/resources/views/admin/dhl/show.blade.php +++ b/resources/views/admin/dhl/show.blade.php @@ -109,9 +109,12 @@
Tracking
@if(false) + @php + $publicTrackingUrl = \App\Domain\EarlyDomainParser::getMainUrl().'/tracking'; + @endphp {{ $shipment->dhl_shipment_no }}
- Verfolgen diff --git a/resources/views/public/tracking.blade.php b/resources/views/public/tracking.blade.php index 4aa66fa..10c07dc 100644 --- a/resources/views/public/tracking.blade.php +++ b/resources/views/public/tracking.blade.php @@ -212,20 +212,37 @@ $(document).ready(function() { }); }); + // Escape HTML special characters so DHL-/DB-derived strings can never + // execute JavaScript even if jQuery's .html() is used. + function escapeTrackingHtml(value) { + if (value === null || value === undefined) { + return ''; + } + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + // Show tracking results function showTrackingResult(data) { var statusBadge = getStatusBadge(data.status); - var trackingStatusInfo = data.tracking_status ? - `

DHL Status: ${data.tracking_status}

` : ''; - var lastTrackedInfo = data.last_tracked_at ? - `

Zuletzt aktualisiert: ${data.last_tracked_at}

` : ''; - + var trackingStatusInfo = data.tracking_status ? + `

DHL Status: ${escapeTrackingHtml(data.tracking_status)}

` : ''; + var lastTrackedInfo = data.last_tracked_at ? + `

Zuletzt aktualisiert: ${escapeTrackingHtml(data.last_tracked_at)}

` : ''; + + var trackingNumberEscaped = escapeTrackingHtml(data.tracking_number); + var trackingNumberUrlEncoded = encodeURIComponent(data.tracking_number ?? ''); + var html = `
- ${data.tracking_number} + ${trackingNumberEscaped}

Status: ${statusBadge}

${trackingStatusInfo} @@ -239,7 +256,7 @@ $(document).ready(function() {

Die Informationen werden regelmäßig aktualisiert. Für detaillierte Tracking-Informationen besuchen Sie die - DHL Website @@ -248,7 +265,7 @@ $(document).ready(function() {

`; - + $('#tracking-content').html(html); $('#tracking-results').show(); @@ -267,7 +284,9 @@ $(document).ready(function() { // Show error message function showError(message) { - $('#error-message .alert p').html(message); + // Use .text() instead of .html() so error strings from the server + // can never inject HTML. + $('#error-message .alert p').text(message); $('#error-message').show(); // Smooth scroll to error @@ -290,7 +309,7 @@ $(document).ready(function() { function getStatusBadge(status) { var badgeClass = ''; var text = status; - + switch(status) { case 'pending': badgeClass = 'badge-warning'; @@ -320,8 +339,11 @@ $(document).ready(function() { default: badgeClass = 'badge-light'; } - - return `${text}`; + + // Both the fallback `text` (raw status) and the class are escaped so + // unmapped DHL status codes can never inject HTML or break out of the + // class attribute. + return `${escapeTrackingHtml(text)}`; } // Get status icon diff --git a/routes/domains/crm.php b/routes/domains/crm.php index ec4307d..b09b46e 100644 --- a/routes/domains/crm.php +++ b/routes/domains/crm.php @@ -299,9 +299,14 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car Route::get('/shipment/{shipment}/download-label', 'DhlShipmentController@downloadLabel')->name('admin.dhl.download-label'); Route::post('/batch-action', 'DhlShipmentController@batchAction')->name('admin.dhl.batch-action'); Route::post('/test-login', 'DhlShipmentController@testLogin')->name('admin.dhl.test_login'); - Route::get('/public/track', 'DhlShipmentController@track')->name('public.tracking'); }); + // The previously registered `public.tracking` route lived inside this + // admin group, which made it auth/admin protected and therefore not + // actually public. The real public tracking page is defined on the + // main domain in `routes/domains/main.php`. It is intentionally not + // duplicated here. + // products attributes Route::get('/admin/product/attributes', 'AttributeController@index')->name('admin_product_attributes'); Route::post('/admin/product/attribute/store', 'AttributeController@store')->name('admin_product_attribute_store'); diff --git a/tests/Unit/Dhl/CreateShipmentJobSerializationTest.php b/tests/Unit/Dhl/CreateShipmentJobSerializationTest.php new file mode 100644 index 0000000..6891e03 --- /dev/null +++ b/tests/Unit/Dhl/CreateShipmentJobSerializationTest.php @@ -0,0 +1,41 @@ +id = 4711; + + $dhlConfig = [ + 'base_url' => 'https://api-eu.dhl.com', + 'api_key' => 'super-secret-api-key', + 'username' => 'mivita-user', + 'password' => 'super-secret-password', + 'api_secret' => 'super-secret-api-secret', + 'billing_number' => '63144073550101', + ]; + + $job = new CreateShipmentJob($order, 2.5, ['priority' => 'normal'], $dhlConfig); + + $serialized = serialize($job); + + expect($serialized) + ->not->toContain('super-secret-api-key') + ->not->toContain('super-secret-password') + ->not->toContain('super-secret-api-secret') + ->not->toContain('mivita-user') + ->not->toContain('63144073550101'); +}); + +it('does not expose a dhlConfig property on the job instance', function () { + $order = new ShoppingOrder; + $order->id = 1; + + $job = new CreateShipmentJob($order, 1.0, [], [ + 'api_key' => 'should-not-be-stored', + 'password' => 'should-not-be-stored', + ]); + + expect(property_exists($job, 'dhlConfig'))->toBeFalse(); +}); diff --git a/tests/Unit/Dhl/DhlConfigCachingTest.php b/tests/Unit/Dhl/DhlConfigCachingTest.php new file mode 100644 index 0000000..a404b8d --- /dev/null +++ b/tests/Unit/Dhl/DhlConfigCachingTest.php @@ -0,0 +1,47 @@ + 'https://api-eu.dhl.com', + 'api_key' => 'cached-api-key', + 'username' => 'cached-user', + 'password' => 'cached-password', + 'account_numbers' => ['V01PAK' => '63144073550101'], + ]; + + $reflection = new ReflectionClass(SettingController::class); + $property = $reflection->getProperty('cachedDhlConfig'); + $property->setAccessible(true); + $property->setValue(null, $cached); + + $controller = new SettingController; + + expect($controller->getDhlConfig())->toBe($cached); + + $secondCall = $controller->getDhlConfig(); + + expect($secondCall)->toBe($cached); +}); + +it('flushes the DHL configuration cache on demand', function () { + $reflection = new ReflectionClass(SettingController::class); + $property = $reflection->getProperty('cachedDhlConfig'); + $property->setAccessible(true); + $property->setValue(null, ['api_key' => 'cached-api-key']); + + expect($property->getValue())->toBe(['api_key' => 'cached-api-key']); + + SettingController::flushDhlConfigCache(); + + expect($property->getValue())->toBeNull(); +}); diff --git a/tests/Unit/Dhl/DhlModalAuthorizationTest.php b/tests/Unit/Dhl/DhlModalAuthorizationTest.php new file mode 100644 index 0000000..f24c2b3 --- /dev/null +++ b/tests/Unit/Dhl/DhlModalAuthorizationTest.php @@ -0,0 +1,47 @@ +andReturn($user); + } else { + Auth::shouldReceive('user')->andReturnNull(); + } + + $controller = new ModalController; + $method = (new ReflectionClass(ModalController::class))->getMethod('authorizeDhlShipmentModal'); + $method->setAccessible(true); + $method->invoke($controller); +} + +afterEach(function () { + Mockery::close(); +}); + +it('rejects guests for the DHL shipment modal', function () { + invokeDhlModalAuth(null); +})->throws(HttpException::class, 'DHL shipment modal is only available for admin users.'); + +it('rejects VIP users (admin == 1) for the DHL shipment modal', function () { + $vip = (new User)->forceFill(['admin' => 1]); + + invokeDhlModalAuth($vip); +})->throws(HttpException::class, 'DHL shipment modal is only available for admin users.'); + +it('rejects regular consultants for the DHL shipment modal', function () { + $consultant = (new User)->forceFill(['admin' => 0]); + + invokeDhlModalAuth($consultant); +})->throws(HttpException::class, 'DHL shipment modal is only available for admin users.'); + +it('allows real admin users (admin >= 2) for the DHL shipment modal', function () { + $admin = (new User)->forceFill(['admin' => 2]); + + invokeDhlModalAuth($admin); + + expect(true)->toBeTrue(); +}); diff --git a/tests/Unit/Dhl/DhlProductResolverTest.php b/tests/Unit/Dhl/DhlProductResolverTest.php index d65525f..8724c4a 100644 --- a/tests/Unit/Dhl/DhlProductResolverTest.php +++ b/tests/Unit/Dhl/DhlProductResolverTest.php @@ -1,6 +1,9 @@ 'env', + 'dhl.international_countries' => ['AT'], + ]); + + Setting::whereSlug('dhl_international_countries')->delete(); + Setting::setContentBySlug('dhl_international_countries', ['FR'], 'object'); + + expect((new DhlProductResolver)->getSupportedInternationalCountries())->toBe(['FR']); + } finally { + Setting::whereSlug('dhl_international_countries')->delete(); + + if ($createdSettingsTable) { + Schema::drop('settings'); + } + } +}); + +it('keeps an intentionally empty saved DHL international country list', function () { + $createdSettingsTable = ensureDhlSettingsTableForResolverTest(); + + try { + config([ + 'dhl.config_source' => 'env', + 'dhl.international_countries' => ['AT'], + ]); + + Setting::whereSlug('dhl_international_countries')->delete(); + Setting::setContentBySlug('dhl_international_countries', [], 'object'); + + expect((new DhlProductResolver)->getSupportedInternationalCountries())->toBe([]); + } finally { + Setting::whereSlug('dhl_international_countries')->delete(); + + if ($createdSettingsTable) { + Schema::drop('settings'); + } + } +}); + it('normalizes configurable international countries', function () { expect(DhlProductResolver::normalizeCountryCodeList([' at ', 'DE', 'XX', 'FR', 'AT']))->toBe(['AT', 'FR']); }); +function ensureDhlSettingsTableForResolverTest(): bool +{ + if (Schema::hasTable('settings')) { + return false; + } + + Schema::create('settings', function (Blueprint $table): void { + $table->increments('id'); + $table->string('slug')->index(); + $table->string('type')->nullable(); + $table->json('object')->nullable(); + $table->text('full_text')->nullable(); + $table->text('text')->nullable(); + $table->integer('int')->nullable(); + $table->timestamps(); + }); + + return true; +} + it('describes DHL product scope for preflight checks', function () { $resolver = new DhlProductResolver; diff --git a/tests/Unit/Dhl/DhlRouteRegistrationTest.php b/tests/Unit/Dhl/DhlRouteRegistrationTest.php new file mode 100644 index 0000000..d9f97d5 --- /dev/null +++ b/tests/Unit/Dhl/DhlRouteRegistrationTest.php @@ -0,0 +1,15 @@ +not->toMatch('/Route::(get|post|match|any)\([^)]*\)->name\([\'"]public\.tracking[\'"]\)/'); +}); + +it('still exposes the public tracking route on the main domain', function () { + $mainRoutes = file_get_contents(base_path('routes/domains/main.php')); + + expect($mainRoutes) + ->toMatch('/Route::get\([\'"]\/tracking[\'"][^)]*\)->name\([\'"]public\.tracking[\'"]\)/'); +}); diff --git a/tests/Unit/Dhl/DhlSanitizeLoggingTest.php b/tests/Unit/Dhl/DhlSanitizeLoggingTest.php new file mode 100644 index 0000000..96ea1dc --- /dev/null +++ b/tests/Unit/Dhl/DhlSanitizeLoggingTest.php @@ -0,0 +1,116 @@ + 'https://api-eu.dhl.com', + 'api_key' => 'super-secret-api-key', + 'username' => 'mivita-user', + 'password' => 'super-secret-password', + 'api_secret' => 'super-secret-api-secret', + 'billing_number' => '63144073550101', + 'use_queue' => false, + 'default_product' => 'V01PAK', + 'print_only_if_codeable' => true, + 'international_countries' => ['AT', 'ES'], + 'account_numbers' => [ + 'V01PAK' => '63144073550101', + 'V62KP' => '63144073556201', + 'V53PAK' => '63144073555301', + 'V07PAK' => null, + ], + ]; + + $sanitized = DhlShipmentService::sanitizeDhlConfigForLog($config); + $serialized = json_encode($sanitized); + + expect($sanitized) + ->toHaveKey('has_api_key', true) + ->toHaveKey('has_username', true) + ->toHaveKey('has_password', true) + ->toHaveKey('has_api_secret', true) + ->toHaveKey('base_url', 'https://api-eu.dhl.com') + ->toHaveKey('international_countries', ['AT', 'ES']) + ->and($sanitized['account_numbers_configured'])->toEqualCanonicalizing(['V01PAK', 'V62KP', 'V53PAK']); + + expect($serialized) + ->not->toContain('super-secret-api-key') + ->not->toContain('super-secret-password') + ->not->toContain('super-secret-api-secret') + ->not->toContain('mivita-user') + ->not->toContain('63144073550101'); +}); + +it('marks missing DHL credentials as not configured', function () { + $sanitized = DhlShipmentService::sanitizeDhlConfigForLog([ + 'base_url' => null, + 'api_key' => '', + 'username' => null, + 'password' => '', + 'account_numbers' => [ + 'V01PAK' => '', + 'V62KP' => null, + ], + ]); + + expect($sanitized) + ->toHaveKey('has_api_key', false) + ->toHaveKey('has_username', false) + ->toHaveKey('has_password', false) + ->toHaveKey('account_numbers_configured', []); +}); + +it('redacts personally identifiable information from order data logs', function () { + $orderData = [ + 'order_id' => 4711, + 'product_code' => 'V01PAK', + 'weight_kg' => 1.25, + 'label_format' => 'PDF', + 'print_format' => 'A4', + 'print_only_if_codeable' => true, + 'reference' => 'Order-4711', + 'shipper' => [ + 'name' => 'mivita care gmbh', + 'street' => 'Leinfeld', + 'houseNumber' => '2', + 'postalCode' => '87755', + 'city' => 'Kirchhaslach', + 'country' => 'DE', + 'email' => 'versand@mivita.care', + 'phone' => '+49 123 456789', + ], + 'consignee' => [ + 'name' => 'Max Mustermann', + 'street' => 'Hauptstrasse', + 'houseNumber' => '5', + 'postalCode' => '10115', + 'city' => 'Berlin', + 'country' => 'DE', + 'email' => 'max@example.com', + 'phone' => '+4930123456', + 'postNumber' => '1234567890', + ], + ]; + + $sanitized = DhlShipmentService::sanitizeOrderDataForLog($orderData); + $serialized = json_encode($sanitized); + + expect($sanitized) + ->toHaveKey('order_id', 4711) + ->toHaveKey('product_code', 'V01PAK') + ->toHaveKey('weight_kg', 1.25) + ->toHaveKey('consignee_country', 'DE') + ->toHaveKey('consignee_postal_prefix', '10') + ->toHaveKey('consignee_has_post_number', true) + ->toHaveKey('has_reference', true); + + expect($serialized) + ->not->toContain('Max Mustermann') + ->not->toContain('Hauptstrasse') + ->not->toContain('max@example.com') + ->not->toContain('+4930123456') + ->not->toContain('1234567890') + ->not->toContain('10115') + ->not->toContain('Berlin'); +}); diff --git a/tests/Unit/Dhl/DhlTrackingAuthErrorTest.php b/tests/Unit/Dhl/DhlTrackingAuthErrorTest.php new file mode 100644 index 0000000..e4700be --- /dev/null +++ b/tests/Unit/Dhl/DhlTrackingAuthErrorTest.php @@ -0,0 +1,108 @@ +getProperty('cachedDhlConfig'); + $property->setAccessible(true); + $property->setValue(null, [ + 'api_key' => 'cached-test-api-key-1234', + ]); +}); + +afterEach(function () { + SettingController::flushDhlConfigCache(); + DhlTrackingService::clearQuotaPause(); + Mockery::close(); +}); + +it('returns an explicit auth_error and a meaningful message on HTTP 401', function () { + Http::fake([ + 'api-eu.dhl.com/track/shipments*' => Http::response(['title' => 'Unauthorized'], 401), + ]); + + $service = new DhlTrackingService; + $result = $service->trackShipment('00340434292135100148'); + + expect($result) + ->toHaveKey('success', false) + ->toHaveKey('auth_error', true) + ->toHaveKey('http_status', 401) + ->toHaveKey('api_used', 'unified'); + + expect($result['message']) + ->toContain('Authentifizierung fehlgeschlagen') + ->toContain('HTTP 401') + ->toContain('Shipment Tracking - Unified'); + + // No fallback request to a phantom Parcel DE tracking endpoint must occur. + Http::assertSentCount(1); +}); + +it('marks HTTP 403 as auth error too', function () { + Http::fake([ + 'api-eu.dhl.com/track/shipments*' => Http::response(['title' => 'Forbidden'], 403), + ]); + + $service = new DhlTrackingService; + $result = $service->trackShipment('00340434292135100148'); + + expect($result['auth_error'])->toBeTrue() + ->and($result['http_status'])->toBe(403) + ->and($result['message'])->toContain('HTTP 403'); +}); + +it('distinguishes a "not found" response from a 401 auth error', function () { + Http::fake([ + 'api-eu.dhl.com/track/shipments*' => Http::response(['shipments' => []], 200), + ]); + + $service = new DhlTrackingService; + $result = $service->trackShipment('99999999999999999999'); + + expect($result) + ->toHaveKey('success', false) + ->toHaveKey('not_found', true) + ->and($result['message'])->toBe('Sendung nicht gefunden oder noch nicht im DHL-System erfasst.'); + + expect($result)->not->toHaveKey('auth_error'); +}); + +it('does not fall back to the non-existent parcel-de tracking endpoint anymore', function () { + Http::fake([ + 'api-eu.dhl.com/track/shipments*' => Http::response(['title' => 'Unauthorized'], 401), + 'api-eu.dhl.com/parcel/de/tracking*' => Http::response('should never be called', 500), + ]); + + $service = new DhlTrackingService; + $service->trackShipment('00340434292135100148'); + + Http::assertSentCount(1); + Http::assertNotSent(function ($request) { + return str_contains($request->url(), '/parcel/de/tracking'); + }); +}); + +it('redacts the api key in the auth-error log payload', function () { + Http::fake([ + 'api-eu.dhl.com/track/shipments*' => Http::response([], 401), + ]); + + \Illuminate\Support\Facades\Log::spy(); + + $service = new DhlTrackingService; + $service->trackShipment('00340434292135100148'); + + \Illuminate\Support\Facades\Log::shouldHaveReceived('error') + ->withArgs(function (string $message, array $context) { + return str_contains($message, 'authentication failed') + && ($context['api_key_suffix'] ?? null) === '***1234' + && ! str_contains(json_encode($context), 'cached-test-api-key'); + }); +}); diff --git a/tests/Unit/Dhl/DhlTrackingBatchThrottleTest.php b/tests/Unit/Dhl/DhlTrackingBatchThrottleTest.php new file mode 100644 index 0000000..ef37e62 --- /dev/null +++ b/tests/Unit/Dhl/DhlTrackingBatchThrottleTest.php @@ -0,0 +1,132 @@ +getProperty('cachedDhlConfig'); + $property->setAccessible(true); + $property->setValue(null, [ + 'api_key' => 'cached-test-api-key-7777', + 'use_queue' => false, + ]); + + DhlTrackingService::clearQuotaPause(); + DhlTrackingService::setCallIntervalSeconds(0); +}); + +afterEach(function () { + SettingController::flushDhlConfigCache(); + DhlTrackingService::clearQuotaPause(); + DhlTrackingService::setCallIntervalSeconds(5); + Mockery::close(); +}); + +/** + * @return DhlShipment&\Mockery\MockInterface + */ +function makeTrackableShipment(int $id, string $trackingNumber) +{ + /** @var DhlShipment&\Mockery\MockInterface $shipment */ + $shipment = Mockery::mock(DhlShipment::class)->makePartial(); + $shipment->id = $id; + $shipment->dhl_shipment_no = $trackingNumber; + $shipment->status = 'in_transit'; + + return $shipment; +} + +it('fires exactly one DHL request per shipment - no pseudo-batch comma list anymore', function () { + Http::fake([ + 'api-eu.dhl.com/track/shipments*' => Http::response([ + 'shipments' => [ + [ + 'id' => 'A1', + 'status' => [ + 'statusCode' => 'transit', + 'status' => 'Sendung in Zustellung', + 'timestamp' => '2026-05-27T12:00:00+02:00', + ], + 'events' => [], + ], + ], + ], 200), + ]); + + $shipments = new Collection([ + makeTrackableShipment(1, 'A1'), + makeTrackableShipment(2, 'B2'), + makeTrackableShipment(3, 'C3'), + ]); + + foreach ($shipments as $shipment) { + $shipment->shouldReceive('update')->andReturnTrue(); + } + + $service = new DhlTrackingService; + $stats = $service->updateTrackingBatch($shipments); + + expect($stats['updated'])->toBe(3); + expect($stats['failed'])->toBe(0); + + // 3 shipments -> exactly 3 HTTP calls, each with a single trackingNumber. + Http::assertSentCount(3); + Http::assertSent(function ($request) { + $value = $request->data()['trackingNumber'] ?? null; + + return $value !== null && ! str_contains((string) $value, ','); + }); +}); + +it('sleeps between calls when the throttle is enabled', function () { + DhlTrackingService::setCallIntervalSeconds(1); + + Http::fake([ + 'api-eu.dhl.com/track/shipments*' => Http::response([ + 'shipments' => [ + [ + 'id' => 'A1', + 'status' => ['statusCode' => 'transit', 'status' => 'In Zustellung', 'timestamp' => '2026-05-27T12:00:00+02:00'], + 'events' => [], + ], + ], + ], 200), + ]); + + $shipments = new Collection([ + makeTrackableShipment(1, 'A1'), + makeTrackableShipment(2, 'A1'), + makeTrackableShipment(3, 'A1'), + ]); + foreach ($shipments as $shipment) { + $shipment->shouldReceive('update')->andReturnTrue(); + } + + $service = new DhlTrackingService; + + $start = microtime(true); + $stats = $service->updateTrackingBatch($shipments); + $elapsed = microtime(true) - $start; + + expect($stats['updated'])->toBe(3); + // 3 calls with a 1s gap between calls -> >=2s total wall-clock time. + expect($elapsed)->toBeGreaterThanOrEqual(2.0); +}); + +it('respects setCallIntervalSeconds(0) for the test suite', function () { + expect(DhlTrackingService::getCallIntervalSeconds())->toBe(0); + + DhlTrackingService::setCallIntervalSeconds(5); + expect(DhlTrackingService::getCallIntervalSeconds())->toBe(5); + + DhlTrackingService::setCallIntervalSeconds(-99); + expect(DhlTrackingService::getCallIntervalSeconds())->toBe(0); +}); + +it('does not have a trackMultipleShipments method anymore (removed in Phase 13)', function () { + expect(method_exists(DhlTrackingService::class, 'trackMultipleShipments'))->toBeFalse(); +}); diff --git a/tests/Unit/Dhl/DhlTrackingRateLimitTest.php b/tests/Unit/Dhl/DhlTrackingRateLimitTest.php new file mode 100644 index 0000000..96ff126 --- /dev/null +++ b/tests/Unit/Dhl/DhlTrackingRateLimitTest.php @@ -0,0 +1,220 @@ +getProperty('cachedDhlConfig'); + $property->setAccessible(true); + $property->setValue(null, [ + 'api_key' => 'cached-test-api-key-9999', + 'use_queue' => false, + ]); + + // The 429 path activates a process-wide quota pause via the cache, + // which would otherwise leak between tests and short-circuit later + // assertions that expect a real HTTP request. + DhlTrackingService::clearQuotaPause(); + + // Batch tracking sleeps 5 seconds between calls in production; tests + // must not actually sleep, otherwise the suite would take minutes. + DhlTrackingService::setCallIntervalSeconds(0); +}); + +afterEach(function () { + SettingController::flushDhlConfigCache(); + DhlTrackingService::clearQuotaPause(); + Mockery::close(); +}); + +it('returns an explicit rate_limited flag with a German operator-facing message on HTTP 429', function () { + Http::fake([ + 'api-eu.dhl.com/track/shipments*' => Http::response( + ['status' => 429, 'title' => 'Too Many Requests'], + 429, + ['Retry-After' => '120'] + ), + ]); + + $service = new DhlTrackingService; + $result = $service->trackShipment('00340434292135100148'); + + expect($result) + ->toHaveKey('success', false) + ->toHaveKey('rate_limited', true) + ->toHaveKey('http_status', 429) + ->toHaveKey('retry_after', 120) + ->toHaveKey('api_used', 'unified'); + + // The message must reflect the actual DHL standard limits documented at + // https://developer.dhl.com/api-reference/shipment-tracking#rate-limits + // and must NOT accuse the operator of using a sandbox key - that was an + // incorrect Phase 12 assumption. + expect($result['message']) + ->toContain('Tageslimit erreicht') + ->toContain('HTTP 429') + ->toContain('250 Aufrufe pro Tag') + ->toContain('Quota-Erhoehung') + ->toContain('2 Minute') + ->not->toContain('Sandbox-Demo-Key'); +}); + +it('aborts the batch immediately on HTTP 429 and does not update last_tracked_at', function () { + Http::fake([ + 'api-eu.dhl.com/track/shipments*' => Http::response(['title' => 'Too Many Requests'], 429), + ]); + + /** + * @var Collection + */ + $shipments = collect(range(1, 12))->map(function (int $i) { + $shipment = Mockery::mock(DhlShipment::class)->makePartial(); + $shipment->id = $i; + $shipment->dhl_shipment_no = '003404342921351001'.str_pad((string) $i, 2, '0', STR_PAD_LEFT); + $shipment->status = 'in_transit'; + $shipment->shouldNotReceive('update'); + + return $shipment; + }); + + $service = new DhlTrackingService; + $stats = $service->updateTrackingBatch($shipments); + + // Phase 13: one HTTP call per shipment, abort on first 429. So only + // the first shipment is marked failed - the other 11 are skipped + // entirely without burning more quota. + expect($stats['updated'])->toBe(0); + expect($stats['failed'])->toBe(1); + expect($stats['results'])->toHaveCount(1); + expect($stats['results'][0]) + ->toHaveKey('rate_limited', true) + ->toHaveKey('success', false); + + Http::assertSentCount(1); +}); + +it('parses an HTTP-date Retry-After header into a positive integer', function () { + $retryDate = gmdate('D, d M Y H:i:s', time() + 300).' GMT'; + + Http::fake([ + 'api-eu.dhl.com/track/shipments*' => Http::response([], 429, ['Retry-After' => $retryDate]), + ]); + + $service = new DhlTrackingService; + $result = $service->trackShipment('00340434292135100148'); + + expect($result['retry_after']) + ->toBeInt() + ->toBeGreaterThan(250) + ->toBeLessThanOrEqual(310); +}); + +it('activates a quota pause from the Retry-After header and short-circuits subsequent calls', function () { + Http::fake([ + 'api-eu.dhl.com/track/shipments*' => Http::response([], 429, ['Retry-After' => '600']), + ]); + + $service = new DhlTrackingService; + $first = $service->trackShipment('00340434292135100100'); + + expect($first['rate_limited'])->toBeTrue(); + expect(DhlTrackingService::isQuotaPaused())->toBeTrue(); + expect(DhlTrackingService::getQuotaPausedUntil()) + ->not->toBeNull() + ->and(DhlTrackingService::getQuotaPausedUntil()->isFuture())->toBeTrue(); + + // Second call - must NOT hit DHL at all, must reuse the cached pause. + $second = $service->trackShipment('00340434292135100101'); + + expect($second) + ->toHaveKey('rate_limited', true) + ->toHaveKey('http_status', 429) + ->toHaveKey('paused_until'); + expect($second['message'])->toContain('Lokale Quota-Pause aktiv bis'); + + Http::assertSentCount(1); +}); + +it('uses the default pause window when DHL does not send a Retry-After header', function () { + Http::fake([ + 'api-eu.dhl.com/track/shipments*' => Http::response(['title' => 'Too Many Requests'], 429), + ]); + + $service = new DhlTrackingService; + $service->trackShipment('00340434292135100100'); + + $until = DhlTrackingService::getQuotaPausedUntil(); + + expect($until)->not->toBeNull(); + // Default is one hour - allow a small jitter for clock drift inside the test run. + $secondsAhead = $until->diffInSeconds(now(), false) * -1; + expect($secondsAhead) + ->toBeGreaterThan(3500) + ->toBeLessThanOrEqual(3700); +}); + +it('skips the entire updateTrackingBatch when a quota pause is already cached', function () { + DhlTrackingService::pauseQuota(900); + + Http::fake([ + 'api-eu.dhl.com/*' => Http::response(['title' => 'Should never be called'], 500), + ]); + + /** + * @var Collection + */ + $shipments = collect(range(1, 25))->map(function (int $i) { + $shipment = Mockery::mock(DhlShipment::class)->makePartial(); + $shipment->id = $i; + $shipment->dhl_shipment_no = '003404342921351002'.str_pad((string) $i, 2, '0', STR_PAD_LEFT); + $shipment->status = 'in_transit'; + $shipment->shouldNotReceive('update'); + + return $shipment; + }); + + $service = new DhlTrackingService; + $stats = $service->updateTrackingBatch($shipments); + + expect($stats['updated'])->toBe(0); + expect($stats['failed'])->toBe(25); + expect($stats['results'])->toHaveCount(25); + foreach ($stats['results'] as $row) { + expect($row['rate_limited'])->toBeTrue(); + expect($row['paused_until'])->toBeString(); + } + + // Crucially: NO HTTP call must have been sent. + Http::assertNothingSent(); +}); + +it('clears the quota pause via clearQuotaPause', function () { + DhlTrackingService::pauseQuota(600); + expect(DhlTrackingService::isQuotaPaused())->toBeTrue(); + + DhlTrackingService::clearQuotaPause(); + expect(DhlTrackingService::isQuotaPaused())->toBeFalse(); + expect(DhlTrackingService::getQuotaPausedUntil())->toBeNull(); +}); + +it('redacts the api key in the rate-limit log payload', function () { + Http::fake([ + 'api-eu.dhl.com/track/shipments*' => Http::response([], 429), + ]); + + \Illuminate\Support\Facades\Log::spy(); + + $service = new DhlTrackingService; + $service->trackShipment('00340434292135100148'); + + \Illuminate\Support\Facades\Log::shouldHaveReceived('error') + ->withArgs(function (string $message, array $context) { + return str_contains($message, 'rate-limited') + && ($context['api_key_suffix'] ?? null) === '***9999' + && ! str_contains(json_encode($context), 'cached-test-api-key'); + }); +}); diff --git a/tests/Unit/Dhl/DhlTrackingStaleProtectionTest.php b/tests/Unit/Dhl/DhlTrackingStaleProtectionTest.php new file mode 100644 index 0000000..362266c --- /dev/null +++ b/tests/Unit/Dhl/DhlTrackingStaleProtectionTest.php @@ -0,0 +1,119 @@ +getProperty('cachedDhlConfig'); + $property->setAccessible(true); + $property->setValue(null, [ + 'api_key' => 'cached-test-api-key-1234', + 'use_queue' => false, + ]); + + DhlTrackingService::clearQuotaPause(); + DhlTrackingService::setCallIntervalSeconds(0); +}); + +afterEach(function () { + SettingController::flushDhlConfigCache(); + DhlTrackingService::clearQuotaPause(); + DhlTrackingService::setCallIntervalSeconds(5); + Mockery::close(); +}); + +/** + * @return DhlShipment&\Mockery\MockInterface + */ +function makeFakeShipment(int $id, string $trackingNumber, ?Carbon\Carbon $lastTrackedAt = null) +{ + /** @var DhlShipment&\Mockery\MockInterface $shipment */ + $shipment = Mockery::mock(DhlShipment::class)->makePartial(); + $shipment->id = $id; + $shipment->dhl_shipment_no = $trackingNumber; + $shipment->last_tracked_at = $lastTrackedAt; + + return $shipment; +} + +it('does not update last_tracked_at when the Unified API returns 401', function () { + Http::fake([ + 'api-eu.dhl.com/track/shipments*' => Http::response([], 401), + ]); + + $shipment = makeFakeShipment(123, '00340434292135100148', now()->subDays(2)); + + // The sync path must never call ->update() on the shipment when an auth + // error occurs. Asserting "never called" is the strongest contract we + // can express here without a database. + $shipment->shouldNotReceive('update'); + + $service = new DhlTrackingService; + $result = $service->updateTracking($shipment, ['auto_retrack' => false]); + + expect($result) + ->toHaveKey('success', false) + ->toHaveKey('auth_error', true) + ->toHaveKey('http_status', 401); +}); + +it('does update last_tracked_at when DHL says the shipment is not found', function () { + Http::fake([ + 'api-eu.dhl.com/track/shipments*' => Http::response(['shipments' => []], 200), + ]); + + $shipment = makeFakeShipment(123, '00340434292135100148'); + + $shipment->shouldReceive('update') + ->once() + ->withArgs(function (array $payload) { + return array_keys($payload) === ['last_tracked_at'] + && ! array_key_exists('tracking_status', $payload) + && ! array_key_exists('status', $payload); + }) + ->andReturnTrue(); + + $service = new DhlTrackingService; + $result = $service->updateTracking($shipment, ['auto_retrack' => false]); + + expect($result) + ->toHaveKey('success', false) + ->toHaveKey('auth_error', false); +}); + +it('stops the batch tracker on the first auth error to avoid burning the API quota', function () { + Http::fake([ + 'api-eu.dhl.com/track/shipments*' => Http::response([], 401), + ]); + + $shipments = new Collection([ + makeFakeShipment(1, 'A1'), + makeFakeShipment(2, 'B2'), + makeFakeShipment(3, 'C3'), + ]); + + foreach ($shipments as $shipment) { + $shipment->shouldNotReceive('update'); + } + + $service = new DhlTrackingService; + $stats = $service->updateTrackingBatch($shipments); + + // Since Phase 13 the batch tracker fires one HTTP call per shipment and + // aborts on the *first* auth error. So exactly one shipment is marked + // failed (the one we tried), the other two are skipped entirely. + expect($stats) + ->toHaveKey('updated', 0) + ->toHaveKey('failed', 1); + + expect($stats['results'])->toHaveCount(1); + expect($stats['results'][0]) + ->toHaveKey('success', false) + ->toHaveKey('auth_error', true); + + Http::assertSentCount(1); +}); diff --git a/tests/Unit/Dhl/ReturnsServiceCountryCodeTest.php b/tests/Unit/Dhl/ReturnsServiceCountryCodeTest.php new file mode 100644 index 0000000..345dfc0 --- /dev/null +++ b/tests/Unit/Dhl/ReturnsServiceCountryCodeTest.php @@ -0,0 +1,48 @@ +getMethod($method); + $reflection->setAccessible(true); + + return $reflection->invoke($service, $argument); +} + +it('converts known ISO-2 country codes to DHL ISO-3 via the resolver', function (string $input, string $expected) { + expect(invokeReturnsServiceConvert('convertCountryCode', $input))->toBe($expected); +})->with([ + 'germany' => ['DE', 'DEU'], + 'austria' => ['AT', 'AUT'], + 'switzerland' => ['CH', 'CHE'], + 'spain' => ['ES', 'ESP'], + 'germany lowercase' => ['de', 'DEU'], + 'germany already iso-3' => ['DEU', 'DEU'], +]); + +it('throws on unsupported country codes instead of silently using DEU', function () { + invokeReturnsServiceConvert('convertCountryCode', 'XX'); +})->throws(InvalidArgumentException::class); + +it('normalizes addresses back to ISO-2 via the resolver', function () { + $converted = invokeReturnsServiceConvert('convertAddressFor2LetterCountry', [ + 'name' => 'Test', + 'country' => 'AUT', + ]); + + expect($converted['country'])->toBe('AT'); +}); + +it('throws when normalizing an address with an unsupported country', function () { + invokeReturnsServiceConvert('convertAddressFor2LetterCountry', ['country' => 'ZZ']); +})->throws(InvalidArgumentException::class); + +it('keeps the address unchanged when the country key is missing', function () { + $converted = invokeReturnsServiceConvert('convertAddressFor2LetterCountry', ['name' => 'Test']); + + expect($converted)->toBe(['name' => 'Test']); +}); diff --git a/tests/Unit/Dhl/ShippingServiceParseAddressTest.php b/tests/Unit/Dhl/ShippingServiceParseAddressTest.php new file mode 100644 index 0000000..9a664ed --- /dev/null +++ b/tests/Unit/Dhl/ShippingServiceParseAddressTest.php @@ -0,0 +1,50 @@ +getMethod('parseAddressFields'); + $reflection->setAccessible(true); + + return $reflection->invoke($service, $address); +} + +it('keeps an explicit house number unchanged', function () { + $address = invokeParseAddressFields([ + 'street' => 'Musterstrasse', + 'houseNumber' => '42a', + ]); + + expect($address['street'])->toBe('Musterstrasse') + ->and($address['houseNumber'])->toBe('42a'); +}); + +it('extracts the house number from a combined street field', function () { + $address = invokeParseAddressFields([ + 'street' => 'Musterstrasse 42a', + 'houseNumber' => '', + ]); + + expect($address['street'])->toBe('Musterstrasse') + ->and($address['houseNumber'])->toBe('42a'); +}); + +it('throws when the street contains no parseable house number instead of defaulting to 1', function () { + invokeParseAddressFields([ + 'street' => 'Postfach', + 'houseNumber' => '', + ]); +})->throws(InvalidArgumentException::class, 'Hausnummer fehlt'); + +it('does not throw when neither street nor house number are provided', function () { + $address = invokeParseAddressFields([ + 'street' => '', + 'houseNumber' => '', + ]); + + expect($address)->toBe(['street' => '', 'houseNumber' => '']); +});