# Backend-Migration: ERPframe → Rust-Backend Stand: 2026-05-14 ## Ziel Die Holzleitner-Lieferservice-App wird vom alten ERPframe/DOCUframe-Backend auf das neue Rust-Backend (`/Users/dennis/Desktop/Arbeit/Holzleitner-Backend`) umgestellt. Auf Client-Seite kommt Clean Architecture konsequent durch: data-Layer (generierte API-DTOs), domain-Layer (App-Entities), Mapper, Repositories, Blocs. ## Entscheidungen | Entscheidung | Wahl | Begründung | |---|---|---| | API-Client | **OpenAPI-Generator** (`dart` mit `dio` als Provider) | Wir haben sauberes OpenAPI im Backend; handschriftliche Services wären 1000 Zeilen Drift-Risiko. | | Discount/Payment-Features | **Feature-Flag mit Stubs**, UI-Pfade bleiben kompilierbar | Diese Features existieren im neuen Backend nicht. Stubs werfen `UnsupportedFeatureException`. UI-Aufrufer können sie defensiv behandeln. Spätere Reaktivierung möglich, wenn Kunde bestätigt. | | Offline-Scan-Queue | **Später**, erst Online-Pfad | Erstmal sauberer Online-Flow. Outbox-Pattern kommt als separate Iteration, wenn das Online-Verhalten sitzt. | | Phasenfolge | A → B → C → D → E → F | Jede Phase einzeln smoke-getestet, bevor die nächste beginnt. | ## Backend-Endpunkte, die wir treffen Aus dem fertigen Rust-Backend (Stand 2026-05-14): | HTTP | Pfad | Use Case | |---|---|---| | GET | `/me/tours/today` | Liste der heutigen Touren des angemeldeten Fahrers | | GET | `/tours/{id}` | Tour-Aggregat (eine Lieferung pro Roundtrip) | | PUT | `/tours/{id}/delivery-order` | Sortierreihenfolge schreiben | | POST | `/scans` | Bulk-Scan-Endpoint mit `clientScanId` für Idempotenz | | POST | `/deliveries/{id}/hold\|resume\|cancel\|complete` | Lifecycle-Übergänge | | PUT | `/deliveries/{id}/assigned-car` | Fahrzeug-Zuordnung | | POST | `/deliveries/{id}/notes` | Notiz anlegen (Text/Bild-Anhang) | | GET | `/me/cars[?includeInactive=true]` | Fahrzeuge des Fahrers | | POST | `/me/cars` | Fahrzeug anlegen | | PATCH | `/me/cars/{id}` | Fahrzeug ändern/deaktivieren | | GET | `/accounts/{personalnummer}` | Account-Stammdaten | Auth: Keycloak OIDC, JWT Bearer, OIDC-Discovery via `http://localhost:8080/realms/holzleitner/.well-known/openid-configuration`, Audience `holzleitner-api`, Client `holzleitner-app` (public + PKCE). ## Modell-Alignment Die App-Modelle (`lib/model/*`) entsprechen nicht den Backend-Modellen. Wichtigste Differenzen, die der Mapper auflösen muss: | App heute | Backend | |---|---| | `Article.amount`, `scannedAmount`, `isParent`, `components[]` | `DeliveryItem.requiredQuantity`, `scanState`, flache Liste, `komponentenArtikelNr?` | | `DeliveryState.{ongoing, finished, onhold, canceled}` | `DeliveryState.{active, completed, held, canceled}` | | `Car { int id, String plate }` | `Car { UUID id, i64 accountId, String plate, bool active }` | | `Note { int id, String content }` | `DeliveryNote { UUID, text?, imageAttachment?, authorPersonalnummer, authorCarId?, createdAt }` | | `Tour { Driver driver, deliveriesPerCar }` | `TourDetails { Tour, Vec, customers, articles, warehouses, notes }` | | `Discount { article, note, noteId }` | — kein Backend-Pendant | | `Payment { id, description, shortcode }` | — kein Backend-Pendant | Stücklisten-Komponenten als eigene Entity entfallen. Die Hierarchie kann die UI aus `komponentenArtikelNr` rekonstruieren, wenn gebraucht. ## Was als Feature-Flag stubbt Discount- und Payment-Pfade bleiben in der UI sichtbar, aber: - `DiscountRepository.add/remove/update` → `throw UnsupportedFeatureException("discounts")` - `PaymentRepository.list/select` → liefert `[]` bzw. `null` - Aufrufende Stellen (z. B. `TourBloc`) fangen die Exception, zeigen Snackbar "Funktion nicht verfügbar" und machen weiter. Konfigurierbar über `FeatureFlags.discountsEnabled = false` und `FeatureFlags.paymentsEnabled = false` (Default beides `false`). ## Phasen ### Phase A — Foundation - `dio: ^5` und `dio_smart_retry` (optional) in `pubspec.yaml`. - `openapi_generator` als `dev_dependency`; Generator-Aufruf in `tool/generate_api_client.sh` (oder build_runner) verdrahten. - OpenAPI-Spec lokal aus dem laufenden Backend ziehen (`curl http://localhost:3000/openapi.json -o openapi/holzleitner.json`), einchecken (zur Reproduzierbarkeit), generierten Code unter `lib/data/api/` ablegen, gitignoren. - `AuthInterceptor` für `dio`: Bearer-Header einhängen, bei 401 versuchen zu refreshen, sonst Logout-Event feuern. - `get_it`-Registrierungen für `Dio`, generierte API-Klassen, Repositories. Smoke: `GET /health` und `GET /me/cars` (mit hartkodiertem Token aus Keycloak) via generiertem Client erreichbar. ### Phase B — Auth (Keycloak OIDC) - `flutter_appauth: ^7` für PKCE-Flow. - `flutter_secure_storage` für Refresh-Token. - `KeycloakAuthService` mit `login()`, `logout()`, `refresh()`, `currentAccessToken()`. - `LoginPage` umbauen: kein Browser-Redirect + Deep-Link, sondern PKCE-Flow via `flutter_appauth`. Bei Erfolg: Tokens in Secure Storage, AuthBloc-Event `SetAuthenticatedEvent`. - `AuthBloc.Authenticated` State trägt jetzt `{ accessToken, refreshToken, idTokenClaims }` statt `sessionId`. - `AuthInterceptor` aus Phase A nutzt diesen State. Smoke: Login → Token landet im Secure Storage → `GET /me/cars` über Interceptor klappt. ### Phase C — Domain & Mapper - Generierte DTOs (`lib/data/api/model/`) bleiben unverändert. - Domain-Entities in `lib/domain/entity/` definieren — analog zu heute, aber an Backend-Felder angepasst (Enum-Werte, UUID-IDs etc.). - Mapper unter `lib/data/mapper/` pro Entity: `TourMapper.fromDto(TourDetailsDto) -> Tour`. - `lib/model/*` und `lib/dto/*` werden **gelöscht**, sobald alle Aufrufer auf die neuen domain-Entities umgestellt sind. Bis dahin laufen beide parallel. Kein UI-Test nötig — diese Phase ist reines Refactoring. ### Phase D — Repositories - Pro Repository abstraktes Interface in `lib/domain/repository/`, konkrete Implementierung in `lib/data/repository/`. - Implementierungen rufen den generierten API-Client auf und mappen DTO ↔ Entity. - Neu/umgestellt: - `TourRepository` → `GET /me/tours/today` + `GET /tours/{id}` + `PUT /tours/{id}/delivery-order` - `DeliveryRepository` → `POST /deliveries/{id}/{hold,resume,cancel,complete}`, `PUT /deliveries/{id}/assigned-car` - `ScanRepository` → `POST /scans` (bulk) - `NoteRepository` → `POST /deliveries/{id}/notes` - `CarsRepository` → `GET/POST/PATCH /me/cars` - `ProcessRepository` (heute Stubs) → bekommt echte Calls - Wegfall: - `DiscountRepository` → Stub mit `UnsupportedFeatureException` - Payment-Methoden in `TourRepository` → Stub `[]` Smoke: Login → `loadTourOfToday()` → Tour kommt durch. ### Phase E — Blocs - `TourBloc`: an neues `Tour`-Aggregat anpassen, Discount/Payment-Events hinter Feature-Flag. - `CarsBloc`: nutzt neue `CarsRepository`-Signaturen (UUID statt int, `active` statt delete). - `CarSelectBloc`: speichert Car-UUID statt `int` in SharedPreferences (Migration der bestehenden gespeicherten Auswahl: bei UUID-Parse-Fehler einfach `null` und neu auswählen lassen). - `AuthBloc`: bereits in Phase B angepasst, hier nur noch Konsistenz-Check. - `PhaseBloc`: bleibt UI-seitig, konsumiert das neue Aggregat. Smoke: Tab-Navigation funktioniert, jeder Tab lädt ohne Crash. ### Phase G — Delivery-Lifecycle-Audit-Log (offen) **Status:** geplant, noch nicht begonnen. Aus der Diskussion zur Wiederherstellung abgebrochener Lieferungen (Phase C+D-4): heute gehen `state_reason`-Begründungen beim `resume` verloren, weil das Feld direkt an der `deliveries`-Zeile lebt und beim Wiederherstellen genullt wird. Item-Aktionen sind sauber auditierbar (`scan_audit`, append-only) — Delivery-Lifecycle ist es nicht. **Scope:** 1. Neue Tabelle `delivery_audit` analog zu `scan_audit`: - `id`, `delivery_id`, `client_action_id` (UUID UNIQUE — Idempotenz) - `action` (`hold`|`resume`|`cancel`|`complete`) - `previous_state`, `resulting_state` - `reason` (Pflicht bei `hold` / `cancel`) - `actor_personalnummer`, `actor_car_id?` - `client_acted_at`, `server_recorded_at` - denormalisierter ERP-Bezug: `erp_belegart_id`, `erp_belegnummer` 2. Backend: Audit-Insert in jedem `apply_action`-Pfad (`delivery_repository.rs`). Request-DTOs bekommen Pflichtfelder `clientActionId` + `clientActedAt`. 3. OpenAPI + Dart-Client neu generieren. 4. App-Bloc: UUID + Timestamp pro Lifecycle-Event mitsenden (Helper-Funktion analog zur Scan-Pipeline, dort sitzt das Pattern schon im `TourBloc`). 5. **Optional, zweiter Schritt:** `GET /deliveries/{id}/audit` plus UI-Anzeige der Historie. Sinnvollste Stelle: im „Wiederherstellen"-Dialog vom Cancel-Recovery zeigen wir den ursprünglichen Eintrag („Wurde am … durch … abgebrochen mit Grund: …"), damit der Fahrer eine bewusste Entscheidung trifft. **Out of Scope dieser Phase:** - `assignCar`-Audit (andere Geste, kein Reason, eigene Sub-Phase). - Notizen-Audit (Notes sind schon append-only — separate Aktivität). ### Phase F — Smoke des kompletten Flows Manuell durchklicken: 1. Login (Keycloak) 2. Fahrzeugauswahl 3. Sortier-Phase (Reihenfolge per Drag & Drop, Persistieren) 4. Beladen-Phase (Scan, Hold, Remove) 5. Auslieferung-Phase (Complete, Notizen, Cancel) 6. Logout Bei jedem Schritt: Backend-Logs prüfen (richtige Endpoints gehen rein, keine 4xx/5xx). ## Was die App nach der Migration *nicht* mehr kann - Rabatte (Discount-Pfade als Stub, UI zeigt Snackbar) - Zahlungsarten-Auswahl (Payment-Pfade als Stub) - Lieferung-spezifische ERP-Optionen (`DeliveryOption`) — fielen ohnehin durch das Modell raus Diese Features kommen zurück, sobald sie im Backend modelliert sind. ## Was die App nach der Migration *neu* kann - Idempotente Bulk-Scans (Offline-Wiederholbarkeit auf Protokoll-Ebene schon vorbereitet) - Saubere Audit-Spuren (`actorCarId` an Scans + Notizen) - Fahrzeug-Soft-Delete (statt Hardes Löschen) - Server-vergebene `tour_date` (statt App-Datum) ## Out of Scope dieser Migration - Offline-Queue für Scans (kommt später) - Object-Storage für Bilder (heute schicken wir Strings, später pre-signed URLs) - Cars-Verwaltung von ERPframe migrieren — Fahrzeuge werden neu angelegt - ERP-Sync (POST /sync/tour) — das ist DOCUcontrol-Seite, nicht App