DHL: Auslandslabels mit V53WPAK, Feldlaengen und Tracking-Wiederholung

Log-Analyse 26.08. (P0-2, P1-5, P2-3):
- Produktcode fuer DHL Paket International ist V53WPAK; mit V53PAK wurde
  kein einziges Auslandslabel erzeugt. Alte Codes werden gemappt
  (LEGACY_PRODUCT_CODE_ALIASES), Abrechnungsnummer und Masse suchen auch
  unter den alten Setting-Schluesseln (dhl_account_v53pak) und Env-Namen.
- Zu lange Felder (Name, Stadt, Telefon, Referenz) werden auf die
  DHL-Grenzen gekuerzt bzw. bereinigt statt den Versand abzulehnen; refNo
  mindestens 8 Zeichen, sonst die Bestellreferenz.
- Tracking wiederholt bei HTTP 5xx der Unified-API bis zu dreimal.

Zolldaten fuer die Schweiz (P0-3) sind nicht Teil dieses Commits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-09-23 15:41:35 +00:00
parent 323024f65d
commit 8f85c0107c
14 changed files with 508 additions and 68 deletions

View file

@ -301,7 +301,9 @@ class DhlShipmentController extends Controller
'shipping_zipcode' => 'required|string|max:10', 'shipping_zipcode' => 'required|string|max:10',
'shipping_city' => 'required|string|max:50', 'shipping_city' => 'required|string|max:50',
'shipping_country_id' => 'required|exists:countries,id', '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_email' => 'required|email|max:100',
'shipping_postnumber' => 'nullable|string|max:20', 'shipping_postnumber' => 'nullable|string|max:20',
]); ]);

View file

@ -140,7 +140,7 @@ class SettingController extends Controller
'account_numbers' => [ 'account_numbers' => [
'V01PAK' => $this->getConfigValue('dhl_account_v01pak', config('dhl.account_numbers.V01PAK'), $useEnvPriority), '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), '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), 'V07PAK' => $this->getConfigValue('dhl_account_v07pak', config('dhl.account_numbers.V07PAK'), $useEnvPriority),
'default' => config('dhl.account_numbers.default'), 'default' => config('dhl.account_numbers.default'),
], ],
@ -149,7 +149,7 @@ class SettingController extends Controller
'dimensions' => [ 'dimensions' => [
'V01PAK' => config('dhl.dimensions.V01PAK'), 'V01PAK' => config('dhl.dimensions.V01PAK'),
'V62KP' => config('dhl.dimensions.V62KP'), 'V62KP' => config('dhl.dimensions.V62KP'),
'V53PAK' => config('dhl.dimensions.V53PAK'), 'V53WPAK' => config('dhl.dimensions.V53WPAK'),
'V07PAK' => config('dhl.dimensions.V07PAK'), 'V07PAK' => config('dhl.dimensions.V07PAK'),
'default' => config('dhl.dimensions.default'), 'default' => config('dhl.dimensions.default'),
], ],
@ -183,6 +183,8 @@ class SettingController extends Controller
private function normalizeDhlProductCode(?string $productCode): string private function normalizeDhlProductCode(?string $productCode): string
{ {
$productCode = DhlProductResolver::LEGACY_PRODUCT_CODE_ALIASES[$productCode] ?? $productCode;
return $productCode === 'V62WP' ? 'V62KP' : ($productCode ?: 'V01PAK'); return $productCode === 'V62WP' ? 'V62KP' : ($productCode ?: 'V01PAK');
} }

View file

@ -14,6 +14,11 @@ use Illuminate\Support\Facades\Log;
*/ */
class DhlDataHelper 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 * 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 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 'Order-'.$order->id;
} }
return mb_substr(preg_replace('/\s+/', ' ', $reference), 0, 35); return mb_substr($reference, 0, self::REFERENCE_MAX_LENGTH);
} }
} }

View file

