27-05-2026 DHL Modul v2.1 / Optimierung tracking

This commit is contained in:
Kevin Adametz 2026-05-27 18:51:23 +02:00
parent 036595be94
commit 2bdc9ada3c
33 changed files with 2367 additions and 2086 deletions

View file

@ -368,20 +368,33 @@ class DhlShipment extends Model
/**
* Tracking interval per status (in hours).
* Determines how often each status should be re-checked via the DHL API.
*
* Determines how often each status should be re-checked via the DHL
* Unified Tracking API. The defaults were tightened in Phase 13 of the
* DHL refactor to fit into the documented Standard service level of
* "250 calls per day, max 1 call every 5 seconds"
* (https://developer.dhl.com/api-reference/shipment-tracking#rate-limits).
*
* Indicative cost for the current production data (266 created /
* 115 in_transit shipments):
* - in_transit 6 h -> 4 calls/day * 115 = ~460 calls/day
* - created 24 h -> 1 call/day * 266 = 266 calls/day
* - out_for_delivery 1 h is kept short on purpose because that status
* only affects very few shipments at a time but matters most for
* customer-facing "kommt heute" emails.
*/
public const TRACKING_INTERVALS = [
'out_for_delivery' => 1,
'in_transit' => 2,
'exception' => 4,
'unknown' => 4,
'created' => 6,
'in_transit' => 6,
'exception' => 8,
'unknown' => 12,
'created' => 24,
];
/**
* Default tracking interval in hours for statuses not explicitly listed
* Default tracking interval in hours for statuses not explicitly listed.
*/
public const DEFAULT_TRACKING_INTERVAL = 4;
public const DEFAULT_TRACKING_INTERVAL = 8;
/**
* Scope for shipments that need a tracking update based on status-dependent intervals.

View file

@ -2,15 +2,15 @@
namespace Acme\Dhl\Services;
use Acme\Dhl\Support\DhlClient;
use Acme\Dhl\Models\DhlShipment;
use Acme\Dhl\Services\ShippingService;
use Illuminate\Support\Facades\Storage;
use InvalidArgumentException;
use Exception;
use Acme\Dhl\Jobs\CreateReturnLabelJob;
use Acme\Dhl\Models\DhlShipment;
use Acme\Dhl\Support\DhlClient;
use App\Services\DhlProductResolver;
use Exception;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use InvalidArgumentException;
/**
* DHL Returns Service for creating and managing return labels
@ -22,8 +22,9 @@ class ReturnsService
/**
* Create a return label for a shipment
*
* @param array $returnData Return shipment data
* @param array $returnData Return shipment data
* @return array Return label details including number and path
*
* @throws InvalidArgumentException When required data is missing
* @throws Exception When API request fails
*/
@ -32,6 +33,7 @@ class ReturnsService
$validatedData = $this->validateReturnData($returnData);
if (config('dhl.use_queue')) {
CreateReturnLabelJob::dispatch($validatedData);
return ['queued' => true];
}
@ -82,14 +84,14 @@ class ReturnsService
$returnNumber = $this->extractReturnNumber($response);
$labelBase64 = $this->extractLabelData($response);
if (!$returnNumber) {
if (! $returnNumber) {
Log::error('[DHL Returns] Failed to extract return number', [
'response' => $response,
]);
throw new Exception('Failed to extract return shipment number from DHL API response');
}
if (!$labelBase64) {
if (! $labelBase64) {
Log::warning('[DHL Returns] No label data in response', [
'return_number' => $returnNumber,
]);
@ -108,7 +110,7 @@ class ReturnsService
'label_path' => $labelPath,
'returnShipment' => $returnShipment,
'raw' => $response,
'method' => 'returns_api'
'method' => 'returns_api',
];
}
@ -129,7 +131,7 @@ class ReturnsService
$consignee = $this->convertAddressFor2LetterCountry($returnData['consignee']);
// Get DHL config for dimensions
$settingController = new \App\Http\Controllers\SettingController();
$settingController = new \App\Http\Controllers\SettingController;
$dhlConfig = $settingController->getDhlConfig();
// Prepare data for regular shipment (shipper and consignee are already swapped)
@ -155,7 +157,7 @@ class ReturnsService
'height' => 60,
],
'reference' => 'Return-' . ($returnData['order_id'] ?? time()),
'reference' => 'Return-'.($returnData['order_id'] ?? time()),
];
Log::info('[DHL Returns] Prepared shipment data for fallback', [
@ -195,14 +197,14 @@ class ReturnsService
'label_path' => $result['label_path'] ?? $result['labelPath'] ?? null,
'returnShipment' => $returnShipment,
'raw' => $result,
'method' => 'shipping_api_fallback'
'method' => 'shipping_api_fallback',
];
}
/**
* Get return shipment by return number
*
* @param string $returnNumber DHL return number
* @param string $returnNumber DHL return number
* @return DhlShipment|null Return shipment model or null if not found
*/
public function getReturnShipment(string $returnNumber): ?DhlShipment
@ -215,7 +217,7 @@ class ReturnsService
/**
* Get all return shipments for an order
*
* @param int $orderId Order ID
* @param int $orderId Order ID
* @return \Illuminate\Database\Eloquent\Collection Return shipments collection
*/
public function getOrderReturns(int $orderId): \Illuminate\Database\Eloquent\Collection
@ -228,7 +230,7 @@ class ReturnsService
/**
* Get returns for a specific outbound shipment
*
* @param int $shipmentId Original outbound shipment ID
* @param int $shipmentId Original outbound shipment ID
* @return \Illuminate\Database\Eloquent\Collection Related return shipments
*/
public function getShipmentReturns(int $shipmentId): \Illuminate\Database\Eloquent\Collection
@ -310,7 +312,7 @@ class ReturnsService
});
// Get billing number from config
$settingController = new \App\Http\Controllers\SettingController();
$settingController = new \App\Http\Controllers\SettingController;
$dhlConfig = $settingController->getDhlConfig();
$billingNumber = $dhlConfig['billing_number'] ?? config('dhl.billing_number');
@ -320,8 +322,8 @@ class ReturnsService
return [
'receiverId' => 'DEDE',
'customerReference' => 'Return-' . ($returnData['order_id'] ?? time()),
'shipmentReference' => 'Return-Order-' . ($returnData['order_id'] ?? time()),
'customerReference' => 'Return-'.($returnData['order_id'] ?? time()),
'shipmentReference' => 'Return-Order-'.($returnData['order_id'] ?? time()),
'billingNumber' => $billingNumber,
'shipper' => $shipper,
'receiver' => $consignee,
@ -355,11 +357,11 @@ class ReturnsService
*/
private function saveLabelFile(?string $returnNumber, ?string $labelBase64, string $format): ?string
{
if (!$labelBase64 || !$returnNumber) {
if (! $labelBase64 || ! $returnNumber) {
return null;
}
$path = 'dhl/returns/' . $returnNumber . '.' . strtolower($format);
$path = 'dhl/returns/'.$returnNumber.'.'.strtolower($format);
Storage::disk('local')->put($path, base64_decode($labelBase64));
return $path;
@ -386,89 +388,59 @@ class ReturnsService
'company' => $shipper['name2'] ?? '',
'email' => $shipper['email'] ?? '',
'recipient' => $returnData,
'api_response_data' => $response
'api_response_data' => $response,
]);
}
/**
* Convert 2-letter country code to 3-letter country code for DHL API
*
* @param string $countryCode 2-letter or 3-letter ISO country code (e.g., "DE" or "DEU")
* @return string 3-letter ISO country code (e.g., "DEU")
* Convert 2-letter or 3-letter country code to DHL-compatible 3-letter code.
*
* Delegates to {@see DhlProductResolver::toDhlCountryCode()} so that the
* resolver remains the single source of truth for supported countries.
* Unknown country codes throw an {@see InvalidArgumentException} so that
* a misconfigured return cannot silently be routed to Germany.
*/
private function convertCountryCode(string $countryCode): string
{
$code = strtoupper(trim($countryCode));
$resolver = new DhlProductResolver;
// If already 3 letters, check if valid and return
if (strlen($code) === 3) {
$validThreeLetterCodes = ['DEU', 'AUT', 'CHE', 'FRA', 'ITA', 'ESP', 'NLD', 'BEL', 'LUX', 'POL', 'CZE', 'DNK', 'SWE', 'NOR', 'GBR', 'USA'];
return in_array($code, $validThreeLetterCodes) ? $code : 'DEU';
try {
return $resolver->toDhlCountryCode($countryCode);
} catch (\InvalidArgumentException $e) {
throw new InvalidArgumentException(
'DHL Retoure: '.$e->getMessage(),
$e->getCode(),
$e
);
}
// Convert 2-letter to 3-letter
$countryMap = [
'DE' => 'DEU',
'AT' => 'AUT',
'CH' => 'CHE',
'FR' => 'FRA',
'IT' => 'ITA',
'ES' => 'ESP',
'NL' => 'NLD',
'BE' => 'BEL',
'LU' => 'LUX',
'PL' => 'POL',
'CZ' => 'CZE',
'DK' => 'DNK',
'SE' => 'SWE',
'NO' => 'NOR',
'GB' => 'GBR',
'US' => 'USA',
];
return $countryMap[$code] ?? 'DEU';
}
/**
* Convert address with 3-letter country code back to 2-letter for ShippingService
*
* @param array $address Address with 3-letter country code
* @return array Address with 2-letter country code
* Convert address with 3-letter country code back to ISO-2 for ShippingService.
*
* Uses {@see DhlProductResolver::normalizeCountryCode()} which accepts both
* 2- and 3-letter codes and rejects unsupported countries. The previous
* implementation silently fell back to `DE` which contradicted the
* resolver-based concept introduced in phase 3.
*/
private function convertAddressFor2LetterCountry(array $address): array
{
$converted = $address;
// Convert 3-letter to 2-letter country code
if (isset($address['country'])) {
$reverseMap = [
'DEU' => 'DE',
'AUT' => 'AT',
'CHE' => 'CH',
'FRA' => 'FR',
'ITA' => 'IT',
'ESP' => 'ES',
'NLD' => 'NL',
'BEL' => 'BE',
'LUX' => 'LU',
'POL' => 'PL',
'CZE' => 'CZ',
'DNK' => 'DK',
'SWE' => 'SE',
'NOR' => 'NO',
'GBR' => 'GB',
'USA' => 'US',
];
if (! isset($address['country']) || $address['country'] === '') {
return $converted;
}
$code = strtoupper($address['country']);
$resolver = new DhlProductResolver;
// If it's 3 letters, convert to 2
if (strlen($code) === 3) {
$converted['country'] = $reverseMap[$code] ?? 'DE';
} else {
// Already 2 letters, keep as is
$converted['country'] = $code;
}
try {
$converted['country'] = $resolver->normalizeCountryCode((string) $address['country']);
} catch (\InvalidArgumentException $e) {
throw new InvalidArgumentException(
'DHL Retoure: '.$e->getMessage(),
$e->getCode(),
$e
);
}
return $converted;

View file

@ -8,6 +8,7 @@ use Acme\Dhl\Jobs\CreateShipmentJob;
use Acme\Dhl\Models\DhlShipment;
use Acme\Dhl\Support\DhlClient;
use App\Services\DhlProductResolver;
use App\Services\DhlShipmentService;
use App\Services\DhlShipmentWeightCalculator;
use Exception;
use Illuminate\Support\Facades\DB;
@ -34,7 +35,7 @@ class ShippingService
*/
public function createLabel(array $orderData): array
{
Log::info('createLabel', $orderData);
Log::info('[DHL Shipping] createLabel called', DhlShipmentService::sanitizeOrderDataForLog($orderData));
$validatedData = $this->validateOrderData($orderData);
if (config('dhl.use_queue')) {
CreateShipmentJob::dispatch($validatedData);
@ -45,12 +46,7 @@ class ShippingService
return DB::transaction(function () use ($validatedData) {
$payload = $this->buildShipmentPayload($validatedData);
// Debug logging: Log the exact payload being sent to DHL API
Log::info('[DHL API] Sending payload to DHL', [
'endpoint' => '/parcel/de/shipping/v2/orders',
'payload' => $payload,
'payload_json' => json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
]);
Log::info('[DHL API] Sending payload to DHL', $this->buildPayloadLogContext($payload, $validatedData));
try {
// Build query parameters for print format
@ -62,9 +58,7 @@ class ShippingService
$response = $this->client->request('post', '/parcel/de/shipping/v2/orders', $payload, $query);
Log::info('[DHL API] Response received', [
'response' => $response,
]);
Log::info('[DHL API] Response received', $this->buildResponseLogContext($response));
$this->assertSuccessfulShipmentResponse($response, $this->shouldUseMustEncode($validatedData));
} catch (DhlValidationException $e) {
if ($this->shouldUseMustEncode($validatedData) && $this->looksLikeAddressValidationError($e->getMessage())) {
@ -73,10 +67,10 @@ class ShippingService
throw $e;
} catch (Exception $e) {
Log::error('[DHL API] Request failed', [
'error' => $e->getMessage(),
'payload' => $payload,
]);
Log::error('[DHL API] Request failed', array_merge(
$this->buildPayloadLogContext($payload, $validatedData),
['error' => $e->getMessage()]
));
throw $e;
}
@ -204,6 +198,53 @@ class ShippingService
return null;
}
/**
* Build a redacted DHL request log context.
*
* Logs only routing-relevant metadata and never the full payload, since
* the payload contains personal address data and billing numbers.
*
* @param array<string, mixed> $payload
* @param array<string, mixed> $validatedData
* @return array<string, mixed>
*/
private function buildPayloadLogContext(array $payload, array $validatedData): array
{
$billingNumber = data_get($payload, 'shipments.0.billingNumber');
return [
'endpoint' => '/parcel/de/shipping/v2/orders',
'order_id' => $validatedData['order_id'] ?? null,
'product_code' => data_get($payload, 'shipments.0.product'),
'billing_number_suffix' => is_string($billingNumber) ? mb_substr($billingNumber, -4) : null,
'weight_grams' => data_get($payload, 'shipments.0.details.weight.value'),
'consignee_country' => data_get($payload, 'shipments.0.consignee.country'),
'has_reference' => ! empty(data_get($payload, 'shipments.0.refNo')),
'must_encode' => $this->shouldUseMustEncode($validatedData),
];
}
/**
* Build a redacted DHL response log context.
*
* Drops the base64 label payload, which is large and not useful in logs.
*
* @param array<string, mixed> $response
* @return array<string, mixed>
*/
private function buildResponseLogContext(array $response): array
{
return [
'shipment_number' => $this->extractShipmentNumber($response),
'has_label' => $this->extractLabelData($response) !== null,
'routing_code' => $this->extractRoutingCode($response),
'item_status_code' => data_get($response, 'items.0.sstatus.statusCode')
?? data_get($response, 'status.statusCode'),
'item_status_title' => data_get($response, 'items.0.sstatus.title')
?? data_get($response, 'status.title'),
];
}
/**
* Validate required order data according to DHL API v2 specification
*/
@ -380,20 +421,30 @@ class ShippingService
$addressData['houseNumber'] = $parsed['houseNumber'];
Log::info('Parsed German address', [
'original' => $street,
'parsed_street' => $parsed['street'],
'parsed_houseNumber' => $parsed['houseNumber'],
'original_street_length' => strlen($street),
'parsed_street_length' => strlen($parsed['street']),
'parsed_house_number_length' => strlen($parsed['houseNumber']),
]);
} elseif (! $parsed['houseNumber']) {
// If we can't parse house number, use a default
$addressData['houseNumber'] = '1';
Log::warning('Could not parse house number from address, using default', [
'street' => $street,
]);
return $addressData;
}
return $addressData;
// No house number could be parsed from the street. We must not invent
// one (the previous `'1'` default caused parcels to be delivered to
// the wrong address) and the DHL API rejects shipments without a
// house number anyway. Surface a validation error so the operator can
// fix the address before we ever hit DHL.
Log::warning('Could not parse house number from address', [
'street_length' => strlen($street),
'country' => $addressData['country'] ?? null,
'postal_prefix' => isset($addressData['postalCode']) && is_string($addressData['postalCode'])
? mb_substr($addressData['postalCode'], 0, 2)
: null,
]);
throw new InvalidArgumentException(
'Hausnummer fehlt in der Adresse und konnte nicht automatisch aus dem Strassenfeld ermittelt werden. Bitte Strasse und Hausnummer separat erfassen.'
);
}
/**