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