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

@ -244,14 +244,7 @@ class DhlUpdateTracking extends Command
*/
private function shouldSendEmail(DhlShipment $shipment, string $oldStatus): bool
{
// E-Mail nur senden wenn:
// 1. Status ist jetzt "in_transit"
// 2. Vorheriger Status war NICHT "in_transit" (also Status hat sich geändert)
// 3. Noch keine E-Mail gesendet wurde
return $shipment->status === 'in_transit'
&& $oldStatus !== 'in_transit'
&& ! $shipment->wasTrackingEmailSent()
&& $shipment->canSendTrackingEmail();
return $shipment->shouldTriggerTrackingEmail($oldStatus);
}
/**
@ -282,7 +275,7 @@ class DhlUpdateTracking extends Command
// Sammle alle Sendungen für diese Bestellung, die noch keine E-Mail erhalten haben
$allShipments = DhlShipment::where('order_id', $order->id)
->where('status', 'in_transit')
->whereIn('status', DhlShipment::TRACKING_EMAIL_TRIGGER_STATUSES)
->whereNotNull('dhl_shipment_no')
->whereNull('tracking_email_sent_at')
->get();
@ -297,7 +290,7 @@ class DhlUpdateTracking extends Command
// Markiere alle Sendungen als versendet
foreach ($allShipments as $s) {
$s->markTrackingEmailSent('auto');
$s->markTrackingEmailSent('auto', $recipientEmail, $allShipments);
}
Log::info('[DHL Cron] Tracking email sent automatically', [

View file

@ -7,9 +7,13 @@ use App\Jobs\CancelShipmentJob;
// Old DHL model replaced with new package model
use App\Jobs\CreateReturnLabelJob;
use App\Mail\MailDhlTracking;
use App\Models\Country;
use App\Models\ShoppingOrder;
use App\Services\DhlAddressValidator;
use App\Services\DhlModalService;
use App\Services\DhlProductResolver;
use App\Services\DhlShipmentService;
use App\Services\DhlShipmentWeightCalculator;
use App\Services\DhlTrackingService;
use Exception;
use Illuminate\Http\JsonResponse;
@ -122,7 +126,12 @@ class DhlShipmentController extends Controller
$query->where('type', $request->get('type'));
}
if ($request->filled('status')) {
$query->where('status', $request->get('status'));
$status = DhlShipment::normalizeStatus($request->get('status'));
if ($status === 'canceled') {
$query->whereIn('status', ['canceled', 'cancelled']);
} else {
$query->where('status', $status);
}
}
if ($request->filled('date_from')) {
$query->whereDate('created_at', '>=', $request->get('date_from'));
@ -181,10 +190,11 @@ class DhlShipmentController extends Controller
'created' => ['class' => 'success', 'text' => 'Erstellt'],
'shipped' => ['class' => 'primary', 'text' => 'Versendet'],
'delivered' => ['class' => 'info', 'text' => 'Zugestellt'],
'cancelled' => ['class' => 'secondary', 'text' => 'Storniert'],
'canceled' => ['class' => 'secondary', 'text' => 'Storniert'],
'failed' => ['class' => 'danger', 'text' => 'Fehler'],
];
$statusInfo = $statusMap[$shipment->status] ?? ['class' => 'light', 'text' => e($shipment->status)];
$status = DhlShipment::normalizeStatus($shipment->status);
$statusInfo = $statusMap[$status] ?? ['class' => 'light', 'text' => e($shipment->status)];
return '<span class="badge badge-'.$statusInfo['class'].'">'.$statusInfo['text'].'</span>';
})
@ -277,6 +287,7 @@ class DhlShipmentController extends Controller
'order_id' => 'required|exists:shopping_orders,id',
'weight' => 'required|numeric|min:0.1|max:31.5',
'product_code' => 'sometimes|string',
'reference' => 'nullable|string|max:35',
'priority' => 'sometimes|string|in:normal,high',
'auto_track' => 'sometimes|boolean',
// Shipping address validation
@ -294,6 +305,10 @@ class DhlShipmentController extends Controller
]);
$order = ShoppingOrder::findOrFail($request->order_id);
$shipmentWeight = max(
(float) $request->weight,
(new DhlShipmentWeightCalculator)->calculate($order)
);
// Check if shipment already exists
/* $existingShipment = DhlShipment::where('shopping_order_id', $order->id)
@ -314,6 +329,7 @@ class DhlShipmentController extends Controller
// Prepare options for shipment creation
$options = [
'product_code' => $request->get('product_code', 'V01PAK'),
'reference' => $request->get('reference'),
'priority' => $request->get('priority', 'normal'),
'auto_track' => $request->get('auto_track', true),
'shipping_address' => $shippingAddress,
@ -323,15 +339,20 @@ class DhlShipmentController extends Controller
// Use DhlShipmentService (handles queue/sync automatically based on config)
$dhlShipmentService = new DhlShipmentService;
$result = $dhlShipmentService->createShipment($order, (float) $request->weight, $options);
$result = $dhlShipmentService->createShipment($order, $shipmentWeight, $options);
Log::info('[DHL Controller] Shipment creation processed', [
'order_id' => $order->id,
'weight' => $request->weight,
'weight' => $shipmentWeight,
'requested_weight' => $request->weight,
'queued' => $result['queued'] ?? false,
'success' => $result['success'] ?? false,
]);
if (! ($result['success'] ?? false)) {
return response()->json($result, ($result['type'] ?? null) === 'dhl_address_validation' ? 422 : 500);
}
return response()->json($result);
} catch (Exception $e) {
Log::error('[DHL Controller] Failed to dispatch shipment creation', [
@ -346,6 +367,81 @@ class DhlShipmentController extends Controller
}
}
public function validateAddress(Request $request, DhlAddressValidator $validator): JsonResponse
{
$country = $request->filled('shipping_country_id')
? Country::find($request->get('shipping_country_id'))
: null;
$resolver = new DhlProductResolver;
$result = $validator->validate(array_merge($request->all(), [
'shipping_country_code' => $country?->code,
]));
$errors = $result['errors'];
$warnings = $result['warnings'];
$product = [
'code' => $request->get('product_code'),
'scope' => null,
'scope_label' => 'Nicht geprüft',
'country_code' => $country?->code,
'country_label' => $country?->getLocated(),
];
if ($country) {
try {
$resolvedProductCode = $resolver->resolveProductCode(
$country->code,
$request->get('product_code'),
config('dhl.default_product', 'V01PAK')
);
$product = [
'code' => $resolvedProductCode,
'scope' => $resolver->getProductScope($resolvedProductCode),
'scope_label' => $resolver->getProductScopeLabel($resolvedProductCode),
'country_code' => $country->code,
'country_label' => $country->getLocated(),
];
} catch (\InvalidArgumentException $e) {
$errors[] = $e->getMessage();
}
}
$status = 'valid';
if ($errors !== []) {
$status = 'error';
} elseif ($warnings !== []) {
$status = 'warning';
}
return response()->json([
'success' => $errors === [],
'status' => $status,
'can_create_label' => $errors === [],
'errors' => array_values(array_unique($errors)),
'warnings' => array_values(array_unique($warnings)),
'message' => $this->addressValidationMessage($status),
'preflight' => [
'product' => $product,
'address' => [
'status' => $result['status'],
'normalized' => $result['normalized'],
'validation_available' => $result['validation_available'],
'validation_level' => $result['validation_level'],
'validation_message' => $result['validation_message'],
],
],
], $errors === [] ? 200 : 422);
}
private function addressValidationMessage(string $status): string
{
return match ($status) {
'valid' => 'Adresse ist formal versandfähig.',
'warning' => 'Adresse ist formal versandfähig, sollte aber vor der Labelerstellung geprüft werden.',
default => 'Adresse ist nicht versandfähig. Bitte korrigieren Sie die markierten Felder.',
};
}
/**
* Display the specified shipment
*/
@ -719,7 +815,7 @@ class DhlShipmentController extends Controller
// Mark all included shipments as sent
foreach ($allShipments as $s) {
$s->markTrackingEmailSent('manual');
$s->markTrackingEmailSent('manual', $recipientEmail, $allShipments);
}
Log::info('[DHL Controller] Tracking email sent', [

View file

@ -241,6 +241,8 @@ class ModalController extends Controller
'V01PAK' => 'DHL Paket (National)',
'V53WPAK' => 'DHL Paket International',
],
'productSuggestions' => (new \App\Services\DhlProductResolver)->getProductSuggestionsByCountry(),
'selectedProductCode' => 'V01PAK',
'errors' => ['Fehler beim Laden der Daten: '.$e->getMessage()],
'warnings' => [],
];

View file

@ -3,6 +3,8 @@
namespace App\Http\Controllers;
use App\Models\Setting;
use App\Services\DhlProductResolver;
use Illuminate\Support\Facades\Session;
use Request;
class SettingController extends Controller
@ -36,9 +38,9 @@ class SettingController extends Controller
// DHL-spezifische Behandlung
if ($data['action'] === 'save_dhl') {
$this->updateDhlConfigCache();
\Session()->flash('alert-save-dhl', 'DHL Konfiguration erfolgreich gespeichert!');
Session::flash('alert-save-dhl', 'DHL Konfiguration erfolgreich gespeichert!');
} else {
\Session()->flash('alert-save', '1');
Session::flash('alert-save', '1');
}
}
@ -72,10 +74,12 @@ class SettingController extends Controller
'test_mode' => config('dhl.legacy.test_mode', true),
// Product Settings
'default_product' => $this->getConfigValue('dhl_product', config('dhl.default_product'), $useEnvPriority),
'default_product' => $this->normalizeDhlProductCode($this->getConfigValue('dhl_product', config('dhl.default_product'), $useEnvPriority)),
'international_countries' => $this->getDhlInternationalCountries($useEnvPriority),
'label_format' => $this->getConfigValue('dhl_label_format', config('dhl.label_format'), $useEnvPriority),
'print_format' => $this->getConfigValue('dhl_print_format', config('dhl.print_format'), $useEnvPriority),
'retoure_print_format' => $this->getConfigValue('dhl_retoure_print_format', config('dhl.retoure_print_format'), $useEnvPriority),
'print_only_if_codeable' => (bool) $this->getConfigValue('dhl_print_only_if_codeable', config('dhl.print_only_if_codeable'), $useEnvPriority),
'use_queue' => $this->getConfigValue('dhl_use_queue', config('dhl.use_queue'), $useEnvPriority),
// Sender Address
@ -94,7 +98,7 @@ class SettingController extends Controller
// Account Numbers
'account_numbers' => [
'V01PAK' => $this->getConfigValue('dhl_account_v01pak', config('dhl.account_numbers.V01PAK'), $useEnvPriority),
'V62WP' => $this->getConfigValue('dhl_account_v62wp', config('dhl.account_numbers.V62WP'), $useEnvPriority),
'V62KP' => $this->getConfigValue('dhl_account_v62kp', $this->getConfigValue('dhl_account_v62wp', config('dhl.account_numbers.V62KP'), $useEnvPriority), $useEnvPriority),
'V53PAK' => $this->getConfigValue('dhl_account_v53pak', config('dhl.account_numbers.V53PAK'), $useEnvPriority),
'V07PAK' => $this->getConfigValue('dhl_account_v07pak', config('dhl.account_numbers.V07PAK'), $useEnvPriority),
'default' => config('dhl.account_numbers.default'),
@ -103,7 +107,7 @@ class SettingController extends Controller
// Dimensions
'dimensions' => [
'V01PAK' => config('dhl.dimensions.V01PAK'),
'V62WP' => config('dhl.dimensions.V62WP'),
'V62KP' => config('dhl.dimensions.V62KP'),
'V53PAK' => config('dhl.dimensions.V53PAK'),
'V07PAK' => config('dhl.dimensions.V07PAK'),
'default' => config('dhl.dimensions.default'),
@ -136,6 +140,26 @@ class SettingController extends Controller
}
}
private function normalizeDhlProductCode(?string $productCode): string
{
return $productCode === 'V62WP' ? 'V62KP' : ($productCode ?: 'V01PAK');
}
/**
* @return string[]
*/
private function getDhlInternationalCountries(bool $useEnvPriority): array
{
$configCountries = config('dhl.international_countries', DhlProductResolver::DEFAULT_INTERNATIONAL_COUNTRIES);
$countries = $configCountries;
if (! $useEnvPriority) {
$countries = Setting::getContentBySlug('dhl_international_countries') ?: $configCountries;
}
return DhlProductResolver::normalizeCountryCodeList(is_array($countries) ? $countries : []);
}
/**
* Update DHL configuration cache after saving settings
*/

View file

