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:
parent
323024f65d
commit
8f85c0107c
14 changed files with 508 additions and 68 deletions
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
60
tests/Unit/Dhl/DhlTrackingServerErrorRetryTest.php
Normal file
60
tests/Unit/Dhl/DhlTrackingServerErrorRetryTest.php
Normal 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);
|
||||
});
|
||||
104
tests/Unit/Dhl/ShippingServiceFieldNormalizationTest.php
Normal file
104
tests/Unit/Dhl/ShippingServiceFieldNormalizationTest.php
Normal 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');
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue