Final commit.

This commit is contained in:
Dennis Nemec
2026-06-01 17:12:28 +02:00
parent 3ecbc82885
commit a9bf8ecdd1
385 changed files with 29081 additions and 12089 deletions

View 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);
}

View 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,
);
}
}

View 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(' · ');
}

View 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;
}

View 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();

View 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();
}

View 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,
);
}
}

View 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();

View 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;
}

View 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,
);
}
}

View 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,
}

View 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,
);
}
}

View 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;
}

View 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;
}

View 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);
}
}

View 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,
);
}
}