Compare commits
20 Commits
7b7cb4eb63
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ff331294e | |||
| a5215367fd | |||
| dce4447fcc | |||
| 5a9e16dc10 | |||
| 335cf02a9f | |||
| d650c735f8 | |||
| 857c92031a | |||
| 99247b4b67 | |||
| 200992262a | |||
| 6b934e4534 | |||
| dd7fe9a8eb | |||
| f832b69b9d | |||
| c239cdfd3e | |||
| 7cf17466b3 | |||
| a027c93c62 | |||
| 3add6b498e | |||
| 7c6f99dda1 | |||
| c5219343a0 | |||
| 633b7631aa | |||
| 83d52364a5 |
12
assets/feature_flags.json
Normal file
12
assets/feature_flags.json
Normal 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@ -1,20 +1,79 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:hl_lieferservice/bloc/app_events.dart';
|
import 'package:hl_lieferservice/bloc/app_events.dart';
|
||||||
import 'package:hl_lieferservice/bloc/app_states.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.
|
/// App-Bootstrap-Bloc.
|
||||||
///
|
///
|
||||||
/// Vor der Backend-Migration lud dieser Bloc eine `hl_server_config.json` aus
|
/// Liest die Server-Umgebungen aus `assets/hl_server_config.json`,
|
||||||
/// assets, parste daraus eine `backendUrl` und persistierte sie ins Dateisystem.
|
/// bestimmt die aktive Umgebung (gespeicherte Wahl, sonst Default) und
|
||||||
/// Mit dem Wechsel auf das Rust-Backend kommt die URL über `BackendConfig`
|
/// registriert dafür das Networking. Außerdem lädt er die Feature-Schalter
|
||||||
/// (compile-time, siehe `data/network/backend_config.dart`); der App-Bloc
|
/// aus `assets/feature_flags.json`. Ein Umgebungswechsel läuft über denselben
|
||||||
/// emittiert jetzt nur noch sofort `AppConfigLoaded`, damit die UI ihre
|
/// Weg; die UI baut ihren Bloc-Baum pro Umgebung neu auf.
|
||||||
/// üblichen Phasen-Übergänge behält.
|
|
||||||
class AppBloc extends Bloc<AppEvents, AppState> {
|
class AppBloc extends Bloc<AppEvents, AppState> {
|
||||||
AppBloc() : super(const AppInitial()) {
|
AppBloc({
|
||||||
on<AppLoadConfig>((event, emit) {
|
required EnvironmentRepository repository,
|
||||||
emit(const AppConfigLoading());
|
required EnvironmentActivator activate,
|
||||||
emit(const AppConfigLoaded());
|
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());
|
||||||
|
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,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,16 @@
|
|||||||
abstract class AppEvents {}
|
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 {
|
class AppLoadConfig extends AppEvents {
|
||||||
String path;
|
String path;
|
||||||
|
|
||||||
AppLoadConfig({required this.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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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.
|
/// Lifecycle-States des App-Bootstraps.
|
||||||
///
|
///
|
||||||
/// Die alte `LocalDocuFrameConfiguration` mit `backendUrl` ist mit der
|
/// `AppConfigLoaded` heißt: Server-Config ist gelesen, die aktive Umgebung
|
||||||
/// Backend-Migration entfallen — die App-Konfiguration kommt jetzt aus
|
/// steht fest, das Networking dafür ist im Locator registriert und die
|
||||||
/// `BackendConfig` (compile-time) und nicht mehr aus einer asset-JSON.
|
/// Feature-Schalter sind geladen.
|
||||||
/// Das `AppConfigLoaded`-Signal bleibt als Marker, dass der App-Bootstrap
|
|
||||||
/// abgeschlossen ist (Networking ist registriert, Token-Provider steht).
|
|
||||||
abstract class AppState {
|
abstract class AppState {
|
||||||
const AppState();
|
const AppState();
|
||||||
}
|
}
|
||||||
@ -18,7 +19,20 @@ class AppConfigLoading extends AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class AppConfigLoaded 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 {
|
class AppConfigLoadingFailed extends AppState {
|
||||||
|
|||||||
@ -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/customer.dart';
|
||||||
import 'package:hl_lieferservice/domain/entity/delivery.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_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_item.dart';
|
||||||
import 'package:hl_lieferservice/domain/entity/delivery_note.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/delivery_service_value.dart';
|
||||||
@ -30,6 +31,7 @@ extension ApiAddressMapper on api.Address {
|
|||||||
postalCode: postalCode,
|
postalCode: postalCode,
|
||||||
city: city,
|
city: city,
|
||||||
country: country,
|
country: country,
|
||||||
|
addition: addition,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,6 +117,7 @@ extension ApiContactSourceMapper on api.ContactSource {
|
|||||||
name3: name3,
|
name3: name3,
|
||||||
abteilung: abteilung,
|
abteilung: abteilung,
|
||||||
funktion: funktion,
|
funktion: funktion,
|
||||||
|
address: address?.toDomain(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -335,6 +338,11 @@ extension ApiTourDetailsMapper on api.TourDetails {
|
|||||||
for (final c in credits) c.deliveryId: c.toDomain(),
|
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.
|
// Service-Definitionen (aktiv, sortiert) + Pro-Lieferung-Werte indizieren.
|
||||||
final servicesList =
|
final servicesList =
|
||||||
services.map((s) => s.toDomain()).toList(growable: false);
|
services.map((s) => s.toDomain()).toList(growable: false);
|
||||||
@ -367,6 +375,7 @@ extension ApiTourDetailsMapper on api.TourDetails {
|
|||||||
warehouses: warehousesMap,
|
warehouses: warehousesMap,
|
||||||
notesByDeliveryId: notesGrouped,
|
notesByDeliveryId: notesGrouped,
|
||||||
creditsByDeliveryId: creditsMap,
|
creditsByDeliveryId: creditsMap,
|
||||||
|
paymentsByDeliveryId: paymentsMap,
|
||||||
services: servicesList,
|
services: servicesList,
|
||||||
serviceValuesByDeliveryId: serviceValues,
|
serviceValuesByDeliveryId: serviceValues,
|
||||||
contactSourcesByDeliveryId: sourcesGrouped,
|
contactSourcesByDeliveryId: sourcesGrouped,
|
||||||
@ -406,3 +415,14 @@ extension ApiDeliveryCreditMapper on api.DeliveryCredit {
|
|||||||
reason: reason,
|
reason: reason,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ApiDeliveryPaymentMapper on api.DeliveryPayment {
|
||||||
|
DeliveryPayment toDomain() => DeliveryPayment(
|
||||||
|
id: id,
|
||||||
|
deliveryId: deliveryId,
|
||||||
|
paymentMethodId: paymentMethodId,
|
||||||
|
paymentMethodCode: paymentMethodCode,
|
||||||
|
amountCents: amountCents,
|
||||||
|
recordedAt: recordedAt.toLocal(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -5,5 +5,25 @@
|
|||||||
abstract interface class AuthTokenProvider {
|
abstract interface class AuthTokenProvider {
|
||||||
/// Liefert einen aktuell gültigen Access-Token oder `null`, wenn
|
/// Liefert einen aktuell gültigen Access-Token oder `null`, wenn
|
||||||
/// keine Session aktiv ist. Darf bei Bedarf einen Refresh anstoßen.
|
/// 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();
|
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)';
|
||||||
|
}
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
/// Endpoint-Konfiguration für das Rust-Backend.
|
/// Endpoint-Konfiguration für das Rust-Backend.
|
||||||
///
|
///
|
||||||
/// Produktiv-Setup: Backend und Keycloak laufen auf dem Host `192.168.1.9`.
|
/// Wird nicht mehr im Code festgelegt, sondern aus der gewählten
|
||||||
/// Es gibt bewusst KEINE Dev-/localhost-/Tunnel-Profile und keine
|
/// Umgebung in `assets/hl_server_config.json` abgeleitet
|
||||||
/// `HL_BACKEND`-Umschaltung mehr — die App zeigt immer auf Prod. So kann kein
|
/// (`ServerCatalog.configFor`). Die aktive Instanz liegt im GetIt-Locator.
|
||||||
/// Build versehentlich auf `localhost` oder eine Dev-IP zeigen.
|
|
||||||
class BackendConfig {
|
class BackendConfig {
|
||||||
const BackendConfig({
|
const BackendConfig({
|
||||||
required this.apiBaseUrl,
|
required this.apiBaseUrl,
|
||||||
@ -37,17 +36,4 @@ class BackendConfig {
|
|||||||
/// dem `CFBundleURLSchemes`-Eintrag in `ios/Runner/Info.plist`
|
/// dem `CFBundleURLSchemes`-Eintrag in `ios/Runner/Info.plist`
|
||||||
/// matchen.
|
/// matchen.
|
||||||
final String keycloakRedirectUrl;
|
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
123
lib/data/network/backend_environment.dart
Normal file
123
lib/data/network/backend_environment.dart
Normal 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;
|
||||||
35
lib/data/network/environment_repository.dart
Normal file
35
lib/data/network/environment_repository.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -22,6 +22,20 @@ class HolzleitnerAuthInterceptor extends Interceptor {
|
|||||||
if (token != null) {
|
if (token != null) {
|
||||||
options.headers['Authorization'] = 'Bearer $token';
|
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) {
|
} catch (e, stack) {
|
||||||
// TODO Phase B: hier ein strukturiertes Logging-Framework
|
// TODO Phase B: hier ein strukturiertes Logging-Framework
|
||||||
// einhängen statt print.
|
// einhängen statt print.
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart' show PlatformException;
|
||||||
import 'package:flutter_appauth/flutter_appauth.dart';
|
import 'package:flutter_appauth/flutter_appauth.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
@ -62,6 +63,12 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
|
|||||||
/// können → App hängt nach Hot-Restart am Splash/Login).
|
/// können → App hängt nach Hot-Restart am Splash/Login).
|
||||||
Future<String?>? _refreshInFlight;
|
Future<String?>? _refreshInFlight;
|
||||||
|
|
||||||
|
/// Dedup-Sperre für den interaktiven Login: es darf zu jedem Zeitpunkt nur
|
||||||
|
/// EIN `authorizeAndExchangeCode` laufen. Ein zweiter Aufruf, während noch
|
||||||
|
/// einer offen ist, würde nativ mit „Connection already in progress"
|
||||||
|
/// abbrechen — dann bliebe der Browser zu. `null` = kein Login läuft.
|
||||||
|
Future<void>? _loginInFlight;
|
||||||
|
|
||||||
final StreamController<AuthSessionEvent> _events =
|
final StreamController<AuthSessionEvent> _events =
|
||||||
StreamController<AuthSessionEvent>.broadcast();
|
StreamController<AuthSessionEvent>.broadcast();
|
||||||
|
|
||||||
@ -80,27 +87,34 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
|
|||||||
|
|
||||||
/// Triggert den PKCE-Login-Flow. Wirft, wenn der User abbricht oder
|
/// Triggert den PKCE-Login-Flow. Wirft, wenn der User abbricht oder
|
||||||
/// Keycloak einen Fehler liefert.
|
/// Keycloak einen Fehler liefert.
|
||||||
Future<void> login() async {
|
///
|
||||||
final result = await _appAuth.authorizeAndExchangeCode(
|
/// Deduped: läuft bereits ein Login, wird dieselbe Future zurückgegeben
|
||||||
AuthorizationTokenRequest(
|
/// statt ein zweites `authorizeAndExchangeCode` zu starten (das nativ mit
|
||||||
_config.keycloakClientId,
|
/// „Connection already in progress" bräche → Browser bliebe zu). Nach
|
||||||
redirectUrl,
|
/// einem UI-Timeout gibt der AuthBloc die Sperre via [discardPendingLogin]
|
||||||
discoveryUrl: _discoveryUrl,
|
/// frei, damit ein erneuter Tap wieder einen frischen Browser-Tab öffnet.
|
||||||
scopes: const ['openid', 'profile'],
|
Future<void> login() {
|
||||||
// Lokales Dev-Setup hat HTTP-Keycloak — der Default-iOS-Browser
|
final existing = _loginInFlight;
|
||||||
// würde sonst abbrechen. In Produktion (HTTPS) ist das ein
|
if (existing != null) return existing;
|
||||||
// No-Op und kann bleiben.
|
late final Future<void> flow;
|
||||||
allowInsecureConnections: true,
|
flow = _login().whenComplete(() {
|
||||||
// Wichtig auf Android: ohne `prompt=login` würde Keycloak bei
|
// Nur freigeben, wenn WIR noch die aktive Login-Future sind. Sonst
|
||||||
// bestehender SSO-Session sofort 302 nach holzleitner://...
|
// würde die späte Completion eines per Timeout verworfenen Versuchs
|
||||||
// antworten, Chrome schließt den Custom Tab dabei so schnell,
|
// die Sperre eines inzwischen neu gestarteten Logins nullen.
|
||||||
// dass der Redirect-Intent unsere RedirectUriReceiverActivity
|
if (identical(_loginInFlight, flow)) _loginInFlight = null;
|
||||||
// gar nicht erreicht — AppAuth meldet stattdessen
|
});
|
||||||
// "User cancelled flow". Erzwingen der Login-Maske → echter
|
_loginInFlight = flow;
|
||||||
// User-Click → sauberer Intent-Dispatch.
|
return flow;
|
||||||
promptValues: const ['login'],
|
}
|
||||||
),
|
|
||||||
);
|
/// Gibt eine (typischerweise per UI-Timeout „aufgegebene") laufende
|
||||||
|
/// Login-Future frei. Der native Flow selbst ist nicht abbrechbar — wir
|
||||||
|
/// lösen nur unsere Dedup-Sperre, damit der nächste [login]-Aufruf einen
|
||||||
|
/// neuen Authorize-Request (frischer Browser-Tab) starten darf.
|
||||||
|
void discardPendingLogin() => _loginInFlight = null;
|
||||||
|
|
||||||
|
Future<void> _login() async {
|
||||||
|
final result = await _authorize();
|
||||||
|
|
||||||
_applyTokens(
|
_applyTokens(
|
||||||
accessToken: result.accessToken,
|
accessToken: result.accessToken,
|
||||||
@ -113,6 +127,52 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
|
|||||||
_events.add(AuthLoggedIn(_idTokenClaims ?? const <String, dynamic>{}));
|
_events.add(AuthLoggedIn(_idTokenClaims ?? const <String, dynamic>{}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Führt den nativen Authorize+Exchange aus. Blockiert ein vom vorigen
|
||||||
|
/// (verwaisten) Versuch nativ noch offener Flow den Aufruf mit
|
||||||
|
/// „Connection already in progress", warten wir kurz und versuchen es
|
||||||
|
/// EINMAL erneut — meist ist der alte Custom-Tab dann geschlossen.
|
||||||
|
Future<AuthorizationTokenResponse> _authorize() async {
|
||||||
|
try {
|
||||||
|
return await _appAuth.authorizeAndExchangeCode(_authRequest());
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
if (_looksLikeInProgress(e)) {
|
||||||
|
debugPrint('Login: nativer Flow noch offen — einmaliger Retry.');
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||||
|
return _appAuth.authorizeAndExchangeCode(_authRequest());
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthorizationTokenRequest _authRequest() => AuthorizationTokenRequest(
|
||||||
|
_config.keycloakClientId,
|
||||||
|
redirectUrl,
|
||||||
|
discoveryUrl: _discoveryUrl,
|
||||||
|
scopes: const ['openid', 'profile'],
|
||||||
|
// Lokales Dev-Setup hat HTTP-Keycloak — der Default-iOS-Browser
|
||||||
|
// würde sonst abbrechen. In Produktion (HTTPS) ist das ein
|
||||||
|
// No-Op und kann bleiben.
|
||||||
|
allowInsecureConnections: true,
|
||||||
|
// Wichtig auf Android: ohne `prompt=login` würde Keycloak bei
|
||||||
|
// bestehender SSO-Session sofort 302 nach holzleitner://...
|
||||||
|
// antworten, Chrome schließt den Custom Tab dabei so schnell,
|
||||||
|
// dass der Redirect-Intent unsere RedirectUriReceiverActivity
|
||||||
|
// gar nicht erreicht — AppAuth meldet stattdessen
|
||||||
|
// "User cancelled flow". Erzwingen der Login-Maske → echter
|
||||||
|
// User-Click → sauberer Intent-Dispatch.
|
||||||
|
promptValues: const ['login'],
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Heuristik: erkennt den „ein Authorize läuft bereits"-Fehler von
|
||||||
|
/// AppAuth (Android). Kein stabiler Code über Plattformen hinweg, daher
|
||||||
|
/// Code + Message defensiv prüfen.
|
||||||
|
static bool _looksLikeInProgress(PlatformException e) {
|
||||||
|
final haystack = '${e.code} ${e.message}'.toLowerCase();
|
||||||
|
return haystack.contains('already in progress') ||
|
||||||
|
haystack.contains('in progress') ||
|
||||||
|
haystack.contains('concurrent');
|
||||||
|
}
|
||||||
|
|
||||||
/// Versucht, eine vorhandene Session aus der Secure Storage zu
|
/// Versucht, eine vorhandene Session aus der Secure Storage zu
|
||||||
/// reaktivieren. Liefert `true`, wenn anschließend ein gültiger
|
/// reaktivieren. Liefert `true`, wenn anschließend ein gültiger
|
||||||
/// Access-Token verfügbar ist.
|
/// Access-Token verfügbar ist.
|
||||||
@ -134,7 +194,15 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
|
|||||||
if (stored == null || stored.isEmpty) return false;
|
if (stored == null || stored.isEmpty) return false;
|
||||||
_refreshToken = stored;
|
_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;
|
if (token == null) return false;
|
||||||
|
|
||||||
_events.add(AuthLoggedIn(_idTokenClaims ?? const <String, dynamic>{}));
|
_events.add(AuthLoggedIn(_idTokenClaims ?? const <String, dynamic>{}));
|
||||||
@ -183,8 +251,15 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
|
|||||||
|
|
||||||
/// Führt EINEN Token-Refresh aus. Bei Erfolg werden die Tokens übernommen
|
/// Führt EINEN Token-Refresh aus. Bei Erfolg werden die Tokens übernommen
|
||||||
/// und der neue Access-Token zurückgegeben (ohne Event — stiller Refresh).
|
/// 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 {
|
Future<String?> _performRefresh(String rt) async {
|
||||||
try {
|
try {
|
||||||
final result = await _appAuth.token(
|
final result = await _appAuth.token(
|
||||||
@ -205,8 +280,24 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
|
|||||||
);
|
);
|
||||||
await _persistRefreshToken();
|
await _persistRefreshToken();
|
||||||
return _accessToken;
|
return _accessToken;
|
||||||
} on Exception {
|
} on Exception catch (e) {
|
||||||
// Refresh hat nicht funktioniert — Session ist tot, nicht
|
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
|
// wiederherstellbar. Reihenfolge bewusst: erst State leeren + Event
|
||||||
// feuern, DANN best-effort den Storage löschen — so kann ein
|
// feuern, DANN best-effort den Storage löschen — so kann ein
|
||||||
// werfendes `delete` weder das Event verschlucken noch eine Exception
|
// werfendes `delete` weder das Event verschlucken noch eine Exception
|
||||||
@ -222,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({
|
void _applyTokens({
|
||||||
required String? accessToken,
|
required String? accessToken,
|
||||||
required String? refreshToken,
|
required String? refreshToken,
|
||||||
|
|||||||
@ -6,20 +6,18 @@ import 'backend_config.dart';
|
|||||||
import 'holzleitner_api_factory.dart';
|
import 'holzleitner_api_factory.dart';
|
||||||
import 'keycloak_oidc_token_provider.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
|
/// Aufgerufen vom `AppBloc`, sobald die Umgebung aus der Config-Datei
|
||||||
/// dem `runApp` — die API-Klassen sind über die gesamte App-Lebensdauer
|
/// feststeht — und erneut bei jedem Umgebungswechsel. Bereits registrierte
|
||||||
/// stabil.
|
/// Instanzen werden dann ersetzt; die App baut ihren Bloc-Baum danach neu
|
||||||
///
|
/// auf, damit niemand mehr den alten Client festhält.
|
||||||
/// Phase B: produktiver `KeycloakOidcTokenProvider`. Die alte
|
|
||||||
/// `DevPasswordGrantTokenProvider`-Implementation bleibt im Code (für
|
|
||||||
/// das reine dart-Smoke-Tool, siehe `tool/smoke_test_api.dart`).
|
|
||||||
void registerNetworking({
|
void registerNetworking({
|
||||||
required GetIt locator,
|
required GetIt locator,
|
||||||
BackendConfig config = BackendConfig.fromEnvironment,
|
required BackendConfig config,
|
||||||
}) {
|
}) {
|
||||||
locator.registerSingleton<BackendConfig>(config);
|
_replace<BackendConfig>(locator, config);
|
||||||
|
|
||||||
final provider = KeycloakOidcTokenProvider(
|
final provider = KeycloakOidcTokenProvider(
|
||||||
config: config,
|
config: config,
|
||||||
@ -29,13 +27,18 @@ void registerNetworking({
|
|||||||
// Doppelt registrieren: einmal unter der konkreten Klasse (für
|
// Doppelt registrieren: einmal unter der konkreten Klasse (für
|
||||||
// den AuthBloc, der Login/Logout/Restore aufruft) und einmal hinter
|
// den AuthBloc, der Login/Logout/Restore aufruft) und einmal hinter
|
||||||
// dem Interface (für den HTTP-Interceptor).
|
// dem Interface (für den HTTP-Interceptor).
|
||||||
locator.registerSingleton<KeycloakOidcTokenProvider>(provider);
|
_replace<KeycloakOidcTokenProvider>(locator, provider);
|
||||||
locator.registerSingleton<AuthTokenProvider>(provider);
|
_replace<AuthTokenProvider>(locator, provider);
|
||||||
|
|
||||||
locator.registerSingleton<HolzleitnerApi>(
|
_replace<HolzleitnerApi>(
|
||||||
buildHolzleitnerApi(
|
locator,
|
||||||
config: config,
|
buildHolzleitnerApi(config: config, tokenProvider: provider),
|
||||||
tokenProvider: locator<AuthTokenProvider>(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _replace<T extends Object>(GetIt locator, T instance) {
|
||||||
|
if (locator.isRegistered<T>()) {
|
||||||
|
locator.unregister<T>();
|
||||||
|
}
|
||||||
|
locator.registerSingleton<T>(instance);
|
||||||
|
}
|
||||||
|
|||||||
@ -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/address.dart';
|
||||||
import 'package:hl_lieferservice/domain/entity/delivery.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_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_note.dart';
|
||||||
import 'package:hl_lieferservice/domain/entity/delivery_service_value.dart';
|
import 'package:hl_lieferservice/domain/entity/delivery_service_value.dart';
|
||||||
import 'package:hl_lieferservice/domain/entity/scan_intent.dart';
|
import 'package:hl_lieferservice/domain/entity/scan_intent.dart';
|
||||||
@ -202,6 +203,7 @@ class TourRepositoryImpl implements TourRepository {
|
|||||||
String? paymentMethodId,
|
String? paymentMethodId,
|
||||||
String? actorCarId,
|
String? actorCarId,
|
||||||
bool paymentCollected = false,
|
bool paymentCollected = false,
|
||||||
|
String? internalNote,
|
||||||
}) async {
|
}) async {
|
||||||
// multipart/form-data: zwei Signatur-PNGs + ein JSON-Feld mit den
|
// multipart/form-data: zwei Signatur-PNGs + ein JSON-Feld mit den
|
||||||
// Bestätigungen. Direkt über die Dio-Instanz, weil der dart-dio-Generator
|
// Bestätigungen. Direkt über die Dio-Instanz, weil der dart-dio-Generator
|
||||||
@ -214,6 +216,7 @@ class TourRepositoryImpl implements TourRepository {
|
|||||||
'paymentCollected': paymentCollected,
|
'paymentCollected': paymentCollected,
|
||||||
if (paymentMethodId != null) 'paymentMethodId': paymentMethodId,
|
if (paymentMethodId != null) 'paymentMethodId': paymentMethodId,
|
||||||
if (actorCarId != null) 'authorCarId': actorCarId,
|
if (actorCarId != null) 'authorCarId': actorCarId,
|
||||||
|
if (internalNote != null) 'internalNote': internalNote,
|
||||||
};
|
};
|
||||||
final form = FormData.fromMap({
|
final form = FormData.fromMap({
|
||||||
'customer_signature': MultipartFile.fromBytes(
|
'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
|
@override
|
||||||
Future<DeliveryServiceValue> setDeliveryService({
|
Future<DeliveryServiceValue> setDeliveryService({
|
||||||
required String deliveryId,
|
required String deliveryId,
|
||||||
@ -502,6 +535,7 @@ class TourRepositoryImpl implements TourRepository {
|
|||||||
postalCode: snap['postalCode'] as String,
|
postalCode: snap['postalCode'] as String,
|
||||||
city: snap['city'] as String,
|
city: snap['city'] as String,
|
||||||
country: snap['country'] as String,
|
country: snap['country'] as String,
|
||||||
|
addition: snap['addition'] as String?,
|
||||||
),
|
),
|
||||||
erpBelegartId: (j['erpBelegartId'] as num).toInt(),
|
erpBelegartId: (j['erpBelegartId'] as num).toInt(),
|
||||||
erpBelegnummer: j['erpBelegnummer'] as String,
|
erpBelegnummer: j['erpBelegnummer'] as String,
|
||||||
|
|||||||
@ -12,17 +12,44 @@ class Address {
|
|||||||
required this.postalCode,
|
required this.postalCode,
|
||||||
required this.city,
|
required this.city,
|
||||||
required this.country,
|
required this.country,
|
||||||
|
this.addition,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String street;
|
final String street;
|
||||||
final String houseNumber;
|
final String houseNumber;
|
||||||
final String postalCode;
|
final String postalCode;
|
||||||
final String city;
|
final String city;
|
||||||
|
/// Land laut ERP-Länderstamm (z. B. „Deutschland"), leer wenn unbekannt.
|
||||||
final String country;
|
final String country;
|
||||||
|
|
||||||
/// Einzeilige Darstellung für Listen/Header.
|
/// Freier Adresszusatz aus dem ERP — praktisch Ortsteil, Etage oder
|
||||||
String get oneLine =>
|
/// Hinweis („Holzheim", „3 Etage", „Parterre"). `null`, wenn leer.
|
||||||
'$street $houseNumber, $postalCode $city';
|
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({
|
Address copyWith({
|
||||||
String? street,
|
String? street,
|
||||||
@ -30,6 +57,7 @@ class Address {
|
|||||||
String? postalCode,
|
String? postalCode,
|
||||||
String? city,
|
String? city,
|
||||||
String? country,
|
String? country,
|
||||||
|
String? addition,
|
||||||
}) {
|
}) {
|
||||||
return Address(
|
return Address(
|
||||||
street: street ?? this.street,
|
street: street ?? this.street,
|
||||||
@ -37,6 +65,7 @@ class Address {
|
|||||||
postalCode: postalCode ?? this.postalCode,
|
postalCode: postalCode ?? this.postalCode,
|
||||||
city: city ?? this.city,
|
city: city ?? this.city,
|
||||||
country: country ?? this.country,
|
country: country ?? this.country,
|
||||||
|
addition: addition ?? this.addition,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,8 +77,10 @@ class Address {
|
|||||||
other.houseNumber == houseNumber &&
|
other.houseNumber == houseNumber &&
|
||||||
other.postalCode == postalCode &&
|
other.postalCode == postalCode &&
|
||||||
other.city == city &&
|
other.city == city &&
|
||||||
other.country == country;
|
other.country == country &&
|
||||||
|
other.addition == addition;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(street, houseNumber, postalCode, city, country);
|
int get hashCode =>
|
||||||
|
Object.hash(street, houseNumber, postalCode, city, country, addition);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import 'address.dart';
|
||||||
|
|
||||||
/// Adress-Rolle eines Beleg-Kontakts. Spiegelt die fünf Adress-FKs am
|
/// Adress-Rolle eines Beleg-Kontakts. Spiegelt die fünf Adress-FKs am
|
||||||
/// ERP-`Belegkopf` (bzw. den Umweg über `Kunden.AdressId`). Die App nutzt
|
/// ERP-`Belegkopf` (bzw. den Umweg über `Kunden.AdressId`). Die App nutzt
|
||||||
/// das primär als Gruppierungs-Label in der Detail-Ansicht.
|
/// das primär als Gruppierungs-Label in der Detail-Ansicht.
|
||||||
@ -95,6 +97,7 @@ class ContactSource {
|
|||||||
this.name3,
|
this.name3,
|
||||||
this.abteilung,
|
this.abteilung,
|
||||||
this.funktion,
|
this.funktion,
|
||||||
|
this.address,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
@ -109,6 +112,10 @@ class ContactSource {
|
|||||||
final String? abteilung;
|
final String? abteilung;
|
||||||
final String? funktion;
|
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
|
/// Zusammengesetzte Anzeige des Namens — Anrede + Titel + Name1..3 in
|
||||||
/// dieser Reihenfolge, leere Felder werden übersprungen. Gibt `null`
|
/// dieser Reihenfolge, leere Felder werden übersprungen. Gibt `null`
|
||||||
/// zurück, wenn die Quelle gar keinen Namen trägt (kann vorkommen, wenn
|
/// zurück, wenn die Quelle gar keinen Namen trägt (kann vorkommen, wenn
|
||||||
@ -125,6 +132,17 @@ class ContactSource {
|
|||||||
return parts.join(' ');
|
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
|
/// Funktionale Zusatzinfo (z. B. „Buchhaltung · Leitung"). Leere
|
||||||
/// Komponenten werden ausgeblendet.
|
/// Komponenten werden ausgeblendet.
|
||||||
String? get subtitle {
|
String? get subtitle {
|
||||||
@ -178,6 +196,7 @@ class MergedContactSource {
|
|||||||
required this.abteilung,
|
required this.abteilung,
|
||||||
required this.funktion,
|
required this.funktion,
|
||||||
required this.channels,
|
required this.channels,
|
||||||
|
this.address,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Alle Rollen, die diesen zusammengeführten Kontakt liefern.
|
/// Alle Rollen, die diesen zusammengeführten Kontakt liefern.
|
||||||
@ -197,6 +216,9 @@ class MergedContactSource {
|
|||||||
/// liefert (kind + ERP-Position).
|
/// liefert (kind + ERP-Position).
|
||||||
final List<ContactChannel> channels;
|
final List<ContactChannel> channels;
|
||||||
|
|
||||||
|
/// Anschrift, falls im ERP gepflegt (bei allen Quellen der Gruppe gleich).
|
||||||
|
final Address? address;
|
||||||
|
|
||||||
/// Zusammengesetzter Anzeigename — identisch zu [ContactSource.displayName].
|
/// Zusammengesetzter Anzeigename — identisch zu [ContactSource.displayName].
|
||||||
String? get displayName {
|
String? get displayName {
|
||||||
final parts = <String>[
|
final parts = <String>[
|
||||||
|
|||||||
@ -11,10 +11,9 @@ enum DeliveryState { active, held, canceled, completed }
|
|||||||
|
|
||||||
/// Eine einzelne Auslieferung an einen Kunden innerhalb einer Tour.
|
/// Eine einzelne Auslieferung an einen Kunden innerhalb einer Tour.
|
||||||
///
|
///
|
||||||
/// Anders als im alten Modell trägt `Delivery` hier ausschließlich
|
/// Trägt die Logistik-Daten der Lieferung plus die Beträge, die der
|
||||||
/// Logistik-Daten — keine Preise, keine Rabatte, keine Zahlungsoptionen.
|
/// Zahlungs-Step braucht (Anzahlung, Zahlungsmethode vom Beleg; Stückpreise
|
||||||
/// Diese ERP-Themen sind in Phase C+D-2 absichtlich nicht migriert und
|
/// liegen an den Positionen).
|
||||||
/// hängen hinter `FeatureFlags`.
|
|
||||||
class Delivery {
|
class Delivery {
|
||||||
const Delivery({
|
const Delivery({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|||||||
32
lib/domain/entity/delivery_payment.dart
Normal file
32
lib/domain/entity/delivery_payment.dart
Normal 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';
|
||||||
|
}
|
||||||
35
lib/domain/entity/delivery_recipient.dart
Normal file
35
lib/domain/entity/delivery_recipient.dart
Normal 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;
|
||||||
|
}
|
||||||
@ -1,8 +1,11 @@
|
|||||||
|
import 'address.dart';
|
||||||
import 'article.dart';
|
import 'article.dart';
|
||||||
import 'contact_source.dart';
|
import 'contact_source.dart';
|
||||||
import 'customer.dart';
|
import 'customer.dart';
|
||||||
import 'delivery.dart';
|
import 'delivery.dart';
|
||||||
import 'delivery_credit.dart';
|
import 'delivery_credit.dart';
|
||||||
|
import 'delivery_payment.dart';
|
||||||
|
import 'delivery_recipient.dart';
|
||||||
import 'delivery_item.dart';
|
import 'delivery_item.dart';
|
||||||
import 'delivery_note.dart';
|
import 'delivery_note.dart';
|
||||||
import 'delivery_service_value.dart';
|
import 'delivery_service_value.dart';
|
||||||
@ -28,6 +31,7 @@ class TourDetails {
|
|||||||
required this.warehouses,
|
required this.warehouses,
|
||||||
required this.notesByDeliveryId,
|
required this.notesByDeliveryId,
|
||||||
required this.creditsByDeliveryId,
|
required this.creditsByDeliveryId,
|
||||||
|
required this.paymentsByDeliveryId,
|
||||||
required this.services,
|
required this.services,
|
||||||
required this.serviceValuesByDeliveryId,
|
required this.serviceValuesByDeliveryId,
|
||||||
required this.contactSourcesByDeliveryId,
|
required this.contactSourcesByDeliveryId,
|
||||||
@ -56,6 +60,10 @@ class TourDetails {
|
|||||||
/// der Eintrag, gibt es aktuell keine Gutschrift.
|
/// der Eintrag, gibt es aktuell keine Gutschrift.
|
||||||
final Map<String, DeliveryCredit> creditsByDeliveryId;
|
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
|
/// Aktive Service-Definitionen (Stammdaten), nach `sortOrder`. Daraus
|
||||||
/// rendert Phase 4 die Auswahl.
|
/// rendert Phase 4 die Auswahl.
|
||||||
final List<Service> services;
|
final List<Service> services;
|
||||||
@ -91,6 +99,52 @@ class TourDetails {
|
|||||||
|
|
||||||
Customer? customerOf(Delivery delivery) => customers[delivery.customerId];
|
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* {
|
Iterable<CustomerContact> contactsOf(Delivery delivery) sync* {
|
||||||
for (final id in delivery.contactPersonIds) {
|
for (final id in delivery.contactPersonIds) {
|
||||||
final c = contacts[id];
|
final c = contacts[id];
|
||||||
@ -157,6 +211,9 @@ class TourDetails {
|
|||||||
s.name3 ?? '',
|
s.name3 ?? '',
|
||||||
s.abteilung ?? '',
|
s.abteilung ?? '',
|
||||||
s.funktion ?? '',
|
s.funktion ?? '',
|
||||||
|
// Anschrift gehört zur Identität: gleicher Name an anderer Adresse
|
||||||
|
// ist ein anderer Datensatz.
|
||||||
|
s.address?.oneLineWithAddition ?? '',
|
||||||
].join('|');
|
].join('|');
|
||||||
final sortedChannels = List<ContactChannel>.of(channels)
|
final sortedChannels = List<ContactChannel>.of(channels)
|
||||||
..sort((a, b) {
|
..sort((a, b) {
|
||||||
@ -186,6 +243,7 @@ class TourDetails {
|
|||||||
abteilung: first.abteilung,
|
abteilung: first.abteilung,
|
||||||
funktion: first.funktion,
|
funktion: first.funktion,
|
||||||
channels: channelsOf(first),
|
channels: channelsOf(first),
|
||||||
|
address: first.address,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -200,6 +258,10 @@ class TourDetails {
|
|||||||
DeliveryCredit? creditOf(String deliveryId) =>
|
DeliveryCredit? creditOf(String deliveryId) =>
|
||||||
creditsByDeliveryId[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`.
|
/// Gesetzter Service-Wert dieser Lieferung für einen Service, oder `null`.
|
||||||
DeliveryServiceValue? serviceValueOf(String deliveryId, String serviceId) =>
|
DeliveryServiceValue? serviceValueOf(String deliveryId, String serviceId) =>
|
||||||
serviceValuesByDeliveryId[deliveryId]?[serviceId];
|
serviceValuesByDeliveryId[deliveryId]?[serviceId];
|
||||||
@ -398,6 +460,7 @@ class TourDetails {
|
|||||||
List<Delivery>? deliveries,
|
List<Delivery>? deliveries,
|
||||||
Map<String, List<DeliveryNote>>? notesByDeliveryId,
|
Map<String, List<DeliveryNote>>? notesByDeliveryId,
|
||||||
Map<String, DeliveryCredit>? creditsByDeliveryId,
|
Map<String, DeliveryCredit>? creditsByDeliveryId,
|
||||||
|
Map<String, DeliveryPayment>? paymentsByDeliveryId,
|
||||||
Map<String, Map<String, DeliveryServiceValue>>? serviceValuesByDeliveryId,
|
Map<String, Map<String, DeliveryServiceValue>>? serviceValuesByDeliveryId,
|
||||||
}) {
|
}) {
|
||||||
return TourDetails(
|
return TourDetails(
|
||||||
@ -409,6 +472,7 @@ class TourDetails {
|
|||||||
warehouses: warehouses,
|
warehouses: warehouses,
|
||||||
notesByDeliveryId: notesByDeliveryId ?? this.notesByDeliveryId,
|
notesByDeliveryId: notesByDeliveryId ?? this.notesByDeliveryId,
|
||||||
creditsByDeliveryId: creditsByDeliveryId ?? this.creditsByDeliveryId,
|
creditsByDeliveryId: creditsByDeliveryId ?? this.creditsByDeliveryId,
|
||||||
|
paymentsByDeliveryId: paymentsByDeliveryId ?? this.paymentsByDeliveryId,
|
||||||
services: services,
|
services: services,
|
||||||
serviceValuesByDeliveryId:
|
serviceValuesByDeliveryId:
|
||||||
serviceValuesByDeliveryId ?? this.serviceValuesByDeliveryId,
|
serviceValuesByDeliveryId ?? this.serviceValuesByDeliveryId,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import 'package:hl_lieferservice/domain/entity/delivery.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_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_note.dart';
|
||||||
import 'package:hl_lieferservice/domain/entity/delivery_service_value.dart';
|
import 'package:hl_lieferservice/domain/entity/delivery_service_value.dart';
|
||||||
import 'package:hl_lieferservice/domain/entity/scan_intent.dart';
|
import 'package:hl_lieferservice/domain/entity/scan_intent.dart';
|
||||||
@ -100,6 +101,7 @@ abstract interface class TourRepository {
|
|||||||
String? paymentMethodId,
|
String? paymentMethodId,
|
||||||
String? actorCarId,
|
String? actorCarId,
|
||||||
bool paymentCollected = false,
|
bool paymentCollected = false,
|
||||||
|
String? internalNote,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Legt eine neue Notiz an einer Lieferung an.
|
/// Legt eine neue Notiz an einer Lieferung an.
|
||||||
@ -166,6 +168,18 @@ abstract interface class TourRepository {
|
|||||||
String? actorCarId,
|
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
|
/// Setzt (Upsert) den Wert eines Service für eine Lieferung. Genau das zum
|
||||||
/// Service-Typ passende Feld angeben. Rückgabe: der gespeicherte Wert.
|
/// Service-Typ passende Feld angeben. Rückgabe: der gespeicherte Wert.
|
||||||
Future<DeliveryServiceValue> setDeliveryService({
|
Future<DeliveryServiceValue> setDeliveryService({
|
||||||
|
|||||||
@ -60,6 +60,9 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
LoginRequested event,
|
LoginRequested event,
|
||||||
Emitter<AuthState> emit,
|
Emitter<AuthState> emit,
|
||||||
) async {
|
) async {
|
||||||
|
// Läuft bereits ein interaktiver Login (Spinner sichtbar), keinen
|
||||||
|
// zweiten Flow starten. Der Provider dedupet zusätzlich nativ.
|
||||||
|
if (state is Authenticating) return;
|
||||||
try {
|
try {
|
||||||
emit(Authenticating());
|
emit(Authenticating());
|
||||||
await tokenProvider.login().timeout(_loginTimeout);
|
await tokenProvider.login().timeout(_loginTimeout);
|
||||||
@ -72,7 +75,12 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
// State dann auf `Authenticated`. Kein Schaden, nur „nachträgliche
|
// State dann auf `Authenticated`. Kein Schaden, nur „nachträgliche
|
||||||
// Anmeldung". Bei späterer Exception passiert nichts; das Future
|
// Anmeldung". Bei späterer Exception passiert nichts; das Future
|
||||||
// ist hier nicht mehr awaited.
|
// ist hier nicht mehr awaited.
|
||||||
|
//
|
||||||
|
// Wichtig: die Dedup-Sperre im Provider freigeben, sonst würde der
|
||||||
|
// nächste „Anmelden"-Tap denselben (aufgegebenen) Flow wiederver-
|
||||||
|
// wenden statt einen frischen Browser-Tab zu öffnen.
|
||||||
debugPrint('Login-Timeout nach ${_loginTimeout.inSeconds}s.');
|
debugPrint('Login-Timeout nach ${_loginTimeout.inSeconds}s.');
|
||||||
|
tokenProvider.discardPendingLogin();
|
||||||
emit(Unauthenticated(loginTimedOut: true));
|
emit(Unauthenticated(loginTimedOut: true));
|
||||||
} catch (err, st) {
|
} catch (err, st) {
|
||||||
debugPrint('Login fehlgeschlagen: $err\n$st');
|
debugPrint('Login fehlgeschlagen: $err\n$st');
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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_bloc.dart';
|
||||||
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.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/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).
|
/// Login-Page nach der Migration auf Keycloak OIDC (Phase B).
|
||||||
///
|
///
|
||||||
@ -28,7 +31,25 @@ class LoginPage extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
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(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
if (sessionExpired)
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import 'package:flutter/cupertino.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.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/car_selection/repository/car_selection_repository.dart';
|
||||||
import 'package:hl_lieferservice/feature/cars/model/selection.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 'events.dart';
|
||||||
import 'state.dart';
|
import 'state.dart';
|
||||||
@ -10,7 +10,12 @@ import 'state.dart';
|
|||||||
class CarSelectBloc extends Bloc<CarSelectEvent, CarSelectState> {
|
class CarSelectBloc extends Bloc<CarSelectEvent, CarSelectState> {
|
||||||
final CarSelectionRepository repository;
|
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<CarSelectLoad>(_load);
|
||||||
on<CarSelectConfirm>(_confirm);
|
on<CarSelectConfirm>(_confirm);
|
||||||
on<CarSelectChange>(_change);
|
on<CarSelectChange>(_change);
|
||||||
@ -36,23 +41,21 @@ class CarSelectBloc extends Bloc<CarSelectEvent, CarSelectState> {
|
|||||||
stored.date.day == today.day;
|
stored.date.day == today.day;
|
||||||
|
|
||||||
if (validForToday) {
|
if (validForToday) {
|
||||||
emit(
|
// Die lokale Auswahl ist nur ein Zeiger. Sie gilt nur, wenn das
|
||||||
CarSelectComplete(
|
// Fahrzeug laut Server (frisch geladen) zu diesem Account gehört
|
||||||
selectedCar: Car(
|
// und aktiv ist — sonst z. B. ein Fahrzeug eines anderen Fahrers,
|
||||||
id: stored.selectedCarId!,
|
// das auf diesem Gerät gewählt wurde.
|
||||||
// accountId/active fließen aus der lokalen Selection
|
final ownCars = await carsRepository.listMine();
|
||||||
// nicht durch — wir persistieren nur (id, plate) als
|
final car = ownCars
|
||||||
// UI-Pointer. Die Tour-Logik holt die vollständigen
|
.where((c) => c.id == stored.selectedCarId && c.active)
|
||||||
// Car-Felder weiterhin aus dem CarsBloc-Listing.
|
.firstOrNull;
|
||||||
accountId: 0,
|
if (car != null) {
|
||||||
plate: stored.selectedCarPlate!,
|
emit(CarSelectComplete(selectedCar: car));
|
||||||
active: true,
|
return;
|
||||||
),
|
}
|
||||||
),
|
await repository.clearSelection(event.userId);
|
||||||
);
|
|
||||||
} else {
|
|
||||||
emit(CarSelectRequired());
|
|
||||||
}
|
}
|
||||||
|
emit(CarSelectRequired());
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('CarSelectBloc._load failed: $e');
|
debugPrint('CarSelectBloc._load failed: $e');
|
||||||
debugPrint('Stacktrace: $st');
|
debugPrint('Stacktrace: $st');
|
||||||
|
|||||||
@ -6,11 +6,22 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
/// (Strings). Alte Pre-Phase-D-Installations können noch int-Werte
|
/// (Strings). Alte Pre-Phase-D-Installations können noch int-Werte
|
||||||
/// unter `_car_id` liegen haben — die werden beim Lesen
|
/// unter `_car_id` liegen haben — die werden beim Lesen
|
||||||
/// stillschweigend ignoriert (Migration durch "neu auswählen").
|
/// 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 {
|
class CarSelectionRepository {
|
||||||
static String _keyDate(String userId) => 'car_selection_${userId}_date';
|
CarSelectionRepository({required this.environmentId});
|
||||||
static String _keyCarId(String userId) => 'car_selection_${userId}_car_id';
|
|
||||||
static String _keyCarPlate(String userId) =>
|
final String environmentId;
|
||||||
'car_selection_${userId}_car_plate';
|
|
||||||
|
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 {
|
Future<CarSelection?> getSelection(String userId) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
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 {
|
Future<void> saveSelection(String userId, CarSelection selection) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
|||||||
@ -43,6 +43,13 @@ class PhaseBloc extends Bloc<PhaseEvent, PhaseState> {
|
|||||||
/// Phasen an die aktuelle Tour-Version.
|
/// Phasen an die aktuelle Tour-Version.
|
||||||
final TourTokenResolver? tourTokenResolver;
|
final TourTokenResolver? tourTokenResolver;
|
||||||
|
|
||||||
|
/// Merkt sich pro Fahrzeug, unter welchem Tour-Token der In-Memory-Stand
|
||||||
|
/// geladen wurde. Ändert sich der Token (Tageswechsel im Kopf-Kalender,
|
||||||
|
/// neuer Sync), lädt der nächste [PhaseLoadForCar] den persistierten Stand
|
||||||
|
/// der **neuen** Tour nach, statt den alten Tag weiterzuzeigen. Der alte
|
||||||
|
/// Tag bleibt persistiert und wird bei Rückkehr wiederhergestellt.
|
||||||
|
final Map<String, String> _loadedTokenByCar = {};
|
||||||
|
|
||||||
PhaseBloc({
|
PhaseBloc({
|
||||||
PhaseService? phaseService,
|
PhaseService? phaseService,
|
||||||
this.carCountResolver,
|
this.carCountResolver,
|
||||||
@ -59,6 +66,14 @@ class PhaseBloc extends Bloc<PhaseEvent, PhaseState> {
|
|||||||
/// bedeutungslos.
|
/// bedeutungslos.
|
||||||
String _token() => tourTokenResolver?.call() ?? 'no-tour';
|
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() {
|
PhaseReady _ensureReady() {
|
||||||
final current = state;
|
final current = state;
|
||||||
return current is PhaseReady
|
return current is PhaseReady
|
||||||
@ -77,18 +92,51 @@ class PhaseBloc extends Bloc<PhaseEvent, PhaseState> {
|
|||||||
|
|
||||||
Future<void> _load(PhaseLoadForCar event, Emitter<PhaseState> emit) async {
|
Future<void> _load(PhaseLoadForCar event, Emitter<PhaseState> emit) async {
|
||||||
final current = _ensureReady();
|
final current = _ensureReady();
|
||||||
// Wenn bereits geladen, nichts tun — der Stepper-Tap entscheidet aktiv.
|
final token = _token();
|
||||||
if (current.phaseByCar.containsKey(event.carId)) return;
|
// Bereits für GENAU diese Tour-Version geladen → nichts tun (der
|
||||||
|
// Stepper-Tap entscheidet aktiv). Ein anderer Token (Tageswechsel, neuer
|
||||||
|
// Sync) lädt den persistierten Stand der neuen Tour nach: ein neuer Tag
|
||||||
|
// startet so wieder in der Eintrittsphase (Beladen/Ausliefern zurück),
|
||||||
|
// der vorherige Tag behält seinen Stand für die Rückkehr.
|
||||||
|
if (current.phaseByCar.containsKey(event.carId) &&
|
||||||
|
_loadedTokenByCar[event.carId] == token) {
|
||||||
|
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 {
|
try {
|
||||||
final token = _token();
|
|
||||||
final persisted = await phaseService.load(event.carId, token);
|
final persisted = await phaseService.load(event.carId, token);
|
||||||
final persistedMax = await phaseService.loadMax(event.carId, token);
|
final persistedMax = await phaseService.loadMax(event.carId, token);
|
||||||
final phase = persisted ?? _entryPhase();
|
final phase = persisted ?? _entryPhase();
|
||||||
// Max ist mindestens die aktuelle Phase. Falls in der Persistenz ein
|
// Max ist mindestens die aktuelle Phase. Ein höheres persistiertes Max
|
||||||
// höherer Wert steht (Rücksprung), den nehmen.
|
// (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;
|
DeliveryPhase maxPhase = phase;
|
||||||
if (persistedMax != null && persistedMax.index > maxPhase.index) {
|
if (persisted != null &&
|
||||||
|
persistedMax != null &&
|
||||||
|
persistedMax.index > maxPhase.index) {
|
||||||
maxPhase = persistedMax;
|
maxPhase = persistedMax;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,6 +150,7 @@ class PhaseBloc extends Bloc<PhaseEvent, PhaseState> {
|
|||||||
await phaseService.saveMax(event.carId, token, maxPhase);
|
await phaseService.saveMax(event.carId, token, maxPhase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_loadedTokenByCar[event.carId] = token;
|
||||||
add(PhaseLoaded(
|
add(PhaseLoaded(
|
||||||
carId: event.carId,
|
carId: event.carId,
|
||||||
phase: phase,
|
phase: phase,
|
||||||
@ -111,6 +160,7 @@ class PhaseBloc extends Bloc<PhaseEvent, PhaseState> {
|
|||||||
debugPrint("PhaseBloc._load: $e $st");
|
debugPrint("PhaseBloc._load: $e $st");
|
||||||
// Fail-soft: ohne Persistenz weiter, damit der Flow nicht hängen bleibt.
|
// Fail-soft: ohne Persistenz weiter, damit der Flow nicht hängen bleibt.
|
||||||
final fallback = _entryPhase();
|
final fallback = _entryPhase();
|
||||||
|
_loadedTokenByCar[event.carId] = token;
|
||||||
add(PhaseLoaded(
|
add(PhaseLoaded(
|
||||||
carId: event.carId,
|
carId: event.carId,
|
||||||
phase: fallback,
|
phase: fallback,
|
||||||
@ -125,9 +175,17 @@ class PhaseBloc extends Bloc<PhaseEvent, PhaseState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _set(PhaseSet event, Emitter<PhaseState> emit) async {
|
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 current = _ensureReady();
|
||||||
final next = current.withPhase(event.carId, event.phase);
|
final next = current.withPhase(event.carId, event.phase);
|
||||||
emit(next);
|
emit(next);
|
||||||
|
// Ohne Tour nichts persistieren — der 'no-tour'-Eimer darf keinen
|
||||||
|
// Fortschritt tragen (s. _load).
|
||||||
|
if (!hasTour) return;
|
||||||
try {
|
try {
|
||||||
final token = _token();
|
final token = _token();
|
||||||
await phaseService.save(event.carId, token, event.phase);
|
await phaseService.save(event.carId, token, event.phase);
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import 'package:uuid/uuid.dart';
|
|||||||
import 'package:hl_lieferservice/data/cache/attachment_cache.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.dart';
|
||||||
import 'package:hl_lieferservice/domain/entity/delivery_credit.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_item.dart';
|
||||||
import 'package:hl_lieferservice/domain/entity/delivery_note.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/delivery_service_value.dart';
|
||||||
@ -50,6 +51,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
on<UploadDeliveryNoteImage>(_onUploadDeliveryNoteImage);
|
on<UploadDeliveryNoteImage>(_onUploadDeliveryNoteImage);
|
||||||
on<SetDeliveryCredit>(_onSetDeliveryCredit);
|
on<SetDeliveryCredit>(_onSetDeliveryCredit);
|
||||||
on<RemoveDeliveryCredit>(_onRemoveDeliveryCredit);
|
on<RemoveDeliveryCredit>(_onRemoveDeliveryCredit);
|
||||||
|
on<RecordDeliveryPayment>(_onRecordDeliveryPayment);
|
||||||
on<SetDeliveryServiceValue>(_onSetDeliveryServiceValue);
|
on<SetDeliveryServiceValue>(_onSetDeliveryServiceValue);
|
||||||
on<RemoveDeliveryServiceValue>(_onRemoveDeliveryServiceValue);
|
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).
|
/// damit ein Pull-to-refresh denselben Tag neu lädt (nicht heute).
|
||||||
DateTime? _selectedDate;
|
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.
|
/// Räumt verwaiste Cache-Bilder weg, sobald frische Tour-Daten vorliegen.
|
||||||
/// Fire-and-forget — Cache-Pflege darf den Load nie verzögern oder
|
/// Fire-and-forget — Cache-Pflege darf den Load nie verzögern oder
|
||||||
/// scheitern lassen.
|
/// scheitern lassen.
|
||||||
@ -80,10 +88,14 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
// ─── LoadTour ────────────────────────────────────────────────────────
|
// ─── LoadTour ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<void> _onLoad(LoadTour event, Emitter<TourState> emit) async {
|
Future<void> _onLoad(LoadTour event, Emitter<TourState> emit) async {
|
||||||
_selectedDate = event.date;
|
// 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());
|
emit(const TourLoading());
|
||||||
try {
|
try {
|
||||||
final details = await tourRepository.getMyTourDetails(date: _selectedDate);
|
final details = await tourRepository.getMyTourDetails(date: _selectedDate);
|
||||||
|
if (seq != _requestSeq) return; // überholt von neuerer Anfrage
|
||||||
if (details == null) {
|
if (details == null) {
|
||||||
emit(const TourEmpty());
|
emit(const TourEmpty());
|
||||||
return;
|
return;
|
||||||
@ -91,6 +103,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
emit(TourLoaded(details: details));
|
emit(TourLoaded(details: details));
|
||||||
_pruneAttachmentCache(details);
|
_pruneAttachmentCache(details);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
|
if (seq != _requestSeq) return;
|
||||||
debugPrint('TourBloc.LoadTour fehlgeschlagen: $e\n$st');
|
debugPrint('TourBloc.LoadTour fehlgeschlagen: $e\n$st');
|
||||||
final message = _messageOf(e, 'Tour konnte nicht geladen werden');
|
final message = _messageOf(e, 'Tour konnte nicht geladen werden');
|
||||||
emit(TourLoadFailed(message: message));
|
emit(TourLoadFailed(message: message));
|
||||||
@ -103,15 +116,17 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
Future<void> _onRefresh(RefreshTour event, Emitter<TourState> emit) async {
|
Future<void> _onRefresh(RefreshTour event, Emitter<TourState> emit) async {
|
||||||
final current = state;
|
final current = state;
|
||||||
if (current is! TourLoaded) {
|
if (current is! TourLoaded) {
|
||||||
// Keine sichtbare Tour zum „weichen" Refreshen — wie ein LoadTour
|
// Keine sichtbare Tour zum „weichen" Refreshen — wie ein Reload
|
||||||
// behandeln. Das aktuell gewählte Datum beibehalten.
|
// behandeln. `const LoadTour()` behält das aktuell gewählte Datum.
|
||||||
await _onLoad(LoadTour(date: _selectedDate), emit);
|
await _onLoad(const LoadTour(), emit);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final seq = ++_requestSeq;
|
||||||
emit(current.copyWith(isRefreshing: true, refreshError: null));
|
emit(current.copyWith(isRefreshing: true, refreshError: null));
|
||||||
try {
|
try {
|
||||||
final details = await tourRepository.getMyTourDetails(date: _selectedDate);
|
final details = await tourRepository.getMyTourDetails(date: _selectedDate);
|
||||||
|
if (seq != _requestSeq) return; // überholt von neuerer Anfrage
|
||||||
if (details == null) {
|
if (details == null) {
|
||||||
emit(const TourEmpty());
|
emit(const TourEmpty());
|
||||||
return;
|
return;
|
||||||
@ -119,6 +134,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
emit(TourLoaded(details: details));
|
emit(TourLoaded(details: details));
|
||||||
_pruneAttachmentCache(details);
|
_pruneAttachmentCache(details);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
|
if (seq != _requestSeq) return;
|
||||||
debugPrint('TourBloc.RefreshTour fehlgeschlagen: $e\n$st');
|
debugPrint('TourBloc.RefreshTour fehlgeschlagen: $e\n$st');
|
||||||
final message = _messageOf(e, 'Tour konnte nicht neu geladen werden');
|
final message = _messageOf(e, 'Tour konnte nicht neu geladen werden');
|
||||||
// alten Stand sichtbar lassen, Fehler oben mitführen
|
// alten Stand sichtbar lassen, Fehler oben mitführen
|
||||||
@ -611,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 ──────────────────────────────────────────────
|
// ─── Betrags-Gutschrift ──────────────────────────────────────────────
|
||||||
|
|
||||||
/// Tauscht die Gutschrift einer Lieferung im Aggregat aus (`null` = entfernt).
|
/// Tauscht die Gutschrift einer Lieferung im Aggregat aus (`null` = entfernt).
|
||||||
@ -1059,6 +1114,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
paymentMethodId: event.paymentMethodId,
|
paymentMethodId: event.paymentMethodId,
|
||||||
actorCarId: event.actorCarId,
|
actorCarId: event.actorCarId,
|
||||||
paymentCollected: event.paymentCollected,
|
paymentCollected: event.paymentCollected,
|
||||||
|
internalNote: event.internalNote,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,16 +7,28 @@ sealed class TourEvent {
|
|||||||
const TourEvent();
|
const TourEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initial-Load bzw. Laden der Tour des angemeldeten Fahrers für ein
|
/// Lädt die Tour des angemeldeten Fahrers. Account-Filter sitzt im JWT.
|
||||||
/// bestimmtes Datum. Account-Filter sitzt im JWT.
|
|
||||||
///
|
///
|
||||||
/// [date] `null` = „heute" (Server entscheidet). Wählt der Fahrer im
|
/// Zwei Spielarten:
|
||||||
/// Kopf-Kalender ein Datum, wird es hier übergeben und der Bloc merkt es
|
/// * [LoadTour] (ohne Datum): **Reload/Retry** — lädt den aktuell im Bloc
|
||||||
/// sich für nachfolgende [RefreshTour]-Events.
|
/// gemerkten Tag erneut und ändert die Datumswahl NICHT. Für Retry-Buttons
|
||||||
|
/// und Fehler-Seiten, die sonst versehentlich auf „heute" zurückspringen.
|
||||||
|
/// * [LoadTour.forDate]: setzt gezielt ein Datum (`null` = heute) und merkt
|
||||||
|
/// es sich als aktuelles Datum für nachfolgende Reloads/Refreshes. Für den
|
||||||
|
/// Kopf-Kalender und den Initial-Load (aus dem [TourDateCubit]).
|
||||||
class LoadTour extends TourEvent {
|
class LoadTour extends TourEvent {
|
||||||
const LoadTour({this.date});
|
const LoadTour()
|
||||||
|
: date = null,
|
||||||
|
hasDate = false;
|
||||||
|
|
||||||
|
const LoadTour.forDate(this.date) : hasDate = true;
|
||||||
|
|
||||||
|
/// Ziel-Datum, nur relevant wenn [hasDate] `true` ist. `null` = heute.
|
||||||
final DateTime? date;
|
final DateTime? date;
|
||||||
|
|
||||||
|
/// `true` = [date] wurde bewusst gesetzt; `false` = „aktuelles Datum
|
||||||
|
/// behalten" (reiner Reload).
|
||||||
|
final bool hasDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pull-to-refresh / manueller Reload aus der Übersicht. Vorhandener
|
/// Pull-to-refresh / manueller Reload aus der Übersicht. Vorhandener
|
||||||
@ -213,6 +225,7 @@ class CompleteDelivery extends TourEvent {
|
|||||||
this.paymentMethodId,
|
this.paymentMethodId,
|
||||||
this.actorCarId,
|
this.actorCarId,
|
||||||
this.paymentCollected = false,
|
this.paymentCollected = false,
|
||||||
|
this.internalNote,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String deliveryId;
|
final String deliveryId;
|
||||||
@ -230,6 +243,10 @@ class CompleteDelivery extends TourEvent {
|
|||||||
/// Fahrer hat das Vor-Ort-Inkasso (Bar/EC) des offenen Betrags bestätigt.
|
/// Fahrer hat das Vor-Ort-Inkasso (Bar/EC) des offenen Betrags bestätigt.
|
||||||
/// `false`, wenn kein Inkasso anfiel (offen == 0 oder „Auf Rechnung").
|
/// `false`, wenn kein Inkasso anfiel (offen == 0 oder „Auf Rechnung").
|
||||||
final bool paymentCollected;
|
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
|
/// Legt eine neue (Text- oder Bild-)Notiz an einer Lieferung an. Aktuell
|
||||||
@ -262,6 +279,22 @@ class RemoveDeliveryCredit extends TourEvent {
|
|||||||
final String actorCarId;
|
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).
|
/// Setzt/ändert den Wert eines Service für eine Lieferung (Checkbox/Zahl).
|
||||||
class SetDeliveryServiceValue extends TourEvent {
|
class SetDeliveryServiceValue extends TourEvent {
|
||||||
const SetDeliveryServiceValue({
|
const SetDeliveryServiceValue({
|
||||||
|
|||||||
@ -93,3 +93,22 @@ const Object _sentinel = Object();
|
|||||||
class TourEmpty extends TourState {
|
class TourEmpty extends TourState {
|
||||||
const TourEmpty();
|
const TourEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Token, der die aktuell geladene **Tour-Version** identifiziert (aus
|
||||||
|
/// `Tour.syncedAt`). Der `PhaseBloc` bindet die persistierten Phasen-Häkchen
|
||||||
|
/// daran: ein Tageswechsel im Kopf-Kalender oder ein neuer Sync ergibt einen
|
||||||
|
/// neuen Token und damit einen eigenen, getrennten Phasen-Stand. `null` =
|
||||||
|
/// keine Tour geladen (z. B. `TourEmpty`).
|
||||||
|
///
|
||||||
|
/// **Einzige Quelle** für diese Ableitung — wird vom `tourTokenResolver` in
|
||||||
|
/// `app.dart` UND von `Home._ensurePhaseLoaded` genutzt, damit beide Seiten
|
||||||
|
/// garantiert denselben Token sehen und ein Tour-Wechsel zuverlässig als
|
||||||
|
/// solcher erkannt wird.
|
||||||
|
extension TourStatePhaseToken on TourState {
|
||||||
|
String? get phaseToken {
|
||||||
|
final s = this;
|
||||||
|
return s is TourLoaded
|
||||||
|
? s.details.tour.syncedAt.millisecondsSinceEpoch.toString()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -39,8 +39,5 @@ class DeliveryWorkflowBloc
|
|||||||
final next = [...state.pendingImageNotes]..removeAt(e.index);
|
final next = [...state.pendingImageNotes]..removeAt(e.index);
|
||||||
emit(state.copyWith(pendingImageNotes: next));
|
emit(state.copyWith(pendingImageNotes: next));
|
||||||
});
|
});
|
||||||
on<WorkflowOverridePaymentMethod>((e, emit) => emit(
|
|
||||||
state.copyWith(paymentMethodOverrideId: e.paymentMethodId),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,10 +34,3 @@ class WorkflowRemovePendingImage extends DeliveryWorkflowEvent {
|
|||||||
const WorkflowRemovePendingImage(this.index);
|
const WorkflowRemovePendingImage(this.index);
|
||||||
final int index;
|
final int index;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Payment-Auswahl ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class WorkflowOverridePaymentMethod extends DeliveryWorkflowEvent {
|
|
||||||
const WorkflowOverridePaymentMethod({required this.paymentMethodId});
|
|
||||||
final String? paymentMethodId;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import 'package:image_picker/image_picker.dart';
|
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 {
|
enum WorkflowStep {
|
||||||
info,
|
info,
|
||||||
notes,
|
notes,
|
||||||
articles,
|
articles,
|
||||||
services,
|
services,
|
||||||
|
payment,
|
||||||
summary,
|
summary,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -15,6 +16,7 @@ extension WorkflowStepX on WorkflowStep {
|
|||||||
WorkflowStep.notes => 'Notizen',
|
WorkflowStep.notes => 'Notizen',
|
||||||
WorkflowStep.articles => 'Artikel & Gutschriften',
|
WorkflowStep.articles => 'Artikel & Gutschriften',
|
||||||
WorkflowStep.services => 'Checkliste',
|
WorkflowStep.services => 'Checkliste',
|
||||||
|
WorkflowStep.payment => 'Zahlung',
|
||||||
WorkflowStep.summary => 'Übersicht',
|
WorkflowStep.summary => 'Übersicht',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -24,6 +26,7 @@ extension WorkflowStepX on WorkflowStep {
|
|||||||
WorkflowStep.notes => 'Notizen',
|
WorkflowStep.notes => 'Notizen',
|
||||||
WorkflowStep.articles => 'Artikel',
|
WorkflowStep.articles => 'Artikel',
|
||||||
WorkflowStep.services => 'Checkliste',
|
WorkflowStep.services => 'Checkliste',
|
||||||
|
WorkflowStep.payment => 'Zahlung',
|
||||||
WorkflowStep.summary => 'Übersicht',
|
WorkflowStep.summary => 'Übersicht',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -38,8 +41,9 @@ class PendingImageNote {
|
|||||||
final DateTime pickedAt;
|
final DateTime pickedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State des Detail-Workflows. Ein State, ein Bloc — der Step-Wechsel,
|
/// State des Detail-Workflows. Ein State, ein Bloc — der Step-Wechsel und
|
||||||
/// die Drafts und die Payment-Auswahl liegen alle hier. So sieht jede
|
/// 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
|
/// Step-Page denselben kohärenten Zustand und ein Step kann Daten aus
|
||||||
/// einem anderen lesen (z. B. Summary liest Article-Drafts).
|
/// einem anderen lesen (z. B. Summary liest Article-Drafts).
|
||||||
class DeliveryWorkflowState {
|
class DeliveryWorkflowState {
|
||||||
@ -47,7 +51,6 @@ class DeliveryWorkflowState {
|
|||||||
required this.deliveryId,
|
required this.deliveryId,
|
||||||
required this.step,
|
required this.step,
|
||||||
required this.pendingImageNotes,
|
required this.pendingImageNotes,
|
||||||
required this.paymentMethodOverrideId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
factory DeliveryWorkflowState.initial(String deliveryId) =>
|
factory DeliveryWorkflowState.initial(String deliveryId) =>
|
||||||
@ -55,7 +58,6 @@ class DeliveryWorkflowState {
|
|||||||
deliveryId: deliveryId,
|
deliveryId: deliveryId,
|
||||||
step: WorkflowStep.info,
|
step: WorkflowStep.info,
|
||||||
pendingImageNotes: const [],
|
pendingImageNotes: const [],
|
||||||
paymentMethodOverrideId: null,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final String deliveryId;
|
final String deliveryId;
|
||||||
@ -64,24 +66,14 @@ class DeliveryWorkflowState {
|
|||||||
/// Lokal gehaltene Bild-Notizen — solange kein Upload-Endpoint da ist.
|
/// Lokal gehaltene Bild-Notizen — solange kein Upload-Endpoint da ist.
|
||||||
final List<PendingImageNote> pendingImageNotes;
|
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({
|
DeliveryWorkflowState copyWith({
|
||||||
WorkflowStep? step,
|
WorkflowStep? step,
|
||||||
List<PendingImageNote>? pendingImageNotes,
|
List<PendingImageNote>? pendingImageNotes,
|
||||||
Object? paymentMethodOverrideId = _sentinel,
|
|
||||||
}) {
|
}) {
|
||||||
return DeliveryWorkflowState(
|
return DeliveryWorkflowState(
|
||||||
deliveryId: deliveryId,
|
deliveryId: deliveryId,
|
||||||
step: step ?? this.step,
|
step: step ?? this.step,
|
||||||
pendingImageNotes: pendingImageNotes ?? this.pendingImageNotes,
|
pendingImageNotes: pendingImageNotes ?? this.pendingImageNotes,
|
||||||
paymentMethodOverrideId: identical(paymentMethodOverrideId, _sentinel)
|
|
||||||
? this.paymentMethodOverrideId
|
|
||||||
: paymentMethodOverrideId as String?,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const Object _sentinel = Object();
|
|
||||||
|
|||||||
@ -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);
|
||||||
@ -2,19 +2,19 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
import 'package:hl_lieferservice/domain/entity/delivery.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/domain/entity/tour_details.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.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_event.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.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/presentation/delivery_sign.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_bloc.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_event.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_state.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_articles.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_info.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_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_services.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_summary.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
|
// entgegennimmt und den Key zurückgibt, dann hier
|
||||||
// `tourBloc.add(AddDeliveryNote(imageAttachment: key))` aufrufen.
|
// `tourBloc.add(AddDeliveryNote(imageAttachment: key))` aufrufen.
|
||||||
//
|
//
|
||||||
// * ~~B4 Zahlungsmethode beim Abschluss ändern~~ — ERLEDIGT: Die im
|
// * ~~B4 Zahlungsmethode beim Abschluss ändern~~ — ERLEDIGT und abgelöst:
|
||||||
// Summary gewählte Methode (`paymentMethodOverrideId` im Workflow-State)
|
// Die Zahlung wird im eigenen Step „Zahlung" abgewickelt und am Server
|
||||||
// reist beim Abschluss am `/complete`-Endpoint mit und wird atomar auf
|
// protokolliert (`POST /deliveries/{id}/payment`). Beim Abschluss reist
|
||||||
// der Lieferung persistiert (Server prüft existiert + aktiv).
|
// die Methode der gültigen Zahlung mit.
|
||||||
//
|
//
|
||||||
// * **B5 Unterschrift**: Signature-Pad-Bilder (Kunde + Fahrer)
|
// * **B5 Unterschrift**: Signature-Pad-Bilder (Kunde + Fahrer)
|
||||||
// hochladen + auf der Lieferung speichern. Backend hat dafür weder
|
// 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.
|
// 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,
|
/// jeder Step bekommt die aktuelle `Delivery` + `TourDetails` als Props,
|
||||||
/// damit die Steps keine eigenen Bloc-Subscriptions auf die Tour brauchen.
|
/// damit die Steps keine eigenen Bloc-Subscriptions auf die Tour brauchen.
|
||||||
///
|
///
|
||||||
@ -95,7 +95,8 @@ class _DeliveryDetailScaffoldState extends State<_DeliveryDetailScaffold> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final s = context.read<TourBloc>().state;
|
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;
|
_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(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: theme.primaryColor,
|
backgroundColor: theme.primaryColor,
|
||||||
foregroundColor: theme.colorScheme.onPrimary,
|
foregroundColor: theme.colorScheme.onPrimary,
|
||||||
title: Text(customer?.name ?? 'Lieferung'),
|
title: Text(recipient.name),
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
const _StepHeader(),
|
_StepHeader(delivery: delivery, details: details),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _StepBody(delivery: delivery, details: details),
|
child: _StepBody(delivery: delivery, details: details),
|
||||||
@ -171,8 +173,23 @@ class _DeliveryDetailScaffoldState extends State<_DeliveryDetailScaffold> {
|
|||||||
|
|
||||||
// ─── Step-Header (Pills) ────────────────────────────────────────────────
|
// ─── 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 {
|
class _StepHeader extends StatelessWidget {
|
||||||
const _StepHeader();
|
const _StepHeader({required this.delivery, required this.details});
|
||||||
|
|
||||||
|
final Delivery delivery;
|
||||||
|
final TourDetails details;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -189,9 +206,16 @@ class _StepHeader extends StatelessWidget {
|
|||||||
step: WorkflowStep.values[i],
|
step: WorkflowStep.values[i],
|
||||||
isActive: state.step.index == i,
|
isActive: state.step.index == i,
|
||||||
isPassed: state.step.index > i,
|
isPassed: state.step.index > i,
|
||||||
onTap: () => context
|
enabled:
|
||||||
.read<DeliveryWorkflowBloc>()
|
!_lockedByPayment(
|
||||||
.add(WorkflowGoToStep(WorkflowStep.values[i])),
|
WorkflowStep.values[i],
|
||||||
|
delivery,
|
||||||
|
details,
|
||||||
|
),
|
||||||
|
onTap:
|
||||||
|
() => context.read<DeliveryWorkflowBloc>().add(
|
||||||
|
WorkflowGoToStep(WorkflowStep.values[i]),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (i < WorkflowStep.values.length - 1)
|
if (i < WorkflowStep.values.length - 1)
|
||||||
@ -211,6 +235,7 @@ class _StepPill extends StatelessWidget {
|
|||||||
required this.step,
|
required this.step,
|
||||||
required this.isActive,
|
required this.isActive,
|
||||||
required this.isPassed,
|
required this.isPassed,
|
||||||
|
required this.enabled,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -218,6 +243,9 @@ class _StepPill extends StatelessWidget {
|
|||||||
final WorkflowStep step;
|
final WorkflowStep step;
|
||||||
final bool isActive;
|
final bool isActive;
|
||||||
final bool isPassed;
|
final bool isPassed;
|
||||||
|
|
||||||
|
/// `false` = Step gesperrt (z. B. Übersicht vor abgewickelter Zahlung).
|
||||||
|
final bool enabled;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -248,53 +276,58 @@ class _StepPill extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: onTap,
|
onTap: enabled ? onTap : null,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Padding(
|
child: Opacity(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
opacity: enabled ? 1 : 0.4,
|
||||||
child: Column(
|
child: Padding(
|
||||||
mainAxisSize: MainAxisSize.min,
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
children: [
|
child: Column(
|
||||||
Stack(
|
mainAxisSize: MainAxisSize.min,
|
||||||
alignment: Alignment.center,
|
children: [
|
||||||
children: [
|
Stack(
|
||||||
Container(
|
alignment: Alignment.center,
|
||||||
width: 30,
|
children: [
|
||||||
height: 30,
|
Container(
|
||||||
decoration: BoxDecoration(
|
width: 30,
|
||||||
color: circleColor,
|
height: 30,
|
||||||
shape: BoxShape.circle,
|
decoration: BoxDecoration(
|
||||||
border: isActive
|
color: circleColor,
|
||||||
? Border.all(color: primary, width: 2)
|
shape: BoxShape.circle,
|
||||||
: null,
|
border:
|
||||||
|
isActive
|
||||||
|
? Border.all(color: primary, width: 2)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child:
|
||||||
|
isPassed && !isActive
|
||||||
|
? Icon(Icons.check, color: circleFg, size: 16)
|
||||||
|
: Text(
|
||||||
|
'${index + 1}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: circleFg,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Center(
|
],
|
||||||
child: isPassed && !isActive
|
|
||||||
? Icon(Icons.check, color: circleFg, size: 16)
|
|
||||||
: Text(
|
|
||||||
'${index + 1}',
|
|
||||||
style: TextStyle(
|
|
||||||
color: circleFg,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 13,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
step.shortName,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(
|
|
||||||
color: labelColor,
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: labelWeight,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 4),
|
||||||
],
|
Text(
|
||||||
|
step.shortName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: labelColor,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: labelWeight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -313,9 +346,10 @@ class _StepConnector extends StatelessWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 2,
|
height: 2,
|
||||||
color: isPassed
|
color:
|
||||||
? theme.colorScheme.primary
|
isPassed
|
||||||
: theme.colorScheme.surfaceContainerHighest,
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.surfaceContainerHighest,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -343,6 +377,8 @@ class _StepBody extends StatelessWidget {
|
|||||||
return StepArticles(delivery: delivery, details: details);
|
return StepArticles(delivery: delivery, details: details);
|
||||||
case WorkflowStep.services:
|
case WorkflowStep.services:
|
||||||
return StepServices(delivery: delivery, details: details);
|
return StepServices(delivery: delivery, details: details);
|
||||||
|
case WorkflowStep.payment:
|
||||||
|
return StepPayment(delivery: delivery, details: details);
|
||||||
case WorkflowStep.summary:
|
case WorkflowStep.summary:
|
||||||
return StepSummary(delivery: delivery, details: details);
|
return StepSummary(delivery: delivery, details: details);
|
||||||
}
|
}
|
||||||
@ -365,62 +401,34 @@ class _BottomNav extends StatelessWidget {
|
|||||||
/// dann den `completed`-Status zeigt.
|
/// dann den `completed`-Status zeigt.
|
||||||
void _onSign(BuildContext context) {
|
void _onSign(BuildContext context) {
|
||||||
final tourBloc = context.read<TourBloc>();
|
final tourBloc = context.read<TourBloc>();
|
||||||
// Die im Summary-Step gewählte Zahlungsmethode lebt im Workflow-State.
|
// Kassiert wurde bereits im Step „Zahlung" (serverseitig protokolliert).
|
||||||
// Beim Abschluss reisen wir sie mit ans Backend (atomar mit der Signatur);
|
// Beim Abschluss reisen Methode und Inkasso-Flag der gültigen Zahlung
|
||||||
// `null` = die am Beleg hinterlegte Methode bleibt.
|
// mit; ohne offenen Betrag bleibt die Methode vom Beleg.
|
||||||
final paymentMethodOverrideId =
|
final payment = DeliveryPaymentStatus.of(delivery, details).validPayment;
|
||||||
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');
|
|
||||||
|
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
builder: (routeContext) => SignatureView(
|
builder:
|
||||||
delivery: delivery,
|
(routeContext) => SignatureView(
|
||||||
details: details,
|
delivery: delivery,
|
||||||
requiresCollection: requiresCollection,
|
details: details,
|
||||||
openAmount: open,
|
onSigned: (result) {
|
||||||
paymentMethodLabel: method?.name ?? '',
|
tourBloc.add(
|
||||||
onSigned: (result) {
|
CompleteDelivery(
|
||||||
tourBloc.add(CompleteDelivery(
|
deliveryId: delivery.id,
|
||||||
deliveryId: delivery.id,
|
customerSignaturePng: result.customerSignaturePng,
|
||||||
customerSignaturePng: result.customerSignaturePng,
|
driverSignaturePng: result.driverSignaturePng,
|
||||||
driverSignaturePng: result.driverSignaturePng,
|
receiptConfirmed: result.receiptConfirmed,
|
||||||
receiptConfirmed: result.receiptConfirmed,
|
notesAcknowledged: result.notesAcknowledged,
|
||||||
notesAcknowledged: result.notesAcknowledged,
|
acknowledgedNoteIds: result.acknowledgedNoteIds,
|
||||||
acknowledgedNoteIds: result.acknowledgedNoteIds,
|
paymentMethodId: payment?.paymentMethodId,
|
||||||
paymentMethodId: paymentMethodOverrideId,
|
paymentCollected: payment?.isCollectedOnSite ?? false,
|
||||||
paymentCollected: result.paymentCollected,
|
internalNote: result.internalNote,
|
||||||
));
|
),
|
||||||
Navigator.of(routeContext).pop();
|
);
|
||||||
},
|
Navigator.of(routeContext).pop();
|
||||||
),
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -438,11 +446,12 @@ class _BottomNav extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: isFirst
|
onPressed:
|
||||||
? null
|
isFirst
|
||||||
: () => context
|
? null
|
||||||
.read<DeliveryWorkflowBloc>()
|
: () => context.read<DeliveryWorkflowBloc>().add(
|
||||||
.add(const WorkflowPreviousStep()),
|
const WorkflowPreviousStep(),
|
||||||
|
),
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
label: const Text('Zurück'),
|
label: const Text('Zurück'),
|
||||||
),
|
),
|
||||||
@ -451,26 +460,37 @@ class _BottomNav extends StatelessWidget {
|
|||||||
// Unterschreiben/Abschließen nur bei aktiver Lieferung.
|
// Unterschreiben/Abschließen nur bei aktiver Lieferung.
|
||||||
// Ist sie bereits abgeschlossen (oder pausiert/abgebrochen),
|
// Ist sie bereits abgeschlossen (oder pausiert/abgebrochen),
|
||||||
// bleibt der Button gesperrt.
|
// bleibt der Button gesperrt.
|
||||||
Builder(builder: (context) {
|
Builder(
|
||||||
final isActive =
|
builder: (context) {
|
||||||
delivery.state == DeliveryState.active;
|
final isActive = delivery.state == DeliveryState.active;
|
||||||
final isCompleted =
|
final isCompleted =
|
||||||
delivery.state == DeliveryState.completed;
|
delivery.state == DeliveryState.completed;
|
||||||
return FilledButton.icon(
|
return FilledButton.icon(
|
||||||
onPressed: isActive ? () => _onSign(context) : null,
|
onPressed: isActive ? () => _onSign(context) : null,
|
||||||
icon: Icon(isCompleted
|
icon: Icon(
|
||||||
? Icons.check_circle_outline
|
isCompleted
|
||||||
: Icons.draw_outlined),
|
? Icons.check_circle_outline
|
||||||
label: Text(
|
: Icons.draw_outlined,
|
||||||
isCompleted ? 'Abgeschlossen' : 'Unterschreiben',
|
),
|
||||||
),
|
label: Text(
|
||||||
);
|
isCompleted ? 'Abgeschlossen' : 'Unterschreiben',
|
||||||
})
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
else
|
else
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () => context
|
// Aus „Zahlung" erst weiter, wenn abgewickelt/nichts offen.
|
||||||
.read<DeliveryWorkflowBloc>()
|
onPressed:
|
||||||
.add(const WorkflowNextStep()),
|
_lockedByPayment(
|
||||||
|
WorkflowStep.values[state.step.index + 1],
|
||||||
|
delivery,
|
||||||
|
details,
|
||||||
|
)
|
||||||
|
? null
|
||||||
|
: () => context.read<DeliveryWorkflowBloc>().add(
|
||||||
|
const WorkflowNextStep(),
|
||||||
|
),
|
||||||
icon: const Icon(Icons.arrow_forward),
|
icon: const Icon(Icons.arrow_forward),
|
||||||
label: const Text('Weiter'),
|
label: const Text('Weiter'),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import 'package:signature/signature.dart';
|
|||||||
import 'package:hl_lieferservice/domain/entity/delivery.dart';
|
import 'package:hl_lieferservice/domain/entity/delivery.dart';
|
||||||
import 'package:hl_lieferservice/domain/entity/delivery_note.dart';
|
import 'package:hl_lieferservice/domain/entity/delivery_note.dart';
|
||||||
import 'package:hl_lieferservice/domain/entity/tour_details.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
|
/// Daten, die der Abschluss-Flow an den Aufrufer zurückgibt: beide
|
||||||
/// Unterschriften als PNG plus die dokumentierten Bestätigungen des Kunden.
|
/// Unterschriften als PNG plus die dokumentierten Bestätigungen des Kunden.
|
||||||
@ -17,7 +18,7 @@ class SignatureResult {
|
|||||||
required this.receiptConfirmed,
|
required this.receiptConfirmed,
|
||||||
required this.notesAcknowledged,
|
required this.notesAcknowledged,
|
||||||
required this.acknowledgedNoteIds,
|
required this.acknowledgedNoteIds,
|
||||||
required this.paymentCollected,
|
this.internalNote,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Uint8List customerSignaturePng;
|
final Uint8List customerSignaturePng;
|
||||||
@ -26,21 +27,21 @@ class SignatureResult {
|
|||||||
final bool notesAcknowledged;
|
final bool notesAcknowledged;
|
||||||
final List<String> acknowledgedNoteIds;
|
final List<String> acknowledgedNoteIds;
|
||||||
|
|
||||||
/// Fahrer hat das Inkasso (Bar/EC) des offenen Betrags bestätigt. `false`,
|
/// Optionale interne Notiz des Fahrers, abgefragt nach beiden
|
||||||
/// wenn kein Inkasso anfiel (offen == 0 oder „Auf Rechnung").
|
/// Unterschriften. `null` = keine.
|
||||||
final bool paymentCollected;
|
final String? internalNote;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mehrstufiger Unterschrift-Flow zum Abschließen einer Lieferung.
|
/// Mehrstufiger Unterschrift-Flow zum Abschließen einer Lieferung.
|
||||||
///
|
///
|
||||||
/// Stufe 0 (Fahrer, optional): nur wenn beim Abschluss ein offener Betrag
|
/// Das Kassieren passiert vorher im eigenen Workflow-Step „Zahlung" und
|
||||||
/// per Vor-Ort-Inkasso (Bar/EC) zu kassieren ist ([requiresCollection]).
|
/// wird dort protokolliert — dieser Flow enthält nur noch die Unterschriften.
|
||||||
/// Der Fahrer bestätigt, dass der Betrag erhalten/abgerechnet wurde — VOR
|
///
|
||||||
/// beiden Unterschriften.
|
|
||||||
/// Stufe 1 (Kunde): sieht die Anmerkungen zur Lieferung, hakt zwei
|
/// Stufe 1 (Kunde): sieht die Anmerkungen zur Lieferung, hakt zwei
|
||||||
/// Bestätigungen ab (Anmerkungen-Kenntnisnahme — nur Pflicht, wenn Notizen
|
/// Bestätigungen ab (Anmerkungen-Kenntnisnahme — nur Pflicht, wenn Notizen
|
||||||
/// vorhanden; Empfangsbestätigung — immer Pflicht) und unterschreibt.
|
/// 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
|
/// Erst nach beiden Unterschriften ruft die View [onSigned] mit dem
|
||||||
/// vollständigen [SignatureResult] auf — der Aufrufer triggert dann den
|
/// vollständigen [SignatureResult] auf — der Aufrufer triggert dann den
|
||||||
@ -51,31 +52,18 @@ class SignatureView extends StatefulWidget {
|
|||||||
required this.delivery,
|
required this.delivery,
|
||||||
required this.details,
|
required this.details,
|
||||||
required this.onSigned,
|
required this.onSigned,
|
||||||
this.requiresCollection = false,
|
|
||||||
this.openAmount = 0,
|
|
||||||
this.paymentMethodLabel = '',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final Delivery delivery;
|
final Delivery delivery;
|
||||||
final TourDetails details;
|
final TourDetails details;
|
||||||
final void Function(SignatureResult result) onSigned;
|
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
|
@override
|
||||||
State<SignatureView> createState() => _SignatureViewState();
|
State<SignatureView> createState() => _SignatureViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stufen des Abschluss-Flows.
|
/// Stufen des Abschluss-Flows.
|
||||||
enum _SignStage { payment, customer, driver }
|
enum _SignStage { customer, driver }
|
||||||
|
|
||||||
class _SignatureViewState extends State<SignatureView> {
|
class _SignatureViewState extends State<SignatureView> {
|
||||||
static const String _receiptText =
|
static const String _receiptText =
|
||||||
@ -97,8 +85,7 @@ class _SignatureViewState extends State<SignatureView> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
late final List<DeliveryNote> _notes;
|
late final List<DeliveryNote> _notes;
|
||||||
late _SignStage _stage;
|
_SignStage _stage = _SignStage.customer;
|
||||||
bool _paymentConfirmed = false;
|
|
||||||
bool _receiptAccepted = false;
|
bool _receiptAccepted = false;
|
||||||
bool _notesAccepted = false;
|
bool _notesAccepted = false;
|
||||||
bool _customerEmpty = true;
|
bool _customerEmpty = true;
|
||||||
@ -109,10 +96,8 @@ class _SignatureViewState extends State<SignatureView> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Inkasso-Bestätigung (Stufe 0) nur wenn gefordert, sonst direkt zum Kunden.
|
_notes =
|
||||||
_stage =
|
widget.details.notesByDeliveryId[widget.delivery.id] ??
|
||||||
widget.requiresCollection ? _SignStage.payment : _SignStage.customer;
|
|
||||||
_notes = widget.details.notesByDeliveryId[widget.delivery.id] ??
|
|
||||||
const <DeliveryNote>[];
|
const <DeliveryNote>[];
|
||||||
_customerController.addListener(() {
|
_customerController.addListener(() {
|
||||||
if (_customerEmpty != _customerController.isEmpty) {
|
if (_customerEmpty != _customerController.isEmpty) {
|
||||||
@ -139,8 +124,6 @@ class _SignatureViewState extends State<SignatureView> {
|
|||||||
/// Ist der Primär-Button auf der aktuellen Stufe aktiv?
|
/// Ist der Primär-Button auf der aktuellen Stufe aktiv?
|
||||||
bool get _stageValid {
|
bool get _stageValid {
|
||||||
switch (_stage) {
|
switch (_stage) {
|
||||||
case _SignStage.payment:
|
|
||||||
return _paymentConfirmed;
|
|
||||||
case _SignStage.customer:
|
case _SignStage.customer:
|
||||||
return _customerStepValid;
|
return _customerStepValid;
|
||||||
case _SignStage.driver:
|
case _SignStage.driver:
|
||||||
@ -150,9 +133,6 @@ class _SignatureViewState extends State<SignatureView> {
|
|||||||
|
|
||||||
Future<void> _onPrimaryPressed() async {
|
Future<void> _onPrimaryPressed() async {
|
||||||
switch (_stage) {
|
switch (_stage) {
|
||||||
case _SignStage.payment:
|
|
||||||
setState(() => _stage = _SignStage.customer);
|
|
||||||
return;
|
|
||||||
case _SignStage.customer:
|
case _SignStage.customer:
|
||||||
setState(() => _stage = _SignStage.driver);
|
setState(() => _stage = _SignStage.driver);
|
||||||
return;
|
return;
|
||||||
@ -160,6 +140,11 @@ class _SignatureViewState extends State<SignatureView> {
|
|||||||
final customerPng = await _customerController.toPngBytes();
|
final customerPng = await _customerController.toPngBytes();
|
||||||
final driverPng = await _driverController.toPngBytes();
|
final driverPng = await _driverController.toPngBytes();
|
||||||
if (customerPng == null || driverPng == null) return;
|
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(
|
widget.onSigned(
|
||||||
SignatureResult(
|
SignatureResult(
|
||||||
customerSignaturePng: customerPng,
|
customerSignaturePng: customerPng,
|
||||||
@ -168,33 +153,23 @@ class _SignatureViewState extends State<SignatureView> {
|
|||||||
notesAcknowledged: _notesEmpty ? false : _notesAccepted,
|
notesAcknowledged: _notesEmpty ? false : _notesAccepted,
|
||||||
acknowledgedNoteIds:
|
acknowledgedNoteIds:
|
||||||
_notesEmpty ? const [] : _notes.map((n) => n.id).toList(),
|
_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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(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 date = DateFormat('dd.MM.yyyy').format(DateTime.now());
|
||||||
final isPayment = _stage == _SignStage.payment;
|
|
||||||
final isDriver = _stage == _SignStage.driver;
|
final isDriver = _stage == _SignStage.driver;
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
switch (_stage) {
|
switch (_stage) {
|
||||||
case _SignStage.payment:
|
|
||||||
title = 'Zahlung bestätigen';
|
|
||||||
case _SignStage.customer:
|
case _SignStage.customer:
|
||||||
title = 'Unterschrift des Kunden';
|
title = 'Unterschrift des Kunden';
|
||||||
case _SignStage.driver:
|
case _SignStage.driver:
|
||||||
@ -207,56 +182,42 @@ class _SignatureViewState extends State<SignatureView> {
|
|||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
if (isPayment) ...[
|
if (!isDriver) ...[
|
||||||
// Stufe 0 — Fahrer kassiert (Bar/EC) und bestätigt VOR den
|
_NotesSection(notes: _notes),
|
||||||
// Unterschriften.
|
|
||||||
_PaymentDueCard(
|
|
||||||
openAmount: widget.openAmount,
|
|
||||||
methodLabel: widget.paymentMethodLabel,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_ConfirmTile(
|
_ConfirmTile(
|
||||||
value: _paymentConfirmed,
|
value: _notesAccepted,
|
||||||
|
enabled: !_notesEmpty,
|
||||||
|
label: _notesText,
|
||||||
|
onChanged: (v) => setState(() => _notesAccepted = v),
|
||||||
|
),
|
||||||
|
_ConfirmTile(
|
||||||
|
value: _receiptAccepted,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
label: _paymentText,
|
label: _receiptText,
|
||||||
onChanged: (v) => setState(() => _paymentConfirmed = v),
|
onChanged: (v) => setState(() => _receiptAccepted = v),
|
||||||
),
|
|
||||||
] else ...[
|
|
||||||
if (!isDriver) ...[
|
|
||||||
_NotesSection(notes: _notes),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_ConfirmTile(
|
|
||||||
value: _notesAccepted,
|
|
||||||
enabled: !_notesEmpty,
|
|
||||||
label: _notesText,
|
|
||||||
onChanged: (v) => setState(() => _notesAccepted = v),
|
|
||||||
),
|
|
||||||
_ConfirmTile(
|
|
||||||
value: _receiptAccepted,
|
|
||||||
enabled: true,
|
|
||||||
label: _receiptText,
|
|
||||||
onChanged: (v) => setState(() => _receiptAccepted = v),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
Text(
|
|
||||||
'Lieferung an: ${customer?.name ?? '⟨Unbekannter Kunde⟩'}',
|
|
||||||
style: theme.textTheme.titleSmall
|
|
||||||
?.copyWith(fontWeight: FontWeight.w700),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_SignaturePad(
|
|
||||||
controller: isDriver ? _driverController : _customerController,
|
|
||||||
onClear: () =>
|
|
||||||
(isDriver ? _driverController : _customerController)
|
|
||||||
.clear(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'${customer?.address.city ?? ''}, den $date',
|
|
||||||
style: theme.textTheme.bodySmall,
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
|
Text(
|
||||||
|
'Lieferung an: ${recipient.name}',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_SignaturePad(
|
||||||
|
controller: isDriver ? _driverController : _customerController,
|
||||||
|
onClear:
|
||||||
|
() =>
|
||||||
|
(isDriver ? _driverController : _customerController)
|
||||||
|
.clear(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${recipient.address.city}, den $date',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _stageValid ? _onPrimaryPressed : null,
|
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 ────────────────────────────────────────────────────────
|
// ─── Notizen-Block ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class _NotesSection extends StatelessWidget {
|
class _NotesSection extends StatelessWidget {
|
||||||
@ -345,8 +246,9 @@ class _NotesSection extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Anmerkungen zur Lieferung',
|
'Anmerkungen zur Lieferung',
|
||||||
style:
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (notes.isEmpty)
|
if (notes.isEmpty)
|
||||||
|
|||||||
@ -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/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_catalog.dart';
|
||||||
import 'package:hl_lieferservice/feature/loading/widget/reason_picker_sheet.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.
|
/// Step 3 — Artikel & Gutschriften.
|
||||||
///
|
///
|
||||||
@ -99,17 +101,26 @@ class StepArticles extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
// Betrags-Gutschrift, per Feature-Flag komplett ausblendbar.
|
||||||
_SectionHeader(text: 'Gutschriften'),
|
FeatureGate(
|
||||||
const SizedBox(height: 8),
|
feature: Feature.articlesCreditSection,
|
||||||
Card(
|
child: Column(
|
||||||
margin: EdgeInsets.zero,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
child: Padding(
|
children: [
|
||||||
padding: const EdgeInsets.all(16),
|
const SizedBox(height: 24),
|
||||||
child: DiscountEditor(
|
_SectionHeader(text: 'Gutschriften'),
|
||||||
deliveryId: delivery.id,
|
const SizedBox(height: 8),
|
||||||
active: delivery.state == DeliveryState.active,
|
Card(
|
||||||
),
|
margin: EdgeInsets.zero,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: DiscountEditor(
|
||||||
|
deliveryId: delivery.id,
|
||||||
|
active: delivery.state == DeliveryState.active,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -2,16 +2,19 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:url_launcher/url_launcher.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/contact_source.dart';
|
||||||
import 'package:hl_lieferservice/domain/entity/customer.dart';
|
import 'package:hl_lieferservice/domain/entity/customer.dart';
|
||||||
import 'package:hl_lieferservice/domain/entity/delivery.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_item.dart';
|
||||||
|
import 'package:hl_lieferservice/domain/entity/delivery_recipient.dart';
|
||||||
import 'package:hl_lieferservice/domain/entity/tour_details.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_bloc.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.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_bloc.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_event.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/bloc/workflow_state.dart';
|
||||||
|
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/reason_dialog.dart';
|
||||||
|
|
||||||
/// Step 1 — Informationen zur Lieferung.
|
/// Step 1 — Informationen zur Lieferung.
|
||||||
///
|
///
|
||||||
@ -34,10 +37,26 @@ class StepInfo extends StatelessWidget {
|
|||||||
// damit derselbe Datensatz nicht zweimal (z. B. „Belegadresse" UND
|
// damit derselbe Datensatz nicht zweimal (z. B. „Belegadresse" UND
|
||||||
// „Kundenstamm") aufpoppt.
|
// „Kundenstamm") aufpoppt.
|
||||||
final mergedSources = details.mergedContactSourcesOf(delivery);
|
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(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||||
children: [
|
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'),
|
_SectionHeader(text: 'Beleg'),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_BelegCard(delivery: delivery),
|
_BelegCard(delivery: delivery),
|
||||||
@ -46,7 +65,7 @@ class StepInfo extends StatelessWidget {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_QuickActions(
|
_QuickActions(
|
||||||
delivery: delivery,
|
delivery: delivery,
|
||||||
customer: customer,
|
recipient: recipient,
|
||||||
mergedSources: mergedSources,
|
mergedSources: mergedSources,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
@ -54,9 +73,19 @@ class StepInfo extends StatelessWidget {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_AgreementsCard(delivery: delivery),
|
_AgreementsCard(delivery: delivery),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_SectionHeader(text: 'Kundeninformationen'),
|
_SectionHeader(
|
||||||
|
text: differs ? 'Besteller (Kunde)' : 'Kundeninformationen',
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
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) ...[
|
if (mergedSources.isNotEmpty) ...[
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_SectionHeader(text: 'Alle Kontaktinfos'),
|
_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 ──────────────────────────────────────────────────────────────
|
// ─── Beleg ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class _BelegCard extends StatelessWidget {
|
class _BelegCard extends StatelessWidget {
|
||||||
@ -124,12 +282,12 @@ enum _StatusAction { hold, cancel, resume }
|
|||||||
class _QuickActions extends StatelessWidget {
|
class _QuickActions extends StatelessWidget {
|
||||||
const _QuickActions({
|
const _QuickActions({
|
||||||
required this.delivery,
|
required this.delivery,
|
||||||
required this.customer,
|
required this.recipient,
|
||||||
required this.mergedSources,
|
required this.mergedSources,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Delivery delivery;
|
final Delivery delivery;
|
||||||
final Customer? customer;
|
final DeliveryRecipient recipient;
|
||||||
final List<MergedContactSource> mergedSources;
|
final List<MergedContactSource> mergedSources;
|
||||||
|
|
||||||
/// Alle aus den Beleg-Kontaktquellen anrufbaren Nummern, in
|
/// Alle aus den Beleg-Kontaktquellen anrufbaren Nummern, in
|
||||||
@ -171,11 +329,9 @@ class _QuickActions extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _launchMaps(BuildContext context) async {
|
Future<void> _launchMaps(BuildContext context) async {
|
||||||
final address = customer != null
|
// Navigiert wird zur Lieferadresse — nicht zur Anschrift des
|
||||||
? '${customer!.address.street} ${customer!.address.houseNumber}, '
|
// Bestellers, die abweichen kann.
|
||||||
'${customer!.address.postalCode} ${customer!.address.city}'
|
final encoded = Uri.encodeComponent(recipient.address.oneLine);
|
||||||
: delivery.deliveryAddressSnapshot.oneLine;
|
|
||||||
final encoded = Uri.encodeComponent(address);
|
|
||||||
// Universelles `geo:?q=…`-Schema funktioniert auf Android + iOS.
|
// Universelles `geo:?q=…`-Schema funktioniert auf Android + iOS.
|
||||||
final uri = Uri.parse(
|
final uri = Uri.parse(
|
||||||
'https://www.google.com/maps/search/?api=1&query=$encoded',
|
'https://www.google.com/maps/search/?api=1&query=$encoded',
|
||||||
@ -245,39 +401,6 @@ class _QuickActions extends StatelessWidget {
|
|||||||
await _call(context, picked.value);
|
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(
|
Future<void> _onStatusSelected(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
_StatusAction action,
|
_StatusAction action,
|
||||||
@ -285,11 +408,11 @@ class _QuickActions extends StatelessWidget {
|
|||||||
final tourBloc = context.read<TourBloc>();
|
final tourBloc = context.read<TourBloc>();
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case _StatusAction.hold:
|
case _StatusAction.hold:
|
||||||
final reason = await _askReason(context, 'Lieferung pausieren');
|
final reason = await showReasonDialog(context, 'Lieferung pausieren');
|
||||||
if (reason == null) return;
|
if (reason == null) return;
|
||||||
tourBloc.add(HoldDelivery(deliveryId: delivery.id, reason: reason));
|
tourBloc.add(HoldDelivery(deliveryId: delivery.id, reason: reason));
|
||||||
case _StatusAction.cancel:
|
case _StatusAction.cancel:
|
||||||
final reason = await _askReason(context, 'Lieferung abbrechen');
|
final reason = await showReasonDialog(context, 'Lieferung abbrechen');
|
||||||
if (reason == null) return;
|
if (reason == null) return;
|
||||||
tourBloc.add(CancelDelivery(deliveryId: delivery.id, reason: reason));
|
tourBloc.add(CancelDelivery(deliveryId: delivery.id, reason: reason));
|
||||||
case _StatusAction.resume:
|
case _StatusAction.resume:
|
||||||
@ -527,11 +650,19 @@ class _AgreementsCard extends StatelessWidget {
|
|||||||
// ─── Kunde + Kontakte ───────────────────────────────────────────────────
|
// ─── Kunde + Kontakte ───────────────────────────────────────────────────
|
||||||
|
|
||||||
class _CustomerCard extends StatelessWidget {
|
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 Customer? customer;
|
||||||
final List<CustomerContact> contacts;
|
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 {
|
Future<void> _call(BuildContext context, String phone) async {
|
||||||
final ok = await launchUrl(Uri(scheme: 'tel', path: phone));
|
final ok = await launchUrl(Uri(scheme: 'tel', path: phone));
|
||||||
if (!ok && context.mounted) {
|
if (!ok && context.mounted) {
|
||||||
@ -589,12 +720,51 @@ class _CustomerCard extends StatelessWidget {
|
|||||||
'${c.address.postalCode} ${c.address.city}',
|
'${c.address.postalCode} ${c.address.city}',
|
||||||
style: const TextStyle(fontSize: 14),
|
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) ...[
|
if (contacts.isNotEmpty) ...[
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
Text(
|
Text(
|
||||||
@ -900,20 +1070,22 @@ class _ArticleRow extends StatelessWidget {
|
|||||||
// Namensblock und allen hinterlegten Telefon-/Mobil-/E-Mail-/Web-Kanälen.
|
// Namensblock und allen hinterlegten Telefon-/Mobil-/E-Mail-/Web-Kanälen.
|
||||||
// Sortiert kommen sie vom Backend; das UI bildet sie 1:1 ab.
|
// Sortiert kommen sie vom Backend; das UI bildet sie 1:1 ab.
|
||||||
|
|
||||||
|
/// Ö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 {
|
class _AllContactsCard extends StatelessWidget {
|
||||||
const _AllContactsCard({required this.sources});
|
const _AllContactsCard({required this.sources});
|
||||||
|
|
||||||
final List<MergedContactSource> sources;
|
final List<MergedContactSource> sources;
|
||||||
|
|
||||||
Future<void> _launch(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')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Card(
|
return Card(
|
||||||
@ -927,7 +1099,7 @@ class _AllContactsCard extends StatelessWidget {
|
|||||||
if (i > 0) const Divider(height: 1),
|
if (i > 0) const Divider(height: 1),
|
||||||
_ContactSourceTile(
|
_ContactSourceTile(
|
||||||
source: sources[i],
|
source: sources[i],
|
||||||
onLaunch: _launch,
|
onLaunch: _launchContact,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@ -979,6 +1151,36 @@ class _ContactSourceTile extends StatelessWidget {
|
|||||||
color: theme.colorScheme.onSurfaceVariant,
|
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) ...[
|
if (channels.isEmpty) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
185
lib/feature/delivery/detail/presentation/steps/step_payment.dart
Normal file
185
lib/feature/delivery/detail/presentation/steps/step_payment.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,68 +1,49 @@
|
|||||||
import 'package:flutter/material.dart';
|
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.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/domain/entity/tour_details.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_bloc.dart';
|
import 'package:hl_lieferservice/feature/delivery/detail/model/delivery_payment_status.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_event.dart';
|
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/delivered_items_card.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_state.dart';
|
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/payment_breakdown_card.dart';
|
||||||
import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.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
|
/// Listet alle Artikel mit der **tatsächlich auszuliefernden Menge** auf,
|
||||||
/// (Original-Soll minus lokaler Partial-Remove-Drafts minus
|
/// dazu die Betragsaufstellung und den Stand der Zahlung. Die Zahlungsart
|
||||||
/// Komplett-Removes). Dazu Anzahlung-Anzeige, optionale Gutschrift,
|
/// wird nicht mehr hier gewählt, sondern im Step „Zahlung" abgewickelt und
|
||||||
/// Zahlungsmethoden-Dropdown.
|
/// protokolliert — die Übersicht zeigt das Ergebnis nur an.
|
||||||
///
|
///
|
||||||
/// Der „Unterschreiben"-Button lebt in der Bottom-Navigation des
|
/// Der „Unterschreiben"-Button lebt in der Bottom-Navigation des
|
||||||
/// Page-Wrappers; hier zeigen wir den Resümee-Block, der direkt vor der
|
/// Page-Wrappers; hier zeigen wir den Resümee-Block, der direkt vor der
|
||||||
/// Unterschrift steht.
|
/// Unterschrift steht.
|
||||||
class StepSummary extends StatelessWidget {
|
class StepSummary extends StatelessWidget {
|
||||||
const StepSummary({
|
const StepSummary({super.key, required this.delivery, required this.details});
|
||||||
super.key,
|
|
||||||
required this.delivery,
|
|
||||||
required this.details,
|
|
||||||
});
|
|
||||||
|
|
||||||
final Delivery delivery;
|
final Delivery delivery;
|
||||||
final TourDetails details;
|
final TourDetails details;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<DeliveryWorkflowBloc, DeliveryWorkflowState>(
|
final status = DeliveryPaymentStatus.of(delivery, details);
|
||||||
builder: (context, wfState) {
|
return ListView(
|
||||||
return ListView(
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
children: [
|
||||||
children: [
|
const _SectionHeader(text: 'Ausgelieferte Artikel'),
|
||||||
_SectionHeader(text: 'Ausgelieferte Artikel'),
|
const SizedBox(height: 8),
|
||||||
const SizedBox(height: 8),
|
DeliveredItemsCard(delivery: delivery, details: details),
|
||||||
_DeliveredItems(
|
const SizedBox(height: 24),
|
||||||
delivery: delivery,
|
const _SectionHeader(text: 'Zahlung'),
|
||||||
details: details,
|
const SizedBox(height: 8),
|
||||||
),
|
PaymentBreakdownCard(
|
||||||
const SizedBox(height: 24),
|
status: status,
|
||||||
_SectionHeader(text: 'Zahlung'),
|
credit: details.creditOf(delivery.id),
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
_PaymentSummary(
|
const SizedBox(height: 12),
|
||||||
delivery: delivery,
|
PaymentRecordCard(status: status),
|
||||||
credit: details.creditOf(delivery.id),
|
const SizedBox(height: 16),
|
||||||
),
|
const _SignHint(),
|
||||||
const SizedBox(height: 24),
|
],
|
||||||
_SectionHeader(text: 'Zahlungsmethode'),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_PaymentMethodPicker(
|
|
||||||
delivery: delivery,
|
|
||||||
overrideId: wfState.paymentMethodOverrideId,
|
|
||||||
credit: details.creditOf(delivery.id),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const _SignHint(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -75,436 +56,9 @@ class _SectionHeader extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Text(
|
return Text(
|
||||||
text,
|
text,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(
|
||||||
fontWeight: FontWeight.w700,
|
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -531,10 +85,7 @@ class _SignHint extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Mit „Unterschreiben" unten schließt der Kunde den Vorgang ab.',
|
'Mit „Unterschreiben" unten schließt der Kunde den Vorgang ab.',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 13, color: theme.colorScheme.primary),
|
||||||
fontSize: 13,
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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/bloc.dart';
|
||||||
import 'package:hl_lieferservice/feature/car_selection/bloc/state.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_bloc.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.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/bloc/tour_state.dart';
|
||||||
@ -165,8 +166,10 @@ class _DiscountEditorState extends State<DiscountEditor> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Default: freies Betrags-Textfeld. Hinter dem Feature-Flag
|
// Default: freies Betrags-Textfeld. Hinter dem Feature-Flag
|
||||||
// `discountAmountStepper` liegt die ursprüngliche +/−-Variante.
|
// `articles.credit_amount_stepper` liegt die +/−-Variante.
|
||||||
if (FeatureFlags.discountAmountStepper)
|
if (context
|
||||||
|
.read<FeatureFlags>()
|
||||||
|
.isEnabled(Feature.articlesCreditAmountStepper))
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@ -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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
};
|
||||||
@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
40
lib/feature/delivery/model/tour_day_text.dart
Normal file
40
lib/feature/delivery/model/tour_day_text.dart
Normal 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}';
|
||||||
|
}
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:hl_lieferservice/domain/entity/article.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.dart';
|
||||||
import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
|
import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
|
||||||
import 'package:hl_lieferservice/domain/entity/tour_details.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/detail/presentation/delivery_detail_page.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_info.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/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:
|
/// Entscheidet beim Tap auf eine Lieferung, wohin navigiert wird:
|
||||||
///
|
///
|
||||||
/// * Aktive Lieferung mit noch offenen Filial-Artikeln → zuerst der
|
/// * Aktive Lieferung mit noch offenen Filial-Artikeln → zuerst der
|
||||||
/// Filial-Abhol-Scan-Screen. Der Fahrer ist an der Filiale und muss die
|
/// 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
|
/// Ware abscannen, bevor er ausliefern kann. Nach dem Scan geht es von
|
||||||
/// Übersicht zurück (die Lieferung verliert ihren Filial-Hinweis).
|
/// dort direkt in die Auslieferung (die Lieferung verliert ihren
|
||||||
|
/// Filial-Hinweis).
|
||||||
/// * Sonst → direkt die Auslieferung (`DeliveryDetail`), die beim Kunden
|
/// * Sonst → direkt die Auslieferung (`DeliveryDetail`), die beim Kunden
|
||||||
/// bearbeitet wird.
|
/// bearbeitet wird.
|
||||||
///
|
///
|
||||||
@ -407,6 +408,7 @@ class _NextDeliveryCard extends StatelessWidget {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final delivery = entry.delivery;
|
final delivery = entry.delivery;
|
||||||
final customer = details.customerOf(delivery);
|
final customer = details.customerOf(delivery);
|
||||||
|
final recipient = details.recipientOf(delivery);
|
||||||
final pendingExternal = details.pendingExternalWarehouseGroups(delivery);
|
final pendingExternal = details.pendingExternalWarehouseGroups(delivery);
|
||||||
final hasPendingExternal = pendingExternal.isNotEmpty;
|
final hasPendingExternal = pendingExternal.isNotEmpty;
|
||||||
|
|
||||||
@ -431,15 +433,19 @@ class _NextDeliveryCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
_HeaderRow(
|
_HeaderRow(
|
||||||
position: entry.position,
|
position: entry.position,
|
||||||
customer: customer,
|
name: recipient.name,
|
||||||
desiredTime: delivery.desiredTime,
|
desiredTime: delivery.desiredTime,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
_InfoRow(
|
_InfoRow(
|
||||||
icon: Icons.location_on_outlined,
|
icon: Icons.location_on_outlined,
|
||||||
text: delivery.deliveryAddressSnapshot.oneLine,
|
text: recipient.address.oneLineWithAddition,
|
||||||
emphasized: true,
|
emphasized: true,
|
||||||
),
|
),
|
||||||
|
if (recipient.differsFromOrderer)
|
||||||
|
OrdererHint(
|
||||||
|
ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
|
||||||
|
),
|
||||||
if (delivery.specialAgreements != null &&
|
if (delivery.specialAgreements != null &&
|
||||||
delivery.specialAgreements!.isNotEmpty) ...[
|
delivery.specialAgreements!.isNotEmpty) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
@ -491,12 +497,12 @@ class _NextDeliveryCard extends StatelessWidget {
|
|||||||
class _HeaderRow extends StatelessWidget {
|
class _HeaderRow extends StatelessWidget {
|
||||||
const _HeaderRow({
|
const _HeaderRow({
|
||||||
required this.position,
|
required this.position,
|
||||||
required this.customer,
|
required this.name,
|
||||||
required this.desiredTime,
|
required this.desiredTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
final int position;
|
final int position;
|
||||||
final Customer? customer;
|
final String name;
|
||||||
final String? desiredTime;
|
final String? desiredTime;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -523,7 +529,7 @@ class _HeaderRow extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
customer?.name ?? '⟨Unbekannter Kunde⟩',
|
name,
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
@ -790,6 +796,7 @@ class _DeliveryTile extends StatelessWidget {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final delivery = entry.delivery;
|
final delivery = entry.delivery;
|
||||||
final customer = details.customerOf(delivery);
|
final customer = details.customerOf(delivery);
|
||||||
|
final recipient = details.recipientOf(delivery);
|
||||||
final pendingExternal = details.pendingExternalWarehouseGroups(delivery);
|
final pendingExternal = details.pendingExternalWarehouseGroups(delivery);
|
||||||
final hasPendingExternal = pendingExternal.isNotEmpty;
|
final hasPendingExternal = pendingExternal.isNotEmpty;
|
||||||
|
|
||||||
@ -827,7 +834,7 @@ class _DeliveryTile extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
customer?.name ?? '⟨Unbekannter Kunde⟩',
|
recipient.name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@ -839,12 +846,16 @@ class _DeliveryTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
delivery.deliveryAddressSnapshot.oneLine,
|
recipient.address.oneLineWithAddition,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (recipient.differsFromOrderer)
|
||||||
|
OrdererHint(
|
||||||
|
ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
|
||||||
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|||||||
@ -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/feature/delivery/overview/presentation/delivery_overview.dart';
|
||||||
import 'package:hl_lieferservice/widget/home/presentation/home_drawer.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/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
|
/// Inhalt der Phase "Ausliefern". Sortieren und Beladen werden über eigene
|
||||||
/// Pages und das Phasen-Routing in `Home` gerendert — diese Page übernimmt
|
/// Pages und das Phasen-Routing in `Home` gerendert — diese Page übernimmt
|
||||||
@ -35,16 +37,9 @@ class DeliveryOverviewPage extends StatelessWidget {
|
|||||||
carId: carId,
|
carId: carId,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: BlocConsumer<TourBloc, TourState>(
|
// Die Refresh-Fehler-Snackbar (inkl. Retry) sitzt zentral im
|
||||||
listenWhen: (prev, next) =>
|
// PhaseStepper-Header (`_ReloadButton`) und gilt damit für alle Phasen.
|
||||||
next is TourLoaded && next.refreshError != null,
|
body: BlocBuilder<TourBloc, TourState>(
|
||||||
listener: (context, state) {
|
|
||||||
if (state is TourLoaded && state.refreshError != null) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(state.refreshError!)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case TourLoaded(:final details):
|
case TourLoaded(:final details):
|
||||||
@ -84,25 +79,26 @@ class _EmptyTourBody extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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.
|
// normaler Zustand — kein Fehler. UX-Hinweis und Pull-to-refresh.
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
context.read<TourBloc>().add(const RefreshTour());
|
context.read<TourBloc>().add(const RefreshTour());
|
||||||
},
|
},
|
||||||
child: ListView(
|
child: ListView(
|
||||||
children: const [
|
children: [
|
||||||
SizedBox(height: 120),
|
const SizedBox(height: 120),
|
||||||
Icon(Icons.event_busy, size: 64, color: Colors.grey),
|
const Icon(Icons.event_busy, size: 64, color: Colors.grey),
|
||||||
SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Center(
|
Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 32),
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
child: Text(
|
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.',
|
'Zum Aktualisieren nach unten ziehen.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(fontSize: 16),
|
style: const TextStyle(fontSize: 16),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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/feature/delivery/overview/presentation/delivery_fail_page.dart';
|
||||||
import 'package:hl_lieferservice/widget/home/presentation/home_drawer.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/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
|
/// Page für die erste Phase bei Mehr-Auto-Teams: Auswählen der eigenen
|
||||||
/// Lieferungen aus dem gemeinsamen Tour-Pool.
|
/// Lieferungen aus dem gemeinsamen Tour-Pool.
|
||||||
@ -73,13 +76,13 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showReleaseDialog(Delivery delivery, TourDetails details) async {
|
Future<void> _showReleaseDialog(Delivery delivery, TourDetails details) async {
|
||||||
final customer = details.customerOf(delivery);
|
final recipient = details.recipientOf(delivery);
|
||||||
final result = await showDialog<bool>(
|
final result = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('Lieferung freigeben'),
|
title: const Text('Lieferung freigeben'),
|
||||||
content: Text(
|
content: Text(
|
||||||
'${customer?.name ?? 'Diese Lieferung'} wurde Ihrem Fahrzeug '
|
'${recipient.name} wurde Ihrem Fahrzeug '
|
||||||
'zugeordnet. Möchten Sie sie wieder freigeben?',
|
'zugeordnet. Möchten Sie sie wieder freigeben?',
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
@ -105,7 +108,7 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
|
|||||||
Delivery delivery,
|
Delivery delivery,
|
||||||
TourDetails details,
|
TourDetails details,
|
||||||
) async {
|
) async {
|
||||||
final customer = details.customerOf(delivery);
|
final recipient = details.recipientOf(delivery);
|
||||||
final foreignPlate = _plateFor(delivery.assignedCarId);
|
final foreignPlate = _plateFor(delivery.assignedCarId);
|
||||||
final ownPlate = _plateFor(widget.selectedCarId);
|
final ownPlate = _plateFor(widget.selectedCarId);
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@ -119,7 +122,7 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
|
|||||||
style: theme.textTheme.bodyMedium,
|
style: theme.textTheme.bodyMedium,
|
||||||
children: [
|
children: [
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: '${customer?.name ?? 'Diese Lieferung'} ist aktuell ',
|
text: '${recipient.name} ist aktuell ',
|
||||||
),
|
),
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: foreignPlate,
|
text: foreignPlate,
|
||||||
@ -241,6 +244,7 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
|
|||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final delivery = available[index];
|
final delivery = available[index];
|
||||||
final customer = details.customerOf(delivery);
|
final customer = details.customerOf(delivery);
|
||||||
|
final recipient = details.recipientOf(delivery);
|
||||||
final isSelected = _selectedIds.contains(delivery.id);
|
final isSelected = _selectedIds.contains(delivery.id);
|
||||||
return CheckboxListTile(
|
return CheckboxListTile(
|
||||||
key: ValueKey('available-${delivery.id}'),
|
key: ValueKey('available-${delivery.id}'),
|
||||||
@ -258,12 +262,13 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
title: Text(
|
title: Text(
|
||||||
customer?.name ?? '⟨Unbekannter Kunde⟩',
|
recipient.name,
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: RecipientSubtitle(
|
||||||
delivery.deliveryAddressSnapshot.oneLine,
|
recipient: recipient,
|
||||||
style: const TextStyle(fontSize: 12),
|
ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
|
||||||
|
addressStyle: const TextStyle(fontSize: 12),
|
||||||
),
|
),
|
||||||
controlAffinity: ListTileControlAffinity.leading,
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
);
|
);
|
||||||
@ -289,6 +294,7 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
|
|||||||
final isOwn = delivery.assignedCarId == widget.selectedCarId;
|
final isOwn = delivery.assignedCarId == widget.selectedCarId;
|
||||||
final plate = _plateFor(delivery.assignedCarId);
|
final plate = _plateFor(delivery.assignedCarId);
|
||||||
final customer = details.customerOf(delivery);
|
final customer = details.customerOf(delivery);
|
||||||
|
final recipient = details.recipientOf(delivery);
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
color: isOwn
|
color: isOwn
|
||||||
@ -310,15 +316,16 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
|
|||||||
: theme.colorScheme.onSurfaceVariant,
|
: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
customer?.name ?? '⟨Unbekannter Kunde⟩',
|
recipient.name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: isOwn ? theme.colorScheme.primary : null,
|
color: isOwn ? theme.colorScheme.primary : null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: RecipientSubtitle(
|
||||||
delivery.deliveryAddressSnapshot.oneLine,
|
recipient: recipient,
|
||||||
style: const TextStyle(fontSize: 12),
|
ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
|
||||||
|
addressStyle: const TextStyle(fontSize: 12),
|
||||||
),
|
),
|
||||||
trailing: _plateBadge(context, plate, own: isOwn),
|
trailing: _plateBadge(context, plate, own: isOwn),
|
||||||
),
|
),
|
||||||
@ -370,18 +377,26 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
|
|||||||
return BlocBuilder<TourBloc, TourState>(
|
return BlocBuilder<TourBloc, TourState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state is TourLoadFailed) {
|
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) {
|
if (state is TourEmpty) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
drawer: const HomeAppDrawer(),
|
drawer: const HomeAppDrawer(),
|
||||||
appBar: AppBar(title: const Text('Lieferungen auswählen')),
|
appBar: AppBar(title: const Text('Lieferungen auswählen')),
|
||||||
body: const Center(
|
body: Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Für heute ist keine Tour zugewiesen.',
|
'Für ${TourDayText.of(context.watch<TourDateCubit>().state).afterFor} '
|
||||||
style: TextStyle(fontSize: 16),
|
'ist keine Tour zugewiesen.',
|
||||||
|
style: const TextStyle(fontSize: 16),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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/bloc/tour_state.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.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/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/home/presentation/home_drawer.dart';
|
||||||
import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.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
|
/// 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
|
/// 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() {
|
Widget _emptyState() {
|
||||||
|
final day = TourDayText.of(context.watch<TourDateCubit>().state);
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.inbox_outlined, size: 64, color: Colors.grey),
|
const Icon(Icons.inbox_outlined, size: 64, color: Colors.grey),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
'Keine Lieferungen heute',
|
'Keine Lieferungen ${day.when}',
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 32),
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
child: Text(
|
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,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
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
|
// währenddessen den Lade-Branch (Spinner) und landet danach wieder
|
||||||
// hier (TourEmpty) oder in der sortierbaren Lieferungsliste.
|
// hier (TourEmpty) oder in der sortierbaren Lieferungsliste.
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
@ -172,6 +178,7 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
|
|||||||
|
|
||||||
Widget _singleDeliveryHint(Delivery single, TourDetails details) {
|
Widget _singleDeliveryHint(Delivery single, TourDetails details) {
|
||||||
final customer = details.customerOf(single);
|
final customer = details.customerOf(single);
|
||||||
|
final recipient = details.recipientOf(single);
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
_hintCard(
|
_hintCard(
|
||||||
@ -190,10 +197,11 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(customer?.name ?? '⟨Unbekannter Kunde⟩'),
|
title: Text(recipient.name),
|
||||||
subtitle: Text(
|
subtitle: RecipientSubtitle(
|
||||||
single.deliveryAddressSnapshot.oneLine,
|
recipient: recipient,
|
||||||
style: const TextStyle(fontSize: 11),
|
ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
|
||||||
|
addressStyle: const TextStyle(fontSize: 11),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -251,6 +259,25 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
|
|||||||
body: _emptyState(),
|
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) {
|
if (state is! TourLoaded) {
|
||||||
// Lade-Zwischenzustand (auch beim 'Neu laden') mit derselben
|
// Lade-Zwischenzustand (auch beim 'Neu laden') mit derselben
|
||||||
// PhaseStepper-AppBar — sonst flackert die Leiste kurz auf die
|
// PhaseStepper-AppBar — sonst flackert die Leiste kurz auf die
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hl_lieferservice/domain/entity/delivery.dart';
|
import 'package:hl_lieferservice/domain/entity/delivery.dart';
|
||||||
import 'package:hl_lieferservice/domain/entity/tour_details.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.
|
/// Drag&Drop-Liste der heutigen Lieferungen eines Fahrzeugs.
|
||||||
///
|
///
|
||||||
@ -100,6 +101,7 @@ class _SortableDeliveryListState extends State<SortableDeliveryList> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
final customer = widget.details.customerOf(delivery);
|
final customer = widget.details.customerOf(delivery);
|
||||||
|
final recipient = widget.details.recipientOf(delivery);
|
||||||
return ListTile(
|
return ListTile(
|
||||||
key: Key('reorder-item-${delivery.id}'),
|
key: Key('reorder-item-${delivery.id}'),
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
@ -111,10 +113,11 @@ class _SortableDeliveryListState extends State<SortableDeliveryList> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(customer?.name ?? '⟨Unbekannter Kunde⟩'),
|
title: Text(recipient.name),
|
||||||
subtitle: Text(
|
subtitle: RecipientSubtitle(
|
||||||
delivery.deliveryAddressSnapshot.oneLine,
|
recipient: recipient,
|
||||||
style: const TextStyle(fontSize: 11),
|
ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
|
||||||
|
addressStyle: const TextStyle(fontSize: 11),
|
||||||
),
|
),
|
||||||
trailing: const Icon(Icons.drag_handle),
|
trailing: const Icon(Icons.drag_handle),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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_bloc.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.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/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/article_scanner_stripe.dart';
|
||||||
import 'package:hl_lieferservice/widget/scanner/item_matcher.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/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`-
|
/// QR-Validierung (`Artikelnr;Kundennr;Belegnr`) und derselbe `ScanItem`-
|
||||||
/// Pfad wie die Beladen-Phase — über die geteilten Scanner-Module.
|
/// Pfad wie die Beladen-Phase — über die geteilten Scanner-Module.
|
||||||
///
|
///
|
||||||
/// Nach vollständigem Scan: Erfolgs-Zustand + Button „zurück zur Übersicht".
|
/// Nach vollständigem Scan: Erfolgs-Zustand + Button „Weiter zur Lieferung",
|
||||||
/// Der Fahrer fährt dann zum Kunden; dort öffnet ein erneuter Tap die
|
/// der diese Page durch die eigentliche Auslieferung (`DeliveryDetail`)
|
||||||
/// eigentliche Auslieferung (`DeliveryDetail`).
|
/// ersetzt. „Zurück" führt von dort direkt zur Übersicht, nicht erneut
|
||||||
|
/// hierher.
|
||||||
class FilialePickupScanPage extends StatefulWidget {
|
class FilialePickupScanPage extends StatefulWidget {
|
||||||
const FilialePickupScanPage({super.key, required this.deliveryId});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<TourBloc, TourState>(
|
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 externalItems = _externalItems(delivery, details);
|
||||||
final doneCount = externalItems.where((it) => it.isDone).length;
|
final doneCount = externalItems.where((it) => it.isDone).length;
|
||||||
final allDone = externalItems.isNotEmpty && doneCount == externalItems.length;
|
final allDone = externalItems.isNotEmpty && doneCount == externalItems.length;
|
||||||
@ -217,7 +227,7 @@ class _FilialePickupScanPageState extends State<FilialePickupScanPage> {
|
|||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
_Header(
|
_Header(
|
||||||
customerName: customer?.name ?? '⟨Unbekannter Kunde⟩',
|
customerName: recipient.name,
|
||||||
belegnummer: delivery.erpBelegnummer,
|
belegnummer: delivery.erpBelegnummer,
|
||||||
doneCount: doneCount,
|
doneCount: doneCount,
|
||||||
total: externalItems.length,
|
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,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: onConfirm,
|
onPressed: onConfirm,
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_forward),
|
||||||
label: const Text('Fertig — zurück zur Übersicht'),
|
label: const Text('Weiter zur Lieferung'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
20
lib/feature/feature_flags/feature.dart
Normal file
20
lib/feature/feature_flags/feature.dart
Normal 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;
|
||||||
|
}
|
||||||
45
lib/feature/feature_flags/feature_flags.dart
Normal file
45
lib/feature/feature_flags/feature_flags.dart
Normal 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;
|
||||||
|
}
|
||||||
20
lib/feature/feature_flags/feature_flags_repository.dart
Normal file
20
lib/feature/feature_flags/feature_flags_repository.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
lib/feature/feature_flags/feature_gate.dart
Normal file
22
lib/feature/feature_flags/feature_gate.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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/item_matcher.dart';
|
||||||
import 'package:hl_lieferservice/widget/scanner/manual_entry_dialog.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/scanner/scan_code_parser.dart';
|
||||||
|
import 'package:hl_lieferservice/widget/orderer_hint.dart';
|
||||||
|
|
||||||
/// Vollbild-Sicht eines Kunden in der Beladen-Phase mit aktivem Scanner.
|
/// Vollbild-Sicht eines Kunden in der Beladen-Phase mit aktivem Scanner.
|
||||||
///
|
///
|
||||||
@ -523,6 +524,7 @@ class _CustomerBody extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final customer = details.customerOf(delivery);
|
final customer = details.customerOf(delivery);
|
||||||
|
final recipient = details.recipientOf(delivery);
|
||||||
// Items werden vom Aggregat-Helper schon nach Lager gruppiert
|
// Items werden vom Aggregat-Helper schon nach Lager gruppiert
|
||||||
// geliefert: Standardlager zuerst, danach Filiale alphabetisch.
|
// geliefert: Standardlager zuerst, danach Filiale alphabetisch.
|
||||||
// Nicht-scanbare Positionen und `removed`-Items sind dabei schon
|
// Nicht-scanbare Positionen und `removed`-Items sind dabei schon
|
||||||
@ -611,7 +613,7 @@ class _CustomerBody extends StatelessWidget {
|
|||||||
// Kunden" wird dadurch räumlich klar.
|
// Kunden" wird dadurch räumlich klar.
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_CustomerAvatar(customer: customer),
|
_CustomerAvatar(customer: customer, name: recipient.name),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -630,14 +632,18 @@ class _CustomerBody extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
customer?.name ?? '⟨Unbekannter Kunde⟩',
|
recipient.name,
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
delivery.deliveryAddressSnapshot.oneLine,
|
recipient.address.oneLineWithAddition,
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
|
if (recipient.differsFromOrderer)
|
||||||
|
OrdererHint(
|
||||||
|
ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
|
||||||
|
),
|
||||||
if (delivery.state != DeliveryState.active) ...[
|
if (delivery.state != DeliveryState.active) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_DeliveryStateBadge(delivery: delivery),
|
_DeliveryStateBadge(delivery: delivery),
|
||||||
@ -987,12 +993,13 @@ class _NothingToLoadHint extends StatelessWidget {
|
|||||||
///
|
///
|
||||||
/// Farbe ist deterministisch aus der `customer.id` abgeleitet, damit
|
/// Farbe ist deterministisch aus der `customer.id` abgeleitet, damit
|
||||||
/// Re-Builds und Page-Wechsel den Avatar nicht „flackern" lassen.
|
/// Re-Builds und Page-Wechsel den Avatar nicht „flackern" lassen.
|
||||||
/// Initialen kommen aus dem Namen — Vor- und Zunamen kombiniert,
|
/// Initialen kommen aus dem angezeigten Namen (Empfänger vor Ort) — Vor-
|
||||||
/// einzelne Worte mit einem Buchstaben.
|
/// und Zunamen kombiniert, einzelne Worte mit einem Buchstaben.
|
||||||
class _CustomerAvatar extends StatelessWidget {
|
class _CustomerAvatar extends StatelessWidget {
|
||||||
const _CustomerAvatar({required this.customer});
|
const _CustomerAvatar({required this.customer, required this.name});
|
||||||
|
|
||||||
final Customer? customer;
|
final Customer? customer;
|
||||||
|
final String name;
|
||||||
|
|
||||||
/// Kleine kuratierte Palette — kräftig genug zum Erkennen, aber nicht
|
/// Kleine kuratierte Palette — kräftig genug zum Erkennen, aber nicht
|
||||||
/// schreiend. Reihenfolge ist Absicht: die ersten Farben fallen am
|
/// schreiend. Reihenfolge ist Absicht: die ersten Farben fallen am
|
||||||
@ -1009,9 +1016,9 @@ class _CustomerAvatar extends StatelessWidget {
|
|||||||
];
|
];
|
||||||
|
|
||||||
String get _initials {
|
String get _initials {
|
||||||
final name = customer?.name.trim() ?? '';
|
final trimmed = name.trim();
|
||||||
if (name.isEmpty) return '?';
|
if (trimmed.isEmpty) return '?';
|
||||||
final parts = name
|
final parts = trimmed
|
||||||
.split(RegExp(r'\s+'))
|
.split(RegExp(r'\s+'))
|
||||||
.where((p) => p.isNotEmpty)
|
.where((p) => p.isNotEmpty)
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
|
|||||||
@ -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/feature/loading/presentation/loading_customer_page.dart';
|
||||||
import 'package:hl_lieferservice/widget/home/presentation/home_drawer.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/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
|
/// Übersichts-Ansicht für die Beladen-Phase: alle Kunden mit ihren
|
||||||
/// Artikeln und Soll/Ist-Mengen.
|
/// Artikeln und Soll/Ist-Mengen.
|
||||||
@ -64,14 +67,34 @@ class LoadingOverviewPage extends StatelessWidget {
|
|||||||
|
|
||||||
return BlocBuilder<TourBloc, TourState>(
|
return BlocBuilder<TourBloc, TourState>(
|
||||||
builder: (context, tourState) {
|
builder: (context, tourState) {
|
||||||
|
// 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
|
||||||
|
// mehr zum Datumswechsel.
|
||||||
|
final stepperHeader = PreferredSize(
|
||||||
|
preferredSize: const Size.fromHeight(140),
|
||||||
|
child: PhaseStepper(
|
||||||
|
currentPhase: DeliveryPhase.beladen,
|
||||||
|
carId: carId,
|
||||||
|
),
|
||||||
|
);
|
||||||
if (tourState is TourLoadFailed) {
|
if (tourState is TourLoadFailed) {
|
||||||
return const DeliveryLoadingFailedPage();
|
// 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) {
|
if (tourState is TourEmpty) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
drawer: const HomeAppDrawer(),
|
drawer: const HomeAppDrawer(),
|
||||||
appBar: AppBar(title: const Text('Beladung')),
|
appBar: stepperHeader,
|
||||||
body: const _EmptyOverview(),
|
body: const SafeArea(top: false, child: _EmptyOverview()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (tourState is! TourLoaded) {
|
if (tourState is! TourLoaded) {
|
||||||
@ -79,8 +102,11 @@ class LoadingOverviewPage extends StatelessWidget {
|
|||||||
// damit der Fahrer beim Hängen nicht ohne Logout dasitzt.
|
// damit der Fahrer beim Hängen nicht ohne Logout dasitzt.
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
drawer: const HomeAppDrawer(),
|
drawer: const HomeAppDrawer(),
|
||||||
appBar: AppBar(title: const Text('Beladung')),
|
appBar: stepperHeader,
|
||||||
body: const Center(child: CircularProgressIndicator()),
|
body: const SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,13 +129,7 @@ class LoadingOverviewPage extends StatelessWidget {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
drawer: const HomeAppDrawer(),
|
drawer: const HomeAppDrawer(),
|
||||||
appBar: PreferredSize(
|
appBar: stepperHeader,
|
||||||
preferredSize: const Size.fromHeight(140),
|
|
||||||
child: PhaseStepper(
|
|
||||||
currentPhase: DeliveryPhase.beladen,
|
|
||||||
carId: carId,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: ordered.isEmpty
|
child: ordered.isEmpty
|
||||||
@ -389,6 +409,7 @@ class _OverviewTile extends StatelessWidget {
|
|||||||
final scannedAnyStandard =
|
final scannedAnyStandard =
|
||||||
standardItems.any((it) => it.scanProgress.scannedQuantity > 0);
|
standardItems.any((it) => it.scanProgress.scannedQuantity > 0);
|
||||||
final customer = details.customerOf(delivery);
|
final customer = details.customerOf(delivery);
|
||||||
|
final recipient = details.recipientOf(delivery);
|
||||||
|
|
||||||
Color cardColor;
|
Color cardColor;
|
||||||
Color borderColor;
|
Color borderColor;
|
||||||
@ -525,7 +546,7 @@ class _OverviewTile extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
customer?.name ?? '⟨Unbekannter Kunde⟩',
|
recipient.name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@ -537,12 +558,16 @@ class _OverviewTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
delivery.deliveryAddressSnapshot.oneLine,
|
recipient.address.oneLineWithAddition,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (recipient.differsFromOrderer)
|
||||||
|
OrdererHint(
|
||||||
|
ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
|
||||||
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@ -861,6 +886,7 @@ class _EmptyOverview extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final day = TourDayText.of(context.watch<TourDateCubit>().state);
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@ -873,7 +899,7 @@ class _EmptyOverview extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Für heute ist aktuell keine Tour verfügbar.',
|
'Für ${day.afterFor} ist aktuell keine Tour verfügbar.',
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.bodySmall
|
.bodySmall
|
||||||
|
|||||||
148
lib/feature/settings/presentation/environment_settings_page.dart
Normal file
148
lib/feature/settings/presentation/environment_settings_page.dart
Normal 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import 'package:hl_lieferservice/data/network/backend_config.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_bloc.dart';
|
||||||
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart';
|
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart';
|
||||||
import 'package:hl_lieferservice/feature/settings/bloc/settings_bloc.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
|
/// Externer Browser statt In-App-WebView, damit ggf. im Browser
|
||||||
/// gespeicherte Credentials / Authenticator-Apps weiter funktionieren.
|
/// gespeicherte Credentials / Authenticator-Apps weiter funktionieren.
|
||||||
Future<void> _openAccountConsole() async {
|
Future<void> _openAccountConsole() async {
|
||||||
final issuer = BackendConfig.fromEnvironment.keycloakIssuerUrl;
|
final issuer = locator<BackendConfig>().keycloakIssuerUrl;
|
||||||
final uri = Uri.parse('$issuer/account/');
|
final uri = Uri.parse('$issuer/account/');
|
||||||
final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
if (!ok && mounted) {
|
if (!ok && mounted) {
|
||||||
|
|||||||
@ -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_bloc.dart';
|
||||||
import 'package:hl_lieferservice/bloc/app_events.dart';
|
import 'package:hl_lieferservice/bloc/app_events.dart';
|
||||||
import 'package:hl_lieferservice/data/cache/attachment_cache.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/data/network/network_locator.dart';
|
||||||
import 'package:hl_lieferservice/feature/settings/bloc/settings_bloc.dart';
|
import 'package:hl_lieferservice/feature/settings/bloc/settings_bloc.dart';
|
||||||
import 'package:hl_lieferservice/feature/settings/bloc/settings_event.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;
|
final locator = GetIt.instance;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
// Backend-Migration Phase A: HTTP-Stack + Token-Provider registrieren,
|
// Das Networking (HTTP-Stack + Token-Provider) registriert der AppBloc,
|
||||||
// bevor irgendein Bloc starten kann. Wirft eine Compile-Sicherheit
|
// sobald die Umgebung aus `assets/hl_server_config.json` feststeht. Alle
|
||||||
// hin, dass `HolzleitnerApi` ab hier per `locator<HolzleitnerApi>()`
|
// Konsumenten von `locator<HolzleitnerApi>()` entstehen erst danach
|
||||||
// verfügbar ist.
|
// (Bloc-Baum wird erst bei `AppConfigLoaded` gebaut).
|
||||||
registerNetworking(locator: locator);
|
|
||||||
|
|
||||||
// Persistenter Vorschau-Cache für Attachment-Bilder. Über die gesamte
|
// Persistenter Vorschau-Cache für Attachment-Bilder. Über die gesamte
|
||||||
// App-Lebensdauer stabil und zustandslos (das Verzeichnis löst er lazy
|
// App-Lebensdauer stabil und zustandslos (das Verzeichnis löst er lazy
|
||||||
// selbst auf), daher hier als Singleton.
|
// selbst auf), daher hier als Singleton.
|
||||||
locator.registerSingleton<AttachmentCache>(AttachmentCache());
|
locator.registerSingleton<AttachmentCache>(AttachmentCache());
|
||||||
|
|
||||||
runApp(MultiBlocProvider(providers: [
|
runApp(MultiBlocProvider(providers: [
|
||||||
BlocProvider(create: (context) => AppBloc(),),
|
BlocProvider(
|
||||||
|
create: (context) => AppBloc(
|
||||||
|
repository: EnvironmentRepository(),
|
||||||
|
activate: (config) =>
|
||||||
|
registerNetworking(locator: locator, config: config),
|
||||||
|
),
|
||||||
|
),
|
||||||
BlocProvider(create: (context) => SettingsBloc())
|
BlocProvider(create: (context) => SettingsBloc())
|
||||||
], child: App()));
|
], child: App()));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,30 +1,21 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:hl_lieferservice/bloc/app_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/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_bloc.dart';
|
||||||
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.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/feature/authentication/presentation/login_enforcer.dart';
|
||||||
import 'package:hl_lieferservice/main.dart' show locator;
|
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/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/feature/cars/presentation/car_management_page.dart';
|
||||||
import 'package:hl_lieferservice/data/repository/payment_methods_repository_impl.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:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart';
|
||||||
import 'package:holzleitner_api/holzleitner_api.dart' show HolzleitnerApi;
|
import 'package:holzleitner_api/holzleitner_api.dart' show HolzleitnerApi;
|
||||||
import 'package:hl_lieferservice/data/repository/tour_repository_impl.dart';
|
import 'package:hl_lieferservice/feature/feature_flags/feature_flags.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/widget/home/bloc/navigation_bloc.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/bloc/operation_bloc.dart';
|
||||||
import 'package:hl_lieferservice/widget/operations/presentation/operation_view_enforcer.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 'package:hl_lieferservice/bloc/app_states.dart';
|
||||||
import 'home/presentation/home.dart';
|
import 'home/presentation/home.dart';
|
||||||
@ -43,7 +34,14 @@ class _DeliveryAppState extends State<DeliveryApp> {
|
|||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state is AppConfigLoaded) {
|
if (state is AppConfigLoaded) {
|
||||||
return MultiBlocProvider(
|
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: [
|
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) => NavigationBloc()),
|
||||||
BlocProvider(create: (context) => OperationBloc()),
|
BlocProvider(create: (context) => OperationBloc()),
|
||||||
BlocProvider(
|
BlocProvider(
|
||||||
@ -58,62 +56,6 @@ class _DeliveryAppState extends State<DeliveryApp> {
|
|||||||
// und ggf. direkt einloggen.
|
// und ggf. direkt einloggen.
|
||||||
..add(const RestoreSessionRequested()),
|
..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.
|
|
||||||
tourTokenResolver: () {
|
|
||||||
final tourState = context.read<TourBloc>().state;
|
|
||||||
return tourState is TourLoaded
|
|
||||||
? tourState.details.tour.syncedAt
|
|
||||||
.millisecondsSinceEpoch
|
|
||||||
.toString()
|
|
||||||
: null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
BlocProvider(
|
BlocProvider(
|
||||||
// Zahlungsmethoden sind firmenweite Stammdaten — wir laden
|
// Zahlungsmethoden sind firmenweite Stammdaten — wir laden
|
||||||
// sie einmal beim App-Start und cachen sie im Cubit. Der
|
// sie einmal beim App-Start und cachen sie im Cubit. Der
|
||||||
@ -125,36 +67,42 @@ class _DeliveryAppState extends State<DeliveryApp> {
|
|||||||
)..load(),
|
)..load(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
// Fahrerbezogene Blocs (Tour, Fahrzeuge, Auswahl, Phase) je
|
||||||
title: 'Holzleitner Auslieferung',
|
// Anmeldung neu — siehe SessionScope.
|
||||||
debugShowCheckedModeBanner: false,
|
child: SessionScope(
|
||||||
// Wrap the Navigator (not just the home route) so the loading
|
environmentId: state.active.id,
|
||||||
// overlay covers every pushed route — DeliveryDetail, Cars,
|
child: MaterialApp(
|
||||||
// dialogs, etc. — not only the initial home tree.
|
title: 'Holzleitner Auslieferung',
|
||||||
builder: (context, child) =>
|
debugShowCheckedModeBanner: false,
|
||||||
OperationViewEnforcer(child: child ?? const SizedBox.shrink()),
|
// Wrap the Navigator (not just the home route) so the loading
|
||||||
home: BlocBuilder<AppBloc, AppState>(
|
// overlay covers every pushed route — DeliveryDetail, Cars,
|
||||||
builder: (context, state) {
|
// dialogs, etc. — not only the initial home tree.
|
||||||
if (state is AppConfigLoading) {
|
builder: (context, child) =>
|
||||||
return Scaffold(
|
OperationViewEnforcer(child: child ?? const SizedBox.shrink()),
|
||||||
body: Center(child: CircularProgressIndicator()),
|
home: BlocBuilder<AppBloc, AppState>(
|
||||||
);
|
builder: (context, state) {
|
||||||
}
|
if (state is AppConfigLoading) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (state is AppConfigLoadingFailed) {
|
if (state is AppConfigLoadingFailed) {
|
||||||
return Scaffold(body: Center(child: Text(state.message)));
|
return Scaffold(body: Center(child: Text(state.message)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state is AppConfigLoaded) {
|
if (state is AppConfigLoaded) {
|
||||||
return LoginEnforcer(
|
// Zahlungsmethoden lädt SessionScope nach jeder Anmeldung neu.
|
||||||
child: CarSelectionEnforcer(child: Home()),
|
return LoginEnforcer(
|
||||||
);
|
child: CarSelectionEnforcer(child: Home()),
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Container();
|
return Container();
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
routes: {"/cars": (context) => CarManagementPage()},
|
||||||
),
|
),
|
||||||
routes: {"/cars": (context) => CarManagementPage()},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -164,7 +112,12 @@ class _DeliveryAppState extends State<DeliveryApp> {
|
|||||||
title: 'Holzleitner Auslieferung',
|
title: 'Holzleitner Auslieferung',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
home: Scaffold(
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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';
|
static const _ext = 'jpeg';
|
||||||
|
|
||||||
Future<Uint8List> _load() async {
|
Future<Uint8List> _load() async {
|
||||||
@ -133,12 +141,42 @@ class _AttachmentImageState extends State<AttachmentImage> {
|
|||||||
if (snapshot.hasError ||
|
if (snapshot.hasError ||
|
||||||
snapshot.data == null ||
|
snapshot.data == null ||
|
||||||
snapshot.data!.isEmpty) {
|
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(
|
return Center(
|
||||||
child: Padding(
|
child: GestureDetector(
|
||||||
padding: const EdgeInsets.all(16),
|
behavior: HitTestBehavior.opaque,
|
||||||
child: Icon(
|
onTap: _retry,
|
||||||
Icons.broken_image_outlined,
|
child: Padding(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,12 +5,16 @@ 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_bloc.dart';
|
||||||
import 'package:hl_lieferservice/feature/cars/bloc/cars_event.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/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_bloc.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/bloc/phase_event.dart';
|
import 'package:hl_lieferservice/feature/delivery/bloc/phase_event.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/bloc/phase_state.dart';
|
import 'package:hl_lieferservice/feature/delivery/bloc/phase_state.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_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_event.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/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/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_overview_page.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_selection_page.dart';
|
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_selection_page.dart';
|
||||||
@ -33,7 +37,12 @@ class Home extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HomeState extends State<Home> {
|
class _HomeState extends State<Home> {
|
||||||
String? _initializedCarId;
|
/// Key `carId|tourToken`, für den der [PhaseBloc] zuletzt zum Laden
|
||||||
|
/// angestoßen wurde. Ändert sich das Auto ODER die Tour-Version
|
||||||
|
/// (Tageswechsel im Kopf-Kalender, neuer Sync), wird erneut geladen — der
|
||||||
|
/// Bloc holt dann den persistierten Stand der **neuen** Tour, sodass ein
|
||||||
|
/// neuer Tag nicht die Beladen-/Ausliefern-Häkchen des alten Tags erbt.
|
||||||
|
String? _initializedPhaseKey;
|
||||||
|
|
||||||
/// Merkt, für welches Auto die automatische Lieferungs-Zuordnung bereits
|
/// Merkt, für welches Auto die automatische Lieferungs-Zuordnung bereits
|
||||||
/// Auto, für das zuletzt automatisch zugewiesen wurde (Ein-Auto-Teams).
|
/// Auto, für das zuletzt automatisch zugewiesen wurde (Ein-Auto-Teams).
|
||||||
@ -52,7 +61,15 @@ class _HomeState extends State<Home> {
|
|||||||
|
|
||||||
// Tour beim ersten Aufbau laden. Account-Filter sitzt jetzt
|
// Tour beim ersten Aufbau laden. Account-Filter sitzt jetzt
|
||||||
// serverseitig im JWT — kein Personalnummer-Argument mehr nötig.
|
// serverseitig im JWT — kein Personalnummer-Argument mehr nötig.
|
||||||
context.read<TourBloc>().add(const LoadTour());
|
//
|
||||||
|
// Das im Kopf-Kalender gewählte Datum berücksichtigen: bleibt der
|
||||||
|
// TourDateCubit über einen (Warm-)Neustart bestehen (Header zeigt z. B.
|
||||||
|
// noch „Morgen"), muss der Initial-Load DENSELBEN Tag laden — sonst
|
||||||
|
// klaffen Anzeige (Morgen) und geladene Daten (Heute) auseinander.
|
||||||
|
// `null` = heute (Server entscheidet), also unverändertes Default.
|
||||||
|
context
|
||||||
|
.read<TourBloc>()
|
||||||
|
.add(LoadTour.forDate(context.read<TourDateCubit>().state));
|
||||||
|
|
||||||
// CarsBloc auch hier triggern: wenn der CarSelectBloc beim App-Start
|
// CarsBloc auch hier triggern: wenn der CarSelectBloc beim App-Start
|
||||||
// eine valide Tages-Auswahl aus den SharedPreferences fand, wurde die
|
// eine valide Tages-Auswahl aus den SharedPreferences fand, wurde die
|
||||||
@ -66,18 +83,22 @@ class _HomeState extends State<Home> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Stellt sicher, dass für das aktuell gewählte Auto eine Phase im
|
/// Stellt sicher, dass für das aktuell gewählte Auto eine Phase im
|
||||||
/// [PhaseBloc] existiert. Wird im build() reaktiv aufgerufen, daher mit
|
/// [PhaseBloc] existiert — und zwar für die **aktuell geladene Tour**.
|
||||||
/// `_initializedCarId` gegen mehrfache Loads gesichert.
|
/// Wird im build() reaktiv aufgerufen, daher per [_initializedPhaseKey]
|
||||||
|
/// (`carId|tourToken`) gegen mehrfache Loads gesichert. Der Token kommt aus
|
||||||
|
/// [TourStatePhaseToken.phaseToken] (derselbe wie im `tourTokenResolver`);
|
||||||
|
/// `null` (keine Tour) zählt dabei als eigener Zustand.
|
||||||
///
|
///
|
||||||
/// Wichtig: Wir feuern den Load erst, sobald sowohl Tour als auch Cars
|
/// Wichtig: Wir feuern den Load erst, sobald sowohl Tour als auch Cars
|
||||||
/// einen geladen-Zustand haben — sonst fragt der `PhaseBloc` den
|
/// einen geladen-Zustand haben — sonst fragt der `PhaseBloc` den
|
||||||
/// `carCountResolver` ab, bekommt `null` und entscheidet für Mehr-Auto-
|
/// `carCountResolver` ab, bekommt `null` und entscheidet für Mehr-Auto-
|
||||||
/// Teams fälschlich auf `sortieren` statt `auswaehlen`.
|
/// Teams fälschlich auf `sortieren` statt `auswaehlen`.
|
||||||
void _ensurePhaseLoaded(String carId) {
|
void _ensurePhaseLoaded(String carId) {
|
||||||
if (_initializedCarId == carId) return;
|
final key = '$carId|${context.read<TourBloc>().state.phaseToken}';
|
||||||
|
if (_initializedPhaseKey == key) return;
|
||||||
final carsState = context.read<CarsBloc>().state;
|
final carsState = context.read<CarsBloc>().state;
|
||||||
if (carsState is! CarsLoaded) return;
|
if (carsState is! CarsLoaded) return;
|
||||||
_initializedCarId = carId;
|
_initializedPhaseKey = key;
|
||||||
context.read<PhaseBloc>().add(PhaseLoadForCar(carId: carId));
|
context.read<PhaseBloc>().add(PhaseLoadForCar(carId: carId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,6 +181,29 @@ class _HomeState extends State<Home> {
|
|||||||
// Spinner zeigen — das dauert in der Praxis maximal
|
// Spinner zeigen — das dauert in der Praxis maximal
|
||||||
// ein paar Frames.
|
// ein paar Frames.
|
||||||
if (phase == null) {
|
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(
|
return const Scaffold(
|
||||||
body: Center(child: CircularProgressIndicator()),
|
body: Center(child: CircularProgressIndicator()),
|
||||||
);
|
);
|
||||||
|
|||||||
69
lib/widget/orderer_hint.dart
Normal file
69
lib/widget/orderer_hint.dart
Normal 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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -125,12 +125,19 @@ class PhaseStepper extends StatelessWidget {
|
|||||||
final carCount = carsState is CarsLoaded ? carsState.cars.length : 0;
|
final carCount = carsState is CarsLoaded ? carsState.cars.length : 0;
|
||||||
final phases = visiblePhases ?? _effectivePhases(carCount);
|
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
|
// Höchste erreichte Phase aus dem PhaseBloc — bestimmt, welche
|
||||||
// Vorwärts-Sprünge erlaubt sind.
|
// Vorwärts-Sprünge erlaubt sind.
|
||||||
final phaseBlocState = context.watch<PhaseBloc>().state;
|
final phaseBlocState = context.watch<PhaseBloc>().state;
|
||||||
final maxReached = phaseBlocState is PhaseReady
|
final blocMax = phaseBlocState is PhaseReady
|
||||||
? (phaseBlocState.maxPhaseFor(carId) ?? currentPhase)
|
? (phaseBlocState.maxPhaseFor(carId) ?? currentPhase)
|
||||||
: currentPhase;
|
: currentPhase;
|
||||||
|
final maxReached = hasTour ? blocMax : phases.first;
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
color: theme.primaryColor,
|
color: theme.primaryColor,
|
||||||
@ -425,7 +432,9 @@ class _SelectedDatePill extends StatelessWidget {
|
|||||||
// Standard-Fenster um heute; falls das aktuell angezeigte Datum (z. B.
|
// Standard-Fenster um heute; falls das aktuell angezeigte Datum (z. B.
|
||||||
// ein Dev-Override in der Vergangenheit) außerhalb liegt, Fenster
|
// ein Dev-Override in der Vergangenheit) außerhalb liegt, Fenster
|
||||||
// erweitern, damit initialDate immer im gültigen Bereich bleibt.
|
// 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 lastDefault = today.add(const Duration(days: 60));
|
||||||
final firstDate = current.isBefore(firstDefault) ? current : firstDefault;
|
final firstDate = current.isBefore(firstDefault) ? current : firstDefault;
|
||||||
final lastDate = current.isAfter(lastDefault) ? current : lastDefault;
|
final lastDate = current.isAfter(lastDefault) ? current : lastDefault;
|
||||||
@ -441,7 +450,7 @@ class _SelectedDatePill extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
if (picked == null || !context.mounted) return;
|
if (picked == null || !context.mounted) return;
|
||||||
context.read<TourDateCubit>().pick(picked);
|
context.read<TourDateCubit>().pick(picked);
|
||||||
context.read<TourBloc>().add(LoadTour(date: picked));
|
context.read<TourBloc>().add(LoadTour.forDate(picked));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -477,7 +486,31 @@ class _ReloadButton extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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
|
// Nur neu rendern, wenn sich der Refresh-Status ändert — sonst
|
||||||
// läuft der Builder bei jedem Scan-Tick mit.
|
// läuft der Builder bei jedem Scan-Tick mit.
|
||||||
buildWhen: (prev, curr) {
|
buildWhen: (prev, curr) {
|
||||||
|
|||||||
124
lib/widget/session_scope.dart
Normal file
124
lib/widget/session_scope.dart
Normal 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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": {
|
"/admin/delivered-belegnummern": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"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}": {
|
"/attachments/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"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": {
|
"/deliveries/{delivery_id}/resume": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -1129,16 +1414,27 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/me/tours/today": {
|
"/me/tours": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
"tours"
|
"tours"
|
||||||
],
|
],
|
||||||
"summary": "Listet heutige Touren des angemeldeten Fahrers (Filter aus dem JWT).",
|
"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_today",
|
"operationId": "list_my_tours",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "date",
|
||||||
|
"in": "query",
|
||||||
|
"description": "Ziel-Tourdatum YYYY-MM-DD (Default: heute)",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Liste der heutigen Touren",
|
"description": "Liste der Touren am Ziel-Datum",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@ -1147,6 +1443,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Ung\u00fcltiges Datum"
|
||||||
|
},
|
||||||
"401": {
|
"401": {
|
||||||
"description": "Authentifizierung fehlgeschlagen"
|
"description": "Authentifizierung fehlgeschlagen"
|
||||||
}
|
}
|
||||||
@ -1722,11 +2021,19 @@
|
|||||||
"country"
|
"country"
|
||||||
],
|
],
|
||||||
"properties": {
|
"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": {
|
"city": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"country": {
|
"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": {
|
"houseNumber": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@ -1910,6 +2217,13 @@
|
|||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"description": "Fahrzeug des Akteurs (Audit-Spur). Muss zum Account geh\u00f6ren."
|
"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": {
|
"notesAcknowledged": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "\u201eAnmerkungen zur Lieferung zur Kenntnis genommen.\" \u2014 Pflicht nur, wenn\nNotizen existieren (das pr\u00fcft der Server)."
|
"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": {
|
"ContactChannel": {
|
||||||
"type": "object",
|
"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.",
|
"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": {
|
"ContactSource": {
|
||||||
"type": "object",
|
"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": [
|
"required": [
|
||||||
"id",
|
"id",
|
||||||
"deliveryId",
|
"deliveryId",
|
||||||
@ -1999,6 +2366,17 @@
|
|||||||
"null"
|
"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": {
|
"anrede": {
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"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": {
|
"DeliveryItem": {
|
||||||
"type": "object",
|
"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.",
|
"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": {
|
"DeliveryResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"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": {
|
"ScanEvent": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
@ -3137,6 +3850,17 @@
|
|||||||
"null"
|
"null"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"address": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/Address",
|
||||||
|
"description": "Anschrift dieses Adress-Datensatzes, falls gepflegt."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"anrede": {
|
"anrede": {
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
@ -3416,6 +4140,7 @@
|
|||||||
"warehouses",
|
"warehouses",
|
||||||
"notes",
|
"notes",
|
||||||
"credits",
|
"credits",
|
||||||
|
"payments",
|
||||||
"services",
|
"services",
|
||||||
"deliveryServices",
|
"deliveryServices",
|
||||||
"contactSources",
|
"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`."
|
"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": {
|
"services": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
|
|||||||
@ -17,6 +17,8 @@ doc/CarResponse.md
|
|||||||
doc/CarsApi.md
|
doc/CarsApi.md
|
||||||
doc/CarsList.md
|
doc/CarsList.md
|
||||||
doc/CompleteDeliveryAcknowledgements.md
|
doc/CompleteDeliveryAcknowledgements.md
|
||||||
|
doc/CompletedDeliveriesResponse.md
|
||||||
|
doc/CompletedDeliveryItem.md
|
||||||
doc/ContactChannel.md
|
doc/ContactChannel.md
|
||||||
doc/ContactKind.md
|
doc/ContactKind.md
|
||||||
doc/ContactRole.md
|
doc/ContactRole.md
|
||||||
@ -34,10 +36,13 @@ doc/Delivery.md
|
|||||||
doc/DeliveryCredit.md
|
doc/DeliveryCredit.md
|
||||||
doc/DeliveryCreditEventRequest.md
|
doc/DeliveryCreditEventRequest.md
|
||||||
doc/DeliveryCreditResponse.md
|
doc/DeliveryCreditResponse.md
|
||||||
|
doc/DeliveryDetails.md
|
||||||
doc/DeliveryItem.md
|
doc/DeliveryItem.md
|
||||||
doc/DeliveryNote.md
|
doc/DeliveryNote.md
|
||||||
doc/DeliveryNoteResponse.md
|
doc/DeliveryNoteResponse.md
|
||||||
doc/DeliveryOrderEntry.md
|
doc/DeliveryOrderEntry.md
|
||||||
|
doc/DeliveryPayment.md
|
||||||
|
doc/DeliveryPaymentResponse.md
|
||||||
doc/DeliveryResponse.md
|
doc/DeliveryResponse.md
|
||||||
doc/DeliveryServiceResponse.md
|
doc/DeliveryServiceResponse.md
|
||||||
doc/DeliveryServiceValue.md
|
doc/DeliveryServiceValue.md
|
||||||
@ -52,6 +57,11 @@ doc/PaymentMethod.md
|
|||||||
doc/PaymentMethodResponse.md
|
doc/PaymentMethodResponse.md
|
||||||
doc/PaymentMethodsApi.md
|
doc/PaymentMethodsApi.md
|
||||||
doc/PaymentMethodsList.md
|
doc/PaymentMethodsList.md
|
||||||
|
doc/PendingReviewResponse.md
|
||||||
|
doc/PositionsModifiedResponse.md
|
||||||
|
doc/RecordDeliveryPaymentRequest.md
|
||||||
|
doc/ResolveReviewRequest.md
|
||||||
|
doc/ReviewedItemResponse.md
|
||||||
doc/ScanEvent.md
|
doc/ScanEvent.md
|
||||||
doc/ScanResult.md
|
doc/ScanResult.md
|
||||||
doc/ScanResultStatus.md
|
doc/ScanResultStatus.md
|
||||||
@ -115,6 +125,8 @@ lib/src/model/car.dart
|
|||||||
lib/src/model/car_response.dart
|
lib/src/model/car_response.dart
|
||||||
lib/src/model/cars_list.dart
|
lib/src/model/cars_list.dart
|
||||||
lib/src/model/complete_delivery_acknowledgements.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_channel.dart
|
||||||
lib/src/model/contact_kind.dart
|
lib/src/model/contact_kind.dart
|
||||||
lib/src/model/contact_role.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.dart
|
||||||
lib/src/model/delivery_credit_event_request.dart
|
lib/src/model/delivery_credit_event_request.dart
|
||||||
lib/src/model/delivery_credit_response.dart
|
lib/src/model/delivery_credit_response.dart
|
||||||
|
lib/src/model/delivery_details.dart
|
||||||
lib/src/model/delivery_item.dart
|
lib/src/model/delivery_item.dart
|
||||||
lib/src/model/delivery_note.dart
|
lib/src/model/delivery_note.dart
|
||||||
lib/src/model/delivery_note_response.dart
|
lib/src/model/delivery_note_response.dart
|
||||||
lib/src/model/delivery_order_entry.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_response.dart
|
||||||
lib/src/model/delivery_service_response.dart
|
lib/src/model/delivery_service_response.dart
|
||||||
lib/src/model/delivery_service_value.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.dart
|
||||||
lib/src/model/payment_method_response.dart
|
lib/src/model/payment_method_response.dart
|
||||||
lib/src/model/payment_methods_list.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_event.dart
|
||||||
lib/src/model/scan_result.dart
|
lib/src/model/scan_result.dart
|
||||||
lib/src/model/scan_result_status.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/model/warehouse.dart
|
||||||
lib/src/serializers.dart
|
lib/src/serializers.dart
|
||||||
pubspec.yaml
|
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
|
|
||||||
|
|||||||
@ -66,10 +66,15 @@ All URIs are relative to *http://localhost*
|
|||||||
Class | Method | HTTP request | Description
|
Class | Method | HTTP request | Description
|
||||||
------------ | ------------- | ------------- | -------------
|
------------ | ------------- | ------------- | -------------
|
||||||
[*AccountsApi*](doc/AccountsApi.md) | [**getAccount**](doc/AccountsApi.md#getaccount) | **GET** /accounts/{personalnummer} | Liest den Account zu einer Personalnummer.
|
[*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. `404`, 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 (`completed_at`).
|
||||||
[*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** (`mail_sent_at IS NULL`). „Ausgeliefert\" = es existiert ein Abschluss. Mit `day` (DD-MM-YYYY) nur Abschlüsse dieses Berliner Kalendertages; **ohne `day` alle offenen** (über alle Tage) — so bleiben Belege über Mitternacht nicht hängen.
|
[*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** (`mail_sent_at IS NULL`). „Ausgeliefert\" = es existiert ein Abschluss. Mit `day` (DD-MM-YYYY) nur Abschlüsse dieses Berliner Kalendertages; **ohne `day` 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) | [**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** (`mail_sent_at = now()`, nur wo noch offen). Vom Mailclient aufzurufen, NACHDEM ERPframe die Mails erfolgreich verschickt hat — danach erscheinen die Belege nicht mehr in `GET /admin/delivered-belegnummern`.
|
[*AdminApi*](doc/AdminApi.md) | [**markMailSent**](doc/AdminApi.md#markmailsent) | **POST** /admin/mark-mail-sent | Markiert die Liefermails der angegebenen Belegnummern als **versendet** (`mail_sent_at = now()`, nur wo noch offen). Vom Mailclient aufzurufen, NACHDEM ERPframe die Mails erfolgreich verschickt hat — danach erscheinen die Belege nicht mehr in `GET /admin/delivered-belegnummern`.
|
||||||
|
[*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. `404`, wenn die Belegnummer unbekannt ist. Gibt es mehrere Lieferungen mit derselben Belegnummer, ist das Flag `true`, 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) | [**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 `GET /admin/reviews` (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 (`?w=&h=&q=&ext=&page=`).
|
[*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 (`?w=&h=&q=&ext=&page=`).
|
||||||
[*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) | [**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.
|
[*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 `204 No Content`.
|
[*DeliveriesApi*](doc/DeliveriesApi.md) | [**deleteNote**](doc/DeliveriesApi.md#deletenote) | **DELETE** /deliveries/{delivery_id}/notes/{note_id} | Löscht eine Notiz. Antwortet mit `204 No Content`.
|
||||||
[*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\"). Nur bei aktiver Lieferung. Antwort `204`.
|
[*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\"). Nur bei aktiver Lieferung. Antwort `204`.
|
||||||
[*DeliveriesApi*](doc/DeliveriesApi.md) | [**hold**](doc/DeliveriesApi.md#hold) | **POST** /deliveries/{delivery_id}/hold | Setzt die Lieferung auf `held`. Nur aus `active` zulässig.
|
[*DeliveriesApi*](doc/DeliveriesApi.md) | [**hold**](doc/DeliveriesApi.md#hold) | **POST** /deliveries/{delivery_id}/hold | Setzt die Lieferung auf `held`. Nur aus `active` zulässig.
|
||||||
|
[*DeliveriesApi*](doc/DeliveriesApi.md) | [**recordPayment**](doc/DeliveriesApi.md#recordpayment) | **POST** /deliveries/{delivery_id}/payment | 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.
|
||||||
[*DeliveriesApi*](doc/DeliveriesApi.md) | [**resume**](doc/DeliveriesApi.md#resume) | **POST** /deliveries/{delivery_id}/resume | Setzt die Lieferung zurück auf `active`. Nur aus `held` zulässig.
|
[*DeliveriesApi*](doc/DeliveriesApi.md) | [**resume**](doc/DeliveriesApi.md#resume) | **POST** /deliveries/{delivery_id}/resume | Setzt die Lieferung zurück auf `active`. Nur aus `held` 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 (`boolValue`/`numericValue`) muss gesetzt sein; numerische Werte werden gegen min/max geprüft. Nur bei aktiver Lieferung.
|
[*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 (`boolValue`/`numericValue`) 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. `delivery_id` ist Teil des Pfads (REST-Konsistenz), die Notiz wird über `note_id` adressiert.
|
[*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. `delivery_id` ist Teil des Pfads (REST-Konsistenz), die Notiz wird über `note_id` 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. `kind` ist nicht änderbar.
|
[*ServicesApi*](doc/ServicesApi.md) | [**updateService**](doc/ServicesApi.md#updateservice) | **PATCH** /services/{id} | Patcht Name/Grenzen/Aktiv-Flag/Sortierung. `kind` 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 `(driver_personalnummer, tour_date)`, pro Lieferung `(belegart_id, belegnummer)`.
|
[*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 `(driver_personalnummer, tour_date)`, pro Lieferung `(belegart_id, belegnummer)`.
|
||||||
[*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) | [**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 `?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).
|
||||||
[*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 `400 validation` abgelehnt.
|
[*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 `400 validation` abgelehnt.
|
||||||
|
|
||||||
|
|
||||||
@ -116,6 +122,8 @@ Class | Method | HTTP request | Description
|
|||||||
- [CarResponse](doc/CarResponse.md)
|
- [CarResponse](doc/CarResponse.md)
|
||||||
- [CarsList](doc/CarsList.md)
|
- [CarsList](doc/CarsList.md)
|
||||||
- [CompleteDeliveryAcknowledgements](doc/CompleteDeliveryAcknowledgements.md)
|
- [CompleteDeliveryAcknowledgements](doc/CompleteDeliveryAcknowledgements.md)
|
||||||
|
- [CompletedDeliveriesResponse](doc/CompletedDeliveriesResponse.md)
|
||||||
|
- [CompletedDeliveryItem](doc/CompletedDeliveryItem.md)
|
||||||
- [ContactChannel](doc/ContactChannel.md)
|
- [ContactChannel](doc/ContactChannel.md)
|
||||||
- [ContactKind](doc/ContactKind.md)
|
- [ContactKind](doc/ContactKind.md)
|
||||||
- [ContactRole](doc/ContactRole.md)
|
- [ContactRole](doc/ContactRole.md)
|
||||||
@ -132,10 +140,13 @@ Class | Method | HTTP request | Description
|
|||||||
- [DeliveryCredit](doc/DeliveryCredit.md)
|
- [DeliveryCredit](doc/DeliveryCredit.md)
|
||||||
- [DeliveryCreditEventRequest](doc/DeliveryCreditEventRequest.md)
|
- [DeliveryCreditEventRequest](doc/DeliveryCreditEventRequest.md)
|
||||||
- [DeliveryCreditResponse](doc/DeliveryCreditResponse.md)
|
- [DeliveryCreditResponse](doc/DeliveryCreditResponse.md)
|
||||||
|
- [DeliveryDetails](doc/DeliveryDetails.md)
|
||||||
- [DeliveryItem](doc/DeliveryItem.md)
|
- [DeliveryItem](doc/DeliveryItem.md)
|
||||||
- [DeliveryNote](doc/DeliveryNote.md)
|
- [DeliveryNote](doc/DeliveryNote.md)
|
||||||
- [DeliveryNoteResponse](doc/DeliveryNoteResponse.md)
|
- [DeliveryNoteResponse](doc/DeliveryNoteResponse.md)
|
||||||
- [DeliveryOrderEntry](doc/DeliveryOrderEntry.md)
|
- [DeliveryOrderEntry](doc/DeliveryOrderEntry.md)
|
||||||
|
- [DeliveryPayment](doc/DeliveryPayment.md)
|
||||||
|
- [DeliveryPaymentResponse](doc/DeliveryPaymentResponse.md)
|
||||||
- [DeliveryResponse](doc/DeliveryResponse.md)
|
- [DeliveryResponse](doc/DeliveryResponse.md)
|
||||||
- [DeliveryServiceResponse](doc/DeliveryServiceResponse.md)
|
- [DeliveryServiceResponse](doc/DeliveryServiceResponse.md)
|
||||||
- [DeliveryServiceValue](doc/DeliveryServiceValue.md)
|
- [DeliveryServiceValue](doc/DeliveryServiceValue.md)
|
||||||
@ -148,6 +159,11 @@ Class | Method | HTTP request | Description
|
|||||||
- [PaymentMethod](doc/PaymentMethod.md)
|
- [PaymentMethod](doc/PaymentMethod.md)
|
||||||
- [PaymentMethodResponse](doc/PaymentMethodResponse.md)
|
- [PaymentMethodResponse](doc/PaymentMethodResponse.md)
|
||||||
- [PaymentMethodsList](doc/PaymentMethodsList.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)
|
- [ScanEvent](doc/ScanEvent.md)
|
||||||
- [ScanResult](doc/ScanResult.md)
|
- [ScanResult](doc/ScanResult.md)
|
||||||
- [ScanResultStatus](doc/ScanResultStatus.md)
|
- [ScanResultStatus](doc/ScanResultStatus.md)
|
||||||
|
|||||||
@ -8,8 +8,9 @@ import 'package:holzleitner_api/api.dart';
|
|||||||
## Properties
|
## Properties
|
||||||
Name | Type | Description | Notes
|
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** | |
|
**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** | |
|
**houseNumber** | **String** | |
|
||||||
**postalCode** | **String** | |
|
**postalCode** | **String** | |
|
||||||
**street** | **String** | |
|
**street** | **String** | |
|
||||||
|
|||||||
@ -9,12 +9,113 @@ All URIs are relative to *http://localhost*
|
|||||||
|
|
||||||
Method | HTTP request | Description
|
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. `404`, 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 (`completed_at`).
|
||||||
[**deliveredBelegnummern**](AdminApi.md#deliveredbelegnummern) | **GET** /admin/delivered-belegnummern | Liefert die Belegnummern ausgelieferter (abgeschlossener) Lieferungen, **deren Liefermail noch nicht versendet wurde** (`mail_sent_at IS NULL`). „Ausgeliefert\" = es existiert ein Abschluss. Mit `day` (DD-MM-YYYY) nur Abschlüsse dieses Berliner Kalendertages; **ohne `day` alle offenen** (über alle Tage) — so bleiben Belege über Mitternacht nicht hängen.
|
[**deliveredBelegnummern**](AdminApi.md#deliveredbelegnummern) | **GET** /admin/delivered-belegnummern | Liefert die Belegnummern ausgelieferter (abgeschlossener) Lieferungen, **deren Liefermail noch nicht versendet wurde** (`mail_sent_at IS NULL`). „Ausgeliefert\" = es existiert ein Abschluss. Mit `day` (DD-MM-YYYY) nur Abschlüsse dieses Berliner Kalendertages; **ohne `day` 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.
|
[**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** (`mail_sent_at = now()`, nur wo noch offen). Vom Mailclient aufzurufen, NACHDEM ERPframe die Mails erfolgreich verschickt hat — danach erscheinen die Belege nicht mehr in `GET /admin/delivered-belegnummern`.
|
[**markMailSent**](AdminApi.md#markmailsent) | **POST** /admin/mark-mail-sent | Markiert die Liefermails der angegebenen Belegnummern als **versendet** (`mail_sent_at = now()`, nur wo noch offen). Vom Mailclient aufzurufen, NACHDEM ERPframe die Mails erfolgreich verschickt hat — danach erscheinen die Belege nicht mehr in `GET /admin/delivered-belegnummern`.
|
||||||
|
[**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. `404`, wenn die Belegnummer unbekannt ist. Gibt es mehrere Lieferungen mit derselben Belegnummer, ist das Flag `true`, 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).
|
[**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 `GET /admin/reviews` (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**
|
# **deliveredBelegnummern**
|
||||||
> DeliveredBelegnummernResponse deliveredBelegnummern(day)
|
> 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)
|
[[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<PendingReviewResponse>**](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**
|
# **markMailSent**
|
||||||
> MarkMailSentResponse markMailSent(markMailSentRequest)
|
> 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)
|
[[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**
|
||||||
> pushCompletion(deliveryId)
|
> 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)
|
[[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)
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ Name | Type | Description | Notes
|
|||||||
------------ | ------------- | ------------- | -------------
|
------------ | ------------- | ------------- | -------------
|
||||||
**acknowledgedNoteIds** | **BuiltList<String>** | Notiz-IDs, die zum Abschlusszeitpunkt sichtbar waren und mit-bestätigt wurden (Audit-Robustheit). | [optional]
|
**acknowledgedNoteIds** | **BuiltList<String>** | 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]
|
**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]
|
**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]
|
**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]
|
**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]
|
||||||
|
|||||||
18
packages/holzleitner_api/doc/CompletedDeliveriesResponse.md
Normal file
18
packages/holzleitner_api/doc/CompletedDeliveriesResponse.md
Normal 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<CompletedDeliveryItem>**](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)
|
||||||
|
|
||||||
|
|
||||||
16
packages/holzleitner_api/doc/CompletedDeliveryItem.md
Normal file
16
packages/holzleitner_api/doc/CompletedDeliveryItem.md
Normal 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)
|
||||||
|
|
||||||
|
|
||||||
@ -9,6 +9,7 @@ import 'package:holzleitner_api/api.dart';
|
|||||||
Name | Type | Description | Notes
|
Name | Type | Description | Notes
|
||||||
------------ | ------------- | ------------- | -------------
|
------------ | ------------- | ------------- | -------------
|
||||||
**abteilung** | **String** | | [optional]
|
**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]
|
**anrede** | **String** | | [optional]
|
||||||
**deliveryId** | **String** | |
|
**deliveryId** | **String** | |
|
||||||
**funktion** | **String** | | [optional]
|
**funktion** | **String** | | [optional]
|
||||||
|
|||||||
@ -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 `204 No Content`.
|
[**deleteNote**](DeliveriesApi.md#deletenote) | **DELETE** /deliveries/{delivery_id}/notes/{note_id} | Löscht eine Notiz. Antwortet mit `204 No Content`.
|
||||||
[**deleteServiceValue**](DeliveriesApi.md#deleteservicevalue) | **DELETE** /deliveries/{delivery_id}/services/{service_id} | Entfernt den Service-Wert einer Lieferung (Service „nicht gesetzt\"). Nur bei aktiver Lieferung. Antwort `204`.
|
[**deleteServiceValue**](DeliveriesApi.md#deleteservicevalue) | **DELETE** /deliveries/{delivery_id}/services/{service_id} | Entfernt den Service-Wert einer Lieferung (Service „nicht gesetzt\"). Nur bei aktiver Lieferung. Antwort `204`.
|
||||||
[**hold**](DeliveriesApi.md#hold) | **POST** /deliveries/{delivery_id}/hold | Setzt die Lieferung auf `held`. Nur aus `active` zulässig.
|
[**hold**](DeliveriesApi.md#hold) | **POST** /deliveries/{delivery_id}/hold | Setzt die Lieferung auf `held`. Nur aus `active` zulässig.
|
||||||
|
[**recordPayment**](DeliveriesApi.md#recordpayment) | **POST** /deliveries/{delivery_id}/payment | 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.
|
||||||
[**resume**](DeliveriesApi.md#resume) | **POST** /deliveries/{delivery_id}/resume | Setzt die Lieferung zurück auf `active`. Nur aus `held` zulässig.
|
[**resume**](DeliveriesApi.md#resume) | **POST** /deliveries/{delivery_id}/resume | Setzt die Lieferung zurück auf `active`. Nur aus `held` 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 (`boolValue`/`numericValue`) muss gesetzt sein; numerische Werte werden gegen min/max geprüft. Nur bei aktiver Lieferung.
|
[**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 (`boolValue`/`numericValue`) 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. `delivery_id` ist Teil des Pfads (REST-Konsistenz), die Notiz wird über `note_id` adressiert.
|
[**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. `delivery_id` ist Teil des Pfads (REST-Konsistenz), die Notiz wird über `note_id` 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)
|
[[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**
|
# **resume**
|
||||||
> DeliveryResponse resume(deliveryId)
|
> DeliveryResponse resume(deliveryId)
|
||||||
|
|
||||||
|
|||||||
27
packages/holzleitner_api/doc/DeliveryDetails.md
Normal file
27
packages/holzleitner_api/doc/DeliveryDetails.md
Normal 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<Article>**](Article.md) | Nur die von den Positionen referenzierten Artikel. |
|
||||||
|
**contactChannels** | [**BuiltList<ContactChannel>**](ContactChannel.md) | Die zu `contactSources` gehörenden Einzel-Kanäle. Join per `sourceId`. |
|
||||||
|
**contactSources** | [**BuiltList<ContactSource>**](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<CustomerContact>**](CustomerContact.md) | Ansprechpartner des Kunden. |
|
||||||
|
**delivery** | [**DeliveryWithItems**](DeliveryWithItems.md) | Die Lieferung selbst inkl. Positionen (mit Scan-Ständen) und `sortOrder`. |
|
||||||
|
**deliveryServices** | [**BuiltList<DeliveryServiceValue>**](DeliveryServiceValue.md) | Für diese Lieferung gesetzte Service-Werte. |
|
||||||
|
**notes** | [**BuiltList<DeliveryNote>**](DeliveryNote.md) | Notizen der Lieferung, aufsteigend nach `createdAt`. |
|
||||||
|
**payment** | [**DeliveryPayment**](DeliveryPayment.md) | Jüngste protokollierte Zahlungsabwicklung (`None` = keine). | [optional]
|
||||||
|
**services** | [**BuiltList<Service>**](Service.md) | Aktive Service-Definitionen (Stammdaten). |
|
||||||
|
**tour** | [**Tour**](Tour.md) | Die Tour, zu der die Lieferung gehört. |
|
||||||
|
**warehouses** | [**BuiltList<Warehouse>**](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)
|
||||||
|
|
||||||
|
|
||||||
22
packages/holzleitner_api/doc/DeliveryPayment.md
Normal file
22
packages/holzleitner_api/doc/DeliveryPayment.md
Normal 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)
|
||||||
|
|
||||||
|
|
||||||
15
packages/holzleitner_api/doc/DeliveryPaymentResponse.md
Normal file
15
packages/holzleitner_api/doc/DeliveryPaymentResponse.md
Normal 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)
|
||||||
|
|
||||||
|
|
||||||
23
packages/holzleitner_api/doc/PendingReviewResponse.md
Normal file
23
packages/holzleitner_api/doc/PendingReviewResponse.md
Normal 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<ReviewedItemResponse>**](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)
|
||||||
|
|
||||||
|
|
||||||
16
packages/holzleitner_api/doc/PositionsModifiedResponse.md
Normal file
16
packages/holzleitner_api/doc/PositionsModifiedResponse.md
Normal 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)
|
||||||
|
|
||||||
|
|
||||||
18
packages/holzleitner_api/doc/RecordDeliveryPaymentRequest.md
Normal file
18
packages/holzleitner_api/doc/RecordDeliveryPaymentRequest.md
Normal 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)
|
||||||
|
|
||||||
|
|
||||||
16
packages/holzleitner_api/doc/ResolveReviewRequest.md
Normal file
16
packages/holzleitner_api/doc/ResolveReviewRequest.md
Normal 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)
|
||||||
|
|
||||||
|
|
||||||
20
packages/holzleitner_api/doc/ReviewedItemResponse.md
Normal file
20
packages/holzleitner_api/doc/ReviewedItemResponse.md
Normal 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)
|
||||||
|
|
||||||
|
|
||||||
@ -9,6 +9,7 @@ import 'package:holzleitner_api/api.dart';
|
|||||||
Name | Type | Description | Notes
|
Name | Type | Description | Notes
|
||||||
------------ | ------------- | ------------- | -------------
|
------------ | ------------- | ------------- | -------------
|
||||||
**abteilung** | **String** | | [optional]
|
**abteilung** | **String** | | [optional]
|
||||||
|
**address** | [**Address**](Address.md) | Anschrift dieses Adress-Datensatzes, falls gepflegt. | [optional]
|
||||||
**anrede** | **String** | | [optional]
|
**anrede** | **String** | | [optional]
|
||||||
**channels** | [**BuiltList<SyncContactChannel>**](SyncContactChannel.md) | | [optional]
|
**channels** | [**BuiltList<SyncContactChannel>**](SyncContactChannel.md) | | [optional]
|
||||||
**funktion** | **String** | | [optional]
|
**funktion** | **String** | | [optional]
|
||||||
|
|||||||
@ -17,6 +17,7 @@ Name | Type | Description | Notes
|
|||||||
**deliveries** | [**BuiltList<DeliveryWithItems>**](DeliveryWithItems.md) | |
|
**deliveries** | [**BuiltList<DeliveryWithItems>**](DeliveryWithItems.md) | |
|
||||||
**deliveryServices** | [**BuiltList<DeliveryServiceValue>**](DeliveryServiceValue.md) | Pro-Lieferung gesetzte Service-Werte. Join per `delivery_id` + `service_id`. |
|
**deliveryServices** | [**BuiltList<DeliveryServiceValue>**](DeliveryServiceValue.md) | Pro-Lieferung gesetzte Service-Werte. Join per `delivery_id` + `service_id`. |
|
||||||
**notes** | [**BuiltList<DeliveryNote>**](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`. |
|
**notes** | [**BuiltList<DeliveryNote>**](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<DeliveryPayment>**](DeliveryPayment.md) | Jüngste protokollierte Zahlungsabwicklung pro Lieferung (nur Lieferungen mit mindestens einem Eintrag). Join per `delivery_id`. |
|
||||||
**services** | [**BuiltList<Service>**](Service.md) | Aktive Service-Definitionen (Stammdaten) — die App rendert daraus Phase 4. Bewusst hier mitgeliefert, damit die Detailseite alles aus dem Tour-Aggregat hat. |
|
**services** | [**BuiltList<Service>**](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) | |
|
**tour** | [**Tour**](Tour.md) | |
|
||||||
**warehouses** | [**BuiltList<Warehouse>**](Warehouse.md) | |
|
**warehouses** | [**BuiltList<Warehouse>**](Warehouse.md) | |
|
||||||
|
|||||||
@ -10,7 +10,7 @@ All URIs are relative to *http://localhost*
|
|||||||
Method | HTTP request | Description
|
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.
|
[**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 `?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).
|
||||||
[**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 `400 validation` abgelehnt.
|
[**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 `400 validation` 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)
|
[[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**
|
# **listMyTours**
|
||||||
> TourSummaryList listMyToursToday()
|
> 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
|
### Example
|
||||||
```dart
|
```dart
|
||||||
import 'package:holzleitner_api/api.dart';
|
import 'package:holzleitner_api/api.dart';
|
||||||
|
|
||||||
final api = HolzleitnerApi().getToursApi();
|
final api = HolzleitnerApi().getToursApi();
|
||||||
|
final String date = date_example; // String | Ziel-Tourdatum YYYY-MM-DD (Default: heute)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = api.listMyToursToday();
|
final response = api.listMyTours(date);
|
||||||
print(response);
|
print(response);
|
||||||
} catch on DioException (e) {
|
} catch on DioException (e) {
|
||||||
print('Exception when calling ToursApi->listMyToursToday: $e\n');
|
print('Exception when calling ToursApi->listMyTours: $e\n');
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Parameters
|
### Parameters
|
||||||
This endpoint does not need any parameter.
|
|
||||||
|
Name | Type | Description | Notes
|
||||||
|
------------- | ------------- | ------------- | -------------
|
||||||
|
**date** | **String**| Ziel-Tourdatum YYYY-MM-DD (Default: heute) | [optional]
|
||||||
|
|
||||||
### Return type
|
### Return type
|
||||||
|
|
||||||
|
|||||||
@ -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/car_response.dart';
|
||||||
export 'package:holzleitner_api/src/model/cars_list.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/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_channel.dart';
|
||||||
export 'package:holzleitner_api/src/model/contact_kind.dart';
|
export 'package:holzleitner_api/src/model/contact_kind.dart';
|
||||||
export 'package:holzleitner_api/src/model/contact_role.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.dart';
|
||||||
export 'package:holzleitner_api/src/model/delivery_credit_event_request.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_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_item.dart';
|
||||||
export 'package:holzleitner_api/src/model/delivery_note.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_note_response.dart';
|
||||||
export 'package:holzleitner_api/src/model/delivery_order_entry.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_response.dart';
|
||||||
export 'package:holzleitner_api/src/model/delivery_service_response.dart';
|
export 'package:holzleitner_api/src/model/delivery_service_response.dart';
|
||||||
export 'package:holzleitner_api/src/model/delivery_service_value.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.dart';
|
||||||
export 'package:holzleitner_api/src/model/payment_method_response.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/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_event.dart';
|
||||||
export 'package:holzleitner_api/src/model/scan_result.dart';
|
export 'package:holzleitner_api/src/model/scan_result.dart';
|
||||||
export 'package:holzleitner_api/src/model/scan_result_status.dart';
|
export 'package:holzleitner_api/src/model/scan_result_status.dart';
|
||||||
|
|||||||
@ -8,11 +8,17 @@ import 'package:built_value/json_object.dart';
|
|||||||
import 'package:built_value/serializer.dart';
|
import 'package:built_value/serializer.dart';
|
||||||
import 'package:dio/dio.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/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/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/import_summary.dart';
|
||||||
import 'package:holzleitner_api/src/model/mark_mail_sent_request.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/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 {
|
class AdminApi {
|
||||||
|
|
||||||
@ -22,6 +28,181 @@ class AdminApi {
|
|||||||
|
|
||||||
const AdminApi(this._dio, this._serializers);
|
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. `404`, 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 (`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.
|
||||||
|
///
|
||||||
|
/// 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** (`mail_sent_at IS NULL`). „Ausgeliefert\" = es existiert ein Abschluss. Mit `day` (DD-MM-YYYY) nur Abschlüsse dieses Berliner Kalendertages; **ohne `day` alle offenen** (über alle Tage) — so bleiben Belege über Mitternacht nicht hängen.
|
/// Liefert die Belegnummern ausgelieferter (abgeschlossener) Lieferungen, **deren Liefermail noch nicht versendet wurde** (`mail_sent_at IS NULL`). „Ausgeliefert\" = es existiert ein Abschluss. Mit `day` (DD-MM-YYYY) nur Abschlüsse dieses Berliner Kalendertages; **ohne `day` 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** (`mail_sent_at = now()`, nur wo noch offen). Vom Mailclient aufzurufen, NACHDEM ERPframe die Mails erfolgreich verschickt hat — danach erscheinen die Belege nicht mehr in `GET /admin/delivered-belegnummern`.
|
/// Markiert die Liefermails der angegebenen Belegnummern als **versendet** (`mail_sent_at = now()`, nur wo noch offen). Vom Mailclient aufzurufen, NACHDEM ERPframe die Mails erfolgreich verschickt hat — danach erscheinen die Belege nicht mehr in `GET /admin/delivered-belegnummern`.
|
||||||
///
|
///
|
||||||
///
|
///
|
||||||
@ -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. `404`, wenn die Belegnummer unbekannt ist. Gibt es mehrere Lieferungen mit derselben Belegnummer, ist das Flag `true`, 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).
|
/// 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;
|
return _response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bestätigt die Prüfung einer geänderten Lieferung (Vier-Augen) — die Lieferung verschwindet danach aus `GET /admin/reviews` (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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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_event_request.dart';
|
||||||
import 'package:holzleitner_api/src/model/delivery_credit_response.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_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_response.dart';
|
||||||
import 'package:holzleitner_api/src/model/delivery_service_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/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/set_delivery_service_request.dart';
|
||||||
import 'package:holzleitner_api/src/model/update_delivery_note_request.dart';
|
import 'package:holzleitner_api/src/model/update_delivery_note_request.dart';
|
||||||
|
|
||||||
@ -736,6 +738,109 @@ class DeliveriesApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
///
|
||||||
|
/// 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 `active`. Nur aus `held` zulässig.
|
/// Setzt die Lieferung zurück auf `active`. Nur aus `held` zulässig.
|
||||||
///
|
///
|
||||||
///
|
///
|
||||||
|
|||||||
@ -103,11 +103,11 @@ class ToursApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Listet die Touren des angemeldeten Fahrers (Filter aus dem JWT) für ein
|
/// 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).
|
||||||
/// Ziel-Datum. Ohne [date] gilt serverseitig „heute".
|
///
|
||||||
///
|
///
|
||||||
/// Parameters:
|
/// 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
|
/// * [cancelToken] - A [CancelToken] that can be used to cancel the operation
|
||||||
/// * [headers] - Can be used to add additional headers to the request
|
/// * [headers] - Can be used to add additional headers to the request
|
||||||
/// * [extras] - Can be used to add flags to the request
|
/// * [extras] - Can be used to add flags to the request
|
||||||
@ -146,7 +146,7 @@ class ToursApi {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final _queryParameters = <String, dynamic>{
|
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>(
|
final _response = await _dio.request<Object>(
|
||||||
|
|||||||
@ -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
|
/// 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:
|
/// 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]
|
/// * [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]
|
/// * [houseNumber]
|
||||||
/// * [postalCode]
|
/// * [postalCode]
|
||||||
/// * [street]
|
/// * [street]
|
||||||
@BuiltValue()
|
@BuiltValue()
|
||||||
abstract class Address implements Built<Address, AddressBuilder> {
|
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')
|
@BuiltValueField(wireName: r'city')
|
||||||
String get 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')
|
@BuiltValueField(wireName: r'country')
|
||||||
String get country;
|
String get country;
|
||||||
|
|
||||||
@ -56,6 +62,13 @@ class _$AddressSerializer implements PrimitiveSerializer<Address> {
|
|||||||
Address object, {
|
Address object, {
|
||||||
FullType specifiedType = FullType.unspecified,
|
FullType specifiedType = FullType.unspecified,
|
||||||
}) sync* {
|
}) sync* {
|
||||||
|
if (object.addition != null) {
|
||||||
|
yield r'addition';
|
||||||
|
yield serializers.serialize(
|
||||||
|
object.addition,
|
||||||
|
specifiedType: const FullType.nullable(String),
|
||||||
|
);
|
||||||
|
}
|
||||||
yield r'city';
|
yield r'city';
|
||||||
yield serializers.serialize(
|
yield serializers.serialize(
|
||||||
object.city,
|
object.city,
|
||||||
@ -104,6 +117,14 @@ class _$AddressSerializer implements PrimitiveSerializer<Address> {
|
|||||||
final key = serializedList[i] as String;
|
final key = serializedList[i] as String;
|
||||||
final value = serializedList[i + 1];
|
final value = serializedList[i + 1];
|
||||||
switch (key) {
|
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':
|
case r'city':
|
||||||
final valueDes = serializers.deserialize(
|
final valueDes = serializers.deserialize(
|
||||||
value,
|
value,
|
||||||
|
|||||||
@ -7,6 +7,8 @@ part of 'address.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
class _$Address extends Address {
|
class _$Address extends Address {
|
||||||
|
@override
|
||||||
|
final String? addition;
|
||||||
@override
|
@override
|
||||||
final String city;
|
final String city;
|
||||||
@override
|
@override
|
||||||
@ -22,7 +24,8 @@ class _$Address extends Address {
|
|||||||
(AddressBuilder()..update(updates))._build();
|
(AddressBuilder()..update(updates))._build();
|
||||||
|
|
||||||
_$Address._(
|
_$Address._(
|
||||||
{required this.city,
|
{this.addition,
|
||||||
|
required this.city,
|
||||||
required this.country,
|
required this.country,
|
||||||
required this.houseNumber,
|
required this.houseNumber,
|
||||||
required this.postalCode,
|
required this.postalCode,
|
||||||
@ -39,6 +42,7 @@ class _$Address extends Address {
|
|||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (identical(other, this)) return true;
|
if (identical(other, this)) return true;
|
||||||
return other is Address &&
|
return other is Address &&
|
||||||
|
addition == other.addition &&
|
||||||
city == other.city &&
|
city == other.city &&
|
||||||
country == other.country &&
|
country == other.country &&
|
||||||
houseNumber == other.houseNumber &&
|
houseNumber == other.houseNumber &&
|
||||||
@ -49,6 +53,7 @@ class _$Address extends Address {
|
|||||||
@override
|
@override
|
||||||
int get hashCode {
|
int get hashCode {
|
||||||
var _$hash = 0;
|
var _$hash = 0;
|
||||||
|
_$hash = $jc(_$hash, addition.hashCode);
|
||||||
_$hash = $jc(_$hash, city.hashCode);
|
_$hash = $jc(_$hash, city.hashCode);
|
||||||
_$hash = $jc(_$hash, country.hashCode);
|
_$hash = $jc(_$hash, country.hashCode);
|
||||||
_$hash = $jc(_$hash, houseNumber.hashCode);
|
_$hash = $jc(_$hash, houseNumber.hashCode);
|
||||||
@ -61,6 +66,7 @@ class _$Address extends Address {
|
|||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return (newBuiltValueToStringHelper(r'Address')
|
return (newBuiltValueToStringHelper(r'Address')
|
||||||
|
..add('addition', addition)
|
||||||
..add('city', city)
|
..add('city', city)
|
||||||
..add('country', country)
|
..add('country', country)
|
||||||
..add('houseNumber', houseNumber)
|
..add('houseNumber', houseNumber)
|
||||||
@ -73,6 +79,10 @@ class _$Address extends Address {
|
|||||||
class AddressBuilder implements Builder<Address, AddressBuilder> {
|
class AddressBuilder implements Builder<Address, AddressBuilder> {
|
||||||
_$Address? _$v;
|
_$Address? _$v;
|
||||||
|
|
||||||
|
String? _addition;
|
||||||
|
String? get addition => _$this._addition;
|
||||||
|
set addition(String? addition) => _$this._addition = addition;
|
||||||
|
|
||||||
String? _city;
|
String? _city;
|
||||||
String? get city => _$this._city;
|
String? get city => _$this._city;
|
||||||
set city(String? city) => _$this._city = city;
|
set city(String? city) => _$this._city = city;
|
||||||
@ -100,6 +110,7 @@ class AddressBuilder implements Builder<Address, AddressBuilder> {
|
|||||||
AddressBuilder get _$this {
|
AddressBuilder get _$this {
|
||||||
final $v = _$v;
|
final $v = _$v;
|
||||||
if ($v != null) {
|
if ($v != null) {
|
||||||
|
_addition = $v.addition;
|
||||||
_city = $v.city;
|
_city = $v.city;
|
||||||
_country = $v.country;
|
_country = $v.country;
|
||||||
_houseNumber = $v.houseNumber;
|
_houseNumber = $v.houseNumber;
|
||||||
@ -126,6 +137,7 @@ class AddressBuilder implements Builder<Address, AddressBuilder> {
|
|||||||
_$Address _build() {
|
_$Address _build() {
|
||||||
final _$result = _$v ??
|
final _$result = _$v ??
|
||||||
_$Address._(
|
_$Address._(
|
||||||
|
addition: addition,
|
||||||
city: BuiltValueNullFieldError.checkNotNull(city, r'Address', 'city'),
|
city: BuiltValueNullFieldError.checkNotNull(city, r'Address', 'city'),
|
||||||
country: BuiltValueNullFieldError.checkNotNull(
|
country: BuiltValueNullFieldError.checkNotNull(
|
||||||
country, r'Address', 'country'),
|
country, r'Address', 'country'),
|
||||||
|
|||||||
@ -14,6 +14,7 @@ part 'complete_delivery_acknowledgements.g.dart';
|
|||||||
/// Properties:
|
/// Properties:
|
||||||
/// * [acknowledgedNoteIds] - Notiz-IDs, die zum Abschlusszeitpunkt sichtbar waren und mit-bestätigt wurden (Audit-Robustheit).
|
/// * [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.
|
/// * [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).
|
/// * [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).
|
/// * [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).
|
/// * [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')
|
@BuiltValueField(wireName: r'authorCarId')
|
||||||
String? get 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).
|
/// „Anmerkungen zur Lieferung zur Kenntnis genommen.\" — Pflicht nur, wenn Notizen existieren (das prüft der Server).
|
||||||
@BuiltValueField(wireName: r'notesAcknowledged')
|
@BuiltValueField(wireName: r'notesAcknowledged')
|
||||||
bool? get notesAcknowledged;
|
bool? get notesAcknowledged;
|
||||||
@ -81,6 +86,13 @@ class _$CompleteDeliveryAcknowledgementsSerializer implements PrimitiveSerialize
|
|||||||
specifiedType: const FullType.nullable(String),
|
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) {
|
if (object.notesAcknowledged != null) {
|
||||||
yield r'notesAcknowledged';
|
yield r'notesAcknowledged';
|
||||||
yield serializers.serialize(
|
yield serializers.serialize(
|
||||||
@ -145,6 +157,14 @@ class _$CompleteDeliveryAcknowledgementsSerializer implements PrimitiveSerialize
|
|||||||
if (valueDes == null) continue;
|
if (valueDes == null) continue;
|
||||||
result.authorCarId = valueDes;
|
result.authorCarId = valueDes;
|
||||||
break;
|
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':
|
case r'notesAcknowledged':
|
||||||
final valueDes = serializers.deserialize(
|
final valueDes = serializers.deserialize(
|
||||||
value,
|
value,
|
||||||
|
|||||||
@ -13,6 +13,8 @@ class _$CompleteDeliveryAcknowledgements
|
|||||||
@override
|
@override
|
||||||
final String? authorCarId;
|
final String? authorCarId;
|
||||||
@override
|
@override
|
||||||
|
final String? internalNote;
|
||||||
|
@override
|
||||||
final bool? notesAcknowledged;
|
final bool? notesAcknowledged;
|
||||||
@override
|
@override
|
||||||
final bool? paymentCollected;
|
final bool? paymentCollected;
|
||||||
@ -28,6 +30,7 @@ class _$CompleteDeliveryAcknowledgements
|
|||||||
_$CompleteDeliveryAcknowledgements._(
|
_$CompleteDeliveryAcknowledgements._(
|
||||||
{this.acknowledgedNoteIds,
|
{this.acknowledgedNoteIds,
|
||||||
this.authorCarId,
|
this.authorCarId,
|
||||||
|
this.internalNote,
|
||||||
this.notesAcknowledged,
|
this.notesAcknowledged,
|
||||||
this.paymentCollected,
|
this.paymentCollected,
|
||||||
this.paymentMethodId,
|
this.paymentMethodId,
|
||||||
@ -48,6 +51,7 @@ class _$CompleteDeliveryAcknowledgements
|
|||||||
return other is CompleteDeliveryAcknowledgements &&
|
return other is CompleteDeliveryAcknowledgements &&
|
||||||
acknowledgedNoteIds == other.acknowledgedNoteIds &&
|
acknowledgedNoteIds == other.acknowledgedNoteIds &&
|
||||||
authorCarId == other.authorCarId &&
|
authorCarId == other.authorCarId &&
|
||||||
|
internalNote == other.internalNote &&
|
||||||
notesAcknowledged == other.notesAcknowledged &&
|
notesAcknowledged == other.notesAcknowledged &&
|
||||||
paymentCollected == other.paymentCollected &&
|
paymentCollected == other.paymentCollected &&
|
||||||
paymentMethodId == other.paymentMethodId &&
|
paymentMethodId == other.paymentMethodId &&
|
||||||
@ -59,6 +63,7 @@ class _$CompleteDeliveryAcknowledgements
|
|||||||
var _$hash = 0;
|
var _$hash = 0;
|
||||||
_$hash = $jc(_$hash, acknowledgedNoteIds.hashCode);
|
_$hash = $jc(_$hash, acknowledgedNoteIds.hashCode);
|
||||||
_$hash = $jc(_$hash, authorCarId.hashCode);
|
_$hash = $jc(_$hash, authorCarId.hashCode);
|
||||||
|
_$hash = $jc(_$hash, internalNote.hashCode);
|
||||||
_$hash = $jc(_$hash, notesAcknowledged.hashCode);
|
_$hash = $jc(_$hash, notesAcknowledged.hashCode);
|
||||||
_$hash = $jc(_$hash, paymentCollected.hashCode);
|
_$hash = $jc(_$hash, paymentCollected.hashCode);
|
||||||
_$hash = $jc(_$hash, paymentMethodId.hashCode);
|
_$hash = $jc(_$hash, paymentMethodId.hashCode);
|
||||||
@ -72,6 +77,7 @@ class _$CompleteDeliveryAcknowledgements
|
|||||||
return (newBuiltValueToStringHelper(r'CompleteDeliveryAcknowledgements')
|
return (newBuiltValueToStringHelper(r'CompleteDeliveryAcknowledgements')
|
||||||
..add('acknowledgedNoteIds', acknowledgedNoteIds)
|
..add('acknowledgedNoteIds', acknowledgedNoteIds)
|
||||||
..add('authorCarId', authorCarId)
|
..add('authorCarId', authorCarId)
|
||||||
|
..add('internalNote', internalNote)
|
||||||
..add('notesAcknowledged', notesAcknowledged)
|
..add('notesAcknowledged', notesAcknowledged)
|
||||||
..add('paymentCollected', paymentCollected)
|
..add('paymentCollected', paymentCollected)
|
||||||
..add('paymentMethodId', paymentMethodId)
|
..add('paymentMethodId', paymentMethodId)
|
||||||
@ -96,6 +102,10 @@ class CompleteDeliveryAcknowledgementsBuilder
|
|||||||
String? get authorCarId => _$this._authorCarId;
|
String? get authorCarId => _$this._authorCarId;
|
||||||
set authorCarId(String? authorCarId) => _$this._authorCarId = 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? _notesAcknowledged;
|
||||||
bool? get notesAcknowledged => _$this._notesAcknowledged;
|
bool? get notesAcknowledged => _$this._notesAcknowledged;
|
||||||
set notesAcknowledged(bool? notesAcknowledged) =>
|
set notesAcknowledged(bool? notesAcknowledged) =>
|
||||||
@ -125,6 +135,7 @@ class CompleteDeliveryAcknowledgementsBuilder
|
|||||||
if ($v != null) {
|
if ($v != null) {
|
||||||
_acknowledgedNoteIds = $v.acknowledgedNoteIds?.toBuilder();
|
_acknowledgedNoteIds = $v.acknowledgedNoteIds?.toBuilder();
|
||||||
_authorCarId = $v.authorCarId;
|
_authorCarId = $v.authorCarId;
|
||||||
|
_internalNote = $v.internalNote;
|
||||||
_notesAcknowledged = $v.notesAcknowledged;
|
_notesAcknowledged = $v.notesAcknowledged;
|
||||||
_paymentCollected = $v.paymentCollected;
|
_paymentCollected = $v.paymentCollected;
|
||||||
_paymentMethodId = $v.paymentMethodId;
|
_paymentMethodId = $v.paymentMethodId;
|
||||||
@ -154,6 +165,7 @@ class CompleteDeliveryAcknowledgementsBuilder
|
|||||||
_$CompleteDeliveryAcknowledgements._(
|
_$CompleteDeliveryAcknowledgements._(
|
||||||
acknowledgedNoteIds: _acknowledgedNoteIds?.build(),
|
acknowledgedNoteIds: _acknowledgedNoteIds?.build(),
|
||||||
authorCarId: authorCarId,
|
authorCarId: authorCarId,
|
||||||
|
internalNote: internalNote,
|
||||||
notesAcknowledged: notesAcknowledged,
|
notesAcknowledged: notesAcknowledged,
|
||||||
paymentCollected: paymentCollected,
|
paymentCollected: paymentCollected,
|
||||||
paymentMethodId: paymentMethodId,
|
paymentMethodId: paymentMethodId,
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user