Compare commits

..

16 Commits

Author SHA1 Message Date
2ff331294e fix: Tour-Datum — Leer-Texte, veraltete Antworten, Kalenderfenster
- Leer-Ansichten (Auswählen, Sortieren, Beladen, Ausliefern) nennen den
  gewählten Tag statt immer „heute": „Für den 02.09.2026 ist keine Tour
  zugewiesen." (TourDayText).
- TourBloc: Loads/Refreshes laufen nebenläufig; bei schnellem
  Datumswechsel konnte die langsamere Antwort des vorigen Tages die Tour
  des gewählten Tages überschreiben. Jetzt schreibt nur die jüngste
  Anfrage ihr Ergebnis.
- Kalender reicht 90 statt 14 Tage zurück (z. B. nachimportierte Touren
  vom Monatsanfang).
- Test: neuer Fahrer startet auf „heute", auch wenn der vorige einen
  anderen Tag gewählt hatte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:04:56 +02:00
a5215367fd fix: gespeicherte Fahrzeugauswahl gegen Server-Fahrzeuge prüfen
Die Tagesauswahl liegt lokal pro Fahrer und wurde ungeprüft
wiederhergestellt. Durch den alten Session-Fehler hatte 414 auf dem
Gerät das Fahrzeug von 423 gespeichert — das erschien weiter, obwohl
„Fahrzeug verwalten" (frisch vom Server) leer war.

CarSelectBloc lädt beim Wiederherstellen die Fahrzeuge des Accounts vom
Server und übernimmt die Auswahl nur, wenn das Fahrzeug dazugehört und
aktiv ist; sonst wird sie verworfen und neu abgefragt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:53:37 +02:00
dce4447fcc fix: fahrerbezogene Daten bei jeder Anmeldung neu laden
Tour, Datum, Fahrzeuge, Fahrzeugauswahl und Phase lebten bisher pro
Server-Umgebung, nicht pro Anmeldung. Nach Abmelden von 423 und Anmelden
von 414 lieferte der gecachte CarsBloc weiter die Fahrzeuge von 423.

Neu: SessionScope unter dem AuthBloc legt diese Blocs bei jeder Anmeldung
(neuer Fahrer oder Re-Login) komplett neu an; alles wird frisch vom
Server geholt. Token-Refresh desselben Fahrers zählt nicht als neue
Anmeldung. Zahlungsmethoden werden ebenfalls nach jeder Anmeldung neu
geladen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:47:25 +02:00
5a9e16dc10 feat: Anschrift unter „Alle Kontaktinfos" anzeigen
Jede Kontaktquelle zeigt ihre Anschrift (inkl. Zusatz, Land nur im
Ausland), sofern das ERP eine pflegt. Die Anschrift zählt beim
Zusammenführen gleicher Quellen mit — gleicher Name an anderer Adresse
bleibt eine eigene Karte. API-Client neu generiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:26:01 +02:00
335cf02a9f feat: Adresszusatz (Ortsteil/Etage/Hinweis) anzeigen, Land nur im Ausland
- Address.addition aus dem Backend (ERP-Freitext, praktisch Ortsteil,
  Etage oder Hinweis); API-Client neu generiert.
- Listen (Sortieren, Beladen, Liefer-Übersicht): Adresse mit Zusatz in
  Klammern, z. B. „… 41472 Neuss (Holzheim)".
- Lieferdetails: „Zusatz: …" unter der Anschrift von Empfänger und
  Besteller; Land nur außerhalb Deutschlands.
- Maps-Suche weiter ohne Zusatz (sonst stört „3 Etage" die Suche).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:13:28 +02:00
d650c735f8 fix: Empfänger statt Besteller in Umlade-Dialogen und Filial-Abholung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:57:12 +02:00
857c92031a feat: abweichende Lieferadresse und Empfänger vor Ort anzeigen
Belege können im ERP eine eigene Lieferadresse (LieferAdressId) mit
anderer Person tragen als der Besteller. Die App zeigt jetzt überall den
Empfänger vor Ort:

- TourDetails.recipientOf liefert Empfänger (Name aus der Lieferadresse,
  sonst Besteller), Lieferanschrift und differsFromOrderer.
- Sortieren, Auswählen, Beladen und Liefer-Übersicht: Empfängername +
  Lieferadresse, bei Abweichung „Bestellt von: <Kunde>".
- Lieferdetails: hervorgehobene Karte „Abweichende Lieferadresse" mit
  Empfänger, Anschrift und Kontakt; Kunden-Block als „Besteller (Kunde)"
  mit Adresse, Kundennummer und Kontaktdaten.
- Maps navigiert zur Lieferadresse statt zur Kundenadresse; AppBar,
  Unterschrift und Beladen-Avatar nutzen den Empfänger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:51:31 +02:00
99247b4b67 fix(pickup): nach vollständiger Filial-Abholung direkt die Lieferung öffnen
Der Fertig-Button der Filial-Abholung ersetzt die Scan-Page jetzt durch
DeliveryDetail (pushReplacement) statt zur Übersicht zurückzukehren.
Zurück aus der Lieferung führt weiterhin zur Übersicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:31:09 +02:00
200992262a feat(flags): Feature-Flags aus Config-Datei; Gutschriften-Abschnitt abschaltbar
- Schalter stehen in assets/feature_flags.json (Key -> enabled +
  Beschreibung) statt als Konstanten im Code
- Enum Feature buendelt die stabilen Keys; FeatureFlags liest die Datei,
  ignoriert unbekannte Keys und nutzt fuer fehlende den Default aus dem Enum
- Laden beim App-Start im AppBloc, bereitgestellt per RepositoryProvider;
  FeatureGate blendet Bereiche bei inaktivem Feature komplett aus
- articles.credit_section = false: Abschnitt "Gutschriften" im
  Artikel-Step ist ausgeblendet
- Bisheriges Stepper-Flag migriert (articles.credit_amount_stepper); die
  fuenf ungenutzten statischen Flags mit veralteten Kommentaren entfernt
- Tests: Asset enthaelt alle Keys, Parsing, Defaults, FeatureGate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:07:40 +02:00
6b934e4534 feat(abschluss): interne Notiz nach den Unterschriften abfragen
- Nach "Abschliessen" oeffnet sich ein Blatt "Interne Notiz" (optional,
  max. 2000 Zeichen). Ohne Eingabe genuegt ein Tipp auf "Ohne Notiz
  abschliessen"; "Zurueck zur Unterschrift" bricht nur das Abschliessen ab
- Die Notiz reist im Abschluss-Aufruf mit (internalNote) und landet im
  Lieferbericht, nicht in den Kunden-Notizen
- API-Client aus aktueller Backend-Spec neu generiert
- Widget-Tests fuer das Blatt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:52:51 +02:00
dd7fe9a8eb feat(zahlung): eigener Step "Zahlung" vor der Uebersicht
- Neuer Workflow-Step "Zahlung" zeigt ausgelieferte Artikel mit Menge und
  Preis, Betragsaufstellung, Zahlungsstatus und einen grossen Button
  "Zahlung abwickeln" (fest unten)
- Zahlungs-Modal: grosser offener Betrag, Methoden als Cards aus den
  Backend-Stammdaten, bei EC-Karte gross die Kundennummer; schliesst nur
  ueber Abbrechen, "Zahlung erhalten" oder "Lieferung abbrechen" (mit Grund)
- Bestaetigung wird am Server protokolliert (RecordDeliveryPayment); bis
  dahin sind "Weiter" und die Uebersicht gesperrt, ausser es ist nichts
  offen (bereits bezahlt). Aendert sich der Betrag danach, wird die Zahlung
  als veraltet markiert und muss erneut abgewickelt werden
- Uebersicht zeigt die abgewickelte Zahlung statt eigener Methodenauswahl;
  Unterschrift-Flow ohne doppelte Inkasso-Stufe; Abschluss sendet Methode
  und Inkasso-Flag der gueltigen Zahlung
- DeliveryPaymentStatus als einzige Quelle fuer offenen Betrag
  (gleiche Formel wie Backend); Artikelliste/Betragskarte als geteilte
  Widgets; Begruendungs-Dialog geteilt mit Info-Step
- API-Client aus aktueller Backend-Spec neu generiert
- Tests fuer Zahlungsstatus und Modal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:04:36 +02:00
f832b69b9d feat(umgebung): Server-Umgebung im Login-Screen waehlbar (Produktion/Test)
- Umgebungen (Name, API-URL, Keycloak-Issuer) kommen aus
  assets/hl_server_config.json statt hartcodiertem BackendConfig.prod;
  Testumgebung 192.168.1.9:3001 ist dort hinterlegt
- AppBloc laedt die Config, aktiviert die gespeicherte Wahl (Fallback
  Default) und registriert das Networking; Wechsel persistiert per
  SharedPreferences und registriert neu
- Bloc-Baum ist pro Umgebung gekeyt: beim Wechsel entstehen alle Blocs
  mit dem neuen API-Client neu
- Login-Screen: Zahnrad oben rechts oeffnet Einstellungen mit Umgebungs-
  Cards (Name + IP:Port); Hinweis auf aktive Umgebung unter dem Button
- Fahrzeugauswahl pro Umgebung getrennt (eigene DB = eigene Car-IDs)
- Tests fuer Config-Parsing, Auswahl-Fallback, Card-Wechsel, Re-Registrierung

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:06:49 +02:00
c239cdfd3e feat(retry): jeder dynamisch geladene Request hat im Fehlerfall einen Retry
Inventur aller Lese-Requests (Tour, Fahrzeuge, Zahlungsmethoden,
Foto-Vorschauen) und aller Stellen, an denen ihr Fehlerzustand gerendert wird.
Geschlossene Luecken:

- Sortieren-Seite: TourLoadFailed fiel in den Lade-Zweig -> Endlos-Spinner
  ohne Ausweg. Jetzt Fehlerseite mit 'Erneut versuchen' im Header-Rahmen
- Home: scheiterte beim Start CarLoad oder LoadTour, wurde die Phase nie
  geladen -> Endlos-Spinner. Jetzt Fehlerseite mit Retry (+ Drawer)
- Beladen-/Auswaehlen-Seite: Fehlerseite stand ohne Scaffold/Header/Drawer
  da; jetzt im selben Rahmen wie die uebrigen Zustaende
- Hintergrund-Reload (refreshError): Snackbar jetzt MIT Retry-Aktion und
  zentral im PhaseStepper-Header -> gilt in allen Phasen (vorher nur
  Ausliefern, ohne Retry; auf Sortieren/Beladen blieb der Fehler stumm)
- AttachmentImage: Fehleranzeige ist antippbar ('Erneut laden')

Zahlungsmethoden haben den Retry bereits aus dem vorigen Commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 12:15:19 +03:00
7cf17466b3 fix(auth): Session ueberlebt Netz-Aussetzer; Zahlungsmethoden laden nach Re-Login neu
Kunde meldete haeufig 'Sitzung abgelaufen' und danach dauerhaft rotes
'Sitzung abgelaufen' bei den Zahlungsmethoden trotz erfolgreichem Login.

Token-Refresh:
- _performRefresh wertete JEDEN Fehler als tote Session (Logout + Refresh-
  Token geloescht) - auch Mobilfunk-/VPN-Aussetzer. Jetzt gilt nur noch eine
  echte Ablehnung durch Keycloak (OAuth invalid_grant o. ae.) als abgelaufen;
  voruebergehende Fehler behalten die Session, nutzen den noch gueltigen
  Access-Token weiter oder werfen AuthTemporarilyUnavailableException
- Interceptor schickt Requests nicht mehr tokenlos weiter (ergab 401 ->
  irrefuehrendes 'Sitzung abgelaufen'), sondern bricht als Verbindungsfehler ab
- restoreSession verwirft den Refresh-Token bei fehlendem Netz nicht mehr

Zahlungsmethoden:
- Cubit lud nur einmal beim App-Start (vor dem Login -> 401) und blieb danach
  fuer immer im Fehlerzustand. Jetzt Reload bei jedem Wechsel auf Authenticated
- Fehlerkarte in der Uebersicht hat einen 'Erneut laden'-Button

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 11:59:56 +03:00
a027c93c62 fix(phasen): ohne Tour nur erster Schritt aktiv - Stepper sperrt Beladen/Ausliefern
An einem Tag ohne Tour konnte man in der AppBar durch alle Stepper-Schritte
navigieren. Ursache: an leeren Tagen gilt der GETEILTE 'no-tour'-Persistenz-
Eimer; einmal dort aufgeschlossener Fortschritt (hohes Max) machte an JEDEM
leeren Tag Beladen/Ausliefern anklickbar.

- Stepper: ohne geladene Tour (TourEmpty/loading) ist maxReached hart der
  erste sichtbare Schritt -> weitere Schritte nicht anklickbar, keine Haekchen
- PhaseBloc._load: ohne Tour ausschliesslich Eintrittsphase, Persistenz wird
  weder gelesen noch geschrieben (kein Fortschritt im 'no-tour'-Eimer)
- PhaseBloc._set: ohne Tour jeden Vorwaerts-Wechsel verwerfen, nichts
  persistieren - unabhaengig vom ausloesenden Button/Tap

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-09 01:32:14 +02:00
3add6b498e fix(phasen): Reset zeigt sofort nur 'Sortieren' - keine ererbten Haekchen
Nach einem Tour-Wechsel (Tageswechsel im Kopf-Kalender, Resync) blieben bis
zum asynchronen Nachladen aus den SharedPreferences noch Phase und Haekchen
(maxReached) der vorherigen Tour sichtbar; zudem konnte ein persistiertes
Max uebernommen werden, obwohl fuer die neue Tour-Version gar keine Phase
persistiert war.

- Bei Tour-Wechsel eines bereits geladenen Autos den In-Memory-Stand SOFORT
  synchron auf die Eintrittsphase setzen (nur Sortieren aktiv, keine
  Haekchen); ein persistierter Stand der neuen Tour folgt per PhaseLoaded
- persistedMax nur uebernehmen, wenn fuer diese Tour-Version auch eine Phase
  persistiert ist (frische Tour-Version startet immer ohne ererbte Haekchen)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-09 01:25:20 +02:00
146 changed files with 10553 additions and 1247 deletions

12
assets/feature_flags.json Normal file
View File

@ -0,0 +1,12 @@
{
"features": {
"articles.credit_section": {
"enabled": false,
"description": "Abschnitt „Gutschriften“ (Betrags-Gutschrift) im Artikel-Step der Lieferung. Aus = komplett ausgeblendet."
},
"articles.credit_amount_stepper": {
"enabled": false,
"description": "Eingabe der Betrags-Gutschrift per +/−-Stepper in 10-€-Schritten statt freiem Textfeld."
}
}
}

View File

@ -1,3 +1,21 @@
{
"backendUrl": "http://192.168.1.9:3000"
"defaultEnvironment": "production",
"keycloak": {
"clientId": "holzleitner-app",
"redirectUrl": "holzleitner://oauth2redirect"
},
"environments": [
{
"id": "production",
"name": "Produktion",
"apiBaseUrl": "http://192.168.1.9:3000",
"keycloakIssuerUrl": "http://192.168.1.9:8080/realms/holzleitner"
},
{
"id": "test",
"name": "Testumgebung",
"apiBaseUrl": "http://192.168.1.9:3001",
"keycloakIssuerUrl": "http://192.168.1.9:8080/realms/holzleitner"
}
]
}

View File

@ -1,20 +1,79 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/bloc/app_events.dart';
import 'package:hl_lieferservice/bloc/app_states.dart';
import 'package:hl_lieferservice/data/network/backend_config.dart';
import 'package:hl_lieferservice/data/network/backend_environment.dart';
import 'package:hl_lieferservice/data/network/environment_repository.dart';
import 'package:hl_lieferservice/feature/feature_flags/feature_flags_repository.dart';
/// Aktiviert das Networking für eine Umgebung (Registrierung im Locator).
typedef EnvironmentActivator = void Function(BackendConfig config);
/// App-Bootstrap-Bloc.
///
/// Vor der Backend-Migration lud dieser Bloc eine `hl_server_config.json` aus
/// assets, parste daraus eine `backendUrl` und persistierte sie ins Dateisystem.
/// Mit dem Wechsel auf das Rust-Backend kommt die URL über `BackendConfig`
/// (compile-time, siehe `data/network/backend_config.dart`); der App-Bloc
/// emittiert jetzt nur noch sofort `AppConfigLoaded`, damit die UI ihre
/// üblichen Phasen-Übergänge behält.
/// Liest die Server-Umgebungen aus `assets/hl_server_config.json`,
/// bestimmt die aktive Umgebung (gespeicherte Wahl, sonst Default) und
/// registriert dafür das Networking. Außerdem lädt er die Feature-Schalter
/// aus `assets/feature_flags.json`. Ein Umgebungswechsel läuft über denselben
/// Weg; die UI baut ihren Bloc-Baum pro Umgebung neu auf.
class AppBloc extends Bloc<AppEvents, AppState> {
AppBloc() : super(const AppInitial()) {
on<AppLoadConfig>((event, emit) {
AppBloc({
required EnvironmentRepository repository,
required EnvironmentActivator activate,
FeatureFlagsRepository featureFlagsRepository =
const FeatureFlagsRepository(),
}) : _repository = repository,
_activate = activate,
_featureFlagsRepository = featureFlagsRepository,
super(const AppInitial()) {
on<AppLoadConfig>(_onLoad);
on<AppSwitchEnvironment>(_onSwitch);
}
final EnvironmentRepository _repository;
final EnvironmentActivator _activate;
final FeatureFlagsRepository _featureFlagsRepository;
Future<void> _onLoad(AppLoadConfig event, Emitter<AppState> emit) async {
emit(const AppConfigLoading());
emit(const AppConfigLoaded());
});
try {
final catalog = await _repository.loadCatalog('assets/${event.path}');
final featureFlags = await _featureFlagsRepository.load();
final storedId = await _repository.loadSelectedId();
// Unbekannte ID (Umgebung aus der Config entfernt) → Default.
final active = catalog.byId(storedId) ?? catalog.defaultEnvironment;
_activate(catalog.configFor(active));
emit(AppConfigLoaded(
catalog: catalog,
active: active,
featureFlags: featureFlags,
));
} catch (e, st) {
debugPrint('AppBloc: Konfiguration nicht ladbar: $e\n$st');
emit(AppConfigLoadingFailed(
message: 'Konfiguration konnte nicht geladen werden.\n$e',
));
}
}
Future<void> _onSwitch(
AppSwitchEnvironment event,
Emitter<AppState> emit,
) async {
final current = state;
if (current is! AppConfigLoaded) return;
if (current.active.id == event.environmentId) return;
final BackendEnvironment? next = current.catalog.byId(event.environmentId);
if (next == null) return;
await _repository.saveSelectedId(next.id);
_activate(current.catalog.configFor(next));
emit(AppConfigLoaded(
catalog: current.catalog,
active: next,
featureFlags: current.featureFlags,
));
}
}

View File

@ -1,7 +1,16 @@
abstract class AppEvents {}
/// Lädt die Server-Config aus den Assets und aktiviert die gespeicherte
/// (oder die Default-)Umgebung. [path] ist relativ zu `assets/`.
class AppLoadConfig extends AppEvents {
String path;
AppLoadConfig({required this.path});
}
/// Wechselt auf die Umgebung mit [environmentId] und speichert die Wahl.
class AppSwitchEnvironment extends AppEvents {
AppSwitchEnvironment({required this.environmentId});
final String environmentId;
}

View File

@ -1,10 +1,11 @@
import 'package:hl_lieferservice/data/network/backend_environment.dart';
import 'package:hl_lieferservice/feature/feature_flags/feature_flags.dart';
/// Lifecycle-States des App-Bootstraps.
///
/// Die alte `LocalDocuFrameConfiguration` mit `backendUrl` ist mit der
/// Backend-Migration entfallen — die App-Konfiguration kommt jetzt aus
/// `BackendConfig` (compile-time) und nicht mehr aus einer asset-JSON.
/// Das `AppConfigLoaded`-Signal bleibt als Marker, dass der App-Bootstrap
/// abgeschlossen ist (Networking ist registriert, Token-Provider steht).
/// `AppConfigLoaded` heißt: Server-Config ist gelesen, die aktive Umgebung
/// steht fest, das Networking dafür ist im Locator registriert und die
/// Feature-Schalter sind geladen.
abstract class AppState {
const AppState();
}
@ -18,7 +19,20 @@ class AppConfigLoading extends AppState {
}
class AppConfigLoaded extends AppState {
const AppConfigLoaded();
const AppConfigLoaded({
required this.catalog,
required this.active,
required this.featureFlags,
});
/// Alle auswählbaren Umgebungen aus der Config-Datei.
final ServerCatalog catalog;
/// Die Umgebung, gegen die die App gerade spricht.
final BackendEnvironment active;
/// Feature-Schalter aus `assets/feature_flags.json`.
final FeatureFlags featureFlags;
}
class AppConfigLoadingFailed extends AppState {

View File

@ -6,6 +6,7 @@ import 'package:hl_lieferservice/domain/entity/contact_source.dart';
import 'package:hl_lieferservice/domain/entity/customer.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery_credit.dart';
import 'package:hl_lieferservice/domain/entity/delivery_payment.dart';
import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
import 'package:hl_lieferservice/domain/entity/delivery_note.dart';
import 'package:hl_lieferservice/domain/entity/delivery_service_value.dart';
@ -30,6 +31,7 @@ extension ApiAddressMapper on api.Address {
postalCode: postalCode,
city: city,
country: country,
addition: addition,
);
}
@ -115,6 +117,7 @@ extension ApiContactSourceMapper on api.ContactSource {
name3: name3,
abteilung: abteilung,
funktion: funktion,
address: address?.toDomain(),
);
}
@ -335,6 +338,11 @@ extension ApiTourDetailsMapper on api.TourDetails {
for (final c in credits) c.deliveryId: c.toDomain(),
};
// Zahlungen: höchstens eine pro Lieferung (jüngster Eintrag).
final paymentsMap = <String, DeliveryPayment>{
for (final p in payments) p.deliveryId: p.toDomain(),
};
// Service-Definitionen (aktiv, sortiert) + Pro-Lieferung-Werte indizieren.
final servicesList =
services.map((s) => s.toDomain()).toList(growable: false);
@ -367,6 +375,7 @@ extension ApiTourDetailsMapper on api.TourDetails {
warehouses: warehousesMap,
notesByDeliveryId: notesGrouped,
creditsByDeliveryId: creditsMap,
paymentsByDeliveryId: paymentsMap,
services: servicesList,
serviceValuesByDeliveryId: serviceValues,
contactSourcesByDeliveryId: sourcesGrouped,
@ -406,3 +415,14 @@ extension ApiDeliveryCreditMapper on api.DeliveryCredit {
reason: reason,
);
}
extension ApiDeliveryPaymentMapper on api.DeliveryPayment {
DeliveryPayment toDomain() => DeliveryPayment(
id: id,
deliveryId: deliveryId,
paymentMethodId: paymentMethodId,
paymentMethodCode: paymentMethodCode,
amountCents: amountCents,
recordedAt: recordedAt.toLocal(),
);
}

View File

@ -5,5 +5,25 @@
abstract interface class AuthTokenProvider {
/// Liefert einen aktuell gültigen Access-Token oder `null`, wenn
/// keine Session aktiv ist. Darf bei Bedarf einen Refresh anstoßen.
///
/// Wirft [AuthTemporarilyUnavailableException], wenn zwar eine Session
/// existiert, der Token aber wegen eines **vorübergehenden** Problems
/// (Netz/VPN/Timeout) gerade nicht erneuert werden konnte.
Future<String?> currentAccessToken();
}
/// Der Access-Token konnte gerade nicht erneuert werden, obwohl die Session
/// (Refresh-Token) weiterhin gültig sein dürfte — typisch: Mobilfunk-/VPN-
/// Aussetzer, Timeout, Login-Server kurz nicht erreichbar.
///
/// Bewusst **kein** „Session abgelaufen": der Aufrufer soll den Request als
/// Verbindungsfehler behandeln und später erneut versuchen, statt den Fahrer
/// auszuloggen.
class AuthTemporarilyUnavailableException implements Exception {
const AuthTemporarilyUnavailableException(this.cause);
final Object cause;
@override
String toString() => 'AuthTemporarilyUnavailableException($cause)';
}

View File

@ -1,9 +1,8 @@
/// Endpoint-Konfiguration für das Rust-Backend.
///
/// Produktiv-Setup: Backend und Keycloak laufen auf dem Host `192.168.1.9`.
/// Es gibt bewusst KEINE Dev-/localhost-/Tunnel-Profile und keine
/// `HL_BACKEND`-Umschaltung mehr — die App zeigt immer auf Prod. So kann kein
/// Build versehentlich auf `localhost` oder eine Dev-IP zeigen.
/// Wird nicht mehr im Code festgelegt, sondern aus der gewählten
/// Umgebung in `assets/hl_server_config.json` abgeleitet
/// (`ServerCatalog.configFor`). Die aktive Instanz liegt im GetIt-Locator.
class BackendConfig {
const BackendConfig({
required this.apiBaseUrl,
@ -37,17 +36,4 @@ class BackendConfig {
/// dem `CFBundleURLSchemes`-Eintrag in `ios/Runner/Info.plist`
/// matchen.
final String keycloakRedirectUrl;
/// Produktiv-Konfiguration — einzige Quelle der Wahrheit.
static const BackendConfig prod = BackendConfig(
apiBaseUrl: 'http://192.168.1.9:3000',
keycloakIssuerUrl: 'http://192.168.1.9:8080/realms/holzleitner',
keycloakClientId: 'holzleitner-app',
keycloakRedirectUrl: 'holzleitner://oauth2redirect',
);
/// Aktive Konfiguration. Früher per `--dart-define=HL_BACKEND` zwischen
/// Dev-Profilen (usb-Tunnel / LAN-IP) umschaltbar — entfernt. Das Flag wird
/// jetzt ignoriert; es zählt immer [prod].
static const BackendConfig fromEnvironment = prod;
}

View File

@ -0,0 +1,123 @@
import 'backend_config.dart';
/// Eine auswählbare Server-Umgebung (z. B. Produktion, Test).
///
/// Quelle ist ausschließlich `assets/hl_server_config.json` — die App
/// kennt keine hartcodierten Server. Siehe [ServerCatalog].
class BackendEnvironment {
const BackendEnvironment({
required this.id,
required this.name,
required this.apiBaseUrl,
required this.keycloakIssuerUrl,
});
/// Stabiler Schlüssel; wird als Auswahl persistiert und trennt lokale,
/// umgebungsabhängige Daten (z. B. Fahrzeugauswahl).
final String id;
/// Anzeigename in der Umgebungsauswahl.
final String name;
/// Basis-URL der Rust-API (ohne abschließenden Slash).
final String apiBaseUrl;
/// Realm-Issuer der Keycloak-Instanz dieser Umgebung.
final String keycloakIssuerUrl;
/// „IP:Port“ der API für die Anzeige, z. B. `192.168.1.9:3001`.
String get hostAndPort {
final uri = Uri.parse(apiBaseUrl);
return uri.hasPort ? '${uri.host}:${uri.port}' : uri.host;
}
factory BackendEnvironment.fromJson(Map<String, dynamic> json) {
return BackendEnvironment(
id: _requireString(json, 'id'),
name: _requireString(json, 'name'),
apiBaseUrl: _stripTrailingSlash(_requireString(json, 'apiBaseUrl')),
keycloakIssuerUrl:
_stripTrailingSlash(_requireString(json, 'keycloakIssuerUrl')),
);
}
}
/// Alle in der Config-Datei hinterlegten Umgebungen plus die
/// umgebungsübergreifenden Keycloak-Client-Daten.
///
/// Client-Id und Redirect-URL sind global, weil sie an die native
/// Plattform-Konfiguration gebunden sind (Redirect-Scheme in
/// `build.gradle.kts` / `Info.plist`) und nicht pro Server variieren.
class ServerCatalog {
ServerCatalog({
required this.environments,
required this.defaultEnvironmentId,
required this.keycloakClientId,
required this.keycloakRedirectUrl,
}) {
if (environments.isEmpty) {
throw const FormatException('Config enthält keine Umgebungen.');
}
final ids = environments.map((e) => e.id).toSet();
if (ids.length != environments.length) {
throw const FormatException('Umgebungs-IDs in der Config sind doppelt.');
}
if (!ids.contains(defaultEnvironmentId)) {
throw FormatException(
'defaultEnvironment "$defaultEnvironmentId" existiert nicht.',
);
}
}
final List<BackendEnvironment> environments;
final String defaultEnvironmentId;
final String keycloakClientId;
final String keycloakRedirectUrl;
BackendEnvironment get defaultEnvironment => byId(defaultEnvironmentId)!;
BackendEnvironment? byId(String? id) {
for (final env in environments) {
if (env.id == id) return env;
}
return null;
}
/// Vollständige Netzwerk-Konfiguration für [environment].
BackendConfig configFor(BackendEnvironment environment) {
return BackendConfig(
apiBaseUrl: environment.apiBaseUrl,
keycloakIssuerUrl: environment.keycloakIssuerUrl,
keycloakClientId: keycloakClientId,
keycloakRedirectUrl: keycloakRedirectUrl,
);
}
factory ServerCatalog.fromJson(Map<String, dynamic> json) {
final keycloak = json['keycloak'];
if (keycloak is! Map<String, dynamic>) {
throw const FormatException('Abschnitt "keycloak" fehlt in der Config.');
}
final rawEnvironments = json['environments'];
if (rawEnvironments is! List) {
throw const FormatException('Liste "environments" fehlt in der Config.');
}
return ServerCatalog(
environments: rawEnvironments
.map((e) => BackendEnvironment.fromJson(e as Map<String, dynamic>))
.toList(growable: false),
defaultEnvironmentId: _requireString(json, 'defaultEnvironment'),
keycloakClientId: _requireString(keycloak, 'clientId'),
keycloakRedirectUrl: _requireString(keycloak, 'redirectUrl'),
);
}
}
String _requireString(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is String && value.trim().isNotEmpty) return value.trim();
throw FormatException('Feld "$key" fehlt oder ist leer in der Config.');
}
String _stripTrailingSlash(String url) =>
url.endsWith('/') ? url.substring(0, url.length - 1) : url;

View File

@ -0,0 +1,35 @@
import 'dart:convert';
import 'package:flutter/services.dart' show rootBundle;
import 'package:shared_preferences/shared_preferences.dart';
import 'backend_environment.dart';
/// Lädt die Umgebungsliste aus der Asset-Config und persistiert, welche
/// Umgebung der Nutzer gewählt hat.
///
/// Bewusst wird nur die **Auswahl** gespeichert, nicht die Liste selbst:
/// So wirkt eine geänderte Config (neue IP, neue Umgebung) mit dem
/// nächsten App-Update, ohne dass veraltete Kopien im Gerät hängen.
class EnvironmentRepository {
static const _selectedKey = 'selected_backend_environment';
Future<ServerCatalog> loadCatalog(String assetPath) async {
final raw = await rootBundle.loadString(assetPath);
final json = jsonDecode(raw);
if (json is! Map<String, dynamic>) {
throw const FormatException('Config ist kein JSON-Objekt.');
}
return ServerCatalog.fromJson(json);
}
Future<String?> loadSelectedId() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_selectedKey);
}
Future<void> saveSelectedId(String id) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_selectedKey, id);
}
}

View File

