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,258 @@
<?php
namespace App\Services;
class DhlAddressValidator
{
private const DACH_COUNTRIES = ['DE', 'AT', 'CH'];
private const COUNTRY_SPECIFIC_POSTAL_PATTERNS = [
'DE' => ['pattern' => '/^\d{5}$/', 'message' => 'Deutsche Postleitzahl muss 5 Ziffern haben.'],
'AT' => ['pattern' => '/^\d{4}$/', 'message' => 'Oesterreichische Postleitzahl muss 4 Ziffern haben.'],
'CH' => ['pattern' => '/^\d{4}$/', 'message' => 'Schweizer Postleitzahl muss 4 Ziffern haben.'],
'ES' => ['pattern' => '/^\d{5}$/', 'message' => 'Spanische Postleitzahl muss 5 Ziffern haben.'],
];
/**
* @return array{status: string, can_create_label: bool, errors: array<int, string>, warnings: array<int, string>, normalized: array<string, mixed>, validation_available: bool, validation_level: string, validation_message: string}
*/
public function validate(array $address): array
{
$normalized = $this->normalize($address);
$errors = [];
$warnings = [];
$validationAvailable = $this->hasDachValidation($normalized['country_code']);
$validationLevel = $validationAvailable ? 'formal_dach' : 'basic';
$validationMessage = $validationAvailable
? 'Formale DACH-Pruefung aktiv: Pflichtfelder, PLZ-Format, Plausibilitaet und Packstation-Regeln werden geprueft. Eine echte Adressdatenbank-/DHL-Leitcodepruefung ist nicht angebunden.'
: 'Fuer dieses Zielland ist aktuell nur eine Basis-Adresspruefung verfuegbar.';
foreach ($this->requiredFields($normalized) as $field => $label) {
if ($normalized[$field] === '') {
$errors[] = "{$label} ist erforderlich.";
}
}
if ($normalized['firstname'] === '' && $normalized['lastname'] === '' && $normalized['company'] === '') {
$errors[] = 'Entweder Name oder Firmenname muss angegeben werden.';
}
$this->validateCountryAndPostalCode($normalized, $errors, $warnings);
$this->validateAddressPlausibility($normalized, $errors);
$this->validatePackstation($normalized, $errors, $warnings);
$this->validateWarnings($normalized, $warnings);
$status = 'valid';
if ($errors !== []) {
$status = 'error';
} elseif ($warnings !== []) {
$status = 'warning';
}
return [
'status' => $status,
'can_create_label' => $errors === [],
'errors' => array_values(array_unique($errors)),
'warnings' => array_values(array_unique($warnings)),
'normalized' => $normalized,
'validation_available' => $validationAvailable,
'validation_level' => $validationLevel,
'validation_message' => $validationMessage,
];
}
/**
* @return array<string, string>
*/
private function normalize(array $address): array
{
$countryCode = $address['country_code']
?? $address['shipping_country_code']
?? data_get($address, 'country.code')
?? '';
return [
'firstname' => trim((string) ($address['firstname'] ?? $address['shipping_firstname'] ?? '')),
'lastname' => trim((string) ($address['lastname'] ?? $address['shipping_lastname'] ?? '')),
'company' => trim((string) ($address['company'] ?? $address['shipping_company'] ?? '')),
'street' => trim((string) ($address['address'] ?? $address['street'] ?? $address['shipping_address'] ?? '')),
'house_number' => trim((string) ($address['houseNumber'] ?? $address['house_number'] ?? $address['shipping_houseNumber'] ?? '')),
'postal_code' => trim((string) ($address['zipcode'] ?? $address['postalCode'] ?? $address['postal_code'] ?? $address['shipping_zipcode'] ?? '')),
'city' => trim((string) ($address['city'] ?? $address['shipping_city'] ?? '')),
'country_code' => strtoupper(trim((string) $countryCode)),
'email' => trim((string) ($address['email'] ?? $address['shipping_email'] ?? '')),
'phone' => trim((string) ($address['phone'] ?? $address['shipping_phone'] ?? '')),
'postnumber' => trim((string) ($address['postnumber'] ?? $address['postNumber'] ?? $address['shipping_postnumber'] ?? '')),
];
}
/**
* @return array<string, string>
*/
private function requiredFields(array $address): array
{
$fields = [
'street' => 'Straße',
'postal_code' => 'Postleitzahl',
'city' => 'Ort',
'country_code' => 'Land',
];
if (! $this->isPackstation($address)) {
$fields['house_number'] = 'Hausnummer';
}
return $fields;
}
private function validateCountryAndPostalCode(array $address, array &$errors, array &$warnings): void
{
if ($address['country_code'] === '') {
return;
}
$resolver = new DhlProductResolver;
try {
if ($resolver->getAllowedProductCodesForCountry($address['country_code']) === []) {
$errors[] = "DHL-Versand in das Zielland {$address['country_code']} ist aktuell nicht freigegeben.";
}
} catch (\InvalidArgumentException $e) {
$errors[] = $e->getMessage();
return;
}
if (isset(self::COUNTRY_SPECIFIC_POSTAL_PATTERNS[$address['country_code']]) && $address['postal_code'] !== '') {
$rule = self::COUNTRY_SPECIFIC_POSTAL_PATTERNS[$address['country_code']];
if (! preg_match($rule['pattern'], $address['postal_code'])) {
$errors[] = $rule['message'];
}
return;
}
if ($address['postal_code'] !== '') {
$warnings[] = 'Fuer dieses Zielland ist aktuell nur eine Basis-Adresspruefung verfuegbar. Bitte Adresse manuell pruefen.';
}
}
private function hasDachValidation(string $countryCode): bool
{
return in_array($countryCode, self::DACH_COUNTRIES, true);
}
private function validateAddressPlausibility(array $address, array &$errors): void
{
$isDachAddress = $this->hasDachValidation($address['country_code']);
if ($address['street'] !== '' && mb_strlen($address['street']) < 3) {
$errors[] = 'Straße ist zu kurz.';
}
if ($address['street'] !== '' && ! preg_match('/[a-zäöüß]/iu', $address['street'])) {
$errors[] = 'Straße muss Buchstaben enthalten.';
}
if ($address['city'] !== '' && mb_strlen($address['city']) < 2) {
$errors[] = 'Ort ist zu kurz.';
}
if ($address['city'] !== '' && ! preg_match('/[a-zäöüß]/iu', $address['city'])) {
$errors[] = 'Ort muss Buchstaben enthalten.';
}
if ($address['postal_code'] !== '' && ! preg_match('/^[A-Z0-9][A-Z0-9 -]{2,9}$/i', $address['postal_code'])) {
$errors[] = 'Postleitzahl enthaelt ungueltige Zeichen.';
}
if ($isDachAddress && ! $this->isPackstation($address) && ! preg_match('/\d/', $address['house_number'])) {
$errors[] = 'Hausnummer muss fuer DACH-Adressen eine Ziffer enthalten.';
}
if ($this->containsPlaceholderValue($address['street'])) {
$errors[] = 'Straße wirkt wie eine Test- oder Platzhalteradresse.';
}
if ($this->containsPlaceholderValue($address['city'])) {
$errors[] = 'Ort wirkt wie eine Test- oder Platzhalterangabe.';
}
}
private function validatePackstation(array $address, array &$errors, array &$warnings): void
{
if ($this->isPackstation($address) && $address['postnumber'] === '') {
$errors[] = 'DHL Postnummer ist fuer Packstation/Paketbox erforderlich.';
}
if ($address['postnumber'] === '') {
return;
}
if ($address['country_code'] !== 'DE') {
$errors[] = 'Packstation/Paketbox ist aktuell nur fuer Deutschland erlaubt.';
}
if (! preg_match('/^[0-9]{6,10}$/', $address['postnumber'])) {
$errors[] = 'DHL Postnummer muss 6-10 Ziffern enthalten.';
}
if (! $this->isPackstation($address)) {
$warnings[] = 'DHL Postnummer ist gesetzt. Bitte pruefen, ob Straße/Nr. eine Packstation oder Paketbox enthaelt.';
}
$lockerNumber = $this->extractLockerNumber($address);
if ($this->isPackstation($address) && $lockerNumber === null) {
$errors[] = 'Packstation-/Paketbox-Nummer fehlt.';
}
if ($lockerNumber !== null && ((int) $lockerNumber < 100 || (int) $lockerNumber > 999)) {
$errors[] = 'Packstation-/Paketbox-Nummer muss 3-stellig sein (100-999).';
}
}
private function validateWarnings(array $address, array &$warnings): void
{
if ($address['phone'] === '') {
$warnings[] = 'Telefonnummer fehlt. DHL kann Empfaenger bei Zustellproblemen eventuell nicht kontaktieren.';
}
if ($address['email'] === '') {
$warnings[] = 'E-Mail-Adresse fehlt. Tracking-Mails koennen nicht automatisch an diese Adresse gesendet werden.';
}
if (! $this->hasDachValidation($address['country_code']) && ! $this->isPackstation($address) && ! preg_match('/\d/', $address['house_number'])) {
$warnings[] = 'Hausnummer enthaelt keine Ziffer. Bitte Adresse manuell pruefen.';
}
}
private function containsPlaceholderValue(string $value): bool
{
$value = mb_strtolower(trim($value));
if ($value === '') {
return false;
}
return (bool) preg_match('/(test|fake|falsch|dummy|asdf|qwertz|qwerty|xxx|kein|ungueltig|invalid)/u', $value)
|| preg_match('/^(.)\1{2,}$/u', $value);
}
private function isPackstation(array $address): bool
{
return (bool) preg_match('/\b(packstation|paketbox)\b/i', $address['street'].' '.$address['house_number']);
}
private function extractLockerNumber(array $address): ?string
{
if (preg_match('/(?:packstation|paketbox)\s*(\d+)/i', $address['street'], $matches)) {
return $matches[1];
}
if ($this->isPackstation($address) && preg_match('/\d+/', $address['house_number'], $matches)) {
return $matches[0];
}
return null;
}
}

