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

@ -0,0 +1,8 @@
<?php
namespace Acme\Dhl\Exceptions;
class DhlAddressValidationException extends DhlValidationException
{
//
}

View file

@ -18,6 +18,7 @@ class DhlShipment extends Model
'order_id',
'dhl_shipment_no',
'routing_code',
'reference',
'type',
'related_shipment_id',
'product_code',
@ -60,6 +61,15 @@ class DhlShipment extends Model
'unknown' => 'unknown',
];
public const TRACKING_EMAIL_TRIGGER_STATUSES = [
'in_transit',
'out_for_delivery',
];
public const LEGACY_STATUS_ALIASES = [
'cancelled' => 'canceled',
];
/**
* Get the tracking events for this shipment
*/
@ -164,7 +174,9 @@ class DhlShipment extends Model
*/
public function getStatusTranslation(): string
{
return __('dhl.status.'.$this->status, [], $this->status);
$status = self::normalizeStatus($this->status);
return __('dhl.status.'.$status, [], $status);
}
/**
@ -172,9 +184,20 @@ class DhlShipment extends Model
*/
public static function getStatusTranslationFor(string $status): string
{
$status = self::normalizeStatus($status);
return __('dhl.status.'.$status, [], $status);
}
public static function normalizeStatus(?string $status): string
{
if ($status === null || $status === '') {
return 'unknown';
}
return self::LEGACY_STATUS_ALIASES[$status] ?? $status;
}
/**
* Get translated type for current locale
*/
@ -234,23 +257,92 @@ class DhlShipment extends Model
return $this->tracking_email_sent_at !== null;
}
/**
* @return array<int, array<string, mixed>>
*/
public function getTrackingEmailHistory(): array
{
$history = data_get($this->api_response_data ?? [], 'tracking_email_history', []);
if (! is_array($history)) {
return [];
}
return array_values(array_reverse($history));
}
public function shouldTriggerTrackingEmail(?string $previousStatus): bool
{
$currentStatus = self::normalizeStatus($this->status);
$previousStatus = self::normalizeStatus($previousStatus);
return in_array($currentStatus, self::TRACKING_EMAIL_TRIGGER_STATUSES, true)
&& $currentStatus !== $previousStatus
&& ! $this->wasTrackingEmailSent()
&& $this->canSendTrackingEmail();
}
/**
* Mark tracking email as sent
*/
public function markTrackingEmailSent(string $type = 'manual'): void
public function markTrackingEmailSent(string $type = 'manual', ?string $recipientEmail = null, ?iterable $includedShipments = null): void
{
$apiResponseData = $this->api_response_data ?? [];
$history = data_get($apiResponseData, 'tracking_email_history', []);
if (! is_array($history)) {
$history = [];
}
$history[] = [
'sent_at' => now()->toISOString(),
'type' => $type,
'recipient_email' => $recipientEmail,
'status' => self::normalizeStatus($this->status),
'tracking_status' => $this->tracking_status,
'dhl_shipment_no' => $this->dhl_shipment_no,
'included_shipment_ids' => $this->extractIncludedShipmentIds($includedShipments),
];
data_set($apiResponseData, 'tracking_email_history', $history);
$this->update([
'tracking_email_sent_at' => now(),
'tracking_email_type' => $type,
'api_response_data' => $apiResponseData,
]);
}
/**
* @return array<int, int|string>
*/
private function extractIncludedShipmentIds(?iterable $includedShipments): array
{
if ($includedShipments === null) {
return [$this->id];
}
$ids = [];
foreach ($includedShipments as $shipment) {
if ($shipment instanceof self && $shipment->id !== null) {
$ids[] = $shipment->id;
}
}
return $ids ?: [$this->id];
}
/**
* Get status badge class for Bootstrap
*/
public function getStatusBadgeClass(): string
{
return match ($this->status) {
return self::getStatusBadgeClassFor($this->status);
}
public static function getStatusBadgeClassFor(?string $status): string
{
return match (self::normalizeStatus($status)) {
'created', 'pending' => 'secondary',
'in_transit' => 'info',
'out_for_delivery' => 'primary',
@ -266,13 +358,13 @@ class DhlShipment extends Model
*/
public function scopeActive($query)
{
return $query->whereNotIn('status', ['delivered', 'canceled', 'returned', 'failed']);
return $query->whereNotIn('status', self::TERMINAL_STATUSES);
}
/**
* Terminal statuses where tracking is considered complete
*/
public const TERMINAL_STATUSES = ['delivered', 'canceled', 'returned', 'failed'];
public const TERMINAL_STATUSES = ['delivered', 'canceled', 'cancelled', 'returned', 'failed'];
/**
* Tracking interval per status (in hours).
@ -345,7 +437,7 @@ class DhlShipment extends Model
*/
public function scopeNeedsTrackingEmail($query)
{
return $query->where('status', 'in_transit')
return $query->whereIn('status', self::TRACKING_EMAIL_TRIGGER_STATUSES)
->whereNull('tracking_email_sent_at');
}
}

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 {