@ -2,8 +2,9 @@
namespace App\Jobs;
use App\Models\DhlShipment;
use App\Services\DhlApiService;
use Acme\Dhl\Models\DhlShipment;
use App\Services\DhlTrackingService;
use DateTime;
use Exception;
use Illuminate\Bus\Queueable as BusQueueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -37,7 +38,7 @@ class TrackShipmentJob implements ShouldQueue
*
* @var int
*/
public $tries = 2; // Lower tries for tracking as it's less critical
public $tries = 2;
/**
* The maximum number of seconds the job can run before timing out.
@ -49,8 +50,7 @@ class TrackShipmentJob implements ShouldQueue
/**
* Create a new job instance.
*
* @param DhlShipment $dhlShipment
* @param array $options
* @param array<string, mixed> $options
*/
public function __construct(DhlShipment $dhlShipment, array $options = [])
{
@ -64,29 +64,28 @@ class TrackShipmentJob implements ShouldQueue
/**
* Execute the job.
*/
public function handle(): void
public function handle(DhlTrackingService $trackingService): void
{
try {
Log::info('[DHL Queue] Starting shipment tracking job', [
'shipment_id' => $this->dhlShipment->id,
'tracking_number' => $this->dhlShipment->tracking_number,
'dhl_shipment_no' => $this->dhlShipment->dhl_shipment_no,
'attempt' => $this->attempts(),
]);
$dhlService = new DhlApiService();
// Get tracking details
$trackingDetails = $dhlService->getTrackingDetails($this->dhlShipment);
$result = $trackingService->updateTrackingNow($this->dhlShipment, $this->options);
Log::info('[DHL Queue] Shipment tracking updated successfully', [
'shipment_id' => $this->dhlShipment->id,
'tracking_status' => $trackingDetails['status'] ?? 'unknown',
'events_count' => isset($trackingDetails['events']) ? count($trackingDetails['events']) : 0,
'success' => $result['success'] ?? false,
'tracking_status' => $result['tracking_status'] ?? 'unknown',
'tracking_completed' => $result['tracking_completed'] ?? false,
]);
// Schedule next tracking update if shipment is still in transit
if (isset($this->options['auto_retrack']) && $this->options['auto_retrack']) {
$status = $trackingDetails['status'] ?? '';
if (($this->options['auto_retrack'] ?? false) && ! ($result['tracking_completed'] ?? false)) {
$this->dhlShipment->refresh();
$status = $this->dhlShipment->status ?? '';
if ($this->shouldContinueTracking($status)) {
// Schedule next tracking in 2-6 hours based on current status
$nextTrackingDelay = $this->getNextTrackingDelay($status);
@ -103,7 +102,7 @@ class TrackShipmentJob implements ShouldQueue
} catch (Exception $e) {
Log::warning('[DHL Queue] Shipment tracking failed', [
'shipment_id' => $this->dhlShipment->id,
'tracking_number' => $this->dhlShipment->tracking_number,
'dhl_shipment_no' => $this->dhlShipment->dhl_shipment_no,
'error' => $e->getMessage(),
'attempt' => $this->attempts(),
'max_tries' => $this->tries,
@ -126,14 +125,12 @@ class TrackShipmentJob implements ShouldQueue
/**
* Handle a job failure.
*
* @param Exception $exception
*/
public function failed(Exception $exception): void
{
Log::warning('[DHL Queue] TrackShipmentJob permanently failed', [
'shipment_id' => $this->dhlShipment->id,
'tracking_number' => $this->dhlShipment->tracking_number,
'dhl_shipment_no' => $this->dhlShipment->dhl_shipment_no,
'error' => $exception->getMessage(),
]);
@ -142,29 +139,14 @@ class TrackShipmentJob implements ShouldQueue
/**
* Determine if we should continue tracking this shipment
*
* @param string $status
* @return bool
*/
private function shouldContinueTracking(string $status): bool
{
$finalStates = [
'delivered',
'delivered_to_recipient',
'delivered_to_pickup_location',
'returned_to_sender',
'cancelled',
'lost',
];
return !in_array(strtolower($status), $finalStates);
return ! in_array(strtolower($status), DhlShipment::TERMINAL_STATUSES, true);
}
/**
* Get delay for next tracking update based on current status
*
* @param string $status
* @return int Minutes until next tracking
*/
private function getNextTrackingDelay(string $status): int
{
@ -184,10 +166,8 @@ class TrackShipmentJob implements ShouldQueue
/**
* Determine the time at which the job should timeout.
*
* @return \DateTime
*/
public function retryUntil()
public function retryUntil(): DateTime
{
return now()->addMinutes(30); // Short timeout for tracking
}

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',
];
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();
}
}
}
foreach ($requiredAddressFields as $field => $label) {
if (empty(trim($shipmentData[$field] ?? ''))) {
$errors[] = "{$label} ist erforderlich.";
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,14 +2,14 @@
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
@ -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';
}
/**

View file

@ -49,6 +49,8 @@ return [
'print_format' => env('DHL_PRINT_FORMAT', 'A4'),
'retoure_print_format' => env('DHL_RETOURE_PRINT_FORMAT', 'A4'),
'profile' => env('DHL_PROFILE', 'STANDARD_GRUPPENPROFIL'),
'print_only_if_codeable' => env('DHL_PRINT_ONLY_IF_CODEABLE', true),
'international_countries' => array_values(array_filter(array_map('trim', explode(',', env('DHL_INTERNATIONAL_COUNTRIES', 'AT,ES'))))),
/*
|--------------------------------------------------------------------------
@ -93,7 +95,7 @@ return [
'account_numbers' => [
'default' => env('DHL_ACCOUNT_NUMBER_DEFAULT', '63144073550101'),
'V01PAK' => env('DHL_ACCOUNT_NUMBER_V01PAK', '63144073550101'), // DHL Paket National
'V62WP' => env('DHL_ACCOUNT_NUMBER_V62WP', '63144073556201'), // Warenpost National
'V62KP' => env('DHL_ACCOUNT_NUMBER_V62KP', '63144073556201'), // DHL Kleinpaket
'V53PAK' => env('DHL_ACCOUNT_NUMBER_V53PAK', '63144073555301'), // DHL Paket International
'V07PAK' => env('DHL_ACCOUNT_NUMBER_V07PAK', '63144073550701'), // DHL Retoure Online
],
@ -101,7 +103,7 @@ return [
'dimensions' => [
'default' => ['length' => 120, 'width' => 60, 'height' => 60],
'V01PAK' => ['length' => 120, 'width' => 60, 'height' => 60], // DHL Paket National
'V62WP' => ['length' => 35, 'width' => 25, 'height' => 8], // Warenpost National
'V62KP' => ['length' => 35, 'width' => 25, 'height' => 8], // DHL Kleinpaket
'V53PAK' => ['length' => 120, 'width' => 60, 'height' => 60], // DHL Paket International
'V07PAK' => ['length' => 120, 'width' => 60, 'height' => 60], // DHL Retoure Online
],

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (! Schema::hasColumn('dhl_package_shipments', 'reference')) {
Schema::table('dhl_package_shipments', function (Blueprint $table) {
$table->string('reference', 35)->nullable()->after('routing_code');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasColumn('dhl_package_shipments', 'reference')) {
Schema::table('dhl_package_shipments', function (Blueprint $table) {
$table->dropColumn('reference');
});
}
}
};

View file

@ -1,6 +1,6 @@
# Entwicklungskonzept DHL Modul
Stand: 13.05.2026
Stand: 27.05.2026
## Ziel
@ -168,6 +168,78 @@ Risiko: Statusspruenge direkt auf `out_for_delivery` oder besondere DHL-Statusco
4. Statusspruenge wie `created` direkt nach `out_for_delivery` abdecken.
5. Command `dhl:update-tracking` Tests fuer Mailausloeser und Nicht-Ausloeser ergaenzen.
### Phase 8: DHL-seitige Adressvalidierung ueber mustEncode / printOnlyIfCodable
Status: umgesetzt fuer deutsche Empfaengeradressen.
Ziel:
- Die bestehende formale Vorabpruefung bleibt erhalten.
- Bei der finalen DHL-Labelerstellung soll DHL selbst pruefen, ob die Adresse leitcodierbar bzw. versandfaehig ist.
- Dafuer soll der DHL-Query-Parameter `mustEncode=true` genutzt werden.
- `printOnlyIfCodable` ist laut DHL-Spezifikation der Legacy-Name dieser Funktion.
- Wenn DHL die Adresse ablehnt, wird kein Etikett erstellt.
- Die DHL-Fehlermeldung wird in das bestehende Modal zurueckgespiegelt, damit der Admin die Lieferadresse direkt korrigieren kann.
- Laut DHL-Beschreibung ist diese Pruefung nur fuer deutsche Empfaengeradressen relevant.
Geplanter Ablauf:
1. Admin oeffnet das DHL-Erstellungsmodal.
2. Die bestehende Vorabpruefung prueft weiterhin:
- Pflichtfelder
- PLZ-Format
- Produkt-/Zielland-Kombination
- Packstation-/Paketbox-Regeln
- einfache Plausibilitaet
3. Beim Klick auf `Sendung jetzt erstellen` wird die Sendung an DHL uebergeben.
4. Der DHL-Request enthaelt `mustEncode=true`.
5. DHL erstellt das Label nur, wenn die Adresse codeable/leitcodierbar ist.
6. Wenn DHL die Adresse ablehnt:
- es wird kein Shipment als erfolgreich erstellt markiert
- die DHL-Fehlermeldung wird normalisiert
- das Modal bleibt geoeffnet
- die Fehlermeldung erscheint unten in der Vorabpruefung
- der Admin kann die Adresse korrigieren und erneut pruefen/erstellen
Geplante technische Integrationspunkte:
- `packages/acme-laravel-dhl/src/Services/ShippingService.php`
- Create-Shipment-Request um den Query-Parameter `mustEncode=true` erweitern.
- Legacy-Begriff `printOnlyIfCodable` nur als Dokumentations-/Kompatibilitaetshinweis fuehren, nicht als neuen internen Optionsnamen verwenden.
- DHL-Fehlerantworten fuer nicht codeable Adressen strukturiert auswerten.
- `app/Services/DhlDataHelper.php`
- Option aus Konfiguration/Settings fuer die Request-Erstellung vorbereiten.
- `app/Services/DhlShipmentService.php`
- DHL-Adressfehler als fachliche Validierungsfehler behandeln, nicht als generischen Systemfehler.
- `app/Http/Controllers/DhlShipmentController.php`
- Fehlerantwort im JSON so zurueckgeben, dass das Modal sie anzeigen kann.
- `resources/views/admin/dhl/modal_create_shipment.blade.php`
- DHL-Fehler bei Labelerstellung in der bestehenden Vorabpruefungsbox anzeigen.
- Keine separate Browser-Alert-Meldung.
- `config/dhl.php` und/oder DHL-Settings
- Einstellung z. B. `print_only_if_codeable` vorbereiten.
- Standard fachlich klaeren: fuer Produktion bevorzugt aktiv, fuer Tests/Sandbox ggf. konfigurierbar.
Fachliche Entscheidung:
- Die formale Pruefung bleibt vor DHL bestehen, damit offensichtliche Fehler frueh im Modal sichtbar sind.
- `mustEncode=true` ist die finale DHL-seitige Absicherung direkt bei der Labelerstellung.
- Eine abgelehnte DHL-Adresse blockiert die Labelerstellung.
- Provider-Ausfall oder API-Fehler muss von einer fachlichen DHL-Adressablehnung unterscheidbar sein.
- Die Fehlermeldung soll fuer Admins handlungsorientiert sein, z. B. `DHL kann diese Adresse nicht leitcodieren. Bitte Straße, Hausnummer, PLZ und Ort pruefen.`
- Fuer nicht-deutsche Empfaengeradressen darf `mustEncode` nicht als vollwertige externe Adressvalidierung dargestellt werden, solange DHL diese Einschraenkung in der API-Dokumentation nennt.
Offene Klaerung nach Implementierung:
- Verhalten in Sandbox und Produktion.
- Welche DHL-Status-/Fehlercodes fuer nicht codeable Adressen zurueckkommen.
- Ob `mustEncode` fuer alle genutzten Produkte gilt:
- `V01PAK`
- `V62KP`
- `V53PAK`
- Ob internationale Sendungen ignoriert werden, Warnungen liefern oder anders behandelt werden.
- Ob bestehende manuelle Admin-Uebersteuerung fachlich erlaubt sein soll oder DHL-Ablehnung immer hart blockiert.
## Empfohlene Reihenfolge
1. `V62WP` -> `V62KP`, Statuswerte und `TrackShipmentJob` korrigieren.
@ -176,6 +248,7 @@ Risiko: Statusspruenge direkt auf `out_for_delivery` oder besondere DHL-Statusco
4. Referenzfeld und Gewichtskorrektur umsetzen.
5. Adressvalidierung integrieren.
6. Tracking-Anzeigen und Mailregeln final testen.
7. DHL-seitige Adressvalidierung ueber `mustEncode=true` integrieren, sobald API-Verhalten und Fehlerrueckgaben geklaert sind.
## Teststrategie
@ -185,6 +258,768 @@ Risiko: Statusspruenge direkt auf `out_for_delivery` oder besondere DHL-Statusco
- Regression-Test fuer `V62KP` Payload.
- Command-Test fuer `dhl:update-tracking --send-emails`.
## Entwicklungsprotokoll
Dieser Abschnitt dokumentiert die tatsaechlich umgesetzten Entwicklungsschritte. Jede Phase wird hier nach Abschluss mit Ziel, betroffenen Dateien, fachlicher Entscheidung und Verifikation ergaenzt.
### 27.05.2026 - Phase 1: Pflichtkorrekturen vor DHL-Frist
Status: abgeschlossen.
Ziel:
- Neue DHL-Labels duerfen nicht mehr mit `V62WP` Warenpost erstellt werden.
- `V62KP` DHL Kleinpaket wird als neues Produkt fuer nationale Kleinpaket-Sendungen eingefuehrt.
- Historische Sendungen mit `V62WP` bleiben im System lesbar.
Umsetzung:
- `config/dhl.php`
- `DHL_ACCOUNT_NUMBER_V62KP` als neuer Konfigurationskey eingefuehrt.
- `V62KP` in `account_numbers` und `dimensions` aufgenommen.
- `V62WP` aus der produktiven Konfiguration fuer neue Label entfernt.
- `app/Http/Controllers/SettingController.php`
- DHL-Konfiguration liefert nun `account_numbers.V62KP` und `dimensions.V62KP`.
- Altes Datenbank-Setting `dhl_account_v62wp` wird nur noch als Fallback genutzt, falls `dhl_account_v62kp` noch nicht gepflegt ist.
- Altes Default-Produkt `V62WP` wird beim Lesen auf `V62KP` normalisiert.
- `resources/views/admin/settings/index.blade.php`
- Admin-Auswahl fuer Standard-Produkt von `V62WP - Warenpost National` auf `V62KP - DHL Kleinpaket` umgestellt.
- Admin-Feld `dhl_account_v62kp` eingefuehrt.
- Bestehender Wert aus `dhl_account_v62wp` wird im Formular als Fallback angezeigt, damit Bestandskonfigurationen nicht leer starten.
- `app/Services/DhlModalService.php`
- Produktauswahl im DHL-Cockpit bietet `V62KP - DHL Kleinpaket` statt `V62WP - DHL Warenpost National` an.
- Fallback-Produktsatz ebenfalls auf `V62KP` umgestellt.
- `packages/acme-laravel-dhl/src/Services/ShippingService.php`
- Validierung fuer neue Label erlaubt `V62KP`.
- `V62WP` wird fuer neue Label abgelehnt.
- Billing-Nummer-Aufloesung nutzt automatisch den neuen Setting-Key `dhl_account_v62kp`.
- `resources/lang/de/dhl.php`, `resources/lang/en/dhl.php`, `resources/lang/es/dhl.php`, `resources/lang/fr/dhl.php`
- `V62KP` als DHL Kleinpaket ergaenzt.
- `V62WP` als Legacy-Anzeige belassen, damit historische Sendungen weiterhin verstaendlich dargestellt werden.
- `tests/Pest.php`
- TestCase-Bootstrapping fuer `tests/Unit/Dhl` registriert.
- `tests/Unit/Dhl/ShippingServiceProductCodeTest.php`
- Neuer Regression-Test fuer Phase 1 ergaenzt.
Fachliche Entscheidung:
- `V62WP` wird nicht hart aus allen Anzeigen entfernt, weil bestehende DHL-Sendungen mit diesem Produktcode historisch nachvollziehbar bleiben muessen.
- Neue Labelerstellung blockiert `V62WP` bewusst ueber die `ShippingService`-Validierung.
- Eine Datenmigration fuer bestehende Settings wurde noch nicht angelegt. Stattdessen wird `dhl_account_v62wp` temporaer als Fallback verwendet. Eine spaetere Migration kann den Wert dauerhaft nach `dhl_account_v62kp` ueberfuehren.
Verifikation:
- `./vendor/bin/pint --dirty --format agent`
- `php artisan test --compact tests/Unit/Dhl/ShippingServiceProductCodeTest.php`
- Ergebnis: 3 Tests bestanden, 5 Assertions.
- IDE/Linter-Pruefung der geaenderten Dateien:
- Ergebnis: keine Fehler.
Offene Folgepunkte:
- Phase 2: `TrackShipmentJob` auf aktuelles DHL-Model und aktuellen `DhlTrackingService` umstellen. Erledigt am 27.05.2026.
- Phase 2: Storno-Statuswerte zwischen `canceled` und `cancelled` vereinheitlichen. Erledigt am 27.05.2026.
- Optional: Migration oder Admin-Hinweis fuer das alte Setting `dhl_account_v62wp`, sobald die Fallback-Phase beendet werden soll.
### 27.05.2026 - Phase 2: Tracking-Job und Storno-Status stabilisiert
Status: abgeschlossen fuer Tracking-Job, Statusvereinheitlichung und strukturierte Storno-Fehlerbasis.
Ziel:
- Asynchrones Tracking darf nicht mehr auf das alte, nicht mehr produktive DHL-Model `App\Models\DhlShipment` zugreifen.
- Storno-Statuswerte werden intern auf `canceled` vereinheitlicht.
- Alte Daten mit `cancelled` bleiben lesbar und filterbar.
- Storno-Fehler werden fachlich verwertbarer im Shipment protokolliert.
Umsetzung:
- `app/Jobs/TrackShipmentJob.php`
- Import von `App\Models\DhlShipment` auf `Acme\Dhl\Models\DhlShipment` umgestellt.
- Abhaengigkeit auf den alten `DhlApiService` entfernt.
- Job nutzt nun `DhlTrackingService::updateTrackingNow()` ueber Laravel-Dependency-Injection im `handle()`-Methodenparameter.
- Queue-Tracking fuehrt dadurch synchron innerhalb des Jobs aus und dispatcht nicht erneut rekursiv in dieselbe Queue.
- Logging verwendet jetzt `dhl_shipment_no` statt des alten/inkonsistenten `tracking_number`.
- `app/Services/DhlTrackingService.php`
- `updateTrackingNow()` ergaenzt, um Tracking bewusst ohne Queue-Dispatch auszufuehren.
- Bestehender Controller-/Service-Pfad `updateTracking()` bleibt unveraendert und entscheidet weiterhin anhand der DHL-Konfiguration zwischen sync/async.
- `packages/acme-laravel-dhl/src/Models/DhlShipment.php`
- `cancelled` als Legacy-Alias fuer `canceled` eingefuehrt.
- Statusuebersetzung und Badge-Klasse normalisieren alte `cancelled`-Werte auf `canceled`.
- Terminal-Statusliste enthaelt `canceled` und `cancelled`, damit Alt-Datensaetze nicht weiter getrackt werden.
- `packages/acme-laravel-dhl/src/Services/ShippingService.php`
- Erfolgreiche Stornierung setzt weiterhin intern `status = canceled`.
- Erfolgreiche Storno-Antwort wird unter `api_response_data.cancellation` gespeichert.
- Fehlgeschlagene Stornos werden unter `api_response_data.cancellation_error` strukturiert gespeichert:
- `status`
- `http_status`
- `dhl_code`
- `detail`
- `exception_class`
- `occurred_at`
- `app/Services/DhlShipmentService.php`
- Lokale Storno-Validierungsfehler wie fehlende DHL-Sendungsnummer oder nicht stornierbarer Status werden ebenfalls in `api_response_data.cancellation_error` protokolliert.
- Admin-Feedback fuer nicht stornierbaren Status nutzt die uebersetzte Statusbezeichnung.
- `app/Http/Controllers/DhlShipmentController.php`
- Statusfilter normalisiert `cancelled` auf `canceled`.
- Bei Filter `canceled` werden neue `canceled`- und alte `cancelled`-Sendungen gemeinsam gefunden.
- DataTable-Badges verwenden intern `canceled`.
- `resources/views/admin/dhl/cockpit.blade.php`
- Statusfilter zeigt `canceled` als neuen Wert fuer "Storniert".
- Alte URL-/Request-Werte mit `cancelled` bleiben im Select kompatibel.
- `resources/views/admin/dhl/show.blade.php`
- Detailansicht behandelt `canceled` und `cancelled` gleich.
- Aktionsbereich wird fuer beide Storno-Statuswerte ausgeblendet.
- `resources/views/public/tracking.blade.php`
- Public-Tracking behandelt `canceled` und `cancelled` gleich.
- `resources/lang/de/dhl.php`, `resources/lang/en/dhl.php`, `resources/lang/es/dhl.php`, `resources/lang/fr/dhl.php`
- Neuer Statuskey `canceled` ergaenzt.
- Legacy-Key `cancelled` bleibt erhalten.
- `tests/Unit/Dhl/DhlShipmentStatusTest.php`
- Neue Tests fuer Statusnormalisierung, Uebersetzung und Badge-Klasse.
- `tests/Unit/Dhl/TrackShipmentJobTest.php`
- Neuer Test, dass der Queue-Job den aktuellen `DhlTrackingService` nutzt.
Fachliche Entscheidung:
- Intern gilt ab jetzt `canceled` als kanonischer DHL-Storno-Status.
- `cancelled` wird nicht migriert oder entfernt, weil es in bestehenden Daten/URLs/Views vorkommen kann und weiterhin verstaendlich angezeigt werden soll.
- Storno-Fehler werden zunaechst in `api_response_data` gespeichert, um keine neue Datenbankmigration fuer den ersten Stabilisierungsschritt zu erzwingen. Falls spaeter gezielte Admin-Auswertungen gebraucht werden, koennen dedizierte Spalten oder eine eigene Fehler-Tabelle folgen.
Verifikation:
- `./vendor/bin/pint --dirty --format agent`
- `php artisan test --compact tests/Unit/Dhl`
- Ergebnis: 6 Tests bestanden, 12 Assertions.
- IDE/Linter-Pruefung der geaenderten Dateien:
- Ergebnis: keine Fehler.
Offene Folgepunkte:
- Phase 2 Rest: Tests fuer echte Storno-API-Fehler mit HTTP-Fakes/Mocking weiter ausbauen.
- Phase 2 Rest: Admin-Detailanzeige fuer gespeicherte `api_response_data.cancellation_error` bei Bedarf sichtbar machen.
- Phase 3: Internationalen Produkt-/Billing-Resolver einfuehren. Erledigt am 27.05.2026.
### 27.05.2026 - Phase 3: Internationaler Produkt- und Laenderresolver
Status: abgeschlossen fuer zentrale Produkt-/Laenderentscheidung, initiale Laenderfreigabe und harten Zielland-Fallback-Stopp.
Ziel:
- Produktcode, Zielland und DHL-Laendercode werden zentral entschieden.
- Deutschland nutzt fuer neue Label nur erlaubte nationale Produkte `V01PAK` oder `V62KP`.
- Oesterreich und Spanien nutzen initial `V53PAK`.
- Unbekannte oder nicht freigegebene Ziellaender duerfen nicht mehr still auf `DEU` fallen.
- Das DHL-Cockpit schlaegt den Produktcode anhand des Ziellands vor, laesst aber erlaubte Admin-Korrekturen zu.
Umsetzung:
- `app/Services/DhlProductResolver.php`
- Neuer zentraler Resolver fuer DHL-Produkt- und Laenderentscheidungen.
- Regeln initial:
- `DE`: erlaubt `V01PAK`, `V62KP`
- `AT`: erlaubt `V53PAK`
- `ES`: erlaubt `V53PAK`
- DHL-Laendercode-Konvertierung zentralisiert, z. B. `DE -> DEU`, `AT -> AUT`, `ES -> ESP`.
- Unbekannte Laendercodes loesen eine klare Exception aus statt auf Deutschland zurueckzufallen.
- Explizit falsch gewaehlte Produkt-/Laender-Kombinationen werden abgelehnt.
- Fehlende Billing-Nummern werden ueber `assertBillingNumber()` fachlich klar abgebrochen.
- `app/Services/DhlDataHelper.php`
- Empfaengerland ist jetzt Pflicht fuer die DHL-Datenaufbereitung.
- Produktcode wird ueber `DhlProductResolver::resolveForShipment()` bestimmt.
- Der alte Empfaengerland-Fallback auf `DE` wurde entfernt.
- Dimensionen werden anhand des aufgeloesten Produktcodes gelesen.
- `packages/acme-laravel-dhl/src/Services/ShippingService.php`
- Payload-Erstellung nutzt den Resolver fuer Zielland und Produktcode.
- Billing-Nummer wird gegen den aufgeloesten Produktcode validiert.
- `convertCountryCode()` nutzt den Resolver und gibt keinen `DEU`-Fallback mehr zurueck.
- Normale Empfaengeradressen und Packstation/Paketbox-Payloads verwenden die gleiche harte Laendercode-Validierung.
- `app/Services/DhlModalService.php`
- Modal-Daten enthalten nun `productSuggestions` und `selectedProductCode`.
- Initialer Produktvorschlag wird aus dem Zielland abgeleitet.
- Servervalidierung prueft Produkt-/Zielland-Kombinationen ueber den Resolver.
- `resources/views/admin/dhl/modal_in_order_shipment.blade.php`
- Produktauswahl markiert den vorgeschlagenen Produktcode.
- Laenderoptionen tragen den ISO-Code als `data-country-code`.
- Hinweistext ergaenzt, dass der Produktcode anhand des Ziellands vorgeschlagen wird.
- `resources/views/admin/dhl/modal_create_shipment.blade.php`
- Bei Ziellandwechsel wird der passende Produktvorschlag automatisch gesetzt.
- `app/Http/Controllers/ModalController.php`
- Fallback-Daten fuer das DHL-Modal um Produktvorschlaege und Default-Produkt ergaenzt.
- `tests/Unit/Dhl/DhlProductResolverTest.php`
- Neue Tests fuer Deutschland, Oesterreich, Spanien, nicht freigegebene Laender, unbekannte Laendercodes und fehlende Billing-Nummern.
- `tests/Unit/Dhl/ShippingServiceProductCodeTest.php`
- Payload-Test fuer internationales Paket nach Oesterreich ergaenzt.
- Regression-Test ergaenzt, dass nicht freigegebene Laender nicht auf Deutschland fallen.
Fachliche Entscheidung:
- `DE`, `AT` und `ES` sind die initial freigegebenen DHL-Versandlaender fuer diese Phase.
- Weitere Laender werden nicht implizit erlaubt, auch wenn eine ISO-Konvertierung technisch bekannt ist. Sie muessen fachlich freigegeben und im Resolver ergaenzt werden.
- Falls im Backend kein Produktcode explizit uebergeben wird, kann der Resolver fuer `AT`/`ES` automatisch `V53PAK` vorschlagen. Wenn ein Admin explizit ein nicht erlaubtes Produkt waehlt, wird die Labelerstellung serverseitig abgelehnt.
- Der alte `DEU`-Fallback wurde bewusst entfernt, weil ein falsches Zielland zu falschen Labels und kostenpflichtigen Stornos fuehren kann.
Verifikation:
- `./vendor/bin/pint --dirty --format agent`
- `php artisan test --compact tests/Unit/Dhl`
- Ergebnis: 15 Tests bestanden, 32 Assertions.
- IDE/Linter-Pruefung der geaenderten Dateien:
- Ergebnis: keine Fehler.
Offene Folgepunkte:
- Phase 3 Erweiterung: Weitere Laender erst nach fachlicher Freigabe in `DhlProductResolver` aufnehmen.
- Phase 3 Erweiterung: Optional Admin-Setting fuer freigegebene internationale Ziellaender einfuehren.
- Phase 4: Adressvalidierung vor Labelerstellung integrieren. Erledigt als formale Basisvalidierung am 27.05.2026.
### 27.05.2026 - Phase 4: Formale Adressvalidierung vor Labelerstellung
Status: abgeschlossen fuer serverseitige Basisvalidierung, Warn-/Fehlerstatus und Cockpit-Vorpruefung. Externe postalische Validierung ist noch offen.
Ziel:
- Vor der Labelerstellung wird eine DHL-Adresse serverseitig bewertet.
- Offensichtlich nicht versandfaehige Adressen blockieren die Labelerstellung.
- Pruefbeduerftige Adressen erzeugen Warnungen und muessen im Cockpit bewusst bestaetigt werden.
- Packstation/Paketbox-Faelle werden strenger validiert.
- Die bestehende Labelerstellung bleibt auch serverseitig abgesichert, falls die UI-Pruefung umgangen wird.
Umsetzung:
- `app/Services/DhlAddressValidator.php`
- Neuer zentraler Validator fuer formale DHL-Adresspruefung.
- Rueckgabeformat:
- `status`: `valid`, `warning` oder `error`
- `can_create_label`: true/false
- `errors`
- `warnings`
- `normalized`
- Blockierende Pruefungen:
- Pflichtfelder fuer Strasse, PLZ, Ort und Land.
- Name/Firma muss vorhanden sein.
- Zielland muss im `DhlProductResolver` freigegeben sein.
- PLZ-Format fuer `DE`, `AT`, `ES`.
- Packstation/Paketbox nur fuer Deutschland.
- DHL Postnummer muss bei Packstation/Paketbox vorhanden und 6-10-stellig sein.
- Packstation-/Paketbox-Nummer muss vorhanden und 3-stellig im Bereich 100-999 sein.
- Warnungen:
- Telefonnummer fehlt.
- E-Mail-Adresse fehlt.
- Hausnummer enthaelt keine Ziffer.
- Postnummer ist gesetzt, aber Strasse/Nr. sieht nicht nach Packstation/Paketbox aus.
- `app/Services/DhlModalService.php`
- Bestehende Modal-Adresspruefung nutzt nun den neuen `DhlAddressValidator`.
- `validateShipmentData()` fuehrt die Adressvalidierung auch serverseitig vor der Labelerstellung aus.
- `app/Http/Controllers/DhlShipmentController.php`
- Neuer Endpoint `validateAddress()`.
- Antwortet mit Status, Fehlern, Warnungen und `can_create_label`.
- Gibt HTTP 422 zurueck, wenn die Adresse nicht label-faehig ist.
- `routes/domains/crm.php`
- Neue Route `POST /admin/dhl/validate-address` mit Name `admin.dhl.validate-address`.
- `resources/views/admin/dhl/modal_create_shipment.blade.php`
- Modal ruft vor der eigentlichen Labelerstellung den neuen Validierungsendpunkt auf.
- Bei Fehlern wird die Labelerstellung blockiert.
- Bei Warnungen muss der Admin bewusst bestaetigen, bevor das Label erstellt wird.
- `tests/Unit/Dhl/DhlAddressValidatorTest.php`
- Neue Tests fuer:
- formal gueltige Adresse
- nicht freigegebenes Zielland
- falsches PLZ-Format fuer aktiviertes Land
- Warnstatus ohne Blockade
- gueltige Packstation
- ungueltige Packstation-Postnummer
- fehlende Packstation-Pflichtdaten
Fachliche Entscheidung:
- Diese Phase implementiert noch keine echte postalische Existenzpruefung von Strasse/PLZ/Ort.
- Der Validator verhindert aber bereits die teuersten formalen Fehler vor Labelerstellung.
- Warnungen blockieren nicht automatisch, weil Admins im Cockpit bewusst korrigierte oder fachlich bekannte Sonderfaelle versenden koennen sollen.
- Die spaetere Provider-Integration kann hinter dem gleichen `DhlAddressValidator` ergaenzt werden.
Verifikation:
- `./vendor/bin/pint --dirty --format agent`
- `php artisan test --compact tests/Unit/Dhl`
- Ergebnis: 22 Tests bestanden, 50 Assertions.
- IDE/Linter-Pruefung der geaenderten Dateien:
- Ergebnis: keine Fehler.
Offene Folgepunkte:
- Phase 4 Erweiterung: DHL DATAFACTORY AUTOCOMPLETE 2.0 fuer `DE`/`AT`/`CH` fachlich und technisch evaluieren.
- Phase 4 Erweiterung: Falls DHL DATAFACTORY nicht fuer alle benoetigten Laender reicht, externen Provider wie Loqate, Google Address Validation oder HERE bewerten.
- Phase 4 Erweiterung: Validierungsergebnis optional dauerhaft an Shipment/Order protokollieren.
- Phase 5: Referenzfeld und Admin-UX umsetzen. Erledigt am 27.05.2026.
### 27.05.2026 - Phase 5: Referenzfeld und Admin-UX
Status: abgeschlossen.
Ziel:
- Admins koennen im DHL-Cockpit eine eigene Versandreferenz setzen.
- Die Referenz wird als DHL `refNo` an die Shipping API uebergeben.
- Die Referenz wird an der Sendung gespeichert und in der Detailansicht angezeigt.
- Das DHL-Laengenlimit von 35 Zeichen wird eingehalten.
- Ohne Admin-Eingabe bleibt der bisherige Fallback `Order-{id}` erhalten.
Umsetzung:
- `database/migrations/2026_05_27_120253_add_reference_to_dhl_package_shipments_table.php`
- Neue nullable Spalte `reference` mit Laenge 35 fuer `dhl_package_shipments`.
- Migration prueft `Schema::hasColumn`, damit sie robust gegen bereits vorhandene Spalten bleibt.
- `packages/acme-laravel-dhl/src/Models/DhlShipment.php`
- `reference` in `$fillable` aufgenommen.
- `resources/views/admin/dhl/modal_in_order_shipment.blade.php`
- Neues Feld `reference` in der Sendungskonfiguration.
- Default-Wert: `Order-{id}`.
- `maxlength="35"` und Hilfetext zum DHL `refNo`.
- `resources/views/admin/dhl/modal_create_shipment.blade.php`
- Clientseitige Validierung fuer maximal 35 Zeichen ergaenzt.
- `app/Http/Controllers/DhlShipmentController.php`
- Servervalidierung fuer `reference` mit `max:35`.
- Uebergibt `reference` in die Optionen fuer `DhlShipmentService`.
- `app/Services/DhlDataHelper.php`
- Uebernimmt `reference` oder alternativ `shipment_reference` aus den Optionen.
- Normalisiert Leerraum.
- Kuerzt programmgesteuert auf 35 Zeichen.
- Nutzt `Order-{id}` als Fallback, wenn keine Referenz gesetzt ist.
- `packages/acme-laravel-dhl/src/Services/ShippingService.php`
- Bestehendes Mapping nach DHL `refNo` bleibt aktiv.
- Gesendete Referenz wird beim Erstellen des `DhlShipment`-Datensatzes gespeichert.
- `resources/views/admin/dhl/show.blade.php`
- Referenz wird in den Sendungsinformationen angezeigt.
- `tests/Unit/Dhl/DhlDataHelperReferenceTest.php`
- Neue Tests fuer Admin-Referenz, Fallback `Order-{id}` und 35-Zeichen-Normalisierung.
Fachliche Entscheidung:
- Die Referenz bleibt bewusst ein kurzes Freitextfeld und wird nicht an Bestellnotizen oder interne Kommentare gekoppelt.
- Das Feld wird fuer DHL `refNo` und spaetere Nachvollziehbarkeit genutzt, nicht als internes Memo.
- Der Fallback `Order-{id}` bleibt erhalten, damit bestehende Prozesse ohne manuelle Referenz unveraendert funktionieren.
Verifikation:
- `./vendor/bin/pint --dirty --format agent`
- `php artisan test --compact tests/Unit/Dhl`
- Ergebnis: 25 Tests bestanden, 54 Assertions.
- IDE/Linter-Pruefung der geaenderten Dateien:
- Ergebnis: keine Fehler.
Offene Folgepunkte:
- Datenbankmigration vor Nutzung in der Zielumgebung ausfuehren.
- Phase 6: DHL-Gewicht korrekt berechnen.
### 27.05.2026 - Abgleich DHL Geschaeftskundenportal: Aktivierte Dienste
Status: dokumentiert als fachlicher Abgleich vor Phase 6.
Quelle:
- Screenshot aus dem DHL Geschaeftskundenportal vom 27.05.2026 mit aktivierten Produkten und Abrechnungsnummern.
Aktivierte Dienste laut Portal:
- `63144073550101` - DHL Paket GKP
- `63144073550701` - DHL Retoure Online
- `63144073555301` - DHL Paket International GKP
- `63144073555302` - DHL Retoure Int. A
- `63144073556201` - Warenpost National / DHL Kleinpaket
- `63144073556601` - Warenpost International
- `63144073550801` - DHL Retoure MAUL
Abgleich mit aktueller Implementierung:
- `DHL Paket GKP`
- Aktueller Produktcode im Modul: `V01PAK`
- Konfiguriert in `config/dhl.php` als `DHL_ACCOUNT_NUMBER_V01PAK`.
- Wird fuer nationale Paketsendungen genutzt.
- Status: abgedeckt.
- `DHL Paket International GKP`
- Aktueller Produktcode im Modul: `V53PAK`
- Konfiguriert in `config/dhl.php` als `DHL_ACCOUNT_NUMBER_V53PAK`.
- Wird aktuell nur fuer fachlich freigegebene Ziellaender `AT` und `ES` genutzt.
- Status: technisch abgedeckt, Laenderfreigabe bleibt bewusst begrenzt.
- `Warenpost National / DHL Kleinpaket`
- Aktueller Produktcode im Modul: `V62KP`.
- Konfiguriert in `config/dhl.php` als `DHL_ACCOUNT_NUMBER_V62KP`.
- Ersetzt das alte `V62WP`.
- Status: abgedeckt.
- `DHL Retoure Online`
- Konfiguriert in `config/dhl.php` als `DHL_ACCOUNT_NUMBER_V07PAK`.
- Status: Konto ist konfiguriert. Der bestehende `ReturnsService` nutzt in Teilen noch Fallback-Logik und muss vor produktiver Retourennutzung separat gegen das aktive Retoure-Konto geprueft werden.
- `DHL Retoure Int. A`
- Konto im Portal aktiv.
- Im Modul aktuell nicht als eigenstaendiger internationaler Retourenprozess modelliert.
- Status: offen, eigener Folgepunkt.
- `Warenpost International`
- Konto im Portal aktiv.
- Im Modul aktuell nicht freigeschaltet. Laut DHL API ist dafuer `V66WPI` relevant; dafuer sind Zoll-/CN22-Daten und eigene Gewichts-/Laenderregeln erforderlich.
- Status: bewusst nicht Teil der bisherigen Phasen.
- `DHL Retoure MAUL`
- Konto im Portal aktiv.
- Im Modul aktuell nicht modelliert.
- Status: offen, nur nach fachlichem Bedarf umsetzen.
Technische Entscheidung:
- Fuer Phase 6 wird nur das Gewicht fuer die bereits freigegebenen Ausgangsprodukte betrachtet: `V01PAK`, `V53PAK`, `V62KP`.
- Internationale Warenpost (`V66WPI`) wird nicht stillschweigend aktiviert, obwohl ein Konto im Portal sichtbar ist. Die Produktart benoetigt separate Regeln und ggf. Zollangaben.
- Internationale Retouren und Retoure MAUL werden nicht mit bestehenden Paket-/Retoure-Fallbacks vermischt.
Neue Folgepunkte aus dem Portal-Abgleich:
- Retourenlogik separat gegen aktives `DHL Retoure Online` Konto pruefen und ggf. `V07PAK`/Returns-API sauber modellieren.
- Internationales Retourenprodukt `DHL Retoure Int. A` fachlich klaeren.
- `Warenpost International`/`V66WPI` nur als eigene Phase ergaenzen, wenn Zoll-, Gewichts- und Laenderregeln geklaert sind.
- `DHL Retoure MAUL` nur bei konkretem Prozessbedarf aufnehmen.
### 27.05.2026 - Nachtrag vor Phase 6: Internationale Paketlaender per DHL-Settings steuerbar
Status: abgeschlossen.
Ziel:
- Kunden/Admins koennen selbst entscheiden, welche Ziellaender fuer `DHL Paket International` aktiv sind.
- Die Freigabe erfolgt im bestehenden Settings-Bereich ueber Checkboxen.
- Der `DhlProductResolver` nutzt die gespeicherten Laender dynamisch statt einer fest kodierten `AT`/`ES`-Liste.
- Deutschland bleibt separat geregelt und wird nicht als internationales Paketland gespeichert.
Umsetzung:
- `config/dhl.php`
- Neuer Fallback `international_countries` aus `DHL_INTERNATIONAL_COUNTRIES`.
- Default bleibt `AT,ES`.
- `app/Http/Controllers/SettingController.php`
- `getDhlConfig()` liefert nun `international_countries`.
- Bei `DHL_CONFIG_SOURCE=env` wird keine Datenbankabfrage fuer diese Liste ausgefuehrt.
- Bei Datenbankprioritaet wird `dhl_international_countries` aus den Settings gelesen.
- `resources/views/admin/settings/index.blade.php`
- Neuer Checkbox-Bereich `DHL Paket International Ziellaender`.
- Es werden aktive App-Laender angezeigt, deren ISO-Code im DHL-Resolver bekannt ist.
- Speicherung als Setting `dhl_international_countries` vom Typ `object`.
- `app/Services/DhlProductResolver.php`
- Feste internationale Liste durch `getSupportedInternationalCountries()` ersetzt.
- Resolver liest je nach `DHL_CONFIG_SOURCE` entweder Config/ENV oder Datenbank-Setting.
- `normalizeCountryCodeList()` normalisiert, dedupliziert und filtert unbekannte Codes sowie `DE`.
- Produktvorschlaege fuer das Modal werden dynamisch aus der aktivierten Laenderliste erzeugt.
- `app/Http/Controllers/ModalController.php`
- Fallback-Daten fuer das DHL-Modal nutzen nun ebenfalls dynamische Produktvorschlaege.
- `tests/Unit/Dhl/DhlProductResolverTest.php`
- Tests fuer konfigurierbare internationale Laender ergaenzt.
- Tests fuer Normalisierung und Filterung der Laenderliste ergaenzt.
Fachliche Entscheidung:
- Die Checkboxen aktivieren nur `V53PAK` fuer DHL Paket International.
- `V66WPI` Warenpost International bleibt davon unberuehrt und wird nicht versehentlich aktiviert.
- `DE` wird nicht als internationales Zielland zugelassen, weil nationale Sendungen ueber `V01PAK`/`V62KP` laufen.
Verifikation:
- `./vendor/bin/pint --dirty --format agent`
- `php artisan test --compact tests/Unit/Dhl`
- Ergebnis: 27 Tests bestanden, 60 Assertions.
- IDE/Linter-Pruefung der geaenderten Dateien:
- Ergebnis: keine Fehler.
### 27.05.2026 - Phase 6: DHL-Gewicht korrekt berechnen
Status: abgeschlossen.
Ziel:
- Das DHL-Labelgewicht beruecksichtigt Kompensationsprodukte.
- Die bestehende Warenkorb-, Versandkosten- und Kompensationslogik bleibt unveraendert.
- Admins koennen das Gewicht weiterhin im Modal erhoehen, aber nicht unter das berechnete DHL-Mindestgewicht druecken.
- Produktbezogene DHL-Gewichtsgrenzen werden geprueft.
Umsetzung:
- `app/Services/DhlShipmentWeightCalculator.php`
- Neuer zentraler Gewichtsdienst fuer DHL-Labelerstellung.
- Basisgewicht ist `ShoppingOrder->weight` in Gramm.
- Fuer `shopping_order_items` mit `comp > 0` wird das verknuepfte `Product->weight` je `qty` addiert.
- Falls kein Gewicht vorhanden ist, wird ein sicherer Fallback von `1.0 kg` genutzt.
- Rundung erfolgt auf drei Nachkommastellen.
- Produktlimits:
- `V01PAK`: 31.5 kg
- `V53PAK`: 31.5 kg
- `V62KP`: 1.0 kg
- `app/Services/DhlModalService.php`
- Laedt Bestellpositionen jetzt mit Produktrelation.
- Modal-Gewicht nutzt den neuen Kalkulator.
- Validierung prueft das Gewicht gegen das gewaehlte DHL-Produkt.
- `app/Http/Controllers/DhlShipmentController.php`
- Vor Labelerstellung wird das tatsaechliche Versandgewicht auf mindestens das berechnete DHL-Gewicht gesetzt.
- Ein manuell hoeheres Gewicht aus dem Formular bleibt erhalten.
- `app/Services/DhlShipmentService.php`
- Ermittelt serverseitig ebenfalls das Mindestgewicht, damit Queue- und Direktaufrufe abgesichert sind.
- `packages/acme-laravel-dhl/src/Services/ShippingService.php`
- Prueft produktbezogene DHL-Gewichtsgrenzen vor Payload-Erstellung.
- `resources/views/admin/dhl/modal_in_order_shipment.blade.php`
- Hinweistext am Gewichtsfeld stellt klar, dass Kompensationsprodukte eingerechnet sind.
- `tests/Unit/Dhl/DhlShipmentWeightCalculatorTest.php`
- Neue Tests fuer:
- Basisgewicht aus Bestellung
- Addition von Kompensationsprodukt-Gewichten
- Fallbackgewicht
- `V62KP`-Gewichtslimit
- erlaubtes Paketgewicht fuer `V01PAK`
Fachliche Entscheidung:
- Die Berechnung wirkt nur auf das DHL-Labelgewicht.
- `ShoppingOrder->weight` und die bestehende Checkout-/Versandkostenlogik werden nicht veraendert.
- Kompensationsartikel werden nur dann addiert, wenn `comp > 0` und ein Produktgewicht vorhanden ist.
- `V62KP` wird mit 1.0 kg begrenzt, damit Kleinpaket nicht versehentlich fuer schwerere Sendungen genutzt wird.
Verifikation:
- `./vendor/bin/pint --dirty --format agent`
- `php artisan test --compact tests/Unit/Dhl`
- Ergebnis: 32 Tests bestanden, 66 Assertions.
- IDE/Linter-Pruefung der geaenderten Dateien:
- Ergebnis: keine Fehler.
### 27.05.2026 - Phase 7: Tracking-Codes und Tracking-Mails finalisiert
Status: abgeschlossen fuer Admin/User-Anzeige und automatische Mail-Ausloesung.
Ziel:
- Tracking-Codes sind in Admin- und User-Order-Details sichtbar.
- Automatische Tracking-Mails werden nur einmal pro Sendung versendet.
- Mails werden nur bei relevanter Statusaenderung ausgeloest.
- Statusspruenge wie `created` direkt nach `out_for_delivery` werden abgedeckt.
- Mehrere passende Sendungen einer Bestellung werden weiterhin in einer Mail zusammengefasst.
Umsetzung:
- `packages/acme-laravel-dhl/src/Models/DhlShipment.php`
- Neue Konstante `TRACKING_EMAIL_TRIGGER_STATUSES`.
- Relevante automatische Mail-Statuswerte:
- `in_transit`
- `out_for_delivery`
- Neue Methode `shouldTriggerTrackingEmail()`.
- Bedingung:
- aktueller Status ist relevant
- Status hat sich gegenueber dem vorherigen Status geaendert
- Tracking-Mail wurde noch nicht markiert
- Sendung hat DHL-Sendungsnummer und Empfaengeradresse
- Scope `needsTrackingEmail()` nutzt die zentrale Statusliste.
- `app/Console/Commands/DhlUpdateTracking.php`
- Automatische Mailentscheidung nutzt nun `DhlShipment::shouldTriggerTrackingEmail()`.
- Beim Zusammenfassen mehrerer Sendungen werden alle unbenachrichtigten Sendungen der Bestellung mit relevantem Status aufgenommen.
- Dadurch werden direkte Spruenge nach `out_for_delivery` nicht mehr uebersehen.
- `app/Services/DhlTrackingService.php`
- DHL-Statusmapping erweitert:
- `transit`, `in-transit`, `in_transit` -> `in_transit`
- `out-for-delivery`, `out_for_delivery` -> `out_for_delivery`
- weitere Varianten fuer `pre_transit` und `failed`
- Mapping ist nun statisch nutzbar und separat testbar.
- `resources/views/admin/dhl/show.blade.php`
- Oeffentlicher Tracking-Link nutzt nun den korrekten Query-Parameter `tracking_number`.
- `resources/views/admin/sales/_detail_dhl_shipments.blade.php`
- Bereits vorhandener DHL-Block wird auch in den User-Order-Modalen mit eingebunden, Aktionen bleiben ueber `isAdmin` abgesichert.
- `tests/Unit/Dhl/DhlShipmentStatusTest.php`
- Tests fuer Tracking-Mail-Ausloesung bei `out_for_delivery`.
- Tests fuer einmalige Mail-Ausloesung.
- Tests, dass `delivered` keine neue automatische Tracking-Mail mehr ausloest.
- Tests fuer DHL-Statusmapping-Varianten.
Fachliche Entscheidung:
- Automatische Tracking-Mails werden bei `in_transit` oder `out_for_delivery` versendet.
- `delivered` loest keine neue automatische Tracking-Mail aus, weil die Benachrichtigung dann fachlich zu spaet waere und Mehrfachmails vermieden werden sollen.
- Manuelles erneutes Senden im Admin bleibt weiterhin moeglich.
- Mehrere Sendungen einer Bestellung werden zusammengefasst, sofern sie noch keine Tracking-Mail erhalten haben und einen relevanten Status besitzen.
Verifikation:
- `./vendor/bin/pint --dirty --format agent`
- `php artisan test --compact tests/Unit/Dhl`
- Ergebnis: 38 Tests bestanden, 74 Assertions.
- IDE/Linter-Pruefung der geaenderten Dateien:
- Ergebnis: keine Fehler.
### 27.05.2026 - Nachtrag Phase 7: Tracking-E-Mail-Historie in der Detailansicht
Status: abgeschlossen.
Ziel:
- In der DHL-Detailansicht soll nicht nur der letzte Tracking-Mail-Status sichtbar sein.
- Es soll nachvollziehbar sein, welche Tracking-E-Mails mit welchem Sendungsstatus versendet wurden.
- Automatische und manuelle Versendungen sollen unterscheidbar bleiben.
- Zusammengefasste Tracking-Mails sollen zeigen, welche Sendungen enthalten waren.
Umsetzung:
- `packages/acme-laravel-dhl/src/Models/DhlShipment.php`
- `markTrackingEmailSent()` erweitert.
- Bei jedem Versand wird ein Eintrag in `api_response_data.tracking_email_history` geschrieben.
- Gespeicherte Felder je Eintrag:
- `sent_at`
- `type` (`auto` oder `manual`)
- `recipient_email`
- `status`
- `tracking_status`
- `dhl_shipment_no`
- `included_shipment_ids`
- Neue Methode `getTrackingEmailHistory()` liefert die Historie mit neuestem Eintrag zuerst.
- Neue statische Methode `getStatusBadgeClassFor()` fuer Status-Badges in historischen Eintraegen.
- `app/Http/Controllers/DhlShipmentController.php`
- Manueller Tracking-Mail-Versand uebergibt Empfaenger und enthaltene Sendungen an `markTrackingEmailSent()`.
- `app/Console/Commands/DhlUpdateTracking.php`
- Automatischer Tracking-Mail-Versand uebergibt Empfaenger und enthaltene Sendungen an `markTrackingEmailSent()`.
- `resources/views/admin/dhl/show.blade.php`
- Bereich `Tracking-E-Mail Status` zeigt weiterhin den letzten Versand prominent an.
- Darunter wird eine Historientabelle angezeigt mit:
- Zeitpunkt
- Typ
- Status zum Versandzeitpunkt
- Empfaenger
- enthaltene Sendungs-IDs
- `tests/Unit/Dhl/DhlShipmentStatusTest.php`
- Tests fuer Tracking-Mail-Historie mit neuestem Eintrag zuerst.
- Test fuer Legacy-Sendungen ohne Historie.
Fachliche Entscheidung:
- Die Historie wird im bestehenden `api_response_data` JSON der Sendung gespeichert, damit keine neue Tabelle notwendig ist.
- Die bestehenden Felder `tracking_email_sent_at` und `tracking_email_type` bleiben fuer schnelle Anzeige und bestehende Logik erhalten.
- Historische Sendungen, die nur die alten Felder besitzen, bleiben kompatibel und zeigen weiterhin den letzten Versandstatus.
Verifikation:
- `./vendor/bin/pint --dirty --format agent`
- `php artisan test --compact tests/Unit/Dhl`
- Ergebnis: 40 Tests bestanden, 79 Assertions.
- IDE/Linter-Pruefung der geaenderten Dateien:
- Ergebnis: keine Fehler.
### 27.05.2026 - Nachtrag: Modale Vorabpruefung vor Labelerstellung
Status: abgeschlossen.
Ziel:
- Vor der finalen DHL-Labelerstellung soll im bestehenden Erstellungsmodal eine sichtbare Vorabpruefung erscheinen.
- Der Admin soll Produktcode, nationale/internationale Sendungsart, Zielland und Lieferadressstatus sehen.
- Fehler blockieren die Labelerstellung; Warnungen bleiben sichtbar und muessen bewusst bestaetigt werden.
Umsetzung:
- `app/Http/Controllers/DhlShipmentController.php`
- Der bestehende Endpoint `validateAddress()` liefert nun zusaetzlich strukturierte `preflight`-Daten.
- Die Produktpruefung nutzt `DhlProductResolver`.
- Fehler aus Produkt-/Zielland-Kombinationen werden gemeinsam mit Adressfehlern zurueckgegeben.
- `app/Services/DhlProductResolver.php`
- Neue Methoden `getProductScope()` und `getProductScopeLabel()`.
- Dadurch kann die UI Produktcodes fachlich als national oder international anzeigen.
- `resources/views/admin/dhl/modal_in_order_shipment.blade.php`
- Neuer Statusbereich `Vorabpruefung vor Labelerstellung`.
- Der Statusbereich sitzt am Ende des Formulars direkt vor den Aktionsbuttons.
- Initialer Buttontext: `Vorabpruefung durchfuehren`.
- `app/Services/DhlAddressValidator.php`
- Die Lieferadresse wird zusaetzlich auf plausible Feldinhalte geprueft.
- Offensichtlich ungueltige Werte bei Straße, PLZ oder Ort fuehren jetzt zu Fehlern statt nur zu einer positiven Formalpruefung.
- Fuer DE, AT und CH ist eine formale DACH-Pruefung hinterlegt.
- Diese umfasst Pflichtfelder, PLZ-Format, Plausibilitaet, Platzhalter-/Testdaten und Packstation-Regeln.
- DACH-Hausnummern ohne Ziffer werden als Fehler blockiert.
- Die UI weist nun ausdruecklich darauf hin, dass keine echte DHL-/Adressdatenbank- oder Leitcodepruefung angebunden ist.
- Fuer unterstuetzte Ziellaender ohne landesspezifische Pruefung wird ein Hinweis auf die reine Basis-Adresspruefung ausgegeben.
- `resources/views/admin/dhl/modal_create_shipment.blade.php`
- Der erste Klick fuehrt nur die Vorabpruefung aus.
- Die separate Browser-Alert-Fehlermeldung bei Vorpruefungsfehlern wurde entfernt.
- Der Validierungsstatus zeigt nun `Formale DACH-Pruefung` statt irrefuehrend `Aktiv`.
- Das Ergebnis wird direkt im Modal angezeigt:
- Produktcode
- Sendungsart
- Zielland
- normalisierte Lieferadresse
- Status der Adressvalidierung
- Fehler und Hinweise
- Erst nach erfolgreicher Vorabpruefung wechselt der Button auf `Sendung jetzt erstellen`.
- Aenderungen an Formularfeldern setzen die Freigabe automatisch zurueck.
- `tests/Unit/Dhl/DhlProductResolverTest.php`
- Neuer Test fuer nationale und internationale Produktklassifizierung.
- `tests/Unit/Dhl/DhlAddressValidatorTest.php`
- Neuer Test fuer unplausible Lieferadressfelder.
- Neue Tests fuer CH-Adressvalidierung und Basispruefungs-Hinweise.
- Neue Tests fuer Platzhalteradressen und DACH-Hausnummern ohne Ziffer.
Fachliche Entscheidung:
- Die bestehende Servervalidierung bleibt die Quelle der Wahrheit.
- Die Vorabpruefung ersetzt keine Validierung bei der finalen Erstellung; vor dem Erstellen wird nochmals gegen denselben Endpoint geprueft.
- Dadurch werden nachtraegliche Formularaenderungen oder veraltete Modalzustande nicht ungeprueft an DHL uebergeben.
Verifikation:
- `./vendor/bin/pint --dirty --format agent`
- `php artisan test --compact tests/Unit/Dhl`
- Ergebnis: 46 Tests bestanden, 109 Assertions.
- IDE/Linter-Pruefung der geaenderten Dateien:
- Ergebnis: keine Fehler.
### 27.05.2026 - Phase 8: DHL-seitige Adressvalidierung ueber mustEncode
Status: umgesetzt fuer deutsche Empfaengeradressen.
Ziel:
- DHL soll selbst die finale Adress-/Leitcodefaehigkeit pruefen.
- Grundlage ist der DHL-Query-Parameter `mustEncode=true`.
- `printOnlyIfCodable` ist laut DHL-Spezifikation der Legacy-Name.
- Wenn diese Option gesetzt ist, soll DHL das Etikett nur dann erstellen, wenn die Adresse codeable/leitcodierbar ist.
- Wird die Adresse von DHL abgelehnt, soll der Fehler im bestehenden Modal erscheinen und dort korrigierbar sein.
- Laut DHL-Dokumentation ist die Funktion nur fuer deutsche Empfaengeradressen relevant.
Umsetzung:
- Die aktuelle formale Vorabpruefung bleibt erhalten.
- `config/dhl.php`
- Neue Option `print_only_if_codeable`, steuerbar ueber `DHL_PRINT_ONLY_IF_CODEABLE`.
- Standard: aktiv.
- `app/Http/Controllers/SettingController.php`
- DHL-Konfiguration liefert `print_only_if_codeable`.
- `resources/views/admin/settings/index.blade.php`
- Neue Checkbox `DHL-Leitcodierung erzwingen (mustEncode)`.
- `app/Services/DhlDataHelper.php`
- Option wird in die Orderdaten fuer den DHL-Request uebernommen.
- `packages/acme-laravel-dhl/src/Services/ShippingService.php`
- Fuer deutsche Empfaengeradressen wird bei aktiver Option der Query-Parameter `mustEncode=true` an die DHL Create-Shipment-Operation uebergeben.
- DHL-Responses ohne Label/Shipment oder mit Item-Fehlerstatus werden vor dem Speichern der Sendung abgefangen.
- Nicht leitcodierbare Adressen werden als `DhlAddressValidationException` normalisiert.
- `packages/acme-laravel-dhl/src/Exceptions/DhlAddressValidationException.php`
- Neue fachliche Exception fuer DHL-Adressablehnungen.
- `app/Services/DhlShipmentService.php`
- DHL-Adressablehnungen werden als Validierungsfehler zurueckgegeben.
- Wenn Queue aktiv ist, aber `mustEncode` fuer eine deutsche Empfaengeradresse greift, wird synchron erstellt, damit der DHL-Fehler direkt im Modal sichtbar bleibt.
- `app/Http/Controllers/DhlShipmentController.php`
- Gibt DHL-Adressvalidierungsfehler mit HTTP 422 zurueck.
- `resources/views/admin/dhl/modal_create_shipment.blade.php`
- Fehler aus der finalen DHL-Erstellung werden in der bestehenden Vorabpruefungsbox angezeigt.
- Keine separate Browser-Alert-Meldung.
Offene Punkte:
- DHL-Sandbox-Verhalten testen.
- Fehlercodes fuer nicht codeable Adressen sammeln.
- Produktabdeckung fuer `V01PAK`, `V62KP` und `V53PAK` klaeren.
- Klaeren, wie internationale Sendungen behandelt werden, da DHL `mustEncode` als nur fuer deutsche Empfaengeradressen relevant beschreibt.
Verifikation:
- `./vendor/bin/pint --dirty --format agent`
- `php artisan test --compact tests/Unit/Dhl`
- Ergebnis: 49 Tests bestanden, 115 Assertions.
## Legacy-Dokumentation
Die bisherigen Markdown-Dateien wurden nach `dev/dhl-modul/legacy` verschoben. Sie bleiben als Historie erhalten, sind aber nicht mehr die aktuelle Arbeitsgrundlage.

View file

@ -0,0 +1,19 @@
ABO
Ein Abo hat eine bestimmte Regel und einen eigenen Bestand an Produkten.
Zusätzlich soll es möglich sein, kurz bevor das Abo ausgeführt wird, soll es möglich sein aus dem regulären Stand der Produkte also da, wo der Berater oder auch später der Kunde seine Produkte bestellt, weitere Produkte seinem Abo hinzuzufügen.
Dazu folgenden Ablauf ich würde jedem Berater oder Kunden, der ein Abo hat drei Tage dann vorher eine E-Mail zuschicken, dass dein Abo jetzt entsprechend vorbereitet wird. In diesem Zeitraum hat er Zeit aus der regulären Bestellung weitere Produkte zu seiner Abo Bestellung hinzuzufügen. Das bedeutet er geht quasi in das eigentliche bestellen fügt seinen Warenkorb dann nicht einer neuen Bestellungen hinzu, sondern hat die Option zu meinem Abo hinzufügen. In diesem Augenblick spart er sich am Ende Versandkosten und bekommt eine gesammelte Bestellung. Ich würde es so machen, dass dann die Bestellung regulär in die Bestellungen läuft aber mit dem Hinweis wird zusammen mit dem Abo versendet.
Für die Berater und die Kunden fallen dabei die Versandkosten bei dem Berater zusätzlich das Kompensation Produkt (das ist noch zu klären). Gut wäre hier auch eine Option in den Einstellungen.
Es gibt ja einen Lock, d.h. X Tage bevor das Abo ausgeführt wird, ist es nicht mehr änderbar.
Dieser Lock muss händisch änderbar sein, irgendwo in den Einstellungen. Wir haben uns darauf geeinigt, dass das zwei oder drei Tage vorher ausreicht. Ich glaube, derzeit sind es fünf oder zehn Tage.
Mitgliedschaft
Die Mitgliedschaft wird immer zehn Tage vorher verlängert. Das kann grundsätzlich raus denn jeweils früher möglich unterschiedliche Pakete für die Mitgliedschaft zu buchen. Das ist jetzt nicht mehr möglich. Von daher verlängert sich am Ende die Mitgliedschaft in dem Augenblick, wo die Mitgliedschaft Gebühr bezahlt wird. Dafür gibt es so genannte Reminder im System. Diese müssen inhaltlich angepasst werden. Die Reminder sollen grundsätzlich bleiben und auf die Zahlung der Mitgliedschaft hinwirken.. in den E-Mails gibt es Links, die anscheinend nicht mehr richtig funktionieren. Richtig gut wäre es, wenn wir hier einen Magiclink bauen können, der direkt zur Zahlung geht.
Weiterhin ist es natürlich möglich, auch direkt im Sales Center die entsprechende Verlängerung abzuschließen. Hier gibt es das Produkt Business Paket oder ähnlich. Dieses Produkt hat aber die Beschreibung. Beim ersten Abschluss eines Beratervertrag ist die Verlängerung benötigt ein eigenes Paket, wo die Beschreibung entsprechend angepasst wird.

View file

@ -0,0 +1,49 @@
Das DHL Cockpit ist super, leider klappt es nur für Deutschland.
Kommt Österreich, Spanien etc auch noch dazu?
Vermutlich ist derzeit die Schnittstelle nur für den deutschen Markt ausgelegt prüfen, wie wir die weiteren reinbekommen.
1 Feld für Sendungsreferenz oder Sonstiges (da wir öfters "Nachlieferung" oder ähnliches rein schreiben müssen)
Es wäre gut, wenn eine Meldung kommen würde, falls die Straße, PLZ oder Ort falsch ist, bevor das Etikett erstellt wird. Erst wenn man das Etikett ausdrucken will erscheint in DHL eine Information, dass zb die Straße nicht existiert. Dann muss man alles stornieren. Warenpost können wir nicht stornieren und muss beszehalt werden.
Ihr brauchen wir eine Lösung, die die Adresse validiert bitte einmal vorschlagen, was es hier für Lösungen gibt über APIs - Direkt über DHL?
stornieren über das DHL Cockpit hat leider noch nicht funktioniert. Da kam eine Fehlermeldung.
Wäre super, wenn Du das noch ergänzen könntest.
Prüfen, warum das nicht funktioniert. Hier gibt es bisher schon eine implementierte Lösung.
=> Gewicht Kompenataion, wird aktuell nicht mit in das DHL Paket Gewicht mit eingerechnet, da das Kompensation Produkt mit dem Gewicht null in den Warenkorb kommt damit keine Berechnung des Produktes entsteht. Hier müssten wir eine Lösung finden, wie wir das Gewicht vermutlich direkt über das Produkt auslesen und zum Paketgewicht hinzufügen.
Prüfen der Verwandte E-Mails. Derzeit werden Tracking Mails generiert. Hier müssen wir einmal prüfen, in welchem Rhythmus und wie oft diese generiert und ausgelöst werden. Wichtig ist, dass wir hier nicht zu viele raussenden etc.
Wichtig ist auch, dass bei jeder Bestellung sowohl beim Admin als auch beim User als auch im User N Portal die Tracking Codes entsprechend einsehbar Sendung aufrufbar.
Versenden nur mit Statusänderung.
Prüfen folgender Mail
Sehr geehrte Kundinnen und Kunden,
wir möchten Sie erneut daran erinnern, dass bis spätestens Sonntag, 31.05.2026, eine technische Umstellung Ihrer Systeme erforderlich ist. Ab dem 31.05.2026 treten verbindliche technische Anpassungen an unseren DHL-Systemen in Kraft.
Bitte stellen Sie sicher, dass die Umstellung fristgerecht erfolgt, um eine weiterhin reibungslose Nutzung unserer DHL-Systeme zu gewährleisten.
Deaktivierung Produktkürzel Warenpost zum 31.05.2026
Zum 01.01.2025 wurde das neue Produkt DHL Kleinpaket eingeführt und hat das bisherige Produkt Warenpost abgelöst. Wir haben festgestellt, dass Sie aktuell noch das veraltete Produktkürzel für Warenpost verwenden. Bislang wurde dieses Kürzel in unseren Systemen automatisch in DHL Kleinpaket umgewandelt. Diese technische Übergangslösung wird zum 31.05.2026 deaktiviert. Ab diesem Zeitpunkt ist eine automatische Anpassung des veralteten Produktkürzels leider nicht mehr möglich.
Bitte nehmen Sie die Umstellung daher zeitnah vor:
API-Anbindung: Ersetzen Sie das Produktkürzel „V62WP“ (Warenpost) durch „V62KP“ (DHL Kleinpaket). Sollten Sie eine Softwarelösung für die Sendungsbeauftragung nutzen, setzen Sie sich bitte zeitnah mit dem Hersteller in Verbindung,
CSV-Import (Funktion „Versenden“ im Post & DHL Geschäftskundenportal oder DHL Polling Software): Passen Sie Ihre CSV-Dateien entsprechend an und ersetzen Sie „V62WP“ durch „V62KP“.
Bei Fragen zur Umstellung können Sie uns jederzeit über das Group API Developer Portal Help Center kontaktieren. https://support-developer.dhl.com/support/home Als eingeloggter Nutzer können Sie dort außerdem ein Ticket erstellen, um Unterstützung zu erhalten.
Vielen Dank für Ihre Unterstützung bei der Umstellung, mit der Sie einen reibungslosen Versand auch über den 31.05.2026 hinaus sicherstellen.
Viele Grüße
Ihr DHL-Team

View file

@ -0,0 +1,157 @@
#### 1. Überarbeitetes Dashboard & KPI-Übersicht
Das Dashboard soll eine interaktive Struktur erhalten (Linien 1 bis x). Folgende Kennzahlen müssen präzise abgebildet werden:
- **Anzahl der eigenen Kundenabos:** Direkte Sichtbarkeit der persönlichen Kundenentwicklung.
- **Kundenabos im Team (Terminologie-Korrektur):** Die Bezeichnung wird von „Team Kunden“ auf **„Teamkundenabos“** oder **„Kundenabos im Team“** geändert. Grund: Team-Kunden haben nicht zwingend ein aktives Abonnement; die Kennzahl muss jedoch rein die Abos widerspiegeln.
- **Umsatz & Volumen:** Darstellung des Umsatzes pro Linie (Abos, Einzelbestellungen etc.) inklusive Summenbildung.
#### 2. Interaktivität & „Deep Dive“ (Klickbarkeit)
Es ist entscheidend, dass Zahlen keine abstrakten Werte bleiben. Hinter jeder Kennzahl müssen die entsprechenden Personen sichtbar sein:
- **Klick-Funktion für Statistiken:** Wenn z. B. „55 Teamabos“ angezeigt werden, muss diese Zahl anklickbar sein, um die Liste der dahinterstehenden Personen zu öffnen.
- **Neupartner & Teamabos:** Eine direkte Klickmöglichkeit für Teamabos und Neupartner wird implementiert, damit Führungskräfte sofort sehen, wer diese Menschen sind und sie gezielt unterstützen können.
- **Detailansicht pro Person:** Beim Klick auf eine Linie öffnen sich detaillierte Infos (Generation, Punkte pro Abo, Ausführungsdaten der Kundenabos).
#### 3. Spezial-Kennzahl: „1000 Punkte Shop“
Um Top-Performer hervorzuheben, wird eine neue Metrik eingeführt:
- **Definition:** Erfassung aller Teampartner, die einen Kundenumsatz von mindestens 1000 Punkten erzielen.
- **Funktion:** Beim Klick auf diese Kennzahl erscheint eine Namensliste, die nach Volumen absteigend sortiert ist und die jeweiligen Volumenpunkte explizit anzeigt.
#### 4. System-Anpassungen & Formulare
- **Bestell-Formular:** Integration einer Pflichtabfrage: _„Von wem hast du von Mivita erfahren?“_, um die Zuweisung und das Marketing besser tracken zu können.
- **Stornoprozess:** Prüfung der Logik für Stornorechnungen. Es muss sichergestellt werden, dass bei einer Stornierung die entsprechenden Punkte systemseitig korrekt zurückgeführt (abgezogen) werden.
#### 5. Rechtliches & Sichtbarkeit (Incentives)
- **Transparenz in Ranglisten:** Für Incentives (z. B. Montenegro) sollen alle Namen der Teilnehmer (nicht nur die Top 30) mit Foto und Land angezeigt werden.
- **Opt-in Button:** Kevin implementiert einen Button, mit dem Partner aktiv zustimmen können, dass ihr Name in den Ranglisten für alle sichtbar ist.
- **Rechtliche Prüfung:** Ein Network-Anwalt wird durch Dani/Alois hinzugezogen, um die Anzeige von Namen in internen Ehrungen ohne explizite Einzelzustimmung abzuklären.
#### 6. Multimedia-Bereich
- **Event-Archiv:** Ein neuer Reiter (analog zum News-Archiv) wird erstellt. Hier lädt Susi regelmäßig Fotos von Veranstaltungen und Calls hoch, um das Momentum im Team zu fördern.
### Briefing: Optimierung & Ausbau des Mivita Backoffice (Fokus: Dashboard-Logik & Interaktivität)
Ziel dieses Umbaus ist es, das aktuelle "Daten-Chaos" durch eine übersichtliche, interaktive und klickbare dreistufige Tabellenstruktur zu ersetzen. Führungskräfte müssen von der Vogelperspektive bis auf den einzelnen Kunden durchklicken können.
#### Stufe 1: Das Haupt-Dashboard (Die Gesamtübersicht)
Die Startseite der Statistiken zeigt eine kompakte Zusammenfassung der gesamten Struktur (alle tatsächlich vorhandenen Linien sowie eine Summenzeile am Ende).
- **Spaltenaufbau:** * Linie (1. Linie, 2. Linie etc.)
- Anzahl der Berater
- Umsatz (Gesamtumsatz aus Abos, Einzelbestellungen etc.)
- Anzahl der Teamabos
- Anzahl der Kundenabos
- **Klick-Logik:** Egal, wo man in dieser Übersicht hinklickt (auf eine ganze Linie oder auf eine konkrete Zahl wie z.B. "3 Teamabos"), es öffnet sich immer eine tiefergehende Detailseite.
- **Aktueller MVP-Stand:** Die Kennzahlen sind als klickbare Badges umgesetzt. Die Summenzeile ist ebenfalls klickbar und öffnet über alle vorhandenen Linien. Bei Teamabos und Teamkundenabos wird zusätzlich angezeigt, wie viele Abos im gewählten Monat neu dazugekommen sind.
#### Stufe 2: Die Linien-Detailansicht (Generationen-Ebene)
Klickt man in der Hauptübersicht beispielsweise auf **"1. Linie"**, öffnet sich eine neue Seite, die alle direkten Partner (Firstlines) dieser Linie namentlich auflistet (z. B. Anna, Lena, Lisa).
- **Spaltenaufbau pro Partner:**
- Name der Firstline (z. B. Anna)
- Eigenes Abo (Anzeige in Punkten)
- Kundenabos (Anzahl der Abos & Gesamtpunktewert dieser Abos)
- Teampartnerabos (Anzahl der Abos & Gesamtpunktewert in der Organisation)
- Kundenabos im Team (Anzahl der Abos & Gesamtumsatz dieser Abos)
- **Gesamt:** Diese Spalte ist besonders wichtig. Sie addiert alle Abos (Eigenes Abo + Kundenabos + Teamabos + Kundenabos des Teams) und zeigt die Gesamt-Aboanzahl sowie die Gesamt-Umsatzpunkte pro Person an.
- **Klick-Logik:** Auch hier ist jede Kennzahl (z.B. "Kundenabos" bei Anna) wieder anklickbar.
#### Stufe 3: Die Tiefen-Detailansicht (Listen-Ebene)
Wenn man noch mehr Infos zu einer spezifischen Kennzahl haben möchte (entweder durch Klick auf eine Zahl im Haupt-Dashboard oder durch Klick auf eine Metrik bei einem bestimmten Partner in Stufe 2), öffnet sich das tiefste Level.
- **Beispiel 1 (Klick auf Annas "Kundenabos"):** Es öffnet sich das Fenster "Anna - Kundenabos". Man sieht oben eine Zusammenfassung (z.B. "Insgesamt: 4 Kundenabos / 200 Abokundenpunkte"). Darunter werden alle 4 Kunden einzeln und übersichtlich aufgelistet.
- **Beispiel 2 (Klick auf "3 Teamabos" im Haupt-Dashboard):** Es öffnet sich das Fenster "Teamabos Generation 1". Auch hier gibt es eine Zusammenfassung und darunter die Liste der 3 Personen (z.B. Sabine, Carola, Anna).
- **Angezeigte Daten pro Eintrag in der Liste:**
- Name des Kunden/Partners
- Punktewert (z. B. 50 Punkte)
- Nächste Ausführung (Datum, z. B. 10.4.2026)
- Abo Lieferungen (Anzahl, z. B. 1)
- Status des Abos, z. B. aktiv, angehalten, storniert oder inaktiv
- Besteht-seit-Datum; neue Abos im gewählten Monat werden optisch hervorgehoben
- **Aktueller MVP-Stand:** Detailtabellen haben eine Suche, klickbare Spaltensortierung, eine Summenzeile und einen CSV-Export. Der gewählte Monat/Jahr bleibt beim Wechsel zwischen Übersicht und Detailansicht erhalten.
- **Performance/Snapshots:** Abgeschlossene Monate können als Backoffice-Statistik-Snapshot gespeichert werden. Dadurch bleiben vergangene Monatswerte stabil und müssen bei großen Teams nicht jedes Mal live neu berechnet werden.
- **1000-Punkte-Shop:** Die Detailansicht zeigt keine zusätzliche Qualifikations-Einteilung mehr, sondern den aktuellen Karriere-Level des Beraters. Die Punkte bleiben nach Eigenpunkten, Kundenabo-Punkten, Einzelbestellungs-Punkten und sonstigen Kundenpunkten getrennt sichtbar.
- **Karriere-Level:** Detailansichten zeigen den aktuellen Karriere-Level des jeweiligen Beraters, damit die Namenlisten fachlich besser einordbar sind.
- **Datenschutz-Hinweis:** Detailansichten weisen sichtbar darauf hin, dass personenbezogene Daten rechtlich noch final geklärt werden und aktuell nur für berechtigte VIP-Auswertungen vorgesehen sind.
- **Übersichts-Export:** Die Linienübersicht kann als CSV exportiert werden. Enthalten sind alle Linien, die Summenzeile, neue Abo-Zählungen und die getrennten Punktewerte.
- **Tests:** CSV-Inhalte für Übersicht und Details, der Zeitraum-Erhalt zwischen Übersicht, Detailansicht und Export, neue Abo-Markierungen und Abo-Statusgründe aus Zahlungsfehlern sind gezielt abgesichert.
- **Performance-Hinweis:** Die Übersicht zeigt, ob die Daten live oder aus einem Snapshot geladen wurden, inklusive Laufzeit der Berechnung.
- **Checkout-Herkunft:** Kundenbestellungen im Shop fragen eine vordefinierte Herkunft plus optionalen Freitext ab. Die Werte werden an der Bestellung gespeichert und im Bestelldetail angezeigt.
---
#### Weitere wichtige Backoffice-Anpassungen (abseits der Listen-Logik)
- **Neue Spezial-Kennzahl „1000 Punkte Shop“:**
- Eine zusätzliche, anklickbare Kennzahl in der Übersicht.
- Zeigt die Anzahl der Teampartner an, die mindestens 1000 Punkte Kundenumsatz generiert haben.
- Beim Klick darauf erscheint eine Liste mit den Namen dieser Partner, absteigend sortiert nach Volumen, wobei die genauen Volumenpunkte angezeigt werden.
- **Erfassung der Herkunft (Formular):**
- Im Bestellformular wird die verpflichtende Abfrage eingefügt: _„Von wem hast du von Mivita erfahren?“_ (Wichtig für Zuordnung und Tracking).
- **Stornoprozess:**
- Die IT muss prüfen, ob Stornorechnungen systemseitig korrekt verarbeitet werden, sodass bei einem Storno die entsprechenden Punkte automatisch und fehlerfrei zurückgeführt (abgezogen) werden.

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 {

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View file

@ -12,6 +12,7 @@ return [
'returned' => 'Retourniert',
'failed' => 'Fehler',
'unknown' => 'Unbekannt',
'canceled' => 'Storniert',
'cancelled' => 'Storniert',
'shipped' => 'Versendet',
],
@ -26,7 +27,8 @@ return [
'V53WPAK' => 'DHL Paket International',
'V54EPAK' => 'DHL Paket International',
'V55PAK' => 'DHL Paket International',
'V62WP' => 'DHL Paket International',
'V62KP' => 'DHL Kleinpaket',
'V62WP' => 'DHL Warenpost (Legacy)',
'V66WPI' => 'DHL Paket International',
],

View file

@ -12,6 +12,7 @@ return [
'returned' => 'Returned',
'failed' => 'Failed',
'unknown' => 'Unknown',
'canceled' => 'Canceled',
'cancelled' => 'Cancelled',
'shipped' => 'Shipped',
],
@ -26,7 +27,8 @@ return [
'V53WPAK' => 'DHL Package International',
'V54EPAK' => 'DHL Package International',
'V55PAK' => 'DHL Package International',
'V62WP' => 'DHL Package International',
'V62KP' => 'DHL Kleinpaket',
'V62WP' => 'DHL Warenpost (Legacy)',
'V66WPI' => 'DHL Package International',
],

View file

@ -12,6 +12,7 @@ return [
'returned' => 'Devuelto',
'failed' => 'Fallido',
'unknown' => 'Desconocido',
'canceled' => 'Cancelado',
'cancelled' => 'Cancelado',
'shipped' => 'Enviado',
],
@ -26,7 +27,8 @@ return [
'V53WPAK' => 'DHL Paquete Internacional',
'V54EPAK' => 'DHL Paquete Internacional',
'V55PAK' => 'DHL Paquete Internacional',
'V62WP' => 'DHL Paquete Internacional',
'V62KP' => 'DHL Kleinpaket',
'V62WP' => 'DHL Warenpost (Legacy)',
'V66WPI' => 'DHL Paquete Internacional',
],

View file

@ -12,6 +12,7 @@ return [
'returned' => 'Retourné',
'failed' => 'Erreur',
'unknown' => 'Inconnu',
'canceled' => 'Annulé',
'cancelled' => 'Annulé',
'shipped' => 'Expédié',
],
@ -26,7 +27,8 @@ return [
'V53WPAK' => 'DHL Paket International',
'V54EPAK' => 'DHL Paket International',
'V55PAK' => 'DHL Paket International',
'V62WP' => 'DHL Paket International',
'V62KP' => 'DHL Kleinpaket',
'V62WP' => 'DHL Warenpost (Legacy)',
'V66WPI' => 'DHL Paket International',
],

View file

@ -102,7 +102,7 @@
<option value="created" {{ request('status') == 'created' ? 'selected' : '' }}>Erstellt</option>
<option value="shipped" {{ request('status') == 'shipped' ? 'selected' : '' }}>Versendet</option>
<option value="delivered" {{ request('status') == 'delivered' ? 'selected' : '' }}>Zugestellt</option>
<option value="cancelled" {{ request('status') == 'cancelled' ? 'selected' : '' }}>Storniert</option>
<option value="canceled" {{ in_array(request('status'), ['canceled', 'cancelled']) ? 'selected' : '' }}>Storniert</option>
<option value="failed" {{ request('status') == 'failed' ? 'selected' : '' }}>Fehler</option>
</select>
</div>

View file

@ -109,6 +109,145 @@ $(document).ready(function() {
return;
}
if ($('#dhl-preflight-confirmed').val() !== '1') {
validateDhlAddress(formData, submitBtn, function() {
$('#dhl-preflight-confirmed').val('1');
submitBtn.prop('disabled', false).html('<i class="fas fa-shipping-fast"></i> Sendung jetzt erstellen');
});
return;
}
validateDhlAddress(formData, submitBtn, function() {
submitDhlShipment(form, formData, submitBtn);
});
});
function validateDhlAddress(formData, submitBtn, onSuccess) {
submitBtn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Prüfe Sendung...');
$.ajax({
url: '{{ route('admin.dhl.validate-address') }}',
method: 'POST',
data: formData,
success: function(response) {
renderDhlPreflightStatus(response);
onSuccess();
},
error: function(xhr) {
var response = xhr.responseJSON || {};
renderDhlPreflightStatus(response);
resetDhlPreflight(true);
}
});
}
function renderDhlPreflightStatus(response) {
var status = response.status || 'error';
var product = (response.preflight && response.preflight.product) ? response.preflight.product : {};
var address = (response.preflight && response.preflight.address) ? response.preflight.address : {};
var normalized = address.normalized || {};
var errors = response.errors || [];
var warnings = response.warnings || [];
var statusClass = status === 'valid' ? 'success' : (status === 'warning' ? 'warning' : 'danger');
var statusIcon = status === 'valid' ? 'check-circle' : (status === 'warning' ? 'exclamation-triangle' : 'times-circle');
var productScope = product.scope_label || 'Nicht geprüft';
var productCode = product.code || $('#modal-product-code').val() || '-';
var countryLabel = product.country_label || $('#shipping_country option:selected').text().trim() || '-';
var validationBadgeClass = address.validation_available ? 'info' : 'warning';
var validationBadgeText = address.validation_available ? 'Formale DACH-Prüfung' : 'Basisprüfung';
var validationMessage = address.validation_message || 'Adressvalidierung wurde nicht eindeutig bestimmt.';
var listHtml = '';
if (errors.length) {
listHtml += '<div class="alert alert-danger mb-2"><strong>Fehler:</strong><ul class="mb-0 pl-3">';
errors.forEach(function(error) {
listHtml += '<li>' + escapeHtml(error) + '</li>';
});
listHtml += '</ul></div>';
}
if (warnings.length) {
listHtml += '<div class="alert alert-warning mb-2"><strong>Hinweise:</strong><ul class="mb-0 pl-3">';
warnings.forEach(function(warning) {
listHtml += '<li>' + escapeHtml(warning) + '</li>';
});
listHtml += '</ul></div>';
}
if (!errors.length && !warnings.length) {
listHtml = '<div class="alert alert-success mb-2">Alle Vorabprüfungen sind erfolgreich.</div>';
}
$('#dhl-preflight-status')
.removeClass('border-secondary border-success border-warning border-danger')
.addClass('border-' + statusClass)
.html(`
<div class="card-header d-flex align-items-center">
<i class="fas fa-${statusIcon} text-${statusClass} mr-2"></i>
<strong>Vorabprüfung: ${escapeHtml(response.message || 'Prüfung abgeschlossen')}</strong>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4">
<small class="text-muted d-block">Produktcode</small>
<strong>${escapeHtml(productCode)}</strong>
</div>
<div class="col-md-4">
<small class="text-muted d-block">Sendungsart</small>
<strong>${escapeHtml(productScope)}</strong>
</div>
<div class="col-md-4">
<small class="text-muted d-block">Zielland</small>
<strong>${escapeHtml(countryLabel)}</strong>
</div>
</div>
<div class="mb-3">
<small class="text-muted d-block">Lieferadresse</small>
<strong>${escapeHtml([normalized.street, normalized.house_number].filter(Boolean).join(' '))}</strong>,
${escapeHtml([normalized.postal_code, normalized.city].filter(Boolean).join(' '))}
</div>
<div class="mb-3">
<small class="text-muted d-block">Adressvalidierung</small>
<span class="badge badge-${validationBadgeClass} mr-2">${escapeHtml(validationBadgeText)}</span>
<span>${escapeHtml(validationMessage)}</span>
</div>
${listHtml}
${response.can_create_label ? '<small class="text-muted">Prüfung bestätigt. Klicken Sie erneut auf „Sendung jetzt erstellen“, um das Label zu erzeugen.</small>' : ''}
</div>
`);
}
function resetDhlPreflight(keepStatus) {
$('#dhl-preflight-confirmed').val('0');
$('#create-shipment-btn').prop('disabled', false).html('<i class="fas fa-clipboard-check"></i> Vorabprüfung durchführen');
if (keepStatus) {
return;
}
$('#dhl-preflight-status')
.removeClass('border-success border-warning border-danger')
.addClass('border-secondary')
.html(`
<div class="card-header d-flex align-items-center">
<i class="fas fa-clipboard-check text-secondary mr-2"></i>
<strong>Vorabprüfung vor Labelerstellung</strong>
</div>
<div class="card-body">
<p class="text-muted mb-0">
Formular geändert. Bitte führen Sie die Vorabprüfung erneut aus, bevor das Label erstellt wird.
</p>
</div>
`);
}
function escapeHtml(value) {
return $('<div>').text(value || '').html();
}
function submitDhlShipment(form, formData, submitBtn) {
// Disable submit button
submitBtn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Wird erstellt...');
@ -160,30 +299,60 @@ $(document).ready(function() {
});
}, 1000); // Wait 1 seconds to show success message
} else {
alert(response.message || 'Fehler beim Erstellen der Sendung.');
submitBtn.prop('disabled', false).html('<i class="fas fa-shipping-fast"></i> Sendung erstellen');
renderDhlCreationErrorStatus(response);
resetDhlPreflight(true);
}
},
error: function(xhr) {
var errorMessage = 'Fehler beim Erstellen der Sendung.';
var response = xhr.responseJSON || {
message: 'Fehler beim Erstellen der Sendung.'
};
if (xhr.responseJSON && xhr.responseJSON.message) {
errorMessage = xhr.responseJSON.message;
response.message = xhr.responseJSON.message;
} else if (xhr.responseText) {
try {
var errorData = JSON.parse(xhr.responseText);
if (errorData.errors) {
errorMessage = Object.values(errorData.errors).flat().join(', ');
response.errors = Object.values(errorData.errors).flat();
}
} catch (e) {
// Ignore JSON parse errors
}
}
alert(errorMessage);
submitBtn.prop('disabled', false).html('<i class="fas fa-shipping-fast"></i> Sendung erstellen');
renderDhlCreationErrorStatus(response);
resetDhlPreflight(true);
}
});
}
function renderDhlCreationErrorStatus(response) {
var errors = response.errors || [response.message || 'DHL hat die Labelerstellung abgelehnt.'];
renderDhlPreflightStatus({
status: 'error',
can_create_label: false,
message: 'DHL hat die Adresse bei der Labelerstellung abgelehnt.',
errors: errors,
warnings: [],
preflight: {
product: {
code: $('#modal-product-code').val(),
scope_label: $('#modal-product-code option:selected').text().trim()
},
address: {
validation_available: true,
validation_message: 'DHL-Leitcodierung über mustEncode wurde bei der Labelerstellung geprüft.',
normalized: {
street: $('#shipping_address').val(),
house_number: $('#shipping_houseNumber').val(),
postal_code: $('#shipping_zipcode').val(),
city: $('#shipping_city').val()
}
}
}
});
}
// Enhanced form validation
function validateForm() {
@ -217,6 +386,12 @@ $(document).ready(function() {
}
});
var reference = $('#modal-reference').val();
if (reference && reference.length > 35) {
$('#modal-reference').addClass('is-invalid').after('<div class="invalid-feedback">Referenz darf maximal 35 Zeichen lang sein.</div>');
isValid = false;
}
return isValid;
}
@ -238,6 +413,23 @@ $(document).ready(function() {
}
});
var dhlProductSuggestions = @json($productSuggestions ?? []);
$('#shipping_country').on('change', function() {
var countryCode = $(this).find(':selected').data('country-code');
var suggestedProduct = dhlProductSuggestions[countryCode];
if (suggestedProduct && $('#modal-product-code option[value="' + suggestedProduct + '"]').length) {
$('#modal-product-code').val(suggestedProduct);
}
});
$('#modal-create-shipment-form').on('input change', 'input, select', function() {
if ($(this).attr('id') !== 'dhl-preflight-confirmed') {
resetDhlPreflight();
}
});
// Real-time required field validation
$('input[required], select[required]').on('blur', function() {
var field = $(this);

View file

@ -1,6 +1,7 @@
<form id="modal-create-shipment-form" method="POST" action="{{ route('admin.dhl.store') }}">
@csrf
<input type="hidden" name="order_id" value="{{ $order->id }}">
<input type="hidden" id="dhl-preflight-confirmed" value="0">
<div class="modal-body">
<div class="row">
@ -52,7 +53,7 @@
<input type="number" class="form-control" id="modal-weight" name="weight" min="0.1"
max="31.5" step="0.1" value="{{ number_format($orderWeight, 1) }}" required>
<small class="form-text text-muted">
Berechnet: {{ number_format($orderWeight, 1) }} kg
Berechnetes DHL-Gewicht inkl. Kompensationsprodukten: {{ number_format($orderWeight, 3, ',', '.') }} kg
</small>
</div>
@ -63,9 +64,14 @@
</label>
<select class="form-control custom-select" id="modal-product-code" name="product_code">
@foreach ($productCodes as $code => $name)
<option value="{{ $code }}">{{ $code }} - {{ $name }}</option>
<option value="{{ $code }}" {{ ($selectedProductCode ?? null) === $code ? 'selected' : '' }}>
{{ $code }} - {{ $name }}
</option>
@endforeach
</select>
<small class="form-text text-muted">
Das Produkt wird anhand des Ziellandes vorgeschlagen und kann bei erlaubten Kombinationen angepasst werden.
</small>
</div>
<div class="form-group">
@ -86,6 +92,19 @@
Automatisches Tracking
</label>
</div>
<div class="form-group mt-3">
<label for="modal-reference" class="font-weight-semibold">
<i class="fas fa-tag"></i>
Referenz <small class="text-muted">(optional)</small>
</label>
<input type="text" class="form-control" id="modal-reference" name="reference"
value="{{ 'Order-' . $order->id }}" maxlength="35"
placeholder="z.B. Nachlieferung, Ersatz, Order-{{ $order->id }}">
<small class="form-text text-muted">
Wird als DHL refNo uebertragen und an der Sendung gespeichert. Maximal 35 Zeichen.
</small>
</div>
</div>
</div>
@ -176,6 +195,7 @@
id="shipping_country" required>
@foreach ($availableCountries as $countryOption)
<option value="{{ $countryOption->id }}"
data-country-code="{{ $countryOption->code }}"
{{ $shippingAddress['country'] && $shippingAddress['country']->id == $countryOption->id ? 'selected' : '' }}>
{{ $countryOption->getLocated() }}
</option>
@ -228,6 +248,18 @@
</div>
</div>
</div>
<div id="dhl-preflight-status" class="card border-secondary mt-3 mb-0">
<div class="card-header d-flex align-items-center">
<i class="fas fa-clipboard-check text-secondary mr-2"></i>
<strong>Vorabprüfung vor Labelerstellung</strong>
</div>
<div class="card-body">
<p class="text-muted mb-0">
Bitte führen Sie vor der Labelerstellung die Vorabprüfung aus. Dabei werden Produktcode, nationale/internationale Sendungsart und Lieferadresse geprüft.
</p>
</div>
</div>
</div>
<div class="modal-footer">
@ -236,7 +268,7 @@
</button>
<button type="submit" class="btn btn-primary" id="create-shipment-btn"
{{ !empty($errors) ? 'disabled' : '' }}>
<i class="fas fa-shipping-fast"></i> Sendung erstellen
<i class="fas fa-clipboard-check"></i> Vorabprüfung durchführen
</button>
</div>
</form>

View file

@ -54,9 +54,10 @@
<span class="badge badge-info">{{ __('dhl.status.delivered') }}</span>
</div>
@break
@case('canceled')
@case('cancelled')
<div class="h5 mb-0 font-weight-bold text-gray-800">
<span class="badge badge-secondary">{{ __('dhl.status.cancelled') }}</span>
<span class="badge badge-secondary">{{ __('dhl.status.canceled') }}</span>
</div>
@break
@case('failed')
@ -110,7 +111,7 @@
@if(false)
<code class="text-info">{{ $shipment->dhl_shipment_no }}</code>
<br>
<a href="{{ route('public.tracking') }}?dhl_shipment_no={{ $shipment->dhl_shipment_no }}"
<a href="{{ route('public.tracking') }}?tracking_number={{ $shipment->dhl_shipment_no }}"
target="_blank" class="text-muted small">
<i class="fas fa-external-link-alt"></i> Verfolgen
</a>
@ -147,7 +148,7 @@
</div>
<!-- Action Buttons -->
@if($shipment->status != 'cancelled')
@if(!in_array($shipment->status, ['canceled', 'cancelled']))
<div class="card mb-4">
<div class="card-body">
<div class="d-flex flex-wrap gap-2">
@ -219,6 +220,16 @@
@endif
</td>
</tr>
<tr>
<td class="font-weight-semibold">Referenz:</td>
<td>
@if($shipment->reference)
<code class="text-dark">{{ $shipment->reference }}</code>
@else
<span class="text-muted">Nicht gesetzt</span>
@endif
</td>
</tr>
{{--
<tr>
<td class="font-weight-semibold">Routing-Code:</td>
@ -511,14 +522,71 @@
</div>
<div class="card-body">
@if($shipment->wasTrackingEmailSent())
<div class="alert alert-success mb-0">
<div class="alert alert-success">
<i class="fas fa-check-circle"></i>
<strong>E-Mail gesendet</strong><br>
<strong>Zuletzt gesendet</strong><br>
<small>
Am {{ $shipment->tracking_email_sent_at->format('d.m.Y \u\m H:i') }} Uhr
({{ $shipment->tracking_email_type === 'auto' ? 'automatisch' : 'manuell' }})
</small>
</div>
@php($trackingEmailHistory = $shipment->getTrackingEmailHistory())
@if(!empty($trackingEmailHistory))
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Zeitpunkt</th>
<th>Typ</th>
<th>Status</th>
<th>Empfänger</th>
<th>Sendungen</th>
</tr>
</thead>
<tbody>
@foreach($trackingEmailHistory as $entry)
<tr>
<td>
@if(!empty($entry['sent_at']))
{{ \Carbon\Carbon::parse($entry['sent_at'])->format('d.m.Y H:i') }}
@else
<span class="text-muted">-</span>
@endif
</td>
<td>
<span class="badge badge-{{ ($entry['type'] ?? '') === 'auto' ? 'info' : 'secondary' }}">
{{ ($entry['type'] ?? '') === 'auto' ? 'Automatisch' : 'Manuell' }}
</span>
</td>
<td>
<span class="badge badge-{{ \Acme\Dhl\Models\DhlShipment::getStatusBadgeClassFor($entry['status'] ?? 'unknown') }}">
{{ \Acme\Dhl\Models\DhlShipment::getStatusTranslationFor($entry['status'] ?? 'unknown') }}
</span>
@if(!empty($entry['tracking_status']))
<br><small class="text-muted">{{ $entry['tracking_status'] }}</small>
@endif
</td>
<td>
@if(!empty($entry['recipient_email']))
<small>{{ $entry['recipient_email'] }}</small>
@else
<span class="text-muted">-</span>
@endif
</td>
<td>
@if(!empty($entry['included_shipment_ids']))
<small>#{{ implode(', #', $entry['included_shipment_ids']) }}</small>
@else
<span class="text-muted">-</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
@else
<div class="alert alert-secondary mb-0">
<i class="fas fa-clock"></i>

View file

@ -170,11 +170,15 @@
</div>
<div class="form-group col-sm-4">
<label class="form-label">{{ __('Standard Produktcode') }}*</label>
@php
$selectedDhlProduct = \App\Models\Setting::getContentBySlug('dhl_product') ?: 'V01PAK';
$selectedDhlProduct = $selectedDhlProduct === 'V62WP' ? 'V62KP' : $selectedDhlProduct;
@endphp
{{ Form::select('settings[dhl_product][val]', [
'V01PAK' => 'V01PAK - DHL Paket National',
'V53PAK' => 'V53PAK - DHL Paket International',
'V62WP' => 'V62WP - Warenpost National'
], \App\Models\Setting::getContentBySlug('dhl_product') ?: 'V01PAK', array('class'=>'form-control custom-select')) }}
'V62KP' => 'V62KP - DHL Kleinpaket'
], $selectedDhlProduct, array('class'=>'form-control custom-select')) }}
{{ Form::hidden('settings[dhl_product][type]', 'text') }}
</div>
<div class="form-group col-sm-6">
@ -196,6 +200,50 @@
<strong>Deaktiviert:</strong> Versandlabel werden sofort erstellt (synchron)
</small>
</div>
<div class="form-group col-sm-6">
<label class="custom-control custom-checkbox mt-4">
{!! Form::checkbox('settings[dhl_print_only_if_codeable][val]', 1, \App\Models\Setting::getContentBySlug('dhl_print_only_if_codeable') ?? config('dhl.print_only_if_codeable', true), ['class'=>'custom-control-input']) !!}
<span class="custom-control-label">DHL-Leitcodierung erzwingen (mustEncode)</span>
</label>
{{ Form::hidden('settings[dhl_print_only_if_codeable][type]', 'bool') }}
<small class="form-text text-muted">
Aktiviert für deutsche Empfängeradressen <code>mustEncode=true</code>. DHL erstellt dann nur ein Label, wenn die Adresse leitcodierbar ist.
</small>
</div>
<div class="form-group col-sm-12">
<label class="form-label">{{ __('DHL Paket International Zielländer') }}</label>
@php
$dhlInternationalCountries = (new \App\Services\DhlProductResolver())->getSupportedInternationalCountries();
$dhlCountryCodes = array_keys(\App\Services\DhlProductResolver::DHL_COUNTRY_CODES);
$dhlInternationalCountryOptions = \App\Models\Country::query()
->where('active', 1)
->whereIn('code', $dhlCountryCodes)
->where('code', '!=', \App\Services\DhlProductResolver::DOMESTIC_COUNTRY)
->orderBy('de')
->get();
@endphp
<div class="row">
@foreach ($dhlInternationalCountryOptions as $countryOption)
<div class="col-sm-6 col-md-4 col-lg-3">
<label class="custom-control custom-checkbox">
{!! Form::checkbox(
'settings[dhl_international_countries][val][]',
$countryOption->code,
in_array($countryOption->code, $dhlInternationalCountries, true),
['class' => 'custom-control-input']
) !!}
<span class="custom-control-label">
{{ $countryOption->getLocated() }} ({{ $countryOption->code }})
</span>
</label>
</div>
@endforeach
</div>
{{ Form::hidden('settings[dhl_international_countries][type]', 'object') }}
<small class="form-text text-muted">
Aktivierte Länder verwenden im DHL-Modul automatisch V53PAK. Deutschland bleibt separat über V01PAK/V62KP geregelt.
</small>
</div>
<div class="form-group col-sm-4">
<button type="button" class="btn btn-secondary" id="test-dhl-login-btn">
<i class="fas fa-key"></i> API Login testen
@ -227,9 +275,9 @@
{{ Form::hidden('settings[dhl_account_v53pak][type]', 'text') }}
</div>
<div class="form-group col-sm-6">
<label class="form-label">{{ __('V62WP - Warenpost National') }}</label>
{{ Form::text('settings[dhl_account_v62wp][val]', \App\Models\Setting::getContentBySlug('dhl_account_v62wp'), array('class'=>'form-control')) }}
{{ Form::hidden('settings[dhl_account_v62wp][type]', 'text') }}
<label class="form-label">{{ __('V62KP - DHL Kleinpaket') }}</label>
{{ Form::text('settings[dhl_account_v62kp][val]', \App\Models\Setting::getContentBySlug('dhl_account_v62kp') ?: \App\Models\Setting::getContentBySlug('dhl_account_v62wp'), array('class'=>'form-control')) }}
{{ Form::hidden('settings[dhl_account_v62kp][type]', 'text') }}
</div>
</div>

View file

@ -308,6 +308,7 @@ $(document).ready(function() {
badgeClass = 'badge-info';
text = 'Zugestellt';
break;
case 'canceled':
case 'cancelled':
badgeClass = 'badge-secondary';
text = 'Storniert';
@ -345,6 +346,7 @@ $(document).ready(function() {
iconClass = 'fas fa-home';
color = 'text-info';
break;
case 'canceled':
case 'cancelled':
iconClass = 'fas fa-ban';
color = 'text-secondary';

View file

@ -291,6 +291,7 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
Route::post('/datatable', 'DhlShipmentController@datatable')->name('admin.dhl.datatable');
Route::get('/shipment/{shipment}', 'DhlShipmentController@show')->name('admin.dhl.show');
Route::post('/shipment', 'DhlShipmentController@store')->name('admin.dhl.store');
Route::post('/validate-address', 'DhlShipmentController@validateAddress')->name('admin.dhl.validate-address');
Route::delete('/shipment/{shipment}/cancel', 'DhlShipmentController@cancel')->name('admin.dhl.cancel');
Route::post('/shipment/{shipment}/return-label', 'DhlShipmentController@createReturnLabel')->name('admin.dhl.create-return');
Route::post('/shipment/{shipment}/update-tracking', 'DhlShipmentController@updateTracking')->name('admin.dhl.update-tracking');

View file

@ -3,4 +3,5 @@
uses(Tests\TestCase::class)->in('Feature/Incentive');
uses(Tests\TestCase::class)->in('Feature/Sys');
uses(Tests\TestCase::class)->in('Unit/Incentive');
uses(Tests\TestCase::class)->in('Unit/Dhl');
uses(Tests\TestCase::class)->in('Feature/PaymentDashboard');

View file

@ -0,0 +1,174 @@
<?php
use App\Services\DhlAddressValidator;
function validDhlAddress(array $overrides = []): array
{
return array_merge([
'firstname' => 'Max',
'lastname' => 'Mustermann',
'street' => 'Hauptstrasse',
'house_number' => '5',
'postal_code' => '10115',
'city' => 'Berlin',
'country_code' => 'DE',
'email' => 'max@example.com',
'phone' => '+4930123456',
], $overrides);
}
beforeEach(function () {
config([
'dhl.config_source' => 'env',
'dhl.international_countries' => ['AT', 'ES'],
]);
});
it('marks a complete supported address as valid', function () {
$result = (new DhlAddressValidator)->validate(validDhlAddress());
expect($result['status'])->toBe('valid')
->and($result['can_create_label'])->toBeTrue()
->and($result['errors'])->toBeEmpty()
->and($result['warnings'])->toBeEmpty()
->and($result['validation_available'])->toBeTrue()
->and($result['validation_level'])->toBe('formal_dach')
->and($result['validation_message'])->toBe('Formale DACH-Pruefung aktiv: Pflichtfelder, PLZ-Format, Plausibilitaet und Packstation-Regeln werden geprueft. Eine echte Adressdatenbank-/DHL-Leitcodepruefung ist nicht angebunden.');
});
it('blocks unsupported destination countries', function () {
$result = (new DhlAddressValidator)->validate(validDhlAddress([
'country_code' => 'FR',
'postal_code' => '75001',
]));
expect($result['status'])->toBe('error')
->and($result['can_create_label'])->toBeFalse()
->and($result['errors'])->toContain('DHL-Versand in das Zielland FR ist aktuell nicht freigegeben.');
});
it('blocks invalid postal code formats for enabled countries', function () {
$result = (new DhlAddressValidator)->validate(validDhlAddress([
'country_code' => 'AT',
'postal_code' => '10115',
]));
expect($result['status'])->toBe('error')
->and($result['errors'])->toContain('Oesterreichische Postleitzahl muss 4 Ziffern haben.');
});
it('validates swiss postal codes when switzerland is enabled', function () {
config([
'dhl.config_source' => 'env',
'dhl.international_countries' => ['AT', 'CH'],
]);
$result = (new DhlAddressValidator)->validate(validDhlAddress([
'country_code' => 'CH',
'postal_code' => '8000',
]));
expect($result['validation_available'])->toBeTrue()
->and($result['validation_level'])->toBe('formal_dach')
->and($result['errors'])->not->toContain('Schweizer Postleitzahl muss 4 Ziffern haben.');
});
it('marks supported countries without country specific validation as basic checks', function () {
config([
'dhl.config_source' => 'env',
'dhl.international_countries' => ['FR'],
]);
$result = (new DhlAddressValidator)->validate(validDhlAddress([
'country_code' => 'FR',
'postal_code' => '75001',
]));
expect($result['status'])->toBe('warning')
->and($result['can_create_label'])->toBeTrue()
->and($result['validation_available'])->toBeFalse()
->and($result['validation_level'])->toBe('basic')
->and($result['validation_message'])->toBe('Fuer dieses Zielland ist aktuell nur eine Basis-Adresspruefung verfuegbar.')
->and($result['warnings'])->toContain('Fuer dieses Zielland ist aktuell nur eine Basis-Adresspruefung verfuegbar. Bitte Adresse manuell pruefen.');
});
it('blocks implausible delivery address fields', function () {
$result = (new DhlAddressValidator)->validate(validDhlAddress([
'street' => '12',
'postal_code' => '12@@@',
'city' => '1',
]));
expect($result['status'])->toBe('error')
->and($result['can_create_label'])->toBeFalse()
->and($result['errors'])->toContain('Straße ist zu kurz.')
->and($result['errors'])->toContain('Straße muss Buchstaben enthalten.')
->and($result['errors'])->toContain('Ort ist zu kurz.')
->and($result['errors'])->toContain('Ort muss Buchstaben enthalten.')
->and($result['errors'])->toContain('Postleitzahl enthaelt ungueltige Zeichen.');
});
it('blocks placeholder delivery addresses', function () {
$result = (new DhlAddressValidator)->validate(validDhlAddress([
'street' => 'Teststrasse',
'city' => 'Fakeort',
]));
expect($result['status'])->toBe('error')
->and($result['can_create_label'])->toBeFalse()
->and($result['errors'])->toContain('Straße wirkt wie eine Test- oder Platzhalteradresse.')
->and($result['errors'])->toContain('Ort wirkt wie eine Test- oder Platzhalterangabe.');
});
it('blocks DACH addresses with house numbers without digits', function () {
$result = (new DhlAddressValidator)->validate(validDhlAddress([
'house_number' => 'links',
]));
expect($result['status'])->toBe('error')
->and($result['can_create_label'])->toBeFalse()
->and($result['errors'])->toContain('Hausnummer muss fuer DACH-Adressen eine Ziffer enthalten.');
});
it('allows warning-only addresses but marks them for review', function () {
$result = (new DhlAddressValidator)->validate(validDhlAddress([
'phone' => '',
]));
expect($result['status'])->toBe('warning')
->and($result['can_create_label'])->toBeTrue()
->and($result['warnings'])->toContain('Telefonnummer fehlt. DHL kann Empfaenger bei Zustellproblemen eventuell nicht kontaktieren.');
});
it('validates German packstation addresses', function () {
$result = (new DhlAddressValidator)->validate(validDhlAddress([
'street' => 'Packstation',
'house_number' => '145',
'postnumber' => '12345678',
]));
expect($result['status'])->toBe('valid')
->and($result['can_create_label'])->toBeTrue();
});
it('blocks invalid packstation postnumbers', function () {
$result = (new DhlAddressValidator)->validate(validDhlAddress([
'street' => 'Packstation',
'house_number' => '145',
'postnumber' => 'abc',
]));
expect($result['status'])->toBe('error')
->and($result['errors'])->toContain('DHL Postnummer muss 6-10 Ziffern enthalten.');
});
it('requires postnumber and locker number for packstation addresses', function () {
$result = (new DhlAddressValidator)->validate(validDhlAddress([
'street' => 'Packstation',
'house_number' => '',
'postnumber' => '',
]));
expect($result['status'])->toBe('error')
->and($result['errors'])->toContain('DHL Postnummer ist fuer Packstation/Paketbox erforderlich.');
});

View file

@ -0,0 +1,100 @@
<?php
use App\Models\ShoppingOrder;
use App\Services\DhlDataHelper;
function dhlReferenceOrder(): ShoppingOrder
{
$order = new ShoppingOrder;
$order->id = 98765;
return $order;
}
function dhlReferenceOptions(array $overrides = []): array
{
return array_merge([
'product_code' => 'V01PAK',
'shipping_address' => [
'firstname' => 'Max',
'lastname' => 'Mustermann',
'company' => '',
'address' => 'Hauptstrasse',
'houseNumber' => '5',
'zipcode' => '10115',
'city' => 'Berlin',
'country' => (object) ['code' => 'DE'],
'email' => 'max@example.com',
'phone' => '+4930123456',
'postnumber' => null,
],
], $overrides);
}
function dhlReferenceConfig(): array
{
return [
'default_product' => 'V01PAK',
'label_format' => 'PDF',
'dimensions' => [
'V01PAK' => ['length' => 30, 'width' => 20, 'height' => 10],
'default' => ['length' => 30, 'width' => 20, 'height' => 10],
],
'sender' => [
'company' => 'mivita care gmbh',
'name' => '',
'street' => 'Leinfeld',
'house_number' => '2',
'postalCode' => '87755',
'city' => 'Kirchhaslach',
'country' => 'DE',
'email' => 'versand@example.com',
'phone' => '+4987654321',
],
];
}
it('uses the admin shipment reference for DHL order data', function () {
$orderData = DhlDataHelper::prepareOrderData(
dhlReferenceOrder(),
1.2,
dhlReferenceOptions(['reference' => 'Nachlieferung Mai']),
dhlReferenceConfig()
);
expect($orderData['reference'])->toBe('Nachlieferung Mai');
});
it('falls back to the order reference when no admin reference is given', function () {
$orderData = DhlDataHelper::prepareOrderData(
dhlReferenceOrder(),
1.2,
dhlReferenceOptions(['reference' => '']),
dhlReferenceConfig()
);
expect($orderData['reference'])->toBe('Order-98765');
});
it('normalizes the DHL reference to the API length limit', function () {
$orderData = DhlDataHelper::prepareOrderData(
dhlReferenceOrder(),
1.2,
dhlReferenceOptions(['reference' => 'Sehr lange interne Referenz fuer DHL Label Nachlieferung']),
dhlReferenceConfig()
);
expect(strlen($orderData['reference']))->toBe(35)
->and($orderData['reference'])->toBe('Sehr lange interne Referenz fuer DH');
});
it('passes the DHL mustEncode option into order data', function () {
$orderData = DhlDataHelper::prepareOrderData(
dhlReferenceOrder(),
1.2,
dhlReferenceOptions(['print_only_if_codeable' => false]),
dhlReferenceConfig()
);
expect($orderData['print_only_if_codeable'])->toBeFalse();
});

View file

@ -0,0 +1,83 @@
<?php
use App\Services\DhlProductResolver;
beforeEach(function () {
config([
'dhl.config_source' => 'env',
'dhl.international_countries' => ['AT', 'ES'],
]);
});
it('resolves domestic DHL products for Germany', function () {
$resolver = new DhlProductResolver;
expect($resolver->resolveForShipment('DE', 'V62KP'))->toMatchArray([
'country_code' => 'DE',
'dhl_country_code' => 'DEU',
'product_code' => 'V62KP',
]);
});
it('suggests international parcel for Austria and Spain', function (string $countryCode) {
$resolver = new DhlProductResolver;
expect($resolver->resolveProductCode($countryCode, null, 'V01PAK'))->toBe('V53PAK');
})->with([
'austria' => 'AT',
'spain' => 'ES',
]);
it('rejects unsupported destination countries', function () {
(new DhlProductResolver)->resolveProductCode('FR', null, 'V01PAK');
})->throws(InvalidArgumentException::class, 'DHL-Versand in das Zielland FR ist aktuell nicht freigegeben.');
it('uses configured international destination countries', function () {
$previousConfigSource = config('dhl.config_source');
$previousCountries = config('dhl.international_countries');
config([
'dhl.config_source' => 'env',
'dhl.international_countries' => ['AT', 'FR'],
]);
try {
$resolver = new DhlProductResolver;
expect($resolver->resolveProductCode('FR', null, 'V01PAK'))->toBe('V53PAK')
->and($resolver->getProductSuggestionsByCountry())->toMatchArray([
'DE' => 'V01PAK',
'FR' => 'V53PAK',
]);
} finally {
config([
'dhl.config_source' => $previousConfigSource,
'dhl.international_countries' => $previousCountries,
]);
}
});
it('normalizes configurable international countries', function () {
expect(DhlProductResolver::normalizeCountryCodeList([' at ', 'DE', 'XX', 'FR', 'AT']))->toBe(['AT', 'FR']);
});
it('describes DHL product scope for preflight checks', function () {
$resolver = new DhlProductResolver;
expect($resolver->getProductScope('V01PAK'))->toBe('national')
->and($resolver->getProductScopeLabel('V01PAK'))->toBe('DHL Paket National')
->and($resolver->getProductScope('V53PAK'))->toBe('international')
->and($resolver->getProductScopeLabel('V53PAK'))->toBe('DHL Paket International');
});
it('rejects domestic products for international shipments when explicitly selected', function () {
(new DhlProductResolver)->resolveProductCode('AT', 'V01PAK');
})->throws(InvalidArgumentException::class, 'Produkt V01PAK ist fuer DHL-Versand in das Zielland AT nicht erlaubt.');
it('does not fallback unknown countries to Germany', function () {
(new DhlProductResolver)->toDhlCountryCode('XX');
})->throws(InvalidArgumentException::class, 'DHL-Laendercode XX wird nicht unterstuetzt.');
it('requires a billing number for the resolved product', function () {
(new DhlProductResolver)->assertBillingNumber('V53PAK', null);
})->throws(InvalidArgumentException::class, 'Keine DHL-Abrechnungsnummer fuer Produkt V53PAK konfiguriert.');

View file

@ -0,0 +1,96 @@
<?php
use Acme\Dhl\Models\DhlShipment;
use App\Models\ShoppingOrder;
use App\Services\DhlTrackingService;
it('normalizes legacy cancelled status to internal canceled status', function () {
expect(DhlShipment::normalizeStatus('cancelled'))->toBe('canceled')
->and(DhlShipment::normalizeStatus('canceled'))->toBe('canceled');
});
it('translates canceled and legacy cancelled shipments consistently', function () {
app()->setLocale('de');
$canceledShipment = new DhlShipment(['status' => 'canceled']);
$legacyShipment = new DhlShipment(['status' => 'cancelled']);
expect($canceledShipment->getStatusTranslation())->toBe('Storniert')
->and($legacyShipment->getStatusTranslation())->toBe('Storniert')
->and($canceledShipment->getStatusBadgeClass())->toBe('warning')
->and($legacyShipment->getStatusBadgeClass())->toBe('warning');
});
it('returns tracking email history with latest entries first', function () {
$shipment = new DhlShipment([
'api_response_data' => [
'tracking_email_history' => [
[
'sent_at' => '2026-05-27T08:00:00+00:00',
'type' => 'auto',
'status' => 'in_transit',
'recipient_email' => 'first@example.test',
],
[
'sent_at' => '2026-05-27T09:00:00+00:00',
'type' => 'manual',
'status' => 'out_for_delivery',
'recipient_email' => 'second@example.test',
],
],
],
]);
$history = $shipment->getTrackingEmailHistory();
expect($history)->toHaveCount(2)
->and($history[0]['recipient_email'])->toBe('second@example.test')
->and($history[0]['status'])->toBe('out_for_delivery')
->and(DhlShipment::getStatusBadgeClassFor($history[0]['status']))->toBe('primary');
});
it('returns empty tracking email history for legacy shipments without history', function () {
$shipment = new DhlShipment(['api_response_data' => ['items' => []]]);
expect($shipment->getTrackingEmailHistory())->toBe([]);
});
it('triggers tracking emails for relevant status changes only once', function () {
$order = new ShoppingOrder;
$shipment = new DhlShipment([
'status' => 'out_for_delivery',
'dhl_shipment_no' => '00340435065133',
'email' => 'customer@example.test',
]);
$shipment->setRelation('shoppingOrder', $order);
expect($shipment->shouldTriggerTrackingEmail('created'))->toBeTrue()
->and($shipment->shouldTriggerTrackingEmail('out_for_delivery'))->toBeFalse();
$shipment->tracking_email_sent_at = now();
expect($shipment->shouldTriggerTrackingEmail('created'))->toBeFalse();
});
it('does not trigger tracking emails for terminal delivered status', function () {
$order = new ShoppingOrder;
$shipment = new DhlShipment([
'status' => 'delivered',
'dhl_shipment_no' => '00340435065133',
'email' => 'customer@example.test',
]);
$shipment->setRelation('shoppingOrder', $order);
expect($shipment->shouldTriggerTrackingEmail('in_transit'))->toBeFalse();
});
it('maps DHL status variants to internal tracking statuses', function (string $dhlStatus, string $internalStatus) {
expect(DhlTrackingService::mapDhlStatusToInternal($dhlStatus))->toBe($internalStatus);
})->with([
'transit' => ['transit', 'in_transit'],
'in-transit' => ['in-transit', 'in_transit'],
'out-for-delivery' => ['out-for-delivery', 'out_for_delivery'],
'out_for_delivery' => ['out_for_delivery', 'out_for_delivery'],
]);

View file

@ -0,0 +1,60 @@
<?php
use App\Models\Product;
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Services\DhlShipmentWeightCalculator;
use Illuminate\Database\Eloquent\Collection;
function dhlWeightOrder(int $baseWeightGrams, array $items = []): ShoppingOrder
{
$order = new ShoppingOrder;
$order->weight = $baseWeightGrams;
$order->setRelation('shopping_order_items', new Collection($items));
return $order;
}
function dhlWeightItem(int $comp, int $qty, int $productWeightGrams): ShoppingOrderItem
{
$product = new Product;
$product->weight = $productWeightGrams;
$item = new ShoppingOrderItem;
$item->comp = $comp;
$item->qty = $qty;
$item->setRelation('product', $product);
return $item;
}
it('uses the shopping order weight as DHL base weight', function () {
$weight = (new DhlShipmentWeightCalculator)->calculate(dhlWeightOrder(1250));
expect($weight)->toBe(1.25);
});
it('adds compensation product weights to the DHL weight', function () {
$weight = (new DhlShipmentWeightCalculator)->calculate(dhlWeightOrder(500, [
dhlWeightItem(comp: 1, qty: 2, productWeightGrams: 250),
dhlWeightItem(comp: 0, qty: 10, productWeightGrams: 1000),
]));
expect($weight)->toBe(1.0);
});
it('falls back to a safe default DHL weight when no weight exists', function () {
$weight = (new DhlShipmentWeightCalculator)->calculate(dhlWeightOrder(0));
expect($weight)->toBe(1.0);
});
it('validates product specific DHL weight limits', function () {
(new DhlShipmentWeightCalculator)->assertWithinProductLimit(1.001, 'V62KP');
})->throws(InvalidArgumentException::class, 'Gewicht 1.001 kg ueberschreitet das DHL-Maximalgewicht fuer V62KP (1.0 kg).');
it('allows regular parcel products up to the DHL parcel limit', function () {
(new DhlShipmentWeightCalculator)->assertWithinProductLimit(31.5, 'V01PAK');
expect(true)->toBeTrue();
});

View file

@ -0,0 +1,130 @@
<?php
use Acme\Dhl\Exceptions\DhlAddressValidationException;
use Acme\Dhl\Services\ShippingService;
use Acme\Dhl\Support\DhlClient;
function makeDhlPhaseOneShippingService(): ShippingService
{
return new ShippingService(new DhlClient('https://api-sandbox.dhl.com', null, null, null));
}
function validDhlPhaseOneOrderData(string $productCode, string $countryCode = 'DE'): array
{
return [
'order_id' => 123456,
'weight_kg' => 0.5,
'product_code' => $productCode,
'label_format' => 'PDF',
'shipper' => [
'name' => 'mivita care gmbh',
'street' => 'Leinfeld',
'houseNumber' => '2',
'postalCode' => '87755',
'city' => 'Kirchhaslach',
'country' => 'DE',
],
'consignee' => [
'name' => 'Max Mustermann',
'street' => 'Hauptstrasse',
'houseNumber' => '5',
'postalCode' => '10115',
'city' => 'Berlin',
'country' => $countryCode,
],
'reference' => 'ORDER-123456',
];
}
it('accepts DHL Kleinpaket as a product code', function () {
$service = makeDhlPhaseOneShippingService();
$method = new ReflectionMethod($service, 'validateOrderData');
$method->setAccessible(true);
$validated = $method->invoke($service, validDhlPhaseOneOrderData('V62KP'));
expect($validated['product_code'])->toBe('V62KP');
});
it('builds an international DHL parcel payload for Austria', function () {
config([
'dhl.account_numbers.V53PAK' => '63144073555301',
'dhl.legacy.sandbox' => false,
'dhl.legacy.test_mode' => false,
]);
$service = makeDhlPhaseOneShippingService();
$method = new ReflectionMethod($service, 'buildShipmentPayload');
$method->setAccessible(true);
$payload = $method->invoke($service, validDhlPhaseOneOrderData('V53PAK', 'AT'));
expect($payload['shipments'][0]['product'])->toBe('V53PAK')
->and($payload['shipments'][0]['billingNumber'])->toBe('63144073555301')
->and($payload['shipments'][0]['consignee']['country'])->toBe('AUT');
});
it('rejects unsupported countries instead of falling back to Germany', function () {
$service = makeDhlPhaseOneShippingService();
$method = new ReflectionMethod($service, 'buildShipmentPayload');
$method->setAccessible(true);
$method->invoke($service, validDhlPhaseOneOrderData('V53PAK', 'FR'));
})->throws(InvalidArgumentException::class);
it('rejects legacy Warenpost for new labels', function () {
$service = makeDhlPhaseOneShippingService();
$method = new ReflectionMethod($service, 'validateOrderData');
$method->setAccessible(true);
$method->invoke($service, validDhlPhaseOneOrderData('V62WP'));
})->throws(InvalidArgumentException::class);
it('builds a DHL Kleinpaket payload', function () {
config([
'dhl.account_numbers.V62KP' => '63144073556201',
'dhl.legacy.sandbox' => false,
'dhl.legacy.test_mode' => false,
]);
$service = makeDhlPhaseOneShippingService();
$method = new ReflectionMethod($service, 'buildShipmentPayload');
$method->setAccessible(true);
$payload = $method->invoke($service, validDhlPhaseOneOrderData('V62KP'));
expect($payload['shipments'][0]['product'])->toBe('V62KP')
->and($payload['shipments'][0]['billingNumber'])->toBe('63144073556201')
->and($payload['shipments'][0]['refNo'])->toBe('ORDER-123456');
});
it('uses mustEncode only for German consignee addresses', function () {
$service = makeDhlPhaseOneShippingService();
$method = new ReflectionMethod($service, 'shouldUseMustEncode');
$method->setAccessible(true);
expect($method->invoke($service, validDhlPhaseOneOrderData('V01PAK', 'DE') + ['print_only_if_codeable' => true]))->toBeTrue()
->and($method->invoke($service, validDhlPhaseOneOrderData('V53PAK', 'AT') + ['print_only_if_codeable' => true]))->toBeFalse()
->and($method->invoke($service, validDhlPhaseOneOrderData('V01PAK', 'DE') + ['print_only_if_codeable' => false]))->toBeFalse();
});
it('turns non-codeable DHL responses into address validation errors', function () {
$service = makeDhlPhaseOneShippingService();
$method = new ReflectionMethod($service, 'assertSuccessfulShipmentResponse');
$method->setAccessible(true);
$method->invoke($service, [
'status' => [
'statusCode' => 200,
],
'items' => [[
'sstatus' => [
'statusCode' => 400,
'detail' => 'Consignee address is not encodable.',
],
'validationMessages' => [[
'validationMessage' => 'Address is not encodable.',
]],
]],
], true);
})->throws(DhlAddressValidationException::class, 'DHL kann diese Adresse nicht leitcodieren.');

View file

@ -0,0 +1,35 @@
<?php
use Acme\Dhl\Models\DhlShipment;
use App\Jobs\TrackShipmentJob;
use App\Services\DhlTrackingService;
use Mockery\MockInterface;
afterEach(function () {
Mockery::close();
});
it('uses the current DHL tracking service for queued tracking updates', function () {
$shipment = new DhlShipment([
'id' => 123,
'dhl_shipment_no' => '00340434161094000001',
'status' => 'created',
]);
$options = ['auto_retrack' => false];
$job = new TrackShipmentJob($shipment, $options);
/** @var DhlTrackingService&MockInterface $trackingService */
$trackingService = Mockery::mock(DhlTrackingService::class);
$trackingService
->shouldReceive('updateTrackingNow')
->once()
->with($shipment, $options)
->andReturn([
'success' => true,
'tracking_status' => 'transit',
'tracking_completed' => false,
]);
$job->handle($trackingService);
});