mivita/dev/2026-05-13-dhl-modul/ENTWICKLUNGSKONZEPT-DHL-MODUL.md

1462 lines
88 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Entwicklungskonzept DHL Modul
Stand: 27.05.2026
## Ziel
Diese Datei ist die aktuelle Arbeitsgrundlage fuer die Weiterentwicklung des DHL Moduls. Die bisherigen Markdown-Dateien in diesem Ordner dokumentieren abgeschlossene oder ueberholte Zwischenstaende, insbesondere den frueheren SDK-Ansatz, Paketinstallation, SSL/cURL-Fixes und einzelne abgeschlossene Entwicklungsschritte.
Aktuelle Anforderungen kommen aus `docs/dhl/Anpassung DHL Modul.md`:
- Internationaler Versand ausserhalb Deutschlands, insbesondere Oesterreich und Spanien.
- Freies Feld fuer Sendungsreferenz oder interne Hinweise wie "Nachlieferung".
- Adressvalidierung vor Labelerstellung, damit fehlerhafte Labels und kostenpflichtige Stornos vermieden werden.
- Storno im DHL Cockpit pruefen und stabilisieren.
- Gewicht von Kompensationsprodukten in das DHL-Paketgewicht einrechnen.
- Tracking-Mails auf Rhythmus, Ausloeser und Mehrfachversand pruefen.
- Tracking-Codes in Admin, User-Portal und User-N-Portal sichtbar machen.
- Tracking-Mail-Versand nur bei Statusaenderung.
- DHL-Umstellung von `V62WP` Warenpost auf `V62KP` DHL Kleinpaket bis spaetestens 31.05.2026.
## Aktueller technischer Stand
Das produktive Modul basiert auf dem Paket `packages/acme-laravel-dhl` und verwendet die DHL REST API ueber `Acme\Dhl\Support\DhlClient`. Die zentrale Tabelle ist `dhl_package_shipments`.
Wichtige produktive Einstiegspunkte:
- `app/Http/Controllers/DhlShipmentController.php`
- `app/Services/DhlShipmentService.php`
- `app/Services/DhlModalService.php`
- `app/Services/DhlDataHelper.php`
- `app/Services/DhlTrackingService.php`
- `packages/acme-laravel-dhl/src/Services/ShippingService.php`
- `packages/acme-laravel-dhl/src/Services/ReturnsService.php`
- `packages/acme-laravel-dhl/src/Models/DhlShipment.php`
- `config/dhl.php`
Historische Dokumente erwaehnen teilweise alte Strukturen wie `App\Models\DhlShipment`, `dhl_shipments` oder einen konsolidierten `DhlApiService`. Diese Angaben sind nicht mehr massgeblich, ausser sie werden explizit in aktuellem Code noch referenziert. Aktuell gibt es genau dort noch Altlasten, die bereinigt werden muessen.
## Offensichtliche Befunde
### 1. Produktkuerzel `V62WP` ist veraltet
`V62WP` ist weiterhin in Konfiguration, Admin-Settings, Validierung, Produktauswahl und Sprachdateien vorhanden. DHL verlangt die Umstellung auf `V62KP`.
Betroffene Stellen:
- `config/dhl.php`
- `app/Http/Controllers/SettingController.php`
- `app/Services/DhlModalService.php`
- `packages/acme-laravel-dhl/src/Services/ShippingService.php`
- `resources/lang/*/dhl.php`
Risiko: Kleinpaket/Warenpost-Labels koennen nach der DHL-Frist abgelehnt werden.
### 2. Async Tracking verwendet veraltetes Model
`app/Jobs/TrackShipmentJob.php` importiert `App\Models\DhlShipment`, dieses Model existiert im aktuellen System nicht mehr. Die produktive DHL-Integration verwendet `Acme\Dhl\Models\DhlShipment`.
Risiko: Asynchrones Tracking bricht zur Laufzeit, sobald Queue-Tracking genutzt wird.
### 3. Statuswerte fuer Storno sind inkonsistent
Im Paket wird bei erfolgreichem Storno `canceled` gesetzt. Andere Stellen, Uebersetzungen und historische Dokumente verwenden `cancelled`.
Risiko: Filter, Badges, Terminal-Status, Uebersetzungen und Storno-Buttons verhalten sich uneinheitlich.
### 4. Storno ist technisch vorhanden, aber nicht robust genug
Storno laeuft ueber `DELETE /parcel/de/shipping/v2/orders/{shipmentNumber}`. Der Code prueft `canCancel()`, speichert aber Fehler nur begrenzt fachlich verwertbar. Produktspezifische Einschraenkungen wie Warenpost/Kleinpaket sind nicht sauber modelliert.
Risiko: Anwender sehen generische Fehler und koennen nicht erkennen, ob ein Storno produktbedingt, statusbedingt, API-bedingt oder wegen Sandbox/Production-Mismatch scheitert.
### 5. Internationaler Versand ist nur teilweise vorbereitet
`V53PAK` ist als internationales Produkt vorhanden, und einige Laender werden in 3-stellige ISO-Codes konvertiert. Dennoch gibt es keinen zentralen Produktentscheid je Zielland, keine harte Validierung nicht unterstuetzter Laender und einen gefaehrlichen Fallback auf `DEU`.
Risiko: Sendungen nach Oesterreich, Spanien oder weitere Laender koennen falsche Produktcodes, falsche Abrechnungsnummern oder falsche Laendercodes erhalten.
### 6. Adressvalidierung ist nur formal
Aktuell prueft das Modul Pflichtfelder, Hausnummern und einfache PLZ-Regeln. Eine echte Pruefung, ob Strasse, PLZ und Ort postalisch existieren, findet nicht statt.
Empfohlene Loesung: DHL/Post & DHL `DATAFACTORY AUTOCOMPLETE 2.0` fuer DE/AT/CH pruefen und integrieren. Alternativen fuer breiteren Laenderumfang: Loqate, Google Address Validation oder HERE. Wichtig ist eine harte Sperre bei nicht versandfaehigen Adressen vor Labelerstellung.
### 7. Referenzfeld ist API-seitig vorhanden, aber nicht im Cockpit nutzbar
`ShippingService` kann `reference` nach `refNo` mappen. `DhlDataHelper` setzt aktuell automatisch `Order-{id}`.
Risiko: Admins koennen Hinweise wie "Nachlieferung" nicht strukturiert am Label/API-Auftrag hinterlegen.
### 8. Gewicht von Kompensationsprodukten fehlt
Kompensationsprodukte werden im Warenkorb mit Gewicht `0` abgelegt, damit die Kompensationslogik nicht beeinflusst wird. Das DHL-Gewicht kommt aus `ShoppingOrder->weight` und enthaelt dieses Produktgewicht dadurch nicht.
Risiko: DHL-Label wird mit zu geringem Paketgewicht erstellt.
### 9. Tracking-Mail-Logik ist grundsaetzlich brauchbar, muss aber abgesichert werden
Der Scheduler ruft stuendlich `dhl:update-tracking --days=30 --send-emails` auf. Statusabhaengige Intervalle verhindern zu viele API-Calls. Automatische Mails werden nur gesendet, wenn eine Sendung neu auf `in_transit` wechselt und noch keine Mail markiert wurde.
Risiko: Statusspruenge direkt auf `out_for_delivery` oder besondere DHL-Statuscodes koennen ohne Mail bleiben. Mehrere Sendungen einer Bestellung werden teils zusammengefasst, aber die fachliche Regel muss final bestaetigt werden.
## Entwicklungskonzept
### Phase 1: Pflichtkorrekturen vor DHL-Frist
1. `V62WP` vollstaendig auf `V62KP` migrieren.
2. Neue Konfigurationskeys fuer `DHL_ACCOUNT_NUMBER_V62KP`, Admin-Setting `dhl_account_v62kp`, Dimensionen und Uebersetzungen einfuehren.
3. `ShippingService` Validierung um `V62KP` erweitern und `V62WP` entfernen oder nur noch als Legacy-Mapping fuer Altdaten anzeigen.
4. Bestehende Sendungen mit `V62WP` historisch lesbar lassen, aber neue Labelerstellung blockieren.
5. Tests fuer Produktcode-Auswahl, Validierung und Payload-Erstellung schreiben.
### Phase 2: Stabilisierung von Tracking und Storno
1. `TrackShipmentJob` auf aktuelles Model und aktuellen `DhlTrackingService` umstellen.
2. Statuswerte vereinheitlichen. Empfehlung: intern `canceled` verwenden, Uebersetzung auf Deutsch "Storniert".
3. `TERMINAL_STATUSES`, Badges, Filter und Sprachdateien entsprechend angleichen.
4. Storno-Fehler strukturiert speichern: HTTP-Status, DHL-Fehlercode, DHL-Detailtext, Zeitpunkt.
5. Admin-Feedback verbessern: nicht stornierbar wegen Status, Produkt, API-Antwort oder nicht auffindbarer DHL-Sendung.
6. Tests fuer erfolgreiche Stornierung, bereits stornierte Sendung, nicht stornierbare Sendung und API-Fehler.
### Phase 3: Internationalisierung Versand
1. Zentralen Service fuer Produkt-/Billing-Entscheidung einfuehren, z. B. `DhlProductResolver`.
2. Zielland, Produktcode, Abrechnungsnummer und erlaubte Services dort validieren.
3. Regeln initial:
- `DE`: `V01PAK` oder `V62KP`
- `AT`, `ES` und weitere aktivierte Laender: `V53PAK`
4. Fallback auf `DEU` entfernen. Unbekannte Laender muessen mit klarer Fehlermeldung abbrechen.
5. Cockpit-Formular: Produkt anhand Zielland vorschlagen, aber Admin-Korrektur erlauben.
### Phase 4: Adressvalidierung vor Labelerstellung
1. Neuen serverseitigen Validierungsendpunkt fuer DHL-Adressen schaffen.
2. Basisvalidierung behalten: Pflichtfelder, Land, PLZ-Format, Hausnummer, Packstation/Postnummer.
3. DHL DATAFACTORY AUTOCOMPLETE 2.0 fuer DE/AT/CH evaluieren.
4. Falls DHL fuer alle benoetigten Laender nicht ausreicht, externen Provider evaluieren.
5. UI-Status einfuehren:
- gueltig: Labelerstellung erlaubt
- Warnung: Admin kann bewusst fortfahren
- Fehler: Labelerstellung gesperrt
6. Validierungsergebnis optional am Shipment/Order protokollieren.
### Phase 5: Referenzfeld und Admin-UX
1. Neues Formularfeld `reference` oder `shipment_reference` im DHL Cockpit.
2. Wert an `DhlDataHelper::prepareOrderData()` uebergeben.
3. `ShippingService` nutzt vorhandenes Mapping nach `refNo`.
4. Referenz im Shipment speichern, damit spaeter nachvollziehbar ist, warum eine Sendung erstellt wurde.
5. Laengenlimit der DHL API beachten, aktuell maximal 35 Zeichen.
### Phase 6: DHL-Gewicht korrekt berechnen
1. Separaten Gewichtsdienst fuer DHL einfuehren, z. B. `DhlShipmentWeightCalculator`.
2. Basis: `ShoppingOrder->weight`.
3. Fuer `shopping_order_items` mit `comp > 0` das Produktgewicht aus `Product->weight` nachladen und addieren.
4. Nur DHL-Gewicht anpassen, nicht die bestehende Warenkorb-/Versandkostenlogik.
5. Rundung und DHL-Grenzwerte je Produkt testen.
### Phase 7: Tracking-Codes und Mails fachlich finalisieren
1. Bestehende Admin-Anzeige pruefen und bei Bedarf vereinheitlichen.
2. Tracking-Code-Anzeige in User-Portal und User-N-Portal ergaenzen.
3. Mail-Regel final definieren:
- automatisch nur einmal pro Sendung
- nur bei relevanter Statusaenderung
- mehrere Sendungen einer Bestellung sinnvoll zusammenfassen
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.
2. Storno stabilisieren und bessere Fehlermeldungen im Cockpit anzeigen.
3. Internationalen Produktresolver einbauen.
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
- Feature-Tests fuer Controller-Endpunkte: Label erstellen, Storno, Tracking-Mail, Tracking-Update.
- Unit-Tests fuer Produktresolver, Gewichtskalkulation und Adressvalidierung.
- HTTP-Fakes fuer DHL API Responses inklusive Fehlerfaelle.
- 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.
### 27.05.2026 - Nachtrag: DHL-Laenderauswahl in Settings speicherbar
Status: abgeschlossen.
Problem:
- Die Checkboxen fuer `DHL Paket International Ziellaender` liessen sich im Admin sichtbar anklicken.
- Nach dem Speichern wurden die ausgewaehlten Laender aber nicht wieder angezeigt.
- Ursache war `DHL_CONFIG_SOURCE=env`: Dadurch wurden die gespeicherten Datenbankwerte fuer diese Laenderliste beim Lesen ueberdeckt.
- Zusaetzlich wurde eine leere Checkbox-Auswahl als `null` gespeichert und fiel dadurch wieder auf die `.env`-Konfiguration zurueck.
Umsetzung:
- `app/Http/Controllers/SettingController.php`
- `dhl_international_countries` wird beim Speichern normalisiert.
- Ein leerer Wert wird als leere Liste gespeichert.
- Beim Lesen wird ein vorhandener gespeicherter Array-Wert fuer diese Laenderliste verwendet, auch wenn `DHL_CONFIG_SOURCE=env` aktiv ist.
- `app/Services/DhlProductResolver.php`
- Nutzt gespeicherte DHL-Laender, sobald sie als Array vorhanden sind.
- Wenn keine `settings`-Tabelle verfuegbar ist, bleibt der Config-Fallback erhalten.
- `app/Models/Setting.php`
- Object-Settings speichern leere Arrays jetzt als Array statt als `null`.
- `tests/Unit/Dhl/DhlProductResolverTest.php`
- Tests fuer gespeicherte Laender bei `env`-Prioritaet.
- Test fuer bewusst leer gespeicherte Laenderliste.
Verifikation:
- `./vendor/bin/pint --dirty --format agent`
- `php artisan test --compact tests/Unit/Dhl`
- Ergebnis: 51 Tests bestanden, 117 Assertions.
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.
## Phase 9 - Security & Code-Hygiene Hardening (Audit 2026-05-27)
Status: umgesetzt am 2026-05-27.
Im Rahmen eines Audits der bestehenden DHL-Umsetzung wurden mehrere sicherheits- und konsistenzrelevante Punkte mit Prio 1 identifiziert und behoben.
### Prio 1.1 Credentials und PII aus Logs entfernen
Problem:
- `DhlShipmentService::createShipment()` schrieb die komplette DHL-Config inklusive `api_key`, `username`, `password`, `api_secret` und Abrechnungsnummern via `\Log::info('dhlConfig', $dhlConfig)` in das Application-Log.
- `DhlDataHelper::prepareOrderData()` schrieb das Roh-Options-Array (inkl. Versandadresse, Name, Telefon) in das Log.
- `ShippingService::createLabel()` schrieb die fertige Order-Daten und in `[DHL API] Sending payload to DHL` zusaetzlich `payload_json` mit dem kompletten Payload (Adressen, Abrechnungsnummer) ins Log. Die Fehlerbranch loggte den vollen Payload ebenfalls. Die Response loggte zudem das base64-kodierte Label.
Umsetzung:
- `app/Services/DhlShipmentService.php`
- Neue statische Helper `sanitizeDhlConfigForLog()` und `sanitizeOrderDataForLog()`.
- `sanitizeDhlConfigForLog()` ersetzt Geheimnisse durch boolesche `has_*`-Flags und gibt nur nicht-vertrauliche Konfigurationsmetadaten zurueck.
- `sanitizeOrderDataForLog()` reduziert Bestelldaten auf Routing-Felder (Order-ID, Produktcode, Empfaenger-Land, ersten beiden Stellen der PLZ, Reference-Flag).
- Bestehende Logs nutzen jetzt diese Helper.
- `app/Services/DhlDataHelper.php`
- Kein Roh-Dump des `$options`-Arrays mehr. Statt dessen strukturiertes Log mit `order_id`, Produktcode und Flags.
- `packages/acme-laravel-dhl/src/Services/ShippingService.php`
- Neue private Helper `buildPayloadLogContext()` und `buildResponseLogContext()`.
- `createLabel()`-Log nutzt `DhlShipmentService::sanitizeOrderDataForLog()`.
- Payload-Log enthaelt nur Produktcode, letzte vier Stellen der Abrechnungsnummer, Gewicht, Empfaenger-Land, `must_encode`-Flag.
- Response-Log enthaelt nur Sendungsnummer, Routing-Code, Status. Das base64-Label wird nicht mehr geloggt.
- Error-Branch verwendet ebenfalls den redaktierten Payload-Log.
Tests:
- `tests/Unit/Dhl/DhlSanitizeLoggingTest.php` (neu)
- Stellt sicher, dass `api_key`, `password`, `api_secret`, `username` und Abrechnungsnummern niemals im Log-Kontext landen.
- Stellt sicher, dass Name, Strasse, Telefon, E-Mail und PLZ aus den Bestelldaten heraus normalisiert werden.
### Prio 1.2 `CreateShipmentJob` darf `dhlConfig` nicht serialisieren
Problem:
- `App\Jobs\CreateShipmentJob` hat die DHL-Konfiguration im Konstruktor in eine `public $dhlConfig`-Property geschrieben. Dadurch wurden API-Key, Basic-Auth-Passwort und Abrechnungsnummern beim Dispatch (per `serialize()`) in den Queue-Speicher (Redis/Datenbank) abgelegt.
Umsetzung:
- `app/Jobs/CreateShipmentJob.php`
- `public $dhlConfig` entfernt.
- Der Konstruktor akzeptiert den Parameter weiterhin (Backwards-Compat), persistiert ihn aber nicht mehr.
- `handle()` laedt die DHL-Konfiguration jetzt ueber `SettingController::getDhlConfig()` direkt aus der kanonischen Quelle. Der Worker hat ohnehin Zugriff darauf.
Tests:
- `tests/Unit/Dhl/CreateShipmentJobSerializationTest.php` (neu)
- Serialisiert eine Job-Instanz und stellt sicher, dass keine der DHL-Secrets im Payload landen.
- Bestaetigt, dass es keine `dhlConfig`-Property mehr gibt.
### Prio 1.3 IDOR im DHL-Versand-Modal verhindern
Problem:
- `ModalController::handleDhlShipmentModal()` haengt am `auth`-Middleware, aber nicht am `admin`-Guard. Jeder eingeloggte CRM-Nutzer konnte `POST /modal/load` mit `action=create-dhl-shipment` und einer beliebigen Order-ID aufrufen und damit Empfaenger-Name, Adresse, E-Mail sowie bestehende Sendungen einer fremden Bestellung auslesen.
Umsetzung:
- `app/Http/Controllers/ModalController.php`
- Neue private Methode `authorizeDhlShipmentModal()` prueft `Auth::user()->isAdmin()` und antwortet sonst mit `403`.
- Wird vor `handleDhlShipmentModal()` aufgerufen.
Tests:
- `tests/Unit/Dhl/DhlModalAuthorizationTest.php` (neu)
- Gaeste, VIP-User (`admin == 1`) und regulaere Berater (`admin == 0`) erhalten `403`.
- Echte Admins (`admin >= 2`) werden durchgelassen.
### Prio 1.4 XSS-Schutz im Tracking-Frontend und in DataTables
Problem:
- Im Backend-DataTable `DhlShipmentController::index()`/`list()` wurde `customer = firstname + ' ' + lastname` per `addColumn` mit anschliessendem `rawColumns(['customer', ...])` ausgegeben. Manipulierte Adressdaten haetten HTML/JS injizieren koennen.
- Im oeffentlichen Tracking-Frontend `resources/views/public/tracking.blade.php` wurden `data.tracking_status`, `data.tracking_number`, `data.last_tracked_at` und `data.status` ungefiltert in jQuery-`.html()`-Templates eingebaut.
Umsetzung:
- `app/Http/Controllers/DhlShipmentController.php`
- `customer`-Spalte ist jetzt `e(trim($firstname.' '.$lastname))`.
- `resources/views/public/tracking.blade.php`
- Neue JS-Funktion `escapeTrackingHtml()` (HTML-Entity-Encoding fuer `&`, `<`, `>`, `"`, `'`).
- Alle Felder aus der Tracking-Response werden vor dem Einsetzen damit escaped.
- Trackingnummer fuer den DHL-Link wird zusaetzlich mit `encodeURIComponent()` geschuetzt.
- `showError()` nutzt `.text()` statt `.html()`.
- `getStatusBadge()` escaped Status-Default und CSS-Klassen, sodass unbekannte DHL-Statuscodes nicht aus dem `class`-Attribut ausbrechen koennen.
### Prio 1.5 `ReturnsService` an `DhlProductResolver` anbinden, `'DEU'`-Fallback entfernen
Problem:
- `ReturnsService::convertCountryCode()` und `convertAddressFor2LetterCountry()` hielten eine eigene Hardcoded-Liste an Laendercodes und fielen bei unbekannten Codes still auf `'DEU'` zurueck. Damit konnten Retouren fuer auslaendische Empfaenger versehentlich nach Deutschland geroutet werden und der `DhlProductResolver` (Single Source of Truth) wurde umgangen.
Umsetzung:
- `packages/acme-laravel-dhl/src/Services/ReturnsService.php`
- `convertCountryCode()` delegiert an `DhlProductResolver::toDhlCountryCode()`.
- `convertAddressFor2LetterCountry()` delegiert an `DhlProductResolver::normalizeCountryCode()`.
- Unbekannte Laender werfen jetzt eine `InvalidArgumentException`, statt unbemerkt eine deutsche Retoure zu erzeugen.
- `app/Http/Controllers/DhlShipmentController.php`
- Alle `'DEU'`-Defaults in den Retouren-Pfaden (`getBillingAddressForReturn()`, `createReturnLabelSync()`) durch `DhlProductResolver::DOMESTIC_COUNTRY` (`'DE'`) ersetzt, damit konsistente ISO-2-Codes an den Resolver gehen.
Tests:
- `tests/Unit/Dhl/ReturnsServiceCountryCodeTest.php` (neu)
- Mappt `DE/AT/CH/ES` korrekt auf ISO-3.
- Akzeptiert bereits korrekte ISO-3-Codes.
- Wirft fuer unbekannte Codes statt still `DEU` zurueckzugeben.
- Normalisiert Adressen zurueck auf ISO-2 und respektiert fehlende `country`-Felder.
### Prio 1.6 `houseNumber = '1'`-Default in `ShippingService::parseAddressFields` entfernen
Problem:
- Wenn der Adressparser keine Hausnummer aus der Strasse extrahieren konnte, hat er die Hausnummer still auf `'1'` gesetzt. Das fuehrte dazu, dass Pakete an die falsche Adresse zugestellt werden konnten, ohne dass der Operator etwas davon mitbekam.
Umsetzung:
- `packages/acme-laravel-dhl/src/Services/ShippingService.php`
- `parseAddressFields()` wirft jetzt eine `InvalidArgumentException` mit klarer deutscher Fehlermeldung, sobald keine Hausnummer ermittelt werden konnte.
- Das interne Erfolgs-Log enthaelt zusaetzlich nur noch Laengen / Praefixe, nicht mehr die volle Adresse.
Tests:
- `tests/Unit/Dhl/ShippingServiceParseAddressTest.php` (neu)
- Explizite Hausnummer wird unveraendert uebernommen.
- Kombiniertes Strassenfeld wird sauber zerlegt.
- Adresse ohne erkennbare Hausnummer fuehrt zu einer Validierungs-Exception.
- Leere Eingabe fuehrt nicht zur Exception (kein neues Verhalten).
### Verifikation Phase 9
- `php artisan test --compact tests/Unit/Dhl tests/Feature/DhlApiCurlLoggingTest.php`
- Ergebnis: 75 Tests bestanden, 195 Assertions.
- `./vendor/bin/pint --dirty --format agent`
### Offene Punkte / weiterhin im Backlog
Die in Phase 9 noch offenen Backlog-Items wurden inzwischen in Phase 10 abgearbeitet (siehe dort).
## Phase 10 - Backlog-Aufraeumen (2026-05-27)
Status: umgesetzt am 2026-05-27 direkt im Anschluss an Phase 9.
### Prio 2.1 - Legacy `DhlApiService` entfernen
Problem:
- `app/Services/DhlApiService.php` (1311 Zeilen) war ein veralteter SOAP-/SDK-basierter Service. Er referenzierte ein nicht existierendes Model `App\Models\DhlShipment` und das nicht installierte SDK `christophschaeffer/dhl-business-shipping`. Die Klasse wurde vom Autoloader geladen, war aber komplett tot (keine Aufrufer in `app/`, `routes/`, `resources/`, `tests/`, `packages/`). Jeder versehentliche Aufruf haette zur Laufzeit zu `Class not found` gefuehrt.
Umsetzung:
- `app/Services/DhlApiService.php` ersatzlos geloescht.
Verifikation:
- `grep -r DhlApiService app/ routes/ resources/ packages/ tests/ config/` → keine Treffer mehr.
- Verbleibende Vorkommen ausschliesslich in `dev/2026-05-13-dhl-modul/legacy/...` (historische Markdowns) und `dev/app-bak/` (Backup-Verzeichnis).
### Prio 2.2 - Request-/Prozess-Caching fuer `getDhlConfig()`
Problem:
- `SettingController::getDhlConfig()` baute die DHL-Konfiguration bei jedem Aufruf von Grund auf neu auf und holte dabei rund 25 Werte einzeln per `Setting::getContentBySlug()` aus der Datenbank. Pro DHL-Vorgang gibt es mehrere Aufrufer (`DhlShipmentService`, `CreateShipmentJob`, `DhlShipmentController` fuer Cancel/Return, …), so dass schnell 50-100 redundante `SELECT ... FROM settings`-Queries pro Request entstanden.
Umsetzung:
- `app/Http/Controllers/SettingController.php`
- Neue statische Property `private static ?array $cachedDhlConfig = null` als prozessweiter In-Memory-Cache.
- `getDhlConfig()` liefert beim zweiten Aufruf direkt aus dem Cache und triggert keine DB-Queries mehr.
- Neue oeffentliche Methode `flushDhlConfigCache()` setzt den Cache zurueck. Sie wird automatisch in `store()` und `updateDhlConfigCache()` aufgerufen, sodass nach einer Settings-Aenderung der naechste Aufruf wieder frische Werte liefert.
Tests:
- `tests/Unit/Dhl/DhlConfigCachingTest.php` (neu)
- Verifiziert, dass `getDhlConfig()` einen vorbefuellten Cache zurueckgibt, ohne die Datenbank zu beruehren.
- Verifiziert, dass `flushDhlConfigCache()` den Cache zuverlaessig leert.
### Prio 2.3 - Doppelte `public.tracking`-Route bereinigen
Problem:
- `routes/domains/crm.php` registrierte `GET /admin/dhl/public/track` mit dem Namen `public.tracking` _innerhalb_ der `admin`-Middleware-Gruppe. Dadurch war die Route in Wahrheit nicht oeffentlich, sondern admin-only. Gleichzeitig existiert in `routes/domains/main.php` die korrekte oeffentliche Route `GET /tracking` ebenfalls unter dem Namen `public.tracking`. Da pro Request immer nur eine Domain-Routedatei geladen wird, gab es keinen direkten Konflikt, aber auf der CRM-Subdomain zeigte der Routenname auf den falschen Endpunkt.
Umsetzung:
- `routes/domains/crm.php`
- Die `public.tracking`-Definition wurde entfernt und durch einen erklaerenden Kommentar ersetzt.
- `resources/views/admin/dhl/show.blade.php`
- Der bisher einzige Verbraucher (`route('public.tracking')` im aktuell deaktivierten `@if(false)`-Block) zeigt jetzt absolut auf die Main-Domain via `\App\Domain\EarlyDomainParser::getMainUrl().'/tracking'`. Die Trackingnummer wird zusaetzlich mit `urlencode()` geschuetzt.
Tests:
- `tests/Unit/Dhl/DhlRouteRegistrationTest.php` (neu)
- Stellt sicher, dass im CRM-Routing kein `Route::...->name('public.tracking')`-Eintrag mehr existiert.
- Stellt sicher, dass die Main-Domain weiterhin `GET /tracking` als `public.tracking` registriert.
### Verifikation Phase 10
- `php artisan test --compact tests/Unit/Dhl tests/Feature/DhlApiCurlLoggingTest.php`
- Ergebnis: 79 Tests bestanden, 201 Assertions.
- `./vendor/bin/pint --dirty --format agent`
### Verbleibendes Backlog
- `updateDhlConfigCache()` ruft weiterhin `\Artisan::call('config:clear')` auf. Das ist global und kann bei gecachter Konfiguration zu kurzfristiger Latenz fuehren - akzeptabel, weil das Setting-Update ein seltener Admin-Vorgang ist.
- `dev/app-bak/Services/DhlApiService.php` (Backup-Kopie ausserhalb des Autoloaders) bleibt als historische Referenz erhalten.
## Phase 11 - Tracking-Hardening (Bugfix Live, 2026-05-27)
Status: umgesetzt am 2026-05-27.
Ausloeser: Im Live-System lieferte das Cockpit beim Klick auf "Tracking aktualisieren" die irrefuehrende Meldung `Sendung nicht gefunden oder noch nicht im System erfasst. HTTP Status: 401`, obwohl die Sendung auf der DHL-Website laengst zugestellt war. Gleichzeitig zeigte das Cockpit einen frischen `last_tracked_at`-Zeitstempel mit altem `tracking_status`-Text - typisches Symptom eines schon laenger fehlschlagenden Trackings.
### Wurzelursachen
1. **Phantom-Fallback-Endpoint**: `DhlTrackingService::trackShipmentDE()` rief `https://api-eu.dhl.com/parcel/de/tracking/v0/shipments` auf. Dieser Endpoint existiert in der offiziellen DHL-Doku nicht (siehe `https://developer.dhl.com/api-reference/shipment-tracking`). Es gibt nur die "Shipment Tracking - Unified API" unter `https://api-eu.dhl.com/track/shipments`. Der Fallback produzierte daher zwangslaeufig 401/404.
2. **Falsche User-Message bei 401**: Eine `401 Unauthorized`-Antwort wurde dem Operator als "Sendung nicht gefunden" gezeigt. Der eigentliche Fehler (DHL-API-Key ohne Tracking-Subscription im DHL Developer Portal) blieb verborgen.
3. **Stale-Daten erscheinen frisch**: Sowohl `updateTrackingSync()` als auch `updateTrackingBatch()` setzten `last_tracked_at = now()` selbst dann, wenn die Tracking-Antwort fehlschlug. Damit wirkte ein veralteter Status im Cockpit "gerade eben aktualisiert".
4. **Per-Shipment-Fallback verbrennt Quota**: Bei einem Batch-Auth-Fehler ist der Code in eine Schleife mit `updateTracking()` pro Sendung gegangen, was den 401 mit `N` Folgeaufrufen multipliziert.
5. **Tote `api_secret`-Property**: Wurde im Konstruktor geladen aber nirgends im Request verwendet (Tracking-Unified-API kennt kein OAuth/Basic).
### Umsetzung
- `app/Services/DhlTrackingService.php`
- Neue Konstante `TRACKING_ENDPOINT = 'https://api-eu.dhl.com/track/shipments'`.
- `trackShipmentDE()` ersatzlos entfernt; `trackShipment()` hat keinen Fallback mehr.
- Neue private Helper `buildHttpOptions()`, `processSingleShipmentResponse()`, `isAuthErrorStatus()`, `buildAuthErrorMessage()`, `redactApiKey()`.
- 401/403 werden explizit als `auth_error => true` plus `http_status` zurueckgegeben, mit klarer User-Message: *"DHL Tracking API: Authentifizierung fehlgeschlagen (HTTP 401). Bitte pruefen, ob der hinterlegte DHL-API-Key gueltig ist und im DHL Developer Portal fuer 'Shipment Tracking - Unified' freigeschaltet wurde."*.
- 404 oder leere Sendungsliste werden als `not_found => true` markiert (eindeutig getrennt von Auth-Fehlern).
- `trackMultipleShipments()` nutzt jetzt denselben gemeinsamen Pfad und liefert ebenfalls strukturierte `auth_error`-Antworten.
- `updateTrackingSync()` setzt `last_tracked_at` **nur noch** bei Erfolg oder bei explizitem `not_found`. Auth- und Transport-Fehler lassen den Zeitstempel unangetastet, damit der naechste Cron-Lauf erneut versucht und das Cockpit den fehlschlagenden Zustand sichtbar macht.
- `updateTrackingBatch()` bricht beim ersten Auth-Fehler ab (kein Per-Shipment-Fallback, kein erneutes Tracking) und markiert alle Sendungen der laufenden Charge mit `auth_error`. Transport-Exceptions fuehren ebenfalls nicht mehr zu einem `last_tracked_at`-Update.
- Properties `apiSecret` und `isSandbox` entfernt.
- Diagnose-Logs enthalten jetzt Endpoint, Status-Code und ein redaktiertes API-Key-Suffix (`***<letzte 4>`), aber niemals den vollen Key.
### Tests
- `tests/Unit/Dhl/DhlTrackingAuthErrorTest.php` (neu, 6 Tests)
- HTTP 401 / 403 fuehrt zu `auth_error => true` mit klarer Message.
- Es wird kein Fallback-Request mehr an `/parcel/de/tracking/...` gesendet.
- 404 / leere Sendungsliste werden eindeutig als `not_found` zurueckgegeben.
- Multi-Tracking liefert ebenfalls strukturierte `auth_error`-Antworten.
- Das Auth-Fehler-Log enthaelt den vollen API-Key nicht (nur Suffix).
- `tests/Unit/Dhl/DhlTrackingStaleProtectionTest.php` (neu, 3 Tests)
- `updateTracking()` ruft bei 401 **kein** `$shipment->update(...)` auf - `last_tracked_at` bleibt also fuer den naechsten Cron-Versuch alt.
- `updateTracking()` setzt bei "not found" gezielt nur `last_tracked_at`, damit nicht sofort erneut probiert wird.
- `updateTrackingBatch()` bricht bei einem Auth-Fehler nach dem ersten Versuch ab (`Http::assertSentCount(1)`) und markiert alle drei Sendungen der Charge als `auth_error => true`.
### Verifikation Phase 11
- `php artisan test --compact tests/Unit/Dhl tests/Feature/DhlApiCurlLoggingTest.php`
- Ergebnis: 88 Tests bestanden, 263 Assertions (vorher Phase 10: 79 Tests, 201 Assertions).
- `./vendor/bin/pint --dirty --format agent`
### Operatives Vorgehen bei einem 401 im Live
Der Code kann das Problem nur sichtbar machen und nicht beheben - die eigentliche Loesung erfolgt im DHL Developer Portal:
1. DHL Developer Portal → My Apps → die produktive App oeffnen.
2. Unter "APIs" pruefen, ob *Shipment Tracking - Unified API* aktiv und genehmigt ist. Falls nicht: hinzufuegen und Production-Freischaltung beantragen (manuelle DHL-Approval).
3. Den `DHL_API_KEY` (Consumer Key) aus dem Portal mit dem Wert in den Einstellungen / `.env` abgleichen.
4. Nach Korrektur einmal *Tracking aktualisieren* im Cockpit anstossen; der `last_tracked_at`-Zeitstempel wird jetzt nur noch bei tatsaechlich erfolgreicher Antwort frisch gesetzt.
## Phase 12 - 429-Handling und lokale Quota-Pause (Bugfix Live, 2026-05-27)
Status: umgesetzt am 2026-05-27 (Folgebefund zu Phase 11).
Ausloeser: Trotz Phase 11 zeigte das Live-System weiterhin veraltete Tracking-Stati. Konkretes Beispiel: Sendung `5082` (Trackingnummer `00340435065133094790`) blieb seit `2026-05-27 05:00:33` auf "in der Region des Empfaengers angekommen", obwohl DHL bereits um 13:41 Uhr "Zustellung erfolgreich" meldete. Quer ueber **alle 381 aktiven Sendungen** war `MAX(last_tracked_at) = 06:00:35` - der stuendliche Cron `dhl:update-tracking` lieferte ab 07:00 Uhr ueber 11 Stunden keine Updates mehr.
### Wurzelursachen (revidiert)
1. **DHL-Tageslimit von 250 Aufrufen pro Tag erschoepft**. Laut [`developer.dhl.com/api-reference/shipment-tracking`](https://developer.dhl.com/api-reference/shipment-tracking) bekommt jede Standard-App initial *250 calls per day, with a maximum of 1 call every 5 seconds*. Direkter Test gegen den Live-Endpunkt belegte das: `curl https://api-eu.dhl.com/track/shipments?trackingNumber=...` lieferte `HTTP 429` mit Body `{"status":429,"title":"Too Many Requests","detail":"Too many requests within defined time period, please try again later."}`. Der Antrag auf eine hoehere Quota muss im DHL Developer Portal gestellt werden.
> Hinweis zur ersten Diagnose: Wir hatten zunaechst vermutet, der Live-`DHL_API_KEY` sei der oeffentliche Sandbox-Demo-Key aus der DHL-Doku, weil der Wert mit den im Repo eingecheckten Sandbox-Beispielen uebereinstimmte (`tests/Feature/DhlApiCurlLoggingTest.php`, `tests/DHL/curl-trace.txt`). Diese Annahme war falsch - der Wert in der Live-`.env` ist ein echter produktiver Key, er hat nur das Standard-Tageslimit. Die fruehere Operator-Message mit dem Hinweis "Sandbox-Demo-Key" wurde entfernt.
2. **429 wurde wie ein generischer Fehler behandelt**: Der Code aus Phase 11 erkannte 401/403 als `auth_error` und 404 als `not_found`, aber 429 fiel in den Pfad "Fehler beim Abrufen der Tracking-Informationen. HTTP 429". Im Batch fuehrte das zum Per-Shipment-Fallback, der pro Sendung einen weiteren 429-Aufruf abgesetzt hat - die schon erschoepfte Quota wurde so noch schneller weiter belastet und der Cron-Lauf dauerte unnoetig lange.
3. **Folgelaeufe verbrennen Quota nur um 429 erneut zu sehen**: Solange DHL noch im selben Quota-Fenster mit 429 antwortet, kostet jeder hourly-Cron mindestens einen HTTP-Roundtrip. Bei knapper Quota ist das ein Selbstlaeufer, der den Recovery-Moment nach dem Tages-Reset spuerbar verzoegert.
### Umsetzung
- `app/Services/DhlTrackingService.php`
- `processSingleShipmentResponse()` erkennt `HTTP 429` explizit und liefert `rate_limited => true` plus optional `retry_after` (Sekunden). Die User-Message lautet jetzt sachlich: *"DHL Tracking API: Tageslimit erreicht (HTTP 429). Die DHL-API liefert vorruebergehend keine Daten mehr. Standard-Apps haben laut DHL-Doku 250 Aufrufe pro Tag und max. 1 Aufruf alle 5 Sekunden. Bei Bedarf im DHL Developer Portal eine Quota-Erhoehung beantragen."* Der frueher hier eingefuegte "Sandbox-Demo-Key"-Hinweis ist entfernt.
- `trackMultipleShipments()` erkennt 429 ebenfalls und liefert dieselbe strukturierte Antwort fuer die Batch-Route.
- `updateTrackingBatch()` bekommt einen eigenen `rate_limited`-Pfad analog zu `auth_error`: Beim ersten 429 wird die gesamte Charge sofort als `rate_limited` markiert und der Cron-Lauf abgebrochen, ohne Per-Shipment-Fallback. `last_tracked_at` bleibt unangetastet, damit der naechste planmaessige Cron-Lauf die gleichen Sendungen erneut versucht (statt sie mit frischem Zeitstempel und altem Status zu konservieren).
- Auch im "Sonstiger Batch-Fehler"-Fallback wird ein `rate_limited`-Befund pro Sendung als Abbruchgrund erkannt - die Schleife bricht sofort ab, ebenso wie schon bei `auth_error`.
- Neuer Helper `extractRetryAfter()` interpretiert sowohl ganzzahlige Sekunden als auch RFC-7231-`HTTP-date`-Werte aus dem `Retry-After`-Header.
- Logging in `[DHL Tracking Service] Unified API rate-limited` / `Multi tracking rate-limited` / `Batch tracking aborted due to rate-limit` enthaelt Endpoint, HTTP-Status, ein redaktiertes API-Key-Suffix (`***<letzte 4>`) und - falls von DHL geliefert - die `Retry-After`-Sekunden.
- Lokale Quota-Pause (neu in Phase 12.1)
- Neuer Cache-Key `dhl_tracking:quota_paused_until` haelt einen absoluten "do not call DHL before"-Zeitstempel. Default-Pause: 1 Stunde, wenn DHL keinen `Retry-After`-Header sendet; andernfalls genau die signalisierte Wartezeit.
- Statische Helper auf `DhlTrackingService`: `isQuotaPaused()`, `getQuotaPausedUntil()`, `pauseQuota(?int $retryAfterSeconds = null)`, `clearQuotaPause()`.
- `trackShipment()`, `trackMultipleShipments()` und `updateTrackingBatch()` pruefen die Pause **vor jedem HTTP-Roundtrip**. Ist sie aktiv, wird sofort eine `rate_limited => true`-Antwort mit `paused_until` zurueckgegeben - kein API-Aufruf. Damit kostet ein Cron-Lauf in einem ausgeschoepften Quota-Fenster **0 Calls** statt 10+ und beschleunigt den Recovery-Moment nach Quota-Reset.
- Beim ersten echten 429-Response wird `pauseQuota($retryAfter)` aufgerufen, sodass ein einzelner Cron-Lauf maximal einen "Quota-Probierschuss" pro Stunde absetzt.
### Tests
- `tests/Unit/Dhl/DhlTrackingRateLimitTest.php` (neu, 9 Tests)
- 429-Single-Tracking-Response: Antwort ist `rate_limited => true`, enthaelt `http_status = 429`, `retry_after = 120` und eine deutschsprachige Operator-Message mit DHL-Doku-konformem Hinweis (`Tageslimit erreicht`, `250 Aufrufe pro Tag`, `Quota-Erhoehung`). Der frueher gepruefte String "Sandbox-Demo-Key" darf explizit **nicht** mehr vorkommen.
- Multi-Tracking liefert dieselbe strukturierte 429-Antwort.
- `updateTrackingBatch()` bricht nach genau einem HTTP-Aufruf ab, markiert die Charge als `rate_limited` und ruft `$shipment->update(...)` nicht auf.
- HTTP-date als `Retry-After` wird korrekt in Sekunden umgerechnet.
- Im Rate-Limit-Log taucht der volle API-Key nicht auf, nur das `***<suffix>`.
- **Quota-Pause aktiviert sich aus dem `Retry-After`-Header** und der zweite `trackShipment()`-Aufruf sendet keinen HTTP-Request mehr (`Http::assertSentCount(1)`).
- **Default-Pause** ohne `Retry-After`-Header dauert ~1 Stunde (mit Toleranz fuer Test-Drift).
- **`updateTrackingBatch()` ueberspringt 25 Sendungen vollstaendig**, wenn die Pause bereits gecached ist (`Http::assertNothingSent()`).
- `clearQuotaPause()` raeumt den Cache-Eintrag wieder auf - wichtig fuer Tests und fuer Operator-Eingriffe.
### Verifikation Phase 12
- `php artisan test --compact tests/Unit/Dhl`
- Ergebnis: 96 Tests bestanden, 406 Assertions (vorher Phase 11: 88 Tests, 263 Assertions).
- `./vendor/bin/pint --dirty --format agent`
### Operatives Vorgehen bei einem 429 im Live
Der Code-Fix verhindert, dass aus einer erschoepften Tracking-Quota ein **mehrstuendiger Datenstau** wird, das Cockpit veraltete Stati als "gerade aktualisiert" anzeigt oder die hourly-Crons die Recovery selbst weiter verzoegern. Die eigentliche Loesung liegt aber im DHL-Account:
1. **DHL Developer Portal -> My Apps -> produktive App oeffnen.**
2. **Pruefen, ob *Shipment Tracking - Unified API* in der App als Production approved ist** (nicht nur Sandbox). Standard-Quota: 250 Aufrufe pro Tag, max. 1 Aufruf alle 5 Sekunden.
3. **Im DHL Developer Portal eine Quota-Erhoehung beantragen** (Antrag-Formular im API-Detailbereich). Solange das offen ist: Hourly-Cron mit Status-basierten Intervallen einplanen, dazu siehe "Empfehlung Sendungsvolumen vs. Quota" unten.
4. **Cron-Lauf beobachten**: Nach dem ersten 429 bleibt der Cron fuer 1 Stunde lokal in einer Quota-Pause, ohne weitere DHL-Aufrufe. Der naechste `dhl:update-tracking` versucht es dann erneut. Sobald die Quota frei wird (Production-Erhoehung oder Tages-Reset), nimmt der Cron die Sendungen automatisch wieder mit.
5. Falls Operator manuell sofort entsperren moechte: `php artisan tinker` -> `App\Services\DhlTrackingService::clearQuotaPause()`.
### Empfehlung Sendungsvolumen vs. Quota
Aktuell sind ~381 Sendungen aktiv (`status IN ('created','in_transit','out_for_delivery','exception','unknown')`). Die DHL-Standardquota von 250 Calls/Tag reicht dafuer mathematisch nicht aus, sobald jede Sendung mehrmals pro Tag aktualisiert werden soll. Solange keine erhoehte Quota bewilligt ist, sind drei Stellschrauben sinnvoll - Reihenfolge nach Wirkung:
1. **`--stale-days` enger setzen** (z.B. 14 statt 30). Sendungen mit Status `created`, die seit zwei Wochen keinen Fortschritt zeigen, sind in der Praxis nicht zustellbar oder Karteileichen. `markStaleShipmentsCompleted()` schliesst sie automatisch ab und reduziert die Cron-Last spuerbar (in der aktuellen DB sind 266 von 381 aktiven Sendungen im Status `created`).
2. **Status-Intervalle verlaengern** (`DhlShipment::TRACKING_INTERVALS`). Vorschlag fuer 250/Tag: `out_for_delivery = 1h`, `in_transit = 6h`, `exception = 8h`, `unknown = 12h`, `created = 24h`. Das passt grob in die Quota und behaelt schnelle Reaktion fuer die "letzte Meile".
3. **Vorhandener "Batch"-Pfad ist faktisch keine Ersparnis**: Die DHL Unified Tracking API kennt laut Doku nur den Singular-Parameter `trackingNumber`. `trackMultipleShipments()` sendet `?trackingNumber=A,B,C,...` - das wird als eine einzige unbekannte Sendungs-ID behandelt, schickt den Lauf in den Per-Shipment-Fallback und kostet *einen Call extra pro Chunk*. Wenn die Quota verlaesslich erhoeht ist, sollte dieser Pfad entweder gegen einzelne Calls mit 5-Sekunden-Throttling ersetzt oder ganz entfernt werden. Bewusst noch nicht angefasst, weil das Refactoring umfangreich ist - separate Phase 13 wenn gewuenscht.
### Verbleibende Beobachtungen
- Eine zusaetzliche Beobachtung (Monitoring) waere sinnvoll: ein Alarm wenn `MAX(last_tracked_at)` quer ueber alle aktiven Sendungen aelter als z.B. 3 Stunden ist. Das macht eine erschoepfte Quota oder einen haengenden Cron sofort sichtbar, ohne dass jemand einzelne Sendungen manuell prueft.
- Default-Pause von 1 Stunde ist eine sinnvolle Heuristik, weil DHL kein verlaessliches `Retry-After` mitsendet. Falls die echte Quota-Reset-Zeit bekannt wird, koennte man die Default-Pause dynamisch bis zum naechsten Reset stellen.
## Phase 13 - Echtes Single-Tracking + Quota-vertraegliche Intervalle (2026-05-27)
Status: umgesetzt am 2026-05-27 (Folge zu Phase 12 - die Quota-Erhoehung auf 10.000 calls/day wurde im DHL Developer Portal beantragt, kann aber nach Erfahrung mehrere Wochen brauchen. Bis dahin muss das System mit den 250/day-Standardlimits sauber funktionieren).
### Ausloeser
Bei der Aufarbeitung von Phase 12 sind zwei strukturelle Schwachstellen aufgefallen:
1. **`trackMultipleShipments()` war kein echter Batch-Aufruf**. Die DHL Unified Tracking API kennt laut [Doku](https://developer.dhl.com/api-reference/shipment-tracking) nur den Singular-Parameter `trackingNumber`. Der Code sendete `?trackingNumber=A,B,C,...,J` als eine kommaseparierte Liste - DHL interpretierte das als *eine* unbekannte Sendungs-ID, lieferte ein leeres `shipments`-Array zurueck und der Code fiel in den Per-Shipment-Fallback. Effekt: **ein verschwendeter Aufruf pro Chunk plus** die zehn echten Aufrufe danach.
2. **Status-Intervalle aus Vor-Audit-Zeit**: `in_transit = 2h` und `created = 6h` waren so eng, dass selbst mit perfekt funktionierender API ca. 2.500 Aufrufe pro Tag nötig waeren - rund das **10-fache** des dokumentierten Standardlimits von 250 calls/day.
### Umsetzung
- `app/Services/DhlTrackingService.php`
- **`trackMultipleShipments()` komplett entfernt**. Damit existiert nur noch der Single-Tracking-Pfad `trackShipment()`, der einen einzelnen `trackingNumber` an DHL sendet. Ein neuer Unit-Test (`it does not have a trackMultipleShipments method anymore`) friert diese Entfernung als Vertrag ein.
- Neue statische Property `$callIntervalSeconds = 5` plus Setter/Getter `setCallIntervalSeconds()` / `getCallIntervalSeconds()`. Voreinstellung folgt der DHL-Doku ("max 1 call every 5 seconds"). In Tests wird der Wert auf `0` gesetzt, damit die Suite nicht real schlaeft.
- `updateTrackingBatch()` neu geschrieben: Anstelle der Chunks von 10 mit Pseudo-Batch-API laeuft jetzt **eine echte HTTP-Anfrage pro Sendung**, dazwischen `sleep($callIntervalSeconds)`. Abbruchbedingungen unveraendert: Quota-Pause vor dem ersten Call, `auth_error` oder `rate_limited` brechen die Schleife sofort ab, alle restlichen Sendungen werden ohne weiteren API-Aufruf uebersprungen. Bei einem Transport-Fehler (DNS/TLS) wird die einzelne Sendung als `transport_error` markiert; der Loop laeuft weiter, weil das ein vorruebergehendes Netzproblem sein kann.
- Das Cron-Log enthaelt jetzt zusaetzlich die Felder `http_calls` und `call_interval_seconds`, sodass Quota- und Throttle-Verhalten direkt nachvollziehbar sind.
- `packages/acme-laravel-dhl/src/Models/DhlShipment.php`
- `TRACKING_INTERVALS` angehoben:
- `out_for_delivery`: 1 h (unveraendert - kundenrelevant, betrifft nur sehr wenige Sendungen gleichzeitig)
- `in_transit`: 2 h -> **6 h**
- `exception`: 4 h -> **8 h**
- `unknown`: 4 h -> **12 h**
- `created`: 6 h -> **24 h**
- `DEFAULT_TRACKING_INTERVAL`: 4 h -> **8 h**.
- Indikative Rechnung mit aktuellem Live-Bestand (266 `created` + 115 `in_transit`):
- `created` 1x/Tag = 266 calls/Tag
- `in_transit` 4x/Tag = 460 calls/Tag
- Summe ~726 calls/Tag bei vollstaendigem Throughput - reicht fuer 10.000/Tag-Quota dreifach, aktiviert bei 250/Tag-Standardquota nach ca. 5 Stunden die Quota-Pause aus Phase 12. Da bleibt der Tracking-Status fuer alle Sendungen ohne `out_for_delivery` mindestens 1x/Tag aktuell - das ist mit Standard-Quota verkraftbar.
### Tests
- `tests/Unit/Dhl/DhlTrackingBatchThrottleTest.php` (neu, 4 Tests)
- 3 Sendungen erzeugen **genau 3 HTTP-Calls**, jede mit *einzelner* `trackingNumber` (kein Komma in den Query-Params).
- `setCallIntervalSeconds(1)` -> 3 Sendungen brauchen >= 2 s Wall-Clock (`sleep(1)` zwischen Call 1->2 und 2->3).
- `getCallIntervalSeconds()` Round-Trip plus `-99` wird auf `0` geklemmt.
- Vertragstest: `DhlTrackingService::trackMultipleShipments` existiert nicht mehr.
- `tests/Unit/Dhl/DhlTrackingStaleProtectionTest.php` (angepasst)
- Der "Batch bricht beim ersten Auth-Fehler ab"-Test pruef jetzt **`failed = 1`** statt `3`: in der neuen Logik wird nur die *erste* Sendung tatsaechlich angefragt, der Rest gar nicht erst gestartet. Das ist quotaschonender als das alte "Charge komplett als auth_error markieren"-Verhalten.
- `tests/Unit/Dhl/DhlTrackingRateLimitTest.php` (angepasst)
- Der frueher gegen `trackMultipleShipments()` gerichtete Test "marks the multi-tracking call as rate_limited too" ist ersatzlos entfernt, weil die Methode nicht mehr existiert.
- Der 429-Batch-Test pruefe jetzt analog **`failed = 1` mit nur einem HTTP-Call** - die anderen 11 Sendungen werden uebersprungen.
- `tests/Unit/Dhl/DhlTrackingAuthErrorTest.php` (angepasst)
- Der frueher direkte `trackMultipleShipments()`-Test ist entfernt, weil die Methode nicht mehr existiert.
### Verifikation Phase 13
- `php artisan test --compact tests/Unit/Dhl`
- Ergebnis: 98 Tests bestanden, 398 Assertions.
- `./vendor/bin/pint --dirty --format agent`
### Operatives Vorgehen
- **Solange Standard-Quota (250/day) aktiv ist**: Mit den neuen Intervallen erreicht das System ein realistisches Limit, bevor die Quota-Pause aus Phase 12 zuschlaegt. `out_for_delivery`-Sendungen werden bevorzugt aktualisiert, der Rest folgt nach.
- **Sobald die beantragte 10.000/day-Quota durchgeht**: Keine Code-Anpassung noetig. Die Intervalle sind so gewaehlt, dass auch der naechste Bestandszuwachs (mehr Sendungen) noch passt; bei sehr starkem Wachstum koennten `in_transit` wieder auf 4 h gesetzt werden.
- **Throttle bei eigenen Service Level**: Wer einen vertraglich erweiterten Service Level mit > 1 call/sec hat, kann das im Bootstrap setzen, z.B. `DhlTrackingService::setCallIntervalSeconds(2);`. Default bleibt 5 s, weil das den Standard-Service-Levels und der Doku entspricht.
### Verbleibende Empfehlung
- `--stale-days` im Cron auf 14 setzen, sobald sicher ist, dass keine legitime Bestellung > 2 Wochen im Status `created` bleibt. Das reduziert den getrackten Sendungspool spuerbar (266 von 381 aktiven Sendungen sind aktuell `created`, viele davon sind reine Karteileichen, deren aelteste seit `2026-04-29 03:00` keine Aenderung mehr hatten). Bewusst noch nicht durchgefuehrt, weil das eine Geschaeftsentscheidung ist - der aktuelle Default bleibt bei `--stale-days=30`.
## 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.