Files
Holzleitner-Lieferservice-App/lib/domain/entity/tour_details.dart
Dennis Nemec a9bf8ecdd1 Final commit.
2026-06-01 17:12:28 +02:00

429 lines
18 KiB
Dart

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