View file

@ -35,15 +35,27 @@ class DhlDataHelper
$settingController = new SettingController;
$dhlConfig = $settingController->getDhlConfig();
}
$dimensions = isset($dhlConfig['dimensions'][$options['product_code']]) ? $dhlConfig['dimensions'][$options['product_code']] : $dhlConfig['dimensions']['default'];
$resolver = new DhlProductResolver;
$destinationCountryCode = $shippingAddress['country']?->code;
if (! $destinationCountryCode) {
throw new \Exception('shipping_address.country is required');
}
$resolvedDhlProduct = $resolver->resolveForShipment(
$destinationCountryCode,
$options['product_code'] ?? null,
$dhlConfig['default_product'] ?? 'V01PAK'
);
$dimensions = $dhlConfig['dimensions'][$resolvedDhlProduct['product_code']] ?? $dhlConfig['dimensions']['default'];
return [
'order_id' => $order->id,
'weight_kg' => $weight,
'product_code' => $options['product_code'] ?? 'V01PAK',
'product_code' => $resolvedDhlProduct['product_code'],
'label_format' => $options['label_format'] ?? $dhlConfig['label_format'] ?? 'PDF',
'print_format' => $options['print_format'] ?? $dhlConfig['print_format'] ?? null,
'retoure_print_format' => $options['retoure_print_format'] ?? $dhlConfig['retoure_print_format'] ?? null,
'print_only_if_codeable' => (bool) ($options['print_only_if_codeable'] ?? $dhlConfig['print_only_if_codeable'] ?? config('dhl.print_only_if_codeable', true)),
// Shipper data (sender) - from admin settings
'shipper' => [
@ -66,7 +78,7 @@ class DhlDataHelper
'houseNumber' => $shippingAddress['houseNumber'] ?? '',
'postalCode' => $shippingAddress['zipcode'] ?? '',
'city' => $shippingAddress['city'] ?? '',
'country' => $shippingAddress['country']?->code ?? 'DE',
'country' => $resolvedDhlProduct['country_code'],
'email' => $shippingAddress['email'] ?? '',
'phone' => $shippingAddress['phone'] ?? '',
// DHL Postnummer für Packstation/Paketbox
@ -82,7 +94,18 @@ class DhlDataHelper
'services' => $options['services'] ?? [],
// Custom reference for tracking
'reference' => 'Order-'.$order->id,
'reference' => self::normalizeReference($options['reference'] ?? $options['shipment_reference'] ?? null, $order),
];
}
private static function normalizeReference(?string $reference, ShoppingOrder $order): string
{
$reference = trim((string) $reference);
if ($reference === '') {
return 'Order-'.$order->id;
}
return mb_substr(preg_replace('/\s+/', ' ', $reference), 0, 35);
}
}

