@if($shipment->wasTrackingEmailSent())
-
+
- E-Mail gesendet
+ Zuletzt gesendet
Am {{ $shipment->tracking_email_sent_at->format('d.m.Y \u\m H:i') }} Uhr
({{ $shipment->tracking_email_type === 'auto' ? 'automatisch' : 'manuell' }})
+
+ @php($trackingEmailHistory = $shipment->getTrackingEmailHistory())
+ @if(!empty($trackingEmailHistory))
+
+
+
+
+ Zeitpunkt
+ Typ
+ Status
+ Empfänger
+ Sendungen
+
+
+
+ @foreach($trackingEmailHistory as $entry)
+
+
+ @if(!empty($entry['sent_at']))
+ {{ \Carbon\Carbon::parse($entry['sent_at'])->format('d.m.Y H:i') }}
+ @else
+ -
+ @endif
+
+
+
+ {{ ($entry['type'] ?? '') === 'auto' ? 'Automatisch' : 'Manuell' }}
+
+
+
+
+ {{ \Acme\Dhl\Models\DhlShipment::getStatusTranslationFor($entry['status'] ?? 'unknown') }}
+
+ @if(!empty($entry['tracking_status']))
+ {{ $entry['tracking_status'] }}
+ @endif
+
+
+ @if(!empty($entry['recipient_email']))
+ {{ $entry['recipient_email'] }}
+ @else
+ -
+ @endif
+
+
+ @if(!empty($entry['included_shipment_ids']))
+ #{{ implode(', #', $entry['included_shipment_ids']) }}
+ @else
+ -
+ @endif
+
+
+ @endforeach
+
+
+
+ @endif
@else
diff --git a/resources/views/admin/settings/index.blade.php b/resources/views/admin/settings/index.blade.php
index 5606f63..9ddae0d 100755
--- a/resources/views/admin/settings/index.blade.php
+++ b/resources/views/admin/settings/index.blade.php
@@ -170,11 +170,15 @@
{{ __('Standard Produktcode') }}*
+ @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') }}
@@ -196,6 +200,50 @@
Deaktiviert: Versandlabel werden sofort erstellt (synchron)
+
+
+ {!! 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']) !!}
+ DHL-Leitcodierung erzwingen (mustEncode)
+
+ {{ Form::hidden('settings[dhl_print_only_if_codeable][type]', 'bool') }}
+
+ Aktiviert für deutsche Empfängeradressen mustEncode=true. DHL erstellt dann nur ein Label, wenn die Adresse leitcodierbar ist.
+
+
+
API Login testen
@@ -227,9 +275,9 @@
{{ Form::hidden('settings[dhl_account_v53pak][type]', 'text') }}
- {{ __('V62WP - Warenpost National') }}
- {{ 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') }}
+ {{ __('V62KP - DHL Kleinpaket') }}
+ {{ 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') }}
diff --git a/resources/views/public/tracking.blade.php b/resources/views/public/tracking.blade.php
index 08658ec..4aa66fa 100644
--- a/resources/views/public/tracking.blade.php
+++ b/resources/views/public/tracking.blade.php
@@ -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';
diff --git a/routes/domains/crm.php b/routes/domains/crm.php
index efd48cd..ec4307d 100644
--- a/routes/domains/crm.php
+++ b/routes/domains/crm.php
@@ -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');
diff --git a/tests/Pest.php b/tests/Pest.php
index efc2cb5..f7b4296 100644
--- a/tests/Pest.php
+++ b/tests/Pest.php
@@ -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');
diff --git a/tests/Unit/Dhl/DhlAddressValidatorTest.php b/tests/Unit/Dhl/DhlAddressValidatorTest.php
new file mode 100644
index 0000000..1bd49c2
--- /dev/null
+++ b/tests/Unit/Dhl/DhlAddressValidatorTest.php
@@ -0,0 +1,174 @@
+ '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.');
+});
diff --git a/tests/Unit/Dhl/DhlDataHelperReferenceTest.php b/tests/Unit/Dhl/DhlDataHelperReferenceTest.php
new file mode 100644
index 0000000..dae72a7
--- /dev/null
+++ b/tests/Unit/Dhl/DhlDataHelperReferenceTest.php
@@ -0,0 +1,100 @@
+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();
+});
diff --git a/tests/Unit/Dhl/DhlProductResolverTest.php b/tests/Unit/Dhl/DhlProductResolverTest.php
new file mode 100644
index 0000000..d65525f
--- /dev/null
+++ b/tests/Unit/Dhl/DhlProductResolverTest.php
@@ -0,0 +1,83 @@
+ '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.');
diff --git a/tests/Unit/Dhl/DhlShipmentStatusTest.php b/tests/Unit/Dhl/DhlShipmentStatusTest.php
new file mode 100644
index 0000000..e2be2c5
--- /dev/null
+++ b/tests/Unit/Dhl/DhlShipmentStatusTest.php
@@ -0,0 +1,96 @@
+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'],
+]);
diff --git a/tests/Unit/Dhl/DhlShipmentWeightCalculatorTest.php b/tests/Unit/Dhl/DhlShipmentWeightCalculatorTest.php
new file mode 100644
index 0000000..5107edd
--- /dev/null
+++ b/tests/Unit/Dhl/DhlShipmentWeightCalculatorTest.php
@@ -0,0 +1,60 @@
+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();
+});
diff --git a/tests/Unit/Dhl/ShippingServiceProductCodeTest.php b/tests/Unit/Dhl/ShippingServiceProductCodeTest.php
new file mode 100644
index 0000000..c10e790
--- /dev/null
+++ b/tests/Unit/Dhl/ShippingServiceProductCodeTest.php
@@ -0,0 +1,130 @@
+ 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.');
diff --git a/tests/Unit/Dhl/TrackShipmentJobTest.php b/tests/Unit/Dhl/TrackShipmentJobTest.php
new file mode 100644
index 0000000..a029554
--- /dev/null
+++ b/tests/Unit/Dhl/TrackShipmentJobTest.php
@@ -0,0 +1,35 @@
+ 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);
+});