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_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',
]);

View file

@ -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');
}

View file

@ -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);
}
}

View file

@ -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',
];
}

View file

@ -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<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 = [
'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
));
}
/**

View file

@ -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;
}

View file

@ -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.
*

View file

@ -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
],

View file

@ -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<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
*/
@ -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

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) {
$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,
]);
}
});

View file

@ -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')

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 () {
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();
});