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

@ -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);
});