@ -319,8 +319,8 @@ class DhlModalService
$productCodes['V01PAK'] = 'DHL Paket National'; $productCodes['V01PAK'] = 'DHL Paket National';
} }
if (! empty($accountNumbers['V53PAK'])) { if (! empty($accountNumbers['V53WPAK'])) {
$productCodes['V53PAK'] = 'DHL Paket International'; $productCodes['V53WPAK'] = 'DHL Paket International';
} }
if (! empty($accountNumbers['V62KP'])) { if (! empty($accountNumbers['V62KP'])) {
@ -335,7 +335,7 @@ class DhlModalService
if (empty($productCodes)) { if (empty($productCodes)) {
$productCodes = [ $productCodes = [
'V01PAK' => 'DHL Paket National', 'V01PAK' => 'DHL Paket National',
'V53PAK' => 'DHL Paket International', 'V53WPAK' => 'DHL Paket International',
'V62KP' => 'DHL Kleinpaket', 'V62KP' => 'DHL Kleinpaket',
]; ];
} }

View file

@ -12,9 +12,30 @@ class DhlProductResolver
public const DOMESTIC_PRODUCT_CODES = ['V01PAK', 'V62KP']; 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<string, string>
*/
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 = [ public const DHL_COUNTRY_CODES = [
'DE' => 'DEU', 'DE' => 'DEU',
@ -65,6 +86,10 @@ class DhlProductResolver
return $productCode; 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)) { if (! in_array($countryCode, $this->getSupportedInternationalCountries(), true)) {
throw new InvalidArgumentException("DHL-Versand in das Zielland {$countryCode} ist aktuell nicht freigegeben."); throw new InvalidArgumentException("DHL-Versand in das Zielland {$countryCode} ist aktuell nicht freigegeben.");
} }
@ -123,6 +148,24 @@ class DhlProductResolver
return $billingNumber; 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 public function getProductScope(string $productCode): string
{ {
$productCode = $this->normalizeProductCode($productCode); $productCode = $this->normalizeProductCode($productCode);
@ -162,7 +205,9 @@ class DhlProductResolver
return null; 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; $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
));
} }
/** /**

View file

@ -13,7 +13,7 @@ class DhlShipmentWeightCalculator
public const PRODUCT_MAX_WEIGHTS_KG = [ public const PRODUCT_MAX_WEIGHTS_KG = [
'V01PAK' => 31.5, 'V01PAK' => 31.5,
'V53PAK' => 31.5, 'V53WPAK' => 31.5,
'V62KP' => 1.0, 'V62KP' => 1.0,
]; ];
@ -34,7 +34,7 @@ class DhlShipmentWeightCalculator
public function getMaxWeightKgForProduct(?string $productCode): float 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; return self::PRODUCT_MAX_WEIGHTS_KG[$productCode] ?? self::DEFAULT_MAX_WEIGHT_KG;
} }

View file

@ -58,6 +58,14 @@ class DhlTrackingService
*/ */
private const DEFAULT_QUOTA_PAUSE_SECONDS = 3600; 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. * Minimum gap between two consecutive DHL tracking calls.
* *
@ -114,17 +122,7 @@ class DhlTrackingService
'has_api_key' => ! empty($this->apiKey), 'has_api_key' => ! empty($this->apiKey),
]); ]);
$response = Http::withHeaders([ $response = $this->requestTrackingWithRetry($trackingNumber);
'DHL-API-Key' => $this->apiKey,
'Accept' => 'application/json',
])
->withOptions($this->buildHttpOptions())
->get(self::TRACKING_ENDPOINT, [
'trackingNumber' => $trackingNumber,
'requesterCountryCode' => 'DE',
'originCountryCode' => 'DE',
'language' => 'de',
]);
return $this->processSingleShipmentResponse($trackingNumber, $response); return $this->processSingleShipmentResponse($trackingNumber, $response);
} catch (Exception $e) { } 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. * Build the standard cURL/Guzzle options used for every DHL tracking call.
* *