View file

@ -45,6 +45,8 @@ class DhlModalService
'shippingAddress' => null,
'availableCountries' => $this->getAvailableCountries(),
'productCodes' => $this->getAvailableProductCodes(),
'productSuggestions' => (new DhlProductResolver)->getProductSuggestionsByCountry(),
'selectedProductCode' => null,
'errors' => [],
'warnings' => [],
'existingShipments' => [],
@ -89,6 +91,7 @@ class DhlModalService
// Process and validate shipping address
$result['shippingAddress'] = $this->processShippingAddress($order);
$result['selectedProductCode'] = $this->getSuggestedProductCode($result['shippingAddress']);
// Validate address completeness
$addressValidation = $this->validateAddress($result['shippingAddress']);
@ -111,7 +114,7 @@ class DhlModalService
'error' => $e->getMessage(),
]);
$result['errors'][] = 'Fehler beim Laden der Bestelldaten: ' . $e->getMessage();
$result['errors'][] = 'Fehler beim Laden der Bestelldaten: '.$e->getMessage();
}
return $result;
@ -125,7 +128,7 @@ class DhlModalService
private function loadOrder($id): ?ShoppingOrder
{
return ShoppingOrder::with([
'shopping_order_items',
'shopping_order_items.product',
'shopping_user',
'dhlShipments', // Include DHL shipments
])->find($id);
@ -170,38 +173,7 @@ class DhlModalService
*/
private function calculateOrderWeight(ShoppingOrder $order): float
{
return $order->weight / 1000; // from grams to kg
/*
// Default fallback weight
$defaultWeight = 1.0;
if (!$order->shopping_order_items || $order->shopping_order_items->isEmpty()) {
return $defaultWeight;
}
// If order has a weight field (in grams), convert to kg
if ($order->weight && $order->weight > 0) {
return round($order->weight / 100, 1); // Convert grams to kg
}
// Calculate from items if available
$totalWeight = 0;
foreach ($order->shopping_order_items as $item) {
if ($item->weight && $item->weight > 0) {
$totalWeight += ($item->weight * $item->quantity);
}
}
if ($totalWeight > 0) {
return round($totalWeight / 100, 1); // Convert grams to kg
}
// Estimate based on item count if no weight data
$itemCount = $order->shopping_order_items->sum('quantity');
$estimatedWeight = max($itemCount * 0.5, $defaultWeight); // Estimate 0.5kg per item
return round($estimatedWeight, 1);
*/
return (new DhlShipmentWeightCalculator)->calculate($order);
}
/**
@ -287,50 +259,12 @@ class DhlModalService
*/
private function validateAddress(array $address): array
{
$errors = [];
$warnings = [];
// Required fields
$requiredFields = [
'firstname' => 'Vorname',
'lastname' => 'Nachname',
'address' => 'Straße',
'zipcode' => 'Postleitzahl',
'city' => 'Stadt',
];
foreach ($requiredFields as $field => $label) {
if (empty(trim($address[$field]))) {
$errors[] = "{$label} ist erforderlich.";
}
}
// Name validation
if (empty(trim($address['firstname'])) && empty(trim($address['lastname'])) && empty(trim($address['company']))) {
$errors[] = 'Entweder Name oder Firmenname muss angegeben werden.';
}
// Street number validation
if (! empty($address['address']) && empty($address['houseNumber'])) {
$warnings[] = 'Hausnummer konnte nicht automatisch erkannt werden. Bitte prüfen Sie die Adressangaben.';
}
// Postal code format validation for Germany
if (! empty($address['zipcode']) && $address['country'] && $address['country']->code === 'DE') {
if (! preg_match('/^\d{5}$/', $address['zipcode'])) {
$warnings[] = 'Deutsche Postleitzahl sollte 5 Ziffern haben.';
}
}
// Country validation
if (! $address['country']) {
$errors[] = 'Land konnte nicht ermittelt werden.';
}
$result = (new DhlAddressValidator)->validate($address);
return [
'valid' => empty($errors),
'errors' => $errors,
'warnings' => $warnings,
'valid' => $result['can_create_label'],
'errors' => $result['errors'],
'warnings' => $result['warnings'],
];
}
@ -386,8 +320,8 @@ class DhlModalService
$productCodes['V53PAK'] = 'DHL Paket International';
}
if (! empty($accountNumbers['V62WP'])) {
$productCodes['V62WP'] = 'DHL Warenpost National';
if (! empty($accountNumbers['V62KP'])) {
$productCodes['V62KP'] = 'DHL Kleinpaket';
}
if (! empty($accountNumbers['V07PAK'])) {
@ -399,13 +333,27 @@ class DhlModalService
$productCodes = [
'V01PAK' => 'DHL Paket National',
'V53PAK' => 'DHL Paket International',
'V62WP' => 'DHL Warenpost National',
'V62KP' => 'DHL Kleinpaket',
];
}
return $productCodes;
}
private function getSuggestedProductCode(array $shippingAddress): string
{
$countryCode = $shippingAddress['country']?->code;
if (! $countryCode) {
return 'V01PAK';
}
try {
return (new DhlProductResolver)->resolveProductCode($countryCode, null, 'V01PAK');
} catch (\InvalidArgumentException) {
return 'V01PAK';
}
}
/**
* Validate shipment parameters before API call
*
@ -431,23 +379,35 @@ class DhlModalService
$errors[] = 'Ungültiger Produktcode ausgewählt.';
}
// Address validation
$requiredAddressFields = [
'shipping_firstname' => 'Vorname',
'shipping_lastname' => 'Nachname',
'shipping_address' => 'Straße',
'shipping_houseNumber' => 'Hausnummer',
'shipping_zipcode' => 'Postleitzahl',
'shipping_city' => 'Stadt',
'shipping_country_id' => 'Land',
];
foreach ($requiredAddressFields as $field => $label) {
if (empty(trim($shipmentData[$field] ?? ''))) {
$errors[] = "{$label} ist erforderlich.";
if (! empty($shipmentData['shipping_country_id']) && $productCode) {
$country = Country::find($shipmentData['shipping_country_id']);
if ($country) {
try {
(new DhlProductResolver)->resolveProductCode($country->code, $productCode);
} catch (\InvalidArgumentException $e) {
$errors[] = $e->getMessage();
}
}
}
if ($productCode) {
try {
(new DhlShipmentWeightCalculator)->assertWithinProductLimit($weight, $productCode);
} catch (\InvalidArgumentException $e) {
$errors[] = $e->getMessage();
}
}
$country = null;
if (! empty($shipmentData['shipping_country_id'])) {
$country = Country::find($shipmentData['shipping_country_id']);
}
$addressValidation = (new DhlAddressValidator)->validate(array_merge($shipmentData, [
'shipping_country_code' => $country?->code,
]));
$errors = array_merge($errors, $addressValidation['errors']);
$warnings = array_merge($warnings, $addressValidation['warnings']);
return [
'valid' => empty($errors),
'errors' => $errors,

View file

@ -0,0 +1,199 @@
<?php
namespace App\Services;
use App\Models\Setting;
use InvalidArgumentException;
class DhlProductResolver
{
public const DOMESTIC_COUNTRY = 'DE';
public const DOMESTIC_PRODUCT_CODES = ['V01PAK', 'V62KP'];
public const INTERNATIONAL_PRODUCT_CODE = 'V53PAK';
public const DEFAULT_INTERNATIONAL_COUNTRIES = ['AT', 'ES'];
public const DHL_COUNTRY_CODES = [
'DE' => 'DEU',
'AT' => 'AUT',
'ES' => 'ESP',
'CH' => 'CHE',
'US' => 'USA',
'GB' => 'GBR',
'FR' => 'FRA',
'IT' => 'ITA',
'NL' => 'NLD',
'BE' => 'BEL',
'PL' => 'POL',
'CZ' => 'CZE',
'DK' => 'DNK',
'SE' => 'SWE',
'NO' => 'NOR',
];
/**
* @return array{country_code: string, dhl_country_code: string, product_code: string}
*/
public function resolveForShipment(string $destinationCountryCode, ?string $requestedProductCode = null, ?string $defaultProductCode = null): array
{
$countryCode = $this->normalizeCountryCode($destinationCountryCode);
$productCode = $this->resolveProductCode($countryCode, $requestedProductCode, $defaultProductCode);
return [
'country_code' => $countryCode,
'dhl_country_code' => $this->toDhlCountryCode($countryCode),
'product_code' => $productCode,
];
}
public function resolveProductCode(string $destinationCountryCode, ?string $requestedProductCode = null, ?string $defaultProductCode = null): string
{
$countryCode = $this->normalizeCountryCode($destinationCountryCode);
$hasRequestedProduct = $requestedProductCode !== null && trim($requestedProductCode) !== '';
$productCode = $this->normalizeProductCode($requestedProductCode ?: $defaultProductCode);
if ($countryCode === self::DOMESTIC_COUNTRY) {
$productCode = $productCode ?: 'V01PAK';
if (! in_array($productCode, self::DOMESTIC_PRODUCT_CODES, true)) {
throw new InvalidArgumentException("Produkt {$productCode} ist fuer DHL-Versand nach Deutschland nicht erlaubt.");
}
return $productCode;
}
if (! in_array($countryCode, $this->getSupportedInternationalCountries(), true)) {
throw new InvalidArgumentException("DHL-Versand in das Zielland {$countryCode} ist aktuell nicht freigegeben.");
}
if (! $productCode || (! $hasRequestedProduct && in_array($productCode, self::DOMESTIC_PRODUCT_CODES, true))) {
return self::INTERNATIONAL_PRODUCT_CODE;
}
if ($productCode !== self::INTERNATIONAL_PRODUCT_CODE) {
throw new InvalidArgumentException("Produkt {$productCode} ist fuer DHL-Versand in das Zielland {$countryCode} nicht erlaubt.");
}
return $productCode;
}
/**
* @return array<string, string>
*/
public function getProductSuggestionsByCountry(): array
{
return array_fill_keys($this->getSupportedInternationalCountries(), self::INTERNATIONAL_PRODUCT_CODE)
+ [self::DOMESTIC_COUNTRY => 'V01PAK'];
}
/**
* @return string[]
*/
public function getAllowedProductCodesForCountry(string $destinationCountryCode): array
{
$countryCode = $this->normalizeCountryCode($destinationCountryCode);
if ($countryCode === self::DOMESTIC_COUNTRY) {
return self::DOMESTIC_PRODUCT_CODES;
}
if (in_array($countryCode, $this->getSupportedInternationalCountries(), true)) {
return [self::INTERNATIONAL_PRODUCT_CODE];
}
return [];
}
public function toDhlCountryCode(string $countryCode): string
{
$countryCode = $this->normalizeCountryCode($countryCode);
return self::DHL_COUNTRY_CODES[$countryCode];
}
public function assertBillingNumber(string $productCode, ?string $billingNumber): string
{
if ($billingNumber === null || trim($billingNumber) === '') {
throw new InvalidArgumentException("Keine DHL-Abrechnungsnummer fuer Produkt {$productCode} konfiguriert.");
}
return $billingNumber;
}
public function getProductScope(string $productCode): string
{
$productCode = $this->normalizeProductCode($productCode);
return $productCode === self::INTERNATIONAL_PRODUCT_CODE ? 'international' : 'national';
}
public function getProductScopeLabel(string $productCode): string
{
return $this->getProductScope($productCode) === 'international'
? 'DHL Paket International'
: 'DHL Paket National';
}
public function normalizeCountryCode(string $countryCode): string
{
$countryCode = strtoupper(trim($countryCode));
if ($countryCode === '') {
throw new InvalidArgumentException('DHL-Zielland fehlt.');
}
if (strlen($countryCode) === 3) {
$countryCode = array_search($countryCode, self::DHL_COUNTRY_CODES, true) ?: $countryCode;
}
if (! array_key_exists($countryCode, self::DHL_COUNTRY_CODES)) {
throw new InvalidArgumentException("DHL-Laendercode {$countryCode} wird nicht unterstuetzt.");
}
return $countryCode;
}
public function normalizeProductCode(?string $productCode): ?string
{
if ($productCode === null || trim($productCode) === '') {
return null;
}
return strtoupper(trim($productCode));
}
/**
* @return string[]
*/
public function getSupportedInternationalCountries(): array
{
$useEnvPriority = config('dhl.config_source') === 'env';
$configCountries = config('dhl.international_countries', self::DEFAULT_INTERNATIONAL_COUNTRIES);
$countries = $configCountries;
if (! $useEnvPriority) {
$countries = Setting::getContentBySlug('dhl_international_countries') ?: $configCountries;
}
return self::normalizeCountryCodeList(is_array($countries) ? $countries : []);
}
/**
* @return string[]
*/
public static function normalizeCountryCodeList(array $countryCodes): array
{
$countryCodes = array_map(
static fn ($countryCode): string => strtoupper(trim((string) $countryCode)),
$countryCodes
);
return array_values(array_filter(array_unique($countryCodes), static function (string $countryCode): bool {
return $countryCode !== ''
&& $countryCode !== self::DOMESTIC_COUNTRY
&& array_key_exists($countryCode, self::DHL_COUNTRY_CODES);
}));
}
}

View file

@ -2,18 +2,18 @@
namespace App\Services;
use Acme\Dhl\Exceptions\DhlAddressValidationException;
use Acme\Dhl\Models\DhlShipment;
use App\Models\ShoppingOrder;
use App\Http\Controllers\SettingController;
use App\Jobs\CreateShipmentJob;
use App\Jobs\CancelShipmentJob;
use App\Services\DhlDataHelper;
use Illuminate\Support\Facades\Log;
use App\Jobs\CreateShipmentJob;
use App\Models\ShoppingOrder;
use Exception;
use Illuminate\Support\Facades\Log;
/**
* DHL Shipment Service
*
*
* Handles both synchronous and asynchronous shipment creation
* based on configuration settings
*/
@ -21,20 +21,23 @@ class DhlShipmentService
{
/**
* Create a DHL shipment (sync or async based on config)
*
* @param ShoppingOrder $order
* @param float $weight
* @param array $options
* @return array
*/
public function createShipment(ShoppingOrder $order, float $weight = 1.0, array $options = []): array
{
$weight = max($weight, (new DhlShipmentWeightCalculator)->calculate($order));
// Get DHL configuration
$settingController = new SettingController();
$settingController = new SettingController;
$dhlConfig = $settingController->getDhlConfig();
\Log::info('dhlConfig', $dhlConfig);
// Check if queue should be used
$useQueue = $dhlConfig['use_queue'] ?? false;
if ($useQueue && $this->requiresSynchronousAddressValidation($options, $dhlConfig)) {
Log::info('[DHL Service] Queue disabled for DHL mustEncode address validation', [
'order_id' => $order->id,
]);
$useQueue = false;
}
if ($useQueue) {
return $this->createShipmentAsync($order, $weight, $options, $dhlConfig);
@ -43,14 +46,20 @@ class DhlShipmentService
}
}
private function requiresSynchronousAddressValidation(array $options, array $dhlConfig): bool
{
if (! (bool) ($options['print_only_if_codeable'] ?? $dhlConfig['print_only_if_codeable'] ?? config('dhl.print_only_if_codeable', true))) {
return false;
}
$country = $options['shipping_address']['country'] ?? null;
$countryCode = is_object($country) ? ($country->code ?? null) : ($country['code'] ?? null);
return strtoupper((string) $countryCode) === DhlProductResolver::DOMESTIC_COUNTRY;
}
/**
* Create shipment asynchronously using queue
*
* @param ShoppingOrder $order
* @param float $weight
* @param array $options
* @param array $dhlConfig
* @return array
*/
private function createShipmentAsync(ShoppingOrder $order, float $weight, array $options, array $dhlConfig): array
{
@ -60,14 +69,14 @@ class DhlShipmentService
Log::info('[DHL Service] Shipment creation dispatched to queue', [
'order_id' => $order->id,
'weight' => $weight
'weight' => $weight,
]);
return [
'success' => true,
'message' => 'Sendung wird erstellt. Sie erhalten eine Benachrichtigung, sobald das Versandlabel verfügbar ist.',
'queued' => true,
'order_id' => $order->id
'order_id' => $order->id,
];
} catch (Exception $e) {
Log::error('[DHL Service] Failed to dispatch shipment creation', [
@ -77,27 +86,21 @@ class DhlShipmentService
return [
'success' => false,
'message' => 'Fehler beim Einreihen der Sendungserstellung: ' . $e->getMessage(),
'queued' => false
'message' => 'Fehler beim Einreihen der Sendungserstellung: '.$e->getMessage(),
'queued' => false,
];
}
}
/**
* Create shipment synchronously
*
* @param ShoppingOrder $order
* @param float $weight
* @param array $options
* @param array $dhlConfig
* @return array
*/
private function createShipmentSync(ShoppingOrder $order, float $weight, array $options, array $dhlConfig): array
{
try {
Log::info('[DHL Service] Creating shipment synchronously', [
'order_id' => $order->id,
'weight' => $weight
'weight' => $weight,
]);
// Create DHL client directly with correct base URL
@ -132,32 +135,42 @@ class DhlShipmentService
'label_path' => $result['labelPath'] ?? null,
'label_url' => $result['labelUrl'] ?? null,
];
} catch (Exception $e) {
Log::error('[DHL Service] Shipment creation failed (sync)', [
} catch (DhlAddressValidationException $e) {
Log::warning('[DHL Service] Shipment address validation failed (sync)', [
'order_id' => $order->id,
'error' => $e->getMessage()
'error' => $e->getMessage(),
]);
return [
'success' => false,
'message' => 'Fehler beim Erstellen des Versandlabels: ' . $e->getMessage(),
'type' => 'dhl_address_validation',
'message' => $e->getMessage(),
'errors' => [$e->getMessage()],
'queued' => false,
'order_id' => $order->id
'order_id' => $order->id,
];
} catch (Exception $e) {
Log::error('[DHL Service] Shipment creation failed (sync)', [
'order_id' => $order->id,
'error' => $e->getMessage(),
]);
return [
'success' => false,
'message' => 'Fehler beim Erstellen des Versandlabels: '.$e->getMessage(),
'queued' => false,
'order_id' => $order->id,
];
}
}
/**
* Cancel a DHL shipment (sync or async based on config)
*
* @param DhlShipment $shipment
* @param array $options
* @return array
*/
public function cancelShipment(DhlShipment $shipment, array $options = []): array
{
// Get DHL configuration
$settingController = new SettingController();
$settingController = new SettingController;
$dhlConfig = $settingController->getDhlConfig();
// Check if queue should be used
@ -172,11 +185,6 @@ class DhlShipmentService
/**
* Cancel shipment asynchronously using queue
*
* @param DhlShipment $shipment
* @param array $options
* @param array $dhlConfig
* @return array
*/
private function cancelShipmentAsync(DhlShipment $shipment, array $options, array $dhlConfig): array
{
@ -186,14 +194,14 @@ class DhlShipmentService
Log::info('[DHL Service] Shipment cancellation dispatched to queue', [
'shipment_id' => $shipment->id,
'dhl_shipment_no' => $shipment->dhl_shipment_no
'dhl_shipment_no' => $shipment->dhl_shipment_no,
]);
return [
'success' => true,
'message' => 'Sendung wird storniert...',
'queued' => true,
'shipment_id' => $shipment->id
'shipment_id' => $shipment->id,
];
} catch (Exception $e) {
Log::error('[DHL Service] Failed to dispatch shipment cancellation', [
@ -203,40 +211,39 @@ class DhlShipmentService
return [
'success' => false,
'message' => 'Fehler beim Einreihen der Stornierung: ' . $e->getMessage(),
'queued' => false
'message' => 'Fehler beim Einreihen der Stornierung: '.$e->getMessage(),
'queued' => false,
];
}
}
/**
* Cancel shipment synchronously
*
* @param DhlShipment $shipment
* @param array $options
* @param array $dhlConfig
* @return array
*/
private function cancelShipmentSync(DhlShipment $shipment, array $options, array $dhlConfig): array
{
try {
// Validate shipment has DHL number
if (empty($shipment->dhl_shipment_no)) {
$this->recordCancellationFailure($shipment, 'missing_shipment_number', 'Sendung hat keine DHL-Sendungsnummer und kann nicht storniert werden.');
return [
'success' => false,
'message' => 'Sendung hat keine DHL-Sendungsnummer und kann nicht storniert werden.',
'queued' => false,
'shipment_id' => $shipment->id
'shipment_id' => $shipment->id,
];
}
// Validate shipment can be cancelled
if (! $shipment->canCancel()) {
$this->recordCancellationFailure($shipment, 'status_not_cancelable', 'Sendung kann im aktuellen Status "'.$shipment->status.'" nicht storniert werden.');
return [
'success' => false,
'message' => 'Sendung kann im aktuellen Status "' . $shipment->status . '" nicht storniert werden. Nur Status "created" oder "pending" sind stornierbar.',
'message' => 'Sendung kann im aktuellen Status "'.$shipment->getStatusTranslation().'" nicht storniert werden. Nur Status "Erstellt" oder "Wartend" sind stornierbar.',
'queued' => false,
'shipment_id' => $shipment->id
'shipment_id' => $shipment->id,
];
}
@ -244,7 +251,7 @@ class DhlShipmentService
'shipment_id' => $shipment->id,
'dhl_shipment_no' => $shipment->dhl_shipment_no,
'status' => $shipment->status,
'base_url' => $dhlConfig['base_url']
'base_url' => $dhlConfig['base_url'],
]);
// Create DHL client
@ -263,37 +270,41 @@ class DhlShipmentService
if ($success) {
Log::info('[DHL Service] Shipment cancelled successfully (sync)', [
'shipment_id' => $shipment->id,
'dhl_shipment_no' => $shipment->dhl_shipment_no
'dhl_shipment_no' => $shipment->dhl_shipment_no,
]);
return [
'success' => true,
'message' => 'Sendung wurde erfolgreich storniert!',
'queued' => false,
'shipment_id' => $shipment->id
'shipment_id' => $shipment->id,
];
} else {
throw new Exception('Cancellation returned false');
}
} catch (\InvalidArgumentException $e) {
$this->recordCancellationFailure($shipment, 'validation_failed', $e->getMessage(), $e);
Log::warning('[DHL Service] Shipment cancellation validation failed', [
'shipment_id' => $shipment->id,
'error' => $e->getMessage()
'error' => $e->getMessage(),
]);
return [
'success' => false,
'message' => $e->getMessage(),
'queued' => false,
'shipment_id' => $shipment->id
'shipment_id' => $shipment->id,
];
} catch (Exception $e) {
$this->recordCancellationFailure($shipment, 'api_failed', $e->getMessage(), $e);
Log::error('[DHL Service] Shipment cancellation failed (sync)', [
'shipment_id' => $shipment->id,
'dhl_shipment_no' => $shipment->dhl_shipment_no,
'status' => $shipment->status,
'error' => $e->getMessage(),
'error_trace' => $e->getTraceAsString()
'error_trace' => $e->getTraceAsString(),
]);
// Check if it's an API authentication/resource error
@ -304,16 +315,50 @@ class DhlShipmentService
'message' => 'Die Sendung konnte bei DHL nicht gefunden werden. Mögliche Ursachen: Sendung wurde bereits storniert, ist zu alt, oder wurde in einem anderen Modus (Sandbox/Production) erstellt.',
'queued' => false,
'shipment_id' => $shipment->id,
'technical_error' => $errorMessage
'technical_error' => $errorMessage,
];
}
return [
'success' => false,
'message' => 'Fehler beim Stornieren der Sendung: ' . $errorMessage,
'message' => 'Fehler beim Stornieren der Sendung: '.$errorMessage,
'queued' => false,
'shipment_id' => $shipment->id
'shipment_id' => $shipment->id,
];
}
}
private function recordCancellationFailure(DhlShipment $shipment, string $reason, string $detail, ?Exception $exception = null): void
{
$apiResponseData = $shipment->api_response_data ?? [];
$apiResponseData['cancellation_error'] = [
'status' => 'failed',
'reason' => $reason,
'http_status' => $exception ? $this->extractHttpStatus($exception->getMessage()) : null,
'dhl_code' => $exception ? $this->extractDhlErrorCode($exception->getMessage()) : null,
'detail' => $detail,
'exception_class' => $exception ? $exception::class : null,
'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;
}
}

View file

@ -0,0 +1,89 @@
<?php
namespace App\Services;
use App\Models\ShoppingOrder;
use InvalidArgumentException;
class DhlShipmentWeightCalculator
{
public const DEFAULT_WEIGHT_KG = 1.0;
public const DEFAULT_MAX_WEIGHT_KG = 31.5;
public const PRODUCT_MAX_WEIGHTS_KG = [
'V01PAK' => 31.5,
'V53PAK' => 31.5,
'V62KP' => 1.0,
];
public function calculate(ShoppingOrder $order): float
{
$this->loadOrderItems($order);
$baseWeightGrams = max((int) ($order->weight ?? 0), 0);
$compensationWeightGrams = $this->calculateCompensationWeightGrams($order);
$totalWeightGrams = $baseWeightGrams + $compensationWeightGrams;
if ($totalWeightGrams <= 0) {
return self::DEFAULT_WEIGHT_KG;
}
return $this->roundWeightKg($totalWeightGrams / 1000);
}
public function getMaxWeightKgForProduct(?string $productCode): float
{
$productCode = strtoupper(trim((string) $productCode));
return self::PRODUCT_MAX_WEIGHTS_KG[$productCode] ?? self::DEFAULT_MAX_WEIGHT_KG;
}
public function assertWithinProductLimit(float $weightKg, ?string $productCode): void
{
$maxWeightKg = $this->getMaxWeightKgForProduct($productCode);
if ($weightKg > $maxWeightKg) {
throw new InvalidArgumentException(sprintf(
'Gewicht %.3f kg ueberschreitet das DHL-Maximalgewicht fuer %s (%.1f kg).',
$weightKg,
$productCode ?: 'das gewaehlte Produkt',
$maxWeightKg
));
}
}
private function calculateCompensationWeightGrams(ShoppingOrder $order): int
{
$items = $order->shopping_order_items ?? collect();
$weightGrams = 0;
foreach ($items as $item) {
if ((int) ($item->comp ?? 0) <= 0) {
continue;
}
$productWeight = (int) ($item->product?->weight ?? 0);
if ($productWeight <= 0) {
continue;
}
$quantity = max((int) ($item->qty ?? 1), 1);
$weightGrams += $productWeight * $quantity;
}
return $weightGrams;
}
private function roundWeightKg(float $weightKg): float
{
return round(max($weightKg, 0.1), 3);
}
private function loadOrderItems(ShoppingOrder $order): void
{
if ($order->exists && ! $order->relationLoaded('shopping_order_items')) {
$order->loadMissing('shopping_order_items.product');
}
}
}

View file

@ -307,6 +307,16 @@ class DhlTrackingService
}
}
/**
* Update tracking immediately, bypassing queue dispatch.
*/
public function updateTrackingNow(DhlShipment $shipment, array $options = []): array
{
$settingController = new SettingController;
return $this->updateTrackingSync($shipment, $options, $settingController->getDhlConfig());
}
/**
* Update tracking asynchronously using queue
*/
@ -366,7 +376,7 @@ class DhlTrackingService
$result = $this->trackShipment($shipment->dhl_shipment_no);
if ($result['success']) {
$internalStatus = $this->mapDhlStatusToInternal($result['status']);
$internalStatus = self::mapDhlStatusToInternal($result['status']);
// Update shipment with tracking data
$updateData = [
@ -481,7 +491,7 @@ class DhlTrackingService
// Remove from map so we can detect missing ones later
unset($shipmentMap[$trackingNo]);
$internalStatus = $this->mapDhlStatusToInternal($trackingResult['status']);
$internalStatus = self::mapDhlStatusToInternal($trackingResult['status']);
$updateData = [
'status' => $internalStatus,
@ -593,19 +603,25 @@ class DhlTrackingService
/**
* Map DHL status codes to internal status
*/
private function mapDhlStatusToInternal(string $dhlStatus): string
public static function mapDhlStatusToInternal(string $dhlStatus): string
{
$statusMap = [
'pre-transit' => 'created',
'pre_transit' => 'created',
'pretransit' => 'created',
'transit' => 'in_transit',
'in-transit' => 'in_transit',
'in_transit' => 'in_transit',
'out-for-delivery' => 'out_for_delivery',
'out_for_delivery' => 'out_for_delivery',
'delivered' => 'delivered',
'failure' => 'failed',
'failed' => 'failed',
'returned' => 'returned',
'exception' => 'exception',
];
return $statusMap[$dhlStatus] ?? 'unknown';
return $statusMap[strtolower($dhlStatus)] ?? 'unknown';
}
/**