Final commit.
This commit is contained in:
55
lib/domain/entity/address.dart
Normal file
55
lib/domain/entity/address.dart
Normal file
@ -0,0 +1,55 @@
|
||||
/// Postanschrift — Value-Object, identitätslos.
|
||||
///
|
||||
/// Tritt im Domain an drei Stellen auf: am `Customer` (Stamm-Adresse) und
|
||||
/// als `deliveryAddressSnapshot` auf der `Delivery` (eingefrorene Kopie der
|
||||
/// Adresse zum Zeitpunkt der Belegerzeugung, damit nachträgliche Änderungen
|
||||
/// am Stammdatensatz die ausgelieferte Tour nicht „verschieben"). Spiegelt
|
||||
/// das Backend-DTO `Address` 1:1.
|
||||
class Address {
|
||||
const Address({
|
||||
required this.street,
|
||||
required this.houseNumber,
|
||||
required this.postalCode,
|
||||
required this.city,
|
||||
required this.country,
|
||||
});
|
||||
|
||||
final String street;
|
||||
final String houseNumber;
|
||||
final String postalCode;
|
||||
final String city;
|
||||
final String country;
|
||||
|
||||
/// Einzeilige Darstellung für Listen/Header.
|
||||
String get oneLine =>
|
||||
'$street $houseNumber, $postalCode $city';
|
||||
|
||||
Address copyWith({
|
||||
String? street,
|
||||
String? houseNumber,
|
||||
String? postalCode,
|
||||
String? city,
|
||||
String? country,
|
||||
}) {
|
||||
return Address(
|
||||
street: street ?? this.street,
|
||||
houseNumber: houseNumber ?? this.houseNumber,
|
||||
postalCode: postalCode ?? this.postalCode,
|
||||
city: city ?? this.city,
|
||||
country: country ?? this.country,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is Address &&
|
||||
other.street == street &&
|
||||
other.houseNumber == houseNumber &&
|
||||
other.postalCode == postalCode &&
|
||||
other.city == city &&
|
||||
other.country == country;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(street, houseNumber, postalCode, city, country);
|
||||
}
|
||||
45
lib/domain/entity/article.dart
Normal file
45
lib/domain/entity/article.dart
Normal file
@ -0,0 +1,45 @@
|
||||
/// Stammdatensatz für einen Artikel (Ware).
|
||||
///
|
||||
/// Der Domain-Artikel kennt — anders als das alte ERPframe-Modell — keine
|
||||
/// Eltern-Kind-Beziehungen mehr. Stücklisten (BOM/Komponenten) werden im
|
||||
/// neuen Backend als gleichrangige `DeliveryItem`s mit gesetztem
|
||||
/// `komponentenArtikelNr` modelliert; der Treiber scannt einfach jedes Item
|
||||
/// separat. Hier deshalb absichtlich kein `components`/`parent`-Feld.
|
||||
class Article {
|
||||
const Article({
|
||||
required this.id,
|
||||
required this.articleNumber,
|
||||
required this.name,
|
||||
required this.scannable,
|
||||
this.defaultWarehouseId,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String articleNumber;
|
||||
final String name;
|
||||
|
||||
/// Nicht-scanbar = wird nicht über den Scanner durchgereicht (z. B.
|
||||
/// Dienstleistung, Versandkosten). In der Loading-Phase ausgeblendet.
|
||||
final bool scannable;
|
||||
|
||||
/// Lager-Default für diesen Artikel; das tatsächlich relevante Lager pro
|
||||
/// Lieferung steht aber am `DeliveryItem.warehouseId`. Wird nur als
|
||||
/// UX-Hinweis verwendet.
|
||||
final String? defaultWarehouseId;
|
||||
|
||||
Article copyWith({
|
||||
String? id,
|
||||
String? articleNumber,
|
||||
String? name,
|
||||
bool? scannable,
|
||||
String? defaultWarehouseId,
|
||||
}) {
|
||||
return Article(
|
||||
id: id ?? this.id,
|
||||
articleNumber: articleNumber ?? this.articleNumber,
|
||||
name: name ?? this.name,
|
||||
scannable: scannable ?? this.scannable,
|
||||
defaultWarehouseId: defaultWarehouseId ?? this.defaultWarehouseId,
|
||||
);
|
||||
}
|
||||
}
|
||||
226
lib/domain/entity/contact_source.dart
Normal file
226
lib/domain/entity/contact_source.dart
Normal file
@ -0,0 +1,226 @@
|
||||
/// Adress-Rolle eines Beleg-Kontakts. Spiegelt die fünf Adress-FKs am
|
||||
/// ERP-`Belegkopf` (bzw. den Umweg über `Kunden.AdressId`). Die App nutzt
|
||||
/// das primär als Gruppierungs-Label in der Detail-Ansicht.
|
||||
enum ContactRole {
|
||||
/// `Belegkopf.AdressId` — die „eigentliche" Belegadresse.
|
||||
header,
|
||||
|
||||
/// `Belegkopf.LieferAdressId` — kann von der Belegadresse abweichen.
|
||||
delivery,
|
||||
|
||||
/// `Belegkopf.RechnungsAdressId`.
|
||||
billing,
|
||||
|
||||
/// `Belegkopf.AnsprechpartnerId` — verlinkt eine Person, nicht eine Firma.
|
||||
contactPerson,
|
||||
|
||||
/// `Kunden.AdressId` (über `Belegkopf.KundenId`). Die Stammadresse des
|
||||
/// Kunden — dient als Fallback, wenn die belegspezifischen Adressen leer
|
||||
/// sind.
|
||||
customerMaster;
|
||||
|
||||
/// Wire-Repräsentation aus dem Backend (serde `snake_case`).
|
||||
static ContactRole fromWire(String value) {
|
||||
switch (value) {
|
||||
case 'header':
|
||||
return ContactRole.header;
|
||||
case 'delivery':
|
||||
return ContactRole.delivery;
|
||||
case 'billing':
|
||||
return ContactRole.billing;
|
||||
case 'contact_person':
|
||||
return ContactRole.contactPerson;
|
||||
case 'customer_master':
|
||||
return ContactRole.customerMaster;
|
||||
default:
|
||||
throw StateError('Unbekannte ContactRole vom Backend: $value');
|
||||
}
|
||||
}
|
||||
|
||||
/// Deutscher Label-Text für die UI.
|
||||
String get label {
|
||||
switch (this) {
|
||||
case ContactRole.header:
|
||||
return 'Belegadresse';
|
||||
case ContactRole.delivery:
|
||||
return 'Lieferadresse';
|
||||
case ContactRole.billing:
|
||||
return 'Rechnungsadresse';
|
||||
case ContactRole.contactPerson:
|
||||
return 'Ansprechpartner';
|
||||
case ContactRole.customerMaster:
|
||||
return 'Kundenstamm';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Art eines Kommunikationskanals. `fax` wird vom Backend bewusst nicht
|
||||
/// mitgeführt — die App braucht es nicht.
|
||||
enum ContactKind {
|
||||
phone,
|
||||
mobile,
|
||||
email,
|
||||
web;
|
||||
|
||||
static ContactKind fromWire(String value) {
|
||||
switch (value) {
|
||||
case 'phone':
|
||||
return ContactKind.phone;
|
||||
case 'mobile':
|
||||
return ContactKind.mobile;
|
||||
case 'email':
|
||||
return ContactKind.email;
|
||||
case 'web':
|
||||
return ContactKind.web;
|
||||
default:
|
||||
throw StateError('Unbekannter ContactKind vom Backend: $value');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Adress-Quelle, die am Beleg hängt — z. B. Lieferadresse oder
|
||||
/// Ansprechpartner. Der Namensblock kommt direkt aus ERP-`Adressen`
|
||||
/// (`Anrede`/`Titel`/`Name1..3`/`Abteilung`/`Funktion`); die eigentlichen
|
||||
/// Telefonnummern, E-Mails etc. liegen verteilt in zugehörigen
|
||||
/// [ContactChannel]s und werden in [TourDetails.channelsOf] zusammengeführt.
|
||||
class ContactSource {
|
||||
const ContactSource({
|
||||
required this.id,
|
||||
required this.deliveryId,
|
||||
required this.role,
|
||||
this.anrede,
|
||||
this.titel,
|
||||
this.name1,
|
||||
this.name2,
|
||||
this.name3,
|
||||
this.abteilung,
|
||||
this.funktion,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String deliveryId;
|
||||
final ContactRole role;
|
||||
|
||||
final String? anrede;
|
||||
final String? titel;
|
||||
final String? name1;
|
||||
final String? name2;
|
||||
final String? name3;
|
||||
final String? abteilung;
|
||||
final String? funktion;
|
||||
|
||||
/// Zusammengesetzte Anzeige des Namens — Anrede + Titel + Name1..3 in
|
||||
/// dieser Reihenfolge, leere Felder werden übersprungen. Gibt `null`
|
||||
/// zurück, wenn die Quelle gar keinen Namen trägt (kann vorkommen, wenn
|
||||
/// nur Telefonnummern hinterlegt sind).
|
||||
String? get displayName {
|
||||
final parts = <String>[
|
||||
if (anrede != null && anrede!.isNotEmpty) anrede!,
|
||||
if (titel != null && titel!.isNotEmpty) titel!,
|
||||
if (name1 != null && name1!.isNotEmpty) name1!,
|
||||
if (name2 != null && name2!.isNotEmpty) name2!,
|
||||
if (name3 != null && name3!.isNotEmpty) name3!,
|
||||
];
|
||||
if (parts.isEmpty) return null;
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/// Funktionale Zusatzinfo (z. B. „Buchhaltung · Leitung"). Leere
|
||||
/// Komponenten werden ausgeblendet.
|
||||
String? get subtitle {
|
||||
final parts = <String>[
|
||||
if (abteilung != null && abteilung!.isNotEmpty) abteilung!,
|
||||
if (funktion != null && funktion!.isNotEmpty) funktion!,
|
||||
];
|
||||
if (parts.isEmpty) return null;
|
||||
return parts.join(' · ');
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein einzelner Kommunikationskanal (Telefon, Mobil, E-Mail, Web). Mehrere
|
||||
/// pro [ContactSource] möglich; die [position] (1-basiert) erhält die
|
||||
/// ERP-Reihenfolge — Position 1 ist der primäre Kanal, Position 2 das
|
||||
/// erste Zusatzfeld usw.
|
||||
class ContactChannel {
|
||||
const ContactChannel({
|
||||
required this.id,
|
||||
required this.sourceId,
|
||||
required this.kind,
|
||||
required this.position,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String sourceId;
|
||||
final ContactKind kind;
|
||||
final int position;
|
||||
final String value;
|
||||
}
|
||||
|
||||
/// Zusammengeführte Sicht auf 1..n [ContactSource]s, die fachlich denselben
|
||||
/// Kontakt darstellen — gleicher Namensblock UND gleiche Channel-Liste
|
||||
/// (also exakt dieselbe Adresse im ERP, nur über verschiedene FKs am
|
||||
/// `Belegkopf` referenziert: typischerweise `AdressId` und `Kunden.AdressId`,
|
||||
/// die in den allermeisten Belegen identisch sind).
|
||||
///
|
||||
/// Die App rendert pro Lieferung eine Karte je Eintrag; `roles` listet
|
||||
/// alle Rollen auf, die zu diesem Eintrag beitragen (z. B. „Belegadresse ·
|
||||
/// Kundenstamm"). Die Channels werden 1:1 von der ersten Quelle übernommen
|
||||
/// — alle Quellen in einer Gruppe haben dieselben.
|
||||
class MergedContactSource {
|
||||
const MergedContactSource({
|
||||
required this.roles,
|
||||
required this.anrede,
|
||||
required this.titel,
|
||||
required this.name1,
|
||||
required this.name2,
|
||||
required this.name3,
|
||||
required this.abteilung,
|
||||
required this.funktion,
|
||||
required this.channels,
|
||||
});
|
||||
|
||||
/// Alle Rollen, die diesen zusammengeführten Kontakt liefern.
|
||||
/// Reihenfolge wie in der enum-Definition: header → delivery → billing
|
||||
/// → contactPerson → customerMaster, damit das Label stabil bleibt.
|
||||
final List<ContactRole> roles;
|
||||
|
||||
final String? anrede;
|
||||
final String? titel;
|
||||
final String? name1;
|
||||
final String? name2;
|
||||
final String? name3;
|
||||
final String? abteilung;
|
||||
final String? funktion;
|
||||
|
||||
/// Channels in der gleichen Reihenfolge, wie das Backend sie pro Quelle
|
||||
/// liefert (kind + ERP-Position).
|
||||
final List<ContactChannel> channels;
|
||||
|
||||
/// Zusammengesetzter Anzeigename — identisch zu [ContactSource.displayName].
|
||||
String? get displayName {
|
||||
final parts = <String>[
|
||||
if (anrede != null && anrede!.isNotEmpty) anrede!,
|
||||
if (titel != null && titel!.isNotEmpty) titel!,
|
||||
if (name1 != null && name1!.isNotEmpty) name1!,
|
||||
if (name2 != null && name2!.isNotEmpty) name2!,
|
||||
if (name3 != null && name3!.isNotEmpty) name3!,
|
||||
];
|
||||
if (parts.isEmpty) return null;
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
String? get subtitle {
|
||||
final parts = <String>[
|
||||
if (abteilung != null && abteilung!.isNotEmpty) abteilung!,
|
||||
if (funktion != null && funktion!.isNotEmpty) funktion!,
|
||||
];
|
||||
if (parts.isEmpty) return null;
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/// Header-Label für die UI — alle Rollen mit `·` getrennt, in
|
||||
/// Enum-Reihenfolge.
|
||||
String get rolesLabel => roles.map((r) => r.label).join(' · ');
|
||||
}
|
||||
|
||||
53
lib/domain/entity/customer.dart
Normal file
53
lib/domain/entity/customer.dart
Normal file
@ -0,0 +1,53 @@
|
||||
import 'address.dart';
|
||||
|
||||
/// Kunden-Stammdatensatz. Ein Kunde kann mehrere `CustomerContact`s haben
|
||||
/// (Ehepartner, Hausverwalter, …); diese werden separat in der
|
||||
/// `TourDetails.contacts`-Map geführt.
|
||||
class Customer {
|
||||
const Customer({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.erpCustomerId,
|
||||
required this.address,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
/// ERP-Kundennummer (Legacy). Wird in der App nur informativ in der
|
||||
/// Detail-Ansicht angezeigt.
|
||||
final int erpCustomerId;
|
||||
final Address address;
|
||||
|
||||
Customer copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
int? erpCustomerId,
|
||||
Address? address,
|
||||
}) {
|
||||
return Customer(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
erpCustomerId: erpCustomerId ?? this.erpCustomerId,
|
||||
address: address ?? this.address,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ansprechpartner zu einem Kunden. Optional, daher als eigene Liste in
|
||||
/// `TourDetails` — eine Lieferung referenziert n Kontakte per Id.
|
||||
class CustomerContact {
|
||||
const CustomerContact({
|
||||
required this.id,
|
||||
required this.customerId,
|
||||
required this.name,
|
||||
this.phone,
|
||||
this.email,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String customerId;
|
||||
final String name;
|
||||
final String? phone;
|
||||
final String? email;
|
||||
}
|
||||
152
lib/domain/entity/delivery.dart
Normal file
152
lib/domain/entity/delivery.dart
Normal file
@ -0,0 +1,152 @@
|
||||
import 'address.dart';
|
||||
import 'delivery_item.dart';
|
||||
|
||||
/// Lebenszyklus einer Lieferung.
|
||||
///
|
||||
/// - `active`: Standard nach Anlage; Fahrer kann scannen/ausliefern.
|
||||
/// - `held`: Pausiert (Kunde nicht da, Termin verschoben) — kein Bearbeitungsfortschritt.
|
||||
/// - `canceled`: Abgebrochen — wird nicht mehr ausgeliefert.
|
||||
/// - `completed`: Abgeschlossen — Signatur und Notizen sind hinterlegt.
|
||||
enum DeliveryState { active, held, canceled, completed }
|
||||
|
||||
/// Eine einzelne Auslieferung an einen Kunden innerhalb einer Tour.
|
||||
///
|
||||
/// Anders als im alten Modell trägt `Delivery` hier ausschließlich
|
||||
/// Logistik-Daten — keine Preise, keine Rabatte, keine Zahlungsoptionen.
|
||||
/// Diese ERP-Themen sind in Phase C+D-2 absichtlich nicht migriert und
|
||||
/// hängen hinter `FeatureFlags`.
|
||||
class Delivery {
|
||||
const Delivery({
|
||||
required this.id,
|
||||
required this.tourId,
|
||||
required this.customerId,
|
||||
required this.contactPersonIds,
|
||||
required this.deliveryAddressSnapshot,
|
||||
required this.erpBelegartId,
|
||||
required this.erpBelegnummer,
|
||||
required this.state,
|
||||
required this.sortOrder,
|
||||
required this.items,
|
||||
required this.prepaidAmount,
|
||||
required this.paymentMethodId,
|
||||
this.assignedCarId,
|
||||
this.desiredTime,
|
||||
this.specialAgreements,
|
||||
this.stateReason,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String tourId;
|
||||
final String customerId;
|
||||
|
||||
/// 0..n Kontakte am Kunden, die für diese Lieferung relevant sind.
|
||||
/// Lookup über `TourDetails.contactById`.
|
||||
final List<String> contactPersonIds;
|
||||
|
||||
/// Eingefrorene Lieferadresse zum Zeitpunkt der Belegerzeugung — bleibt
|
||||
/// stabil, auch wenn die Stammadresse am Kunden später geändert wird.
|
||||
final Address deliveryAddressSnapshot;
|
||||
|
||||
/// ERP-Belegart (Lieferschein, Rechnung, …) und -Nummer. Für die App nur
|
||||
/// informativ; in Notizen/Reklamationen ist die Belegnummer der vom
|
||||
/// Kunden verständliche Bezugspunkt.
|
||||
final int erpBelegartId;
|
||||
final String erpBelegnummer;
|
||||
|
||||
final DeliveryState state;
|
||||
|
||||
/// Optionaler Klartext, warum `state` auf `held`/`canceled` steht. Vom
|
||||
/// Backend nicht-leer erzwungen, sobald ein Reason-pflichtiger Zustand
|
||||
/// gesetzt wird.
|
||||
final String? stateReason;
|
||||
|
||||
/// Sortier-Reihenfolge innerhalb der Tour, gesetzt durch
|
||||
/// `PUT /tours/{id}/delivery-order`. Niedriger = früher.
|
||||
final int sortOrder;
|
||||
|
||||
/// UUID des Fahrzeugs, dem diese Lieferung beim Laden zugewiesen wurde.
|
||||
/// `null` = noch nicht zugewiesen.
|
||||
final String? assignedCarId;
|
||||
|
||||
/// Bei Bestellung schon bezahlter Betrag in EUR. `0.0` wenn der Kunde
|
||||
/// alles bei Lieferung zahlt. Wird vom ERP-Sync gesetzt.
|
||||
final double prepaidAmount;
|
||||
|
||||
/// FK auf eine `PaymentMethod` (UUID). Auflösung zu Display-Name und
|
||||
/// Aktiv-Status geht über die Stammdaten-Liste, die die App separat
|
||||
/// lädt — nicht hier embeddet, damit das Tour-Aggregat klein bleibt.
|
||||
final String paymentMethodId;
|
||||
|
||||
final String? desiredTime;
|
||||
final String? specialAgreements;
|
||||
|
||||
final List<DeliveryItem> items;
|
||||
|
||||
// ─── Abgeleitete Sicht-Eigenschaften ──────────────────────────────────
|
||||
|
||||
/// Nur Items, die der Treiber tatsächlich scannen muss. Nicht-scanbare
|
||||
/// Artikel (Dienstleistungen, Versand) sowie bereits entfernte Items
|
||||
/// werden nicht mitgezählt.
|
||||
Iterable<DeliveryItem> scannableItems(
|
||||
bool Function(String articleId) isScannable,
|
||||
) sync* {
|
||||
for (final item in items) {
|
||||
if (item.isRemoved) continue;
|
||||
if (!isScannable(item.articleId)) continue;
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
|
||||
/// `true`, sobald *alle* scanbaren Items dieser Lieferung als `done`
|
||||
/// markiert sind. Wird in der Loading-Übersicht angezeigt und
|
||||
/// kontrolliert in der Detail-Phase den Übergang zur Signatur.
|
||||
bool allScannableItemsDone(bool Function(String articleId) isScannable) {
|
||||
final scannables = scannableItems(isScannable).toList();
|
||||
if (scannables.isEmpty) return false;
|
||||
return scannables.every((item) => item.isDone);
|
||||
}
|
||||
|
||||
Delivery copyWith({
|
||||
String? id,
|
||||
String? tourId,
|
||||
String? customerId,
|
||||
List<String>? contactPersonIds,
|
||||
Address? deliveryAddressSnapshot,
|
||||
int? erpBelegartId,
|
||||
String? erpBelegnummer,
|
||||
DeliveryState? state,
|
||||
String? stateReason,
|
||||
int? sortOrder,
|
||||
String? assignedCarId,
|
||||
Object? desiredTime = _sentinel,
|
||||
Object? specialAgreements = _sentinel,
|
||||
List<DeliveryItem>? items,
|
||||
double? prepaidAmount,
|
||||
String? paymentMethodId,
|
||||
}) {
|
||||
return Delivery(
|
||||
id: id ?? this.id,
|
||||
tourId: tourId ?? this.tourId,
|
||||
customerId: customerId ?? this.customerId,
|
||||
contactPersonIds: contactPersonIds ?? this.contactPersonIds,
|
||||
deliveryAddressSnapshot: deliveryAddressSnapshot ?? this.deliveryAddressSnapshot,
|
||||
erpBelegartId: erpBelegartId ?? this.erpBelegartId,
|
||||
erpBelegnummer: erpBelegnummer ?? this.erpBelegnummer,
|
||||
state: state ?? this.state,
|
||||
stateReason: stateReason ?? this.stateReason,
|
||||
sortOrder: sortOrder ?? this.sortOrder,
|
||||
assignedCarId: assignedCarId ?? this.assignedCarId,
|
||||
desiredTime: identical(desiredTime, _sentinel)
|
||||
? this.desiredTime
|
||||
: desiredTime as String?,
|
||||
specialAgreements: identical(specialAgreements, _sentinel)
|
||||
? this.specialAgreements
|
||||
: specialAgreements as String?,
|
||||
items: items ?? this.items,
|
||||
prepaidAmount: prepaidAmount ?? this.prepaidAmount,
|
||||
paymentMethodId: paymentMethodId ?? this.paymentMethodId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Object _sentinel = Object();
|
||||
20
lib/domain/entity/delivery_credit.dart
Normal file
20
lib/domain/entity/delivery_credit.dart
Normal file
@ -0,0 +1,20 @@
|
||||
/// Aktuelle Betrags-Gutschrift einer Lieferung (Geld-Nachlass, unabhängig von
|
||||
/// Stückzahl). Server-seitig aus dem append-only `delivery_credit_audit`
|
||||
/// abgeleitet (jüngstes Ereignis); existiert nur, solange der letzte Stand
|
||||
/// `set` ist.
|
||||
class DeliveryCredit {
|
||||
const DeliveryCredit({
|
||||
required this.deliveryId,
|
||||
required this.amountCents,
|
||||
required this.reason,
|
||||
});
|
||||
|
||||
final String deliveryId;
|
||||
|
||||
/// Betrag in Cent (> 0, ≤ 15000).
|
||||
final int amountCents;
|
||||
final String reason;
|
||||
|
||||
/// Betrag in ganzen Euro (die Gutschrift läuft in 10-€-Schritten).
|
||||
int get amountEuros => (amountCents / 100).round();
|
||||
}
|
||||
106
lib/domain/entity/delivery_item.dart
Normal file
106
lib/domain/entity/delivery_item.dart
Normal file
@ -0,0 +1,106 @@
|
||||
import 'scan_progress.dart';
|
||||
|
||||
/// Eine Belegzeile innerhalb einer Lieferung.
|
||||
///
|
||||
/// Verweist über `articleId` auf den Artikel-Stamm (lookup via
|
||||
/// `TourDetails.articleById`) und über `warehouseId` auf das Lager. Die
|
||||
/// Soll-/Ist-Quantitäten leben hier: `requiredQuantity` ist statisch (ERP),
|
||||
/// `scanProgress.scannedQuantity` wandert mit jedem Scan nach oben.
|
||||
///
|
||||
/// `komponentenArtikelNr` markiert Stücklisten-Komponenten. Im neuen
|
||||
/// Backend gibt es **keine** Parent-/Child-Hierarchie mehr — jedes Item ist
|
||||
/// gleichrangig; das Feld dient nur noch der Anzeige ("Teil von X") und
|
||||
/// hat keinerlei Scan-Semantik.
|
||||
class DeliveryItem {
|
||||
const DeliveryItem({
|
||||
required this.id,
|
||||
required this.deliveryId,
|
||||
required this.articleId,
|
||||
required this.warehouseId,
|
||||
required this.belegzeilenNr,
|
||||
required this.requiredQuantity,
|
||||
required this.scanProgress,
|
||||
this.unitPrice = 0,
|
||||
this.komponentenArtikelNr,
|
||||
this.parentArtikelNr,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String deliveryId;
|
||||
final String articleId;
|
||||
final String warehouseId;
|
||||
|
||||
/// ERP-Belegzeilen-Nummer. Bestimmt die Reihenfolge der Items in der
|
||||
/// Detail-Ansicht (aufsteigend).
|
||||
final int belegzeilenNr;
|
||||
final int requiredQuantity;
|
||||
final ScanProgress scanProgress;
|
||||
|
||||
/// Stückpreis (brutto, EUR) aus dem ERP-Sync.
|
||||
final double unitPrice;
|
||||
|
||||
final String? komponentenArtikelNr;
|
||||
|
||||
/// Artikelnummer des Oberartikels, zu dem diese Komponente gehört (aus dem
|
||||
/// Sync). `null` bei Oberartikeln/regulären Zeilen. Die Liste rückt
|
||||
/// Komponenten unter ihrem Oberartikel ein.
|
||||
final String? parentArtikelNr;
|
||||
|
||||
/// `true`, wenn dieses Item eine Stücklisten-Komponente ist (gehört unter
|
||||
/// einen Oberartikel).
|
||||
bool get isComponent => parentArtikelNr != null;
|
||||
|
||||
// ─── Abgeleitete Sicht-Eigenschaften ──────────────────────────────────
|
||||
|
||||
/// Tatsächlich auszuliefernde Menge = Soll − Gutschrift. Nie negativ.
|
||||
int get deliveredQuantity {
|
||||
final d = requiredQuantity - scanProgress.creditedQuantity;
|
||||
return d < 0 ? 0 : d;
|
||||
}
|
||||
|
||||
/// Wert der ausgelieferten Menge dieser Position (brutto, EUR).
|
||||
double get lineTotal => unitPrice * deliveredQuantity;
|
||||
|
||||
/// Vollständig gescannt (Status `done` oder Ist ≥ Soll).
|
||||
bool get isDone =>
|
||||
scanProgress.status == ScanStatus.done ||
|
||||
scanProgress.scannedQuantity >= requiredQuantity;
|
||||
|
||||
/// Aktuell pausiert.
|
||||
bool get isHeld => scanProgress.status == ScanStatus.held;
|
||||
|
||||
/// Nach dem Laden wieder entfernt.
|
||||
bool get isRemoved => scanProgress.status == ScanStatus.removed;
|
||||
|
||||
/// Noch offene Restmenge (für Loading-UI). Nicht negativ.
|
||||
int get remainingQuantity {
|
||||
final remaining = requiredQuantity - scanProgress.scannedQuantity;
|
||||
return remaining < 0 ? 0 : remaining;
|
||||
}
|
||||
|
||||
DeliveryItem copyWith({
|
||||
String? id,
|
||||
String? deliveryId,
|
||||
String? articleId,
|
||||
String? warehouseId,
|
||||
int? belegzeilenNr,
|
||||
int? requiredQuantity,
|
||||
ScanProgress? scanProgress,
|
||||
double? unitPrice,
|
||||
String? komponentenArtikelNr,
|
||||
String? parentArtikelNr,
|
||||
}) {
|
||||
return DeliveryItem(
|
||||
id: id ?? this.id,
|
||||
deliveryId: deliveryId ?? this.deliveryId,
|
||||
articleId: articleId ?? this.articleId,
|
||||
warehouseId: warehouseId ?? this.warehouseId,
|
||||
belegzeilenNr: belegzeilenNr ?? this.belegzeilenNr,
|
||||
requiredQuantity: requiredQuantity ?? this.requiredQuantity,
|
||||
scanProgress: scanProgress ?? this.scanProgress,
|
||||
unitPrice: unitPrice ?? this.unitPrice,
|
||||
komponentenArtikelNr: komponentenArtikelNr ?? this.komponentenArtikelNr,
|
||||
parentArtikelNr: parentArtikelNr ?? this.parentArtikelNr,
|
||||
);
|
||||
}
|
||||
}
|
||||
83
lib/domain/entity/delivery_note.dart
Normal file
83
lib/domain/entity/delivery_note.dart
Normal file
@ -0,0 +1,83 @@
|
||||
/// Notiz an einer Lieferung. Text und/oder Bildanhang können gesetzt sein —
|
||||
/// das Backend erzwingt nicht-leer für mindestens einen der beiden.
|
||||
///
|
||||
/// `imageAttachment` ist die UUID des hinterlegten Bildes; das eigentliche
|
||||
/// Binary wird über einen separaten Endpoint geladen (in einer späteren
|
||||
/// Phase modelliert).
|
||||
class DeliveryNote {
|
||||
const DeliveryNote({
|
||||
required this.id,
|
||||
required this.deliveryId,
|
||||
required this.authorPersonalnummer,
|
||||
required this.createdAt,
|
||||
this.text,
|
||||
this.imageAttachment,
|
||||
this.authorCarId,
|
||||
this.creditDeliveryItemId,
|
||||
this.isAmountCreditNote = false,
|
||||
this.imageAttachmentDeleted = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String deliveryId;
|
||||
final String? text;
|
||||
final String? imageAttachment;
|
||||
|
||||
/// Personalnummer des Fahrers (aus dem JWT zum Zeitpunkt der Erstellung).
|
||||
/// `int` weil im JWT als numerischer Claim transportiert.
|
||||
final int authorPersonalnummer;
|
||||
|
||||
/// Fahrzeug, mit dem die Notiz erstellt wurde (Audit-Spur, optional).
|
||||
final String? authorCarId;
|
||||
|
||||
/// Gesetzt, wenn die Notiz als Gutschrift-Grund zu einer Belegzeile
|
||||
/// angelegt wurde (deren `DeliveryItem`-Id). Erlaubt es, die Notiz beim
|
||||
/// Zurücknehmen der Gutschrift (Unremove) gezielt wieder zu löschen.
|
||||
final String? creditDeliveryItemId;
|
||||
|
||||
/// `true`, wenn die Notiz den Grund einer Betrags-Gutschrift dokumentiert
|
||||
/// (Lieferungs-Ebene). Wird beim Entfernen der Gutschrift gezielt gelöscht.
|
||||
final bool isAmountCreditNote;
|
||||
|
||||
/// `true`, wenn die lokale Bilddatei nach erfolgreichem Report-Upload
|
||||
/// gelöscht wurde — das Bild steckt dann im Lieferbericht (DOCUframe).
|
||||
/// Die UI zeigt statt der Vorschau einen Hinweis.
|
||||
final bool imageAttachmentDeleted;
|
||||
|
||||
final DateTime createdAt;
|
||||
|
||||
DeliveryNote copyWith({
|
||||
String? id,
|
||||
String? deliveryId,
|
||||
Object? text = _sentinel,
|
||||
Object? imageAttachment = _sentinel,
|
||||
int? authorPersonalnummer,
|
||||
Object? authorCarId = _sentinel,
|
||||
Object? creditDeliveryItemId = _sentinel,
|
||||
bool? isAmountCreditNote,
|
||||
bool? imageAttachmentDeleted,
|
||||
DateTime? createdAt,
|
||||
}) {
|
||||
return DeliveryNote(
|
||||
id: id ?? this.id,
|
||||
deliveryId: deliveryId ?? this.deliveryId,
|
||||
text: identical(text, _sentinel) ? this.text : text as String?,
|
||||
imageAttachment: identical(imageAttachment, _sentinel)
|
||||
? this.imageAttachment
|
||||
: imageAttachment as String?,
|
||||
authorPersonalnummer: authorPersonalnummer ?? this.authorPersonalnummer,
|
||||
authorCarId: identical(authorCarId, _sentinel)
|
||||
? this.authorCarId
|
||||
: authorCarId as String?,
|
||||
creditDeliveryItemId: identical(creditDeliveryItemId, _sentinel)
|
||||
? this.creditDeliveryItemId
|
||||
: creditDeliveryItemId as String?,
|
||||
isAmountCreditNote: isAmountCreditNote ?? this.isAmountCreditNote,
|
||||
imageAttachmentDeleted:
|
||||
imageAttachmentDeleted ?? this.imageAttachmentDeleted,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Object _sentinel = Object();
|
||||
15
lib/domain/entity/delivery_service_value.dart
Normal file
15
lib/domain/entity/delivery_service_value.dart
Normal file
@ -0,0 +1,15 @@
|
||||
/// Pro-Lieferung gesetzter Wert eines Service. Je nach Service-Typ ist genau
|
||||
/// einer der beiden Slots gefüllt.
|
||||
class DeliveryServiceValue {
|
||||
const DeliveryServiceValue({
|
||||
required this.deliveryId,
|
||||
required this.serviceId,
|
||||
this.boolValue,
|
||||
this.numericValue,
|
||||
});
|
||||
|
||||
final String deliveryId;
|
||||
final String serviceId;
|
||||
final bool? boolValue;
|
||||
final int? numericValue;
|
||||
}
|
||||
38
lib/domain/entity/payment_method.dart
Normal file
38
lib/domain/entity/payment_method.dart
Normal file
@ -0,0 +1,38 @@
|
||||
/// Zahlungs-Stammdatensatz — spiegelt das Backend-Aggregat `PaymentMethod`.
|
||||
///
|
||||
/// `code` ist der stabile Programm-Identifier (z. B. `"cash"`,
|
||||
/// `"invoice"`); UI-Code kann darüber spezielle Methoden referenzieren,
|
||||
/// ohne die UUID kennen zu müssen. `active = false` ist Soft-Delete —
|
||||
/// die Methode bleibt für historische Lieferungen referenzierbar,
|
||||
/// taucht aber in der Auswahl bei neuen Lieferungen nicht mehr auf.
|
||||
class PaymentMethod {
|
||||
const PaymentMethod({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.name,
|
||||
required this.active,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String code;
|
||||
final String name;
|
||||
final bool active;
|
||||
final DateTime createdAt;
|
||||
|
||||
PaymentMethod copyWith({
|
||||
String? id,
|
||||
String? code,
|
||||
String? name,
|
||||
bool? active,
|
||||
DateTime? createdAt,
|
||||
}) {
|
||||
return PaymentMethod(
|
||||
id: id ?? this.id,
|
||||
code: code ?? this.code,
|
||||
name: name ?? this.name,
|
||||
active: active ?? this.active,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
93
lib/domain/entity/scan_intent.dart
Normal file
93
lib/domain/entity/scan_intent.dart
Normal file
@ -0,0 +1,93 @@
|
||||
/// Vom Treiber ausgelöstes Scan-Ereignis, bevor es serverseitig
|
||||
/// angewendet wurde.
|
||||
///
|
||||
/// `clientScanId` ist ein vom Client generierter UUID-Schlüssel und dient
|
||||
/// als **Idempotenz-Anker**: der Server speichert ihn beim ersten Apply
|
||||
/// und antwortet auf jeden weiteren Request mit derselben Id mit
|
||||
/// `duplicate` statt einer zweiten Anwendung. So bleibt Network-Retry
|
||||
/// (z. B. nach Verbindungsabbruch beim ersten POST) bedeutungslos.
|
||||
///
|
||||
/// `clientScannedAt` ist die Wall-Clock-Zeit am Gerät zum Zeitpunkt des
|
||||
/// Scans — der Server nutzt das nur als Audit-Spur, sortiert aber selbst
|
||||
/// nach Server-Empfangszeit, sodass eine schiefe Uhr am Phone die
|
||||
/// Reihenfolge nicht durcheinanderbringt.
|
||||
class ScanIntent {
|
||||
const ScanIntent({
|
||||
required this.clientScanId,
|
||||
required this.clientScannedAt,
|
||||
required this.deliveryItemId,
|
||||
required this.action,
|
||||
this.actorCarId,
|
||||
this.reason,
|
||||
this.quantity,
|
||||
this.manual = false,
|
||||
});
|
||||
|
||||
final String clientScanId;
|
||||
final DateTime clientScannedAt;
|
||||
final String deliveryItemId;
|
||||
final ScanAction action;
|
||||
|
||||
/// `true`, wenn der Fahrer die Position manuell als geladen bestätigt hat
|
||||
/// (Fallback ohne Barcode). Reine Audit-Information; Default `false`.
|
||||
final bool manual;
|
||||
|
||||
/// Menge für `remove` / `unremove` (Mengen-Gutschrift): wie viele Stück
|
||||
/// der Belegzeile gutgeschrieben bzw. wiederhergestellt werden. `null` =
|
||||
/// ganze Restmenge. Bei `scan`/`unscan`/`hold`/`unhold` ignoriert.
|
||||
final int? quantity;
|
||||
|
||||
/// Fahrzeug, mit dem gescannt wurde — Audit-Spur. Optional, aber die
|
||||
/// App schickt ihn in der Loading-Phase immer mit, weil das Auto zu
|
||||
/// dem Zeitpunkt definitiv gewählt ist.
|
||||
final String? actorCarId;
|
||||
|
||||
/// Klartext-Begründung. Bei `unscan` / `hold` / `remove` vom Backend
|
||||
/// erwartet, bei `scan` / `unhold` ignoriert.
|
||||
final String? reason;
|
||||
}
|
||||
|
||||
/// Auswirkung eines Scan-Ereignisses auf die Pipeline eines Items.
|
||||
/// Spiegel des Backend-Enums `AuditAction`.
|
||||
///
|
||||
/// `unremove` ist die Umkehrung von `remove`: setzt ein `Removed`-Item
|
||||
/// zurück auf `InProgress` (oder `Done`, falls die Soll-Menge schon
|
||||
/// erreicht war). Der ursprüngliche `remove`-Audit-Eintrag bleibt
|
||||
/// erhalten — `unremove` erzeugt einen eigenen Eintrag, sodass die
|
||||
/// Historie der Korrektur vollständig nachvollziehbar bleibt.
|
||||
enum ScanAction { scan, unscan, hold, unhold, remove, unremove }
|
||||
|
||||
/// Ergebnis eines Apply-Versuchs vom Server.
|
||||
class ScanOutcome {
|
||||
const ScanOutcome({
|
||||
required this.clientScanId,
|
||||
required this.status,
|
||||
this.deliveryItemId,
|
||||
this.reason,
|
||||
});
|
||||
|
||||
final String clientScanId;
|
||||
final ScanOutcomeStatus status;
|
||||
|
||||
/// Bei `applied` und `duplicate` immer gesetzt, bei `rejected` häufig
|
||||
/// `null` (z. B. wenn die Id beim Server gar nicht ankam).
|
||||
final String? deliveryItemId;
|
||||
|
||||
/// Bei `rejected` die Server-Begründung — Standard-Text in der UI.
|
||||
final String? reason;
|
||||
}
|
||||
|
||||
enum ScanOutcomeStatus {
|
||||
/// Server hat den Scan angewendet — `scannedQuantity` ist hochgezählt
|
||||
/// oder Status hat sich geändert.
|
||||
applied,
|
||||
|
||||
/// Server hat denselben `clientScanId` schon einmal verarbeitet —
|
||||
/// kein Effekt, aber auch kein Fehler.
|
||||
duplicate,
|
||||
|
||||
/// Server hat den Scan abgelehnt (z. B. Item gehört zu fremder
|
||||
/// Lieferung, Soll-Menge schon voll, Item ist auf `removed`). UI muss
|
||||
/// optimistische Mutation zurückrollen.
|
||||
rejected,
|
||||
}
|
||||
48
lib/domain/entity/scan_progress.dart
Normal file
48
lib/domain/entity/scan_progress.dart
Normal file
@ -0,0 +1,48 @@
|
||||
/// Status der Scan-Pipeline eines einzelnen `DeliveryItem`.
|
||||
///
|
||||
/// - `inProgress`: Soll-Menge noch nicht erreicht, Scanner darf weiterzählen.
|
||||
/// - `done`: Soll-Menge erreicht; weitere Scans werden serverseitig abgewiesen.
|
||||
/// - `held`: Pausiert (z. B. „Ware beschädigt, klärt der Fahrer mit dem Lager") —
|
||||
/// `ScanProgress.heldReason` trägt die Begründung.
|
||||
/// - `removed`: Item wurde nach dem Laden wieder abgebucht (Retoure, Falschladung).
|
||||
enum ScanStatus { inProgress, done, held, removed }
|
||||
|
||||
/// Embedded Value-Object am `DeliveryItem`. Beschreibt, wie weit der Fahrer
|
||||
/// mit dem Scannen dieses Items ist — *nicht*, wo das Item logistisch steht.
|
||||
class ScanProgress {
|
||||
const ScanProgress({
|
||||
required this.status,
|
||||
required this.scannedQuantity,
|
||||
required this.lastUpdatedAt,
|
||||
this.creditedQuantity = 0,
|
||||
this.heldReason,
|
||||
});
|
||||
|
||||
final ScanStatus status;
|
||||
final int scannedQuantity;
|
||||
|
||||
/// Als Gutschrift entfernte Menge (0..=requiredQuantity). Eigene Dimension
|
||||
/// neben [scannedQuantity]: „wie viele Stück dieser Zeile hat der Kunde
|
||||
/// nicht angenommen". `status == removed` entspricht voller Gutschrift
|
||||
/// (creditedQuantity == requiredQuantity).
|
||||
final int creditedQuantity;
|
||||
|
||||
final DateTime lastUpdatedAt;
|
||||
final String? heldReason;
|
||||
|
||||
ScanProgress copyWith({
|
||||
ScanStatus? status,
|
||||
int? scannedQuantity,
|
||||
int? creditedQuantity,
|
||||
DateTime? lastUpdatedAt,
|
||||
String? heldReason,
|
||||
}) {
|
||||
return ScanProgress(
|
||||
status: status ?? this.status,
|
||||
scannedQuantity: scannedQuantity ?? this.scannedQuantity,
|
||||
creditedQuantity: creditedQuantity ?? this.creditedQuantity,
|
||||
lastUpdatedAt: lastUpdatedAt ?? this.lastUpdatedAt,
|
||||
heldReason: heldReason ?? this.heldReason,
|
||||
);
|
||||
}
|
||||
}
|
||||
29
lib/domain/entity/service.dart
Normal file
29
lib/domain/entity/service.dart
Normal file
@ -0,0 +1,29 @@
|
||||
/// Eingabetyp eines Service. `boolean` → Checkbox, `numeric` → Zahlenfeld
|
||||
/// mit optionalen Grenzen.
|
||||
enum ServiceKind { boolean, numeric }
|
||||
|
||||
/// Service-Stammdatensatz (früher „Lieferoption") — admin-konfigurierbar.
|
||||
/// In Phase 4 rendert die App aus den aktiven Services die Auswahl.
|
||||
class Service {
|
||||
const Service({
|
||||
required this.id,
|
||||
required this.key,
|
||||
required this.name,
|
||||
required this.kind,
|
||||
required this.active,
|
||||
required this.sortOrder,
|
||||
this.minValue,
|
||||
this.maxValue,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String key;
|
||||
final String name;
|
||||
final ServiceKind kind;
|
||||
final bool active;
|
||||
final int sortOrder;
|
||||
|
||||
/// Nur bei [ServiceKind.numeric] relevant.
|
||||
final int? minValue;
|
||||
final int? maxValue;
|
||||
}
|
||||
53
lib/domain/entity/tour.dart
Normal file
53
lib/domain/entity/tour.dart
Normal file
@ -0,0 +1,53 @@
|
||||
/// Aggregat-Wurzel eines Tour-Tages.
|
||||
///
|
||||
/// Die `Tour` selbst ist minimal — sie hält nur Identität und Eckdaten;
|
||||
/// die fachlich interessanten Daten (Lieferungen + Stammdaten-Lookups)
|
||||
/// sitzen in `TourDetails`. Diese Trennung erlaubt es, Touren-Listen
|
||||
/// (z. B. `/me/tours/today`) zu rendern, ohne das gesamte Aggregat
|
||||
/// laden zu müssen.
|
||||
class Tour {
|
||||
const Tour({
|
||||
required this.id,
|
||||
required this.accountId,
|
||||
required this.date,
|
||||
required this.syncedAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final int accountId;
|
||||
final DateTime date;
|
||||
|
||||
/// Zeitpunkt des letzten ERP-Sync. Wird in der Header-Zeile als
|
||||
/// „Stand: …"-Hinweis angezeigt — wenn das ungewöhnlich alt ist, sieht
|
||||
/// der Fahrer das.
|
||||
final DateTime syncedAt;
|
||||
|
||||
Tour copyWith({
|
||||
String? id,
|
||||
int? accountId,
|
||||
DateTime? date,
|
||||
DateTime? syncedAt,
|
||||
}) {
|
||||
return Tour(
|
||||
id: id ?? this.id,
|
||||
accountId: accountId ?? this.accountId,
|
||||
date: date ?? this.date,
|
||||
syncedAt: syncedAt ?? this.syncedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tagestour-Übersicht, wie sie `/me/tours/today` liefert. Schlankes Objekt
|
||||
/// für die Initialphase (Tour-Auswahl), ohne das volle Aggregat zu
|
||||
/// transportieren.
|
||||
class TourSummary {
|
||||
const TourSummary({
|
||||
required this.tourId,
|
||||
required this.tourDate,
|
||||
required this.deliveryCount,
|
||||
});
|
||||
|
||||
final String tourId;
|
||||
final DateTime tourDate;
|
||||
final int deliveryCount;
|
||||
}
|
||||
428
lib/domain/entity/tour_details.dart
Normal file
428
lib/domain/entity/tour_details.dart
Normal file
@ -0,0 +1,428 @@
|
||||
import 'article.dart';
|
||||
import 'contact_source.dart';
|
||||
import 'customer.dart';
|
||||
import 'delivery.dart';
|
||||
import 'delivery_credit.dart';
|
||||
import 'delivery_item.dart';
|
||||
import 'delivery_note.dart';
|
||||
import 'delivery_service_value.dart';
|
||||
import 'service.dart';
|
||||
import 'tour.dart';
|
||||
import 'warehouse.dart';
|
||||
|
||||
/// Voll geladenes Tour-Aggregat. Enthält die Tour selbst, alle Lieferungen
|
||||
/// inkl. Items sowie *alle* Stammdaten, die von diesem Schnitt referenziert
|
||||
/// werden. Die Stammdaten kommen als Lookup-Maps statt als List, damit das
|
||||
/// UI ohne O(n)-Suchen auskommt.
|
||||
///
|
||||
/// Die Notizen sind im Backend in einer flachen Liste — wir indizieren sie
|
||||
/// hier einmal per `deliveryId`, weil das UI sie immer „pro Lieferung"
|
||||
/// braucht.
|
||||
class TourDetails {
|
||||
TourDetails({
|
||||
required this.tour,
|
||||
required this.deliveries,
|
||||
required this.customers,
|
||||
required this.contacts,
|
||||
required this.articles,
|
||||
required this.warehouses,
|
||||
required this.notesByDeliveryId,
|
||||
required this.creditsByDeliveryId,
|
||||
required this.services,
|
||||
required this.serviceValuesByDeliveryId,
|
||||
required this.contactSourcesByDeliveryId,
|
||||
required this.contactChannelsBySourceId,
|
||||
});
|
||||
|
||||
final Tour tour;
|
||||
|
||||
/// Alle Lieferungen dieser Tour. Reihenfolge: unsortiert; UI ruft
|
||||
/// `deliveriesSorted` auf, wenn Sortier-Reihenfolge benötigt wird.
|
||||
final List<Delivery> deliveries;
|
||||
|
||||
// ─── Stammdaten-Lookups (Id → Entity) ─────────────────────────────────
|
||||
|
||||
final Map<String, Customer> customers;
|
||||
final Map<String, CustomerContact> contacts;
|
||||
final Map<String, Article> articles;
|
||||
final Map<String, Warehouse> warehouses;
|
||||
|
||||
/// Pro Lieferung: alle Notizen, aufsteigend nach `createdAt`. Wenn eine
|
||||
/// Lieferung keine Notizen hat, liefert der Lookup `null` zurück — das
|
||||
/// UI muss das berücksichtigen.
|
||||
final Map<String, List<DeliveryNote>> notesByDeliveryId;
|
||||
|
||||
/// Pro Lieferung die aktuelle Betrags-Gutschrift (höchstens eine). Fehlt
|
||||
/// der Eintrag, gibt es aktuell keine Gutschrift.
|
||||
final Map<String, DeliveryCredit> creditsByDeliveryId;
|
||||
|
||||
/// Aktive Service-Definitionen (Stammdaten), nach `sortOrder`. Daraus
|
||||
/// rendert Phase 4 die Auswahl.
|
||||
final List<Service> services;
|
||||
|
||||
/// Pro Lieferung die gesetzten Service-Werte, indiziert per `serviceId`.
|
||||
final Map<String, Map<String, DeliveryServiceValue>> serviceValuesByDeliveryId;
|
||||
|
||||
/// Pro Lieferung die Adress-Quellen aus dem ERP (Belegadresse / Liefer-
|
||||
/// adresse / Rechnungsadresse / Ansprechpartner / Kundenstamm). Wird vom
|
||||
/// Sync gefüllt; leere Quellen kommen nicht durch — wer hier 0 Einträge
|
||||
/// sieht, hat im ERP keinen einzigen Kontakt am Beleg hängen.
|
||||
final Map<String, List<ContactSource>> contactSourcesByDeliveryId;
|
||||
|
||||
/// Pro Quelle alle ihre Kommunikationskanäle. Reihenfolge folgt der
|
||||
/// ERP-Position (Telefon 1 → Position 1, Telefon 2 → Position 2, …),
|
||||
/// das UI kann die Liste direkt rendern.
|
||||
final Map<String, List<ContactChannel>> contactChannelsBySourceId;
|
||||
|
||||
// ─── Convenience für UI ───────────────────────────────────────────────
|
||||
|
||||
/// Lieferungen sortiert nach `sortOrder` aufsteigend. Falls zwei
|
||||
/// Lieferungen identische Werte tragen (sollte nicht vorkommen, dient
|
||||
/// nur als Defensive), fällt der Vergleich auf die Belegnummer zurück.
|
||||
List<Delivery> get deliveriesSorted {
|
||||
final copy = List<Delivery>.of(deliveries);
|
||||
copy.sort((a, b) {
|
||||
final byOrder = a.sortOrder.compareTo(b.sortOrder);
|
||||
if (byOrder != 0) return byOrder;
|
||||
return a.erpBelegnummer.compareTo(b.erpBelegnummer);
|
||||
});
|
||||
return copy;
|
||||
}
|
||||
|
||||
Customer? customerOf(Delivery delivery) => customers[delivery.customerId];
|
||||
|
||||
Iterable<CustomerContact> contactsOf(Delivery delivery) sync* {
|
||||
for (final id in delivery.contactPersonIds) {
|
||||
final c = contacts[id];
|
||||
if (c != null) yield c;
|
||||
}
|
||||
}
|
||||
|
||||
/// Alle Adress-Quellen einer Lieferung — in der vom Backend gelieferten
|
||||
/// Reihenfolge (nach [ContactRole], anschließend nach Quell-Id für
|
||||
/// stabile UI). Leere Liste, wenn diese Lieferung im ERP keinen Kontakt
|
||||
/// hängen hat.
|
||||
List<ContactSource> contactSourcesOf(Delivery delivery) =>
|
||||
contactSourcesByDeliveryId[delivery.id] ?? const <ContactSource>[];
|
||||
|
||||
/// Alle Kanäle einer einzelnen Quelle. Leere Liste, wenn die Quelle nur
|
||||
/// einen Namensblock trägt (z. B. ein Ansprechpartner ohne Telefonnummer).
|
||||
List<ContactChannel> channelsOf(ContactSource source) =>
|
||||
contactChannelsBySourceId[source.id] ?? const <ContactChannel>[];
|
||||
|
||||
/// Wie [contactSourcesOf], aber Quellen mit identischem Namensblock UND
|
||||
/// identischer Channel-Liste sind zu einem [MergedContactSource] mit
|
||||
/// Multi-Rollen-Header zusammengeführt. Das eliminiert die typische
|
||||
/// Doppelung „Belegadresse + Kundenstamm" bei Belegen, deren
|
||||
/// `Belegkopf.AdressId` ohnehin auf die Kunden-Stammadresse zeigt.
|
||||
///
|
||||
/// Identity-Fingerprint: alle Namensfelder (Anrede / Titel / Name1..3 /
|
||||
/// Abteilung / Funktion) plus die nach (kind, position) sortierten
|
||||
/// (kind, value)-Paare. Zwei Quellen mit identischem Namen, aber
|
||||
/// abweichenden Channels werden NICHT gemerged — das wäre fachlich
|
||||
/// falsch (zwei verschiedene Kontaktdatensätze derselben Person).
|
||||
List<MergedContactSource> mergedContactSourcesOf(Delivery delivery) {
|
||||
final sources = contactSourcesOf(delivery);
|
||||
if (sources.isEmpty) return const <MergedContactSource>[];
|
||||
|
||||
// Reihenfolge der Erstauftritte merken — die Backend-Sortierung
|
||||
// (Quellen nach Rolle aufsteigend) bestimmt damit auch die Reihenfolge
|
||||
// der Merge-Gruppen in der UI.
|
||||
final order = <String>[];
|
||||
final byKey = <String, List<ContactSource>>{};
|
||||
for (final s in sources) {
|
||||
final key = _identityKey(s, channelsOf(s));
|
||||
if (!byKey.containsKey(key)) {
|
||||
order.add(key);
|
||||
byKey[key] = <ContactSource>[];
|
||||
}
|
||||
byKey[key]!.add(s);
|
||||
}
|
||||
|
||||
return [
|
||||
for (final key in order) _buildMerged(byKey[key]!),
|
||||
];
|
||||
}
|
||||
|
||||
/// Fingerprint einer Quelle: Namensblock + alle (kind, position, value)-
|
||||
/// Tripel. Vorab nach (kind-Index, position) sortiert, damit semantisch
|
||||
/// gleiche Quellen unabhängig von der Speicher-Reihenfolge denselben
|
||||
/// Schlüssel bekommen.
|
||||
String _identityKey(ContactSource s, List<ContactChannel> channels) {
|
||||
final namePart = [
|
||||
s.anrede ?? '',
|
||||
s.titel ?? '',
|
||||
s.name1 ?? '',
|
||||
s.name2 ?? '',
|
||||
s.name3 ?? '',
|
||||
s.abteilung ?? '',
|
||||
s.funktion ?? '',
|
||||
].join('|');
|
||||
final sortedChannels = List<ContactChannel>.of(channels)
|
||||
..sort((a, b) {
|
||||
final byKind = a.kind.index.compareTo(b.kind.index);
|
||||
if (byKind != 0) return byKind;
|
||||
return a.position.compareTo(b.position);
|
||||
});
|
||||
final channelPart = sortedChannels
|
||||
.map((c) => '${c.kind.name}:${c.position}:${c.value}')
|
||||
.join('|');
|
||||
return '$namePart||$channelPart';
|
||||
}
|
||||
|
||||
MergedContactSource _buildMerged(List<ContactSource> group) {
|
||||
// Namensblock + Channels von der ersten Quelle übernehmen — alle Quellen
|
||||
// in der Gruppe sind per Identity-Key garantiert deckungsgleich.
|
||||
final first = group.first;
|
||||
final roles = group.map((s) => s.role).toList()
|
||||
..sort((a, b) => a.index.compareTo(b.index));
|
||||
return MergedContactSource(
|
||||
roles: roles,
|
||||
anrede: first.anrede,
|
||||
titel: first.titel,
|
||||
name1: first.name1,
|
||||
name2: first.name2,
|
||||
name3: first.name3,
|
||||
abteilung: first.abteilung,
|
||||
funktion: first.funktion,
|
||||
channels: channelsOf(first),
|
||||
);
|
||||
}
|
||||
|
||||
Article? articleOf(String articleId) => articles[articleId];
|
||||
|
||||
Warehouse? warehouseOf(String warehouseId) => warehouses[warehouseId];
|
||||
|
||||
List<DeliveryNote> notesOf(String deliveryId) =>
|
||||
notesByDeliveryId[deliveryId] ?? const <DeliveryNote>[];
|
||||
|
||||
/// Aktuelle Betrags-Gutschrift dieser Lieferung, oder `null`.
|
||||
DeliveryCredit? creditOf(String deliveryId) =>
|
||||
creditsByDeliveryId[deliveryId];
|
||||
|
||||
/// Gesetzter Service-Wert dieser Lieferung für einen Service, oder `null`.
|
||||
DeliveryServiceValue? serviceValueOf(String deliveryId, String serviceId) =>
|
||||
serviceValuesByDeliveryId[deliveryId]?[serviceId];
|
||||
|
||||
/// Alle Attachment-IDs, die von Foto-Notizen dieser Tour referenziert
|
||||
/// werden — die Menge der „noch gültigen" Bilder. Dient dem Cache-Pruning
|
||||
/// (`AttachmentCache.retainOnly`): gecachte Vorschauen zu IDs, die hier
|
||||
/// nicht (mehr) vorkommen, gehören zu gelöschten Notizen und dürfen weg.
|
||||
Set<String> get referencedAttachmentIds {
|
||||
final ids = <String>{};
|
||||
for (final notes in notesByDeliveryId.values) {
|
||||
for (final n in notes) {
|
||||
final attachment = n.imageAttachment;
|
||||
if (attachment != null) ids.add(attachment);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
bool isArticleScannable(String articleId) =>
|
||||
articles[articleId]?.scannable ?? false;
|
||||
|
||||
/// Nicht-scanbare Positionen einer Lieferung (Dienstleistung / Pauschale /
|
||||
/// Fracht — `article.scannable == false`). Entfernte Zeilen sind hier
|
||||
/// ausgefiltert, weil eine entfernte Dienstleistung den Belade-/Anfahrt-
|
||||
/// Hinweis nicht mehr rechtfertigt.
|
||||
///
|
||||
/// Diese Positionen werden in der Beladen-Phase **nicht gescannt**, sind
|
||||
/// aber fachlich der Grund, warum eine Lieferung ohne scanbare Ware (reine
|
||||
/// Dienstleistung) trotzdem angefahren werden muss.
|
||||
Iterable<DeliveryItem> nonScannableItems(Delivery delivery) sync* {
|
||||
for (final it in delivery.items) {
|
||||
if (it.isRemoved) continue;
|
||||
if (isArticleScannable(it.articleId)) continue;
|
||||
yield it;
|
||||
}
|
||||
}
|
||||
|
||||
/// `true`, wenn die Lieferung mindestens eine nicht-scanbare Position
|
||||
/// (Dienstleistung / Pauschale) trägt — Basis für den Dienstleistungs-
|
||||
/// Hinweis in der Beladen-Ansicht.
|
||||
bool hasServiceItems(Delivery delivery) =>
|
||||
nonScannableItems(delivery).isNotEmpty;
|
||||
|
||||
// ─── Lager-Aufteilung in der Beladen-Phase ───────────────────────────
|
||||
//
|
||||
// Der Fahrer startet standardmäßig im Standardlager (`Warehouse.isStandard`).
|
||||
// Filialen werden separat angefahren — sie blockieren NICHT den Übergang
|
||||
// in die Auslieferungs-Phase. Eine Lieferung gilt deshalb als „fertig
|
||||
// beladen", sobald **alle scanbaren Standardlager-Items** durch sind;
|
||||
// Filial-Items werden in der UI sichtbar gekennzeichnet, damit der
|
||||
// Fahrer weiß, dass er noch eine zweite Station ansteuern muss.
|
||||
|
||||
bool _isStandard(String warehouseId) =>
|
||||
warehouseOf(warehouseId)?.isStandard ?? false;
|
||||
|
||||
bool _isExternal(String warehouseId) {
|
||||
final w = warehouseOf(warehouseId);
|
||||
return w != null && !w.isStandard;
|
||||
}
|
||||
|
||||
/// Iterator über die scanbaren Items einer Lieferung. `includeRemoved`
|
||||
/// kontrolliert, ob entfernte Positionen Teil der Iteration sind:
|
||||
///
|
||||
/// * `false` (default) — für Status-Berechnungen (`standardWarehouseLoadingDone`,
|
||||
/// `hasExternalWarehouseItems`, …). Entfernte Positionen blockieren
|
||||
/// sonst „Fertig"-Marker oder triggern fälschlich Filial-Hinweise.
|
||||
/// * `true` — für die UI-Anzeige (`itemsGroupedByWarehouse`), damit der
|
||||
/// Fahrer entfernte Items als durchgestrichene Zeilen weiterhin sieht
|
||||
/// und sie ggf. wiederherstellen kann.
|
||||
Iterable<DeliveryItem> _activeScannableItems(
|
||||
Delivery delivery, {
|
||||
bool includeRemoved = false,
|
||||
}) sync* {
|
||||
for (final it in delivery.items) {
|
||||
if (!includeRemoved && it.isRemoved) continue;
|
||||
if (!isArticleScannable(it.articleId)) continue;
|
||||
yield it;
|
||||
}
|
||||
}
|
||||
|
||||
/// Standardlager-Beladung dieser Lieferung ist erledigt: jedes scanbare,
|
||||
/// nicht-entfernte Item aus dem Standardlager ist `done`. Lieferungen
|
||||
/// ohne Standardlager-Items (= alles Filiale) sind trivial fertig —
|
||||
/// im Standardlager ist dann nichts zu tun.
|
||||
bool standardWarehouseLoadingDone(Delivery delivery) {
|
||||
return _activeScannableItems(delivery)
|
||||
.where((it) => _isStandard(it.warehouseId))
|
||||
.every((it) => it.isDone);
|
||||
}
|
||||
|
||||
/// Lieferung enthält mindestens ein noch relevantes Filial-Item.
|
||||
/// „Relevant" = scanbar + nicht entfernt; ob das Item schon gescannt ist
|
||||
/// oder nicht spielt für diese Markierung keine Rolle (entscheidend ist
|
||||
/// nur, dass der Fahrer ein zusätzliches Lager anfahren muss).
|
||||
bool hasExternalWarehouseItems(Delivery delivery) {
|
||||
return _activeScannableItems(delivery).any(
|
||||
(it) => _isExternal(it.warehouseId),
|
||||
);
|
||||
}
|
||||
|
||||
/// Filial-Items, die noch nicht beladen wurden — gedacht für die
|
||||
/// Auslieferungs-Übersicht: dort soll der Fahrer auf einen Blick sehen,
|
||||
/// dass er *vor* der Anfahrt zum Kunden noch ein zweites Lager ansteuern
|
||||
/// muss, und welche Artikel ihn dort erwarten.
|
||||
///
|
||||
/// Item-Filter: scanbar + nicht entfernt + Filiale + `!isDone`. Items
|
||||
/// mit Status `held` zählen ebenfalls als „nicht geholt", weil das
|
||||
/// Warenholen noch aussteht.
|
||||
///
|
||||
/// Sortierung: Lager alphabetisch, innerhalb des Lagers nach
|
||||
/// `belegzeilenNr` aufsteigend — stabile Reihenfolge zwischen Builds.
|
||||
List<({Warehouse warehouse, List<DeliveryItem> items})>
|
||||
pendingExternalWarehouseGroups(Delivery delivery) {
|
||||
final byWarehouseId = <String, List<DeliveryItem>>{};
|
||||
for (final it in _activeScannableItems(delivery)) {
|
||||
if (!_isExternal(it.warehouseId)) continue;
|
||||
if (it.isDone) continue;
|
||||
byWarehouseId.putIfAbsent(it.warehouseId, () => []).add(it);
|
||||
}
|
||||
final groups = <({Warehouse warehouse, List<DeliveryItem> items})>[];
|
||||
byWarehouseId.forEach((warehouseId, items) {
|
||||
final w = warehouseOf(warehouseId);
|
||||
if (w == null) return;
|
||||
items.sort((a, b) => a.belegzeilenNr.compareTo(b.belegzeilenNr));
|
||||
groups.add((warehouse: w, items: items));
|
||||
});
|
||||
groups.sort((a, b) => a.warehouse.name.compareTo(b.warehouse.name));
|
||||
return groups;
|
||||
}
|
||||
|
||||
/// `true`, wenn die Lieferung noch mindestens einen offenen
|
||||
/// Filial-Artikel hat (= Fahrer muss zuerst in die Filiale).
|
||||
bool hasPendingExternalWarehouseItems(Delivery delivery) {
|
||||
for (final it in _activeScannableItems(delivery)) {
|
||||
if (!_isExternal(it.warehouseId)) continue;
|
||||
if (!it.isDone) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Eindeutige Filial-Namen dieser Lieferung — für Badges /
|
||||
/// Sektions-Header in der UI. Sortiert nach Lager-Name, damit die
|
||||
/// Reihenfolge stabil bleibt zwischen Builds.
|
||||
List<String> externalWarehouseLabels(Delivery delivery) {
|
||||
final names = <String>{};
|
||||
for (final it in _activeScannableItems(delivery)) {
|
||||
if (!_isExternal(it.warehouseId)) continue;
|
||||
final w = warehouseOf(it.warehouseId);
|
||||
if (w != null) names.add(w.name);
|
||||
}
|
||||
final list = names.toList()..sort();
|
||||
return list;
|
||||
}
|
||||
|
||||
/// Gruppiert die scanbaren Items einer Lieferung nach Warehouse-Id —
|
||||
/// Standardlager-Eintrag (sofern vorhanden) immer zuerst, danach
|
||||
/// Filiale alphabetisch nach Lager-Name. Items innerhalb einer
|
||||
/// Gruppe sind nach `belegzeilenNr` aufsteigend sortiert.
|
||||
List<({Warehouse warehouse, List<DeliveryItem> items})>
|
||||
itemsGroupedByWarehouse(Delivery delivery) {
|
||||
final byWarehouseId = <String, List<DeliveryItem>>{};
|
||||
// Entfernte Items bleiben in der UI sichtbar (durchgestrichen) und
|
||||
// können dort über das Aktions-Menü wiederhergestellt werden — der
|
||||
// Status-Pfad (`standardWarehouseLoadingDone` etc.) ignoriert sie
|
||||
// trotzdem, weil die jeweiligen Helper ohne `includeRemoved` laufen.
|
||||
for (final it in _activeScannableItems(delivery, includeRemoved: true)) {
|
||||
byWarehouseId.putIfAbsent(it.warehouseId, () => []).add(it);
|
||||
}
|
||||
for (final list in byWarehouseId.values) {
|
||||
list.sort((a, b) => a.belegzeilenNr.compareTo(b.belegzeilenNr));
|
||||
}
|
||||
|
||||
// In zwei Buckets aufteilen, damit der Aufrufer Standard zuerst sieht.
|
||||
final standard = <({Warehouse warehouse, List<DeliveryItem> items})>[];
|
||||
final external = <({Warehouse warehouse, List<DeliveryItem> items})>[];
|
||||
byWarehouseId.forEach((warehouseId, items) {
|
||||
final w = warehouseOf(warehouseId);
|
||||
if (w == null) return; // Defensive: defekte Stammdaten ignorieren
|
||||
final group = (warehouse: w, items: items);
|
||||
if (w.isStandard) {
|
||||
standard.add(group);
|
||||
} else {
|
||||
external.add(group);
|
||||
}
|
||||
});
|
||||
external.sort((a, b) => a.warehouse.name.compareTo(b.warehouse.name));
|
||||
return [...standard, ...external];
|
||||
}
|
||||
|
||||
/// Neues Aggregat mit ausgetauschten/erweiterten Listen — gedacht für
|
||||
/// Bloc-Reducer (Reorder, Assign-Car etc.), die das ganze Aggregat
|
||||
/// behalten und nur ein paar Lieferungen austauschen wollen.
|
||||
TourDetails copyWith({
|
||||
Tour? tour,
|
||||
List<Delivery>? deliveries,
|
||||
Map<String, List<DeliveryNote>>? notesByDeliveryId,
|
||||
Map<String, DeliveryCredit>? creditsByDeliveryId,
|
||||
Map<String, Map<String, DeliveryServiceValue>>? serviceValuesByDeliveryId,
|
||||
}) {
|
||||
return TourDetails(
|
||||
tour: tour ?? this.tour,
|
||||
deliveries: deliveries ?? this.deliveries,
|
||||
customers: customers,
|
||||
contacts: contacts,
|
||||
articles: articles,
|
||||
warehouses: warehouses,
|
||||
notesByDeliveryId: notesByDeliveryId ?? this.notesByDeliveryId,
|
||||
creditsByDeliveryId: creditsByDeliveryId ?? this.creditsByDeliveryId,
|
||||
services: services,
|
||||
serviceValuesByDeliveryId:
|
||||
serviceValuesByDeliveryId ?? this.serviceValuesByDeliveryId,
|
||||
contactSourcesByDeliveryId: contactSourcesByDeliveryId,
|
||||
contactChannelsBySourceId: contactChannelsBySourceId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Ersetzt eine einzelne Lieferung im Aggregat. Reihenfolge bleibt erhalten.
|
||||
TourDetails replaceDelivery(Delivery updated) {
|
||||
final next = List<Delivery>.of(deliveries);
|
||||
final idx = next.indexWhere((d) => d.id == updated.id);
|
||||
if (idx == -1) return this;
|
||||
next[idx] = updated;
|
||||
return copyWith(deliveries: next);
|
||||
}
|
||||
}
|
||||
32
lib/domain/entity/warehouse.dart
Normal file
32
lib/domain/entity/warehouse.dart
Normal file
@ -0,0 +1,32 @@
|
||||
/// Lager-Standort, von dem ein `DeliveryItem` geladen wird.
|
||||
///
|
||||
/// `isStandard` markiert das Hauptlager — die App nutzt das, um in der
|
||||
/// Loading-Übersicht ein „Sonderlager"-Banner zu zeigen, sobald Items aus
|
||||
/// einem nicht-Standard-Lager kommen.
|
||||
class Warehouse {
|
||||
const Warehouse({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.code,
|
||||
required this.isStandard,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String code;
|
||||
final bool isStandard;
|
||||
|
||||
Warehouse copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? code,
|
||||
bool? isStandard,
|
||||
}) {
|
||||
return Warehouse(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
code: code ?? this.code,
|
||||
isStandard: isStandard ?? this.isStandard,
|
||||
);
|
||||
}
|
||||
}
|
||||
36
lib/domain/repository/payment_methods_repository.dart
Normal file
36
lib/domain/repository/payment_methods_repository.dart
Normal file
@ -0,0 +1,36 @@
|
||||
import 'package:hl_lieferservice/domain/entity/payment_method.dart';
|
||||
|
||||
/// Port für Zahlungsmethoden — globale Stammdaten.
|
||||
///
|
||||
/// Im Gegensatz zu `CarsRepository` keine Account-Filter: die Methoden
|
||||
/// sind firmenweit, alle Fahrer sehen dieselbe Liste.
|
||||
///
|
||||
/// Lösch-Verhalten: `delete` wirft eine `PaymentMethodsRepositoryException`
|
||||
/// mit konkretem `409`-Fall, wenn die Methode noch von Lieferungen
|
||||
/// referenziert wird (Backend hat dafür den FK-RESTRICT). Für „weiches
|
||||
/// Entfernen" gibt es `update(active: false)`.
|
||||
abstract interface class PaymentMethodsRepository {
|
||||
Future<List<PaymentMethod>> list({bool includeInactive = false});
|
||||
|
||||
Future<PaymentMethod> create({
|
||||
required String code,
|
||||
required String name,
|
||||
});
|
||||
|
||||
Future<PaymentMethod> update({
|
||||
required String id,
|
||||
String? name,
|
||||
bool? active,
|
||||
});
|
||||
|
||||
Future<void> delete(String id);
|
||||
}
|
||||
|
||||
class PaymentMethodsRepositoryException implements Exception {
|
||||
const PaymentMethodsRepositoryException(this.message, [this.cause]);
|
||||
final String message;
|
||||
final Object? cause;
|
||||
|
||||
@override
|
||||
String toString() => 'PaymentMethodsRepositoryException: $message';
|
||||
}
|
||||
210
lib/domain/repository/tour_repository.dart
Normal file
210
lib/domain/repository/tour_repository.dart
Normal file
@ -0,0 +1,210 @@
|
||||
import 'package:hl_lieferservice/domain/entity/delivery.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/delivery_credit.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/delivery_note.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/delivery_service_value.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/scan_intent.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/tour.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
|
||||
|
||||
/// Port für das Tour-Aggregat.
|
||||
///
|
||||
/// Der Port deckt in dieser Migrations-Phase nur die Read-Seite + die
|
||||
/// beiden Operationen, die zum Loading-Flow zwingend gebraucht werden:
|
||||
/// Sortierung und Fahrzeug-Zuweisung. Hold/Resume/Cancel/Complete und
|
||||
/// Notizen werden in C+D-4 nachgezogen, damit das hier nicht überladen
|
||||
/// wird und der Bloc fokussiert bleibt.
|
||||
///
|
||||
/// Account-Filter sitzt serverseitig im JWT — der Client schickt nie eine
|
||||
/// `personalnummer`/`accountId` mit.
|
||||
abstract interface class TourRepository {
|
||||
/// Die heutige Tour-Übersicht des angemeldeten Fahrers oder `null`,
|
||||
/// wenn keine Tour für heute angelegt ist (ERP-Sync noch nicht
|
||||
/// gelaufen, Treiber-Urlaub etc.).
|
||||
///
|
||||
/// Liefert nur die schlanke `TourSummary`-Repräsentation;
|
||||
/// [getTourDetails] zieht dann den vollen Aggregat-Snapshot.
|
||||
Future<TourSummary?> getMyTourSummaryOfToday();
|
||||
|
||||
/// Lädt das volle Tour-Aggregat (Tour + Lieferungen + Items +
|
||||
/// Stammdaten + Notizen) für die gegebene Tour-Id.
|
||||
Future<TourDetails> getTourDetails(String tourId);
|
||||
|
||||
/// Convenience: kombiniert [getMyTourSummaryOfToday] + [getTourDetails]
|
||||
/// und gibt `null` zurück, wenn keine Tour existiert. Verwendet die App
|
||||
/// beim Initial-Load.
|
||||
Future<TourDetails?> getMyTourDetailsOfToday();
|
||||
|
||||
/// Schreibt die Sortier-Reihenfolge der Lieferungen einer Tour neu.
|
||||
///
|
||||
/// `orderedDeliveryIds` muss **alle** Lieferungen der Tour enthalten,
|
||||
/// in der gewünschten Reihenfolge — das Backend lehnt unvollständige
|
||||
/// Listen mit `400 validation` ab.
|
||||
///
|
||||
/// Rückgabe: deliveryId → neuer sortOrder (für den Bloc-Reducer).
|
||||
Future<Map<String, int>> setDeliveryOrder({
|
||||
required String tourId,
|
||||
required List<String> orderedDeliveryIds,
|
||||
});
|
||||
|
||||
/// Weist einer Lieferung ein Fahrzeug zu. `carId == null` löst die
|
||||
/// bestehende Zuweisung. Der Server gibt die aktualisierte Delivery
|
||||
/// zurück; weil dieser Endpoint nur Stamm-Felder mutiert, ist es Aufgabe
|
||||
/// des Aufrufers, die `items` aus dem lokalen Aggregat zu erhalten.
|
||||
///
|
||||
/// Rückgabe: die Stamm-Delivery **ohne** Items — Aufrufer nutzt
|
||||
/// `copyWith(items: ...)` zum Mergen mit dem lokalen State.
|
||||
Future<Delivery> assignCarToDelivery({
|
||||
required String deliveryId,
|
||||
required String? carId,
|
||||
});
|
||||
|
||||
/// Bricht eine Lieferung ab — endgültig (`canceled`). `reason` ist
|
||||
/// vom Backend Pflicht; leere Begründungen werden mit 400 abgelehnt.
|
||||
/// Rückgabe: Server-Snapshot der Delivery **ohne** Items.
|
||||
Future<Delivery> cancelDelivery({
|
||||
required String deliveryId,
|
||||
required String reason,
|
||||
});
|
||||
|
||||
/// Pausiert eine Lieferung (`held`). Reversibel über [resumeDelivery].
|
||||
/// `reason` ist Pflicht.
|
||||
Future<Delivery> holdDelivery({
|
||||
required String deliveryId,
|
||||
required String reason,
|
||||
});
|
||||
|
||||
/// Setzt eine pausierte Lieferung auf `active` zurück. Kein Reason
|
||||
/// erforderlich.
|
||||
Future<Delivery> resumeDelivery({required String deliveryId});
|
||||
|
||||
/// Schließt eine Lieferung ab (`completed`). Lädt beide Unterschriften
|
||||
/// (Kunde + Fahrer, PNG) per multipart hoch und dokumentiert die
|
||||
/// Bestätigungen des Kunden. Atomar serverseitig — das Backend prüft
|
||||
/// vorher: Lieferung aktiv, alle scanbaren Positionen fertig, Notizen
|
||||
/// bestätigt (falls vorhanden). [paymentMethodId] persistiert die ggf. im
|
||||
/// Summary geänderte Zahlungsmethode (muss existieren + aktiv sein); `null`
|
||||
/// lässt die am Beleg hinterlegte Methode unangetastet. Rückgabe:
|
||||
/// Server-Snapshot der Delivery **ohne** Items (Aufrufer merged Items aus
|
||||
/// dem lokalen Aggregat).
|
||||
Future<Delivery> completeDelivery({
|
||||
required String deliveryId,
|
||||
required List<int> customerSignaturePng,
|
||||
required List<int> driverSignaturePng,
|
||||
required bool receiptConfirmed,
|
||||
required bool notesAcknowledged,
|
||||
required List<String> acknowledgedNoteIds,
|
||||
String? paymentMethodId,
|
||||
String? actorCarId,
|
||||
bool paymentCollected = false,
|
||||
});
|
||||
|
||||
/// Legt eine neue Notiz an einer Lieferung an.
|
||||
///
|
||||
/// Mindestens eines von [text] und [imageAttachment] muss inhaltlich
|
||||
/// gefüllt sein — das Backend erzwingt das. Aktuell unterstützt die App
|
||||
/// nur den Text-Pfad; das `imageAttachment`-Feld bleibt der zukünftigen
|
||||
/// Foto-Upload-Phase vorbehalten.
|
||||
///
|
||||
/// Rückgabe: die neu angelegte Notiz (mit Server-gesetzter `id` und
|
||||
/// `createdAt`) — der Aufrufer hängt sie an das lokale Tour-Aggregat.
|
||||
Future<DeliveryNote> addDeliveryNote({
|
||||
required String deliveryId,
|
||||
String? text,
|
||||
String? imageAttachment,
|
||||
String? creditDeliveryItemId,
|
||||
bool isAmountCreditNote,
|
||||
});
|
||||
|
||||
/// Ändert Text/Bild einer bestehenden Notiz. Mindestens eines von [text]
|
||||
/// und [imageAttachment] muss inhaltlich gefüllt sein. Rückgabe: die
|
||||
/// aktualisierte Notiz (Autor/`createdAt` bleiben).
|
||||
Future<DeliveryNote> updateDeliveryNote({
|
||||
required String deliveryId,
|
||||
required String noteId,
|
||||
String? text,
|
||||
String? imageAttachment,
|
||||
});
|
||||
|
||||
/// Löscht eine Notiz. Innerhalb des (geteilten) Accounts darf jeder Fahrer
|
||||
/// löschen — keine Autor-Prüfung serverseitig.
|
||||
Future<void> deleteDeliveryNote({
|
||||
required String deliveryId,
|
||||
required String noteId,
|
||||
});
|
||||
|
||||
/// Lädt ein Bild zu einer Lieferung hoch (multipart, Feld `file`). Das
|
||||
/// Backend reicht es an DOCUframe weiter und legt eine Bild-Notiz mit der
|
||||
/// zurückgelieferten Referenz (`~ObjectID`) als `imageAttachment` an.
|
||||
/// Rückgabe: die neue Notiz.
|
||||
Future<DeliveryNote> uploadDeliveryNoteImage({
|
||||
required String deliveryId,
|
||||
required String filename,
|
||||
required String mime,
|
||||
required List<int> bytes,
|
||||
});
|
||||
|
||||
/// Setzt/ändert die Betrags-Gutschrift einer Lieferung. Append-only +
|
||||
/// idempotent über [clientEventId]. Rückgabe: aktueller Stand (`null`, wenn
|
||||
/// — theoretisch — nichts gesetzt ist).
|
||||
Future<DeliveryCredit?> setDeliveryCredit({
|
||||
required String deliveryId,
|
||||
required String clientEventId,
|
||||
required int amountCents,
|
||||
required String reason,
|
||||
String? actorCarId,
|
||||
});
|
||||
|
||||
/// Entfernt die Betrags-Gutschrift einer Lieferung (append-only `remove`).
|
||||
/// Rückgabe: aktueller Stand danach (`null`).
|
||||
Future<DeliveryCredit?> removeDeliveryCredit({
|
||||
required String deliveryId,
|
||||
required String clientEventId,
|
||||
String? actorCarId,
|
||||
});
|
||||
|
||||
/// Setzt (Upsert) den Wert eines Service für eine Lieferung. Genau das zum
|
||||
/// Service-Typ passende Feld angeben. Rückgabe: der gespeicherte Wert.
|
||||
Future<DeliveryServiceValue> setDeliveryService({
|
||||
required String deliveryId,
|
||||
required String serviceId,
|
||||
bool? boolValue,
|
||||
int? numericValue,
|
||||
String? actorCarId,
|
||||
});
|
||||
|
||||
/// Entfernt den Service-Wert einer Lieferung (Service „nicht gesetzt").
|
||||
Future<void> removeDeliveryService({
|
||||
required String deliveryId,
|
||||
required String serviceId,
|
||||
});
|
||||
|
||||
/// Wendet eine Liste Scan-Ereignisse als Batch am Server an.
|
||||
///
|
||||
/// Der Endpoint ist bewusst Bulk: damit kann der Client einen
|
||||
/// Scanner-Burst (z. B. 5 Barcodes in 2 Sekunden) in einem HTTP-Call
|
||||
/// abschicken, **muss** aber nicht — auch ein Aufruf mit nur einem
|
||||
/// `ScanIntent` ist erlaubt.
|
||||
///
|
||||
/// Idempotenz: das Backend speichert pro `clientScanId` einmal. Wer
|
||||
/// retried, bekommt `duplicate` zurück; doppelte Anwendung kann es
|
||||
/// nicht geben.
|
||||
///
|
||||
/// Rückgabe: pro Eingabe-Intent ein [ScanOutcome] (Key =
|
||||
/// `clientScanId`). Die Map enthält **jeden** Intent, auch
|
||||
/// `rejected`-Fälle; bei Netzwerk-/Server-Fehlern wirft das Repository
|
||||
/// stattdessen [TourRepositoryException], die Map ist dann nicht
|
||||
/// teilweise gefüllt.
|
||||
Future<Map<String, ScanOutcome>> applyScans(List<ScanIntent> intents);
|
||||
}
|
||||
|
||||
/// Allgemeine Repository-Exception für Tour-Operationen. Konkrete Impls
|
||||
/// dürfen spezifischere Subtypen werfen.
|
||||
class TourRepositoryException implements Exception {
|
||||
const TourRepositoryException(this.message, [this.cause]);
|
||||
|
||||
final String message;
|
||||
final Object? cause;
|
||||
|
||||
@override
|
||||
String toString() => 'TourRepositoryException: $message';
|
||||
}
|
||||
Reference in New Issue
Block a user