27-05-2026 Update DHL Modul v2.0

This commit is contained in:
Kevin 2026-05-27 13:40:38 +00:00
parent 53bdba33cd
commit 036595be94
41 changed files with 3346 additions and 310 deletions

View file

@ -2,9 +2,13 @@
namespace Acme\Dhl\Services;
use Acme\Dhl\Exceptions\DhlAddressValidationException;
use Acme\Dhl\Exceptions\DhlValidationException;
use Acme\Dhl\Jobs\CreateShipmentJob;
use Acme\Dhl\Models\DhlShipment;
use Acme\Dhl\Support\DhlClient;
use App\Services\DhlProductResolver;
use App\Services\DhlShipmentWeightCalculator;
use Exception;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@ -53,6 +57,7 @@ class ShippingService
$query = array_filter([
'printFormat' => $validatedData['print_format'] ?? null,
'retourePrintFormat' => $validatedData['retoure_print_format'] ?? null,
'mustEncode' => $this->shouldUseMustEncode($validatedData) ? 'true' : null,
]);
$response = $this->client->request('post', '/parcel/de/shipping/v2/orders', $payload, $query);
@ -60,6 +65,13 @@ class ShippingService
Log::info('[DHL API] Response received', [
'response' => $response,
]);
$this->assertSuccessfulShipmentResponse($response, $this->shouldUseMustEncode($validatedData));
} catch (DhlValidationException $e) {
if ($this->shouldUseMustEncode($validatedData) && $this->looksLikeAddressValidationError($e->getMessage())) {
throw new DhlAddressValidationException($this->normalizeDhlAddressValidationMessage($e->getMessage()), (int) $e->getCode(), $e);
}
throw $e;
} catch (Exception $e) {
Log::error('[DHL API] Request failed', [
'error' => $e->getMessage(),
@ -101,18 +113,18 @@ class ShippingService
$shipment = DhlShipment::where('dhl_shipment_no', $shipmentNumber)->first();
if (! $shipment) {
throw new InvalidArgumentException('Shipment not found in database: ' . $shipmentNumber);
throw new InvalidArgumentException('Shipment not found in database: '.$shipmentNumber);
}
if (! $shipment->canCancel()) {
throw new InvalidArgumentException('Shipment cannot be canceled (current status: ' . $shipment->status . ')');
throw new InvalidArgumentException('Shipment cannot be canceled (current status: '.$shipment->status.')');
}
Log::info('[DHL Package] Attempting to cancel shipment', [
'shipmentNumber' => $shipmentNumber,
'shipment_id' => $shipment->id,
'status' => $shipment->status,
'endpoint' => "/parcel/de/shipping/v2/orders/{$shipmentNumber}"
'endpoint' => "/parcel/de/shipping/v2/orders/{$shipmentNumber}",
]);
try {
@ -120,28 +132,78 @@ class ShippingService
Log::info('[DHL Package] Shipment cancellation response', [
'shipmentNumber' => $shipmentNumber,
'response' => $response
'response' => $response,
]);
$shipment->update(['status' => 'canceled']);
$this->recordCancellationSuccess($shipment, $response);
Log::info('[DHL Package] Canceled shipment successfully', [
'shipmentNumber' => $shipmentNumber,
'shipment_id' => $shipment->id
'shipment_id' => $shipment->id,
]);
return true;
} catch (\Exception $e) {
$this->recordCancellationFailure($shipment, $e);
Log::error('[DHL Package] Shipment cancellation failed', [
'shipmentNumber' => $shipmentNumber,
'shipment_id' => $shipment->id,
'error' => $e->getMessage(),
'error_class' => get_class($e)
'error_class' => get_class($e),
]);
throw $e;
}
}
private function recordCancellationSuccess(DhlShipment $shipment, array $response): void
{
$apiResponseData = $shipment->api_response_data ?? [];
$apiResponseData['cancellation'] = [
'status' => 'success',
'response' => $response,
'occurred_at' => now()->toISOString(),
];
$shipment->update([
'status' => 'canceled',
'api_response_data' => $apiResponseData,
]);
}
private function recordCancellationFailure(DhlShipment $shipment, \Exception $exception): void
{
$apiResponseData = $shipment->api_response_data ?? [];
$apiResponseData['cancellation_error'] = [
'status' => 'failed',
'http_status' => $this->extractHttpStatus($exception->getMessage()),
'dhl_code' => $this->extractDhlErrorCode($exception->getMessage()),
'detail' => $exception->getMessage(),
'exception_class' => $exception::class,
'occurred_at' => now()->toISOString(),
];
$shipment->update(['api_response_data' => $apiResponseData]);
}
private function extractHttpStatus(string $message): ?int
{
if (preg_match('/\b([45][0-9]{2})\b/', $message, $matches)) {
return (int) $matches[1];
}
return null;
}
private function extractDhlErrorCode(string $message): ?string
{
if (preg_match('/\b([A-Z]{2}-[A-Za-z0-9_-]+)\b/', $message, $matches)) {
return $matches[1];
}
return null;
}
/**
* Validate required order data according to DHL API v2 specification
*/
@ -153,10 +215,11 @@ class ShippingService
$validator = Validator::make($data, [
'order_id' => 'nullable|integer',
'weight_kg' => 'required|numeric|min:0.1|max:31.5', // DHL weight limit
'product_code' => 'nullable|string|in:V01PAK,V53PAK,V62WP,V07PAK',
'product_code' => 'nullable|string|in:V01PAK,V53PAK,V62KP,V07PAK',
'label_format' => 'nullable|string|in:PDF,ZPL',
'print_format' => 'nullable|string', // Values like A4, 910-300-700 etc.
'retoure_print_format' => 'nullable|string',
'print_only_if_codeable' => 'nullable|boolean',
// Shipper validation (sender)
'shipper' => 'required|array',
@ -198,7 +261,81 @@ class ShippingService
throw new InvalidArgumentException($validator->errors()->first());
}
return $validator->validated();
$validated = $validator->validated();
(new DhlShipmentWeightCalculator)->assertWithinProductLimit(
(float) $validated['weight_kg'],
$validated['product_code'] ?? null
);
return $validated;
}
private function shouldUseMustEncode(array $orderData): bool
{
return (bool) ($orderData['print_only_if_codeable'] ?? config('dhl.print_only_if_codeable', true))
&& strtoupper((string) ($orderData['consignee']['country'] ?? '')) === DhlProductResolver::DOMESTIC_COUNTRY;
}
private function assertSuccessfulShipmentResponse(array $response, bool $mustEncodeEnabled): void
{
$itemStatusCode = (int) (data_get($response, 'items.0.sstatus.statusCode')
?? data_get($response, 'items.0.sstatus.status')
?? data_get($response, 'status.statusCode')
?? data_get($response, 'status.status')
?? 200);
if ($itemStatusCode < 400 && $this->extractShipmentNumber($response) !== null && $this->extractLabelData($response) !== null) {
return;
}
$message = $this->extractResponseErrorMessage($response) ?: 'DHL hat kein Versandlabel erstellt.';
if ($mustEncodeEnabled || $this->looksLikeAddressValidationError($message)) {
throw new DhlAddressValidationException($this->normalizeDhlAddressValidationMessage($message));
}
throw new DhlValidationException($message);
}
private function extractResponseErrorMessage(array $response): ?string
{
$message = data_get($response, 'items.0.sstatus.detail')
?? data_get($response, 'items.0.sstatus.title')
?? data_get($response, 'status.detail')
?? data_get($response, 'status.title')
?? data_get($response, 'detail')
?? data_get($response, 'message');
$validationMessages = data_get($response, 'items.0.validationMessages', []);
if (is_array($validationMessages) && $validationMessages !== []) {
$messages = [];
foreach ($validationMessages as $validationMessage) {
$messages[] = $validationMessage['validationMessage']
?? $validationMessage['message']
?? $validationMessage['property']
?? null;
}
$messages = array_values(array_filter($messages));
if ($messages !== []) {
$message = implode('; ', $messages);
}
}
return $message ? (string) $message : null;
}
private function looksLikeAddressValidationError(string $message): bool
{
return (bool) preg_match('/address|adresse|anschrift|leitcod|routing|route|codeable|codable|encodable|mustEncode|postal|postleitzahl|street|straße|strasse|house|hausnummer|city|ort/i', $message);
}
private function normalizeDhlAddressValidationMessage(string $message): string
{
$message = trim(preg_replace('/^DHL API validation error:\s*/i', '', $message));
$message = $message !== '' ? $message : 'DHL kann diese Adresse nicht leitcodieren.';
return 'DHL kann diese Adresse nicht leitcodieren. Bitte Straße, Hausnummer, PLZ und Ort prüfen. DHL-Meldung: '.$message;
}
/**
@ -302,8 +439,14 @@ class ShippingService
*/
private function buildShipmentPayload(array $orderData): array
{
$productCode = $orderData['product_code'] ?? config('dhl.default_product', 'V01PAK');
$billingNumber = $this->getBillingNumberForProduct($productCode);
$resolver = new DhlProductResolver;
$destination = $resolver->resolveForShipment(
$orderData['consignee']['country'] ?? '',
$orderData['product_code'] ?? null,
config('dhl.default_product', 'V01PAK')
);
$productCode = $destination['product_code'];
$billingNumber = $resolver->assertBillingNumber($productCode, $this->getBillingNumberForProduct($productCode));
$payload = [
'profile' => config('dhl.profile', 'STANDARD_GRUPPENPROFIL'),
@ -319,7 +462,7 @@ class ShippingService
'addressHouse' => $orderData['shipper']['houseNumber'] ?? null,
'postalCode' => $orderData['shipper']['postalCode'] ?? '',
'city' => $orderData['shipper']['city'] ?? '',
'country' => $this->convertCountryCode($orderData['shipper']['country'] ?? 'DE'),
'country' => $this->convertCountryCode($orderData['shipper']['country'] ?? ''),
'email' => ! empty($orderData['shipper']['email']) ? $orderData['shipper']['email'] : null,
'phone' => ! empty($orderData['shipper']['phone']) ? $orderData['shipper']['phone'] : null,
], function ($value) {
@ -381,7 +524,7 @@ class ShippingService
'addressHouse' => $consignee['houseNumber'] ?? null,
'postalCode' => $consignee['postalCode'] ?? '',
'city' => $consignee['city'] ?? '',
'country' => $this->convertCountryCode($consignee['country'] ?? 'DE'),
'country' => $this->convertCountryCode($consignee['country'] ?? ''),
'email' => ! empty($consignee['email']) ? $consignee['email'] : null,
'phone' => ! empty($consignee['phone']) ? $consignee['phone'] : null,
], function ($value) {
@ -435,12 +578,12 @@ class ShippingService
'houseNumber' => $consignee['houseNumber'] ?? '',
]);
$errorMessage = 'PACKSTATION-FEHLER: Die Packstation-Nummer muss 3-stellig sein (100-999).' . PHP_EOL . PHP_EOL;
$errorMessage .= 'Eingegeben wurde: "' . $lockerNumber . '"' . PHP_EOL . PHP_EOL;
$errorMessage .= 'HINWEISE:' . PHP_EOL;
$errorMessage .= '• Straße/Nr.: "Packstation 145" (nicht "Packstation 12345")' . PHP_EOL;
$errorMessage .= '• Die Packstation-NUMMER ist die 3-stellige Nummer auf dem gelben DHL-Schild' . PHP_EOL;
$errorMessage .= '• Die 5-10-stellige POSTNUMMER ist etwas anderes (kommt ins separate Feld!)' . PHP_EOL;
$errorMessage = 'PACKSTATION-FEHLER: Die Packstation-Nummer muss 3-stellig sein (100-999).'.PHP_EOL.PHP_EOL;
$errorMessage .= 'Eingegeben wurde: "'.$lockerNumber.'"'.PHP_EOL.PHP_EOL;
$errorMessage .= 'HINWEISE:'.PHP_EOL;
$errorMessage .= '• Straße/Nr.: "Packstation 145" (nicht "Packstation 12345")'.PHP_EOL;
$errorMessage .= '• Die Packstation-NUMMER ist die 3-stellige Nummer auf dem gelben DHL-Schild'.PHP_EOL;
$errorMessage .= '• Die 5-10-stellige POSTNUMMER ist etwas anderes (kommt ins separate Feld!)'.PHP_EOL;
$errorMessage .= '• Beispiel: Packstation 145, PLZ 12345, Postnummer 1234567890';
throw new \InvalidArgumentException($errorMessage);
@ -464,7 +607,7 @@ class ShippingService
'postNumber' => $postNumber,
'postalCode' => $consignee['postalCode'] ?? '',
'city' => $consignee['city'] ?? '',
'country' => $this->convertCountryCode($consignee['country'] ?? 'DE'),
'country' => $this->convertCountryCode($consignee['country'] ?? ''),
], function ($value) {
return $value !== null && $value !== '';
});
@ -534,25 +677,7 @@ class ShippingService
*/
private function convertCountryCode(string $countryCode): string
{
$countryMap = [
'DE' => 'DEU',
'AT' => 'AUT',
'CH' => 'CHE',
'US' => 'USA',
'GB' => 'GBR',
'FR' => 'FRA',
'IT' => 'ITA',
'ES' => 'ESP',
'NL' => 'NLD',
'BE' => 'BEL',
'PL' => 'POL',
'CZ' => 'CZE',
'DK' => 'DNK',
'SE' => 'SWE',
'NO' => 'NOR',
];
return $countryMap[strtoupper($countryCode)] ?? 'DEU';
return (new DhlProductResolver)->toDhlCountryCode($countryCode);
}
/**
@ -576,8 +701,9 @@ class ShippingService
}
// Try to get from admin settings via Setting model first (database settings override config)
$settingKey = 'dhl_account_'.strtolower($productCode);
try {
$settingKey = 'dhl_account_' . strtolower($productCode);
$accountNumber = \App\Models\Setting::getContentBySlug($settingKey);
if ($accountNumber) {
Log::info('Using DHL account number from database settings', [
@ -692,6 +818,7 @@ class ShippingService
'order_id' => $orderData['order_id'] ?? null,
'dhl_shipment_no' => $shipmentNumber,
'routing_code' => $this->extractRoutingCode($response),
'reference' => $payload['shipments'][0]['refNo'] ?? null,
'type' => 'outbound',
'product_code' => $payload['shipments'][0]['product'],
'billing_number' => $payload['shipments'][0]['billingNumber'],
@ -721,7 +848,7 @@ class ShippingService
return null;
}
$path = 'dhl/labels/' . $shipment->dhl_shipment_no . '.' . strtolower($format);
$path = 'dhl/labels/'.$shipment->dhl_shipment_no.'.'.strtolower($format);
$success = false;
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {