diff --git a/app/Http/Controllers/DhlShipmentController.php b/app/Http/Controllers/DhlShipmentController.php index 4d37c84b..a6cb79f4 100644 --- a/app/Http/Controllers/DhlShipmentController.php +++ b/app/Http/Controllers/DhlShipmentController.php @@ -301,7 +301,9 @@ class DhlShipmentController extends Controller 'shipping_zipcode' => 'required|string|max:10', 'shipping_city' => 'required|string|max:50', 'shipping_country_id' => 'required|exists:countries,id', - 'shipping_phone' => 'nullable|string|max:20', + // Telefonnummer wird im ShippingService auf die DHL-Grenzen normalisiert + // bzw. verworfen; sie ist optional und darf den Versand nicht blockieren. + 'shipping_phone' => 'nullable|string|max:50', 'shipping_email' => 'required|email|max:100', 'shipping_postnumber' => 'nullable|string|max:20', ]); diff --git a/app/Http/Controllers/SettingController.php b/app/Http/Controllers/SettingController.php index 0a385abc..ad6872ab 100644 --- a/app/Http/Controllers/SettingController.php +++ b/app/Http/Controllers/SettingController.php @@ -140,7 +140,7 @@ class SettingController extends Controller 'account_numbers' => [ 'V01PAK' => $this->getConfigValue('dhl_account_v01pak', config('dhl.account_numbers.V01PAK'), $useEnvPriority), 'V62KP' => $this->getConfigValue('dhl_account_v62kp', $this->getConfigValue('dhl_account_v62wp', config('dhl.account_numbers.V62KP'), $useEnvPriority), $useEnvPriority), - 'V53PAK' => $this->getConfigValue('dhl_account_v53pak', config('dhl.account_numbers.V53PAK'), $useEnvPriority), + 'V53WPAK' => $this->getConfigValue('dhl_account_v53wpak', $this->getConfigValue('dhl_account_v53pak', config('dhl.account_numbers.V53WPAK'), $useEnvPriority), $useEnvPriority), 'V07PAK' => $this->getConfigValue('dhl_account_v07pak', config('dhl.account_numbers.V07PAK'), $useEnvPriority), 'default' => config('dhl.account_numbers.default'), ], @@ -149,7 +149,7 @@ class SettingController extends Controller 'dimensions' => [ 'V01PAK' => config('dhl.dimensions.V01PAK'), 'V62KP' => config('dhl.dimensions.V62KP'), - 'V53PAK' => config('dhl.dimensions.V53PAK'), + 'V53WPAK' => config('dhl.dimensions.V53WPAK'), 'V07PAK' => config('dhl.dimensions.V07PAK'), 'default' => config('dhl.dimensions.default'), ], @@ -183,6 +183,8 @@ class SettingController extends Controller private function normalizeDhlProductCode(?string $productCode): string { + $productCode = DhlProductResolver::LEGACY_PRODUCT_CODE_ALIASES[$productCode] ?? $productCode; + return $productCode === 'V62WP' ? 'V62KP' : ($productCode ?: 'V01PAK'); } diff --git a/app/Services/DhlDataHelper.php b/app/Services/DhlDataHelper.php index 095ce450..7eed7f7c 100644 --- a/app/Services/DhlDataHelper.php +++ b/app/Services/DhlDataHelper.php @@ -14,6 +14,11 @@ use Illuminate\Support\Facades\Log; */ class DhlDataHelper { + /** DHL Parcel DE Shipping API v2: refNo muss 8-35 Zeichen lang sein. */ + public const REFERENCE_MIN_LENGTH = 8; + + public const REFERENCE_MAX_LENGTH = 35; + /** * Prepare order data for DHL API v2 * @@ -106,14 +111,19 @@ class DhlDataHelper ]; } + /** + * DHL verlangt fuer refNo 8-35 Zeichen. Eine leere oder zu kurze Eingabe + * wird durch die Bestellreferenz ersetzt, damit die Sendung nicht an einer + * manuell erfassten Kurzreferenz scheitert. + */ private static function normalizeReference(?string $reference, ShoppingOrder $order): string { - $reference = trim((string) $reference); + $reference = trim(preg_replace('/\s+/', ' ', (string) $reference)); - if ($reference === '') { + if (mb_strlen($reference) < self::REFERENCE_MIN_LENGTH) { return 'Order-'.$order->id; } - return mb_substr(preg_replace('/\s+/', ' ', $reference), 0, 35); + return mb_substr($reference, 0, self::REFERENCE_MAX_LENGTH); } } diff --git a/app/Services/DhlModalService.php b/app/Services/DhlModalService.php index f21d96ea..098cac68 100644 --- a/app/Services/DhlModalService.php +++ b/app/Services/DhlModalService.php @@ -319,8 +319,8 @@ class DhlModalService $productCodes['V01PAK'] = 'DHL Paket National'; } - if (! empty($accountNumbers['V53PAK'])) { - $productCodes['V53PAK'] = 'DHL Paket International'; + if (! empty($accountNumbers['V53WPAK'])) { + $productCodes['V53WPAK'] = 'DHL Paket International'; } if (! empty($accountNumbers['V62KP'])) { @@ -335,7 +335,7 @@ class DhlModalService if (empty($productCodes)) { $productCodes = [ 'V01PAK' => 'DHL Paket National', - 'V53PAK' => 'DHL Paket International', + 'V53WPAK' => 'DHL Paket International', 'V62KP' => 'DHL Kleinpaket', ]; } diff --git a/app/Services/DhlProductResolver.php b/app/Services/DhlProductResolver.php index b083f601..6ff31031 100644 --- a/app/Services/DhlProductResolver.php +++ b/app/Services/DhlProductResolver.php @@ -12,9 +12,30 @@ class DhlProductResolver public const DOMESTIC_PRODUCT_CODES = ['V01PAK', 'V62KP']; - public const INTERNATIONAL_PRODUCT_CODE = 'V53PAK'; + public const INTERNATIONAL_PRODUCT_CODE = 'V53WPAK'; - public const DEFAULT_INTERNATIONAL_COUNTRIES = ['AT', 'ES', 'CH', 'NL', 'BE', 'FR']; + /** + * Historisch verwendete Produktcodes, die die DHL Parcel DE Shipping API v2 + * nicht kennt. Werden auf den gueltigen Code abgebildet, damit Alt-Settings + * und gespeicherte Sendungen weiter funktionieren. + * + * @var array + */ + public const LEGACY_PRODUCT_CODE_ALIASES = [ + 'V53PAK' => 'V53WPAK', + ]; + + public const DEFAULT_INTERNATIONAL_COUNTRIES = ['AT', 'ES', 'NL', 'BE', 'FR']; + + /** + * Zielländer ausserhalb der EU-Zollunion. DHL verlangt fuer sie einen + * customs-Block (ExportDocument/CN23), den wir noch nicht aufbauen - + * ohne den wird die Sendung von DHL abgelehnt. Sie bleiben deshalb + * gesperrt, auch wenn sie im Setting dhl_international_countries stehen. + * + * @var string[] + */ + public const CUSTOMS_REQUIRED_COUNTRIES = ['CH', 'GB', 'US', 'NO']; public const DHL_COUNTRY_CODES = [ 'DE' => 'DEU', @@ -65,6 +86,10 @@ class DhlProductResolver return $productCode; } + if (in_array($countryCode, self::CUSTOMS_REQUIRED_COUNTRIES, true)) { + throw new InvalidArgumentException("DHL-Versand in das Zielland {$countryCode} benoetigt Zolldaten (ExportDocument/CN23), die das Modul noch nicht erzeugt. Sendung bitte manuell ueber das DHL-Geschaeftskundenportal anlegen."); + } + if (! in_array($countryCode, $this->getSupportedInternationalCountries(), true)) { throw new InvalidArgumentException("DHL-Versand in das Zielland {$countryCode} ist aktuell nicht freigegeben."); } @@ -123,6 +148,24 @@ class DhlProductResolver return $billingNumber; } + /** + * Liefert den gueltigen Produktcode zusammen mit seinen historischen Schreibweisen, + * damit Aufrufer Settings-Slugs und Config-Keys aus beiden Generationen aufloesen koennen. + * + * @return string[] + */ + public function getProductCodeCandidates(string $productCode): array + { + $productCode = $this->normalizeProductCode($productCode) ?? ''; + + $legacyCodes = array_keys(array_filter( + self::LEGACY_PRODUCT_CODE_ALIASES, + static fn (string $target): bool => $target === $productCode + )); + + return array_values(array_unique(array_merge([$productCode], $legacyCodes))); + } + public function getProductScope(string $productCode): string { $productCode = $this->normalizeProductCode($productCode); @@ -162,7 +205,9 @@ class DhlProductResolver return null; } - return strtoupper(trim($productCode)); + $productCode = strtoupper(trim($productCode)); + + return self::LEGACY_PRODUCT_CODE_ALIASES[$productCode] ?? $productCode; } /** @@ -183,7 +228,12 @@ class DhlProductResolver $countries = $storedCountries ?: $configCountries; } - return self::normalizeCountryCodeList(is_array($countries) ? $countries : []); + // Zolldaten-pflichtige Laender werden hier hart herausgefiltert, damit ein + // gesetztes Setting sie nicht doch in den Versand laesst. + return array_values(array_diff( + self::normalizeCountryCodeList(is_array($countries) ? $countries : []), + self::CUSTOMS_REQUIRED_COUNTRIES + )); } /** diff --git a/app/Services/DhlShipmentWeightCalculator.php b/app/Services/DhlShipmentWeightCalculator.php index 159b8216..79b9cbf5 100644 --- a/app/Services/DhlShipmentWeightCalculator.php +++ b/app/Services/DhlShipmentWeightCalculator.php @@ -13,7 +13,7 @@ class DhlShipmentWeightCalculator public const PRODUCT_MAX_WEIGHTS_KG = [ 'V01PAK' => 31.5, - 'V53PAK' => 31.5, + 'V53WPAK' => 31.5, 'V62KP' => 1.0, ]; @@ -34,7 +34,7 @@ class DhlShipmentWeightCalculator public function getMaxWeightKgForProduct(?string $productCode): float { - $productCode = strtoupper(trim((string) $productCode)); + $productCode = (new DhlProductResolver)->normalizeProductCode($productCode) ?? ''; return self::PRODUCT_MAX_WEIGHTS_KG[$productCode] ?? self::DEFAULT_MAX_WEIGHT_KG; } diff --git a/app/Services/DhlTrackingService.php b/app/Services/DhlTrackingService.php index 2965da12..c989ec99 100644 --- a/app/Services/DhlTrackingService.php +++ b/app/Services/DhlTrackingService.php @@ -58,6 +58,14 @@ class DhlTrackingService */ private const DEFAULT_QUOTA_PAUSE_SECONDS = 3600; + /** + * Versuche insgesamt (inklusive Erstversuch) bei HTTP 5xx der Tracking-API, + * und die Wartezeit dazwischen. Die Verzoegerung waechst je Versuch. + */ + private const SERVER_ERROR_MAX_ATTEMPTS = 3; + + private const SERVER_ERROR_RETRY_DELAY_MS = 300; + /** * Minimum gap between two consecutive DHL tracking calls. * @@ -114,17 +122,7 @@ class DhlTrackingService 'has_api_key' => ! empty($this->apiKey), ]); - $response = Http::withHeaders([ - 'DHL-API-Key' => $this->apiKey, - 'Accept' => 'application/json', - ]) - ->withOptions($this->buildHttpOptions()) - ->get(self::TRACKING_ENDPOINT, [ - 'trackingNumber' => $trackingNumber, - 'requesterCountryCode' => 'DE', - 'originCountryCode' => 'DE', - 'language' => 'de', - ]); + $response = $this->requestTrackingWithRetry($trackingNumber); return $this->processSingleShipmentResponse($trackingNumber, $response); } catch (Exception $e) { @@ -144,6 +142,47 @@ class DhlTrackingService } } + /** + * Die Unified Tracking API liefert sporadisch HTTP 5xx. Das ist eine + * Stoerung auf DHL-Seite und geht beim naechsten Versuch meist durch, + * darum wird kurz gewartet und wiederholt, statt jeden Fehlversuch + * einzeln zu protokollieren. + * + * @return Response + */ + private function requestTrackingWithRetry(string $trackingNumber) + { + $attempt = 0; + + do { + $attempt++; + + $response = Http::withHeaders([ + 'DHL-API-Key' => $this->apiKey, + 'Accept' => 'application/json', + ]) + ->withOptions($this->buildHttpOptions()) + ->get(self::TRACKING_ENDPOINT, [ + 'trackingNumber' => $trackingNumber, + 'requesterCountryCode' => 'DE', + 'originCountryCode' => 'DE', + 'language' => 'de', + ]); + + if ($response->status() < 500 || $attempt >= self::SERVER_ERROR_MAX_ATTEMPTS) { + return $response; + } + + Log::info('[DHL Tracking Service] Unified API antwortete mit Serverfehler - erneuter Versuch', [ + 'tracking_number' => $trackingNumber, + 'status_code' => $response->status(), + 'attempt' => $attempt, + ]); + + usleep(self::SERVER_ERROR_RETRY_DELAY_MS * 1000 * $attempt); + } while (true); + } + /** * Build the standard cURL/Guzzle options used for every DHL tracking call. * diff --git a/config/dhl.php b/config/dhl.php index 74fd13f4..4e949d8a 100644 --- a/config/dhl.php +++ b/config/dhl.php @@ -96,7 +96,7 @@ return [ 'default' => env('DHL_ACCOUNT_NUMBER_DEFAULT', '63144073550101'), 'V01PAK' => env('DHL_ACCOUNT_NUMBER_V01PAK', '63144073550101'), // DHL Paket National 'V62KP' => env('DHL_ACCOUNT_NUMBER_V62KP', '63144073556201'), // DHL Kleinpaket - 'V53PAK' => env('DHL_ACCOUNT_NUMBER_V53PAK', '63144073555301'), // DHL Paket International + 'V53WPAK' => env('DHL_ACCOUNT_NUMBER_V53WPAK', env('DHL_ACCOUNT_NUMBER_V53PAK', '63144073555301')), // DHL Paket International 'V07PAK' => env('DHL_ACCOUNT_NUMBER_V07PAK', '63144073550701'), // DHL Retoure Online ], @@ -104,7 +104,7 @@ return [ 'default' => ['length' => 120, 'width' => 60, 'height' => 60], 'V01PAK' => ['length' => 120, 'width' => 60, 'height' => 60], // DHL Paket National 'V62KP' => ['length' => 35, 'width' => 25, 'height' => 8], // DHL Kleinpaket - 'V53PAK' => ['length' => 120, 'width' => 60, 'height' => 60], // DHL Paket International + 'V53WPAK' => ['length' => 120, 'width' => 60, 'height' => 60], // DHL Paket International 'V07PAK' => ['length' => 120, 'width' => 60, 'height' => 60], // DHL Retoure Online ], diff --git a/packages/acme-laravel-dhl/src/Services/ShippingService.php b/packages/acme-laravel-dhl/src/Services/ShippingService.php index 5d0b2f68..9ec37dd1 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\Models\Setting; +use App\Services\DhlDataHelper; use App\Services\DhlProductResolver; use App\Services\DhlShipmentService; use App\Services\DhlShipmentWeightCalculator; @@ -254,10 +255,16 @@ class ShippingService // Pre-process German addresses before validation $data = $this->preprocessAddresses($data); + // Historische Produktcodes auf die gueltige API-Schreibweise heben, + // bevor die in:-Regel greift. + if (! empty($data['product_code'])) { + $data['product_code'] = (new DhlProductResolver)->normalizeProductCode($data['product_code']); + } + $validator = Validator::make($data, [ 'order_id' => 'nullable|integer', 'weight_kg' => 'required|numeric|min:0.1|max:31.5', // DHL weight limit - 'product_code' => 'nullable|string|in:V01PAK,V53PAK,V62KP,V07PAK', + 'product_code' => 'nullable|string|in:V01PAK,V53WPAK,V62KP,V07PAK', 'label_format' => 'nullable|string|in:PDF,ZPL', 'print_format' => 'nullable|string', // Values like A4, 910-300-700 etc. 'retoure_print_format' => 'nullable|string', @@ -387,17 +394,122 @@ class ShippingService { // Process shipper address if (isset($data['shipper'])) { - $data['shipper'] = $this->parseAddressFields($data['shipper']); + $data['shipper'] = $this->normalizeOptionalAddressFields($this->parseAddressFields($data['shipper'])); } // Process consignee address if (isset($data['consignee'])) { - $data['consignee'] = $this->parseAddressFields($data['consignee']); + $data['consignee'] = $this->normalizeOptionalAddressFields($this->parseAddressFields($data['consignee'])); + } + + // refNo muss laut DHL 8-35 Zeichen lang sein. Eine zu kurze manuelle + // Referenz darf die Sendung nicht scheitern lassen. + $data['reference'] = $this->normalizeReference( + $data['reference'] ?? null, + $data['order_id'] ?? null + ); + + if ($data['reference'] === null) { + unset($data['reference']); } return $data; } + /** + * Optionale Felder auf die DHL-Grenzen bringen, statt die Sendung daran scheitern + * zu lassen. Telefonnummer und name2 sind bei DHL optional - eine unsauber erfasste + * Telefonnummer darf keinen Versand blockieren. + * + * @param array $addressData + * @return array + */ + private function normalizeOptionalAddressFields(array $addressData): array + { + if (isset($addressData['phone'])) { + $phone = $this->normalizePhoneNumber((string) $addressData['phone']); + + if ($phone === null) { + unset($addressData['phone']); + } else { + $addressData['phone'] = $phone; + } + } + + foreach (['name', 'name2', 'city'] as $field) { + if (! empty($addressData[$field]) && is_string($addressData[$field])) { + $addressData[$field] = mb_substr(trim($addressData[$field]), 0, 50); + } + } + + return $addressData; + } + + /** + * Entfernt Trenn- und Formatzeichen aus der Telefonnummer. Bleibt danach keine + * plausible Nummer uebrig, wird null zurueckgegeben, damit das optionale Feld + * ganz entfaellt. + */ + private function normalizePhoneNumber(string $phone): ?string + { + $phone = trim($phone); + + if ($phone === '') { + return null; + } + + $isInternational = str_starts_with($phone, '+'); + $digits = preg_replace('/\D/', '', $phone) ?? ''; + + if (strlen($digits) < 6) { + Log::warning('DHL: Telefonnummer wird verworfen (unplausibel)', [ + 'digit_count' => strlen($digits), + ]); + + return null; + } + + $normalized = $isInternational ? '+'.$digits : $digits; + + if (strlen($normalized) > 20) { + Log::warning('DHL: Telefonnummer wird verworfen (zu lang fuer die DHL-API)', [ + 'length' => strlen($normalized), + ]); + + return null; + } + + return $normalized; + } + + /** + * DHL verlangt fuer refNo 8-35 Zeichen. Zu kurze Eingaben werden durch die + * Bestellreferenz ersetzt, zu lange gekuerzt. + */ + private function normalizeReference(?string $reference, mixed $orderId): ?string + { + $reference = trim((string) $reference); + + if (mb_strlen($reference) >= DhlDataHelper::REFERENCE_MIN_LENGTH) { + return mb_substr($reference, 0, DhlDataHelper::REFERENCE_MAX_LENGTH); + } + + if (empty($orderId)) { + return null; + } + + $fallback = 'Order-'.$orderId; + + if ($reference !== '') { + Log::info('DHL: Zu kurze Referenz durch Bestellreferenz ersetzt', [ + 'given_length' => mb_strlen($reference), + 'reference' => $fallback, + ]); + } + + return str_pad($fallback, DhlDataHelper::REFERENCE_MIN_LENGTH, '0'); + } + /** * Parse German address to extract street and house number */ @@ -752,36 +864,46 @@ class ShippingService return $testBillingNumber; } - // Try to get from admin settings via Setting model first (database settings override config) - $settingKey = 'dhl_account_'.strtolower($productCode); + // Historische Schreibweisen mitpruefen, damit vor der Umstellung + // gespeicherte Settings (z. B. dhl_account_v53pak) weiter greifen. + $productCodeCandidates = (new DhlProductResolver)->getProductCodeCandidates($productCode); - try { - $accountNumber = Setting::getContentBySlug($settingKey); - if ($accountNumber) { - Log::info('Using DHL account number from database settings', [ + // Try to get from admin settings via Setting model first (database settings override config) + foreach ($productCodeCandidates as $candidate) { + $settingKey = 'dhl_account_'.strtolower($candidate); + + try { + $accountNumber = Setting::getContentBySlug($settingKey); + if ($accountNumber) { + Log::info('Using DHL account number from database settings', [ + 'product_code' => $productCode, + 'setting_key' => $settingKey, + 'account_number' => $accountNumber, + ]); + + return $accountNumber; + } + } catch (Exception $e) { + Log::warning('Could not load DHL account number from settings', [ 'product_code' => $productCode, + 'setting_key' => $settingKey, + 'error' => $e->getMessage(), + ]); + } + } + + // Try to get account number from config by product code + foreach ($productCodeCandidates as $candidate) { + $accountNumber = config("dhl.account_numbers.{$candidate}"); + if ($accountNumber) { + Log::info('Using DHL account number from config file', [ + 'product_code' => $productCode, + 'config_key' => $candidate, 'account_number' => $accountNumber, ]); return $accountNumber; } - } catch (Exception $e) { - Log::warning('Could not load DHL account number from settings', [ - 'product_code' => $productCode, - 'setting_key' => $settingKey, - 'error' => $e->getMessage(), - ]); - } - - // Try to get account number from config by product code - $accountNumber = config("dhl.account_numbers.{$productCode}"); - if ($accountNumber) { - Log::info('Using DHL account number from config file', [ - 'product_code' => $productCode, - 'account_number' => $accountNumber, - ]); - - return $accountNumber; } // Fallback to default billing number diff --git a/tests/Unit/Dhl/DhlProductResolverTest.php b/tests/Unit/Dhl/DhlProductResolverTest.php index 8724c4af..93f12c62 100644 --- a/tests/Unit/Dhl/DhlProductResolverTest.php +++ b/tests/Unit/Dhl/DhlProductResolverTest.php @@ -25,7 +25,7 @@ it('resolves domestic DHL products for Germany', function () { it('suggests international parcel for Austria and Spain', function (string $countryCode) { $resolver = new DhlProductResolver; - expect($resolver->resolveProductCode($countryCode, null, 'V01PAK'))->toBe('V53PAK'); + expect($resolver->resolveProductCode($countryCode, null, 'V01PAK'))->toBe('V53WPAK'); })->with([ 'austria' => 'AT', 'spain' => 'ES', @@ -47,10 +47,10 @@ it('uses configured international destination countries', function () { try { $resolver = new DhlProductResolver; - expect($resolver->resolveProductCode('FR', null, 'V01PAK'))->toBe('V53PAK') + expect($resolver->resolveProductCode('FR', null, 'V01PAK'))->toBe('V53WPAK') ->and($resolver->getProductSuggestionsByCountry())->toMatchArray([ 'DE' => 'V01PAK', - 'FR' => 'V53PAK', + 'FR' => 'V53WPAK', ]); } finally { config([ @@ -133,8 +133,8 @@ it('describes DHL product scope for preflight checks', function () { expect($resolver->getProductScope('V01PAK'))->toBe('national') ->and($resolver->getProductScopeLabel('V01PAK'))->toBe('DHL Paket National') - ->and($resolver->getProductScope('V53PAK'))->toBe('international') - ->and($resolver->getProductScopeLabel('V53PAK'))->toBe('DHL Paket International'); + ->and($resolver->getProductScope('V53WPAK'))->toBe('international') + ->and($resolver->getProductScopeLabel('V53WPAK'))->toBe('DHL Paket International'); }); it('rejects domestic products for international shipments when explicitly selected', function () { @@ -146,5 +146,56 @@ it('does not fallback unknown countries to Germany', function () { })->throws(InvalidArgumentException::class, 'DHL-Laendercode XX wird nicht unterstuetzt.'); it('requires a billing number for the resolved product', function () { - (new DhlProductResolver)->assertBillingNumber('V53PAK', null); -})->throws(InvalidArgumentException::class, 'Keine DHL-Abrechnungsnummer fuer Produkt V53PAK konfiguriert.'); + (new DhlProductResolver)->assertBillingNumber('V53WPAK', null); +})->throws(InvalidArgumentException::class, 'Keine DHL-Abrechnungsnummer fuer Produkt V53WPAK konfiguriert.'); + +it('maps legacy product codes onto the codes the DHL API accepts', function () { + $resolver = new DhlProductResolver; + + expect($resolver->normalizeProductCode('V53PAK'))->toBe('V53WPAK') + ->and($resolver->normalizeProductCode('v53pak'))->toBe('V53WPAK') + ->and($resolver->normalizeProductCode('V01PAK'))->toBe('V01PAK') + ->and($resolver->normalizeProductCode(null))->toBeNull(); +}); + +it('accepts the legacy international code without rejecting the shipment', function () { + expect((new DhlProductResolver)->resolveProductCode('AT', 'V53PAK'))->toBe('V53WPAK') + ->and((new DhlProductResolver)->getProductScope('V53PAK'))->toBe('international'); +}); + +it('offers legacy setting slugs as fallback candidates for a product', function () { + $resolver = new DhlProductResolver; + + expect($resolver->getProductCodeCandidates('V53WPAK'))->toBe(['V53WPAK', 'V53PAK']) + ->and($resolver->getProductCodeCandidates('V53PAK'))->toBe(['V53WPAK', 'V53PAK']) + ->and($resolver->getProductCodeCandidates('V01PAK'))->toBe(['V01PAK']); +}); + +it('blocks destinations outside the EU customs union with an actionable message', function () { + (new DhlProductResolver)->resolveProductCode('CH', null, 'V01PAK'); +})->throws( + InvalidArgumentException::class, + 'DHL-Versand in das Zielland CH benoetigt Zolldaten (ExportDocument/CN23), die das Modul noch nicht erzeugt.' +); + +it('keeps customs-required countries out of the supported list even when configured', function () { + $previousConfigSource = config('dhl.config_source'); + $previousCountries = config('dhl.international_countries'); + + config([ + 'dhl.config_source' => 'env', + 'dhl.international_countries' => ['AT', 'CH', 'GB'], + ]); + + try { + $resolver = new DhlProductResolver; + + expect($resolver->getSupportedInternationalCountries())->toBe(['AT']) + ->and($resolver->getAllowedProductCodesForCountry('CH'))->toBe([]); + } finally { + config([ + 'dhl.config_source' => $previousConfigSource, + 'dhl.international_countries' => $previousCountries, + ]); + } +}); diff --git a/tests/Unit/Dhl/DhlSanitizeLoggingTest.php b/tests/Unit/Dhl/DhlSanitizeLoggingTest.php index 96ea1dca..b1d09f11 100644 --- a/tests/Unit/Dhl/DhlSanitizeLoggingTest.php +++ b/tests/Unit/Dhl/DhlSanitizeLoggingTest.php @@ -17,7 +17,7 @@ it('redacts DHL credentials from configuration logs', function () { 'account_numbers' => [ 'V01PAK' => '63144073550101', 'V62KP' => '63144073556201', - 'V53PAK' => '63144073555301', + 'V53WPAK' => '63144073555301', 'V07PAK' => null, ], ]; @@ -32,7 +32,7 @@ it('redacts DHL credentials from configuration logs', function () { ->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']); + ->and($sanitized['account_numbers_configured'])->toEqualCanonicalizing(['V01PAK', 'V62KP', 'V53WPAK']); expect($serialized) ->not->toContain('super-secret-api-key') diff --git a/tests/Unit/Dhl/DhlTrackingServerErrorRetryTest.php b/tests/Unit/Dhl/DhlTrackingServerErrorRetryTest.php new file mode 100644 index 00000000..2ab08fb2 --- /dev/null +++ b/tests/Unit/Dhl/DhlTrackingServerErrorRetryTest.php @@ -0,0 +1,60 @@ +getProperty('cachedDhlConfig'); + $property->setAccessible(true); + $property->setValue(null, [ + 'api_key' => 'cached-test-api-key-1234', + ]); +}); + +afterEach(function () { + SettingController::flushDhlConfigCache(); + DhlTrackingService::clearQuotaPause(); +}); + +it('retries a transient server error and returns the successful response', function () { + Http::fakeSequence() + ->push(['title' => 'Internal Server Error'], 500) + ->push(['shipments' => [[ + 'id' => '00340434292135100148', + 'status' => ['statusCode' => 'delivered', 'description' => 'Zugestellt'], + ]]], 200); + + $result = (new DhlTrackingService)->trackShipment('00340434292135100148'); + + expect($result)->toHaveKey('success', true) + ->and($result['status'])->toBe('delivered'); + + Http::assertSentCount(2); +}); + +it('gives up after the configured number of attempts and reports the status once', function () { + Http::fake([ + 'api-eu.dhl.com/track/shipments*' => Http::response(['title' => 'Internal Server Error'], 500), + ]); + + $result = (new DhlTrackingService)->trackShipment('00340434292135100148'); + + expect($result)->toHaveKey('success', false) + ->toHaveKey('http_status', 500); + + Http::assertSentCount(3); +}); + +it('does not retry a client error', function () { + Http::fake([ + 'api-eu.dhl.com/track/shipments*' => Http::response(['title' => 'Not Found'], 404), + ]); + + $result = (new DhlTrackingService)->trackShipment('00340434292135100148'); + + expect($result)->toHaveKey('not_found', true); + + Http::assertSentCount(1); +}); diff --git a/tests/Unit/Dhl/ShippingServiceFieldNormalizationTest.php b/tests/Unit/Dhl/ShippingServiceFieldNormalizationTest.php new file mode 100644 index 00000000..1af11d0f --- /dev/null +++ b/tests/Unit/Dhl/ShippingServiceFieldNormalizationTest.php @@ -0,0 +1,104 @@ + 59996, + 'weight_kg' => 0.5, + 'product_code' => 'V01PAK', + 'label_format' => 'PDF', + 'shipper' => [ + 'name' => 'mivita care gmbh', + 'street' => 'Leinfeld', + 'houseNumber' => '2', + 'postalCode' => '87755', + 'city' => 'Kirchhaslach', + 'country' => 'DE', + ], + 'consignee' => [ + 'name' => 'Max Mustermann', + 'street' => 'Hauptstrasse', + 'houseNumber' => '5', + 'postalCode' => '10115', + 'city' => 'Berlin', + 'country' => 'DE', + ], + 'reference' => 'Order-59996', + ]; + + foreach ($overrides as $key => $value) { + if ($key === 'consignee') { + $data['consignee'] = array_merge($data['consignee'], $value); + + continue; + } + + $data[$key] = $value; + } + + return $data; +} + +function validateDhlOrderData(array $data): array +{ + $service = makeDhlNormalizationService(); + $method = new ReflectionMethod($service, 'validateOrderData'); + $method->setAccessible(true); + + return $method->invoke($service, $data); +} + +it('normalises a phone number that would otherwise exceed the DHL limit', function () { + $validated = validateDhlOrderData(normalizeDhlOrderData([ + 'consignee' => ['phone' => '+49 (0) 8333 / 94 61 767'], + ])); + + expect($validated['consignee']['phone'])->toBe('+49083339461767') + ->and(strlen($validated['consignee']['phone']))->toBeLessThanOrEqual(20); +}); + +it('drops an implausibly short phone number rather than failing the shipment', function () { + $validated = validateDhlOrderData(normalizeDhlOrderData([ + 'consignee' => ['phone' => '123'], + ])); + + expect($validated['consignee'])->not->toHaveKey('phone'); +}); + +it('drops a phone number that stays too long after normalisation', function () { + $validated = validateDhlOrderData(normalizeDhlOrderData([ + 'consignee' => ['phone' => '+49 8333 9461767 9461767 9461767'], + ])); + + expect($validated['consignee'])->not->toHaveKey('phone'); +}); + +it('truncates name2 to the DHL limit instead of rejecting the shipment', function () { + $validated = validateDhlOrderData(normalizeDhlOrderData([ + 'consignee' => ['name2' => str_repeat('Firmenname ', 10)], + ])); + + expect(mb_strlen($validated['consignee']['name2']))->toBe(50); +}); + +it('replaces a reference shorter than the DHL minimum with the order reference', function () { + $validated = validateDhlOrderData(normalizeDhlOrderData(['reference' => 'NL'])); + + expect($validated['reference'])->toBe('Order-59996') + ->and(mb_strlen($validated['reference']))->toBeGreaterThanOrEqual(DhlDataHelper::REFERENCE_MIN_LENGTH); +}); + +it('keeps a reference that already satisfies the DHL length rule', function () { + $validated = validateDhlOrderData(normalizeDhlOrderData(['reference' => 'Kundenreferenz-4711'])); + + expect($validated['reference'])->toBe('Kundenreferenz-4711'); +}); diff --git a/tests/Unit/Dhl/ShippingServiceProductCodeTest.php b/tests/Unit/Dhl/ShippingServiceProductCodeTest.php index c10e7902..81db781e 100644 --- a/tests/Unit/Dhl/ShippingServiceProductCodeTest.php +++ b/tests/Unit/Dhl/ShippingServiceProductCodeTest.php @@ -48,7 +48,7 @@ it('accepts DHL Kleinpaket as a product code', function () { it('builds an international DHL parcel payload for Austria', function () { config([ - 'dhl.account_numbers.V53PAK' => '63144073555301', + 'dhl.account_numbers.V53WPAK' => '63144073555301', 'dhl.legacy.sandbox' => false, 'dhl.legacy.test_mode' => false, ]); @@ -57,9 +57,9 @@ it('builds an international DHL parcel payload for Austria', function () { $method = new ReflectionMethod($service, 'buildShipmentPayload'); $method->setAccessible(true); - $payload = $method->invoke($service, validDhlPhaseOneOrderData('V53PAK', 'AT')); + $payload = $method->invoke($service, validDhlPhaseOneOrderData('V53WPAK', 'AT')); - expect($payload['shipments'][0]['product'])->toBe('V53PAK') + expect($payload['shipments'][0]['product'])->toBe('V53WPAK') ->and($payload['shipments'][0]['billingNumber'])->toBe('63144073555301') ->and($payload['shipments'][0]['consignee']['country'])->toBe('AUT'); }); @@ -69,7 +69,7 @@ it('rejects unsupported countries instead of falling back to Germany', function $method = new ReflectionMethod($service, 'buildShipmentPayload'); $method->setAccessible(true); - $method->invoke($service, validDhlPhaseOneOrderData('V53PAK', 'FR')); + $method->invoke($service, validDhlPhaseOneOrderData('V53WPAK', 'FR')); })->throws(InvalidArgumentException::class); it('rejects legacy Warenpost for new labels', function () { @@ -104,7 +104,7 @@ it('uses mustEncode only for German consignee addresses', function () { $method->setAccessible(true); expect($method->invoke($service, validDhlPhaseOneOrderData('V01PAK', 'DE') + ['print_only_if_codeable' => true]))->toBeTrue() - ->and($method->invoke($service, validDhlPhaseOneOrderData('V53PAK', 'AT') + ['print_only_if_codeable' => true]))->toBeFalse() + ->and($method->invoke($service, validDhlPhaseOneOrderData('V53WPAK', 'AT') + ['print_only_if_codeable' => true]))->toBeFalse() ->and($method->invoke($service, validDhlPhaseOneOrderData('V01PAK', 'DE') + ['print_only_if_codeable' => false]))->toBeFalse(); });