View file

@ -96,7 +96,7 @@ return [
'default' => env('DHL_ACCOUNT_NUMBER_DEFAULT', '63144073550101'), 'default' => env('DHL_ACCOUNT_NUMBER_DEFAULT', '63144073550101'),
'V01PAK' => env('DHL_ACCOUNT_NUMBER_V01PAK', '63144073550101'), // DHL Paket National 'V01PAK' => env('DHL_ACCOUNT_NUMBER_V01PAK', '63144073550101'), // DHL Paket National
'V62KP' => env('DHL_ACCOUNT_NUMBER_V62KP', '63144073556201'), // DHL Kleinpaket '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 'V07PAK' => env('DHL_ACCOUNT_NUMBER_V07PAK', '63144073550701'), // DHL Retoure Online
], ],
@ -104,7 +104,7 @@ return [
'default' => ['length' => 120, 'width' => 60, 'height' => 60], 'default' => ['length' => 120, 'width' => 60, 'height' => 60],
'V01PAK' => ['length' => 120, 'width' => 60, 'height' => 60], // DHL Paket National 'V01PAK' => ['length' => 120, 'width' => 60, 'height' => 60], // DHL Paket National
'V62KP' => ['length' => 35, 'width' => 25, 'height' => 8], // DHL Kleinpaket '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 'V07PAK' => ['length' => 120, 'width' => 60, 'height' => 60], // DHL Retoure Online
], ],

View file

