Files
Holzleitner-Lieferservice-App/lib/domain/entity/tour_details.dart
Dennis Nemec dd7fe9a8eb feat(zahlung): eigener Step "Zahlung" vor der Uebersicht
- Neuer Workflow-Step "Zahlung" zeigt ausgelieferte Artikel mit Menge und
  Preis, Betragsaufstellung, Zahlungsstatus und einen grossen Button
  "Zahlung abwickeln" (fest unten)
- Zahlungs-Modal: grosser offener Betrag, Methoden als Cards aus den
  Backend-Stammdaten, bei EC-Karte gross die Kundennummer; schliesst nur
  ueber Abbrechen, "Zahlung erhalten" oder "Lieferung abbrechen" (mit Grund)
- Bestaetigung wird am Server protokolliert (RecordDeliveryPayment); bis
  dahin sind "Weiter" und die Uebersicht gesperrt, ausser es ist nichts
  offen (bereits bezahlt). Aendert sich der Betrag danach, wird die Zahlung
  als veraltet markiert und muss erneut abgewickelt werden
- Uebersicht zeigt die abgewickelte Zahlung statt eigener Methodenauswahl;
  Unterschrift-Flow ohne doppelte Inkasso-Stufe; Abschluss sendet Methode
  und Inkasso-Flag der gueltigen Zahlung
- DeliveryPaymentStatus als einzige Quelle fuer offenen Betrag
  (gleiche Formel wie Backend); Artikelliste/Betragskarte als geteilte
  Widgets; Begruendungs-Dialog geteilt mit Info-Step
- API-Client aus aktueller Backend-Spec neu generiert
- Tests fuer Zahlungsstatus und Modal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:04:36 +02:00

441 lines
18 KiB
Dart

import 'article.dart';
import 'contact_source.dart';
import 'customer.dart';
import 'delivery.dart';
import 'delivery_credit.dart';
import 'delivery_payment.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.paymentsByDeliveryId,
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;
/// Pro Lieferung die jüngste protokollierte Zahlungsabwicklung. Fehlt der
/// Eintrag, wurde für diese Lieferung noch keine Zahlung abgewickelt.
final Map<String, DeliveryPayment> paymentsByDeliveryId;
/// Aktive Service-Definitionen (Stammdaten), nach `sortOrder`. Daraus
/// rendert Phase 4 die Auswahl.
final List<Service> services;
/// 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];
/// Jüngste protokollierte Zahlung dieser Lieferung, oder `null`.
DeliveryPayment? paymentOf(String deliveryId) =>
paymentsByDeliveryId[deliveryId];
/// Gesetzter Service-Wert dieser Lieferung für einen Service, oder `null`.
DeliveryServiceValue? serviceValueOf(String deliveryId, String serviceId) =>
serviceValuesByDeliveryId[deliveryId]?[serviceId];
/// 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, DeliveryPayment>? paymentsByDeliveryId,
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,
paymentsByDeliveryId: paymentsByDeliveryId ?? this.paymentsByDeliveryId,
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);
}
}