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 deliveries; // ─── Stammdaten-Lookups (Id → Entity) ───────────────────────────────── final Map customers; final Map contacts; final Map articles; final Map 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> notesByDeliveryId; /// Pro Lieferung die aktuelle Betrags-Gutschrift (höchstens eine). Fehlt /// der Eintrag, gibt es aktuell keine Gutschrift. final Map creditsByDeliveryId; /// Aktive Service-Definitionen (Stammdaten), nach `sortOrder`. Daraus /// rendert Phase 4 die Auswahl. final List services; /// Pro Lieferung die gesetzten Service-Werte, indiziert per `serviceId`. final Map> 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> 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> 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 get deliveriesSorted { final copy = List.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 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 contactSourcesOf(Delivery delivery) => contactSourcesByDeliveryId[delivery.id] ?? const []; /// Alle Kanäle einer einzelnen Quelle. Leere Liste, wenn die Quelle nur /// einen Namensblock trägt (z. B. ein Ansprechpartner ohne Telefonnummer). List channelsOf(ContactSource source) => contactChannelsBySourceId[source.id] ?? const []; /// 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 mergedContactSourcesOf(Delivery delivery) { final sources = contactSourcesOf(delivery); if (sources.isEmpty) return const []; // Reihenfolge der Erstauftritte merken — die Backend-Sortierung // (Quellen nach Rolle aufsteigend) bestimmt damit auch die Reihenfolge // der Merge-Gruppen in der UI. final order = []; final byKey = >{}; for (final s in sources) { final key = _identityKey(s, channelsOf(s)); if (!byKey.containsKey(key)) { order.add(key); byKey[key] = []; } 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 channels) { final namePart = [ s.anrede ?? '', s.titel ?? '', s.name1 ?? '', s.name2 ?? '', s.name3 ?? '', s.abteilung ?? '', s.funktion ?? '', ].join('|'); final sortedChannels = List.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 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 notesOf(String deliveryId) => notesByDeliveryId[deliveryId] ?? const []; /// 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 get referencedAttachmentIds { final ids = {}; 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 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 _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 items})> pendingExternalWarehouseGroups(Delivery delivery) { final byWarehouseId = >{}; 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 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 externalWarehouseLabels(Delivery delivery) { final names = {}; 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 items})> itemsGroupedByWarehouse(Delivery delivery) { final byWarehouseId = >{}; // 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 items})>[]; final external = <({Warehouse warehouse, List 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? deliveries, Map>? notesByDeliveryId, Map? creditsByDeliveryId, Map>? 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.of(deliveries); final idx = next.indexWhere((d) => d.id == updated.id); if (idx == -1) return this; next[idx] = updated; return copyWith(deliveries: next); } }