@ -22,6 +22,20 @@ class HolzleitnerAuthInterceptor extends Interceptor {
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
} on AuthTemporarilyUnavailableException catch (e) {
// Token ließ sich wegen eines Netz-/VPN-Problems nicht erneuern. NICHT
// tokenlos weiterschicken — das Backend würde mit 401 antworten und die
// UI fälschlich „Sitzung abgelaufen" zeigen. Stattdessen sauber als
// Verbindungsfehler abbrechen; der nächste Request versucht es erneut.
return handler.reject(
DioException(
requestOptions: options,
type: DioExceptionType.connectionError,
error: e,
message:
'Anmeldung konnte nicht erneuert werden (keine Verbindung zum Login-Server)',
),
);
} catch (e, stack) {
// TODO Phase B: hier ein strukturiertes Logging-Framework
// einhängen statt print.

View File

@ -194,7 +194,15 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
if (stored == null || stored.isEmpty) return false;
_refreshToken = stored;
final token = await currentAccessToken();
final String? token;
try {
token = await currentAccessToken();
} on AuthTemporarilyUnavailableException catch (e) {
// Beim App-Start gerade kein Netz/VPN: Refresh-Token NICHT verwerfen —
// der nächste Start (oder Login) kann die Session wieder aufnehmen.
debugPrint('restoreSession: Refresh vorübergehend nicht möglich: $e');
return false;
}
if (token == null) return false;
_events.add(AuthLoggedIn(_idTokenClaims ?? const <String, dynamic>{}));
@ -243,8 +251,15 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
/// Führt EINEN Token-Refresh aus. Bei Erfolg werden die Tokens übernommen
/// und der neue Access-Token zurückgegeben (ohne Event — stiller Refresh).
/// Bei Fehler ist die Session tot: lokal aufräumen, `AuthSessionExpired`
/// emittieren, `null` zurück.
///
/// Fehler werden unterschieden:
/// * **Abgelehnt** (OAuth `invalid_grant` o. ä.: Refresh-Token abgelaufen/
/// widerrufen, SSO-Session beendet) → Session ist tot: lokal aufräumen,
/// `AuthSessionExpired` emittieren, `null` zurück.
/// * **Vorübergehend** (Netz/VPN/Timeout/Server kurz weg) → Session
/// BEHALTEN; noch gültigen Access-Token weiterverwenden, sonst
/// [AuthTemporarilyUnavailableException] werfen. Früher beendete jeder
/// Netz-Hänger die Session und löschte den Refresh-Token.
Future<String?> _performRefresh(String rt) async {
try {
final result = await _appAuth.token(
@ -265,8 +280,24 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
);
await _persistRefreshToken();
return _accessToken;
} on Exception {
// Refresh hat nicht funktioniert — Session ist tot, nicht
} on Exception catch (e) {
if (!_isSessionRejected(e)) {
// Vorübergehender Fehler (Mobilfunk-/VPN-Aussetzer, Timeout, Keycloak
// kurz nicht erreichbar): die Session ist NICHT tot. Refresh-Token
// behalten und beim nächsten Request erneut versuchen — sonst zwingt
// jeder Netz-Hänger den Fahrer zum Neu-Login.
debugPrint('Token-Refresh vorübergehend fehlgeschlagen: $e');
final cached = _accessToken;
final expiresAt = _expiresAt;
if (cached != null &&
expiresAt != null &&
expiresAt.isAfter(DateTime.now().toUtc())) {
// Bisheriger Access-Token ist noch (kurz) gültig → weiterverwenden.
return cached;
}
throw AuthTemporarilyUnavailableException(e);
}
// Keycloak hat den Refresh-Token ABGELEHNT — Session ist tot, nicht
// wiederherstellbar. Reihenfolge bewusst: erst State leeren + Event
// feuern, DANN best-effort den Storage löschen — so kann ein
// werfendes `delete` weder das Event verschlucken noch eine Exception
@ -282,6 +313,30 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
}
}
/// OAuth-Fehler, bei denen ein erneuter Versuch sinnlos ist: der
/// Authorization-Server hat den Refresh endgültig abgelehnt.
static const Set<String> _fatalOAuthErrors = {
FlutterAppAuthOAuthError.invalidGrant,
FlutterAppAuthOAuthError.invalidClient,
FlutterAppAuthOAuthError.unauthorizedClient,
};
/// `true`, wenn der Fehler eine echte **Ablehnung** durch Keycloak ist
/// (Session wirklich beendet). Alles andere — Netzwerk, Timeout, 5xx —
/// gilt als vorübergehend. Primär über den strukturierten OAuth-Fehlercode
/// von `flutter_appauth`; als Fallback die Textsuche nach `invalid_grant`.
static bool _isSessionRejected(Object e) {
if (e is FlutterAppAuthPlatformException) {
final oauthError = e.platformErrorDetails.error;
if (oauthError != null) return _fatalOAuthErrors.contains(oauthError);
}
if (e is PlatformException) {
final haystack = '${e.code} ${e.message} ${e.details}'.toLowerCase();
return haystack.contains('invalid_grant');
}
return false;
}
void _applyTokens({
required String? accessToken,
required String? refreshToken,

View File

@ -6,20 +6,18 @@ import 'backend_config.dart';
import 'holzleitner_api_factory.dart';
import 'keycloak_oidc_token_provider.dart';
/// Registriert das HTTP-/API-Subsystem im globalen GetIt-Locator.
/// Registriert das HTTP-/API-Subsystem für [config] im globalen
/// GetIt-Locator.
///
/// Aufruf bewusst nicht im AppBloc-Lifecycle, sondern in `main()` vor
/// dem `runApp` — die API-Klassen sind über die gesamte App-Lebensdauer
/// stabil.
///
/// Phase B: produktiver `KeycloakOidcTokenProvider`. Die alte
/// `DevPasswordGrantTokenProvider`-Implementation bleibt im Code (für
/// das reine dart-Smoke-Tool, siehe `tool/smoke_test_api.dart`).
/// Aufgerufen vom `AppBloc`, sobald die Umgebung aus der Config-Datei
/// feststeht — und erneut bei jedem Umgebungswechsel. Bereits registrierte
/// Instanzen werden dann ersetzt; die App baut ihren Bloc-Baum danach neu
/// auf, damit niemand mehr den alten Client festhält.
void registerNetworking({
required GetIt locator,
BackendConfig config = BackendConfig.fromEnvironment,
required BackendConfig config,
}) {
locator.registerSingleton<BackendConfig>(config);
_replace<BackendConfig>(locator, config);
final provider = KeycloakOidcTokenProvider(
config: config,
@ -29,13 +27,18 @@ void registerNetworking({
// Doppelt registrieren: einmal unter der konkreten Klasse (für
// den AuthBloc, der Login/Logout/Restore aufruft) und einmal hinter
// dem Interface (für den HTTP-Interceptor).
locator.registerSingleton<KeycloakOidcTokenProvider>(provider);
locator.registerSingleton<AuthTokenProvider>(provider);
_replace<KeycloakOidcTokenProvider>(locator, provider);
_replace<AuthTokenProvider>(locator, provider);
locator.registerSingleton<HolzleitnerApi>(
buildHolzleitnerApi(
config: config,
tokenProvider: locator<AuthTokenProvider>(),
),
_replace<HolzleitnerApi>(
locator,
buildHolzleitnerApi(config: config, tokenProvider: provider),
);
}
void _replace<T extends Object>(GetIt locator, T instance) {
if (locator.isRegistered<T>()) {
locator.unregister<T>();
}
locator.registerSingleton<T>(instance);
}

View File

@ -7,6 +7,7 @@ import 'package:hl_lieferservice/data/mapper/tour_mapper.dart';
import 'package:hl_lieferservice/domain/entity/address.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery_credit.dart';
import 'package:hl_lieferservice/domain/entity/delivery_payment.dart';
import 'package:hl_lieferservice/domain/entity/delivery_note.dart';
import 'package:hl_lieferservice/domain/entity/delivery_service_value.dart';
import 'package:hl_lieferservice/domain/entity/scan_intent.dart';
@ -202,6 +203,7 @@ class TourRepositoryImpl implements TourRepository {
String? paymentMethodId,
String? actorCarId,
bool paymentCollected = false,
String? internalNote,
}) async {
// multipart/form-data: zwei Signatur-PNGs + ein JSON-Feld mit den
// Bestätigungen. Direkt über die Dio-Instanz, weil der dart-dio-Generator
@ -214,6 +216,7 @@ class TourRepositoryImpl implements TourRepository {
'paymentCollected': paymentCollected,
if (paymentMethodId != null) 'paymentMethodId': paymentMethodId,
if (actorCarId != null) 'authorCarId': actorCarId,
if (internalNote != null) 'internalNote': internalNote,
};
final form = FormData.fromMap({
'customer_signature': MultipartFile.fromBytes(
@ -435,6 +438,36 @@ class TourRepositoryImpl implements TourRepository {
}
}
@override
Future<DeliveryPayment> recordDeliveryPayment({
required String deliveryId,
required String clientEventId,
required String paymentMethodId,
required int expectedAmountCents,
String? actorCarId,
}) async {
try {
final request = api.RecordDeliveryPaymentRequest((b) {
b
..clientEventId = clientEventId
..paymentMethodId = paymentMethodId
..expectedAmountCents = expectedAmountCents;
if (actorCarId != null) b.authorCarId = actorCarId;
});
final response = await _api.getDeliveriesApi().recordPayment(
deliveryId: deliveryId,
recordDeliveryPaymentRequest: request,
);
final payment = response.data?.payment;
if (payment == null) {
throw TourRepositoryException('Zahlung protokollieren: leere Antwort');
}
return payment.toDomain();
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Zahlung protokollieren'), e);
}
}
@override
Future<DeliveryServiceValue> setDeliveryService({
required String deliveryId,
@ -502,6 +535,7 @@ class TourRepositoryImpl implements TourRepository {
postalCode: snap['postalCode'] as String,
city: snap['city'] as String,
country: snap['country'] as String,
addition: snap['addition'] as String?,
),
erpBelegartId: (j['erpBelegartId'] as num).toInt(),
erpBelegnummer: j['erpBelegnummer'] as String,

View File

@ -12,17 +12,44 @@ class Address {
required this.postalCode,
required this.city,
required this.country,
this.addition,
});
final String street;
final String houseNumber;
final String postalCode;
final String city;
/// Land laut ERP-Länderstamm (z. B. „Deutschland"), leer wenn unbekannt.
final String country;
/// Einzeilige Darstellung für Listen/Header.
String get oneLine =>
'$street $houseNumber, $postalCode $city';
/// Freier Adresszusatz aus dem ERP — praktisch Ortsteil, Etage oder
/// Hinweis („Holzheim", „3 Etage", „Parterre"). `null`, wenn leer.
final String? addition;
static const Set<String> _germany = {
'd', 'de', 'deu', 'deutschland', 'germany', 'brd',
};
/// Land für die Anzeige — nur außerhalb Deutschlands, sonst `null`.
String? get foreignCountry {
final c = country.trim();
if (c.isEmpty || _germany.contains(c.toLowerCase())) return null;
return c;
}
/// Einzeilige Anschrift „Straße Nr, PLZ Ort" (+ Land, falls Ausland).
/// Ohne Zusatz — so auch als Such-Query für Maps geeignet.
String get oneLine => [
'$street $houseNumber, $postalCode $city',
if (foreignCountry case final c?) c,
].join(', ');
/// Wie [oneLine], mit Adresszusatz in Klammern — für Listen, damit der
/// Fahrer Ortsteil/Etage auf einen Blick sieht.
String get oneLineWithAddition {
final a = addition?.trim();
return a == null || a.isEmpty ? oneLine : '$oneLine ($a)';
}
Address copyWith({
String? street,
@ -30,6 +57,7 @@ class Address {
String? postalCode,
String? city,
String? country,
String? addition,
}) {
return Address(
street: street ?? this.street,
@ -37,6 +65,7 @@ class Address {
postalCode: postalCode ?? this.postalCode,
city: city ?? this.city,
country: country ?? this.country,
addition: addition ?? this.addition,
);
}
@ -48,8 +77,10 @@ class Address {
other.houseNumber == houseNumber &&
other.postalCode == postalCode &&
other.city == city &&
other.country == country;
other.country == country &&
other.addition == addition;
@override
int get hashCode => Object.hash(street, houseNumber, postalCode, city, country);
int get hashCode =>
Object.hash(street, houseNumber, postalCode, city, country, addition);
}

View File

@ -1,3 +1,5 @@
import 'address.dart';
/// Adress-Rolle eines Beleg-Kontakts. Spiegelt die fünf Adress-FKs am
/// ERP-`Belegkopf` (bzw. den Umweg über `Kunden.AdressId`). Die App nutzt
/// das primär als Gruppierungs-Label in der Detail-Ansicht.
@ -95,6 +97,7 @@ class ContactSource {
this.name3,
this.abteilung,
this.funktion,
this.address,
});
final String id;
@ -109,6 +112,10 @@ class ContactSource {
final String? abteilung;
final String? funktion;
/// Anschrift dieses ERP-Adress-Datensatzes; `null`, wenn dort weder
/// Straße noch Ort gepflegt sind (z. B. reiner Ansprechpartner).
final Address? address;
/// Zusammengesetzte Anzeige des Namens — Anrede + Titel + Name1..3 in
/// dieser Reihenfolge, leere Felder werden übersprungen. Gibt `null`
/// zurück, wenn die Quelle gar keinen Namen trägt (kann vorkommen, wenn
@ -125,6 +132,17 @@ class ContactSource {
return parts.join(' ');
}
/// Nur der Namensblock `Name1..3` ohne Anrede/Titel, so wie der
/// Kundenname im Listen-Kopf erscheint. `null` ohne Namen.
String? get fullName {
final parts = <String>[
for (final n in [name1, name2, name3])
if (n != null && n.trim().isNotEmpty) n.trim(),
];
if (parts.isEmpty) return null;
return parts.join(' ');
}
/// Funktionale Zusatzinfo (z. B. „Buchhaltung · Leitung"). Leere
/// Komponenten werden ausgeblendet.
String? get subtitle {
@ -178,6 +196,7 @@ class MergedContactSource {
required this.abteilung,
required this.funktion,
required this.channels,
this.address,
});
/// Alle Rollen, die diesen zusammengeführten Kontakt liefern.
@ -197,6 +216,9 @@ class MergedContactSource {
/// liefert (kind + ERP-Position).
final List<ContactChannel> channels;
/// Anschrift, falls im ERP gepflegt (bei allen Quellen der Gruppe gleich).
final Address? address;
/// Zusammengesetzter Anzeigename — identisch zu [ContactSource.displayName].
String? get displayName {
final parts = <String>[

View File

@ -11,10 +11,9 @@ enum DeliveryState { active, held, canceled, completed }
/// Eine einzelne Auslieferung an einen Kunden innerhalb einer Tour.
///
/// Anders als im alten Modell trägt `Delivery` hier ausschließlich
/// Logistik-Daten — keine Preise, keine Rabatte, keine Zahlungsoptionen.
/// Diese ERP-Themen sind in Phase C+D-2 absichtlich nicht migriert und
/// hängen hinter `FeatureFlags`.
/// Trägt die Logistik-Daten der Lieferung plus die Beträge, die der
/// Zahlungs-Step braucht (Anzahlung, Zahlungsmethode vom Beleg; Stückpreise
/// liegen an den Positionen).
class Delivery {
const Delivery({
required this.id,

View File

@ -0,0 +1,32 @@
/// Protokollierte Zahlungsabwicklung („Abkassieren") einer Lieferung —
/// spiegelt das Backend-Aggregat `DeliveryPayment`.
///
/// Das Backend liefert pro Lieferung nur den jüngsten Eintrag. Der Betrag ist
/// der offene Betrag zum Zeitpunkt der Bestätigung. Ändert sich der offene
/// Betrag danach (z. B. neue Gutschrift), ist der Eintrag veraltet — siehe
/// `DeliveryPaymentStatus`.
class DeliveryPayment {
const DeliveryPayment({
required this.id,
required this.deliveryId,
required this.paymentMethodId,
required this.paymentMethodCode,
required this.amountCents,
required this.recordedAt,
});
final String id;
final String deliveryId;
final String paymentMethodId;
/// Snapshot des Methoden-Codes (`cash`, `ec_card`, `invoice`, …).
final String paymentMethodCode;
/// Abgewickelter Betrag in Cent.
final int amountCents;
final DateTime recordedAt;
/// Bar/EC = vor Ort kassiert. „Auf Rechnung" bleibt offen.
bool get isCollectedOnSite =>
paymentMethodCode == 'cash' || paymentMethodCode == 'ec_card';
}

View File

@ -0,0 +1,35 @@
import 'address.dart';
import 'contact_source.dart';
/// Wer die Ware vor Ort annimmt und wohin geliefert wird.
///
/// Im ERP kann ein Beleg neben der Belegadresse (= Besteller, Kunde) eine
/// eigene Lieferadresse (`Belegkopf.LieferAdressId`) tragen, z. B. wenn ein
/// Kunde für Angehörige bestellt. Dann unterscheiden sich Empfänger und
/// Besteller in Person und/oder Anschrift. Fahrer navigieren und klingeln
/// beim Empfänger; Zahlung und Beleg laufen weiter über den Besteller.
///
/// Gebaut über `TourDetails.recipientOf`.
class DeliveryRecipient {
const DeliveryRecipient({
required this.name,
required this.address,
required this.differsFromOrderer,
this.contact,
});
/// Name der Person/Firma vor Ort. Ohne eigenen Namen an der Lieferadresse
/// der Name des Bestellers.
final String name;
/// Anschrift, an die geliefert wird (Lieferadress-Snapshot des Belegs).
final Address address;
/// Kontaktquelle der Lieferadresse (Rolle `delivery`), falls das ERP
/// dort Namen oder Kontaktkanäle führt.
final ContactSource? contact;
/// `true`, wenn Person oder Anschrift vom Besteller abweichen. Die UI
/// weist dann auf den Besteller hin.
final bool differsFromOrderer;
}

View File

@ -1,8 +1,11 @@
import 'address.dart';
import 'article.dart';
import 'contact_source.dart';
import 'customer.dart';
import 'delivery.dart';
import 'delivery_credit.dart';
import 'delivery_payment.dart';
import 'delivery_recipient.dart';
import 'delivery_item.dart';
import 'delivery_note.dart';
import 'delivery_service_value.dart';
@ -28,6 +31,7 @@ class TourDetails {
required this.warehouses,
required this.notesByDeliveryId,
required this.creditsByDeliveryId,
required this.paymentsByDeliveryId,
required this.services,
required this.serviceValuesByDeliveryId,
required this.contactSourcesByDeliveryId,
@ -56,6 +60,10 @@ class TourDetails {
/// der Eintrag, gibt es aktuell keine Gutschrift.
final Map<String, DeliveryCredit> creditsByDeliveryId;
/// Pro Lieferung die jüngste protokollierte Zahlungsabwicklung. Fehlt der
/// Eintrag, wurde für diese Lieferung noch keine Zahlung abgewickelt.
final Map<String, DeliveryPayment> paymentsByDeliveryId;
/// Aktive Service-Definitionen (Stammdaten), nach `sortOrder`. Daraus
/// rendert Phase 4 die Auswahl.
final List<Service> services;
@ -91,6 +99,52 @@ class TourDetails {
Customer? customerOf(Delivery delivery) => customers[delivery.customerId];
/// Empfänger vor Ort: Name und Kontakt aus der ERP-Lieferadresse, sonst
/// der Besteller. [DeliveryRecipient.differsFromOrderer] ist gesetzt,
/// sobald Anschrift oder Name vom Besteller abweichen.
///
/// Der Namensvergleich läuft ERP-Adresse gegen ERP-Adresse (Lieferadresse
/// vs. Belegadresse, jeweils `Name1..3`), damit ein nur in `Name2`
/// geführter Vorname keinen Fehlalarm auslöst. Groß-/Kleinschreibung und
/// Leerzeichen zählen nicht als Abweichung.
DeliveryRecipient recipientOf(Delivery delivery) {
final customer = customerOf(delivery);
final deliverySource = contactSourceWithRole(delivery, ContactRole.delivery);
final ordererName = contactSourceWithRole(delivery, ContactRole.header)?.fullName ??
customer?.name ??
'';
final recipientName = deliverySource?.fullName;
final addressDiffers = customer != null &&
!_sameAddress(delivery.deliveryAddressSnapshot, customer.address);
final nameDiffers = recipientName != null &&
_normalized(recipientName) != _normalized(ordererName);
return DeliveryRecipient(
name: recipientName ?? customer?.name ?? '⟨Unbekannter Kunde⟩',
address: delivery.deliveryAddressSnapshot,
contact: deliverySource,
differsFromOrderer: addressDiffers || nameDiffers,
);
}
/// Erste Adress-Quelle einer Lieferung mit der Rolle [role], oder `null`.
ContactSource? contactSourceWithRole(Delivery delivery, ContactRole role) {
for (final s in contactSourcesOf(delivery)) {
if (s.role == role) return s;
}
return null;
}
static String _normalized(String value) =>
value.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' ');
static bool _sameAddress(Address a, Address b) =>
_normalized(a.street) == _normalized(b.street) &&
_normalized(a.houseNumber) == _normalized(b.houseNumber) &&
_normalized(a.postalCode) == _normalized(b.postalCode) &&
_normalized(a.city) == _normalized(b.city);
Iterable<CustomerContact> contactsOf(Delivery delivery) sync* {
for (final id in delivery.contactPersonIds) {
final c = contacts[id];
@ -157,6 +211,9 @@ class TourDetails {
s.name3 ?? '',
s.abteilung ?? '',
s.funktion ?? '',
// Anschrift gehört zur Identität: gleicher Name an anderer Adresse
// ist ein anderer Datensatz.
s.address?.oneLineWithAddition ?? '',
].join('|');
final sortedChannels = List<ContactChannel>.of(channels)
..sort((a, b) {
@ -186,6 +243,7 @@ class TourDetails {
abteilung: first.abteilung,
funktion: first.funktion,
channels: channelsOf(first),
address: first.address,
);
}
@ -200,6 +258,10 @@ class TourDetails {
DeliveryCredit? creditOf(String deliveryId) =>
creditsByDeliveryId[deliveryId];
/// Jüngste protokollierte Zahlung dieser Lieferung, oder `null`.
DeliveryPayment? paymentOf(String deliveryId) =>
paymentsByDeliveryId[deliveryId];
/// Gesetzter Service-Wert dieser Lieferung für einen Service, oder `null`.
DeliveryServiceValue? serviceValueOf(String deliveryId, String serviceId) =>
serviceValuesByDeliveryId[deliveryId]?[serviceId];
@ -398,6 +460,7 @@ class TourDetails {
List<Delivery>? deliveries,
Map<String, List<DeliveryNote>>? notesByDeliveryId,
Map<String, DeliveryCredit>? creditsByDeliveryId,
Map<String, DeliveryPayment>? paymentsByDeliveryId,
Map<String, Map<String, DeliveryServiceValue>>? serviceValuesByDeliveryId,
}) {
return TourDetails(
@ -409,6 +472,7 @@ class TourDetails {
warehouses: warehouses,
notesByDeliveryId: notesByDeliveryId ?? this.notesByDeliveryId,
creditsByDeliveryId: creditsByDeliveryId ?? this.creditsByDeliveryId,
paymentsByDeliveryId: paymentsByDeliveryId ?? this.paymentsByDeliveryId,
services: services,
serviceValuesByDeliveryId:
serviceValuesByDeliveryId ?? this.serviceValuesByDeliveryId,

View File

@ -1,5 +1,6 @@
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery_credit.dart';
import 'package:hl_lieferservice/domain/entity/delivery_payment.dart';
import 'package:hl_lieferservice/domain/entity/delivery_note.dart';
import 'package:hl_lieferservice/domain/entity/delivery_service_value.dart';
import 'package:hl_lieferservice/domain/entity/scan_intent.dart';
@ -100,6 +101,7 @@ abstract interface class TourRepository {
String? paymentMethodId,
String? actorCarId,
bool paymentCollected = false,
String? internalNote,
});
/// Legt eine neue Notiz an einer Lieferung an.
@ -166,6 +168,18 @@ abstract interface class TourRepository {
String? actorCarId,
});
/// Protokolliert die Zahlungsabwicklung („Abkassieren") einer Lieferung.
/// Append-only + idempotent über [clientEventId]. [expectedAmountCents] ist
/// der Betrag, den der Fahrer gesehen hat; weicht der Server-Betrag ab,
/// lehnt das Backend ab. Rückgabe: der protokollierte Eintrag.
Future<DeliveryPayment> recordDeliveryPayment({
required String deliveryId,
required String clientEventId,
required String paymentMethodId,
required int expectedAmountCents,
String? actorCarId,
});
/// Setzt (Upsert) den Wert eines Service für eine Lieferung. Genau das zum
/// Service-Typ passende Feld angeben. Rückgabe: der gespeicherte Wert.
Future<DeliveryServiceValue> setDeliveryService({

View File

@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/bloc/app_bloc.dart';
import 'package:hl_lieferservice/bloc/app_states.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart';
import 'package:hl_lieferservice/feature/settings/presentation/environment_settings_page.dart';
/// Login-Page nach der Migration auf Keycloak OIDC (Phase B).
///
@ -28,7 +31,25 @@ class LoginPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
appBar: AppBar(
actions: [
// Während eines laufenden Logins gesperrt: Ein Umgebungswechsel
// würde den Token-Provider mitten im Browser-Flow austauschen.
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => IconButton(
tooltip: 'Einstellungen',
icon: const Icon(Icons.settings),
onPressed: state is Authenticating
? null
: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const EnvironmentSettingsPage(),
),
),
),
),
],
),
body: Column(
children: [
if (sessionExpired)
@ -111,6 +132,7 @@ class LoginPage extends StatelessWidget {
),
),
),
const _ActiveEnvironmentHint(),
],
),
),
@ -120,3 +142,23 @@ class LoginPage extends StatelessWidget {
);
}
}
/// Zeigt, gegen welchen Server die Anmeldung läuft — damit niemand
/// versehentlich in der Testumgebung ausliefert.
class _ActiveEnvironmentHint extends StatelessWidget {
const _ActiveEnvironmentHint();
@override
Widget build(BuildContext context) {
final state = context.watch<AppBloc>().state;
if (state is! AppConfigLoaded) return const SizedBox.shrink();
final env = state.active;
return Text(
'Umgebung: ${env.name} (${env.hostAndPort})',
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}
}

View File

@ -2,7 +2,7 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/repository/car_selection_repository.dart';
import 'package:hl_lieferservice/feature/cars/model/selection.dart';
import 'package:hl_lieferservice/domain/entity/car.dart';
import 'package:hl_lieferservice/domain/repository/cars_repository.dart';
import 'events.dart';
import 'state.dart';
@ -10,7 +10,12 @@ import 'state.dart';
class CarSelectBloc extends Bloc<CarSelectEvent, CarSelectState> {
final CarSelectionRepository repository;
CarSelectBloc({required this.repository}) : super(CarSelectInitial()) {
/// Fahrzeuge des angemeldeten Accounts vom Server — gegen sie wird eine
/// gespeicherte Tagesauswahl geprüft.
final CarsRepository carsRepository;
CarSelectBloc({required this.repository, required this.carsRepository})
: super(CarSelectInitial()) {
on<CarSelectLoad>(_load);
on<CarSelectConfirm>(_confirm);
on<CarSelectChange>(_change);
@ -36,23 +41,21 @@ class CarSelectBloc extends Bloc<CarSelectEvent, CarSelectState> {
stored.date.day == today.day;
if (validForToday) {
emit(
CarSelectComplete(
selectedCar: Car(
id: stored.selectedCarId!,
// accountId/active fließen aus der lokalen Selection
// nicht durch — wir persistieren nur (id, plate) als
// UI-Pointer. Die Tour-Logik holt die vollständigen
// Car-Felder weiterhin aus dem CarsBloc-Listing.
accountId: 0,
plate: stored.selectedCarPlate!,
active: true,
),
),
);
} else {
emit(CarSelectRequired());
// Die lokale Auswahl ist nur ein Zeiger. Sie gilt nur, wenn das
// Fahrzeug laut Server (frisch geladen) zu diesem Account gehört
// und aktiv ist — sonst z. B. ein Fahrzeug eines anderen Fahrers,
// das auf diesem Gerät gewählt wurde.
final ownCars = await carsRepository.listMine();
final car = ownCars
.where((c) => c.id == stored.selectedCarId && c.active)
.firstOrNull;
if (car != null) {
emit(CarSelectComplete(selectedCar: car));
return;
}
await repository.clearSelection(event.userId);
}
emit(CarSelectRequired());
} catch (e, st) {
debugPrint('CarSelectBloc._load failed: $e');
debugPrint('Stacktrace: $st');

View File

@ -6,11 +6,22 @@ import 'package:shared_preferences/shared_preferences.dart';
/// (Strings). Alte Pre-Phase-D-Installations können noch int-Werte
/// unter `_car_id` liegen haben — die werden beim Lesen
/// stillschweigend ignoriert (Migration durch "neu auswählen").
///
/// Die Keys sind pro Server-Umgebung ([environmentId]) getrennt: Jede
/// Umgebung hat ihre eigene Datenbank und damit eigene Fahrzeug-IDs.
/// Eine Auswahl aus der Produktion darf in der Testumgebung nicht
/// wiederhergestellt werden (und umgekehrt).
class CarSelectionRepository {
static String _keyDate(String userId) => 'car_selection_${userId}_date';
static String _keyCarId(String userId) => 'car_selection_${userId}_car_id';
static String _keyCarPlate(String userId) =>
'car_selection_${userId}_car_plate';
CarSelectionRepository({required this.environmentId});
final String environmentId;
String _keyDate(String userId) =>
'car_selection_${environmentId}_${userId}_date';
String _keyCarId(String userId) =>
'car_selection_${environmentId}_${userId}_car_id';
String _keyCarPlate(String userId) =>
'car_selection_${environmentId}_${userId}_car_plate';
Future<CarSelection?> getSelection(String userId) async {
final prefs = await SharedPreferences.getInstance();
@ -38,6 +49,15 @@ class CarSelectionRepository {
);
}
/// Verwirft die gespeicherte Auswahl (z. B. Fahrzeug gehört nicht mehr
/// zum Account) — der Fahrer muss neu wählen.
Future<void> clearSelection(String userId) async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyDate(userId));
await prefs.remove(_keyCarId(userId));
await prefs.remove(_keyCarPlate(userId));
}
Future<void> saveSelection(String userId, CarSelection selection) async {
final prefs = await SharedPreferences.getInstance();

View File

@ -66,6 +66,14 @@ class PhaseBloc extends Bloc<PhaseEvent, PhaseState> {
/// bedeutungslos.
String _token() => tourTokenResolver?.call() ?? 'no-tour';
/// `true`, wenn aktuell eine Tour geladen ist (der Resolver liefert einen
/// Token). Ohne Resolver (z. B. in Tests) gilt „Tour vorhanden", damit
/// nichts unnötig gesperrt wird.
bool _hasTour() {
final resolve = tourTokenResolver;
return resolve == null || resolve() != null;
}
PhaseReady _ensureReady() {
final current = state;
return current is PhaseReady
@ -95,14 +103,40 @@ class PhaseBloc extends Bloc<PhaseEvent, PhaseState> {
return;
}
// Tour-Wechsel bei bereits geladenem Auto: den alten In-Memory-Stand
// SOFORT synchron auf die Eintrittsphase zurücksetzen (nur „Sortieren"
// aktiv, keine Häkchen), damit während des asynchronen Nachladens aus
// den SharedPreferences nichts von der vorherigen Tour sichtbar bleibt.
// Ein evtl. persistierter Stand der neuen Tour (Rückkehr zu einem alten
// Tag) folgt direkt danach per PhaseLoaded.
if (current.phaseByCar.containsKey(event.carId)) {
final entry = _entryPhase();
emit(current.withLoaded(event.carId, entry, entry));
}
// Keine Tour geladen (Tag ohne Tour): ausschließlich die Eintrittsphase,
// ohne Persistenz zu lesen oder zu schreiben. Der geteilte
// 'no-tour'-Eimer darf keinen Fortschritt tragen — sonst wären an jedem
// leeren Tag Beladen/Ausliefern anklickbar.
if (!_hasTour()) {
final entry = _entryPhase();
_loadedTokenByCar[event.carId] = token;
add(PhaseLoaded(carId: event.carId, phase: entry, maxPhase: entry));
return;
}
try {
final persisted = await phaseService.load(event.carId, token);
final persistedMax = await phaseService.loadMax(event.carId, token);
final phase = persisted ?? _entryPhase();
// Max ist mindestens die aktuelle Phase. Falls in der Persistenz ein
// höherer Wert steht (Rücksprung), den nehmen.
// Max ist mindestens die aktuelle Phase. Ein höheres persistiertes Max
// (Rücksprung am selben Tag) wird NUR übernommen, wenn für diese
// Tour-Version auch eine Phase persistiert ist. Eine frische
// Tour-Version (persisted == null) startet immer ohne ererbte Häkchen.
DeliveryPhase maxPhase = phase;
if (persistedMax != null && persistedMax.index > maxPhase.index) {
if (persisted != null &&
persistedMax != null &&
persistedMax.index > maxPhase.index) {
maxPhase = persistedMax;
}
@ -141,9 +175,17 @@ class PhaseBloc extends Bloc<PhaseEvent, PhaseState> {
}
Future<void> _set(PhaseSet event, Emitter<PhaseState> emit) async {
final hasTour = _hasTour();
// Ohne geladene Tour gibt es nichts zu beladen/auszuliefern: nur die
// Eintrittsphase ist erlaubt, jeder Vorwärts-Wechsel wird verworfen —
// egal, welcher Button oder Stepper-Tap ihn auslöst.
if (!hasTour && event.phase.index > _entryPhase().index) return;
final current = _ensureReady();
final next = current.withPhase(event.carId, event.phase);
emit(next);
// Ohne Tour nichts persistieren — der 'no-tour'-Eimer darf keinen
// Fortschritt tragen (s. _load).
if (!hasTour) return;
try {
final token = _token();
await phaseService.save(event.carId, token, event.phase);

View File

@ -7,6 +7,7 @@ import 'package:uuid/uuid.dart';
import 'package:hl_lieferservice/data/cache/attachment_cache.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery_credit.dart';
import 'package:hl_lieferservice/domain/entity/delivery_payment.dart';
import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
import 'package:hl_lieferservice/domain/entity/delivery_note.dart';
import 'package:hl_lieferservice/domain/entity/delivery_service_value.dart';
@ -50,6 +51,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
on<UploadDeliveryNoteImage>(_onUploadDeliveryNoteImage);
on<SetDeliveryCredit>(_onSetDeliveryCredit);
on<RemoveDeliveryCredit>(_onRemoveDeliveryCredit);
on<RecordDeliveryPayment>(_onRecordDeliveryPayment);
on<SetDeliveryServiceValue>(_onSetDeliveryServiceValue);
on<RemoveDeliveryServiceValue>(_onRemoveDeliveryServiceValue);
}
@ -70,6 +72,12 @@ class TourBloc extends Bloc<TourEvent, TourState> {
/// damit ein Pull-to-refresh denselben Tag neu lädt (nicht heute).
DateTime? _selectedDate;
/// Laufnummer der Tour-Anfragen. Loads/Refreshes laufen nebenläufig; wählt
/// der Fahrer schnell nacheinander zwei Tage, darf die langsamere Antwort
/// des ersten Tages die des zuletzt gewählten nicht überschreiben. Nur die
/// jüngste Anfrage schreibt ihr Ergebnis.
int _requestSeq = 0;
/// Räumt verwaiste Cache-Bilder weg, sobald frische Tour-Daten vorliegen.
/// Fire-and-forget — Cache-Pflege darf den Load nie verzögern oder
/// scheitern lassen.
@ -83,9 +91,11 @@ class TourBloc extends Bloc<TourEvent, TourState> {
// Nur bei explizit gesetztem Datum die Wahl aktualisieren; ein reiner
// Reload (LoadTour()) behält den aktuell angezeigten Tag.
if (event.hasDate) _selectedDate = event.date;
final seq = ++_requestSeq;
emit(const TourLoading());
try {
final details = await tourRepository.getMyTourDetails(date: _selectedDate);
if (seq != _requestSeq) return; // überholt von neuerer Anfrage
if (details == null) {
emit(const TourEmpty());
return;
@ -93,6 +103,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
emit(TourLoaded(details: details));
_pruneAttachmentCache(details);
} catch (e, st) {
if (seq != _requestSeq) return;
debugPrint('TourBloc.LoadTour fehlgeschlagen: $e\n$st');
final message = _messageOf(e, 'Tour konnte nicht geladen werden');
emit(TourLoadFailed(message: message));
@ -111,9 +122,11 @@ class TourBloc extends Bloc<TourEvent, TourState> {
return;
}
final seq = ++_requestSeq;
emit(current.copyWith(isRefreshing: true, refreshError: null));
try {
final details = await tourRepository.getMyTourDetails(date: _selectedDate);
if (seq != _requestSeq) return; // überholt von neuerer Anfrage
if (details == null) {
emit(const TourEmpty());
return;
@ -121,6 +134,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
emit(TourLoaded(details: details));
_pruneAttachmentCache(details);
} catch (e, st) {
if (seq != _requestSeq) return;
debugPrint('TourBloc.RefreshTour fehlgeschlagen: $e\n$st');
final message = _messageOf(e, 'Tour konnte nicht neu geladen werden');
// alten Stand sichtbar lassen, Fehler oben mitführen
@ -613,6 +627,45 @@ class TourBloc extends Bloc<TourEvent, TourState> {
}
}
// ─── Zahlungsabwicklung ──────────────────────────────────────────────
/// Protokolliert die Zahlung. Bewusst NICHT optimistisch: Der Server
/// berechnet den Betrag autoritativ und kann ablehnen (z. B. Betrag hat
/// sich zwischenzeitlich geändert). Erst die Server-Antwort gibt den
/// Zahlungs-Step frei.
Future<void> _onRecordDeliveryPayment(
RecordDeliveryPayment event,
Emitter<TourState> emit,
) async {
if (state is! TourLoaded) return;
opBloc.add(StartOperation(message: 'Zahlung wird protokolliert …'));
try {
final payment = await tourRepository.recordDeliveryPayment(
deliveryId: event.deliveryId,
clientEventId: _uuid.v4(),
paymentMethodId: event.paymentMethodId,
expectedAmountCents: event.expectedAmountCents,
actorCarId: event.actorCarId,
);
final latest = state;
if (latest is TourLoaded) {
final next = Map<String, DeliveryPayment>.from(
latest.details.paymentsByDeliveryId,
)..[event.deliveryId] = payment;
emit(latest.copyWith(
details: latest.details.copyWith(paymentsByDeliveryId: next),
));
}
opBloc.add(FinishOperation(message: 'Zahlung protokolliert'));
} catch (e, st) {
debugPrint('TourBloc.RecordDeliveryPayment fehlgeschlagen: $e\n$st');
opBloc.add(FailOperation(
message: _messageOf(e, 'Zahlung konnte nicht protokolliert werden'),
));
}
}
// ─── Betrags-Gutschrift ──────────────────────────────────────────────
/// Tauscht die Gutschrift einer Lieferung im Aggregat aus (`null` = entfernt).
@ -1061,6 +1114,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
paymentMethodId: event.paymentMethodId,
actorCarId: event.actorCarId,
paymentCollected: event.paymentCollected,
internalNote: event.internalNote,
),
);
}

View File

@ -225,6 +225,7 @@ class CompleteDelivery extends TourEvent {
this.paymentMethodId,
this.actorCarId,
this.paymentCollected = false,
this.internalNote,
});
final String deliveryId;
@ -242,6 +243,10 @@ class CompleteDelivery extends TourEvent {
/// Fahrer hat das Vor-Ort-Inkasso (Bar/EC) des offenen Betrags bestätigt.
/// `false`, wenn kein Inkasso anfiel (offen == 0 oder „Auf Rechnung").
final bool paymentCollected;
/// Optionale interne Notiz des Fahrers (nach den Unterschriften erfasst,
/// nur für den Lieferbericht). `null` = keine.
final String? internalNote;
}
/// Legt eine neue (Text- oder Bild-)Notiz an einer Lieferung an. Aktuell
@ -274,6 +279,22 @@ class RemoveDeliveryCredit extends TourEvent {
final String actorCarId;
}
/// Protokolliert die Zahlungsabwicklung („Abkassieren") einer Lieferung.
/// [expectedAmountCents] ist der offene Betrag, den der Fahrer bestätigt hat.
class RecordDeliveryPayment extends TourEvent {
const RecordDeliveryPayment({
required this.deliveryId,
required this.paymentMethodId,
required this.expectedAmountCents,
required this.actorCarId,
});
final String deliveryId;
final String paymentMethodId;
final int expectedAmountCents;
final String? actorCarId;
}
/// Setzt/ändert den Wert eines Service für eine Lieferung (Checkbox/Zahl).
class SetDeliveryServiceValue extends TourEvent {
const SetDeliveryServiceValue({

View File

@ -39,8 +39,5 @@ class DeliveryWorkflowBloc
final next = [...state.pendingImageNotes]..removeAt(e.index);
emit(state.copyWith(pendingImageNotes: next));
});
on<WorkflowOverridePaymentMethod>((e, emit) => emit(
state.copyWith(paymentMethodOverrideId: e.paymentMethodId),
));
}
}

View File

@ -34,10 +34,3 @@ class WorkflowRemovePendingImage extends DeliveryWorkflowEvent {
const WorkflowRemovePendingImage(this.index);
final int index;
}
// ─── Payment-Auswahl ────────────────────────────────────────────────────
class WorkflowOverridePaymentMethod extends DeliveryWorkflowEvent {
const WorkflowOverridePaymentMethod({required this.paymentMethodId});
final String? paymentMethodId;
}

View File

@ -1,11 +1,12 @@
import 'package:image_picker/image_picker.dart';
/// Die 5 Steps der Auslieferungs-Detail-Page. Reihenfolge ≙ Index.
/// Die 6 Steps der Auslieferungs-Detail-Page. Reihenfolge ≙ Index.
enum WorkflowStep {
info,
notes,
articles,
services,
payment,
summary,
}
@ -15,6 +16,7 @@ extension WorkflowStepX on WorkflowStep {
WorkflowStep.notes => 'Notizen',
WorkflowStep.articles => 'Artikel & Gutschriften',
WorkflowStep.services => 'Checkliste',
WorkflowStep.payment => 'Zahlung',
WorkflowStep.summary => 'Übersicht',
};
@ -24,6 +26,7 @@ extension WorkflowStepX on WorkflowStep {
WorkflowStep.notes => 'Notizen',
WorkflowStep.articles => 'Artikel',
WorkflowStep.services => 'Checkliste',
WorkflowStep.payment => 'Zahlung',
WorkflowStep.summary => 'Übersicht',
};
}
@ -38,8 +41,9 @@ class PendingImageNote {
final DateTime pickedAt;
}
/// State des Detail-Workflows. Ein State, ein Bloc — der Step-Wechsel,
/// die Drafts und die Payment-Auswahl liegen alle hier. So sieht jede
/// State des Detail-Workflows. Ein State, ein Bloc — der Step-Wechsel und
/// die Drafts liegen hier. Die Zahlung ist KEIN lokaler Draft mehr: Sie wird
/// im Step „Zahlung" am Server protokolliert und kommt über die Tour zurück. So sieht jede
/// Step-Page denselben kohärenten Zustand und ein Step kann Daten aus
/// einem anderen lesen (z. B. Summary liest Article-Drafts).
class DeliveryWorkflowState {
@ -47,7 +51,6 @@ class DeliveryWorkflowState {
required this.deliveryId,
required this.step,
required this.pendingImageNotes,
required this.paymentMethodOverrideId,
});
factory DeliveryWorkflowState.initial(String deliveryId) =>
@ -55,7 +58,6 @@ class DeliveryWorkflowState {
deliveryId: deliveryId,
step: WorkflowStep.info,
pendingImageNotes: const [],
paymentMethodOverrideId: null,
);
final String deliveryId;
@ -64,24 +66,14 @@ class DeliveryWorkflowState {
/// Lokal gehaltene Bild-Notizen — solange kein Upload-Endpoint da ist.
final List<PendingImageNote> pendingImageNotes;
/// Wenn der Fahrer im Summary die Zahlungsmethode überschreibt, landet
/// die neue Id hier. `null` = Methode der Lieferung bleibt.
final String? paymentMethodOverrideId;
DeliveryWorkflowState copyWith({
WorkflowStep? step,
List<PendingImageNote>? pendingImageNotes,
Object? paymentMethodOverrideId = _sentinel,
}) {
return DeliveryWorkflowState(
deliveryId: deliveryId,
step: step ?? this.step,
pendingImageNotes: pendingImageNotes ?? this.pendingImageNotes,
paymentMethodOverrideId: identical(paymentMethodOverrideId, _sentinel)
? this.paymentMethodOverrideId
: paymentMethodOverrideId as String?,
);
}
}
const Object _sentinel = Object();

View File

@ -0,0 +1,80 @@
import 'package:intl/intl.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery_payment.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
/// Zahlungsstand einer Lieferung — einzige Quelle für offenen Betrag und
/// „ist die Zahlung erledigt?". Genutzt von Zahlungs-Step, Übersicht und
/// Abschluss, damit alle exakt dieselben Zahlen sehen.
///
/// Offener Betrag = Warenwert (Σ Stückpreis × ausgelieferte Menge) −
/// Anzahlung − Gutschrift, nie negativ. Identische Formel und Rundung wie
/// im Backend (`open_amount_cents`), das den Betrag beim Protokollieren
/// gegenprüft.
class DeliveryPaymentStatus {
const DeliveryPaymentStatus._({
required this.goodsValue,
required this.prepaidAmount,
required this.creditAmount,
required this.openCents,
required this.latestPayment,
});
factory DeliveryPaymentStatus.of(Delivery delivery, TourDetails details) {
final goods = delivery.items.fold<double>(
0,
(acc, item) => acc + item.lineTotal,
);
final credit = (details.creditOf(delivery.id)?.amountCents ?? 0) / 100.0;
final open = goods - delivery.prepaidAmount - credit;
return DeliveryPaymentStatus._(
goodsValue: goods,
prepaidAmount: delivery.prepaidAmount,
creditAmount: credit,
openCents: open <= 0 ? 0 : (open * 100).round(),
latestPayment: details.paymentOf(delivery.id),
);
}
/// Warenwert in Euro.
final double goodsValue;
/// Bei Bestellung bezahlt (Anzahlung) in Euro.
final double prepaidAmount;
/// Aktuelle Geld-Gutschrift in Euro (0 = keine).
final double creditAmount;
/// Offener Betrag in Cent.
final int openCents;
/// Jüngste protokollierte Zahlung, egal ob noch gültig.
final DeliveryPayment? latestPayment;
double get openAmount => openCents / 100.0;
bool get hasOpenAmount => openCents > 0;
/// Die jüngste Zahlung, sofern sie zum aktuellen offenen Betrag passt.
DeliveryPayment? get validPayment {
final p = latestPayment;
if (p == null || !hasOpenAmount || p.amountCents != openCents) return null;
return p;
}
/// Es wurde abgewickelt, aber der offene Betrag hat sich seitdem geändert.
bool get isStale =>
latestPayment != null && hasOpenAmount && validPayment == null;
/// Zahlungs-Step erledigt: nichts offen oder gültig abgewickelt.
bool get isSettled => !hasOpenAmount || validPayment != null;
}
final NumberFormat _euro = NumberFormat.currency(locale: 'de_DE', symbol: '€');
/// Betrag in Euro im deutschen Format, z. B. `1.234,50 €`.
String formatEuro(double amount) => _euro.format(amount);
/// Cent-Betrag im deutschen Format, z. B. `1.234,50 €`.
String formatEuroCents(int cents) => _euro.format(cents / 100.0);

View File

@ -2,19 +2,19 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/payment_method.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_sign.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_event.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_state.dart';
import 'package:hl_lieferservice/feature/delivery/detail/model/delivery_payment_status.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_articles.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_info.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_notes.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_payment.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_services.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_summary.dart';
@ -32,10 +32,10 @@ import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step
// entgegennimmt und den Key zurückgibt, dann hier
// `tourBloc.add(AddDeliveryNote(imageAttachment: key))` aufrufen.
//
// * ~~B4 Zahlungsmethode beim Abschluss ändern~~ — ERLEDIGT: Die im
// Summary gewählte Methode (`paymentMethodOverrideId` im Workflow-State)
// reist beim Abschluss am `/complete`-Endpoint mit und wird atomar auf
// der Lieferung persistiert (Server prüft existiert + aktiv).
// * ~~B4 Zahlungsmethode beim Abschluss ändern~~ — ERLEDIGT und abgelöst:
// Die Zahlung wird im eigenen Step „Zahlung" abgewickelt und am Server
// protokolliert (`POST /deliveries/{id}/payment`). Beim Abschluss reist
// die Methode der gültigen Zahlung mit.
//
// * **B5 Unterschrift**: Signature-Pad-Bilder (Kunde + Fahrer)
// hochladen + auf der Lieferung speichern. Backend hat dafür weder
@ -51,7 +51,7 @@ import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step
// SnackBar-Stub.
// ─────────────────────────────────────────────────────────────────────────
/// Multi-Step Detail-Page einer einzelnen Lieferung. Hülle für 5 Steps;
/// Multi-Step Detail-Page einer einzelnen Lieferung. Hülle für 6 Steps;
/// jeder Step bekommt die aktuelle `Delivery` + `TourDetails` als Props,
/// damit die Steps keine eigenen Bloc-Subscriptions auf die Tour brauchen.
///
@ -95,7 +95,8 @@ class _DeliveryDetailScaffoldState extends State<_DeliveryDetailScaffold> {
void initState() {
super.initState();
final s = context.read<TourBloc>().state;
if (s is TourLoaded && _findDelivery(s.details)?.state == DeliveryState.active) {
if (s is TourLoaded &&
_findDelivery(s.details)?.state == DeliveryState.active) {
_armed = true;
}
}
@ -144,16 +145,17 @@ class _DeliveryDetailScaffoldState extends State<_DeliveryDetailScaffold> {
),
);
}
final customer = details.customerOf(delivery);
// Titel = Empfänger vor Ort, wie in den Listen-Kacheln.
final recipient = details.recipientOf(delivery);
return Scaffold(
appBar: AppBar(
backgroundColor: theme.primaryColor,
foregroundColor: theme.colorScheme.onPrimary,
title: Text(customer?.name ?? 'Lieferung'),
title: Text(recipient.name),
),
body: Column(
children: [
const _StepHeader(),
_StepHeader(delivery: delivery, details: details),
const Divider(height: 1),
Expanded(
child: _StepBody(delivery: delivery, details: details),
@ -171,8 +173,23 @@ class _DeliveryDetailScaffoldState extends State<_DeliveryDetailScaffold> {
// ─── Step-Header (Pills) ────────────────────────────────────────────────
/// Steps hinter „Zahlung" sind gesperrt, solange bei einer aktiven
/// Lieferung ein offener Betrag noch nicht gültig abgewickelt ist.
bool _lockedByPayment(
WorkflowStep step,
Delivery delivery,
TourDetails details,
) {
if (step.index <= WorkflowStep.payment.index) return false;
if (delivery.state != DeliveryState.active) return false;
return !DeliveryPaymentStatus.of(delivery, details).isSettled;
}
class _StepHeader extends StatelessWidget {
const _StepHeader();
const _StepHeader({required this.delivery, required this.details});
final Delivery delivery;
final TourDetails details;
@override
Widget build(BuildContext context) {
@ -189,9 +206,16 @@ class _StepHeader extends StatelessWidget {
step: WorkflowStep.values[i],
isActive: state.step.index == i,
isPassed: state.step.index > i,
onTap: () => context
.read<DeliveryWorkflowBloc>()
.add(WorkflowGoToStep(WorkflowStep.values[i])),
enabled:
!_lockedByPayment(
WorkflowStep.values[i],
delivery,
details,
),
onTap:
() => context.read<DeliveryWorkflowBloc>().add(
WorkflowGoToStep(WorkflowStep.values[i]),
),
),
),
if (i < WorkflowStep.values.length - 1)
@ -211,6 +235,7 @@ class _StepPill extends StatelessWidget {
required this.step,
required this.isActive,
required this.isPassed,
required this.enabled,
required this.onTap,
});
@ -218,6 +243,9 @@ class _StepPill extends StatelessWidget {
final WorkflowStep step;
final bool isActive;
final bool isPassed;
/// `false` = Step gesperrt (z. B. Übersicht vor abgewickelter Zahlung).
final bool enabled;
final VoidCallback onTap;
@override
@ -248,8 +276,10 @@ class _StepPill extends StatelessWidget {
}
return InkWell(
onTap: onTap,
onTap: enabled ? onTap : null,
borderRadius: BorderRadius.circular(8),
child: Opacity(
opacity: enabled ? 1 : 0.4,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
@ -264,12 +294,14 @@ class _StepPill extends StatelessWidget {
decoration: BoxDecoration(
color: circleColor,
shape: BoxShape.circle,
border: isActive
border:
isActive
? Border.all(color: primary, width: 2)
: null,
),
child: Center(
child: isPassed && !isActive
child:
isPassed && !isActive
? Icon(Icons.check, color: circleFg, size: 16)
: Text(
'${index + 1}',
@ -297,6 +329,7 @@ class _StepPill extends StatelessWidget {
],
),
),
),
);
}
}
@ -313,7 +346,8 @@ class _StepConnector extends StatelessWidget {
child: Container(
width: 10,
height: 2,
color: isPassed
color:
isPassed
? theme.colorScheme.primary
: theme.colorScheme.surfaceContainerHighest,
),
@ -343,6 +377,8 @@ class _StepBody extends StatelessWidget {
return StepArticles(delivery: delivery, details: details);
case WorkflowStep.services:
return StepServices(delivery: delivery, details: details);
case WorkflowStep.payment:
return StepPayment(delivery: delivery, details: details);
case WorkflowStep.summary:
return StepSummary(delivery: delivery, details: details);
}
@ -365,59 +401,31 @@ class _BottomNav extends StatelessWidget {
/// dann den `completed`-Status zeigt.
void _onSign(BuildContext context) {
final tourBloc = context.read<TourBloc>();
// Die im Summary-Step gewählte Zahlungsmethode lebt im Workflow-State.
// Beim Abschluss reisen wir sie mit ans Backend (atomar mit der Signatur);
// `null` = die am Beleg hinterlegte Methode bleibt.
final paymentMethodOverrideId =
context.read<DeliveryWorkflowBloc>().state.paymentMethodOverrideId;
// Offener Betrag = Warenwert − Anzahlung − Gutschrift (≥ 0). EXAKT die
// Formel aus StepSummary und dem Backend-Inkasso-Gate.
final creditEuros =
(details.creditOf(delivery.id)?.amountCents ?? 0) / 100.0;
final warenwert =
delivery.items.fold<double>(0, (acc, item) => acc + item.lineTotal);
final open = (warenwert - delivery.prepaidAmount - creditEuros)
.clamp(0.0, double.infinity)
.toDouble();
// Effektive Methode (Override > Beleg) auflösen, um Vor-Ort-Inkasso
// (Bar/EC) von „Auf Rechnung" zu unterscheiden.
final effectiveMethodId =
paymentMethodOverrideId ?? delivery.paymentMethodId;
final pmState = context.read<PaymentMethodsCubit>().state;
PaymentMethod? method;
if (pmState is PaymentMethodsLoaded) {
for (final m in pmState.methods) {
if (m.id == effectiveMethodId) {
method = m;
break;
}
}
}
// Inkasso-Pflicht: offener Betrag > 0 UND Bar/EC. „Auf Rechnung" → nein.
final requiresCollection =
open > 0 && (method?.code == 'cash' || method?.code == 'ec_card');
// Kassiert wurde bereits im Step „Zahlung" (serverseitig protokolliert).
// Beim Abschluss reisen Methode und Inkasso-Flag der gültigen Zahlung
// mit; ohne offenen Betrag bleibt die Methode vom Beleg.
final payment = DeliveryPaymentStatus.of(delivery, details).validPayment;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (routeContext) => SignatureView(
builder:
(routeContext) => SignatureView(
delivery: delivery,
details: details,
requiresCollection: requiresCollection,
openAmount: open,
paymentMethodLabel: method?.name ?? '',
onSigned: (result) {
tourBloc.add(CompleteDelivery(
tourBloc.add(
CompleteDelivery(
deliveryId: delivery.id,
customerSignaturePng: result.customerSignaturePng,
driverSignaturePng: result.driverSignaturePng,
receiptConfirmed: result.receiptConfirmed,
notesAcknowledged: result.notesAcknowledged,
acknowledgedNoteIds: result.acknowledgedNoteIds,
paymentMethodId: paymentMethodOverrideId,
paymentCollected: result.paymentCollected,
));
paymentMethodId: payment?.paymentMethodId,
paymentCollected: payment?.isCollectedOnSite ?? false,
internalNote: result.internalNote,
),
);
Navigator.of(routeContext).pop();
},
),
@ -438,11 +446,12 @@ class _BottomNav extends StatelessWidget {
child: Row(
children: [
OutlinedButton.icon(
onPressed: isFirst
onPressed:
isFirst
? null
: () => context
.read<DeliveryWorkflowBloc>()
.add(const WorkflowPreviousStep()),
: () => context.read<DeliveryWorkflowBloc>().add(
const WorkflowPreviousStep(),
),
icon: const Icon(Icons.arrow_back),
label: const Text('Zurück'),
),
@ -451,26 +460,37 @@ class _BottomNav extends StatelessWidget {
// Unterschreiben/Abschließen nur bei aktiver Lieferung.
// Ist sie bereits abgeschlossen (oder pausiert/abgebrochen),
// bleibt der Button gesperrt.
Builder(builder: (context) {
final isActive =
delivery.state == DeliveryState.active;
Builder(
builder: (context) {
final isActive = delivery.state == DeliveryState.active;
final isCompleted =
delivery.state == DeliveryState.completed;
return FilledButton.icon(
onPressed: isActive ? () => _onSign(context) : null,
icon: Icon(isCompleted
icon: Icon(
isCompleted
? Icons.check_circle_outline
: Icons.draw_outlined),
: Icons.draw_outlined,
),
label: Text(
isCompleted ? 'Abgeschlossen' : 'Unterschreiben',
),
);
})
},
)
else
FilledButton.icon(
onPressed: () => context
.read<DeliveryWorkflowBloc>()
.add(const WorkflowNextStep()),
// Aus „Zahlung" erst weiter, wenn abgewickelt/nichts offen.
onPressed:
_lockedByPayment(
WorkflowStep.values[state.step.index + 1],
delivery,
details,
)
? null
: () => context.read<DeliveryWorkflowBloc>().add(
const WorkflowNextStep(),
),
icon: const Icon(Icons.arrow_forward),
label: const Text('Weiter'),
),

View File

@ -7,6 +7,7 @@ import 'package:signature/signature.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery_note.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/internal_note_sheet.dart';
/// Daten, die der Abschluss-Flow an den Aufrufer zurückgibt: beide
/// Unterschriften als PNG plus die dokumentierten Bestätigungen des Kunden.
@ -17,7 +18,7 @@ class SignatureResult {
required this.receiptConfirmed,
required this.notesAcknowledged,
required this.acknowledgedNoteIds,
required this.paymentCollected,
this.internalNote,
});
final Uint8List customerSignaturePng;
@ -26,21 +27,21 @@ class SignatureResult {
final bool notesAcknowledged;
final List<String> acknowledgedNoteIds;
/// Fahrer hat das Inkasso (Bar/EC) des offenen Betrags bestätigt. `false`,
/// wenn kein Inkasso anfiel (offen == 0 oder „Auf Rechnung").
final bool paymentCollected;
/// Optionale interne Notiz des Fahrers, abgefragt nach beiden
/// Unterschriften. `null` = keine.
final String? internalNote;
}
/// Mehrstufiger Unterschrift-Flow zum Abschließen einer Lieferung.
///
/// Stufe 0 (Fahrer, optional): nur wenn beim Abschluss ein offener Betrag
/// per Vor-Ort-Inkasso (Bar/EC) zu kassieren ist ([requiresCollection]).
/// Der Fahrer bestätigt, dass der Betrag erhalten/abgerechnet wurde — VOR
/// beiden Unterschriften.
/// Das Kassieren passiert vorher im eigenen Workflow-Step „Zahlung" und
/// wird dort protokolliert — dieser Flow enthält nur noch die Unterschriften.
///
/// Stufe 1 (Kunde): sieht die Anmerkungen zur Lieferung, hakt zwei
/// Bestätigungen ab (Anmerkungen-Kenntnisnahme — nur Pflicht, wenn Notizen
/// vorhanden; Empfangsbestätigung — immer Pflicht) und unterschreibt.
/// Stufe 2 (Fahrer): unterschreibt.
/// Stufe 2 (Fahrer): unterschreibt. Danach fragt ein Blatt optional eine
/// interne Notiz ab (nur für den Lieferbericht, nicht für den Kunden).
///
/// Erst nach beiden Unterschriften ruft die View [onSigned] mit dem
/// vollständigen [SignatureResult] auf — der Aufrufer triggert dann den
@ -51,31 +52,18 @@ class SignatureView extends StatefulWidget {
required this.delivery,
required this.details,
required this.onSigned,
this.requiresCollection = false,
this.openAmount = 0,
this.paymentMethodLabel = '',
});
final Delivery delivery;
final TourDetails details;
final void Function(SignatureResult result) onSigned;
/// Offener Betrag muss vor Ort kassiert werden (offen > 0 UND Bar/EC).
/// Schaltet Stufe 0 (Inkasso-Bestätigung) frei.
final bool requiresCollection;
/// Offener Betrag in Euro (nur für die Anzeige in Stufe 0).
final double openAmount;
/// Anzeigename der Zahlungsmethode (z. B. „Bar", „EC-Karte").
final String paymentMethodLabel;
@override
State<SignatureView> createState() => _SignatureViewState();
}
/// Stufen des Abschluss-Flows.
enum _SignStage { payment, customer, driver }
enum _SignStage { customer, driver }
class _SignatureViewState extends State<SignatureView> {
static const String _receiptText =
@ -97,8 +85,7 @@ class _SignatureViewState extends State<SignatureView> {
);
late final List<DeliveryNote> _notes;
late _SignStage _stage;
bool _paymentConfirmed = false;
_SignStage _stage = _SignStage.customer;
bool _receiptAccepted = false;
bool _notesAccepted = false;
bool _customerEmpty = true;
@ -109,10 +96,8 @@ class _SignatureViewState extends State<SignatureView> {
@override
void initState() {
super.initState();
// Inkasso-Bestätigung (Stufe 0) nur wenn gefordert, sonst direkt zum Kunden.
_stage =
widget.requiresCollection ? _SignStage.payment : _SignStage.customer;
_notes = widget.details.notesByDeliveryId[widget.delivery.id] ??
_notes =
widget.details.notesByDeliveryId[widget.delivery.id] ??
const <DeliveryNote>[];
_customerController.addListener(() {
if (_customerEmpty != _customerController.isEmpty) {
@ -139,8 +124,6 @@ class _SignatureViewState extends State<SignatureView> {
/// Ist der Primär-Button auf der aktuellen Stufe aktiv?
bool get _stageValid {
switch (_stage) {
case _SignStage.payment:
return _paymentConfirmed;
case _SignStage.customer:
return _customerStepValid;
case _SignStage.driver:
@ -150,9 +133,6 @@ class _SignatureViewState extends State<SignatureView> {
Future<void> _onPrimaryPressed() async {
switch (_stage) {
case _SignStage.payment:
setState(() => _stage = _SignStage.customer);
return;
case _SignStage.customer:
setState(() => _stage = _SignStage.driver);
return;
@ -160,6 +140,11 @@ class _SignatureViewState extends State<SignatureView> {
final customerPng = await _customerController.toPngBytes();
final driverPng = await _driverController.toPngBytes();
if (customerPng == null || driverPng == null) return;
if (!mounted) return;
// Nach beiden Unterschriften: optional interne Notiz. „Zurück" im
// Blatt bricht nur das Abschließen ab, die Unterschriften bleiben.
final decision = await showInternalNoteSheet(context);
if (decision == null || !mounted) return;
widget.onSigned(
SignatureResult(
customerSignaturePng: customerPng,
@ -168,33 +153,23 @@ class _SignatureViewState extends State<SignatureView> {
notesAcknowledged: _notesEmpty ? false : _notesAccepted,
acknowledgedNoteIds:
_notesEmpty ? const [] : _notes.map((n) => n.id).toList(),
paymentCollected: widget.requiresCollection && _paymentConfirmed,
internalNote: decision.note,
),
);
}
}
String get _paymentText {
final amount = widget.openAmount.toStringAsFixed(2).replaceAll('.', ',');
final via = widget.paymentMethodLabel.isEmpty
? ''
: ' per ${widget.paymentMethodLabel}';
return 'Ich bestätige, dass der offene Betrag von $amount €$via '
'erhalten bzw. abgerechnet wurde.';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final customer = widget.details.customerOf(widget.delivery);
// Unterschrieben wird vor Ort — Name und Ort also vom Empfänger, der
// vom Besteller abweichen kann.
final recipient = widget.details.recipientOf(widget.delivery);
final date = DateFormat('dd.MM.yyyy').format(DateTime.now());
final isPayment = _stage == _SignStage.payment;
final isDriver = _stage == _SignStage.driver;
final String title;
switch (_stage) {
case _SignStage.payment:
title = 'Zahlung bestätigen';
case _SignStage.customer:
title = 'Unterschrift des Kunden';
case _SignStage.driver:
@ -207,21 +182,6 @@ class _SignatureViewState extends State<SignatureView> {
child: ListView(
padding: const EdgeInsets.all(16),
children: [
if (isPayment) ...[
// Stufe 0 — Fahrer kassiert (Bar/EC) und bestätigt VOR den
// Unterschriften.
_PaymentDueCard(
openAmount: widget.openAmount,
methodLabel: widget.paymentMethodLabel,
),
const SizedBox(height: 16),
_ConfirmTile(
value: _paymentConfirmed,
enabled: true,
label: _paymentText,
onChanged: (v) => setState(() => _paymentConfirmed = v),
),
] else ...[
if (!isDriver) ...[
_NotesSection(notes: _notes),
const SizedBox(height: 16),
@ -240,23 +200,24 @@ class _SignatureViewState extends State<SignatureView> {
const SizedBox(height: 16),
],
Text(
'Lieferung an: ${customer?.name ?? '⟨Unbekannter Kunde⟩'}',
style: theme.textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w700),
'Lieferung an: ${recipient.name}',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
_SignaturePad(
controller: isDriver ? _driverController : _customerController,
onClear: () =>
onClear:
() =>
(isDriver ? _driverController : _customerController)
.clear(),
),
const SizedBox(height: 4),
Text(
'${customer?.address.city ?? ''}, den $date',
'${recipient.address.city}, den $date',
style: theme.textTheme.bodySmall,
),
],
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _stageValid ? _onPrimaryPressed : null,
@ -270,66 +231,6 @@ class _SignatureViewState extends State<SignatureView> {
}
}
// ─── Inkasso-Hinweis (Stufe 0) ──────────────────────────────────────────────
class _PaymentDueCard extends StatelessWidget {
const _PaymentDueCard({required this.openAmount, required this.methodLabel});
final double openAmount;
final String methodLabel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final amount = openAmount.toStringAsFixed(2).replaceAll('.', ',');
return Card(
margin: EdgeInsets.zero,
color: theme.colorScheme.primary.withValues(alpha: 0.07),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Offener Betrag',
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Row(
children: [
Icon(Icons.payments_outlined, color: theme.colorScheme.primary),
const SizedBox(width: 10),
Text(
'$amount €',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
color: theme.colorScheme.primary,
),
),
const Spacer(),
if (methodLabel.isNotEmpty)
Chip(
label: Text(methodLabel),
visualDensity: VisualDensity.compact,
),
],
),
const SizedBox(height: 8),
Text(
'Bitte den Betrag bar entgegennehmen oder über das EC-Gerät '
'abrechnen und anschließend bestätigen.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
}
// ─── Notizen-Block ────────────────────────────────────────────────────────
class _NotesSection extends StatelessWidget {
@ -345,8 +246,9 @@ class _NotesSection extends StatelessWidget {
children: [
Text(
'Anmerkungen zur Lieferung',
style:
theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
if (notes.isEmpty)

View File

@ -12,6 +12,8 @@ import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/discount_editor.dart';
import 'package:hl_lieferservice/feature/loading/widget/reason_catalog.dart';
import 'package:hl_lieferservice/feature/loading/widget/reason_picker_sheet.dart';
import 'package:hl_lieferservice/feature/feature_flags/feature.dart';
import 'package:hl_lieferservice/feature/feature_flags/feature_gate.dart';
/// Step 3 — Artikel & Gutschriften.
///
@ -99,6 +101,12 @@ class StepArticles extends StatelessWidget {
],
),
),
// Betrags-Gutschrift, per Feature-Flag komplett ausblendbar.
FeatureGate(
feature: Feature.articlesCreditSection,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 24),
_SectionHeader(text: 'Gutschriften'),
const SizedBox(height: 8),
@ -113,6 +121,9 @@ class StepArticles extends StatelessWidget {
),
),
],
),
),
],
);
}
}

View File

@ -2,16 +2,19 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:hl_lieferservice/domain/entity/address.dart';
import 'package:hl_lieferservice/domain/entity/contact_source.dart';
import 'package:hl_lieferservice/domain/entity/customer.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
import 'package:hl_lieferservice/domain/entity/delivery_recipient.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_event.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_state.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/reason_dialog.dart';
/// Step 1 — Informationen zur Lieferung.
///
@ -34,10 +37,26 @@ class StepInfo extends StatelessWidget {
// damit derselbe Datensatz nicht zweimal (z. B. „Belegadresse" UND
// „Kundenstamm") aufpoppt.
final mergedSources = details.mergedContactSourcesOf(delivery);
final recipient = details.recipientOf(delivery);
final differs = recipient.differsFromOrderer;
final recipientContact = recipient.contact;
final ordererSource =
details.contactSourceWithRole(delivery, ContactRole.header);
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
children: [
// Empfänger ≠ Besteller: ganz oben, damit es vor dem Klingeln
// auffällt. Die Besteller-Daten folgen unten im Kunden-Block.
if (differs) ...[
_DifferentRecipientCard(
recipient: recipient,
channels: recipientContact == null
? const []
: details.channelsOf(recipientContact),
),
const SizedBox(height: 24),
],
_SectionHeader(text: 'Beleg'),
const SizedBox(height: 8),
_BelegCard(delivery: delivery),
@ -46,7 +65,7 @@ class StepInfo extends StatelessWidget {
const SizedBox(height: 8),
_QuickActions(
delivery: delivery,
customer: customer,
recipient: recipient,
mergedSources: mergedSources,
),
const SizedBox(height: 24),
@ -54,9 +73,19 @@ class StepInfo extends StatelessWidget {
const SizedBox(height: 8),
_AgreementsCard(delivery: delivery),
const SizedBox(height: 24),
_SectionHeader(text: 'Kundeninformationen'),
_SectionHeader(
text: differs ? 'Besteller (Kunde)' : 'Kundeninformationen',
),
const SizedBox(height: 8),
_CustomerCard(customer: customer, contacts: contacts),
_CustomerCard(
customer: customer,
contacts: contacts,
// Nur bei abweichendem Empfänger explizit: Kontaktdaten des
// Bestellers, damit der Fahrer ihn bei Rückfragen erreicht.
ordererChannels: differs && ordererSource != null
? details.channelsOf(ordererSource)
: null,
),
if (mergedSources.isNotEmpty) ...[
const SizedBox(height: 24),
_SectionHeader(text: 'Alle Kontaktinfos'),
@ -88,6 +117,135 @@ class _SectionHeader extends StatelessWidget {
}
}
// ─── Adresse ────────────────────────────────────────────────────────────
/// Zeilen unter „PLZ Ort": Land (nur Ausland) und Adresszusatz aus dem ERP
/// (Ortsteil, Etage, Hinweis — für den Fahrer vor Ort wichtig).
List<Widget> _addressExtras(Address address, {required double fontSize}) {
final addition = address.addition?.trim();
return [
if (address.foreignCountry case final country?)
Text(country, style: TextStyle(fontSize: fontSize)),
if (addition != null && addition.isNotEmpty)
Text(
'Zusatz: $addition',
style: TextStyle(fontSize: fontSize, fontWeight: FontWeight.w700),
),
];
}
// ─── Abweichender Empfänger ─────────────────────────────────────────────
/// Hervorgehobene Karte, wenn Lieferadresse oder Empfänger vom Besteller
/// abweichen: wer die Ware vor Ort annimmt, wo, und wie er erreichbar ist.
class _DifferentRecipientCard extends StatelessWidget {
const _DifferentRecipientCard({
required this.recipient,
required this.channels,
});
final DeliveryRecipient recipient;
final List<ContactChannel> channels;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final accent = Colors.deepOrange.shade800;
final address = recipient.address;
return Card(
margin: EdgeInsets.zero,
elevation: 0,
color: Colors.deepOrange.withValues(alpha: 0.08),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Colors.deepOrange.withValues(alpha: 0.6),
width: 1.5,
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.alt_route, color: accent),
const SizedBox(width: 10),
Expanded(
child: Text(
'Abweichende Lieferadresse',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
color: accent,
),
),
),
],
),
const SizedBox(height: 6),
Text(
'Die Ware geht nicht an den Besteller. Bitte an folgende '
'Person bzw. Adresse liefern:',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 12),
Text(
'Empfänger vor Ort',
style: theme.textTheme.labelLarge?.copyWith(
color: accent,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
recipient.name,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.location_on_outlined, color: accent),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${address.street} ${address.houseNumber}',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
Text(
'${address.postalCode} ${address.city}',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
..._addressExtras(address, fontSize: 15),
],
),
),
],
),
if (channels.isNotEmpty) ...[
const SizedBox(height: 8),
for (final ch in channels)
_ChannelRow(channel: ch, onLaunch: _launchContact),
],
],
),
),
);
}
}
// ─── Beleg ──────────────────────────────────────────────────────────────
class _BelegCard extends StatelessWidget {
@ -124,12 +282,12 @@ enum _StatusAction { hold, cancel, resume }
class _QuickActions extends StatelessWidget {
const _QuickActions({
required this.delivery,
required this.customer,
required this.recipient,
required this.mergedSources,
});
final Delivery delivery;
final Customer? customer;
final DeliveryRecipient recipient;
final List<MergedContactSource> mergedSources;
/// Alle aus den Beleg-Kontaktquellen anrufbaren Nummern, in
@ -171,11 +329,9 @@ class _QuickActions extends StatelessWidget {
}
Future<void> _launchMaps(BuildContext context) async {
final address = customer != null
? '${customer!.address.street} ${customer!.address.houseNumber}, '
'${customer!.address.postalCode} ${customer!.address.city}'
: delivery.deliveryAddressSnapshot.oneLine;
final encoded = Uri.encodeComponent(address);
// Navigiert wird zur Lieferadresse — nicht zur Anschrift des
// Bestellers, die abweichen kann.
final encoded = Uri.encodeComponent(recipient.address.oneLine);
// Universelles `geo:?q=…`-Schema funktioniert auf Android + iOS.
final uri = Uri.parse(
'https://www.google.com/maps/search/?api=1&query=$encoded',
@ -245,39 +401,6 @@ class _QuickActions extends StatelessWidget {
await _call(context, picked.value);
}
Future<String?> _askReason(BuildContext context, String title) async {
final controller = TextEditingController();
final result = await showDialog<String>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(title),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Grund',
border: OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(null),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () {
final text = controller.text.trim();
if (text.isEmpty) return;
Navigator.of(dialogContext).pop(text);
},
child: const Text('Bestätigen'),
),
],
),
);
return result;
}
Future<void> _onStatusSelected(
BuildContext context,
_StatusAction action,
@ -285,11 +408,11 @@ class _QuickActions extends StatelessWidget {
final tourBloc = context.read<TourBloc>();
switch (action) {
case _StatusAction.hold:
final reason = await _askReason(context, 'Lieferung pausieren');
final reason = await showReasonDialog(context, 'Lieferung pausieren');
if (reason == null) return;
tourBloc.add(HoldDelivery(deliveryId: delivery.id, reason: reason));
case _StatusAction.cancel:
final reason = await _askReason(context, 'Lieferung abbrechen');
final reason = await showReasonDialog(context, 'Lieferung abbrechen');
if (reason == null) return;
tourBloc.add(CancelDelivery(deliveryId: delivery.id, reason: reason));
case _StatusAction.resume:
@ -527,11 +650,19 @@ class _AgreementsCard extends StatelessWidget {
// ─── Kunde + Kontakte ───────────────────────────────────────────────────
class _CustomerCard extends StatelessWidget {
const _CustomerCard({required this.customer, required this.contacts});
const _CustomerCard({
required this.customer,
required this.contacts,
this.ordererChannels,
});
final Customer? customer;
final List<CustomerContact> contacts;
/// Gesetzt, wenn der Kunde nur Besteller ist (Empfänger weicht ab): dann
/// zeigt die Karte Kundennummer und alle Kontaktkanäle des Bestellers.
final List<ContactChannel>? ordererChannels;
Future<void> _call(BuildContext context, String phone) async {
final ok = await launchUrl(Uri(scheme: 'tel', path: phone));
if (!ok && context.mounted) {
@ -589,12 +720,51 @@ class _CustomerCard extends StatelessWidget {
'${c.address.postalCode} ${c.address.city}',
style: const TextStyle(fontSize: 14),
),
..._addressExtras(c.address, fontSize: 14),
],
),
),
],
),
],
if (ordererChannels case final channels?) ...[
if (c != null) ...[
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.badge_outlined,
color: theme.colorScheme.primary,
),
const SizedBox(width: 10),
Text(
'Kundennummer ${c.erpCustomerId}',
style: const TextStyle(fontSize: 14),
),
],
),
],
const Divider(height: 24),
Text(
'Kontaktdaten Besteller',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
if (channels.isEmpty)
Text(
'Keine Telefonnummer oder E-Mail hinterlegt.',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
)
else
for (final ch in channels)
_ChannelRow(channel: ch, onLaunch: _launchContact),
],
if (contacts.isNotEmpty) ...[
const Divider(height: 24),
Text(
@ -900,19 +1070,21 @@ class _ArticleRow extends StatelessWidget {
// Namensblock und allen hinterlegten Telefon-/Mobil-/E-Mail-/Web-Kanälen.
// Sortiert kommen sie vom Backend; das UI bildet sie 1:1 ab.
class _AllContactsCard extends StatelessWidget {
const _AllContactsCard({required this.sources});
final List<MergedContactSource> sources;
Future<void> _launch(BuildContext context, Uri uri, String label) async {
/// Öffnet einen Kontaktkanal (tel:/mailto:/https) — geteilt von allen
/// Karten, die [_ChannelRow]s zeigen.
Future<void> _launchContact(BuildContext context, Uri uri, String label) async {
final ok = await launchUrl(uri);
if (!ok && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$label konnte nicht geöffnet werden')),
);
}
}
}
class _AllContactsCard extends StatelessWidget {
const _AllContactsCard({required this.sources});
final List<MergedContactSource> sources;
@override
Widget build(BuildContext context) {
@ -927,7 +1099,7 @@ class _AllContactsCard extends StatelessWidget {
if (i > 0) const Divider(height: 1),
_ContactSourceTile(
source: sources[i],
onLaunch: _launch,
onLaunch: _launchContact,
),
],
],
@ -979,6 +1151,36 @@ class _ContactSourceTile extends StatelessWidget {
color: theme.colorScheme.onSurfaceVariant,
),
),
if (source.address case final address?) ...[
const SizedBox(height: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.home_outlined,
size: 18,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${address.street} ${address.houseNumber}'.trim(),
style: const TextStyle(fontSize: 14),
),
Text(
'${address.postalCode} ${address.city}'.trim(),
style: const TextStyle(fontSize: 14),
),
..._addressExtras(address, fontSize: 14),
],
),
),
],
),
],
if (channels.isEmpty) ...[
const SizedBox(height: 6),
Text(

View File

@ -0,0 +1,185 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import 'package:hl_lieferservice/feature/delivery/detail/model/delivery_payment_status.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/delivered_items_card.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/payment_breakdown_card.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/payment_record_card.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/payment_sheet.dart';
/// Step „Zahlung" — Zahlungsabwicklung („Abkassieren") vor der Übersicht.
///
/// Zeigt die ausgelieferten Artikel mit Menge und Preis, die Betrags-
/// aufstellung und den Zahlungsstatus. Über „Zahlung abwickeln" öffnet sich
/// das Zahlungs-Modal; die Bestätigung wird am Server protokolliert.
///
/// Ist nichts offen (bereits bezahlt), kann der Fahrer den Step
/// überspringen. Sonst gibt die Detail-Page „Weiter" erst frei, wenn eine
/// gültige Zahlung protokolliert ist (siehe `DeliveryPaymentStatus`).
class StepPayment extends StatelessWidget {
const StepPayment({super.key, required this.delivery, required this.details});
final Delivery delivery;
final TourDetails details;
@override
Widget build(BuildContext context) {
final status = DeliveryPaymentStatus.of(delivery, details);
final canCollect =
delivery.state == DeliveryState.active && status.hasOpenAmount;
return Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
const _SectionHeader(text: 'Ausgelieferte Artikel'),
const SizedBox(height: 8),
DeliveredItemsCard(delivery: delivery, details: details),
const SizedBox(height: 24),
const _SectionHeader(text: 'Betrag'),
const SizedBox(height: 8),
PaymentBreakdownCard(
status: status,
credit: details.creditOf(delivery.id),
),
const SizedBox(height: 16),
PaymentRecordCard(status: status),
],
),
),
if (canCollect)
_CollectPanel(
status: status,
onCollect: () => _openPaymentSheet(context, status),
),
],
);
}
Future<void> _openPaymentSheet(
BuildContext context,
DeliveryPaymentStatus status,
) async {
final tourBloc = context.read<TourBloc>();
final actorCarId = _actorCarId(context);
final customer = details.customerOf(delivery);
final result = await showPaymentSheet(
context,
openCents: status.openCents,
customerNumber: customer?.erpCustomerId ?? 0,
// Vorauswahl: zuletzt abgewickelte Methode, sonst die vom Beleg.
preselectedMethodId:
status.latestPayment?.paymentMethodId ?? delivery.paymentMethodId,
);
switch (result) {
case PaymentConfirmed(:final method):
tourBloc.add(
RecordDeliveryPayment(
deliveryId: delivery.id,
paymentMethodId: method.id,
expectedAmountCents: status.openCents,
actorCarId: actorCarId,
),
);
case DeliveryCancelRequested(:final reason):
tourBloc.add(CancelDelivery(deliveryId: delivery.id, reason: reason));
case null:
break;
}
}
String? _actorCarId(BuildContext context) {
final state = context.read<CarSelectBloc>().state;
return state is CarSelectComplete ? state.selectedCar.id : null;
}
}
/// Fester Bereich unten: offener Betrag + großer „Zahlung abwickeln"-Button.
/// Bleibt sichtbar, auch wenn die Artikelliste lang ist.
class _CollectPanel extends StatelessWidget {
const _CollectPanel({required this.status, required this.onCollect});
final DeliveryPaymentStatus status;
final VoidCallback onCollect;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final settled = status.validPayment != null;
return Material(
elevation: 6,
color: theme.colorScheme.surface,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Text('Offener Betrag', style: theme.textTheme.titleMedium),
const Spacer(),
Text(
formatEuroCents(status.openCents),
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
color: theme.colorScheme.primary,
),
),
],
),
const SizedBox(height: 10),
if (settled)
// Bereits abgewickelt: Korrektur möglich, aber nicht mehr im
// Vordergrund — „Weiter" ist jetzt der nächste Schritt.
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(52),
),
onPressed: onCollect,
icon: const Icon(Icons.edit_outlined),
label: const Text('Zahlung korrigieren'),
)
else
FilledButton.icon(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(68),
textStyle: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
onPressed: onCollect,
icon: const Icon(Icons.point_of_sale, size: 28),
label: const Text('Zahlung abwickeln'),
),
],
),
),
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Text(
text,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
);
}
}

View File

@ -1,69 +1,50 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery_credit.dart';
import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_event.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_state.dart';
import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart';
import 'package:hl_lieferservice/feature/delivery/detail/model/delivery_payment_status.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/delivered_items_card.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/payment_breakdown_card.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/payment_record_card.dart';
/// Step 5 — Übersicht & Abschluss.
/// Step 6 — Übersicht & Abschluss.
///
/// Listet alle Artikel mit der **tatsächlich auszuliefernden Menge** auf
/// (Original-Soll minus lokaler Partial-Remove-Drafts minus
/// Komplett-Removes). Dazu Anzahlung-Anzeige, optionale Gutschrift,
/// Zahlungsmethoden-Dropdown.
/// Listet alle Artikel mit der **tatsächlich auszuliefernden Menge** auf,
/// dazu die Betragsaufstellung und den Stand der Zahlung. Die Zahlungsart
/// wird nicht mehr hier gewählt, sondern im Step „Zahlung" abgewickelt und
/// protokolliert — die Übersicht zeigt das Ergebnis nur an.
///
/// Der „Unterschreiben"-Button lebt in der Bottom-Navigation des
/// Page-Wrappers; hier zeigen wir den Resümee-Block, der direkt vor der
/// Unterschrift steht.
class StepSummary extends StatelessWidget {
const StepSummary({
super.key,
required this.delivery,
required this.details,
});
const StepSummary({super.key, required this.delivery, required this.details});
final Delivery delivery;
final TourDetails details;
@override
Widget build(BuildContext context) {
return BlocBuilder<DeliveryWorkflowBloc, DeliveryWorkflowState>(
builder: (context, wfState) {
final status = DeliveryPaymentStatus.of(delivery, details);
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
_SectionHeader(text: 'Ausgelieferte Artikel'),
const _SectionHeader(text: 'Ausgelieferte Artikel'),
const SizedBox(height: 8),
_DeliveredItems(
delivery: delivery,
details: details,
),
DeliveredItemsCard(delivery: delivery, details: details),
const SizedBox(height: 24),
_SectionHeader(text: 'Zahlung'),
const _SectionHeader(text: 'Zahlung'),
const SizedBox(height: 8),
_PaymentSummary(
delivery: delivery,
credit: details.creditOf(delivery.id),
),
const SizedBox(height: 24),
_SectionHeader(text: 'Zahlungsmethode'),
const SizedBox(height: 8),
_PaymentMethodPicker(
delivery: delivery,
overrideId: wfState.paymentMethodOverrideId,
PaymentBreakdownCard(
status: status,
credit: details.creditOf(delivery.id),
),
const SizedBox(height: 12),
PaymentRecordCard(status: status),
const SizedBox(height: 16),
const _SignHint(),
],
);
},
);
}
}
@ -75,436 +56,9 @@ class _SectionHeader extends StatelessWidget {
Widget build(BuildContext context) {
return Text(
text,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
);
}
}
class _DeliveredItems extends StatelessWidget {
const _DeliveredItems({
required this.delivery,
required this.details,
});
final Delivery delivery;
final TourDetails details;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Innerhalb einer Belegzeile: Oberartikel vor seinen Komponenten, damit
// Komponenten direkt darunter eingerückt erscheinen.
final items = List<DeliveryItem>.of(delivery.items)
..sort((a, b) {
final byLine = a.belegzeilenNr.compareTo(b.belegzeilenNr);
if (byLine != 0) return byLine;
final byParent =
(a.isComponent ? 1 : 0).compareTo(b.isComponent ? 1 : 0);
if (byParent != 0) return byParent;
return (a.komponentenArtikelNr ?? '')
.compareTo(b.komponentenArtikelNr ?? '');
});
if (items.isEmpty) {
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Keine Artikel hinterlegt.',
style: TextStyle(
color: theme.colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
);
}
return Card(
margin: EdgeInsets.zero,
child: Column(
children: [
for (int i = 0; i < items.length; i++) ...[
_DeliveredRow(
item: items[i],
details: details,
),
if (i < items.length - 1)
const Divider(height: 1, indent: 16, endIndent: 16),
],
],
),
);
}
}
class _DeliveredRow extends StatelessWidget {
const _DeliveredRow({
required this.item,
required this.details,
});
final DeliveryItem item;
final TourDetails details;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final article = details.articleOf(item.articleId);
// Ausgeliefert = Soll − Gutschrift (vom Backend). Voll gutgeschrieben
// (status removed) ⇒ credited == required ⇒ delivered 0.
final credited = item.scanProgress.creditedQuantity;
final delivered = (item.requiredQuantity - credited).clamp(
0,
item.requiredQuantity,
);
final Color avatarColor;
if (delivered == 0) {
avatarColor = Colors.red.shade400;
} else if (delivered < item.requiredQuantity) {
avatarColor = Colors.amber.shade700;
} else {
avatarColor = Colors.green.shade600;
}
return ListTile(
// Komponenten um eine Stufe eingerückt (gehören zum Oberartikel darüber).
contentPadding:
EdgeInsets.only(left: item.isComponent ? 40 : 16, right: 16),
leading: CircleAvatar(
backgroundColor: avatarColor,
foregroundColor: theme.colorScheme.onPrimary,
child: Text(
'$delivered×',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
),
title: Text(
'${item.isComponent ? '↳ ' : ''}${article?.name ?? '⟨Unbekannter Artikel⟩'}',
style: TextStyle(
fontWeight: FontWeight.w600,
decoration: delivered == 0 ? TextDecoration.lineThrough : null,
color: delivered == 0 ? theme.colorScheme.onSurfaceVariant : null,
),
),
subtitle: Text(
[
if (delivered < item.requiredQuantity)
'von ${item.requiredQuantity} bestellt · Gutschrift: $credited'
else
'Artikelnr. ${article?.articleNumber ?? item.articleId}',
'${item.unitPrice.toStringAsFixed(2)} € / Stück',
].join(' · '),
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
trailing: Text(
'${item.lineTotal.toStringAsFixed(2)} €',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
decoration: delivered == 0 ? TextDecoration.lineThrough : null,
color: delivered == 0 ? theme.colorScheme.onSurfaceVariant : null,
),
),
);
}
}
/// Offener Betrag der Lieferung in Euro: Warenwert (Σ Stückpreis × gelieferte
/// Menge) − Anzahlung − Gutschrift, nie negativ. Einzige Quelle dieser Formel —
/// genutzt von der Zahlungs-Übersicht UND der Zahlungsmethoden-Auswahl.
double _openAmount(Delivery delivery, DeliveryCredit? credit) {
final creditEuros = (credit?.amountCents ?? 0) / 100.0;
final warenwert =
delivery.items.fold<double>(0, (acc, item) => acc + item.lineTotal);
return (warenwert - delivery.prepaidAmount - creditEuros)
.clamp(0.0, double.infinity);
}
class _PaymentSummary extends StatelessWidget {
const _PaymentSummary({required this.delivery, required this.credit});
final Delivery delivery;
final DeliveryCredit? credit;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Warenwert = Σ Stückpreis × ausgelieferte Menge (entfernte/teil-entfernte
// Positionen fallen automatisch raus).
final warenwert = delivery.items
.fold<double>(0, (acc, item) => acc + item.lineTotal);
// Offener Betrag über den gemeinsamen Helper (gleiche Formel wie die
// Zahlungsmethoden-Auswahl).
final open = _openAmount(delivery, credit);
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_SummaryRow(
icon: Icons.receipt_long_outlined,
label: 'Warenwert',
valueText: '${warenwert.toStringAsFixed(2)} €',
valueColor: theme.colorScheme.onSurface,
),
const SizedBox(height: 12),
_SummaryRow(
icon: Icons.savings_outlined,
label: 'Bei Bestellung bezahlt',
valueText: '− ${delivery.prepaidAmount.toStringAsFixed(2)} €',
valueColor: delivery.prepaidAmount > 0
? Colors.green.shade700
: theme.colorScheme.onSurfaceVariant,
),
if (credit != null) ...[
const SizedBox(height: 12),
_SummaryRow(
icon: Icons.card_giftcard_outlined,
label: 'Gutschrift',
valueText: '− ${(credit!.amountCents / 100).toStringAsFixed(2)} €',
valueColor: Colors.amber.shade800,
subtitle: credit!.reason,
),
],
const Divider(height: 24),
_SummaryRow(
icon: Icons.account_balance_wallet_outlined,
label: 'Offener Betrag',
valueText: '${open.toStringAsFixed(2)} €',
valueColor: open > 0
? theme.colorScheme.primary
: Colors.green.shade700,
emphasize: true,
),
],
),
),
);
}
}
class _SummaryRow extends StatelessWidget {
const _SummaryRow({
required this.icon,
required this.label,
required this.valueText,
required this.valueColor,
this.subtitle,
this.emphasize = false,
});
final IconData icon;
final String label;
final String valueText;
final Color valueColor;
final String? subtitle;
/// Hebt Label + Wert hervor (für den „Offener Betrag"-Abschluss).
final bool emphasize;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(icon, color: theme.colorScheme.primary),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: emphasize
? const TextStyle(fontWeight: FontWeight.w700)
: null,
),
if (subtitle != null)
Text(
subtitle!,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
Text(
valueText,
style: (emphasize
? theme.textTheme.titleLarge
: theme.textTheme.titleMedium)
?.copyWith(
fontWeight: FontWeight.w700,
color: valueColor,
),
),
],
);
}
}
class _PaymentMethodPicker extends StatelessWidget {
const _PaymentMethodPicker({
required this.delivery,
required this.overrideId,
required this.credit,
});
final Delivery delivery;
final String? overrideId;
final DeliveryCredit? credit;
@override
Widget build(BuildContext context) {
return BlocBuilder<PaymentMethodsCubit, PaymentMethodsState>(
builder: (context, state) {
if (state is PaymentMethodsLoading || state is PaymentMethodsInitial) {
return const Card(
margin: EdgeInsets.zero,
child: Padding(
padding: EdgeInsets.all(16),
child: Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 12),
Text('Zahlungsmethoden laden …'),
],
),
),
);
}
if (state is PaymentMethodsFailed) {
return Card(
margin: EdgeInsets.zero,
color: Theme.of(context).colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
state.message,
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
),
);
}
final loaded = state as PaymentMethodsLoaded;
// Ausschließlich die Backend-Methoden — keine frontend-seitige
// Fabrikation/Hardcodierung. Es werden genau die angezeigt, die im
// Backend (Postgres `payment_methods`, aktiv) hinterlegt sind.
final methods = loaded.methods;
final selectedId = overrideId ?? delivery.paymentMethodId;
// Als Dropdown-Value nur setzen, wenn die Methode tatsächlich in der
// Backend-Liste ist (sonst würde Flutter asserten). Ist die zugewiesene
// Methode zwischenzeitlich deaktiviert/entfernt, bleibt das Feld leer.
final selectedValue =
methods.any((m) => m.id == selectedId) ? selectedId : null;
// Zahlungsmethode nur bei aktiver Lieferung änderbar. Bei
// abgeschlossener/abgebrochener/pausierter Lieferung zeigt das
// Dropdown den gewählten Stand, ist aber gesperrt.
final active = delivery.state == DeliveryState.active;
// Steht kein offener Betrag mehr aus (vollständig vorab bezahlt
// oder per Gutschrift ausgeglichen), ist keine Zahlungsweise zu
// wählen → Auswahl deaktivieren.
final hasOpenAmount = _openAmount(delivery, credit) > 0;
final editable = active && hasOpenAmount;
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<String>(
initialValue: selectedValue,
decoration: const InputDecoration(
labelText: 'Zahlungsmethode',
border: OutlineInputBorder(),
),
items: [
for (final m in methods)
DropdownMenuItem(
value: m.id,
child: Text(m.name),
),
],
// `null` deaktiviert das Dropdown (Flutter-Konvention).
onChanged: editable
? (newId) {
if (newId == null) return;
context.read<DeliveryWorkflowBloc>().add(
WorkflowOverridePaymentMethod(
// Zurück auf die Original-Methode → Override
// löschen, damit das Domain-Modell "no
// override" kennt.
paymentMethodId:
newId == delivery.paymentMethodId
? null
: newId,
),
);
}
: null,
),
if (!active) ...[
const SizedBox(height: 8),
const _PickerHint(
text: 'Lieferung abgeschlossen — Zahlungsmethode nicht '
'mehr änderbar.',
),
] else if (!hasOpenAmount) ...[
const SizedBox(height: 8),
const _PickerHint(
text: 'Keine Zahlung mehr offen (bereits bezahlt) — '
'Auswahl der Zahlungsweise nicht erforderlich.',
),
],
],
),
),
);
},
);
}
}
/// Dezenter Sperr-/Info-Hinweis unter dem Zahlungsmethoden-Dropdown
/// (Schloss-Icon + Text in gedämpfter Farbe).
class _PickerHint extends StatelessWidget {
const _PickerHint({required this.text});
final String text;
@override
Widget build(BuildContext context) {
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
return Row(
children: [
Icon(Icons.lock_outline, size: 16, color: muted),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: muted),
),
),
],
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
);
}
}
@ -531,10 +85,7 @@ class _SignHint extends StatelessWidget {
Expanded(
child: Text(
'Mit „Unterschreiben" unten schließt der Kunde den Vorgang ab.',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.primary,
),
style: TextStyle(fontSize: 13, color: theme.colorScheme.primary),
),
),
],

View File

@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
import 'package:hl_lieferservice/feature/delivery/detail/model/delivery_payment_status.dart';
/// Ausgelieferte Artikel einer Lieferung mit Menge, Stückpreis und
/// Zeilensumme. Gemeinsam genutzt von Zahlungs-Step und Übersicht.
///
/// Menge = tatsächlich ausgeliefert (Soll − Gutschrift). Komplett
/// gutgeschriebene Positionen erscheinen durchgestrichen.
class DeliveredItemsCard extends StatelessWidget {
const DeliveredItemsCard({
super.key,
required this.delivery,
required this.details,
});
final Delivery delivery;
final TourDetails details;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Innerhalb einer Belegzeile: Oberartikel vor seinen Komponenten, damit
// Komponenten direkt darunter eingerückt erscheinen.
final items = List<DeliveryItem>.of(delivery.items)..sort((a, b) {
final byLine = a.belegzeilenNr.compareTo(b.belegzeilenNr);
if (byLine != 0) return byLine;
final byParent = (a.isComponent ? 1 : 0).compareTo(b.isComponent ? 1 : 0);
if (byParent != 0) return byParent;
return (a.komponentenArtikelNr ?? '').compareTo(
b.komponentenArtikelNr ?? '',
);
});
if (items.isEmpty) {
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Keine Artikel hinterlegt.',
style: TextStyle(
color: theme.colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
);
}
return Card(
margin: EdgeInsets.zero,
child: Column(
children: [
for (int i = 0; i < items.length; i++) ...[
_DeliveredRow(item: items[i], details: details),
if (i < items.length - 1)
const Divider(height: 1, indent: 16, endIndent: 16),
],
],
),
);
}
}
class _DeliveredRow extends StatelessWidget {
const _DeliveredRow({required this.item, required this.details});
final DeliveryItem item;
final TourDetails details;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final article = details.articleOf(item.articleId);
// Ausgeliefert = Soll − Gutschrift (vom Backend). Voll gutgeschrieben
// (status removed) ⇒ credited == required ⇒ delivered 0.
final credited = item.scanProgress.creditedQuantity;
final delivered = (item.requiredQuantity - credited).clamp(
0,
item.requiredQuantity,
);
final Color avatarColor;
if (delivered == 0) {
avatarColor = Colors.red.shade400;
} else if (delivered < item.requiredQuantity) {
avatarColor = Colors.amber.shade700;
} else {
avatarColor = Colors.green.shade600;
}
return ListTile(
// Komponenten um eine Stufe eingerückt (gehören zum Oberartikel darüber).
contentPadding: EdgeInsets.only(
left: item.isComponent ? 40 : 16,
right: 16,
),
leading: CircleAvatar(
backgroundColor: avatarColor,
foregroundColor: theme.colorScheme.onPrimary,
child: Text(
'$delivered×',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
),
title: Text(
'${item.isComponent ? '↳ ' : ''}${article?.name ?? '⟨Unbekannter Artikel⟩'}',
style: TextStyle(
fontWeight: FontWeight.w600,
decoration: delivered == 0 ? TextDecoration.lineThrough : null,
color: delivered == 0 ? theme.colorScheme.onSurfaceVariant : null,
),
),
subtitle: Text(
[
if (delivered < item.requiredQuantity)
'von ${item.requiredQuantity} bestellt · Gutschrift: $credited'
else
'Artikelnr. ${article?.articleNumber ?? item.articleId}',
'${formatEuro(item.unitPrice)} / Stück',
].join(' · '),
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
trailing: Text(
formatEuro(item.lineTotal),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
decoration: delivered == 0 ? TextDecoration.lineThrough : null,
color: delivered == 0 ? theme.colorScheme.onSurfaceVariant : null,
),
),
);
}
}

View File

@ -3,7 +3,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
import 'package:hl_lieferservice/feature/feature_flags.dart';
import 'package:hl_lieferservice/feature/feature_flags/feature.dart';
import 'package:hl_lieferservice/feature/feature_flags/feature_flags.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
@ -165,8 +166,10 @@ class _DiscountEditorState extends State<DiscountEditor> {
),
const SizedBox(height: 8),
// Default: freies Betrags-Textfeld. Hinter dem Feature-Flag
// `discountAmountStepper` liegt die ursprüngliche +/−-Variante.
if (FeatureFlags.discountAmountStepper)
// `articles.credit_amount_stepper` liegt die +/−-Variante.
if (context
.read<FeatureFlags>()
.isEnabled(Feature.articlesCreditAmountStepper))
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [

View File

@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
/// Entscheidung im Blatt „Interne Notiz". `null` als Ergebnis von
/// [showInternalNoteSheet] heißt: zurück zur Unterschrift, nichts abschließen.
class InternalNoteDecision {
const InternalNoteDecision(this.note);
/// Getrimmter Text oder `null` = ohne Notiz abschließen.
final String? note;
}
/// Maximale Länge — deckungsgleich mit dem Backend (2000 Zeichen).
const int internalNoteMaxLength = 2000;
/// Fragt nach beiden Unterschriften optional eine interne Notiz ab.
///
/// Ohne Eingabe ist der große Button „Ohne Notiz abschließen" — der Fahrer
/// braucht also nur einen Tipp, wenn er nichts notieren will. Die Notiz ist
/// nur für interne Zwecke (Lieferbericht) und nicht Teil der Bestätigung
/// durch den Kunden.
Future<InternalNoteDecision?> showInternalNoteSheet(BuildContext context) {
return showModalBottomSheet<InternalNoteDecision>(
context: context,
isScrollControlled: true,
useSafeArea: true,
isDismissible: false,
enableDrag: false,
builder: (_) => const _InternalNoteSheet(),
);
}
class _InternalNoteSheet extends StatefulWidget {
const _InternalNoteSheet();
@override
State<_InternalNoteSheet> createState() => _InternalNoteSheetState();
}
class _InternalNoteSheetState extends State<_InternalNoteSheet> {
final TextEditingController _controller = TextEditingController();
@override
void initState() {
super.initState();
// Button-Label hängt vom Text ab („Ohne" vs. „Mit Notiz").
_controller.addListener(() => setState(() {}));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
String? get _note {
final text = _controller.text.trim();
return text.isEmpty ? null : text;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hasNote = _note != null;
return Padding(
// Tastatur schiebt das Blatt hoch, statt die Buttons zu verdecken.
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
// Unten Abstand zur System-Navigationsleiste, sonst liegt
// „Zurück zur Unterschrift" darunter.
child: SafeArea(
top: false,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
Icons.sticky_note_2_outlined,
color: theme.colorScheme.primary,
),
const SizedBox(width: 10),
Text(
'Interne Notiz',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 6),
Text(
'Optional. Nur für interne Zwecke, der Kunde sieht sie nicht. '
'Die Notiz erscheint im Lieferbericht.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
TextField(
controller: _controller,
minLines: 3,
maxLines: 6,
maxLength: internalNoteMaxLength,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
hintText: 'z. B. Kunde wünscht Rückruf wegen Montage',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 8),
FilledButton.icon(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(56),
textStyle: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
),
),
onPressed:
() =>
Navigator.of(context).pop(InternalNoteDecision(_note)),
icon: const Icon(Icons.check),
label: Text(
hasNote ? 'Mit Notiz abschließen' : 'Ohne Notiz abschließen',
),
),
const SizedBox(height: 4),
TextButton(
style: TextButton.styleFrom(
minimumSize: const Size.fromHeight(44),
),
onPressed: () => Navigator.of(context).pop(),
child: const Text('Zurück zur Unterschrift'),
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import 'package:hl_lieferservice/domain/entity/delivery_credit.dart';
import 'package:hl_lieferservice/feature/delivery/detail/model/delivery_payment_status.dart';
/// Betragsaufstellung einer Lieferung: Warenwert − Anzahlung − Gutschrift =
/// offener Betrag. Gemeinsam genutzt von Zahlungs-Step und Übersicht; die
/// Zahlen kommen aus [DeliveryPaymentStatus] (gleiche Formel wie Backend).
class PaymentBreakdownCard extends StatelessWidget {
const PaymentBreakdownCard({
super.key,
required this.status,
required this.credit,
});
final DeliveryPaymentStatus status;
final DeliveryCredit? credit;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_AmountRow(
icon: Icons.receipt_long_outlined,
label: 'Warenwert',
valueText: formatEuro(status.goodsValue),
valueColor: theme.colorScheme.onSurface,
),
const SizedBox(height: 12),
_AmountRow(
icon: Icons.savings_outlined,
label: 'Bei Bestellung bezahlt',
valueText: '− ${formatEuro(status.prepaidAmount)}',
valueColor:
status.prepaidAmount > 0
? Colors.green.shade700
: theme.colorScheme.onSurfaceVariant,
),
if (credit != null) ...[
const SizedBox(height: 12),
_AmountRow(
icon: Icons.card_giftcard_outlined,
label: 'Gutschrift',
valueText: '− ${formatEuroCents(credit!.amountCents)}',
valueColor: Colors.amber.shade800,
subtitle: credit!.reason,
),
],
const Divider(height: 24),
_AmountRow(
icon: Icons.account_balance_wallet_outlined,
label: 'Offener Betrag',
valueText: formatEuroCents(status.openCents),
valueColor:
status.hasOpenAmount
? theme.colorScheme.primary
: Colors.green.shade700,
emphasize: true,
),
],
),
),
);
}
}
class _AmountRow extends StatelessWidget {
const _AmountRow({
required this.icon,
required this.label,
required this.valueText,
required this.valueColor,
this.subtitle,
this.emphasize = false,
});
final IconData icon;
final String label;
final String valueText;
final Color valueColor;
final String? subtitle;
/// Hebt Label + Wert hervor (für den „Offener Betrag"-Abschluss).
final bool emphasize;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(icon, color: theme.colorScheme.primary),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style:
emphasize
? const TextStyle(fontWeight: FontWeight.w700)
: null,
),
if (subtitle != null)
Text(
subtitle!,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
Text(
valueText,
style: (emphasize
? theme.textTheme.titleLarge
: theme.textTheme.titleMedium)
?.copyWith(fontWeight: FontWeight.w700, color: valueColor),
),
],
);
}
}

View File

@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
/// Icon je Zahlungsmethode anhand des stabilen Backend-Codes. Unbekannte
/// Codes (neue Methoden aus den Stammdaten) bekommen ein neutrales Icon.
IconData paymentMethodIcon(String code) => switch (code) {
'cash' => Icons.payments_outlined,
'ec_card' => Icons.credit_card,
'invoice' => Icons.receipt_long_outlined,
_ => Icons.account_balance_wallet_outlined,
};

View File

@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import 'package:hl_lieferservice/domain/entity/delivery_payment.dart';
import 'package:hl_lieferservice/feature/delivery/detail/model/delivery_payment_status.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/payment_method_icon.dart';
import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart';
/// Status-Karte der Zahlungsabwicklung: erledigt, veraltet, noch offen oder
/// nicht nötig. Gemeinsam genutzt von Zahlungs-Step und Übersicht.
class PaymentRecordCard extends StatelessWidget {
const PaymentRecordCard({super.key, required this.status});
final DeliveryPaymentStatus status;
@override
Widget build(BuildContext context) {
final valid = status.validPayment;
if (!status.hasOpenAmount) {
return const _StatusCard(
tone: _Tone.success,
icon: Icons.verified_outlined,
title: 'Bereits bezahlt',
text:
'Es ist kein Betrag mehr offen. Dieser Schritt kann '
'übersprungen werden.',
);
}
if (valid != null) {
return _StatusCard(
tone: _Tone.success,
icon: paymentMethodIcon(valid.paymentMethodCode),
title:
valid.isCollectedOnSite
? 'Zahlung erhalten'
: 'Zahlung auf Rechnung bestätigt',
text:
'${formatEuroCents(valid.amountCents)} per '
'${_methodName(context, valid)} · protokolliert um '
'${DateFormat('HH:mm').format(valid.recordedAt)} Uhr',
);
}
if (status.isStale) {
return const _StatusCard(
tone: _Tone.warning,
icon: Icons.sync_problem_outlined,
title: 'Betrag hat sich geändert',
text:
'Seit der letzten Zahlungsabwicklung hat sich der offene Betrag '
'geändert (z. B. durch eine Gutschrift). Bitte die Zahlung erneut '
'abwickeln.',
);
}
return const _StatusCard(
tone: _Tone.open,
icon: Icons.pending_outlined,
title: 'Zahlung noch nicht abgewickelt',
text:
'Bitte die Zahlung abwickeln, bevor es mit der Übersicht '
'weitergeht.',
);
}
/// Anzeigename aus den Stammdaten; Fallback auf den Code-Snapshot, falls
/// die Methode inzwischen deaktiviert/umbenannt wurde.
String _methodName(BuildContext context, DeliveryPayment payment) {
final state = context.watch<PaymentMethodsCubit>().state;
if (state is PaymentMethodsLoaded) {
for (final m in state.methods) {
if (m.id == payment.paymentMethodId) return m.name;
}
}
return switch (payment.paymentMethodCode) {
'cash' => 'Bar',
'ec_card' => 'EC-Karte',
'invoice' => 'Rechnung',
final code => code,
};
}
}
enum _Tone { success, warning, open }
class _StatusCard extends StatelessWidget {
const _StatusCard({
required this.tone,
required this.icon,
required this.title,
required this.text,
});
final _Tone tone;
final IconData icon;
final String title;
final String text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final Color color = switch (tone) {
_Tone.success => Colors.green.shade700,
_Tone.warning => Colors.orange.shade800,
_Tone.open => theme.colorScheme.primary,
};
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withValues(alpha: 0.4)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: color),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
color: color,
),
),
const SizedBox(height: 2),
Text(text, style: theme.textTheme.bodySmall),
],
),
),
],
),
);
}
}

View File

@ -0,0 +1,407 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/domain/entity/payment_method.dart';
import 'package:hl_lieferservice/feature/delivery/detail/model/delivery_payment_status.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/payment_method_icon.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/reason_dialog.dart';
import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart';
/// Ergebnis des Zahlungs-Modals. `null` = abgebrochen, nichts passiert.
sealed class PaymentSheetResult {
const PaymentSheetResult();
}
/// Der Fahrer hat bestätigt, dass die Zahlung per [method] erhalten wurde
/// (bzw. bei „Rechnung": dass auf Rechnung abgewickelt wird).
class PaymentConfirmed extends PaymentSheetResult {
const PaymentConfirmed(this.method);
final PaymentMethod method;
}
/// Zahlung ist nicht möglich — die ganze Lieferung soll abgebrochen werden.
class DeliveryCancelRequested extends PaymentSheetResult {
const DeliveryCancelRequested(this.reason);
final String reason;
}
/// Öffnet das Zahlungs-Modal.
///
/// Zeigt groß den offenen Betrag, die Zahlungsmethoden als Cards (dynamisch
/// aus den Backend-Stammdaten) und bei EC-Karte groß die Kundennummer, die
/// der Fahrer am EC-Gerät eingibt. Schließt sich nur über „Abbrechen",
/// „Zahlung erhalten" oder „Lieferung abbrechen".
Future<PaymentSheetResult?> showPaymentSheet(
BuildContext context, {
required int openCents,
required int customerNumber,
required String? preselectedMethodId,
}) {
return showModalBottomSheet<PaymentSheetResult>(
context: context,
isScrollControlled: true,
useSafeArea: true,
// Kein versehentliches Wegwischen: Das Modal endet nur über die Buttons.
isDismissible: false,
enableDrag: false,
showDragHandle: false,
builder:
(_) => _PaymentSheet(
openCents: openCents,
customerNumber: customerNumber,
preselectedMethodId: preselectedMethodId,
),
);
}
class _PaymentSheet extends StatefulWidget {
const _PaymentSheet({
required this.openCents,
required this.customerNumber,
required this.preselectedMethodId,
});
final int openCents;
final int customerNumber;
final String? preselectedMethodId;
@override
State<_PaymentSheet> createState() => _PaymentSheetState();
}
class _PaymentSheetState extends State<_PaymentSheet> {
String? _selectedId;
@override
void initState() {
super.initState();
// Vorauswahl = am Beleg hinterlegte Methode. Der Fahrer kann wechseln.
_selectedId = widget.preselectedMethodId;
}
Future<void> _cancelDelivery() async {
final navigator = Navigator.of(context);
final reason = await showReasonDialog(context, 'Lieferung abbrechen');
if (reason == null || !mounted) return;
navigator.pop(DeliveryCancelRequested(reason));
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocBuilder<PaymentMethodsCubit, PaymentMethodsState>(
builder: (context, pmState) {
final methods =
pmState is PaymentMethodsLoaded
? pmState.methods.where((m) => m.active).toList()
: const <PaymentMethod>[];
PaymentMethod? selected;
for (final m in methods) {
if (m.id == _selectedId) selected = m;
}
final isInvoice = selected?.code == 'invoice';
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Flexible(
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
children: [
Text(
'Zahlung abwickeln',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 16),
_AmountBanner(openCents: widget.openCents),
const SizedBox(height: 20),
Text(
'Zahlungsart',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
..._methodSection(context, pmState, methods),
if (selected != null) ...[
const SizedBox(height: 8),
_MethodHint(
method: selected,
customerNumber: widget.customerNumber,
),
],
],
),
),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton.icon(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(60),
textStyle: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
onPressed:
selected == null
? null
: () => Navigator.of(
context,
).pop(PaymentConfirmed(selected!)),
icon: const Icon(Icons.check_circle_outline),
label: Text(
isInvoice
? 'Auf Rechnung bestätigen'
: 'Zahlung erhalten',
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
flex: 3,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
padding: const EdgeInsets.symmetric(
horizontal: 12,
),
foregroundColor: theme.colorScheme.error,
side: BorderSide(color: theme.colorScheme.error),
),
onPressed: _cancelDelivery,
icon: const Icon(Icons.block),
label: const Text('Lieferung abbrechen'),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: TextButton(
style: TextButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
onPressed: () => Navigator.of(context).pop(),
child: const Text('Abbrechen'),
),
),
],
),
],
),
),
),
],
);
},
);
}
List<Widget> _methodSection(
BuildContext context,
PaymentMethodsState state,
List<PaymentMethod> methods,
) {
if (state is PaymentMethodsFailed) {
return [
Card(
margin: EdgeInsets.zero,
color: Theme.of(context).colorScheme.errorContainer,
child: ListTile(
title: Text(state.message),
trailing: TextButton.icon(
onPressed: () => context.read<PaymentMethodsCubit>().load(),
icon: const Icon(Icons.refresh),
label: const Text('Erneut laden'),
),
),
),
];
}
if (state is! PaymentMethodsLoaded) {
return const [
Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
),
];
}
if (methods.isEmpty) {
return const [Text('Keine Zahlungsmethoden hinterlegt.')];
}
return [
for (final m in methods)
_MethodCard(
method: m,
selected: m.id == _selectedId,
onTap: () => setState(() => _selectedId = m.id),
),
];
}
}
/// Der zu zahlende Betrag, bewusst sehr groß.
class _AmountBanner extends StatelessWidget {
const _AmountBanner({required this.openCents});
final int openCents;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final primary = theme.colorScheme.primary;
return Container(
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 16),
decoration: BoxDecoration(
color: primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: primary.withValues(alpha: 0.35)),
),
child: Column(
children: [
Text('Noch zu zahlen', style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
formatEuroCents(openCents),
style: theme.textTheme.displayMedium?.copyWith(
fontWeight: FontWeight.w800,
color: primary,
),
),
),
],
),
);
}
}
class _MethodCard extends StatelessWidget {
const _MethodCard({
required this.method,
required this.selected,
required this.onTap,
});
final PaymentMethod method;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Card(
margin: const EdgeInsets.only(bottom: 10),
clipBehavior: Clip.antiAlias,
elevation: selected ? 2 : 0,
color: selected ? scheme.primaryContainer : scheme.surface,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: BorderSide(
color: selected ? scheme.primary : scheme.outlineVariant,
width: selected ? 2 : 1,
),
),
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 18),
child: Row(
children: [
Icon(
paymentMethodIcon(method.code),
size: 30,
color: selected ? scheme.primary : scheme.onSurfaceVariant,
),
const SizedBox(width: 16),
Expanded(
child: Text(
method.name,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
Icon(
selected ? Icons.radio_button_checked : Icons.radio_button_off,
color: selected ? scheme.primary : scheme.outline,
),
],
),
),
),
);
}
}
/// Hinweis zur gewählten Methode. Bei EC-Karte groß die Kundennummer, die
/// der Fahrer am EC-Gerät eingeben muss.
class _MethodHint extends StatelessWidget {
const _MethodHint({required this.method, required this.customerNumber});
final PaymentMethod method;
final int customerNumber;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (method.code == 'ec_card') {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Text(
'Kundennummer am EC-Gerät eingeben',
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSecondaryContainer,
),
),
const SizedBox(height: 6),
FittedBox(
fit: BoxFit.scaleDown,
child: SelectableText(
'$customerNumber',
style: theme.textTheme.displayMedium?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: 4,
fontFeatures: const [FontFeature.tabularFigures()],
color: theme.colorScheme.onSecondaryContainer,
),
),
),
],
),
);
}
final text = switch (method.code) {
'cash' => 'Betrag bar entgegennehmen und danach bestätigen.',
'invoice' =>
'Der Betrag wird per Rechnung beglichen. Es wird vor Ort '
'nichts kassiert.',
_ => 'Zahlung abwickeln und danach bestätigen.',
};
return Text(
text,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
);
}
}

