27-05-2026 DHL Modul v2.1 / Optimierung tracking
This commit is contained in:
parent
036595be94
commit
2bdc9ada3c
33 changed files with 2367 additions and 2086 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue