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

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