27-05-2026 DHL Modul v2.1 / Optimierung tracking

This commit is contained in:
Kevin Adametz 2026-05-27 18:51:23 +02:00
parent 036595be94
commit 2bdc9ada3c
33 changed files with 2367 additions and 2086 deletions

View file

@ -1007,6 +1007,38 @@ Umsetzung:
- 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.
@ -1020,6 +1052,411 @@ Verifikation:
- `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.