@ -8,6 +8,7 @@ use Acme\Dhl\Jobs\CreateShipmentJob;
use Acme\Dhl\Models\DhlShipment; use Acme\Dhl\Models\DhlShipment;
use Acme\Dhl\Support\DhlClient; use Acme\Dhl\Support\DhlClient;
use App\Models\Setting; use App\Models\Setting;
use App\Services\DhlDataHelper;
use App\Services\DhlProductResolver; use App\Services\DhlProductResolver;
use App\Services\DhlShipmentService; use App\Services\DhlShipmentService;
use App\Services\DhlShipmentWeightCalculator; use App\Services\DhlShipmentWeightCalculator;
@ -254,10 +255,16 @@ class ShippingService
// Pre-process German addresses before validation // Pre-process German addresses before validation
$data = $this->preprocessAddresses($data); $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, [ $validator = Validator::make($data, [
'order_id' => 'nullable|integer', 'order_id' => 'nullable|integer',
'weight_kg' => 'required|numeric|min:0.1|max:31.5', // DHL weight limit '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', 'label_format' => 'nullable|string|in:PDF,ZPL',
'print_format' => 'nullable|string', // Values like A4, 910-300-700 etc. 'print_format' => 'nullable|string', // Values like A4, 910-300-700 etc.
'retoure_print_format' => 'nullable|string', 'retoure_print_format' => 'nullable|string',
@ -387,17 +394,122 @@ class ShippingService
{ {
// Process shipper address // Process shipper address
if (isset($data['shipper'])) { if (isset($data['shipper'])) {
$data['shipper'] = $this->parseAddressFields($data['shipper']); $data['shipper'] = $this->normalizeOptionalAddressFields($this->parseAddressFields($data['shipper']));
} }
// Process consignee address // Process consignee address
if (isset($data['consignee'])) { 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; 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<string, mixed> $addressData
* @return array<string, mixed>
*/
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 * Parse German address to extract street and house number
*/ */
@ -752,14 +864,20 @@ class ShippingService
return $testBillingNumber; return $testBillingNumber;
} }
// Historische Schreibweisen mitpruefen, damit vor der Umstellung
// gespeicherte Settings (z. B. dhl_account_v53pak) weiter greifen.
$productCodeCandidates = (new DhlProductResolver)->getProductCodeCandidates($productCode);
// Try to get from admin settings via Setting model first (database settings override config) // Try to get from admin settings via Setting model first (database settings override config)
$settingKey = 'dhl_account_'.strtolower($productCode); foreach ($productCodeCandidates as $candidate) {
$settingKey = 'dhl_account_'.strtolower($candidate);
try { try {
$accountNumber = Setting::getContentBySlug($settingKey); $accountNumber = Setting::getContentBySlug($settingKey);
if ($accountNumber) { if ($accountNumber) {
Log::info('Using DHL account number from database settings', [ Log::info('Using DHL account number from database settings', [
'product_code' => $productCode, 'product_code' => $productCode,
'setting_key' => $settingKey,
'account_number' => $accountNumber, 'account_number' => $accountNumber,
]); ]);
@ -772,17 +890,21 @@ class ShippingService
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
} }
}
// Try to get account number from config by product code // Try to get account number from config by product code
$accountNumber = config("dhl.account_numbers.{$productCode}"); foreach ($productCodeCandidates as $candidate) {
$accountNumber = config("dhl.account_numbers.{$candidate}");
if ($accountNumber) { if ($accountNumber) {
Log::info('Using DHL account number from config file', [ Log::info('Using DHL account number from config file', [
'product_code' => $productCode, 'product_code' => $productCode,
'config_key' => $candidate,
'account_number' => $accountNumber, 'account_number' => $accountNumber,
]); ]);
return $accountNumber; return $accountNumber;
} }
}
// Fallback to default billing number // Fallback to default billing number
$defaultBillingNumber = config('dhl.billing_number') ?: config('dhl.account_numbers.default'); $defaultBillingNumber = config('dhl.billing_number') ?: config('dhl.account_numbers.default');

View file

@ -25,7 +25,7 @@ it('resolves domestic DHL products for Germany', function () {
it('suggests international parcel for Austria and Spain', function (string $countryCode) { it('suggests international parcel for Austria and Spain', function (string $countryCode) {
$resolver = new DhlProductResolver; $resolver = new DhlProductResolver;
expect($resolver->resolveProductCode($countryCode, null, 'V01PAK'))->toBe('V53PAK'); expect($resolver->resolveProductCode($countryCode, null, 'V01PAK'))->toBe('V53WPAK');
})->with([ })->with([
'austria' => 'AT', 'austria' => 'AT',
'spain' => 'ES', 'spain' => 'ES',
@ -47,10 +47,10 @@ it('uses configured international destination countries', function () {
try { try {
$resolver = new DhlProductResolver; $resolver = new DhlProductResolver;
expect($resolver->resolveProductCode('FR', null, 'V01PAK'))->toBe('V53PAK') expect($resolver->resolveProductCode('FR', null, 'V01PAK'))->toBe('V53WPAK')
->and($resolver->getProductSuggestionsByCountry())->toMatchArray([ ->and($resolver->getProductSuggestionsByCountry())->toMatchArray([
'DE' => 'V01PAK', 'DE' => 'V01PAK',
'FR' => 'V53PAK', 'FR' => 'V53WPAK',
]); ]);
} finally { } finally {
config([ config([
@ -133,8 +133,8 @@ it('describes DHL product scope for preflight checks', function () {
expect($resolver->getProductScope('V01PAK'))->toBe('national') expect($resolver->getProductScope('V01PAK'))->toBe('national')
->and($resolver->getProductScopeLabel('V01PAK'))->toBe('DHL Paket National') ->and($resolver->getProductScopeLabel('V01PAK'))->toBe('DHL Paket National')
->and($resolver->getProductScope('V53PAK'))->toBe('international') ->and($resolver->getProductScope('V53WPAK'))->toBe('international')
->and($resolver->getProductScopeLabel('V53PAK'))->toBe('DHL Paket International'); ->and($resolver->getProductScopeLabel('V53WPAK'))->toBe('DHL Paket International');
}); });
it('rejects domestic products for international shipments when explicitly selected', function () { 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.'); })->throws(InvalidArgumentException::class, 'DHL-Laendercode XX wird nicht unterstuetzt.');
it('requires a billing number for the resolved product', function () { it('requires a billing number for the resolved product', function () {
(new DhlProductResolver)->assertBillingNumber('V53PAK', null); (new DhlProductResolver)->assertBillingNumber('V53WPAK', null);
})->throws(InvalidArgumentException::class, 'Keine DHL-Abrechnungsnummer fuer Produkt V53PAK konfiguriert.'); })->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,
]);
}
});

View file

@ -17,7 +17,7 @@ it('redacts DHL credentials from configuration logs', function () {
'account_numbers' => [ 'account_numbers' => [
'V01PAK' => '63144073550101', 'V01PAK' => '63144073550101',
'V62KP' => '63144073556201', 'V62KP' => '63144073556201',
'V53PAK' => '63144073555301', 'V53WPAK' => '63144073555301',
'V07PAK' => null, 'V07PAK' => null,
], ],
]; ];
@ -32,7 +32,7 @@ it('redacts DHL credentials from configuration logs', function () {
->toHaveKey('has_api_secret', true) ->toHaveKey('has_api_secret', true)
->toHaveKey('base_url', 'https://api-eu.dhl.com') ->toHaveKey('base_url', 'https://api-eu.dhl.com')
->toHaveKey('international_countries', ['AT', 'ES']) ->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) expect($serialized)
->not->toContain('super-secret-api-key') ->not->toContain('super-secret-api-key')

View file

@ -0,0 +1,60 @@
<?php
use App\Http\Controllers\SettingController;
use App\Services\DhlTrackingService;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
$reflection = new ReflectionClass(SettingController::class);
$property = $reflection->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);
});

View file

@ -0,0 +1,104 @@
<?php
use Acme\Dhl\Services\ShippingService;
use Acme\Dhl\Support\DhlClient;
use App\Services\DhlDataHelper;
function makeDhlNormalizationService(): ShippingService
{
return new ShippingService(new DhlClient('https://api-sandbox.dhl.com', null, null, null));
}
function normalizeDhlOrderData(array $overrides = []): array
{
$data = [
'order_id' => 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');
});

View file

@ -48,7 +48,7 @@ it('accepts DHL Kleinpaket as a product code', function () {
it('builds an international DHL parcel payload for Austria', function () { it('builds an international DHL parcel payload for Austria', function () {
config([ config([
'dhl.account_numbers.V53PAK' => '63144073555301', 'dhl.account_numbers.V53WPAK' => '63144073555301',
'dhl.legacy.sandbox' => false, 'dhl.legacy.sandbox' => false,
'dhl.legacy.test_mode' => 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 = new ReflectionMethod($service, 'buildShipmentPayload');
$method->setAccessible(true); $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]['billingNumber'])->toBe('63144073555301')
->and($payload['shipments'][0]['consignee']['country'])->toBe('AUT'); ->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 = new ReflectionMethod($service, 'buildShipmentPayload');
$method->setAccessible(true); $method->setAccessible(true);
$method->invoke($service, validDhlPhaseOneOrderData('V53PAK', 'FR')); $method->invoke($service, validDhlPhaseOneOrderData('V53WPAK', 'FR'));
})->throws(InvalidArgumentException::class); })->throws(InvalidArgumentException::class);
it('rejects legacy Warenpost for new labels', function () { it('rejects legacy Warenpost for new labels', function () {
@ -104,7 +104,7 @@ it('uses mustEncode only for German consignee addresses', function () {
$method->setAccessible(true); $method->setAccessible(true);
expect($method->invoke($service, validDhlPhaseOneOrderData('V01PAK', 'DE') + ['print_only_if_codeable' => true]))->toBeTrue() 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(); ->and($method->invoke($service, validDhlPhaseOneOrderData('V01PAK', 'DE') + ['print_only_if_codeable' => false]))->toBeFalse();
}); });