View File

@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
/// Fragt eine Pflicht-Begründung ab (z. B. für Pausieren/Abbrechen einer
/// Lieferung). Liefert den getrimmten Text oder `null` bei Abbruch.
Future<String?> showReasonDialog(BuildContext context, String title) async {
// Controller bewusst nicht disposen: Der Dialog nutzt ihn noch während
// der Schließ-Animation. Ohne Listener räumt der GC ihn ab.
final controller = TextEditingController();
return showDialog<String>(
context: context,
builder:
(dialogContext) => AlertDialog(
title: Text(title),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Grund',
border: OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(null),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () {
final text = controller.text.trim();
if (text.isEmpty) return;
Navigator.of(dialogContext).pop(text);
},
child: const Text('Bestätigen'),
),
],
),
);
}

View File

@ -0,0 +1,40 @@
/// Formulierung des gewählten Tour-Tags für Fließtexte, z. B. in den
/// Leer-Ansichten („Für heute ist keine Tour zugewiesen." bzw. „Für den
/// 22.09.2026 …").
///
/// `null` als gewähltes Datum heißt „heute" (Server-Default, siehe
/// `TourDateCubit`). Heute/morgen/gestern werden relativ zur Geräte-Uhr
/// benannt, alle anderen Tage mit Datum.
class TourDayText {
const TourDayText._(this._relative, this.date);
factory TourDayText.of(DateTime? selected, {DateTime? now}) {
final clock = now ?? DateTime.now();
final today = DateTime(clock.year, clock.month, clock.day);
final day = selected == null
? today
: DateTime(selected.year, selected.month, selected.day);
final relative = switch (day.difference(today).inDays) {
0 => 'heute',
1 => 'morgen',
-1 => 'gestern',
_ => null,
};
return TourDayText._(relative, _format(day));
}
final String? _relative;
/// Datum als „TT.MM.JJJJ".
final String date;
/// Umstandsangabe: „heute" / „am 22.09.2026".
String get when => _relative ?? 'am $date';
/// Nach „für": „heute" / „den 22.09.2026".
String get afterFor => _relative ?? 'den $date';
static String _format(DateTime d) =>
'${d.day.toString().padLeft(2, '0')}.'
'${d.month.toString().padLeft(2, '0')}.${d.year}';
}

View File

@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/domain/entity/article.dart';
import 'package:hl_lieferservice/domain/entity/customer.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
@ -13,13 +12,15 @@ import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_detail_page.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_info.dart';
import 'package:hl_lieferservice/feature/delivery/pickup/presentation/filiale_pickup_scan_page.dart';
import 'package:hl_lieferservice/widget/orderer_hint.dart';
/// Entscheidet beim Tap auf eine Lieferung, wohin navigiert wird:
///
/// * Aktive Lieferung mit noch offenen Filial-Artikeln → zuerst der
/// Filial-Abhol-Scan-Screen. Der Fahrer ist an der Filiale und muss die
/// Ware abscannen, bevor er ausliefern kann. Nach dem Scan kehrt er zur
/// Übersicht zurück (die Lieferung verliert ihren Filial-Hinweis).
/// Ware abscannen, bevor er ausliefern kann. Nach dem Scan geht es von
/// dort direkt in die Auslieferung (die Lieferung verliert ihren
/// Filial-Hinweis).
/// * Sonst → direkt die Auslieferung (`DeliveryDetail`), die beim Kunden
/// bearbeitet wird.
///
@ -407,6 +408,7 @@ class _NextDeliveryCard extends StatelessWidget {
final theme = Theme.of(context);
final delivery = entry.delivery;
final customer = details.customerOf(delivery);
final recipient = details.recipientOf(delivery);
final pendingExternal = details.pendingExternalWarehouseGroups(delivery);
final hasPendingExternal = pendingExternal.isNotEmpty;
@ -431,15 +433,19 @@ class _NextDeliveryCard extends StatelessWidget {
children: [
_HeaderRow(
position: entry.position,
customer: customer,
name: recipient.name,
desiredTime: delivery.desiredTime,
),
const SizedBox(height: 10),
_InfoRow(
icon: Icons.location_on_outlined,
text: delivery.deliveryAddressSnapshot.oneLine,
text: recipient.address.oneLineWithAddition,
emphasized: true,
),
if (recipient.differsFromOrderer)
OrdererHint(
ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
),
if (delivery.specialAgreements != null &&
delivery.specialAgreements!.isNotEmpty) ...[
const SizedBox(height: 6),
@ -491,12 +497,12 @@ class _NextDeliveryCard extends StatelessWidget {
class _HeaderRow extends StatelessWidget {
const _HeaderRow({
required this.position,
required this.customer,
required this.name,
required this.desiredTime,
});
final int position;
final Customer? customer;
final String name;
final String? desiredTime;
@override
@ -523,7 +529,7 @@ class _HeaderRow extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
customer?.name ?? '⟨Unbekannter Kunde⟩',
name,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
@ -790,6 +796,7 @@ class _DeliveryTile extends StatelessWidget {
final theme = Theme.of(context);
final delivery = entry.delivery;
final customer = details.customerOf(delivery);
final recipient = details.recipientOf(delivery);
final pendingExternal = details.pendingExternalWarehouseGroups(delivery);
final hasPendingExternal = pendingExternal.isNotEmpty;
@ -827,7 +834,7 @@ class _DeliveryTile extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
customer?.name ?? '⟨Unbekannter Kunde⟩',
recipient.name,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
@ -839,12 +846,16 @@ class _DeliveryTile extends StatelessWidget {
),
const SizedBox(height: 2),
Text(
delivery.deliveryAddressSnapshot.oneLine,
recipient.address.oneLineWithAddition,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
if (recipient.differsFromOrderer)
OrdererHint(
ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
),
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.start,

View File

@ -11,6 +11,8 @@ import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_overview.dart';
import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart';
import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_date_cubit.dart';
import 'package:hl_lieferservice/feature/delivery/model/tour_day_text.dart';
/// Inhalt der Phase "Ausliefern". Sortieren und Beladen werden über eigene
/// Pages und das Phasen-Routing in `Home` gerendert — diese Page übernimmt
@ -35,16 +37,9 @@ class DeliveryOverviewPage extends StatelessWidget {
carId: carId,
),
),
body: BlocConsumer<TourBloc, TourState>(
listenWhen: (prev, next) =>
next is TourLoaded && next.refreshError != null,
listener: (context, state) {
if (state is TourLoaded && state.refreshError != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.refreshError!)),
);
}
},
// Die Refresh-Fehler-Snackbar (inkl. Retry) sitzt zentral im
// PhaseStepper-Header (`_ReloadButton`) und gilt damit für alle Phasen.
body: BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
switch (state) {
case TourLoaded(:final details):
@ -84,25 +79,26 @@ class _EmptyTourBody extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Wenn der ERP-Sync für heute keine Tour gemeldet hat, ist das ein
// Wenn der ERP-Sync für den Tag keine Tour gemeldet hat, ist das ein
// normaler Zustand — kein Fehler. UX-Hinweis und Pull-to-refresh.
return RefreshIndicator(
onRefresh: () async {
context.read<TourBloc>().add(const RefreshTour());
},
child: ListView(
children: const [
SizedBox(height: 120),
Icon(Icons.event_busy, size: 64, color: Colors.grey),
SizedBox(height: 16),
children: [
const SizedBox(height: 120),
const Icon(Icons.event_busy, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 32),
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
'Für heute ist keine Tour zugewiesen.\n'
'Für ${TourDayText.of(context.watch<TourDateCubit>().state).afterFor} '
'ist keine Tour zugewiesen.\n'
'Zum Aktualisieren nach unten ziehen.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16),
style: const TextStyle(fontSize: 16),
),
),
),

View File

@ -13,6 +13,9 @@ import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_fail_page.dart';
import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart';
import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart';
import 'package:hl_lieferservice/widget/orderer_hint.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_date_cubit.dart';
import 'package:hl_lieferservice/feature/delivery/model/tour_day_text.dart';
/// Page für die erste Phase bei Mehr-Auto-Teams: Auswählen der eigenen
/// Lieferungen aus dem gemeinsamen Tour-Pool.
@ -73,13 +76,13 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
}
Future<void> _showReleaseDialog(Delivery delivery, TourDetails details) async {
final customer = details.customerOf(delivery);
final recipient = details.recipientOf(delivery);
final result = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Lieferung freigeben'),
content: Text(
'${customer?.name ?? 'Diese Lieferung'} wurde Ihrem Fahrzeug '
'${recipient.name} wurde Ihrem Fahrzeug '
'zugeordnet. Möchten Sie sie wieder freigeben?',
),
actions: [
@ -105,7 +108,7 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
Delivery delivery,
TourDetails details,
) async {
final customer = details.customerOf(delivery);
final recipient = details.recipientOf(delivery);
final foreignPlate = _plateFor(delivery.assignedCarId);
final ownPlate = _plateFor(widget.selectedCarId);
final theme = Theme.of(context);
@ -119,7 +122,7 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
style: theme.textTheme.bodyMedium,
children: [
TextSpan(
text: '${customer?.name ?? 'Diese Lieferung'} ist aktuell ',
text: '${recipient.name} ist aktuell ',
),
TextSpan(
text: foreignPlate,
@ -241,6 +244,7 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
itemBuilder: (context, index) {
final delivery = available[index];
final customer = details.customerOf(delivery);
final recipient = details.recipientOf(delivery);
final isSelected = _selectedIds.contains(delivery.id);
return CheckboxListTile(
key: ValueKey('available-${delivery.id}'),
@ -258,12 +262,13 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
});
},
title: Text(
customer?.name ?? '⟨Unbekannter Kunde⟩',
recipient.name,
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(
delivery.deliveryAddressSnapshot.oneLine,
style: const TextStyle(fontSize: 12),
subtitle: RecipientSubtitle(
recipient: recipient,
ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
addressStyle: const TextStyle(fontSize: 12),
),
controlAffinity: ListTileControlAffinity.leading,
);
@ -289,6 +294,7 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
final isOwn = delivery.assignedCarId == widget.selectedCarId;
final plate = _plateFor(delivery.assignedCarId);
final customer = details.customerOf(delivery);
final recipient = details.recipientOf(delivery);
return Material(
color: isOwn
@ -310,15 +316,16 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
: theme.colorScheme.onSurfaceVariant,
),
title: Text(
customer?.name ?? '⟨Unbekannter Kunde⟩',
recipient.name,
style: TextStyle(
fontWeight: FontWeight.w600,
color: isOwn ? theme.colorScheme.primary : null,
),
),
subtitle: Text(
delivery.deliveryAddressSnapshot.oneLine,
style: const TextStyle(fontSize: 12),
subtitle: RecipientSubtitle(
recipient: recipient,
ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
addressStyle: const TextStyle(fontSize: 12),
),
trailing: _plateBadge(context, plate, own: isOwn),
),
@ -370,18 +377,26 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
return BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
if (state is TourLoadFailed) {
return const DeliveryLoadingFailedPage();
// Mit Scaffold + Drawer: die Fehlerseite (inkl. „Erneut versuchen")
// stand bisher nackt ohne Material-Rahmen da, und der Fahrer kam
// nicht an Einstellungen/Logout.
return Scaffold(
drawer: const HomeAppDrawer(),
appBar: AppBar(title: const Text('Lieferungen auswählen')),
body: const DeliveryLoadingFailedPage(),
);
}
if (state is TourEmpty) {
return Scaffold(
drawer: const HomeAppDrawer(),
appBar: AppBar(title: const Text('Lieferungen auswählen')),
body: const Center(
body: Center(
child: Padding(
padding: EdgeInsets.all(24),
padding: const EdgeInsets.all(24),
child: Text(
'Für heute ist keine Tour zugewiesen.',
style: TextStyle(fontSize: 16),
'Für ${TourDayText.of(context.watch<TourDateCubit>().state).afterFor} '
'ist keine Tour zugewiesen.',
style: const TextStyle(fontSize: 16),
textAlign: TextAlign.center,
),
),

View File

@ -11,8 +11,12 @@ import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart';
import 'package:hl_lieferservice/feature/delivery/overview/widget/sortable_delivery_list.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_fail_page.dart';
import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart';
import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart';
import 'package:hl_lieferservice/widget/orderer_hint.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_date_cubit.dart';
import 'package:hl_lieferservice/feature/delivery/model/tour_day_text.dart';
/// Page für die zweite Phase des Lieferprozesses (Sortieren). Der Fahrer
/// legt per Drag&Drop die Reihenfolge fest, ändert lokal so oft er möchte
@ -140,25 +144,27 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
}
Widget _emptyState() {
final day = TourDayText.of(context.watch<TourDateCubit>().state);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.inbox_outlined, size: 64, color: Colors.grey),
const SizedBox(height: 12),
Text(
'Keine Lieferungen heute',
'Keine Lieferungen ${day.when}',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 6),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 32),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
'Für das ausgewählte Fahrzeug sind heute keine Lieferungen geplant.',
'Für das ausgewählte Fahrzeug sind ${day.when} keine '
'Lieferungen geplant.',
textAlign: TextAlign.center,
),
),
const SizedBox(height: 20),
// Erneut die heutige Tour vom Backend laden. `LoadTour` zeigt
// Erneut die Tour des gewählten Tages vom Backend laden. `LoadTour` zeigt
// währenddessen den Lade-Branch (Spinner) und landet danach wieder
// hier (TourEmpty) oder in der sortierbaren Lieferungsliste.
FilledButton.tonalIcon(
@ -172,6 +178,7 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
Widget _singleDeliveryHint(Delivery single, TourDetails details) {
final customer = details.customerOf(single);
final recipient = details.recipientOf(single);
return Column(
children: [
_hintCard(
@ -190,10 +197,11 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
),
),
),
title: Text(customer?.name ?? '⟨Unbekannter Kunde⟩'),
subtitle: Text(
single.deliveryAddressSnapshot.oneLine,
style: const TextStyle(fontSize: 11),
title: Text(recipient.name),
subtitle: RecipientSubtitle(
recipient: recipient,
ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
addressStyle: const TextStyle(fontSize: 11),
),
),
],
@ -251,6 +259,25 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
body: _emptyState(),
);
}
if (state is TourLoadFailed) {
// Laden gescheitert: Fehlerseite mit „Erneut versuchen" im selben
// Rahmen (Header + Drawer). Vorher fiel dieser Zustand in den
// Lade-Zweig darunter → Endlos-Spinner ohne Ausweg.
return Scaffold(
drawer: const HomeAppDrawer(),
appBar: PreferredSize(
preferredSize: const Size.fromHeight(140),
child: PhaseStepper(
currentPhase: DeliveryPhase.sortieren,
carId: widget.selectedCarId,
),
),
body: const SafeArea(
top: false,
child: DeliveryLoadingFailedPage(),
),
);
}
if (state is! TourLoaded) {
// Lade-Zwischenzustand (auch beim 'Neu laden') mit derselben
// PhaseStepper-AppBar — sonst flackert die Leiste kurz auf die

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
import 'package:hl_lieferservice/widget/orderer_hint.dart';
/// Drag&Drop-Liste der heutigen Lieferungen eines Fahrzeugs.
///
@ -100,6 +101,7 @@ class _SortableDeliveryListState extends State<SortableDeliveryList> {
);
}
final customer = widget.details.customerOf(delivery);
final recipient = widget.details.recipientOf(delivery);
return ListTile(
key: Key('reorder-item-${delivery.id}'),
leading: CircleAvatar(
@ -111,10 +113,11 @@ class _SortableDeliveryListState extends State<SortableDeliveryList> {
),
),
),
title: Text(customer?.name ?? '⟨Unbekannter Kunde⟩'),
subtitle: Text(
delivery.deliveryAddressSnapshot.oneLine,
style: const TextStyle(fontSize: 11),
title: Text(recipient.name),
subtitle: RecipientSubtitle(
recipient: recipient,
ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
addressStyle: const TextStyle(fontSize: 11),
),
trailing: const Icon(Icons.drag_handle),
);

View File

@ -9,6 +9,7 @@ import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_detail_page.dart';
import 'package:hl_lieferservice/widget/scanner/article_scanner_stripe.dart';
import 'package:hl_lieferservice/widget/scanner/item_matcher.dart';
import 'package:hl_lieferservice/widget/scanner/manual_entry_dialog.dart';
@ -26,9 +27,10 @@ import 'package:hl_lieferservice/widget/scanner/scan_code_parser.dart';
/// QR-Validierung (`Artikelnr;Kundennr;Belegnr`) und derselbe `ScanItem`-
/// Pfad wie die Beladen-Phase — über die geteilten Scanner-Module.
///
/// Nach vollständigem Scan: Erfolgs-Zustand + Button „zurück zur Übersicht".
/// Der Fahrer fährt dann zum Kunden; dort öffnet ein erneuter Tap die
/// eigentliche Auslieferung (`DeliveryDetail`).
/// Nach vollständigem Scan: Erfolgs-Zustand + Button „Weiter zur Lieferung",
/// der diese Page durch die eigentliche Auslieferung (`DeliveryDetail`)
/// ersetzt. „Zurück" führt von dort direkt zur Übersicht, nicht erneut
/// hierher.
class FilialePickupScanPage extends StatefulWidget {
const FilialePickupScanPage({super.key, required this.deliveryId});
@ -160,6 +162,14 @@ class _FilialePickupScanPageState extends State<FilialePickupScanPage> {
));
}
void _openDelivery() {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => DeliveryDetail(deliveryId: widget.deliveryId),
),
);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<TourBloc, TourState>(
@ -180,7 +190,7 @@ class _FilialePickupScanPageState extends State<FilialePickupScanPage> {
);
}
final customer = details.customerOf(delivery);
final recipient = details.recipientOf(delivery);
final externalItems = _externalItems(delivery, details);
final doneCount = externalItems.where((it) => it.isDone).length;
final allDone = externalItems.isNotEmpty && doneCount == externalItems.length;
@ -217,7 +227,7 @@ class _FilialePickupScanPageState extends State<FilialePickupScanPage> {
),
children: [
_Header(
customerName: customer?.name ?? '⟨Unbekannter Kunde⟩',
customerName: recipient.name,
belegnummer: delivery.erpBelegnummer,
doneCount: doneCount,
total: externalItems.length,
@ -232,7 +242,7 @@ class _FilialePickupScanPageState extends State<FilialePickupScanPage> {
],
),
),
if (allDone) _DoneBar(onConfirm: () => Navigator.of(context).pop()),
if (allDone) _DoneBar(onConfirm: _openDelivery),
],
),
);
@ -445,8 +455,8 @@ class _DoneBar extends StatelessWidget {
width: double.infinity,
child: FilledButton.icon(
onPressed: onConfirm,
icon: const Icon(Icons.arrow_back),
label: const Text('Fertig — zurück zur Übersicht'),
icon: const Icon(Icons.arrow_forward),
label: const Text('Weiter zur Lieferung'),
),
),
],

View File

@ -1,46 +0,0 @@
/// Globale, statische Feature-Schalter.
///
/// Dient als Übergangs-Geländer während der Migration vom alten
/// ERPframe-Backend auf das neue Rust-Backend: Funktionen, die im neuen
/// Backend (noch) nicht modelliert sind — Rabatte, Zahlungsoptionen,
/// flexible Lieferoptionen, Preisanzeigen, Unterschriften-Upload —
/// werden hier gebündelt ausgeschaltet, statt sie in jedem UI-Widget
/// einzeln auszukommentieren.
///
/// **Konvention**: jeder Flag bekommt einen kurzen Kommentar, *warum*
/// er gerade auf `false` steht und in welcher Phase der Migration
/// das gegebenenfalls wieder geöffnet wird. So bleibt nachvollziehbar,
/// was hier nur „pausiert" und nicht „weg" ist.
class FeatureFlags {
const FeatureFlags._();
/// Rabatt/Gutschrift-Funktion in der Detail-Ansicht.
/// Backend-Modell fehlt — nicht Teil der Logistik-Migration. Wird
/// frühestens nach C+D-2 wiedereröffnet, wenn überhaupt jemals.
static const bool discountsEnabled = false;
/// Auswahl der Zahlungsart (Bar/EC/Vorkasse) am Ende der Lieferung.
/// Backend modelliert das nicht; die Logistik-App soll bewusst keinen
/// Zahlungs-Workflow tragen.
static const bool paymentsEnabled = false;
/// Anzeige von Brutto-/Netto-Preisen und Vorauszahlung in der UI.
/// Backend liefert keine Preise — Logistik ≠ Buchhaltung.
static const bool pricesEnabled = false;
/// Konfigurierbare Lieferoptionen (Treppe, Anschluss, Altgerät, …).
/// Backend-Schema noch nicht vorhanden; geplant für Phase E.
static const bool deliveryOptionsEnabled = false;
/// Fahrer- und Kunden-Signatur beim Abschluss einer Lieferung. Verkabelt:
/// `SignatureView` → `CompleteDelivery` → multipart `/complete` (Signaturen
/// liegen lokal im Backend-Server).
static const bool signaturesEnabled = true;
/// Eingabeart der Betrags-Gutschrift im Artikel-Step.
/// `false` → freies Betrags-Textfeld (Default); `true` → der ursprüngliche
/// +/−-Stepper (10-€-Schritte). Hinter dem Flag versteckt, falls der
/// Stepper wieder gewünscht wird. In beiden Fällen gilt die Backend-Regel:
/// >0, ≤150 €, Vielfaches von 10 €.
static const bool discountAmountStepper = false;
}

View File

@ -0,0 +1,20 @@
/// Alle schaltbaren Features der App.
///
/// [key] ist der stabile Bezeichner in `assets/feature_flags.json`, über den
/// ein Feature aktiviert oder deaktiviert wird. Der Wert selbst steht nie im
/// Code — nur, was gilt, falls ein Key in der Datei fehlt ([defaultEnabled]).
enum Feature {
/// Abschnitt „Gutschriften" (Betrags-Gutschrift) im Artikel-Step.
articlesCreditSection('articles.credit_section', defaultEnabled: true),
/// Betrags-Gutschrift per +/−-Stepper statt freiem Textfeld.
articlesCreditAmountStepper(
'articles.credit_amount_stepper',
defaultEnabled: false,
);
const Feature(this.key, {required this.defaultEnabled});
final String key;
final bool defaultEnabled;
}

View File

@ -0,0 +1,45 @@
import 'package:flutter/foundation.dart';
import 'feature.dart';
/// Unveränderlicher Satz an Feature-Schaltern, geladen aus
/// `assets/feature_flags.json` (siehe [FeatureFlagsRepository]).
///
/// Erwartetes Format:
/// ```json
/// { "features": { "<key>": { "enabled": true, "description": "…" } } }
/// ```
/// `description` ist nur Doku für Menschen. Unbekannte Keys werden
/// ignoriert, fehlende fallen auf [Feature.defaultEnabled] zurück.
@immutable
class FeatureFlags {
const FeatureFlags(this._enabledByKey);
factory FeatureFlags.fromJson(Map<String, dynamic> json) {
final features = json['features'];
if (features is! Map<String, dynamic>) {
throw const FormatException('feature_flags.json: "features" fehlt.');
}
final knownKeys = {for (final f in Feature.values) f.key};
final enabledByKey = <String, bool>{};
features.forEach((key, value) {
final enabled = value is Map<String, dynamic> ? value['enabled'] : null;
if (enabled is! bool) {
throw FormatException(
'feature_flags.json: "$key.enabled" muss true oder false sein.',
);
}
if (!knownKeys.contains(key)) {
debugPrint('FeatureFlags: unbekannter Key "$key" wird ignoriert.');
return;
}
enabledByKey[key] = enabled;
});
return FeatureFlags(enabledByKey);
}
final Map<String, bool> _enabledByKey;
bool isEnabled(Feature feature) =>
_enabledByKey[feature.key] ?? feature.defaultEnabled;
}

View File

@ -0,0 +1,20 @@
import 'dart:convert';
import 'package:flutter/services.dart' show rootBundle;
import 'feature_flags.dart';
/// Lädt die Feature-Schalter aus der mitgelieferten Asset-Datei.
class FeatureFlagsRepository {
const FeatureFlagsRepository({this.assetPath = 'assets/feature_flags.json'});
final String assetPath;
Future<FeatureFlags> load() async {
final json = jsonDecode(await rootBundle.loadString(assetPath));
if (json is! Map<String, dynamic>) {
throw const FormatException('feature_flags.json ist kein JSON-Objekt.');
}
return FeatureFlags.fromJson(json);
}
}

View File

@ -0,0 +1,22 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'feature.dart';
import 'feature_flags.dart';
/// Zeigt [child] nur, wenn [feature] aktiviert ist — sonst nichts (kein
/// Platzhalter, kein Abstand). Die Schalter kommen aus dem [FeatureFlags],
/// das oberhalb per `RepositoryProvider` bereitsteht.
class FeatureGate extends StatelessWidget {
const FeatureGate({super.key, required this.feature, required this.child});
final Feature feature;
final Widget child;
@override
Widget build(BuildContext context) {
return context.read<FeatureFlags>().isEnabled(feature)
? child
: const SizedBox.shrink();
}
}

View File

@ -23,6 +23,7 @@ import 'package:hl_lieferservice/widget/scanner/article_scanner_stripe.dart';
import 'package:hl_lieferservice/widget/scanner/item_matcher.dart';
import 'package:hl_lieferservice/widget/scanner/manual_entry_dialog.dart';
import 'package:hl_lieferservice/widget/scanner/scan_code_parser.dart';
import 'package:hl_lieferservice/widget/orderer_hint.dart';
/// Vollbild-Sicht eines Kunden in der Beladen-Phase mit aktivem Scanner.
///
@ -523,6 +524,7 @@ class _CustomerBody extends StatelessWidget {
@override
Widget build(BuildContext context) {
final customer = details.customerOf(delivery);
final recipient = details.recipientOf(delivery);
// Items werden vom Aggregat-Helper schon nach Lager gruppiert
// geliefert: Standardlager zuerst, danach Filiale alphabetisch.
// Nicht-scanbare Positionen und `removed`-Items sind dabei schon
@ -611,7 +613,7 @@ class _CustomerBody extends StatelessWidget {
// Kunden" wird dadurch räumlich klar.
child: Row(
children: [
_CustomerAvatar(customer: customer),
_CustomerAvatar(customer: customer, name: recipient.name),
const SizedBox(width: 12),
Expanded(
child: Column(
@ -630,14 +632,18 @@ class _CustomerBody extends StatelessWidget {
),
const SizedBox(height: 2),
Text(
customer?.name ?? '⟨Unbekannter Kunde⟩',
recipient.name,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 4),
Text(
delivery.deliveryAddressSnapshot.oneLine,
recipient.address.oneLineWithAddition,
style: Theme.of(context).textTheme.bodyMedium,
),
if (recipient.differsFromOrderer)
OrdererHint(
ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
),
if (delivery.state != DeliveryState.active) ...[
const SizedBox(height: 8),
_DeliveryStateBadge(delivery: delivery),
@ -987,12 +993,13 @@ class _NothingToLoadHint extends StatelessWidget {
///
/// Farbe ist deterministisch aus der `customer.id` abgeleitet, damit
/// Re-Builds und Page-Wechsel den Avatar nicht „flackern" lassen.
/// Initialen kommen aus dem Namen — Vor- und Zunamen kombiniert,
/// einzelne Worte mit einem Buchstaben.
/// Initialen kommen aus dem angezeigten Namen (Empfänger vor Ort) — Vor-
/// und Zunamen kombiniert, einzelne Worte mit einem Buchstaben.
class _CustomerAvatar extends StatelessWidget {
const _CustomerAvatar({required this.customer});
const _CustomerAvatar({required this.customer, required this.name});
final Customer? customer;
final String name;
/// Kleine kuratierte Palette — kräftig genug zum Erkennen, aber nicht
/// schreiend. Reihenfolge ist Absicht: die ersten Farben fallen am
@ -1009,9 +1016,9 @@ class _CustomerAvatar extends StatelessWidget {
];
String get _initials {
final name = customer?.name.trim() ?? '';
if (name.isEmpty) return '?';
final parts = name
final trimmed = name.trim();
if (trimmed.isEmpty) return '?';
final parts = trimmed
.split(RegExp(r'\s+'))
.where((p) => p.isNotEmpty)
.toList(growable: false);

View File

@ -17,6 +17,9 @@ import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery
import 'package:hl_lieferservice/feature/loading/presentation/loading_customer_page.dart';
import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart';
import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart';
import 'package:hl_lieferservice/widget/orderer_hint.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_date_cubit.dart';
import 'package:hl_lieferservice/feature/delivery/model/tour_day_text.dart';
/// Übersichts-Ansicht für die Beladen-Phase: alle Kunden mit ihren
/// Artikeln und Soll/Ist-Mengen.
@ -64,9 +67,6 @@ class LoadingOverviewPage extends StatelessWidget {
return BlocBuilder<TourBloc, TourState>(
builder: (context, tourState) {
if (tourState is TourLoadFailed) {
return const DeliveryLoadingFailedPage();
}
// Auch ohne/vor geladener Tour den Phasen-Header zeigen — sonst
// verschwinden Kopf-Kalender, Menü und Reload hinter einer nackten
// 'Beladung'-AppBar und der Fahrer kommt an einem leeren Tag nicht
@ -78,6 +78,18 @@ class LoadingOverviewPage extends StatelessWidget {
carId: carId,
),
);
if (tourState is TourLoadFailed) {
// Fehlerseite (mit „Erneut versuchen") im selben Rahmen wie die
// übrigen Zustände — vorher ohne Scaffold/Header/Drawer.
return Scaffold(
drawer: const HomeAppDrawer(),
appBar: stepperHeader,
body: const SafeArea(
top: false,
child: DeliveryLoadingFailedPage(),
),
);
}
if (tourState is TourEmpty) {
return Scaffold(
drawer: const HomeAppDrawer(),
@ -397,6 +409,7 @@ class _OverviewTile extends StatelessWidget {
final scannedAnyStandard =
standardItems.any((it) => it.scanProgress.scannedQuantity > 0);
final customer = details.customerOf(delivery);
final recipient = details.recipientOf(delivery);
Color cardColor;
Color borderColor;
@ -533,7 +546,7 @@ class _OverviewTile extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
customer?.name ?? '⟨Unbekannter Kunde⟩',
recipient.name,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
@ -545,12 +558,16 @@ class _OverviewTile extends StatelessWidget {
),
const SizedBox(height: 2),
Text(
delivery.deliveryAddressSnapshot.oneLine,
recipient.address.oneLineWithAddition,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
if (recipient.differsFromOrderer)
OrdererHint(
ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
),
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
@ -869,6 +886,7 @@ class _EmptyOverview extends StatelessWidget {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final day = TourDayText.of(context.watch<TourDateCubit>().state);
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
@ -881,7 +899,7 @@ class _EmptyOverview extends StatelessWidget {
),
const SizedBox(height: 4),
Text(
'Für heute ist aktuell keine Tour verfügbar.',
'Für ${day.afterFor} ist aktuell keine Tour verfügbar.',
style: Theme.of(context)
.textTheme
.bodySmall

View File

@ -0,0 +1,148 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/bloc/app_bloc.dart';
import 'package:hl_lieferservice/bloc/app_events.dart';
import 'package:hl_lieferservice/bloc/app_states.dart';
import 'package:hl_lieferservice/data/network/backend_environment.dart';
/// Einstellungen vor dem Login: Auswahl der Server-Umgebung.
///
/// Erreichbar über das Zahnrad im Login-Screen. Die Umgebungen stammen
/// aus `assets/hl_server_config.json`; die Wahl persistiert der
/// [AppBloc]. Nach dem Antippen einer anderen Umgebung schließt sich die
/// Seite und die App baut sich gegen den neuen Server neu auf.
class EnvironmentSettingsPage extends StatelessWidget {
const EnvironmentSettingsPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Einstellungen')),
body: BlocBuilder<AppBloc, AppState>(
builder: (context, state) {
if (state is! AppConfigLoaded) {
return const Center(child: CircularProgressIndicator());
}
final catalog = state.catalog;
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text('Umgebung', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'Mit welchem Server sich die App verbindet.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
for (final env in catalog.environments)
_EnvironmentCard(
environment: env,
selected: env.id == state.active.id,
isDefault: env.id == catalog.defaultEnvironmentId,
onTap: () => _select(context, state.active, env),
),
],
);
},
),
);
}
void _select(
BuildContext context,
BackendEnvironment active,
BackendEnvironment picked,
) {
final appBloc = context.read<AppBloc>();
Navigator.of(context).pop();
if (picked.id != active.id) {
appBloc.add(AppSwitchEnvironment(environmentId: picked.id));
}
}
}
class _EnvironmentCard extends StatelessWidget {
const _EnvironmentCard({
required this.environment,
required this.selected,
required this.isDefault,
required this.onTap,
});
final BackendEnvironment environment;
final bool selected;
final bool isDefault;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Card(
margin: const EdgeInsets.only(bottom: 12),
clipBehavior: Clip.antiAlias,
elevation: selected ? 2 : 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: selected ? scheme.primary : scheme.outlineVariant,
width: selected ? 2 : 1,
),
),
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Icon(
Icons.dns_outlined,
color: selected ? scheme.primary : scheme.onSurfaceVariant,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
environment.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
if (isDefault) ...[
const SizedBox(width: 8),
Text(
'Standard',
style: TextStyle(
fontSize: 12,
color: scheme.onSurfaceVariant,
),
),
],
],
),
const SizedBox(height: 2),
Text(
environment.hostAndPort,
style: TextStyle(
fontFamily: 'monospace',
color: scheme.onSurfaceVariant,
),
),
],
),
),
if (selected) Icon(Icons.check_circle, color: scheme.primary),
],
),
),
),
);
}
}

View File

@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:hl_lieferservice/data/network/backend_config.dart';
import 'package:hl_lieferservice/main.dart' show locator;
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart';
import 'package:hl_lieferservice/feature/settings/bloc/settings_bloc.dart';
@ -64,7 +65,7 @@ class _SettingsPage extends State<SettingsPage> {
/// Externer Browser statt In-App-WebView, damit ggf. im Browser
/// gespeicherte Credentials / Authenticator-Apps weiter funktionieren.
Future<void> _openAccountConsole() async {
final issuer = BackendConfig.fromEnvironment.keycloakIssuerUrl;
final issuer = locator<BackendConfig>().keycloakIssuerUrl;
final uri = Uri.parse('$issuer/account/');
final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
if (!ok && mounted) {

View File

@ -4,6 +4,7 @@ import 'package:get_it/get_it.dart';
import 'package:hl_lieferservice/bloc/app_bloc.dart';
import 'package:hl_lieferservice/bloc/app_events.dart';
import 'package:hl_lieferservice/data/cache/attachment_cache.dart';
import 'package:hl_lieferservice/data/network/environment_repository.dart';
import 'package:hl_lieferservice/data/network/network_locator.dart';
import 'package:hl_lieferservice/feature/settings/bloc/settings_bloc.dart';
import 'package:hl_lieferservice/feature/settings/bloc/settings_event.dart';
@ -12,19 +13,23 @@ import 'package:hl_lieferservice/widget/app.dart';
final locator = GetIt.instance;
void main() {
// Backend-Migration Phase A: HTTP-Stack + Token-Provider registrieren,
// bevor irgendein Bloc starten kann. Wirft eine Compile-Sicherheit
// hin, dass `HolzleitnerApi` ab hier per `locator<HolzleitnerApi>()`
// verfügbar ist.
registerNetworking(locator: locator);
// Das Networking (HTTP-Stack + Token-Provider) registriert der AppBloc,
// sobald die Umgebung aus `assets/hl_server_config.json` feststeht. Alle
// Konsumenten von `locator<HolzleitnerApi>()` entstehen erst danach
// (Bloc-Baum wird erst bei `AppConfigLoaded` gebaut).
// Persistenter Vorschau-Cache für Attachment-Bilder. Über die gesamte
// App-Lebensdauer stabil und zustandslos (das Verzeichnis löst er lazy
// selbst auf), daher hier als Singleton.
locator.registerSingleton<AttachmentCache>(AttachmentCache());
runApp(MultiBlocProvider(providers: [
BlocProvider(create: (context) => AppBloc(),),
BlocProvider(
create: (context) => AppBloc(
repository: EnvironmentRepository(),
activate: (config) =>
registerNetworking(locator: locator, config: config),
),
),
BlocProvider(create: (context) => SettingsBloc())
], child: App()));
}

View File

@ -1,30 +1,21 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/bloc/app_bloc.dart';
import 'package:hl_lieferservice/data/cache/attachment_cache.dart';
import 'package:hl_lieferservice/data/network/keycloak_oidc_token_provider.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart';
import 'package:hl_lieferservice/feature/authentication/presentation/login_enforcer.dart';
import 'package:hl_lieferservice/main.dart' show locator;
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/presentation/car_selection_enforcer.dart';
import 'package:hl_lieferservice/feature/car_selection/repository/car_selection_repository.dart';
import 'package:hl_lieferservice/data/repository/cars_repository_impl.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart';
import 'package:hl_lieferservice/feature/cars/presentation/car_management_page.dart';
import 'package:hl_lieferservice/data/repository/payment_methods_repository_impl.dart';
import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart';
import 'package:holzleitner_api/holzleitner_api.dart' show HolzleitnerApi;
import 'package:hl_lieferservice/data/repository/tour_repository_impl.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_date_cubit.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
import 'package:hl_lieferservice/feature/feature_flags/feature_flags.dart';
import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
import 'package:hl_lieferservice/widget/operations/presentation/operation_view_enforcer.dart';
import 'package:hl_lieferservice/widget/session_scope.dart';
import 'package:hl_lieferservice/bloc/app_states.dart';
import 'home/presentation/home.dart';
@ -43,7 +34,14 @@ class _DeliveryAppState extends State<DeliveryApp> {
builder: (context, state) {
if (state is AppConfigLoaded) {
return MultiBlocProvider(
// Pro Server-Umgebung ein eigener Baum: Beim Wechsel entstehen
// alle Blocs neu und holen sich den frisch registrierten
// API-Client. Kein Bloc hält Daten der alten Umgebung.
key: ValueKey('environment-${state.active.id}'),
providers: [
// Feature-Schalter aus `assets/feature_flags.json`; Widgets lesen
// sie über `FeatureGate` bzw. `context.read<FeatureFlags>()`.
RepositoryProvider<FeatureFlags>.value(value: state.featureFlags),
BlocProvider(create: (context) => NavigationBloc()),
BlocProvider(create: (context) => OperationBloc()),
BlocProvider(
@ -58,58 +56,6 @@ class _DeliveryAppState extends State<DeliveryApp> {
// und ggf. direkt einloggen.
..add(const RestoreSessionRequested()),
),
BlocProvider(
// Phase-C+D-2-Migration: produktive TourRepository-Impl
// gegen das generierte Rust-Backend-API. Account-Filter
// serverseitig aus dem JWT, deshalb braucht der Bloc
// keinen AuthBloc-Bezug mehr.
create: (context) => TourBloc(
tourRepository: TourRepositoryImpl(locator<HolzleitnerApi>()),
opBloc: context.read<OperationBloc>(),
attachmentCache: locator<AttachmentCache>(),
),
),
BlocProvider(
// Hält das im Header gewählte Tour-Datum (null = heute).
// Steuert, welchen Tag der TourBloc lädt.
create: (context) => TourDateCubit(),
),
BlocProvider(
create: (context) =>
CarSelectBloc(repository: CarSelectionRepository()),
),
BlocProvider(
// Phase-D-Migration: produktive CarsRepository-Impl
// gegen das generierte Rust-Backend-API. Account-Filter
// serverseitig aus dem JWT, deshalb braucht der Bloc
// keinen AuthBloc-Bezug mehr.
create: (context) => CarsBloc(
repository: CarsRepositoryImpl(locator<HolzleitnerApi>()),
opBloc: context.read<OperationBloc>(),
),
),
BlocProvider(
// PhaseBloc liest die Team-Fahrzeug-Anzahl jetzt direkt
// aus dem CarsBloc — der ist die alleinige Quelle der
// Fahrzeug-Stammdaten. Beim ersten Load eines Fahrzeugs
// bestimmt das die Eintrittsphase (Auswählen vs. Sortieren).
create: (context) => PhaseBloc(
carCountResolver: () {
final carsState = context.read<CarsBloc>().state;
return carsState is CarsLoaded
? carsState.cars.length
: null;
},
// Bindet die persistierten Phasen-Häkchen an die aktuelle
// Tour-Version (Tour.syncedAt). Ein erneuter Sync/Seed
// schreibt eine neue syncedAt → neuer Token → frische
// Phasen, ohne dass alte lokale Häkchen hängen bleiben.
// Ableitung zentral in `TourStatePhaseToken` (tour_state.dart),
// dieselbe Quelle wie `Home._ensurePhaseLoaded`.
tourTokenResolver: () =>
context.read<TourBloc>().state.phaseToken,
),
),
BlocProvider(
// Zahlungsmethoden sind firmenweite Stammdaten — wir laden
// sie einmal beim App-Start und cachen sie im Cubit. Der
@ -121,6 +67,10 @@ class _DeliveryAppState extends State<DeliveryApp> {
)..load(),
),
],
// Fahrerbezogene Blocs (Tour, Fahrzeuge, Auswahl, Phase) je
// Anmeldung neu — siehe SessionScope.
child: SessionScope(
environmentId: state.active.id,
child: MaterialApp(
title: 'Holzleitner Auslieferung',
debugShowCheckedModeBanner: false,
@ -142,6 +92,7 @@ class _DeliveryAppState extends State<DeliveryApp> {
}
if (state is AppConfigLoaded) {
// Zahlungsmethoden lädt SessionScope nach jeder Anmeldung neu.
return LoginEnforcer(
child: CarSelectionEnforcer(child: Home()),
);
@ -152,6 +103,7 @@ class _DeliveryAppState extends State<DeliveryApp> {
),
routes: {"/cars": (context) => CarManagementPage()},
),
),
);
}
@ -160,7 +112,12 @@ class _DeliveryAppState extends State<DeliveryApp> {
title: 'Holzleitner Auslieferung',
debugShowCheckedModeBanner: false,
home: Scaffold(
body: Center(child: Text("Fehler beim Laden der Konfiguration")),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(state.message, textAlign: TextAlign.center),
),
),
),
);
}

View File

@ -64,6 +64,14 @@ class _AttachmentImageState extends State<AttachmentImage> {
}
}
/// Stößt den Download nach einem Fehler erneut an (Tipp auf die
/// Fehleranzeige). Ein fehlgeschlagener Bild-Load ist kein Endzustand.
void _retry() {
setState(() {
_future = _load();
});
}
static const _ext = 'jpeg';
Future<Uint8List> _load() async {
@ -133,13 +141,43 @@ class _AttachmentImageState extends State<AttachmentImage> {
if (snapshot.hasError ||
snapshot.data == null ||
snapshot.data!.isEmpty) {
// Laden fehlgeschlagen (Netz/VPN/Server): Tippen lädt erneut.
// Bewusst GestureDetector statt InkWell — das Widget wird auch
// ohne Material-Vorfahren (Vollbild-Ansicht) verwendet.
return Center(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _retry,
child: Padding(
padding: const EdgeInsets.all(16),
child: Icon(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.broken_image_outlined,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.refresh,
size: 14,
color: theme.colorScheme.primary,
),
const SizedBox(width: 4),
Text(
'Erneut laden',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
),
),
],
),
],
),
),
),
);
}

View File

@ -5,6 +5,7 @@ import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_event.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart';
import 'package:hl_lieferservice/feature/cars/presentation/car_fail_page.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_state.dart';
@ -12,6 +13,8 @@ import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_date_cubit.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_fail_page.dart';
import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart';
import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_overview_page.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_selection_page.dart';
@ -178,6 +181,29 @@ class _HomeState extends State<Home> {
// Spinner zeigen — das dauert in der Praxis maximal
// ein paar Frames.
if (phase == null) {
// Start-Sackgasse vermeiden: die Phase wird erst geladen,
// wenn Tour UND Fahrzeuge da sind. Scheiterte einer der
// beiden Requests, drehte hier bisher endlos der Spinner.
// Jetzt: Fehlerseite mit „Erneut versuchen" (+ Drawer für
// Einstellungen/Logout).
if (tourState is TourLoadFailed) {
return Scaffold(
drawer: const HomeAppDrawer(),
appBar: AppBar(),
body: const SafeArea(
child: DeliveryLoadingFailedPage(),
),
);
}
// `watch`: dieser Builder soll auch auf CarsBloc-Wechsel
// reagieren (Failed ↔ Loading ↔ Loaded beim Retry).
if (context.watch<CarsBloc>().state is CarsLoadingFailed) {
return Scaffold(
drawer: const HomeAppDrawer(),
appBar: AppBar(),
body: const SafeArea(child: CarsLoadingFailedPage()),
);
}
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);

View File

@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:hl_lieferservice/domain/entity/delivery_recipient.dart';
/// Kompakter Hinweis in Listen-Kacheln, wenn an eine andere Person oder
/// Anschrift geliefert wird als an den Besteller: „Bestellt von: <Name>".
///
/// Geteilt zwischen Beladen-, Sortier- und Auslieferungs-Listen, damit der
/// Fahrer überall gleich erkennt, dass Empfänger ≠ Kunde ist.
class OrdererHint extends StatelessWidget {
const OrdererHint({super.key, required this.ordererName});
final String ordererName;
@override
Widget build(BuildContext context) {
final color = Colors.deepOrange.shade800;
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 1),
child: Icon(Icons.alt_route, size: 14, color: color),
),
const SizedBox(width: 4),
Expanded(
child: Text(
'Bestellt von: $ordererName',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
),
),
),
],
),
);
}
}
/// `ListTile.subtitle` für Lieferungen: Lieferanschrift und, falls der
/// Empfänger vom Besteller abweicht, darunter der [OrdererHint].
class RecipientSubtitle extends StatelessWidget {
const RecipientSubtitle({
super.key,
required this.recipient,
required this.ordererName,
this.addressStyle,
});
final DeliveryRecipient recipient;
final String ordererName;
final TextStyle? addressStyle;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(recipient.address.oneLineWithAddition, style: addressStyle),
if (recipient.differsFromOrderer)
OrdererHint(ordererName: ordererName),
],
);
}
}

View File

@ -125,12 +125,19 @@ class PhaseStepper extends StatelessWidget {
final carCount = carsState is CarsLoaded ? carsState.cars.length : 0;
final phases = visiblePhases ?? _effectivePhases(carCount);
// Ohne geladene Tour (Tag ohne Tour / noch ladend) ist ausschließlich
// der erste Schritt aktiv: es gibt nichts zu beladen oder auszuliefern,
// also dürfen die weiteren Schritte weder anklickbar noch abgehakt
// sein — unabhängig davon, was der PhaseBloc als Max kennt.
final hasTour = context.watch<TourBloc>().state is TourLoaded;
// Höchste erreichte Phase aus dem PhaseBloc — bestimmt, welche
// Vorwärts-Sprünge erlaubt sind.
final phaseBlocState = context.watch<PhaseBloc>().state;
final maxReached = phaseBlocState is PhaseReady
final blocMax = phaseBlocState is PhaseReady
? (phaseBlocState.maxPhaseFor(carId) ?? currentPhase)
: currentPhase;
final maxReached = hasTour ? blocMax : phases.first;
return Material(
color: theme.primaryColor,
@ -425,7 +432,9 @@ class _SelectedDatePill extends StatelessWidget {
// Standard-Fenster um heute; falls das aktuell angezeigte Datum (z. B.
// ein Dev-Override in der Vergangenheit) außerhalb liegt, Fenster
// erweitern, damit initialDate immer im gültigen Bereich bleibt.
final firstDefault = today.subtract(const Duration(days: 14));
// 90 Tage zurück: vergangene Touren (z. B. zum Nachsehen oder nach
// einem Nach-Import) müssen erreichbar sein.
final firstDefault = today.subtract(const Duration(days: 90));
final lastDefault = today.add(const Duration(days: 60));
final firstDate = current.isBefore(firstDefault) ? current : firstDefault;
final lastDate = current.isAfter(lastDefault) ? current : lastDefault;
@ -477,7 +486,31 @@ class _ReloadButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<TourBloc, TourState>(
return BlocConsumer<TourBloc, TourState>(
// Fehlgeschlagener Hintergrund-Reload → Snackbar MIT Retry-Aktion.
// Zentral im gemeinsamen Header, damit es in ALLEN Phasen gilt (vorher
// nur auf der Ausliefern-Seite und ohne Retry; auf Sortieren/Beladen
// blieb der Fehler stumm). Nur bei neu gesetztem/geändertem Fehler
// feuern — nicht bei jedem Folge-Emit, solange der Fehler noch steht.
listenWhen: (prev, curr) =>
curr is TourLoaded &&
curr.refreshError != null &&
(prev is! TourLoaded || prev.refreshError != curr.refreshError),
listener: (context, state) {
if (state is! TourLoaded || state.refreshError == null) return;
final bloc = context.read<TourBloc>();
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(state.refreshError!),
action: SnackBarAction(
label: 'Erneut versuchen',
onPressed: () => bloc.add(const RefreshTour()),
),
),
);
},
// Nur neu rendern, wenn sich der Refresh-Status ändert — sonst
// läuft der Builder bei jedem Scan-Tick mit.
buildWhen: (prev, curr) {

View File

@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/data/cache/attachment_cache.dart';
import 'package:hl_lieferservice/data/repository/cars_repository_impl.dart';
import 'package:hl_lieferservice/data/repository/tour_repository_impl.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/repository/car_selection_repository.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_date_cubit.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart';
import 'package:hl_lieferservice/main.dart' show locator;
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
import 'package:holzleitner_api/holzleitner_api.dart' show HolzleitnerApi;
/// Fahrerbezogene Blocs — pro Anmeldung frisch.
///
/// Tour, gewähltes Datum, Fahrzeuge, Fahrzeugauswahl und Phase gehören zum
/// angemeldeten Fahrer. Sie leben unterhalb des [AuthBloc] und werden bei
/// JEDER Anmeldung (neuer Fahrer oder derselbe nach Abmelden/Session-Ablauf)
/// komplett neu angelegt: Nichts vom vorherigen Fahrer bleibt im Speicher,
/// alles wird neu vom Server geholt. Ein bloßer Token-Refresh
/// (Authenticated → Authenticated, gleicher Fahrer) zählt nicht als neue
/// Anmeldung.
///
/// [child] ist die `MaterialApp`, damit auch gepushte Routen (Lieferdetails,
/// Fahrzeugverwaltung) die Session-Blocs sehen. Eine neue Sitzung baut sie
/// deshalb mit neu auf — der Navigator startet wieder bei Home.
class SessionScope extends StatefulWidget {
const SessionScope({
super.key,
required this.environmentId,
required this.child,
});
/// Aktive Server-Umgebung; die Fahrzeugauswahl wird je Umgebung gespeichert.
final String environmentId;
final Widget child;
@override
State<SessionScope> createState() => _SessionScopeState();
}
class _SessionScopeState extends State<SessionScope> {
/// Zählt Anmeldungen; Teil des Keys, damit jede Anmeldung einen neuen
/// Bloc-Baum erzeugt.
int _session = 0;
int? _personalnummer;
bool _startsNewSession(AuthState previous, AuthState current) {
if (current is! Authenticated) return false;
return previous is! Authenticated ||
previous.personalnummer != current.personalnummer;
}
void _onAuthChanged(BuildContext context, AuthState state) {
final authenticated = state as Authenticated;
setState(() {
_session++;
_personalnummer = authenticated.personalnummer;
});
// Firmenweite Stammdaten liegen eine Ebene höher; ohne Session endet
// deren Start-Load in 401 — daher nach jeder Anmeldung neu laden.
context.read<PaymentMethodsCubit>().load();
}
@override
Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>(
listenWhen: _startsNewSession,
listener: _onAuthChanged,
child: MultiBlocProvider(
key: ValueKey('session-$_session-$_personalnummer'),
providers: [
BlocProvider(
// Account-Filter serverseitig aus dem JWT.
create: (context) => TourBloc(
tourRepository: TourRepositoryImpl(locator<HolzleitnerApi>()),
opBloc: context.read<OperationBloc>(),
attachmentCache: locator<AttachmentCache>(),
),
),
BlocProvider(
// Im Header gewähltes Tour-Datum (null = heute).
create: (context) => TourDateCubit(),
),
BlocProvider(
create: (context) => CarSelectBloc(
repository: CarSelectionRepository(
environmentId: widget.environmentId,
),
carsRepository: CarsRepositoryImpl(locator<HolzleitnerApi>()),
),
),
BlocProvider(
// Fahrzeuge des angemeldeten Accounts (aus dem JWT).
create: (context) => CarsBloc(
repository: CarsRepositoryImpl(locator<HolzleitnerApi>()),
opBloc: context.read<OperationBloc>(),
),
),
BlocProvider(
// Eintrittsphase hängt an der Fahrzeug-Anzahl (CarsBloc) und an
// der Tour-Version (Tour.syncedAt, siehe `TourStatePhaseToken`).
create: (context) => PhaseBloc(
carCountResolver: () {
final carsState = context.read<CarsBloc>().state;
return carsState is CarsLoaded ? carsState.cars.length : null;
},
tourTokenResolver: () =>
context.read<TourBloc>().state.phaseToken,
),
),
],
child: widget.child,
),
);
}
}

View File

@ -57,6 +57,154 @@
]
}
},
"/admin/belege/{belegnummer}": {
"get": {
"tags": [
"admin"
],
"summary": "Liefert das **volle Detail-Paket** zu einer ERP-Belegnummer: die Lieferung\nmit Positionen (inkl. Scan-St\u00e4nden), Kunde + Ansprechpartner, referenzierte\nArtikel/Lager, Notizen, Geld-Gutschrift, Dienstleistungswerte und Kontakte \u2014\ndestilliert aus dem Tour-Aggregat. Jede Lieferung, unabh\u00e4ngig vom Status.\n`404`, wenn die Belegnummer unbekannt ist.",
"operationId": "belege_details",
"parameters": [
{
"name": "belegnummer",
"in": "path",
"description": "ERP-Belegnummer, z. B. V-30690291",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Volle Lieferdetails",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeliveryDetails"
}
}
}
},
"401": {
"description": "Admin-API-Key fehlt/ung\u00fcltig"
},
"404": {
"description": "Belegnummer unbekannt"
}
},
"security": [
{
"admin_api_key": []
}
]
}
},
"/admin/belege/{belegnummer}/positions-modified": {
"get": {
"tags": [
"admin"
],
"summary": "Liefert zu **einer** ERP-Belegnummer, ob an der Lieferung Positionen\nver\u00e4ndert wurden (Menge reduziert/Zeile entfernt oder Geld-Gutschrift) \u2014\nunabh\u00e4ngig vom Zustand der Lieferung. `404`, wenn die Belegnummer unbekannt\nist. Gibt es mehrere Lieferungen mit derselben Belegnummer, ist das Flag\n`true`, sobald **eine** davon ver\u00e4ndert ist.",
"operationId": "positions_modified",
"parameters": [
{
"name": "belegnummer",
"in": "path",
"description": "ERP-Belegnummer, z. B. V-30690291",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "\u00c4nderungs-Flag der Belegpositionen",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PositionsModifiedResponse"
}
}
}
},
"401": {
"description": "Admin-API-Key fehlt/ung\u00fcltig"
},
"404": {
"description": "Belegnummer unbekannt"
}
},
"security": [
{
"admin_api_key": []
}
]
}
},
"/admin/completed-deliveries": {
"get": {
"tags": [
"admin"
],
"summary": "Liefert **alle** abgeschlossenen (ausgelieferten) Lieferungen \u2014 optional auf\neinen Datumsbereich eingegrenzt, unabh\u00e4ngig vom Mail-Versand-Status.\nGefiltert wird \u00fcber den **Berliner** Kalendertag des Abschluss-Zeitpunkts\n(`completed_at`).",
"description": "Parameter (alle `DD-MM-YYYY`, alle optional): `day` = Einzeltag (Kurzform\n`from=to=day`), sonst `from`/`to` als **inklusive** Bereichsgrenzen (je\noffen). **Ohne jeden Parameter \u2192 ALLE ausgelieferten Belege** (kein\nDatumsfilter). Pro Lieferung: Belegnummer + `positions_modified` (Menge\nreduziert/Zeile entfernt oder Geld-Gutschrift). Die Halb-Grenzen-Variante ist\nf\u00fcr Range-Filter gedacht: `?from=\u2026` und `?to=\u2026` liefern je eine Menge, deren\nSQL-`AND`-Schnitt den Zeitraum ergibt.",
"operationId": "completed_deliveries",
"parameters": [
{
"name": "day",
"in": "query",
"description": "Einzeltag DD-MM-YYYY (Kurzform from=to)",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "from",
"in": "query",
"description": "Untere Grenze DD-MM-YYYY (inklusive)",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "to",
"in": "query",
"description": "Obere Grenze DD-MM-YYYY (inklusive)",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Ausgelieferte Lieferungen (optional bereichsgefiltert)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CompletedDeliveriesResponse"
}
}
}
},
"400": {
"description": "Ung\u00fcltiges Datum"
},
"401": {
"description": "Admin-API-Key fehlt/ung\u00fcltig"
}
},
"security": [
{
"admin_api_key": []
}
]
}
},
"/admin/delivered-belegnummern": {
"get": {
"tags": [
@ -227,6 +375,87 @@
]
}
},
"/admin/reviews": {
"get": {
"tags": [
"admin"
],
"summary": "Listet alle ge\u00e4nderten Lieferscheine, die noch auf eine manuelle\nBest\u00e4tigung (Vier-Augen) warten \u2014 Entfernungen und/oder Geld-Gutschriften.",
"operationId": "list_reviews",
"responses": {
"200": {
"description": "Offene Pr\u00fcfungen",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PendingReviewResponse"
}
}
}
}
},
"401": {
"description": "Admin-API-Key fehlt/ung\u00fcltig"
}
},
"security": [
{
"admin_api_key": []
}
]
}
},
"/admin/reviews/{delivery_id}/resolve": {
"post": {
"tags": [
"admin"
],
"summary": "Best\u00e4tigt die Pr\u00fcfung einer ge\u00e4nderten Lieferung (Vier-Augen) \u2014 die\nLieferung verschwindet danach aus `GET /admin/reviews` (sofern nicht\nerneut ge\u00e4ndert).",
"operationId": "resolve_review",
"parameters": [
{
"name": "delivery_id",
"in": "path",
"description": "UUID der Lieferung",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ResolveReviewRequest"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "Pr\u00fcfung best\u00e4tigt"
},
"400": {
"description": "Ung\u00fcltige delivery_id / Bearbeiter leer"
},
"401": {
"description": "Admin-API-Key fehlt/ung\u00fcltig"
},
"404": {
"description": "Lieferung nicht gefunden"
}
},
"security": [
{
"admin_api_key": []
}
]
}
},
"/attachments/{id}": {
"get": {
"tags": [
@ -814,6 +1043,62 @@
]
}
},
"/deliveries/{delivery_id}/payment": {
"post": {
"tags": [
"deliveries"
],
"summary": "Protokolliert die Zahlungsabwicklung (\u201eAbkassieren\") einer Lieferung.\nAppend-only, idempotent \u00fcber `clientEventId`. Der Server berechnet den\noffenen Betrag selbst; er muss > 0 sein und `expectedAmountCents`\nentsprechen. Nur bei aktiver Lieferung und aktiver Zahlungsmethode.",
"operationId": "record_payment",
"parameters": [
{
"name": "delivery_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RecordDeliveryPaymentRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Zahlung protokolliert",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeliveryPaymentResponse"
}
}
}
},
"400": {
"description": "Lieferung nicht aktiv, Methode ung\u00fcltig, kein offener Betrag oder Betrag abweichend"
},
"401": {
"description": "Authentifizierung fehlgeschlagen"
},
"404": {
"description": "Lieferung nicht gefunden"
}
},
"security": [
{
"bearer_auth": []
}
]
}
},
"/deliveries/{delivery_id}/resume": {
"post": {
"tags": [
@ -1129,16 +1414,27 @@
]
}
},
"/me/tours/today": {
"/me/tours": {
"get": {
"tags": [
"tours"
],
"summary": "Listet heutige Touren des angemeldeten Fahrers (Filter aus dem JWT).",
"operationId": "list_my_tours_today",
"summary": "Listet die Touren des angemeldeten Fahrers (Filter aus dem JWT) f\u00fcr ein\nZiel-Datum. Ohne `?date=` gilt \u201eheute\". Der Fahrer w\u00e4hlt das Datum aktiv\nim Kopf-Kalender der App (z. B. um die Tour von morgen vorab zu sehen).",
"operationId": "list_my_tours",
"parameters": [
{
"name": "date",
"in": "query",
"description": "Ziel-Tourdatum YYYY-MM-DD (Default: heute)",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Liste der heutigen Touren",
"description": "Liste der Touren am Ziel-Datum",
"content": {
"application/json": {
"schema": {
@ -1147,6 +1443,9 @@
}
}
},
"400": {
"description": "Ung\u00fcltiges Datum"
},
"401": {
"description": "Authentifizierung fehlgeschlagen"
}
@ -1722,11 +2021,19 @@
"country"
],
"properties": {
"addition": {
"type": [
"string",
"null"
],
"description": "Freier Adresszusatz. Kommt aus dem ERP-Textfeld `Adressen.Land`, das\ndie Sachbearbeitung praktisch f\u00fcr Ortsteil, Etage oder Hinweise nutzt\n(z. B. \u201eHolzheim\", \u201e3. Etage\", \u201eParterre\"). `None`, wenn leer."
},
"city": {
"type": "string"
},
"country": {
"type": "string"
"type": "string",
"description": "Land laut ERP-L\u00e4nderstamm (`Adressen.LandID` \u2192 `Laender.Land`), z. B.\n\u201eDeutschland\" oder \u201eBelgien\". Leer, wenn im ERP kein Land verkn\u00fcpft ist."
},
"houseNumber": {
"type": "string"
@ -1910,6 +2217,13 @@
"format": "uuid",
"description": "Fahrzeug des Akteurs (Audit-Spur). Muss zum Account geh\u00f6ren."
},
"internalNote": {
"type": [
"string",
"null"
],
"description": "Optionale interne Notiz des Fahrers, abgefragt nach beiden\nUnterschriften. Nur f\u00fcr interne Zwecke (Lieferbericht), nicht Teil\nder Kunden-Best\u00e4tigung. Leer/Whitespace = keine Notiz; max. 2000 Zeichen."
},
"notesAcknowledged": {
"type": "boolean",
"description": "\u201eAnmerkungen zur Lieferung zur Kenntnis genommen.\" \u2014 Pflicht nur, wenn\nNotizen existieren (das pr\u00fcft der Server)."
@ -1932,6 +2246,59 @@
}
}
},
"CompletedDeliveriesResponse": {
"type": "object",
"required": [
"count",
"deliveries"
],
"properties": {
"count": {
"type": "integer",
"description": "Anzahl der abgeschlossenen Lieferungen im Bereich.",
"minimum": 0
},
"deliveries": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CompletedDeliveryItem"
},
"description": "Die abgeschlossenen Lieferungen, aufsteigend nach Abschluss-Zeitpunkt."
},
"from": {
"type": [
"string",
"null"
],
"description": "Wirksame untere Grenze (ISO `YYYY-MM-DD`) oder `null` (offen)."
},
"to": {
"type": [
"string",
"null"
],
"description": "Wirksame obere Grenze (ISO `YYYY-MM-DD`) oder `null` (offen)."
}
}
},
"CompletedDeliveryItem": {
"type": "object",
"description": "Eine abgeschlossene Lieferung im Tagesabruf.",
"required": [
"belegnummer",
"positions_modified"
],
"properties": {
"belegnummer": {
"type": "string",
"description": "ERP-Belegnummer der Lieferung."
},
"positions_modified": {
"type": "boolean",
"description": "`true`, wenn an der Lieferung Positionen ver\u00e4ndert wurden \u2014 eine Zeile\nwurde entfernt oder in der Menge reduziert (St\u00fcck-Gutschrift) **oder** es\nliegt eine aktive Geld-Gutschrift vor."
}
}
},
"ContactChannel": {
"type": "object",
"description": "Ein einzelner Kontaktkanal (Telefonnummer / Mobil / E-Mail / Web).\nMehrere pro [`ContactSource`] m\u00f6glich, die `position` h\u00e4lt die\n1-basierte ERP-Reihenfolge (`Telefon` \u2192 1, `Telefon2` \u2192 2 usw.) fest,\ndamit der \u201eprim\u00e4re\" Kanal je Art stabil identifizierbar bleibt.",
@ -1986,7 +2353,7 @@
},
"ContactSource": {
"type": "object",
"description": "Snapshot eines ERP-Adress-Datensatzes, der zum Zeitpunkt des Tour-Syncs\nan einer Lieferung hing \u2014 Namensblock ohne Anschrift, weil die Adresse\nihrerseits schon im Lieferungs-Snapshot steckt (`snap_*`-Spalten). Die\neigentlichen Telefonnummern, E-Mails etc. liegen in den\nzugeh\u00f6rigen [`ContactChannel`]s.",
"description": "Snapshot eines ERP-Adress-Datensatzes, der zum Zeitpunkt des Tour-Syncs\nan einer Lieferung hing \u2014 Namensblock plus (falls im ERP gepflegt) die\nAnschrift dieses Datensatzes. Die eigentlichen Telefonnummern, E-Mails\netc. liegen in den zugeh\u00f6rigen [`ContactChannel`]s.",
"required": [
"id",
"deliveryId",
@ -1999,6 +2366,17 @@
"null"
]
},
"address": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/Address",
"description": "Anschrift dieses Adress-Datensatzes; `None`, wenn im ERP weder\nStra\u00dfe noch Ort gepflegt sind (z. B. reiner Ansprechpartner)."
}
]
},
"anrede": {
"type": [
"string",
@ -2421,6 +2799,120 @@
}
}
},
"DeliveryDetails": {
"type": "object",
"required": [
"tour",
"delivery",
"customerContacts",
"articles",
"warehouses",
"notes",
"services",
"deliveryServices",
"contactSources",
"contactChannels"
],
"properties": {
"articles": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Article"
},
"description": "Nur die von den Positionen referenzierten Artikel."
},
"contactChannels": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ContactChannel"
},
"description": "Die zu `contactSources` geh\u00f6renden Einzel-Kan\u00e4le. Join per `sourceId`."
},
"contactSources": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ContactSource"
},
"description": "Kontaktquellen der Lieferung (Liefer-/Rechnungsadresse, Ansprechpartner \u2026)."
},
"credit": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/DeliveryCredit",
"description": "Aktuelle Betrags-Gutschrift (`null`, wenn keine aktiv)."
}
]
},
"customer": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/Customer",
"description": "Der Kunde der Lieferung (`null`, falls unauffindbar \u2014 sollte nicht\nvorkommen)."
}
]
},
"customerContacts": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CustomerContact"
},
"description": "Ansprechpartner des Kunden."
},
"delivery": {
"$ref": "#/components/schemas/DeliveryWithItems",
"description": "Die Lieferung selbst inkl. Positionen (mit Scan-St\u00e4nden) und `sortOrder`."
},
"deliveryServices": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DeliveryServiceValue"
},
"description": "F\u00fcr diese Lieferung gesetzte Service-Werte."
},
"notes": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DeliveryNote"
},
"description": "Notizen der Lieferung, aufsteigend nach `createdAt`."
},
"payment": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/DeliveryPayment",
"description": "J\u00fcngste protokollierte Zahlungsabwicklung (`None` = keine)."
}
]
},
"services": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Service"
},
"description": "Aktive Service-Definitionen (Stammdaten)."
},
"tour": {
"$ref": "#/components/schemas/Tour",
"description": "Die Tour, zu der die Lieferung geh\u00f6rt."
},
"warehouses": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Warehouse"
},
"description": "Nur die von den Positionen referenzierten Lager."
}
}
},
"DeliveryItem": {
"type": "object",
"description": "Einzelposition einer Lieferung. Vereint regul\u00e4re Belegzeilen und\nSt\u00fccklisten-Komponenten zu einer flachen Liste \u2014 die St\u00fccklisten-\nHierarchie ist ein ERP-Konstrukt und wird beim Sync aufgel\u00f6st.\n\n\u00dcber die Felder `belegzeilen_nr` und `komponenten_artikel_nr` bleibt\ndie ERP-Herkunft aufl\u00f6sbar.",
@ -2579,6 +3071,68 @@
}
}
},
"DeliveryPayment": {
"type": "object",
"description": "Protokollierte Zahlungsabwicklung (\u201eAbkassieren\") einer Lieferung.\n\nAppend-only: jede Best\u00e4tigung des Fahrers ist ein eigener Eintrag; im\nTour-Aggregat wird pro Lieferung nur der j\u00fcngste mitgeliefert. Der Betrag\nist der server-seitig berechnete offene Betrag zum Zeitpunkt der\nBest\u00e4tigung. Weicht der aktuelle offene Betrag davon ab (z. B. nach einer\nneuen Gutschrift), ist der Eintrag veraltet und muss erneuert werden.",
"required": [
"id",
"deliveryId",
"paymentMethodId",
"paymentMethodCode",
"amountCents",
"recordedByPersonalnummer",
"recordedAt"
],
"properties": {
"amountCents": {
"type": "integer",
"format": "int64",
"description": "Abgewickelter Betrag in Cent (offener Betrag bei Best\u00e4tigung)."
},
"deliveryId": {
"type": "string",
"format": "uuid"
},
"id": {
"type": "string",
"format": "uuid"
},
"paymentMethodCode": {
"type": "string",
"description": "Snapshot des Methoden-Codes (`cash`, `ec_card`, `invoice`, \u2026)."
},
"paymentMethodId": {
"type": "string",
"format": "uuid"
},
"recordedAt": {
"type": "string",
"format": "date-time"
},
"recordedByCarId": {
"type": [
"string",
"null"
],
"format": "uuid"
},
"recordedByPersonalnummer": {
"type": "integer",
"format": "int64"
}
}
},
"DeliveryPaymentResponse": {
"type": "object",
"required": [
"payment"
],
"properties": {
"payment": {
"$ref": "#/components/schemas/DeliveryPayment"
}
}
},
"DeliveryResponse": {
"type": "object",
"required": [
@ -2814,6 +3368,165 @@
}
}
},
"PendingReviewResponse": {
"type": "object",
"required": [
"delivery_id",
"erp_belegart_id",
"erp_belegnummer",
"customer_name",
"tour_date",
"last_change_at",
"credited_items",
"money_credit_cents"
],
"properties": {
"credited_items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ReviewedItemResponse"
},
"description": "Entfernte/teil-gutgeschriebene Positionen."
},
"customer_name": {
"type": "string"
},
"delivery_id": {
"type": "string"
},
"erp_belegart_id": {
"type": "integer",
"format": "int64"
},
"erp_belegnummer": {
"type": "string"
},
"last_change_at": {
"type": "string",
"description": "Zeitpunkt der letzten beleg-\u00e4ndernden Aktion (RFC 3339)."
},
"money_credit_cents": {
"type": "integer",
"format": "int64",
"description": "Geld-Gutschrift in Cent (0 = keine)."
},
"money_credit_reason": {
"type": [
"string",
"null"
]
},
"tour_date": {
"type": "string",
"description": "Tourdatum (ISO `YYYY-MM-DD`)."
}
}
},
"PositionsModifiedResponse": {
"type": "object",
"required": [
"belegnummer",
"positions_modified"
],
"properties": {
"belegnummer": {
"type": "string",
"description": "ERP-Belegnummer, nach der gefragt wurde."
},
"positions_modified": {
"type": "boolean",
"description": "`true`, wenn an der Lieferung Positionen ver\u00e4ndert wurden \u2014 eine Zeile\nwurde entfernt oder in der Menge reduziert (St\u00fcck-Gutschrift) **oder** es\nliegt eine aktive Geld-Gutschrift vor."
}
}
},
"RecordDeliveryPaymentRequest": {
"type": "object",
"required": [
"clientEventId",
"paymentMethodId",
"expectedAmountCents"
],
"properties": {
"authorCarId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "Fahrzeug des Akteurs (Audit-Spur). Muss zum Account geh\u00f6ren."
},
"clientEventId": {
"type": "string",
"format": "uuid",
"description": "Idempotenz-Schl\u00fcssel \u2014 pro Best\u00e4tigung genau einmal vergeben."
},
"expectedAmountCents": {
"type": "integer",
"format": "int64",
"description": "Betrag in Cent, den der Fahrer in der App gesehen und best\u00e4tigt hat.\nDer Server berechnet den offenen Betrag selbst und lehnt ab, wenn er\ndavon abweicht \u2014 so landet nie ein Betrag im Protokoll, den der\nFahrer nicht gesehen hat."
},
"paymentMethodId": {
"type": "string",
"format": "uuid",
"description": "Gew\u00e4hlte Zahlungsmethode. Muss existieren und aktiv sein."
}
}
},
"ResolveReviewRequest": {
"type": "object",
"required": [
"resolved_by"
],
"properties": {
"note": {
"type": [
"string",
"null"
],
"description": "Optionale Notiz zur getroffenen Entscheidung."
},
"resolved_by": {
"type": "string",
"description": "Bearbeiter (Name/K\u00fcrzel), der die Pr\u00fcfung best\u00e4tigt."
}
}
},
"ReviewedItemResponse": {
"type": "object",
"required": [
"belegzeilen_nr",
"artikel_nr",
"article_name",
"required_quantity",
"credited_quantity"
],
"properties": {
"article_name": {
"type": "string"
},
"artikel_nr": {
"type": "string"
},
"belegzeilen_nr": {
"type": "integer",
"format": "int32"
},
"credited_quantity": {
"type": "integer",
"format": "int32"
},
"reason": {
"type": [
"string",
"null"
]
},
"required_quantity": {
"type": "integer",
"format": "int32"
}
}
},
"ScanEvent": {
"type": "object",
"required": [
@ -3137,6 +3850,17 @@
"null"
]
},
"address": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/Address",
"description": "Anschrift dieses Adress-Datensatzes, falls gepflegt."
}
]
},
"anrede": {
"type": [
"string",
@ -3416,6 +4140,7 @@
"warehouses",
"notes",
"credits",
"payments",
"services",
"deliveryServices",
"contactSources",
@ -3481,6 +4206,13 @@
},
"description": "Alle Notizen aller Lieferungen dieser Tour, in einer Liste.\nDie App joint clientseitig per `delivery_id`. Reihenfolge:\npro Lieferung aufsteigend nach `created_at`."
},
"payments": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DeliveryPayment"
},
"description": "J\u00fcngste protokollierte Zahlungsabwicklung pro Lieferung (nur\nLieferungen mit mindestens einem Eintrag). Join per `delivery_id`."
},
"services": {
"type": "array",
"items": {

View File

@ -17,6 +17,8 @@ doc/CarResponse.md
doc/CarsApi.md
doc/CarsList.md
doc/CompleteDeliveryAcknowledgements.md
doc/CompletedDeliveriesResponse.md
doc/CompletedDeliveryItem.md
doc/ContactChannel.md
doc/ContactKind.md
doc/ContactRole.md
@ -34,10 +36,13 @@ doc/Delivery.md
doc/DeliveryCredit.md
doc/DeliveryCreditEventRequest.md
doc/DeliveryCreditResponse.md
doc/DeliveryDetails.md
doc/DeliveryItem.md
doc/DeliveryNote.md
doc/DeliveryNoteResponse.md
doc/DeliveryOrderEntry.md
doc/DeliveryPayment.md
doc/DeliveryPaymentResponse.md
doc/DeliveryResponse.md
doc/DeliveryServiceResponse.md
doc/DeliveryServiceValue.md
@ -52,6 +57,11 @@ doc/PaymentMethod.md
doc/PaymentMethodResponse.md
doc/PaymentMethodsApi.md
doc/PaymentMethodsList.md
doc/PendingReviewResponse.md
doc/PositionsModifiedResponse.md
doc/RecordDeliveryPaymentRequest.md
doc/ResolveReviewRequest.md
doc/ReviewedItemResponse.md
doc/ScanEvent.md
doc/ScanResult.md
doc/ScanResultStatus.md
@ -115,6 +125,8 @@ lib/src/model/car.dart
lib/src/model/car_response.dart
lib/src/model/cars_list.dart
lib/src/model/complete_delivery_acknowledgements.dart
lib/src/model/completed_deliveries_response.dart
lib/src/model/completed_delivery_item.dart
lib/src/model/contact_channel.dart
lib/src/model/contact_kind.dart
lib/src/model/contact_role.dart
@ -132,10 +144,13 @@ lib/src/model/delivery.dart
lib/src/model/delivery_credit.dart
lib/src/model/delivery_credit_event_request.dart
lib/src/model/delivery_credit_response.dart
lib/src/model/delivery_details.dart
lib/src/model/delivery_item.dart
lib/src/model/delivery_note.dart
lib/src/model/delivery_note_response.dart
lib/src/model/delivery_order_entry.dart
lib/src/model/delivery_payment.dart
lib/src/model/delivery_payment_response.dart
lib/src/model/delivery_response.dart
lib/src/model/delivery_service_response.dart
lib/src/model/delivery_service_value.dart
@ -148,6 +163,11 @@ lib/src/model/mark_mail_sent_response.dart
lib/src/model/payment_method.dart
lib/src/model/payment_method_response.dart
lib/src/model/payment_methods_list.dart
lib/src/model/pending_review_response.dart
lib/src/model/positions_modified_response.dart
lib/src/model/record_delivery_payment_request.dart
lib/src/model/resolve_review_request.dart
lib/src/model/reviewed_item_response.dart
lib/src/model/scan_event.dart
lib/src/model/scan_result.dart
lib/src/model/scan_result_status.dart
@ -177,12 +197,3 @@ lib/src/model/update_service_request.dart
lib/src/model/warehouse.dart
lib/src/serializers.dart
pubspec.yaml
test/contact_channel_test.dart
test/contact_kind_test.dart
test/contact_role_test.dart
test/contact_source_test.dart
test/delivered_belegnummern_response_test.dart
test/mark_mail_sent_request_test.dart
test/mark_mail_sent_response_test.dart
test/sync_contact_channel_test.dart
test/sync_contact_source_test.dart

View File

@ -66,10 +66,15 @@ All URIs are relative to *http://localhost*
Class | Method | HTTP request | Description
------------ | ------------- | ------------- | -------------
[*AccountsApi*](doc/AccountsApi.md) | [**getAccount**](doc/AccountsApi.md#getaccount) | **GET** /accounts/{personalnummer} | Liest den Account zu einer Personalnummer.
[*AdminApi*](doc/AdminApi.md) | [**belegeDetails**](doc/AdminApi.md#belegedetails) | **GET** /admin/belege/{belegnummer} | Liefert das **volle Detail-Paket** zu einer ERP-Belegnummer: die Lieferung mit Positionen (inkl. Scan-Ständen), Kunde + Ansprechpartner, referenzierte Artikel/Lager, Notizen, Geld-Gutschrift, Dienstleistungswerte und Kontakte — destilliert aus dem Tour-Aggregat. Jede Lieferung, unabhängig vom Status. &#x60;404&#x60;, wenn die Belegnummer unbekannt ist.
[*AdminApi*](doc/AdminApi.md) | [**completedDeliveries**](doc/AdminApi.md#completeddeliveries) | **GET** /admin/completed-deliveries | Liefert **alle** abgeschlossenen (ausgelieferten) Lieferungen — optional auf einen Datumsbereich eingegrenzt, unabhängig vom Mail-Versand-Status. Gefiltert wird über den **Berliner** Kalendertag des Abschluss-Zeitpunkts (&#x60;completed_at&#x60;).
[*AdminApi*](doc/AdminApi.md) | [**deliveredBelegnummern**](doc/AdminApi.md#deliveredbelegnummern) | **GET** /admin/delivered-belegnummern | Liefert die Belegnummern ausgelieferter (abgeschlossener) Lieferungen, **deren Liefermail noch nicht versendet wurde** (&#x60;mail_sent_at IS NULL&#x60;). „Ausgeliefert\&quot; &#x3D; es existiert ein Abschluss. Mit &#x60;day&#x60; (DD-MM-YYYY) nur Abschlüsse dieses Berliner Kalendertages; **ohne &#x60;day&#x60; alle offenen** (über alle Tage) — so bleiben Belege über Mitternacht nicht hängen.
[*AdminApi*](doc/AdminApi.md) | [**importErp**](doc/AdminApi.md#importerp) | **POST** /admin/import-erp | Stößt den ERP-Import für ein Datum an und liefert die Zusammenfassung.
[*AdminApi*](doc/AdminApi.md) | [**listReviews**](doc/AdminApi.md#listreviews) | **GET** /admin/reviews | Listet alle geänderten Lieferscheine, die noch auf eine manuelle Bestätigung (Vier-Augen) warten — Entfernungen und/oder Geld-Gutschriften.
[*AdminApi*](doc/AdminApi.md) | [**markMailSent**](doc/AdminApi.md#markmailsent) | **POST** /admin/mark-mail-sent | Markiert die Liefermails der angegebenen Belegnummern als **versendet** (&#x60;mail_sent_at &#x3D; now()&#x60;, nur wo noch offen). Vom Mailclient aufzurufen, NACHDEM ERPframe die Mails erfolgreich verschickt hat — danach erscheinen die Belege nicht mehr in &#x60;GET /admin/delivered-belegnummern&#x60;.
[*AdminApi*](doc/AdminApi.md) | [**positionsModified**](doc/AdminApi.md#positionsmodified) | **GET** /admin/belege/{belegnummer}/positions-modified | Liefert zu **einer** ERP-Belegnummer, ob an der Lieferung Positionen verändert wurden (Menge reduziert/Zeile entfernt oder Geld-Gutschrift) — unabhängig vom Zustand der Lieferung. &#x60;404&#x60;, wenn die Belegnummer unbekannt ist. Gibt es mehrere Lieferungen mit derselben Belegnummer, ist das Flag &#x60;true&#x60;, sobald **eine** davon verändert ist.
[*AdminApi*](doc/AdminApi.md) | [**pushCompletion**](doc/AdminApi.md#pushcompletion) | **POST** /admin/push-completion | Stößt das ERP-Rückschreiben eines bereits lokal abgeschlossenen Lieferabschlusses erneut an (idempotenter Retry, falls der automatische Push beim Abschluss fehlschlug).
[*AdminApi*](doc/AdminApi.md) | [**resolveReview**](doc/AdminApi.md#resolvereview) | **POST** /admin/reviews/{delivery_id}/resolve | Bestätigt die Prüfung einer geänderten Lieferung (Vier-Augen) — die Lieferung verschwindet danach aus &#x60;GET /admin/reviews&#x60; (sofern nicht erneut geändert).
[*AttachmentsApi*](doc/AttachmentsApi.md) | [**getAttachment**](doc/AttachmentsApi.md#getattachment) | **GET** /attachments/{id} | Liefert ein gerendertes Vorschaubild des Attachments (Bytes), geladen aus DOCUframe. Auflösung/Format über Query-Parameter steuerbar (&#x60;?w&#x3D;&amp;h&#x3D;&amp;q&#x3D;&amp;ext&#x3D;&amp;page&#x3D;&#x60;).
[*CarsApi*](doc/CarsApi.md) | [**createMyCar**](doc/CarsApi.md#createmycar) | **POST** /me/cars | Legt ein neues Fahrzeug für den angemeldeten Fahrer an.
[*CarsApi*](doc/CarsApi.md) | [**listMyCars**](doc/CarsApi.md#listmycars) | **GET** /me/cars | Listet die Fahrzeuge des angemeldeten Fahrers.
@ -82,6 +87,7 @@ Class | Method | HTTP request | Description
[*DeliveriesApi*](doc/DeliveriesApi.md) | [**deleteNote**](doc/DeliveriesApi.md#deletenote) | **DELETE** /deliveries/{delivery_id}/notes/{note_id} | Löscht eine Notiz. Antwortet mit &#x60;204 No Content&#x60;.
[*DeliveriesApi*](doc/DeliveriesApi.md) | [**deleteServiceValue**](doc/DeliveriesApi.md#deleteservicevalue) | **DELETE** /deliveries/{delivery_id}/services/{service_id} | Entfernt den Service-Wert einer Lieferung (Service „nicht gesetzt\&quot;). Nur bei aktiver Lieferung. Antwort &#x60;204&#x60;.
[*DeliveriesApi*](doc/DeliveriesApi.md) | [**hold**](doc/DeliveriesApi.md#hold) | **POST** /deliveries/{delivery_id}/hold | Setzt die Lieferung auf &#x60;held&#x60;. Nur aus &#x60;active&#x60; zulässig.
[*DeliveriesApi*](doc/DeliveriesApi.md) | [**recordPayment**](doc/DeliveriesApi.md#recordpayment) | **POST** /deliveries/{delivery_id}/payment | Protokolliert die Zahlungsabwicklung („Abkassieren\&quot;) einer Lieferung. Append-only, idempotent über &#x60;clientEventId&#x60;. Der Server berechnet den offenen Betrag selbst; er muss &gt; 0 sein und &#x60;expectedAmountCents&#x60; entsprechen. Nur bei aktiver Lieferung und aktiver Zahlungsmethode.
[*DeliveriesApi*](doc/DeliveriesApi.md) | [**resume**](doc/DeliveriesApi.md#resume) | **POST** /deliveries/{delivery_id}/resume | Setzt die Lieferung zurück auf &#x60;active&#x60;. Nur aus &#x60;held&#x60; zulässig.
[*DeliveriesApi*](doc/DeliveriesApi.md) | [**setService**](doc/DeliveriesApi.md#setservice) | **PUT** /deliveries/{delivery_id}/services/{service_id} | Setzt (Upsert) den Wert eines Service für eine Lieferung. Genau das zum Service-Typ passende Feld (&#x60;boolValue&#x60;/&#x60;numericValue&#x60;) muss gesetzt sein; numerische Werte werden gegen min/max geprüft. Nur bei aktiver Lieferung.
[*DeliveriesApi*](doc/DeliveriesApi.md) | [**updateNote**](doc/DeliveriesApi.md#updatenote) | **PATCH** /deliveries/{delivery_id}/notes/{note_id} | Ändert Text/Bild einer Notiz. Innerhalb des (geteilten) Accounts darf jeder Fahrer Notizen pflegen — kein Autor-Check. &#x60;delivery_id&#x60; ist Teil des Pfads (REST-Konsistenz), die Notiz wird über &#x60;note_id&#x60; adressiert.
@ -98,7 +104,7 @@ Class | Method | HTTP request | Description
[*ServicesApi*](doc/ServicesApi.md) | [**updateService**](doc/ServicesApi.md#updateservice) | **PATCH** /services/{id} | Patcht Name/Grenzen/Aktiv-Flag/Sortierung. &#x60;kind&#x60; ist nicht änderbar.
[*SyncApi*](doc/SyncApi.md) | [**syncTour**](doc/SyncApi.md#synctour) | **POST** /sync/tour | Sync-Endpoint für das ERP: legt eine Tagestour samt Lieferungen und Positionen idempotent an. Identität pro Tour &#x60;(driver_personalnummer, tour_date)&#x60;, pro Lieferung &#x60;(belegart_id, belegnummer)&#x60;.
[*ToursApi*](doc/ToursApi.md) | [**getTour**](doc/ToursApi.md#gettour) | **GET** /tours/{tour_id} | Lädt eine Tour mit allen Lieferungen, Positionen und referenzierten Stammdaten — die App nutzt das als einzigen großen Read.
[*ToursApi*](doc/ToursApi.md) | [**listMyToursToday**](doc/ToursApi.md#listmytourstoday) | **GET** /me/tours/today | Listet heutige Touren des angemeldeten Fahrers (Filter aus dem JWT).
[*ToursApi*](doc/ToursApi.md) | [**listMyTours**](doc/ToursApi.md#listmytours) | **GET** /me/tours | Listet die Touren des angemeldeten Fahrers (Filter aus dem JWT) für ein Ziel-Datum. Ohne &#x60;?date&#x3D;&#x60; gilt „heute\&quot;. Der Fahrer wählt das Datum aktiv im Kopf-Kalender der App (z. B. um die Tour von morgen vorab zu sehen).
[*ToursApi*](doc/ToursApi.md) | [**setDeliveryOrder**](doc/ToursApi.md#setdeliveryorder) | **PUT** /tours/{tour_id}/delivery-order | Schreibt die Sortier-Reihenfolge aller Lieferungen einer Tour neu. Der Client schickt die **vollständige** neue Reihenfolge; fehlende oder fremde Lieferungs-Ids werden mit &#x60;400 validation&#x60; abgelehnt.
@ -116,6 +122,8 @@ Class | Method | HTTP request | Description
- [CarResponse](doc/CarResponse.md)
- [CarsList](doc/CarsList.md)
- [CompleteDeliveryAcknowledgements](doc/CompleteDeliveryAcknowledgements.md)
- [CompletedDeliveriesResponse](doc/CompletedDeliveriesResponse.md)
- [CompletedDeliveryItem](doc/CompletedDeliveryItem.md)
- [ContactChannel](doc/ContactChannel.md)
- [ContactKind](doc/ContactKind.md)
- [ContactRole](doc/ContactRole.md)
@ -132,10 +140,13 @@ Class | Method | HTTP request | Description
- [DeliveryCredit](doc/DeliveryCredit.md)
- [DeliveryCreditEventRequest](doc/DeliveryCreditEventRequest.md)
- [DeliveryCreditResponse](doc/DeliveryCreditResponse.md)
- [DeliveryDetails](doc/DeliveryDetails.md)
- [DeliveryItem](doc/DeliveryItem.md)
- [DeliveryNote](doc/DeliveryNote.md)
- [DeliveryNoteResponse](doc/DeliveryNoteResponse.md)
- [DeliveryOrderEntry](doc/DeliveryOrderEntry.md)
- [DeliveryPayment](doc/DeliveryPayment.md)
- [DeliveryPaymentResponse](doc/DeliveryPaymentResponse.md)
- [DeliveryResponse](doc/DeliveryResponse.md)
- [DeliveryServiceResponse](doc/DeliveryServiceResponse.md)
- [DeliveryServiceValue](doc/DeliveryServiceValue.md)
@ -148,6 +159,11 @@ Class | Method | HTTP request | Description
- [PaymentMethod](doc/PaymentMethod.md)
- [PaymentMethodResponse](doc/PaymentMethodResponse.md)
- [PaymentMethodsList](doc/PaymentMethodsList.md)
- [PendingReviewResponse](doc/PendingReviewResponse.md)
- [PositionsModifiedResponse](doc/PositionsModifiedResponse.md)
- [RecordDeliveryPaymentRequest](doc/RecordDeliveryPaymentRequest.md)
- [ResolveReviewRequest](doc/ResolveReviewRequest.md)
- [ReviewedItemResponse](doc/ReviewedItemResponse.md)
- [ScanEvent](doc/ScanEvent.md)
- [ScanResult](doc/ScanResult.md)
- [ScanResultStatus](doc/ScanResultStatus.md)

View File

@ -8,8 +8,9 @@ import 'package:holzleitner_api/api.dart';
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**addition** | **String** | Freier Adresszusatz. Kommt aus dem ERP-Textfeld `Adressen.Land`, das die Sachbearbeitung praktisch für Ortsteil, Etage oder Hinweise nutzt (z. B. „Holzheim\", „3. Etage\", „Parterre\"). `None`, wenn leer. | [optional]
**city** | **String** | |
**country** | **String** | |
**country** | **String** | Land laut ERP-Länderstamm (`Adressen.LandID` → `Laender.Land`), z. B. „Deutschland\" oder „Belgien\". Leer, wenn im ERP kein Land verknüpft ist. |
**houseNumber** | **String** | |
**postalCode** | **String** | |
**street** | **String** | |

View File

@ -9,12 +9,113 @@ All URIs are relative to *http://localhost*
Method | HTTP request | Description
------------- | ------------- | -------------
[**belegeDetails**](AdminApi.md#belegedetails) | **GET** /admin/belege/{belegnummer} | Liefert das **volle Detail-Paket** zu einer ERP-Belegnummer: die Lieferung mit Positionen (inkl. Scan-Ständen), Kunde + Ansprechpartner, referenzierte Artikel/Lager, Notizen, Geld-Gutschrift, Dienstleistungswerte und Kontakte — destilliert aus dem Tour-Aggregat. Jede Lieferung, unabhängig vom Status. &#x60;404&#x60;, wenn die Belegnummer unbekannt ist.
[**completedDeliveries**](AdminApi.md#completeddeliveries) | **GET** /admin/completed-deliveries | Liefert **alle** abgeschlossenen (ausgelieferten) Lieferungen — optional auf einen Datumsbereich eingegrenzt, unabhängig vom Mail-Versand-Status. Gefiltert wird über den **Berliner** Kalendertag des Abschluss-Zeitpunkts (&#x60;completed_at&#x60;).
[**deliveredBelegnummern**](AdminApi.md#deliveredbelegnummern) | **GET** /admin/delivered-belegnummern | Liefert die Belegnummern ausgelieferter (abgeschlossener) Lieferungen, **deren Liefermail noch nicht versendet wurde** (&#x60;mail_sent_at IS NULL&#x60;). „Ausgeliefert\&quot; &#x3D; es existiert ein Abschluss. Mit &#x60;day&#x60; (DD-MM-YYYY) nur Abschlüsse dieses Berliner Kalendertages; **ohne &#x60;day&#x60; alle offenen** (über alle Tage) — so bleiben Belege über Mitternacht nicht hängen.
[**importErp**](AdminApi.md#importerp) | **POST** /admin/import-erp | Stößt den ERP-Import für ein Datum an und liefert die Zusammenfassung.
[**listReviews**](AdminApi.md#listreviews) | **GET** /admin/reviews | Listet alle geänderten Lieferscheine, die noch auf eine manuelle Bestätigung (Vier-Augen) warten — Entfernungen und/oder Geld-Gutschriften.
[**markMailSent**](AdminApi.md#markmailsent) | **POST** /admin/mark-mail-sent | Markiert die Liefermails der angegebenen Belegnummern als **versendet** (&#x60;mail_sent_at &#x3D; now()&#x60;, nur wo noch offen). Vom Mailclient aufzurufen, NACHDEM ERPframe die Mails erfolgreich verschickt hat — danach erscheinen die Belege nicht mehr in &#x60;GET /admin/delivered-belegnummern&#x60;.
[**positionsModified**](AdminApi.md#positionsmodified) | **GET** /admin/belege/{belegnummer}/positions-modified | Liefert zu **einer** ERP-Belegnummer, ob an der Lieferung Positionen verändert wurden (Menge reduziert/Zeile entfernt oder Geld-Gutschrift) — unabhängig vom Zustand der Lieferung. &#x60;404&#x60;, wenn die Belegnummer unbekannt ist. Gibt es mehrere Lieferungen mit derselben Belegnummer, ist das Flag &#x60;true&#x60;, sobald **eine** davon verändert ist.
[**pushCompletion**](AdminApi.md#pushcompletion) | **POST** /admin/push-completion | Stößt das ERP-Rückschreiben eines bereits lokal abgeschlossenen Lieferabschlusses erneut an (idempotenter Retry, falls der automatische Push beim Abschluss fehlschlug).
[**resolveReview**](AdminApi.md#resolvereview) | **POST** /admin/reviews/{delivery_id}/resolve | Bestätigt die Prüfung einer geänderten Lieferung (Vier-Augen) — die Lieferung verschwindet danach aus &#x60;GET /admin/reviews&#x60; (sofern nicht erneut geändert).
# **belegeDetails**
> DeliveryDetails belegeDetails(belegnummer)
Liefert das **volle Detail-Paket** zu einer ERP-Belegnummer: die Lieferung mit Positionen (inkl. Scan-Ständen), Kunde + Ansprechpartner, referenzierte Artikel/Lager, Notizen, Geld-Gutschrift, Dienstleistungswerte und Kontakte — destilliert aus dem Tour-Aggregat. Jede Lieferung, unabhängig vom Status. `404`, wenn die Belegnummer unbekannt ist.
### Example
```dart
import 'package:holzleitner_api/api.dart';
// TODO Configure API key authorization: admin_api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('admin_api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('admin_api_key').apiKeyPrefix = 'Bearer';
final api = HolzleitnerApi().getAdminApi();
final String belegnummer = belegnummer_example; // String | ERP-Belegnummer, z. B. V-30690291
try {
final response = api.belegeDetails(belegnummer);
print(response);
} catch on DioException (e) {
print('Exception when calling AdminApi->belegeDetails: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**belegnummer** | **String**| ERP-Belegnummer, z. B. V-30690291 |
### Return type
[**DeliveryDetails**](DeliveryDetails.md)
### Authorization
[admin_api_key](../README.md#admin_api_key)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **completedDeliveries**
> CompletedDeliveriesResponse completedDeliveries(day, from, to)
Liefert **alle** abgeschlossenen (ausgelieferten) Lieferungen — optional auf einen Datumsbereich eingegrenzt, unabhängig vom Mail-Versand-Status. Gefiltert wird über den **Berliner** Kalendertag des Abschluss-Zeitpunkts (`completed_at`).
Parameter (alle `DD-MM-YYYY`, alle optional): `day` = Einzeltag (Kurzform `from=to=day`), sonst `from`/`to` als **inklusive** Bereichsgrenzen (je offen). **Ohne jeden Parameter → ALLE ausgelieferten Belege** (kein Datumsfilter). Pro Lieferung: Belegnummer + `positions_modified` (Menge reduziert/Zeile entfernt oder Geld-Gutschrift). Die Halb-Grenzen-Variante ist für Range-Filter gedacht: `?from=…` und `?to=…` liefern je eine Menge, deren SQL-`AND`-Schnitt den Zeitraum ergibt.
### Example
```dart
import 'package:holzleitner_api/api.dart';
// TODO Configure API key authorization: admin_api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('admin_api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('admin_api_key').apiKeyPrefix = 'Bearer';
final api = HolzleitnerApi().getAdminApi();
final String day = day_example; // String | Einzeltag DD-MM-YYYY (Kurzform from=to)
final String from = from_example; // String | Untere Grenze DD-MM-YYYY (inklusive)
final String to = to_example; // String | Obere Grenze DD-MM-YYYY (inklusive)
try {
final response = api.completedDeliveries(day, from, to);
print(response);
} catch on DioException (e) {
print('Exception when calling AdminApi->completedDeliveries: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**day** | **String**| Einzeltag DD-MM-YYYY (Kurzform from=to) | [optional]
**from** | **String**| Untere Grenze DD-MM-YYYY (inklusive) | [optional]
**to** | **String**| Obere Grenze DD-MM-YYYY (inklusive) | [optional]
### Return type
[**CompletedDeliveriesResponse**](CompletedDeliveriesResponse.md)
### Authorization
[admin_api_key](../README.md#admin_api_key)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **deliveredBelegnummern**
> DeliveredBelegnummernResponse deliveredBelegnummern(day)
@ -105,6 +206,47 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **listReviews**
> BuiltList<PendingReviewResponse> listReviews()
Listet alle geänderten Lieferscheine, die noch auf eine manuelle Bestätigung (Vier-Augen) warten — Entfernungen und/oder Geld-Gutschriften.
### Example
```dart
import 'package:holzleitner_api/api.dart';
// TODO Configure API key authorization: admin_api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('admin_api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('admin_api_key').apiKeyPrefix = 'Bearer';
final api = HolzleitnerApi().getAdminApi();
try {
final response = api.listReviews();
print(response);
} catch on DioException (e) {
print('Exception when calling AdminApi->listReviews: $e\n');
}
```
### Parameters
This endpoint does not need any parameter.
### Return type
[**BuiltList&lt;PendingReviewResponse&gt;**](PendingReviewResponse.md)
### Authorization
[admin_api_key](../README.md#admin_api_key)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **markMailSent**
> MarkMailSentResponse markMailSent(markMailSentRequest)
@ -150,6 +292,51 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **positionsModified**
> PositionsModifiedResponse positionsModified(belegnummer)
Liefert zu **einer** ERP-Belegnummer, ob an der Lieferung Positionen verändert wurden (Menge reduziert/Zeile entfernt oder Geld-Gutschrift) — unabhängig vom Zustand der Lieferung. `404`, wenn die Belegnummer unbekannt ist. Gibt es mehrere Lieferungen mit derselben Belegnummer, ist das Flag `true`, sobald **eine** davon verändert ist.
### Example
```dart
import 'package:holzleitner_api/api.dart';
// TODO Configure API key authorization: admin_api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('admin_api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('admin_api_key').apiKeyPrefix = 'Bearer';
final api = HolzleitnerApi().getAdminApi();
final String belegnummer = belegnummer_example; // String | ERP-Belegnummer, z. B. V-30690291
try {
final response = api.positionsModified(belegnummer);
print(response);
} catch on DioException (e) {
print('Exception when calling AdminApi->positionsModified: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**belegnummer** | **String**| ERP-Belegnummer, z. B. V-30690291 |
### Return type
[**PositionsModifiedResponse**](PositionsModifiedResponse.md)
### Authorization
[admin_api_key](../README.md#admin_api_key)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **pushCompletion**
> pushCompletion(deliveryId)
@ -194,3 +381,49 @@ void (empty response body)
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **resolveReview**
> resolveReview(deliveryId, resolveReviewRequest)
Bestätigt die Prüfung einer geänderten Lieferung (Vier-Augen) — die Lieferung verschwindet danach aus `GET /admin/reviews` (sofern nicht erneut geändert).
### Example
```dart
import 'package:holzleitner_api/api.dart';
// TODO Configure API key authorization: admin_api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('admin_api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('admin_api_key').apiKeyPrefix = 'Bearer';
final api = HolzleitnerApi().getAdminApi();
final String deliveryId = deliveryId_example; // String | UUID der Lieferung
final ResolveReviewRequest resolveReviewRequest = ; // ResolveReviewRequest |
try {
api.resolveReview(deliveryId, resolveReviewRequest);
} catch on DioException (e) {
print('Exception when calling AdminApi->resolveReview: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**deliveryId** | **String**| UUID der Lieferung |
**resolveReviewRequest** | [**ResolveReviewRequest**](ResolveReviewRequest.md)| |
### Return type
void (empty response body)
### Authorization
[admin_api_key](../README.md#admin_api_key)
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: Not defined
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)

View File

@ -10,6 +10,7 @@ Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**acknowledgedNoteIds** | **BuiltList&lt;String&gt;** | Notiz-IDs, die zum Abschlusszeitpunkt sichtbar waren und mit-bestätigt wurden (Audit-Robustheit). | [optional]
**authorCarId** | **String** | Fahrzeug des Akteurs (Audit-Spur). Muss zum Account gehören. | [optional]
**internalNote** | **String** | Optionale interne Notiz des Fahrers, abgefragt nach beiden Unterschriften. Nur für interne Zwecke (Lieferbericht), nicht Teil der Kunden-Bestätigung. Leer/Whitespace = keine Notiz; max. 2000 Zeichen. | [optional]
**notesAcknowledged** | **bool** | „Anmerkungen zur Lieferung zur Kenntnis genommen.\" — Pflicht nur, wenn Notizen existieren (das prüft der Server). | [optional]
**paymentCollected** | **bool** | Inkasso-Bestätigung des Fahrers: „der offene Betrag wurde erhalten (bar) bzw. über das EC-Gerät abgerechnet.\" Pflicht nur, wenn beim Abschluss ein offener Betrag > 0 besteht UND die Methode ein Vor-Ort- Inkasso ist (Bar/EC) — das prüft der Server. Der kassierte Betrag wird server-seitig autoritativ berechnet (nicht vom Client übernommen). | [optional]
**paymentMethodId** | **String** | Optionale Zahlungsmethode, die der Fahrer beim Abschluss gewählt hat. `None` = die am Beleg hinterlegte Methode bleibt. Falls gesetzt, muss sie existieren **und** aktiv sein (vom Server geprüft). | [optional]

View File

@ -0,0 +1,18 @@
# holzleitner_api.model.CompletedDeliveriesResponse
## Load the model package
```dart
import 'package:holzleitner_api/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**count** | **int** | Anzahl der abgeschlossenen Lieferungen im Bereich. |
**deliveries** | [**BuiltList&lt;CompletedDeliveryItem&gt;**](CompletedDeliveryItem.md) | Die abgeschlossenen Lieferungen, aufsteigend nach Abschluss-Zeitpunkt. |
**from** | **String** | Wirksame untere Grenze (ISO `YYYY-MM-DD`) oder `null` (offen). | [optional]
**to** | **String** | Wirksame obere Grenze (ISO `YYYY-MM-DD`) oder `null` (offen). | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -0,0 +1,16 @@
# holzleitner_api.model.CompletedDeliveryItem
## Load the model package
```dart
import 'package:holzleitner_api/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**belegnummer** | **String** | ERP-Belegnummer der Lieferung. |
**positionsModified** | **bool** | `true`, wenn an der Lieferung Positionen verändert wurden — eine Zeile wurde entfernt oder in der Menge reduziert (Stück-Gutschrift) **oder** es liegt eine aktive Geld-Gutschrift vor. |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -9,6 +9,7 @@ import 'package:holzleitner_api/api.dart';
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**abteilung** | **String** | | [optional]
**address** | [**Address**](Address.md) | Anschrift dieses Adress-Datensatzes; `None`, wenn im ERP weder Straße noch Ort gepflegt sind (z. B. reiner Ansprechpartner). | [optional]
**anrede** | **String** | | [optional]
**deliveryId** | **String** | |
**funktion** | **String** | | [optional]

View File

@ -17,6 +17,7 @@ Method | HTTP request | Description
[**deleteNote**](DeliveriesApi.md#deletenote) | **DELETE** /deliveries/{delivery_id}/notes/{note_id} | Löscht eine Notiz. Antwortet mit &#x60;204 No Content&#x60;.
[**deleteServiceValue**](DeliveriesApi.md#deleteservicevalue) | **DELETE** /deliveries/{delivery_id}/services/{service_id} | Entfernt den Service-Wert einer Lieferung (Service „nicht gesetzt\&quot;). Nur bei aktiver Lieferung. Antwort &#x60;204&#x60;.
[**hold**](DeliveriesApi.md#hold) | **POST** /deliveries/{delivery_id}/hold | Setzt die Lieferung auf &#x60;held&#x60;. Nur aus &#x60;active&#x60; zulässig.
[**recordPayment**](DeliveriesApi.md#recordpayment) | **POST** /deliveries/{delivery_id}/payment | Protokolliert die Zahlungsabwicklung („Abkassieren\&quot;) einer Lieferung. Append-only, idempotent über &#x60;clientEventId&#x60;. Der Server berechnet den offenen Betrag selbst; er muss &gt; 0 sein und &#x60;expectedAmountCents&#x60; entsprechen. Nur bei aktiver Lieferung und aktiver Zahlungsmethode.
[**resume**](DeliveriesApi.md#resume) | **POST** /deliveries/{delivery_id}/resume | Setzt die Lieferung zurück auf &#x60;active&#x60;. Nur aus &#x60;held&#x60; zulässig.
[**setService**](DeliveriesApi.md#setservice) | **PUT** /deliveries/{delivery_id}/services/{service_id} | Setzt (Upsert) den Wert eines Service für eine Lieferung. Genau das zum Service-Typ passende Feld (&#x60;boolValue&#x60;/&#x60;numericValue&#x60;) muss gesetzt sein; numerische Werte werden gegen min/max geprüft. Nur bei aktiver Lieferung.
[**updateNote**](DeliveriesApi.md#updatenote) | **PATCH** /deliveries/{delivery_id}/notes/{note_id} | Ändert Text/Bild einer Notiz. Innerhalb des (geteilten) Accounts darf jeder Fahrer Notizen pflegen — kein Autor-Check. &#x60;delivery_id&#x60; ist Teil des Pfads (REST-Konsistenz), die Notiz wird über &#x60;note_id&#x60; adressiert.
@ -365,6 +366,49 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **recordPayment**
> DeliveryPaymentResponse recordPayment(deliveryId, recordDeliveryPaymentRequest)
Protokolliert die Zahlungsabwicklung („Abkassieren\") einer Lieferung. Append-only, idempotent über `clientEventId`. Der Server berechnet den offenen Betrag selbst; er muss > 0 sein und `expectedAmountCents` entsprechen. Nur bei aktiver Lieferung und aktiver Zahlungsmethode.
### Example
```dart
import 'package:holzleitner_api/api.dart';
final api = HolzleitnerApi().getDeliveriesApi();
final String deliveryId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final RecordDeliveryPaymentRequest recordDeliveryPaymentRequest = ; // RecordDeliveryPaymentRequest |
try {
final response = api.recordPayment(deliveryId, recordDeliveryPaymentRequest);
print(response);
} catch on DioException (e) {
print('Exception when calling DeliveriesApi->recordPayment: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**deliveryId** | **String**| |
**recordDeliveryPaymentRequest** | [**RecordDeliveryPaymentRequest**](RecordDeliveryPaymentRequest.md)| |
### Return type
[**DeliveryPaymentResponse**](DeliveryPaymentResponse.md)
### Authorization
[bearer_auth](../README.md#bearer_auth)
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **resume**
> DeliveryResponse resume(deliveryId)

View File

@ -0,0 +1,27 @@
# holzleitner_api.model.DeliveryDetails
## Load the model package
```dart
import 'package:holzleitner_api/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**articles** | [**BuiltList&lt;Article&gt;**](Article.md) | Nur die von den Positionen referenzierten Artikel. |
**contactChannels** | [**BuiltList&lt;ContactChannel&gt;**](ContactChannel.md) | Die zu `contactSources` gehörenden Einzel-Kanäle. Join per `sourceId`. |
**contactSources** | [**BuiltList&lt;ContactSource&gt;**](ContactSource.md) | Kontaktquellen der Lieferung (Liefer-/Rechnungsadresse, Ansprechpartner …). |
**credit** | [**DeliveryCredit**](DeliveryCredit.md) | Aktuelle Betrags-Gutschrift (`null`, wenn keine aktiv). | [optional]
**customer** | [**Customer**](Customer.md) | Der Kunde der Lieferung (`null`, falls unauffindbar — sollte nicht vorkommen). | [optional]
**customerContacts** | [**BuiltList&lt;CustomerContact&gt;**](CustomerContact.md) | Ansprechpartner des Kunden. |
**delivery** | [**DeliveryWithItems**](DeliveryWithItems.md) | Die Lieferung selbst inkl. Positionen (mit Scan-Ständen) und `sortOrder`. |
**deliveryServices** | [**BuiltList&lt;DeliveryServiceValue&gt;**](DeliveryServiceValue.md) | Für diese Lieferung gesetzte Service-Werte. |
**notes** | [**BuiltList&lt;DeliveryNote&gt;**](DeliveryNote.md) | Notizen der Lieferung, aufsteigend nach `createdAt`. |
**payment** | [**DeliveryPayment**](DeliveryPayment.md) | Jüngste protokollierte Zahlungsabwicklung (`None` = keine). | [optional]
**services** | [**BuiltList&lt;Service&gt;**](Service.md) | Aktive Service-Definitionen (Stammdaten). |
**tour** | [**Tour**](Tour.md) | Die Tour, zu der die Lieferung gehört. |
**warehouses** | [**BuiltList&lt;Warehouse&gt;**](Warehouse.md) | Nur die von den Positionen referenzierten Lager. |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -0,0 +1,22 @@
# holzleitner_api.model.DeliveryPayment
## Load the model package
```dart
import 'package:holzleitner_api/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**amountCents** | **int** | Abgewickelter Betrag in Cent (offener Betrag bei Bestätigung). |
**deliveryId** | **String** | |
**id** | **String** | |
**paymentMethodCode** | **String** | Snapshot des Methoden-Codes (`cash`, `ec_card`, `invoice`, …). |
**paymentMethodId** | **String** | |
**recordedAt** | [**DateTime**](DateTime.md) | |
**recordedByCarId** | **String** | | [optional]
**recordedByPersonalnummer** | **int** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -0,0 +1,15 @@
# holzleitner_api.model.DeliveryPaymentResponse
## Load the model package
```dart
import 'package:holzleitner_api/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**payment** | [**DeliveryPayment**](DeliveryPayment.md) | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -0,0 +1,23 @@
# holzleitner_api.model.PendingReviewResponse
## Load the model package
```dart
import 'package:holzleitner_api/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**creditedItems** | [**BuiltList&lt;ReviewedItemResponse&gt;**](ReviewedItemResponse.md) | Entfernte/teil-gutgeschriebene Positionen. |
**customerName** | **String** | |
**deliveryId** | **String** | |
**erpBelegartId** | **int** | |
**erpBelegnummer** | **String** | |
**lastChangeAt** | **String** | Zeitpunkt der letzten beleg-ändernden Aktion (RFC 3339). |
**moneyCreditCents** | **int** | Geld-Gutschrift in Cent (0 = keine). |
**moneyCreditReason** | **String** | | [optional]
**tourDate** | **String** | Tourdatum (ISO `YYYY-MM-DD`). |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -0,0 +1,16 @@
# holzleitner_api.model.PositionsModifiedResponse
## Load the model package
```dart
import 'package:holzleitner_api/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**belegnummer** | **String** | ERP-Belegnummer, nach der gefragt wurde. |
**positionsModified** | **bool** | `true`, wenn an der Lieferung Positionen verändert wurden — eine Zeile wurde entfernt oder in der Menge reduziert (Stück-Gutschrift) **oder** es liegt eine aktive Geld-Gutschrift vor. |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -0,0 +1,18 @@
# holzleitner_api.model.RecordDeliveryPaymentRequest
## Load the model package
```dart
import 'package:holzleitner_api/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**authorCarId** | **String** | Fahrzeug des Akteurs (Audit-Spur). Muss zum Account gehören. | [optional]
**clientEventId** | **String** | Idempotenz-Schlüssel — pro Bestätigung genau einmal vergeben. |
**expectedAmountCents** | **int** | Betrag in Cent, den der Fahrer in der App gesehen und bestätigt hat. Der Server berechnet den offenen Betrag selbst und lehnt ab, wenn er davon abweicht — so landet nie ein Betrag im Protokoll, den der Fahrer nicht gesehen hat. |
**paymentMethodId** | **String** | Gewählte Zahlungsmethode. Muss existieren und aktiv sein. |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -0,0 +1,16 @@
# holzleitner_api.model.ResolveReviewRequest
## Load the model package
```dart
import 'package:holzleitner_api/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**note** | **String** | Optionale Notiz zur getroffenen Entscheidung. | [optional]
**resolvedBy** | **String** | Bearbeiter (Name/Kürzel), der die Prüfung bestätigt. |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -0,0 +1,20 @@
# holzleitner_api.model.ReviewedItemResponse
## Load the model package
```dart
import 'package:holzleitner_api/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**articleName** | **String** | |
**artikelNr** | **String** | |
**belegzeilenNr** | **int** | |
**creditedQuantity** | **int** | |
**reason** | **String** | | [optional]
**requiredQuantity** | **int** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -9,6 +9,7 @@ import 'package:holzleitner_api/api.dart';
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**abteilung** | **String** | | [optional]
**address** | [**Address**](Address.md) | Anschrift dieses Adress-Datensatzes, falls gepflegt. | [optional]
**anrede** | **String** | | [optional]
**channels** | [**BuiltList&lt;SyncContactChannel&gt;**](SyncContactChannel.md) | | [optional]
**funktion** | **String** | | [optional]

View File

@ -17,6 +17,7 @@ Name | Type | Description | Notes
**deliveries** | [**BuiltList&lt;DeliveryWithItems&gt;**](DeliveryWithItems.md) | |
**deliveryServices** | [**BuiltList&lt;DeliveryServiceValue&gt;**](DeliveryServiceValue.md) | Pro-Lieferung gesetzte Service-Werte. Join per `delivery_id` + `service_id`. |
**notes** | [**BuiltList&lt;DeliveryNote&gt;**](DeliveryNote.md) | Alle Notizen aller Lieferungen dieser Tour, in einer Liste. Die App joint clientseitig per `delivery_id`. Reihenfolge: pro Lieferung aufsteigend nach `created_at`. |
**payments** | [**BuiltList&lt;DeliveryPayment&gt;**](DeliveryPayment.md) | Jüngste protokollierte Zahlungsabwicklung pro Lieferung (nur Lieferungen mit mindestens einem Eintrag). Join per `delivery_id`. |
**services** | [**BuiltList&lt;Service&gt;**](Service.md) | Aktive Service-Definitionen (Stammdaten) — die App rendert daraus Phase 4. Bewusst hier mitgeliefert, damit die Detailseite alles aus dem Tour-Aggregat hat. |
**tour** | [**Tour**](Tour.md) | |
**warehouses** | [**BuiltList&lt;Warehouse&gt;**](Warehouse.md) | |

View File

@ -10,7 +10,7 @@ All URIs are relative to *http://localhost*
Method | HTTP request | Description
------------- | ------------- | -------------
[**getTour**](ToursApi.md#gettour) | **GET** /tours/{tour_id} | Lädt eine Tour mit allen Lieferungen, Positionen und referenzierten Stammdaten — die App nutzt das als einzigen großen Read.
[**listMyToursToday**](ToursApi.md#listmytourstoday) | **GET** /me/tours/today | Listet heutige Touren des angemeldeten Fahrers (Filter aus dem JWT).
[**listMyTours**](ToursApi.md#listmytours) | **GET** /me/tours | Listet die Touren des angemeldeten Fahrers (Filter aus dem JWT) für ein Ziel-Datum. Ohne &#x60;?date&#x3D;&#x60; gilt „heute\&quot;. Der Fahrer wählt das Datum aktiv im Kopf-Kalender der App (z. B. um die Tour von morgen vorab zu sehen).
[**setDeliveryOrder**](ToursApi.md#setdeliveryorder) | **PUT** /tours/{tour_id}/delivery-order | Schreibt die Sortier-Reihenfolge aller Lieferungen einer Tour neu. Der Client schickt die **vollständige** neue Reihenfolge; fehlende oder fremde Lieferungs-Ids werden mit &#x60;400 validation&#x60; abgelehnt.
@ -55,27 +55,31 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **listMyToursToday**
> TourSummaryList listMyToursToday()
# **listMyTours**
> TourSummaryList listMyTours(date)
Listet heutige Touren des angemeldeten Fahrers (Filter aus dem JWT).
Listet die Touren des angemeldeten Fahrers (Filter aus dem JWT) für ein Ziel-Datum. Ohne `?date=` gilt „heute\". Der Fahrer wählt das Datum aktiv im Kopf-Kalender der App (z. B. um die Tour von morgen vorab zu sehen).
### Example
```dart
import 'package:holzleitner_api/api.dart';
final api = HolzleitnerApi().getToursApi();
final String date = date_example; // String | Ziel-Tourdatum YYYY-MM-DD (Default: heute)
try {
final response = api.listMyToursToday();
final response = api.listMyTours(date);
print(response);
} catch on DioException (e) {
print('Exception when calling ToursApi->listMyToursToday: $e\n');
print('Exception when calling ToursApi->listMyTours: $e\n');
}
```
### Parameters
This endpoint does not need any parameter.
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**date** | **String**| Ziel-Tourdatum YYYY-MM-DD (Default: heute) | [optional]
### Return type

View File

@ -34,6 +34,8 @@ export 'package:holzleitner_api/src/model/car.dart';
export 'package:holzleitner_api/src/model/car_response.dart';
export 'package:holzleitner_api/src/model/cars_list.dart';
export 'package:holzleitner_api/src/model/complete_delivery_acknowledgements.dart';
export 'package:holzleitner_api/src/model/completed_deliveries_response.dart';
export 'package:holzleitner_api/src/model/completed_delivery_item.dart';
export 'package:holzleitner_api/src/model/contact_channel.dart';
export 'package:holzleitner_api/src/model/contact_kind.dart';
export 'package:holzleitner_api/src/model/contact_role.dart';
@ -50,10 +52,13 @@ export 'package:holzleitner_api/src/model/delivery.dart';
export 'package:holzleitner_api/src/model/delivery_credit.dart';
export 'package:holzleitner_api/src/model/delivery_credit_event_request.dart';
export 'package:holzleitner_api/src/model/delivery_credit_response.dart';
export 'package:holzleitner_api/src/model/delivery_details.dart';
export 'package:holzleitner_api/src/model/delivery_item.dart';
export 'package:holzleitner_api/src/model/delivery_note.dart';
export 'package:holzleitner_api/src/model/delivery_note_response.dart';
export 'package:holzleitner_api/src/model/delivery_order_entry.dart';
export 'package:holzleitner_api/src/model/delivery_payment.dart';
export 'package:holzleitner_api/src/model/delivery_payment_response.dart';
export 'package:holzleitner_api/src/model/delivery_response.dart';
export 'package:holzleitner_api/src/model/delivery_service_response.dart';
export 'package:holzleitner_api/src/model/delivery_service_value.dart';
@ -66,6 +71,11 @@ export 'package:holzleitner_api/src/model/mark_mail_sent_response.dart';
export 'package:holzleitner_api/src/model/payment_method.dart';
export 'package:holzleitner_api/src/model/payment_method_response.dart';
export 'package:holzleitner_api/src/model/payment_methods_list.dart';
export 'package:holzleitner_api/src/model/pending_review_response.dart';
export 'package:holzleitner_api/src/model/positions_modified_response.dart';
export 'package:holzleitner_api/src/model/record_delivery_payment_request.dart';
export 'package:holzleitner_api/src/model/resolve_review_request.dart';
export 'package:holzleitner_api/src/model/reviewed_item_response.dart';
export 'package:holzleitner_api/src/model/scan_event.dart';
export 'package:holzleitner_api/src/model/scan_result.dart';
export 'package:holzleitner_api/src/model/scan_result_status.dart';

View File

@ -8,11 +8,17 @@ import 'package:built_value/json_object.dart';
import 'package:built_value/serializer.dart';
import 'package:dio/dio.dart';
import 'package:built_collection/built_collection.dart';
import 'package:holzleitner_api/src/api_util.dart';
import 'package:holzleitner_api/src/model/completed_deliveries_response.dart';
import 'package:holzleitner_api/src/model/delivered_belegnummern_response.dart';
import 'package:holzleitner_api/src/model/delivery_details.dart';
import 'package:holzleitner_api/src/model/import_summary.dart';
import 'package:holzleitner_api/src/model/mark_mail_sent_request.dart';
import 'package:holzleitner_api/src/model/mark_mail_sent_response.dart';
import 'package:holzleitner_api/src/model/pending_review_response.dart';
import 'package:holzleitner_api/src/model/positions_modified_response.dart';
import 'package:holzleitner_api/src/model/resolve_review_request.dart';
class AdminApi {
@ -22,6 +28,181 @@ class AdminApi {
const AdminApi(this._dio, this._serializers);
/// Liefert das **volle Detail-Paket** zu einer ERP-Belegnummer: die Lieferung mit Positionen (inkl. Scan-Ständen), Kunde + Ansprechpartner, referenzierte Artikel/Lager, Notizen, Geld-Gutschrift, Dienstleistungswerte und Kontakte — destilliert aus dem Tour-Aggregat. Jede Lieferung, unabhängig vom Status. &#x60;404&#x60;, wenn die Belegnummer unbekannt ist.
///
///
/// Parameters:
/// * [belegnummer] - ERP-Belegnummer, z. B. V-30690291
/// * [cancelToken] - A [CancelToken] that can be used to cancel the operation
/// * [headers] - Can be used to add additional headers to the request
/// * [extras] - Can be used to add flags to the request
/// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response
/// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress
/// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress
///
/// Returns a [Future] containing a [Response] with a [DeliveryDetails] as data
/// Throws [DioException] if API call or serialization fails
Future<Response<DeliveryDetails>> belegeDetails({
required String belegnummer,
CancelToken? cancelToken,
Map<String, dynamic>? headers,
Map<String, dynamic>? extra,
ValidateStatus? validateStatus,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
}) async {
final _path = r'/admin/belege/{belegnummer}'.replaceAll('{' r'belegnummer' '}', encodeQueryParameter(_serializers, belegnummer, const FullType(String)).toString());
final _options = Options(
method: r'GET',
headers: <String, dynamic>{
...?headers,
},
extra: <String, dynamic>{
'secure': <Map<String, String>>[
{
'type': 'apiKey',
'name': 'admin_api_key',
'keyName': 'X-Admin-Api-Key',
'where': 'header',
},
],
...?extra,
},
validateStatus: validateStatus,
);
final _response = await _dio.request<Object>(
_path,
options: _options,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
);
DeliveryDetails? _responseData;
try {
final rawResponse = _response.data;
_responseData = rawResponse == null ? null : _serializers.deserialize(
rawResponse,
specifiedType: const FullType(DeliveryDetails),
) as DeliveryDetails;
} catch (error, stackTrace) {
throw DioException(
requestOptions: _response.requestOptions,
response: _response,
type: DioExceptionType.unknown,
error: error,
stackTrace: stackTrace,
);
}
return Response<DeliveryDetails>(
data: _responseData,
headers: _response.headers,
isRedirect: _response.isRedirect,
requestOptions: _response.requestOptions,
redirects: _response.redirects,
statusCode: _response.statusCode,
statusMessage: _response.statusMessage,
extra: _response.extra,
);
}
/// Liefert **alle** abgeschlossenen (ausgelieferten) Lieferungen — optional auf einen Datumsbereich eingegrenzt, unabhängig vom Mail-Versand-Status. Gefiltert wird über den **Berliner** Kalendertag des Abschluss-Zeitpunkts (&#x60;completed_at&#x60;).
/// Parameter (alle &#x60;DD-MM-YYYY&#x60;, alle optional): &#x60;day&#x60; &#x3D; Einzeltag (Kurzform &#x60;from&#x3D;to&#x3D;day&#x60;), sonst &#x60;from&#x60;/&#x60;to&#x60; als **inklusive** Bereichsgrenzen (je offen). **Ohne jeden Parameter → ALLE ausgelieferten Belege** (kein Datumsfilter). Pro Lieferung: Belegnummer + &#x60;positions_modified&#x60; (Menge reduziert/Zeile entfernt oder Geld-Gutschrift). Die Halb-Grenzen-Variante ist für Range-Filter gedacht: &#x60;?from&#x3D;…&#x60; und &#x60;?to&#x3D;…&#x60; liefern je eine Menge, deren SQL-&#x60;AND&#x60;-Schnitt den Zeitraum ergibt.
///
/// Parameters:
/// * [day] - Einzeltag DD-MM-YYYY (Kurzform from=to)
/// * [from] - Untere Grenze DD-MM-YYYY (inklusive)
/// * [to] - Obere Grenze DD-MM-YYYY (inklusive)
/// * [cancelToken] - A [CancelToken] that can be used to cancel the operation
/// * [headers] - Can be used to add additional headers to the request
/// * [extras] - Can be used to add flags to the request
/// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response
/// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress
/// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress
///
/// Returns a [Future] containing a [Response] with a [CompletedDeliveriesResponse] as data
/// Throws [DioException] if API call or serialization fails
Future<Response<CompletedDeliveriesResponse>> completedDeliveries({
String? day,
String? from,
String? to,
CancelToken? cancelToken,
Map<String, dynamic>? headers,
Map<String, dynamic>? extra,
ValidateStatus? validateStatus,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
}) async {
final _path = r'/admin/completed-deliveries';
final _options = Options(
method: r'GET',
headers: <String, dynamic>{
...?headers,
},
extra: <String, dynamic>{
'secure': <Map<String, String>>[
{
'type': 'apiKey',
'name': 'admin_api_key',
'keyName': 'X-Admin-Api-Key',
'where': 'header',
},
],
...?extra,
},
validateStatus: validateStatus,
);
final _queryParameters = <String, dynamic>{
if (day != null) r'day': encodeQueryParameter(_serializers, day, const FullType(String)),
if (from != null) r'from': encodeQueryParameter(_serializers, from, const FullType(String)),
if (to != null) r'to': encodeQueryParameter(_serializers, to, const FullType(String)),
};
final _response = await _dio.request<Object>(
_path,
options: _options,
queryParameters: _queryParameters,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
);
CompletedDeliveriesResponse? _responseData;
try {
final rawResponse = _response.data;
_responseData = rawResponse == null ? null : _serializers.deserialize(
rawResponse,
specifiedType: const FullType(CompletedDeliveriesResponse),
) as CompletedDeliveriesResponse;
} catch (error, stackTrace) {
throw DioException(
requestOptions: _response.requestOptions,
response: _response,
type: DioExceptionType.unknown,
error: error,
stackTrace: stackTrace,
);
}
return Response<CompletedDeliveriesResponse>(
data: _responseData,
headers: _response.headers,
isRedirect: _response.isRedirect,
requestOptions: _response.requestOptions,
redirects: _response.redirects,
statusCode: _response.statusCode,
statusMessage: _response.statusMessage,
extra: _response.extra,
);
}
/// Liefert die Belegnummern ausgelieferter (abgeschlossener) Lieferungen, **deren Liefermail noch nicht versendet wurde** (&#x60;mail_sent_at IS NULL&#x60;). „Ausgeliefert\&quot; &#x3D; es existiert ein Abschluss. Mit &#x60;day&#x60; (DD-MM-YYYY) nur Abschlüsse dieses Berliner Kalendertages; **ohne &#x60;day&#x60; alle offenen** (über alle Tage) — so bleiben Belege über Mitternacht nicht hängen.
///
///
@ -196,6 +377,86 @@ class AdminApi {
);
}
/// Listet alle geänderten Lieferscheine, die noch auf eine manuelle Bestätigung (Vier-Augen) warten — Entfernungen und/oder Geld-Gutschriften.
///
///
/// Parameters:
/// * [cancelToken] - A [CancelToken] that can be used to cancel the operation
/// * [headers] - Can be used to add additional headers to the request
/// * [extras] - Can be used to add flags to the request
/// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response
/// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress
/// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress
///
/// Returns a [Future] containing a [Response] with a [BuiltList<PendingReviewResponse>] as data
/// Throws [DioException] if API call or serialization fails
Future<Response<BuiltList<PendingReviewResponse>>> listReviews({
CancelToken? cancelToken,
Map<String, dynamic>? headers,
Map<String, dynamic>? extra,
ValidateStatus? validateStatus,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
}) async {
final _path = r'/admin/reviews';
final _options = Options(
method: r'GET',
headers: <String, dynamic>{
...?headers,
},
extra: <String, dynamic>{
'secure': <Map<String, String>>[
{
'type': 'apiKey',
'name': 'admin_api_key',
'keyName': 'X-Admin-Api-Key',
'where': 'header',
},
],
...?extra,
},
validateStatus: validateStatus,
);
final _response = await _dio.request<Object>(
_path,
options: _options,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
);
BuiltList<PendingReviewResponse>? _responseData;
try {
final rawResponse = _response.data;
_responseData = rawResponse == null ? null : _serializers.deserialize(
rawResponse,
specifiedType: const FullType(BuiltList, [FullType(PendingReviewResponse)]),
) as BuiltList<PendingReviewResponse>;
} catch (error, stackTrace) {
throw DioException(
requestOptions: _response.requestOptions,
response: _response,
type: DioExceptionType.unknown,
error: error,
stackTrace: stackTrace,
);
}
return Response<BuiltList<PendingReviewResponse>>(
data: _responseData,
headers: _response.headers,
isRedirect: _response.isRedirect,
requestOptions: _response.requestOptions,
redirects: _response.redirects,
statusCode: _response.statusCode,
statusMessage: _response.statusMessage,
extra: _response.extra,
);
}
/// Markiert die Liefermails der angegebenen Belegnummern als **versendet** (&#x60;mail_sent_at &#x3D; now()&#x60;, nur wo noch offen). Vom Mailclient aufzurufen, NACHDEM ERPframe die Mails erfolgreich verschickt hat — danach erscheinen die Belege nicht mehr in &#x60;GET /admin/delivered-belegnummern&#x60;.
///
///
@ -298,6 +559,88 @@ class AdminApi {
);
}
/// Liefert zu **einer** ERP-Belegnummer, ob an der Lieferung Positionen verändert wurden (Menge reduziert/Zeile entfernt oder Geld-Gutschrift) — unabhängig vom Zustand der Lieferung. &#x60;404&#x60;, wenn die Belegnummer unbekannt ist. Gibt es mehrere Lieferungen mit derselben Belegnummer, ist das Flag &#x60;true&#x60;, sobald **eine** davon verändert ist.
///
///
/// Parameters:
/// * [belegnummer] - ERP-Belegnummer, z. B. V-30690291
/// * [cancelToken] - A [CancelToken] that can be used to cancel the operation
/// * [headers] - Can be used to add additional headers to the request
/// * [extras] - Can be used to add flags to the request
/// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response
/// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress
/// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress
///
/// Returns a [Future] containing a [Response] with a [PositionsModifiedResponse] as data
/// Throws [DioException] if API call or serialization fails
Future<Response<PositionsModifiedResponse>> positionsModified({
required String belegnummer,
CancelToken? cancelToken,
Map<String, dynamic>? headers,
Map<String, dynamic>? extra,
ValidateStatus? validateStatus,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
}) async {
final _path = r'/admin/belege/{belegnummer}/positions-modified'.replaceAll('{' r'belegnummer' '}', encodeQueryParameter(_serializers, belegnummer, const FullType(String)).toString());
final _options = Options(
method: r'GET',
headers: <String, dynamic>{
...?headers,
},
extra: <String, dynamic>{
'secure': <Map<String, String>>[
{
'type': 'apiKey',
'name': 'admin_api_key',
'keyName': 'X-Admin-Api-Key',
'where': 'header',
},
],
...?extra,
},
validateStatus: validateStatus,
);
final _response = await _dio.request<Object>(
_path,
options: _options,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
);
PositionsModifiedResponse? _responseData;
try {
final rawResponse = _response.data;
_responseData = rawResponse == null ? null : _serializers.deserialize(
rawResponse,
specifiedType: const FullType(PositionsModifiedResponse),
) as PositionsModifiedResponse;
} catch (error, stackTrace) {
throw DioException(
requestOptions: _response.requestOptions,
response: _response,
type: DioExceptionType.unknown,
error: error,
stackTrace: stackTrace,
);
}
return Response<PositionsModifiedResponse>(
data: _responseData,
headers: _response.headers,
isRedirect: _response.isRedirect,
requestOptions: _response.requestOptions,
redirects: _response.redirects,
statusCode: _response.statusCode,
statusMessage: _response.statusMessage,
extra: _response.extra,
);
}
/// Stößt das ERP-Rückschreiben eines bereits lokal abgeschlossenen Lieferabschlusses erneut an (idempotenter Retry, falls der automatische Push beim Abschluss fehlschlug).
///
///
@ -357,4 +700,80 @@ class AdminApi {
return _response;
}
/// Bestätigt die Prüfung einer geänderten Lieferung (Vier-Augen) — die Lieferung verschwindet danach aus &#x60;GET /admin/reviews&#x60; (sofern nicht erneut geändert).
///
///
/// Parameters:
/// * [deliveryId] - UUID der Lieferung
/// * [resolveReviewRequest]
/// * [cancelToken] - A [CancelToken] that can be used to cancel the operation
/// * [headers] - Can be used to add additional headers to the request
/// * [extras] - Can be used to add flags to the request
/// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response
/// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress
/// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress
///
/// Returns a [Future]
/// Throws [DioException] if API call or serialization fails
Future<Response<void>> resolveReview({
required String deliveryId,
required ResolveReviewRequest resolveReviewRequest,
CancelToken? cancelToken,
Map<String, dynamic>? headers,
Map<String, dynamic>? extra,
ValidateStatus? validateStatus,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
}) async {
final _path = r'/admin/reviews/{delivery_id}/resolve'.replaceAll('{' r'delivery_id' '}', encodeQueryParameter(_serializers, deliveryId, const FullType(String)).toString());
final _options = Options(
method: r'POST',
headers: <String, dynamic>{
...?headers,
},
extra: <String, dynamic>{
'secure': <Map<String, String>>[
{
'type': 'apiKey',
'name': 'admin_api_key',
'keyName': 'X-Admin-Api-Key',
'where': 'header',
},
],
...?extra,
},
contentType: 'application/json',
validateStatus: validateStatus,
);
dynamic _bodyData;
try {
const _type = FullType(ResolveReviewRequest);
_bodyData = _serializers.serialize(resolveReviewRequest, specifiedType: _type);
} catch(error, stackTrace) {
throw DioException(
requestOptions: _options.compose(
_dio.options,
_path,
),
type: DioExceptionType.unknown,
error: error,
stackTrace: stackTrace,
);
}
final _response = await _dio.request<Object>(
_path,
data: _bodyData,
options: _options,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
);
return _response;
}
}

View File

@ -15,9 +15,11 @@ import 'package:holzleitner_api/src/model/create_delivery_note_request.dart';
import 'package:holzleitner_api/src/model/delivery_credit_event_request.dart';
import 'package:holzleitner_api/src/model/delivery_credit_response.dart';
import 'package:holzleitner_api/src/model/delivery_note_response.dart';
import 'package:holzleitner_api/src/model/delivery_payment_response.dart';
import 'package:holzleitner_api/src/model/delivery_response.dart';
import 'package:holzleitner_api/src/model/delivery_service_response.dart';
import 'package:holzleitner_api/src/model/hold_delivery_request.dart';
import 'package:holzleitner_api/src/model/record_delivery_payment_request.dart';
import 'package:holzleitner_api/src/model/set_delivery_service_request.dart';
import 'package:holzleitner_api/src/model/update_delivery_note_request.dart';
@ -736,6 +738,109 @@ class DeliveriesApi {
);
}
/// Protokolliert die Zahlungsabwicklung („Abkassieren\&quot;) einer Lieferung. Append-only, idempotent über &#x60;clientEventId&#x60;. Der Server berechnet den offenen Betrag selbst; er muss &gt; 0 sein und &#x60;expectedAmountCents&#x60; entsprechen. Nur bei aktiver Lieferung und aktiver Zahlungsmethode.
///
///
/// Parameters:
/// * [deliveryId]
/// * [recordDeliveryPaymentRequest]
/// * [cancelToken] - A [CancelToken] that can be used to cancel the operation
/// * [headers] - Can be used to add additional headers to the request
/// * [extras] - Can be used to add flags to the request
/// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response
/// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress
/// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress
///
/// Returns a [Future] containing a [Response] with a [DeliveryPaymentResponse] as data
/// Throws [DioException] if API call or serialization fails
Future<Response<DeliveryPaymentResponse>> recordPayment({
required String deliveryId,
required RecordDeliveryPaymentRequest recordDeliveryPaymentRequest,
CancelToken? cancelToken,
Map<String, dynamic>? headers,
Map<String, dynamic>? extra,
ValidateStatus? validateStatus,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
}) async {
final _path = r'/deliveries/{delivery_id}/payment'.replaceAll('{' r'delivery_id' '}', encodeQueryParameter(_serializers, deliveryId, const FullType(String)).toString());
final _options = Options(
method: r'POST',
headers: <String, dynamic>{
...?headers,
},
extra: <String, dynamic>{
'secure': <Map<String, String>>[
{
'type': 'http',
'scheme': 'bearer',
'name': 'bearer_auth',
},
],
...?extra,
},
contentType: 'application/json',
validateStatus: validateStatus,
);
dynamic _bodyData;
try {
const _type = FullType(RecordDeliveryPaymentRequest);
_bodyData = _serializers.serialize(recordDeliveryPaymentRequest, specifiedType: _type);
} catch(error, stackTrace) {
throw DioException(
requestOptions: _options.compose(
_dio.options,
_path,
),
type: DioExceptionType.unknown,
error: error,
stackTrace: stackTrace,
);
}
final _response = await _dio.request<Object>(
_path,
data: _bodyData,
options: _options,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
);
DeliveryPaymentResponse? _responseData;
try {
final rawResponse = _response.data;
_responseData = rawResponse == null ? null : _serializers.deserialize(
rawResponse,
specifiedType: const FullType(DeliveryPaymentResponse),
) as DeliveryPaymentResponse;
} catch (error, stackTrace) {
throw DioException(
requestOptions: _response.requestOptions,
response: _response,
type: DioExceptionType.unknown,
error: error,
stackTrace: stackTrace,
);
}
return Response<DeliveryPaymentResponse>(
data: _responseData,
headers: _response.headers,
isRedirect: _response.isRedirect,
requestOptions: _response.requestOptions,
redirects: _response.redirects,
statusCode: _response.statusCode,
statusMessage: _response.statusMessage,
extra: _response.extra,
);
}
/// Setzt die Lieferung zurück auf &#x60;active&#x60;. Nur aus &#x60;held&#x60; zulässig.
///
///

View File

@ -103,11 +103,11 @@ class ToursApi {
);
}
/// Listet die Touren des angemeldeten Fahrers (Filter aus dem JWT) für ein
/// Ziel-Datum. Ohne [date] gilt serverseitig „heute".
/// Listet die Touren des angemeldeten Fahrers (Filter aus dem JWT) für ein Ziel-Datum. Ohne &#x60;?date&#x3D;&#x60; gilt „heute\&quot;. Der Fahrer wählt das Datum aktiv im Kopf-Kalender der App (z. B. um die Tour von morgen vorab zu sehen).
///
///
/// Parameters:
/// * [date] - Ziel-Tourdatum im Format YYYY-MM-DD (Default: heute)
/// * [date] - Ziel-Tourdatum YYYY-MM-DD (Default: heute)
/// * [cancelToken] - A [CancelToken] that can be used to cancel the operation
/// * [headers] - Can be used to add additional headers to the request
/// * [extras] - Can be used to add flags to the request
@ -146,7 +146,7 @@ class ToursApi {
);
final _queryParameters = <String, dynamic>{
if (date != null) r'date': date,
if (date != null) r'date': encodeQueryParameter(_serializers, date, const FullType(String)),
};
final _response = await _dio.request<Object>(

View File

@ -11,16 +11,22 @@ part 'address.g.dart';
/// Postanschrift — wird sowohl als aktuelle Kundenanschrift in [`Customer`] als auch als unveränderlicher Snapshot in [`crate::domain::Delivery`] verwendet (`delivery_address_snapshot`). Bewusst als Value Object modelliert: gleiche Adresse = gleicher Wert. Strikte Equality erleichtert Sync-Diffs zwischen ERP und Backend. [`Customer`]: crate::domain::Customer
///
/// Properties:
/// * [addition] - Freier Adresszusatz. Kommt aus dem ERP-Textfeld `Adressen.Land`, das die Sachbearbeitung praktisch für Ortsteil, Etage oder Hinweise nutzt (z. B. „Holzheim\", „3. Etage\", „Parterre\"). `None`, wenn leer.
/// * [city]
/// * [country]
/// * [country] - Land laut ERP-Länderstamm (`Adressen.LandID` → `Laender.Land`), z. B. „Deutschland\" oder „Belgien\". Leer, wenn im ERP kein Land verknüpft ist.
/// * [houseNumber]
/// * [postalCode]
/// * [street]
@BuiltValue()
abstract class Address implements Built<Address, AddressBuilder> {
/// Freier Adresszusatz. Kommt aus dem ERP-Textfeld `Adressen.Land`, das die Sachbearbeitung praktisch für Ortsteil, Etage oder Hinweise nutzt (z. B. „Holzheim\", „3. Etage\", „Parterre\"). `None`, wenn leer.
@BuiltValueField(wireName: r'addition')
String? get addition;
@BuiltValueField(wireName: r'city')
String get city;
/// Land laut ERP-Länderstamm (`Adressen.LandID` → `Laender.Land`), z. B. „Deutschland\" oder „Belgien\". Leer, wenn im ERP kein Land verknüpft ist.
@BuiltValueField(wireName: r'country')
String get country;
@ -56,6 +62,13 @@ class _$AddressSerializer implements PrimitiveSerializer<Address> {
Address object, {
FullType specifiedType = FullType.unspecified,
}) sync* {
if (object.addition != null) {
yield r'addition';
yield serializers.serialize(
object.addition,
specifiedType: const FullType.nullable(String),
);
}
yield r'city';
yield serializers.serialize(
object.city,
@ -104,6 +117,14 @@ class _$AddressSerializer implements PrimitiveSerializer<Address> {
final key = serializedList[i] as String;
final value = serializedList[i + 1];
switch (key) {
case r'addition':
final valueDes = serializers.deserialize(
value,
specifiedType: const FullType.nullable(String),
) as String?;
if (valueDes == null) continue;
result.addition = valueDes;
break;
case r'city':
final valueDes = serializers.deserialize(
value,

View File

@ -7,6 +7,8 @@ part of 'address.dart';
// **************************************************************************
class _$Address extends Address {
@override
final String? addition;
@override
final String city;
@override
@ -22,7 +24,8 @@ class _$Address extends Address {
(AddressBuilder()..update(updates))._build();
_$Address._(
{required this.city,
{this.addition,
required this.city,
required this.country,
required this.houseNumber,
required this.postalCode,
@ -39,6 +42,7 @@ class _$Address extends Address {
bool operator ==(Object other) {
if (identical(other, this)) return true;
return other is Address &&
addition == other.addition &&
city == other.city &&
country == other.country &&
houseNumber == other.houseNumber &&
@ -49,6 +53,7 @@ class _$Address extends Address {
@override
int get hashCode {
var _$hash = 0;
_$hash = $jc(_$hash, addition.hashCode);
_$hash = $jc(_$hash, city.hashCode);
_$hash = $jc(_$hash, country.hashCode);
_$hash = $jc(_$hash, houseNumber.hashCode);
@ -61,6 +66,7 @@ class _$Address extends Address {
@override
String toString() {
return (newBuiltValueToStringHelper(r'Address')
..add('addition', addition)
..add('city', city)
..add('country', country)
..add('houseNumber', houseNumber)
@ -73,6 +79,10 @@ class _$Address extends Address {
class AddressBuilder implements Builder<Address, AddressBuilder> {
_$Address? _$v;
String? _addition;
String? get addition => _$this._addition;
set addition(String? addition) => _$this._addition = addition;
String? _city;
String? get city => _$this._city;
set city(String? city) => _$this._city = city;
@ -100,6 +110,7 @@ class AddressBuilder implements Builder<Address, AddressBuilder> {
AddressBuilder get _$this {
final $v = _$v;
if ($v != null) {
_addition = $v.addition;
_city = $v.city;
_country = $v.country;
_houseNumber = $v.houseNumber;
@ -126,6 +137,7 @@ class AddressBuilder implements Builder<Address, AddressBuilder> {
_$Address _build() {
final _$result = _$v ??
_$Address._(
addition: addition,
city: BuiltValueNullFieldError.checkNotNull(city, r'Address', 'city'),
country: BuiltValueNullFieldError.checkNotNull(
country, r'Address', 'country'),

View File

@ -14,6 +14,7 @@ part 'complete_delivery_acknowledgements.g.dart';
/// Properties:
/// * [acknowledgedNoteIds] - Notiz-IDs, die zum Abschlusszeitpunkt sichtbar waren und mit-bestätigt wurden (Audit-Robustheit).
/// * [authorCarId] - Fahrzeug des Akteurs (Audit-Spur). Muss zum Account gehören.
/// * [internalNote] - Optionale interne Notiz des Fahrers, abgefragt nach beiden Unterschriften. Nur für interne Zwecke (Lieferbericht), nicht Teil der Kunden-Bestätigung. Leer/Whitespace = keine Notiz; max. 2000 Zeichen.
/// * [notesAcknowledged] - „Anmerkungen zur Lieferung zur Kenntnis genommen.\" — Pflicht nur, wenn Notizen existieren (das prüft der Server).
/// * [paymentCollected] - Inkasso-Bestätigung des Fahrers: „der offene Betrag wurde erhalten (bar) bzw. über das EC-Gerät abgerechnet.\" Pflicht nur, wenn beim Abschluss ein offener Betrag > 0 besteht UND die Methode ein Vor-Ort- Inkasso ist (Bar/EC) — das prüft der Server. Der kassierte Betrag wird server-seitig autoritativ berechnet (nicht vom Client übernommen).
/// * [paymentMethodId] - Optionale Zahlungsmethode, die der Fahrer beim Abschluss gewählt hat. `None` = die am Beleg hinterlegte Methode bleibt. Falls gesetzt, muss sie existieren **und** aktiv sein (vom Server geprüft).
@ -28,6 +29,10 @@ abstract class CompleteDeliveryAcknowledgements implements Built<CompleteDeliver
@BuiltValueField(wireName: r'authorCarId')
String? get authorCarId;
/// Optionale interne Notiz des Fahrers, abgefragt nach beiden Unterschriften. Nur für interne Zwecke (Lieferbericht), nicht Teil der Kunden-Bestätigung. Leer/Whitespace = keine Notiz; max. 2000 Zeichen.
@BuiltValueField(wireName: r'internalNote')
String? get internalNote;
/// „Anmerkungen zur Lieferung zur Kenntnis genommen.\" — Pflicht nur, wenn Notizen existieren (das prüft der Server).
@BuiltValueField(wireName: r'notesAcknowledged')
bool? get notesAcknowledged;
@ -81,6 +86,13 @@ class _$CompleteDeliveryAcknowledgementsSerializer implements PrimitiveSerialize
specifiedType: const FullType.nullable(String),
);
}
if (object.internalNote != null) {
yield r'internalNote';
yield serializers.serialize(
object.internalNote,
specifiedType: const FullType.nullable(String),
);
}
if (object.notesAcknowledged != null) {
yield r'notesAcknowledged';
yield serializers.serialize(
@ -145,6 +157,14 @@ class _$CompleteDeliveryAcknowledgementsSerializer implements PrimitiveSerialize
if (valueDes == null) continue;
result.authorCarId = valueDes;
break;
case r'internalNote':
final valueDes = serializers.deserialize(
value,
specifiedType: const FullType.nullable(String),
) as String?;
if (valueDes == null) continue;
result.internalNote = valueDes;
break;
case r'notesAcknowledged':
final valueDes = serializers.deserialize(
value,

View File

@ -13,6 +13,8 @@ class _$CompleteDeliveryAcknowledgements
@override
final String? authorCarId;
@override
final String? internalNote;
@override
final bool? notesAcknowledged;
@override
final bool? paymentCollected;
@ -28,6 +30,7 @@ class _$CompleteDeliveryAcknowledgements
_$CompleteDeliveryAcknowledgements._(
{this.acknowledgedNoteIds,
this.authorCarId,
this.internalNote,
this.notesAcknowledged,
this.paymentCollected,
this.paymentMethodId,
@ -48,6 +51,7 @@ class _$CompleteDeliveryAcknowledgements
return other is CompleteDeliveryAcknowledgements &&
acknowledgedNoteIds == other.acknowledgedNoteIds &&
authorCarId == other.authorCarId &&
internalNote == other.internalNote &&
notesAcknowledged == other.notesAcknowledged &&
paymentCollected == other.paymentCollected &&
paymentMethodId == other.paymentMethodId &&
@ -59,6 +63,7 @@ class _$CompleteDeliveryAcknowledgements
var _$hash = 0;
_$hash = $jc(_$hash, acknowledgedNoteIds.hashCode);
_$hash = $jc(_$hash, authorCarId.hashCode);
_$hash = $jc(_$hash, internalNote.hashCode);
_$hash = $jc(_$hash, notesAcknowledged.hashCode);
_$hash = $jc(_$hash, paymentCollected.hashCode);
_$hash = $jc(_$hash, paymentMethodId.hashCode);
@ -72,6 +77,7 @@ class _$CompleteDeliveryAcknowledgements
return (newBuiltValueToStringHelper(r'CompleteDeliveryAcknowledgements')
..add('acknowledgedNoteIds', acknowledgedNoteIds)
..add('authorCarId', authorCarId)
..add('internalNote', internalNote)
..add('notesAcknowledged', notesAcknowledged)
..add('paymentCollected', paymentCollected)
..add('paymentMethodId', paymentMethodId)
@ -96,6 +102,10 @@ class CompleteDeliveryAcknowledgementsBuilder
String? get authorCarId => _$this._authorCarId;
set authorCarId(String? authorCarId) => _$this._authorCarId = authorCarId;
String? _internalNote;
String? get internalNote => _$this._internalNote;
set internalNote(String? internalNote) => _$this._internalNote = internalNote;
bool? _notesAcknowledged;
bool? get notesAcknowledged => _$this._notesAcknowledged;
set notesAcknowledged(bool? notesAcknowledged) =>
@ -125,6 +135,7 @@ class CompleteDeliveryAcknowledgementsBuilder
if ($v != null) {
_acknowledgedNoteIds = $v.acknowledgedNoteIds?.toBuilder();
_authorCarId = $v.authorCarId;
_internalNote = $v.internalNote;
_notesAcknowledged = $v.notesAcknowledged;
_paymentCollected = $v.paymentCollected;
_paymentMethodId = $v.paymentMethodId;
@ -154,6 +165,7 @@ class CompleteDeliveryAcknowledgementsBuilder
_$CompleteDeliveryAcknowledgements._(
acknowledgedNoteIds: _acknowledgedNoteIds?.build(),
authorCarId: authorCarId,
internalNote: internalNote,
notesAcknowledged: notesAcknowledged,
paymentCollected: paymentCollected,
paymentMethodId: paymentMethodId,

View File

@ -0,0 +1,166 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// ignore_for_file: unused_element
import 'package:holzleitner_api/src/model/completed_delivery_item.dart';
import 'package:built_collection/built_collection.dart';
import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';
part 'completed_deliveries_response.g.dart';
/// CompletedDeliveriesResponse
///
/// Properties:
/// * [count] - Anzahl der abgeschlossenen Lieferungen im Bereich.
/// * [deliveries] - Die abgeschlossenen Lieferungen, aufsteigend nach Abschluss-Zeitpunkt.
/// * [from] - Wirksame untere Grenze (ISO `YYYY-MM-DD`) oder `null` (offen).
/// * [to] - Wirksame obere Grenze (ISO `YYYY-MM-DD`) oder `null` (offen).
@BuiltValue()
abstract class CompletedDeliveriesResponse implements Built<CompletedDeliveriesResponse, CompletedDeliveriesResponseBuilder> {
/// Anzahl der abgeschlossenen Lieferungen im Bereich.
@BuiltValueField(wireName: r'count')
int get count;
/// Die abgeschlossenen Lieferungen, aufsteigend nach Abschluss-Zeitpunkt.
@BuiltValueField(wireName: r'deliveries')
BuiltList<CompletedDeliveryItem> get deliveries;
/// Wirksame untere Grenze (ISO `YYYY-MM-DD`) oder `null` (offen).
@BuiltValueField(wireName: r'from')
String? get from;
/// Wirksame obere Grenze (ISO `YYYY-MM-DD`) oder `null` (offen).
@BuiltValueField(wireName: r'to')
String? get to;
CompletedDeliveriesResponse._();
factory CompletedDeliveriesResponse([void updates(CompletedDeliveriesResponseBuilder b)]) = _$CompletedDeliveriesResponse;
@BuiltValueHook(initializeBuilder: true)
static void _defaults(CompletedDeliveriesResponseBuilder b) => b;
@BuiltValueSerializer(custom: true)
static Serializer<CompletedDeliveriesResponse> get serializer => _$CompletedDeliveriesResponseSerializer();
}
class _$CompletedDeliveriesResponseSerializer implements PrimitiveSerializer<CompletedDeliveriesResponse> {
@override
final Iterable<Type> types = const [CompletedDeliveriesResponse, _$CompletedDeliveriesResponse];
@override
final String wireName = r'CompletedDeliveriesResponse';
Iterable<Object?> _serializeProperties(
Serializers serializers,
CompletedDeliveriesResponse object, {
FullType specifiedType = FullType.unspecified,
}) sync* {
yield r'count';
yield serializers.serialize(
object.count,
specifiedType: const FullType(int),
);
yield r'deliveries';
yield serializers.serialize(
object.deliveries,
specifiedType: const FullType(BuiltList, [FullType(CompletedDeliveryItem)]),
);
if (object.from != null) {
yield r'from';
yield serializers.serialize(
object.from,
specifiedType: const FullType.nullable(String),
);
}
if (object.to != null) {
yield r'to';
yield serializers.serialize(
object.to,
specifiedType: const FullType.nullable(String),
);
}
}
@override
Object serialize(
Serializers serializers,
CompletedDeliveriesResponse object, {
FullType specifiedType = FullType.unspecified,
}) {
return _serializeProperties(serializers, object, specifiedType: specifiedType).toList();
}
void _deserializeProperties(
Serializers serializers,
Object serialized, {
FullType specifiedType = FullType.unspecified,
required List<Object?> serializedList,
required CompletedDeliveriesResponseBuilder result,
required List<Object?> unhandled,
}) {
for (var i = 0; i < serializedList.length; i += 2) {
final key = serializedList[i] as String;
final value = serializedList[i + 1];
switch (key) {
case r'count':
final valueDes = serializers.deserialize(
value,
specifiedType: const FullType(int),
) as int;
result.count = valueDes;
break;
case r'deliveries':
final valueDes = serializers.deserialize(
value,
specifiedType: const FullType(BuiltList, [FullType(CompletedDeliveryItem)]),
) as BuiltList<CompletedDeliveryItem>;
result.deliveries.replace(valueDes);
break;
case r'from':
final valueDes = serializers.deserialize(
value,
specifiedType: const FullType.nullable(String),
) as String?;
if (valueDes == null) continue;
result.from = valueDes;
break;
case r'to':
final valueDes = serializers.deserialize(
value,
specifiedType: const FullType.nullable(String),
) as String?;
if (valueDes == null) continue;
result.to = valueDes;
break;
default:
unhandled.add(key);
unhandled.add(value);
break;
}
}
}
@override
CompletedDeliveriesResponse deserialize(
Serializers serializers,
Object serialized, {
FullType specifiedType = FullType.unspecified,
}) {
final result = CompletedDeliveriesResponseBuilder();
final serializedList = (serialized as Iterable<Object?>).toList();
final unhandled = <Object?>[];
_deserializeProperties(
serializers,
serialized,
specifiedType: specifiedType,
serializedList: serializedList,
unhandled: unhandled,
result: result,
);
return result.build();
}
}

View File

@ -0,0 +1,147 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'completed_deliveries_response.dart';
// **************************************************************************
// BuiltValueGenerator
// **************************************************************************
class _$CompletedDeliveriesResponse extends CompletedDeliveriesResponse {
@override
final int count;
@override
final BuiltList<CompletedDeliveryItem> deliveries;
@override
final String? from;
@override
final String? to;
factory _$CompletedDeliveriesResponse(
[void Function(CompletedDeliveriesResponseBuilder)? updates]) =>
(CompletedDeliveriesResponseBuilder()..update(updates))._build();
_$CompletedDeliveriesResponse._(
{required this.count, required this.deliveries, this.from, this.to})
: super._();
@override
CompletedDeliveriesResponse rebuild(
void Function(CompletedDeliveriesResponseBuilder) updates) =>
(toBuilder()..update(updates)).build();
@override
CompletedDeliveriesResponseBuilder toBuilder() =>
CompletedDeliveriesResponseBuilder()..replace(this);
@override
bool operator ==(Object other) {
if (identical(other, this)) return true;
return other is CompletedDeliveriesResponse &&
count == other.count &&
deliveries == other.deliveries &&
from == other.from &&
to == other.to;
}
@override
int get hashCode {
var _$hash = 0;
_$hash = $jc(_$hash, count.hashCode);
_$hash = $jc(_$hash, deliveries.hashCode);
_$hash = $jc(_$hash, from.hashCode);
_$hash = $jc(_$hash, to.hashCode);
_$hash = $jf(_$hash);
return _$hash;
}
@override
String toString() {
return (newBuiltValueToStringHelper(r'CompletedDeliveriesResponse')
..add('count', count)
..add('deliveries', deliveries)
..add('from', from)
..add('to', to))
.toString();
}
}
class CompletedDeliveriesResponseBuilder
implements
Builder<CompletedDeliveriesResponse,
CompletedDeliveriesResponseBuilder> {
_$CompletedDeliveriesResponse? _$v;
int? _count;
int? get count => _$this._count;
set count(int? count) => _$this._count = count;
ListBuilder<CompletedDeliveryItem>? _deliveries;
ListBuilder<CompletedDeliveryItem> get deliveries =>
_$this._deliveries ??= ListBuilder<CompletedDeliveryItem>();
set deliveries(ListBuilder<CompletedDeliveryItem>? deliveries) =>
_$this._deliveries = deliveries;
String? _from;
String? get from => _$this._from;
set from(String? from) => _$this._from = from;
String? _to;
String? get to => _$this._to;
set to(String? to) => _$this._to = to;
CompletedDeliveriesResponseBuilder() {
CompletedDeliveriesResponse._defaults(this);
}
CompletedDeliveriesResponseBuilder get _$this {
final $v = _$v;
if ($v != null) {
_count = $v.count;
_deliveries = $v.deliveries.toBuilder();
_from = $v.from;
_to = $v.to;
_$v = null;
}
return this;
}
@override
void replace(CompletedDeliveriesResponse other) {
_$v = other as _$CompletedDeliveriesResponse;
}
@override
void update(void Function(CompletedDeliveriesResponseBuilder)? updates) {
if (updates != null) updates(this);
}
@override
CompletedDeliveriesResponse build() => _build();
_$CompletedDeliveriesResponse _build() {
_$CompletedDeliveriesResponse _$result;
try {
_$result = _$v ??
_$CompletedDeliveriesResponse._(
count: BuiltValueNullFieldError.checkNotNull(
count, r'CompletedDeliveriesResponse', 'count'),
deliveries: deliveries.build(),
from: from,
to: to,
);
} catch (_) {
late String _$failedField;
try {
_$failedField = 'deliveries';
deliveries.build();
} catch (e) {
throw BuiltValueNestedFieldError(
r'CompletedDeliveriesResponse', _$failedField, e.toString());
}
rethrow;
}
replace(_$result);
return _$result;
}
}
// ignore_for_file: deprecated_member_use_from_same_package,type=lint

View File

@ -0,0 +1,124 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// ignore_for_file: unused_element
import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';
part 'completed_delivery_item.g.dart';
/// Eine abgeschlossene Lieferung im Tagesabruf.
///
/// Properties:
/// * [belegnummer] - ERP-Belegnummer der Lieferung.
/// * [positionsModified] - `true`, wenn an der Lieferung Positionen verändert wurden — eine Zeile wurde entfernt oder in der Menge reduziert (Stück-Gutschrift) **oder** es liegt eine aktive Geld-Gutschrift vor.
@BuiltValue()
abstract class CompletedDeliveryItem implements Built<CompletedDeliveryItem, CompletedDeliveryItemBuilder> {
/// ERP-Belegnummer der Lieferung.
@BuiltValueField(wireName: r'belegnummer')
String get belegnummer;
/// `true`, wenn an der Lieferung Positionen verändert wurden — eine Zeile wurde entfernt oder in der Menge reduziert (Stück-Gutschrift) **oder** es liegt eine aktive Geld-Gutschrift vor.
@BuiltValueField(wireName: r'positions_modified')
bool get positionsModified;
CompletedDeliveryItem._();
factory CompletedDeliveryItem([void updates(CompletedDeliveryItemBuilder b)]) = _$CompletedDeliveryItem;
@BuiltValueHook(initializeBuilder: true)
static void _defaults(CompletedDeliveryItemBuilder b) => b;
@BuiltValueSerializer(custom: true)
static Serializer<CompletedDeliveryItem> get serializer => _$CompletedDeliveryItemSerializer();
}
class _$CompletedDeliveryItemSerializer implements PrimitiveSerializer<CompletedDeliveryItem> {
@override
final Iterable<Type> types = const [CompletedDeliveryItem, _$CompletedDeliveryItem];
@override
final String wireName = r'CompletedDeliveryItem';
Iterable<Object?> _serializeProperties(
Serializers serializers,
CompletedDeliveryItem object, {
FullType specifiedType = FullType.unspecified,
}) sync* {
yield r'belegnummer';
yield serializers.serialize(
object.belegnummer,
specifiedType: const FullType(String),
);
yield r'positions_modified';
yield serializers.serialize(
object.positionsModified,
specifiedType: const FullType(bool),
);
}
@override
Object serialize(
Serializers serializers,
CompletedDeliveryItem object, {
FullType specifiedType = FullType.unspecified,
}) {
return _serializeProperties(serializers, object, specifiedType: specifiedType).toList();
}
void _deserializeProperties(
Serializers serializers,
Object serialized, {
FullType specifiedType = FullType.unspecified,
required List<Object?> serializedList,
required CompletedDeliveryItemBuilder result,
required List<Object?> unhandled,
}) {
for (var i = 0; i < serializedList.length; i += 2) {
final key = serializedList[i] as String;
final value = serializedList[i + 1];
switch (key) {
case r'belegnummer':
final valueDes = serializers.deserialize(
value,
specifiedType: const FullType(String),
) as String;
result.belegnummer = valueDes;
break;
case r'positions_modified':
final valueDes = serializers.deserialize(
value,
specifiedType: const FullType(bool),
) as bool;
result.positionsModified = valueDes;
break;
default:
unhandled.add(key);
unhandled.add(value);
break;
}
}
}
@override
CompletedDeliveryItem deserialize(
Serializers serializers,
Object serialized, {
FullType specifiedType = FullType.unspecified,
}) {
final result = CompletedDeliveryItemBuilder();
final serializedList = (serialized as Iterable<Object?>).toList();
final unhandled = <Object?>[];
_deserializeProperties(
serializers,
serialized,
specifiedType: specifiedType,
serializedList: serializedList,
unhandled: unhandled,
result: result,
);
return result.build();
}
}

Some files were not shown because too many files have changed in this diff Show More