# 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<DeliveryWithItems>, 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
