Final commit.
This commit is contained in:
428
lib/domain/entity/tour_details.dart
Normal file
428
lib/domain/entity/tour_details.dart
Normal file
@ -0,0 +1,428 @@
|
||||
import 'article.dart';
|
||||
import 'contact_source.dart';
|
||||
import 'customer.dart';
|
||||
import 'delivery.dart';
|
||||
import 'delivery_credit.dart';
|
||||
import 'delivery_item.dart';
|
||||
import 'delivery_note.dart';
|
||||
import 'delivery_service_value.dart';
|
||||
import 'service.dart';
|
||||
import 'tour.dart';
|
||||
import 'warehouse.dart';
|
||||
|
||||
/// Voll geladenes Tour-Aggregat. Enthält die Tour selbst, alle Lieferungen
|
||||
/// inkl. Items sowie *alle* Stammdaten, die von diesem Schnitt referenziert
|
||||
/// werden. Die Stammdaten kommen als Lookup-Maps statt als List, damit das
|
||||
/// UI ohne O(n)-Suchen auskommt.
|
||||
///
|
||||
/// Die Notizen sind im Backend in einer flachen Liste — wir indizieren sie
|
||||
/// hier einmal per `deliveryId`, weil das UI sie immer „pro Lieferung"
|
||||
/// braucht.
|
||||
class TourDetails {
|
||||
TourDetails({
|
||||
required this.tour,
|
||||
required this.deliveries,
|
||||
required this.customers,
|
||||
required this.contacts,
|
||||
required this.articles,
|
||||
required this.warehouses,
|
||||
required this.notesByDeliveryId,
|
||||
required this.creditsByDeliveryId,
|
||||
required this.services,
|
||||
required this.serviceValuesByDeliveryId,
|
||||
required this.contactSourcesByDeliveryId,
|
||||
required this.contactChannelsBySourceId,
|
||||
});
|
||||
|
||||
final Tour tour;
|
||||
|
||||
/// Alle Lieferungen dieser Tour. Reihenfolge: unsortiert; UI ruft
|
||||
/// `deliveriesSorted` auf, wenn Sortier-Reihenfolge benötigt wird.
|
||||
final List<Delivery> deliveries;
|
||||
|
||||
// ─── Stammdaten-Lookups (Id → Entity) ─────────────────────────────────
|
||||
|
||||
final Map<String, Customer> customers;
|
||||
final Map<String, CustomerContact> contacts;
|
||||
final Map<String, Article> articles;
|
||||
final Map<String, Warehouse> warehouses;
|
||||
|
||||
/// Pro Lieferung: alle Notizen, aufsteigend nach `createdAt`. Wenn eine
|
||||
/// Lieferung keine Notizen hat, liefert der Lookup `null` zurück — das
|
||||
/// UI muss das berücksichtigen.
|
||||
final Map<String, List<DeliveryNote>> notesByDeliveryId;
|
||||
|
||||
/// Pro Lieferung die aktuelle Betrags-Gutschrift (höchstens eine). Fehlt
|
||||
/// der Eintrag, gibt es aktuell keine Gutschrift.
|
||||
final Map<String, DeliveryCredit> creditsByDeliveryId;
|
||||
|
||||
/// Aktive Service-Definitionen (Stammdaten), nach `sortOrder`. Daraus
|
||||
/// rendert Phase 4 die Auswahl.
|
||||
final List<Service> services;
|
||||
|
||||
/// Pro Lieferung die gesetzten Service-Werte, indiziert per `serviceId`.
|
||||
final Map<String, Map<String, DeliveryServiceValue>> serviceValuesByDeliveryId;
|
||||
|
||||
/// Pro Lieferung die Adress-Quellen aus dem ERP (Belegadresse / Liefer-
|
||||
/// adresse / Rechnungsadresse / Ansprechpartner / Kundenstamm). Wird vom
|
||||
/// Sync gefüllt; leere Quellen kommen nicht durch — wer hier 0 Einträge
|
||||
/// sieht, hat im ERP keinen einzigen Kontakt am Beleg hängen.
|
||||
final Map<String, List<ContactSource>> contactSourcesByDeliveryId;
|
||||
|
||||
/// Pro Quelle alle ihre Kommunikationskanäle. Reihenfolge folgt der
|
||||
/// ERP-Position (Telefon 1 → Position 1, Telefon 2 → Position 2, …),
|
||||
/// das UI kann die Liste direkt rendern.
|
||||
final Map<String, List<ContactChannel>> contactChannelsBySourceId;
|
||||
|
||||
// ─── Convenience für UI ───────────────────────────────────────────────
|
||||
|
||||
/// Lieferungen sortiert nach `sortOrder` aufsteigend. Falls zwei
|
||||
/// Lieferungen identische Werte tragen (sollte nicht vorkommen, dient
|
||||
/// nur als Defensive), fällt der Vergleich auf die Belegnummer zurück.
|
||||
List<Delivery> get deliveriesSorted {
|
||||
final copy = List<Delivery>.of(deliveries);
|
||||
copy.sort((a, b) {
|
||||
final byOrder = a.sortOrder.compareTo(b.sortOrder);
|
||||
if (byOrder != 0) return byOrder;
|
||||
return a.erpBelegnummer.compareTo(b.erpBelegnummer);
|
||||
});
|
||||
return copy;
|
||||
}
|
||||
|
||||
Customer? customerOf(Delivery delivery) => customers[delivery.customerId];
|
||||
|
||||
Iterable<CustomerContact> contactsOf(Delivery delivery) sync* {
|
||||
for (final id in delivery.contactPersonIds) {
|
||||
final c = contacts[id];
|
||||
if (c != null) yield c;
|
||||
}
|
||||
}
|
||||
|
||||
/// Alle Adress-Quellen einer Lieferung — in der vom Backend gelieferten
|
||||
/// Reihenfolge (nach [ContactRole], anschließend nach Quell-Id für
|
||||
/// stabile UI). Leere Liste, wenn diese Lieferung im ERP keinen Kontakt
|
||||
/// hängen hat.
|
||||
List<ContactSource> contactSourcesOf(Delivery delivery) =>
|
||||
contactSourcesByDeliveryId[delivery.id] ?? const <ContactSource>[];
|
||||
|
||||
/// Alle Kanäle einer einzelnen Quelle. Leere Liste, wenn die Quelle nur
|
||||
/// einen Namensblock trägt (z. B. ein Ansprechpartner ohne Telefonnummer).
|
||||
List<ContactChannel> channelsOf(ContactSource source) =>
|
||||
contactChannelsBySourceId[source.id] ?? const <ContactChannel>[];
|
||||
|
||||
/// Wie [contactSourcesOf], aber Quellen mit identischem Namensblock UND
|
||||
/// identischer Channel-Liste sind zu einem [MergedContactSource] mit
|
||||
/// Multi-Rollen-Header zusammengeführt. Das eliminiert die typische
|
||||
/// Doppelung „Belegadresse + Kundenstamm" bei Belegen, deren
|
||||
/// `Belegkopf.AdressId` ohnehin auf die Kunden-Stammadresse zeigt.
|
||||
///
|
||||
/// Identity-Fingerprint: alle Namensfelder (Anrede / Titel / Name1..3 /
|
||||
/// Abteilung / Funktion) plus die nach (kind, position) sortierten
|
||||
/// (kind, value)-Paare. Zwei Quellen mit identischem Namen, aber
|
||||
/// abweichenden Channels werden NICHT gemerged — das wäre fachlich
|
||||
/// falsch (zwei verschiedene Kontaktdatensätze derselben Person).
|
||||
List<MergedContactSource> mergedContactSourcesOf(Delivery delivery) {
|
||||
final sources = contactSourcesOf(delivery);
|
||||
if (sources.isEmpty) return const <MergedContactSource>[];
|
||||
|
||||
// Reihenfolge der Erstauftritte merken — die Backend-Sortierung
|
||||
// (Quellen nach Rolle aufsteigend) bestimmt damit auch die Reihenfolge
|
||||
// der Merge-Gruppen in der UI.
|
||||
final order = <String>[];
|
||||
final byKey = <String, List<ContactSource>>{};
|
||||
for (final s in sources) {
|
||||
final key = _identityKey(s, channelsOf(s));
|
||||
if (!byKey.containsKey(key)) {
|
||||
order.add(key);
|
||||
byKey[key] = <ContactSource>[];
|
||||
}
|
||||
byKey[key]!.add(s);
|
||||
}
|
||||
|
||||
return [
|
||||
for (final key in order) _buildMerged(byKey[key]!),
|
||||
];
|
||||
}
|
||||
|
||||
/// Fingerprint einer Quelle: Namensblock + alle (kind, position, value)-
|
||||
/// Tripel. Vorab nach (kind-Index, position) sortiert, damit semantisch
|
||||
/// gleiche Quellen unabhängig von der Speicher-Reihenfolge denselben
|
||||
/// Schlüssel bekommen.
|
||||
String _identityKey(ContactSource s, List<ContactChannel> channels) {
|
||||
final namePart = [
|
||||
s.anrede ?? '',
|
||||
s.titel ?? '',
|
||||
s.name1 ?? '',
|
||||
s.name2 ?? '',
|
||||
s.name3 ?? '',
|
||||
s.abteilung ?? '',
|
||||
s.funktion ?? '',
|
||||
].join('|');
|
||||
final sortedChannels = List<ContactChannel>.of(channels)
|
||||
..sort((a, b) {
|
||||
final byKind = a.kind.index.compareTo(b.kind.index);
|
||||
if (byKind != 0) return byKind;
|
||||
return a.position.compareTo(b.position);
|
||||
});
|
||||
final channelPart = sortedChannels
|
||||
.map((c) => '${c.kind.name}:${c.position}:${c.value}')
|
||||
.join('|');
|
||||
return '$namePart||$channelPart';
|
||||
}
|
||||
|
||||
MergedContactSource _buildMerged(List<ContactSource> group) {
|
||||
// Namensblock + Channels von der ersten Quelle übernehmen — alle Quellen
|
||||
// in der Gruppe sind per Identity-Key garantiert deckungsgleich.
|
||||
final first = group.first;
|
||||
final roles = group.map((s) => s.role).toList()
|
||||
..sort((a, b) => a.index.compareTo(b.index));
|
||||
return MergedContactSource(
|
||||
roles: roles,
|
||||
anrede: first.anrede,
|
||||
titel: first.titel,
|
||||
name1: first.name1,
|
||||
name2: first.name2,
|
||||
name3: first.name3,
|
||||
abteilung: first.abteilung,
|
||||
funktion: first.funktion,
|
||||
channels: channelsOf(first),
|
||||
);
|
||||
}
|
||||
|
||||
Article? articleOf(String articleId) => articles[articleId];
|
||||
|
||||
Warehouse? warehouseOf(String warehouseId) => warehouses[warehouseId];
|
||||
|
||||
List<DeliveryNote> notesOf(String deliveryId) =>
|
||||
notesByDeliveryId[deliveryId] ?? const <DeliveryNote>[];
|
||||
|
||||
/// Aktuelle Betrags-Gutschrift dieser Lieferung, oder `null`.
|
||||
DeliveryCredit? creditOf(String deliveryId) =>
|
||||
creditsByDeliveryId[deliveryId];
|
||||
|
||||
/// Gesetzter Service-Wert dieser Lieferung für einen Service, oder `null`.
|
||||
DeliveryServiceValue? serviceValueOf(String deliveryId, String serviceId) =>
|
||||
serviceValuesByDeliveryId[deliveryId]?[serviceId];
|
||||
|
||||
/// Alle Attachment-IDs, die von Foto-Notizen dieser Tour referenziert
|
||||
/// werden — die Menge der „noch gültigen" Bilder. Dient dem Cache-Pruning
|
||||
/// (`AttachmentCache.retainOnly`): gecachte Vorschauen zu IDs, die hier
|
||||
/// nicht (mehr) vorkommen, gehören zu gelöschten Notizen und dürfen weg.
|
||||
Set<String> get referencedAttachmentIds {
|
||||
final ids = <String>{};
|
||||
for (final notes in notesByDeliveryId.values) {
|
||||
for (final n in notes) {
|
||||
final attachment = n.imageAttachment;
|
||||
if (attachment != null) ids.add(attachment);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
bool isArticleScannable(String articleId) =>
|
||||
articles[articleId]?.scannable ?? false;
|
||||
|
||||
/// Nicht-scanbare Positionen einer Lieferung (Dienstleistung / Pauschale /
|
||||
/// Fracht — `article.scannable == false`). Entfernte Zeilen sind hier
|
||||
/// ausgefiltert, weil eine entfernte Dienstleistung den Belade-/Anfahrt-
|
||||
/// Hinweis nicht mehr rechtfertigt.
|
||||
///
|
||||
/// Diese Positionen werden in der Beladen-Phase **nicht gescannt**, sind
|
||||
/// aber fachlich der Grund, warum eine Lieferung ohne scanbare Ware (reine
|
||||
/// Dienstleistung) trotzdem angefahren werden muss.
|
||||
Iterable<DeliveryItem> nonScannableItems(Delivery delivery) sync* {
|
||||
for (final it in delivery.items) {
|
||||
if (it.isRemoved) continue;
|
||||
if (isArticleScannable(it.articleId)) continue;
|
||||
yield it;
|
||||
}
|
||||
}
|
||||
|
||||
/// `true`, wenn die Lieferung mindestens eine nicht-scanbare Position
|
||||
/// (Dienstleistung / Pauschale) trägt — Basis für den Dienstleistungs-
|
||||
/// Hinweis in der Beladen-Ansicht.
|
||||
bool hasServiceItems(Delivery delivery) =>
|
||||
nonScannableItems(delivery).isNotEmpty;
|
||||
|
||||
// ─── Lager-Aufteilung in der Beladen-Phase ───────────────────────────
|
||||
//
|
||||
// Der Fahrer startet standardmäßig im Standardlager (`Warehouse.isStandard`).
|
||||
// Filialen werden separat angefahren — sie blockieren NICHT den Übergang
|
||||
// in die Auslieferungs-Phase. Eine Lieferung gilt deshalb als „fertig
|
||||
// beladen", sobald **alle scanbaren Standardlager-Items** durch sind;
|
||||
// Filial-Items werden in der UI sichtbar gekennzeichnet, damit der
|
||||
// Fahrer weiß, dass er noch eine zweite Station ansteuern muss.
|
||||
|
||||
bool _isStandard(String warehouseId) =>
|
||||
warehouseOf(warehouseId)?.isStandard ?? false;
|
||||
|
||||
bool _isExternal(String warehouseId) {
|
||||
final w = warehouseOf(warehouseId);
|
||||
return w != null && !w.isStandard;
|
||||
}
|
||||
|
||||
/// Iterator über die scanbaren Items einer Lieferung. `includeRemoved`
|
||||
/// kontrolliert, ob entfernte Positionen Teil der Iteration sind:
|
||||
///
|
||||
/// * `false` (default) — für Status-Berechnungen (`standardWarehouseLoadingDone`,
|
||||
/// `hasExternalWarehouseItems`, …). Entfernte Positionen blockieren
|
||||
/// sonst „Fertig"-Marker oder triggern fälschlich Filial-Hinweise.
|
||||
/// * `true` — für die UI-Anzeige (`itemsGroupedByWarehouse`), damit der
|
||||
/// Fahrer entfernte Items als durchgestrichene Zeilen weiterhin sieht
|
||||
/// und sie ggf. wiederherstellen kann.
|
||||
Iterable<DeliveryItem> _activeScannableItems(
|
||||
Delivery delivery, {
|
||||
bool includeRemoved = false,
|
||||
}) sync* {
|
||||
for (final it in delivery.items) {
|
||||
if (!includeRemoved && it.isRemoved) continue;
|
||||
if (!isArticleScannable(it.articleId)) continue;
|
||||
yield it;
|
||||
}
|
||||
}
|
||||
|
||||
/// Standardlager-Beladung dieser Lieferung ist erledigt: jedes scanbare,
|
||||
/// nicht-entfernte Item aus dem Standardlager ist `done`. Lieferungen
|
||||
/// ohne Standardlager-Items (= alles Filiale) sind trivial fertig —
|
||||
/// im Standardlager ist dann nichts zu tun.
|
||||
bool standardWarehouseLoadingDone(Delivery delivery) {
|
||||
return _activeScannableItems(delivery)
|
||||
.where((it) => _isStandard(it.warehouseId))
|
||||
.every((it) => it.isDone);
|
||||
}
|
||||
|
||||
/// Lieferung enthält mindestens ein noch relevantes Filial-Item.
|
||||
/// „Relevant" = scanbar + nicht entfernt; ob das Item schon gescannt ist
|
||||
/// oder nicht spielt für diese Markierung keine Rolle (entscheidend ist
|
||||
/// nur, dass der Fahrer ein zusätzliches Lager anfahren muss).
|
||||
bool hasExternalWarehouseItems(Delivery delivery) {
|
||||
return _activeScannableItems(delivery).any(
|
||||
(it) => _isExternal(it.warehouseId),
|
||||
);
|
||||
}
|
||||
|
||||
/// Filial-Items, die noch nicht beladen wurden — gedacht für die
|
||||
/// Auslieferungs-Übersicht: dort soll der Fahrer auf einen Blick sehen,
|
||||
/// dass er *vor* der Anfahrt zum Kunden noch ein zweites Lager ansteuern
|
||||
/// muss, und welche Artikel ihn dort erwarten.
|
||||
///
|
||||
/// Item-Filter: scanbar + nicht entfernt + Filiale + `!isDone`. Items
|
||||
/// mit Status `held` zählen ebenfalls als „nicht geholt", weil das
|
||||
/// Warenholen noch aussteht.
|
||||
///
|
||||
/// Sortierung: Lager alphabetisch, innerhalb des Lagers nach
|
||||
/// `belegzeilenNr` aufsteigend — stabile Reihenfolge zwischen Builds.
|
||||
List<({Warehouse warehouse, List<DeliveryItem> items})>
|
||||
pendingExternalWarehouseGroups(Delivery delivery) {
|
||||
final byWarehouseId = <String, List<DeliveryItem>>{};
|
||||
for (final it in _activeScannableItems(delivery)) {
|
||||
if (!_isExternal(it.warehouseId)) continue;
|
||||
if (it.isDone) continue;
|
||||
byWarehouseId.putIfAbsent(it.warehouseId, () => []).add(it);
|
||||
}
|
||||
final groups = <({Warehouse warehouse, List<DeliveryItem> items})>[];
|
||||
byWarehouseId.forEach((warehouseId, items) {
|
||||
final w = warehouseOf(warehouseId);
|
||||
if (w == null) return;
|
||||
items.sort((a, b) => a.belegzeilenNr.compareTo(b.belegzeilenNr));
|
||||
groups.add((warehouse: w, items: items));
|
||||
});
|
||||
groups.sort((a, b) => a.warehouse.name.compareTo(b.warehouse.name));
|
||||
return groups;
|
||||
}
|
||||
|
||||
/// `true`, wenn die Lieferung noch mindestens einen offenen
|
||||
/// Filial-Artikel hat (= Fahrer muss zuerst in die Filiale).
|
||||
bool hasPendingExternalWarehouseItems(Delivery delivery) {
|
||||
for (final it in _activeScannableItems(delivery)) {
|
||||
if (!_isExternal(it.warehouseId)) continue;
|
||||
if (!it.isDone) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Eindeutige Filial-Namen dieser Lieferung — für Badges /
|
||||
/// Sektions-Header in der UI. Sortiert nach Lager-Name, damit die
|
||||
/// Reihenfolge stabil bleibt zwischen Builds.
|
||||
List<String> externalWarehouseLabels(Delivery delivery) {
|
||||
final names = <String>{};
|
||||
for (final it in _activeScannableItems(delivery)) {
|
||||
if (!_isExternal(it.warehouseId)) continue;
|
||||
final w = warehouseOf(it.warehouseId);
|
||||
if (w != null) names.add(w.name);
|
||||
}
|
||||
final list = names.toList()..sort();
|
||||
return list;
|
||||
}
|
||||
|
||||
/// Gruppiert die scanbaren Items einer Lieferung nach Warehouse-Id —
|
||||
/// Standardlager-Eintrag (sofern vorhanden) immer zuerst, danach
|
||||
/// Filiale alphabetisch nach Lager-Name. Items innerhalb einer
|
||||
/// Gruppe sind nach `belegzeilenNr` aufsteigend sortiert.
|
||||
List<({Warehouse warehouse, List<DeliveryItem> items})>
|
||||
itemsGroupedByWarehouse(Delivery delivery) {
|
||||
final byWarehouseId = <String, List<DeliveryItem>>{};
|
||||
// Entfernte Items bleiben in der UI sichtbar (durchgestrichen) und
|
||||
// können dort über das Aktions-Menü wiederhergestellt werden — der
|
||||
// Status-Pfad (`standardWarehouseLoadingDone` etc.) ignoriert sie
|
||||
// trotzdem, weil die jeweiligen Helper ohne `includeRemoved` laufen.
|
||||
for (final it in _activeScannableItems(delivery, includeRemoved: true)) {
|
||||
byWarehouseId.putIfAbsent(it.warehouseId, () => []).add(it);
|
||||
}
|
||||
for (final list in byWarehouseId.values) {
|
||||
list.sort((a, b) => a.belegzeilenNr.compareTo(b.belegzeilenNr));
|
||||
}
|
||||
|
||||
// In zwei Buckets aufteilen, damit der Aufrufer Standard zuerst sieht.
|
||||
final standard = <({Warehouse warehouse, List<DeliveryItem> items})>[];
|
||||
final external = <({Warehouse warehouse, List<DeliveryItem> items})>[];
|
||||
byWarehouseId.forEach((warehouseId, items) {
|
||||
final w = warehouseOf(warehouseId);
|
||||
if (w == null) return; // Defensive: defekte Stammdaten ignorieren
|
||||
final group = (warehouse: w, items: items);
|
||||
if (w.isStandard) {
|
||||
standard.add(group);
|
||||
} else {
|
||||
external.add(group);
|
||||
}
|
||||
});
|
||||
external.sort((a, b) => a.warehouse.name.compareTo(b.warehouse.name));
|
||||
return [...standard, ...external];
|
||||
}
|
||||
|
||||
/// Neues Aggregat mit ausgetauschten/erweiterten Listen — gedacht für
|
||||
/// Bloc-Reducer (Reorder, Assign-Car etc.), die das ganze Aggregat
|
||||
/// behalten und nur ein paar Lieferungen austauschen wollen.
|
||||
TourDetails copyWith({
|
||||
Tour? tour,
|
||||
List<Delivery>? deliveries,
|
||||
Map<String, List<DeliveryNote>>? notesByDeliveryId,
|
||||
Map<String, DeliveryCredit>? creditsByDeliveryId,
|
||||
Map<String, Map<String, DeliveryServiceValue>>? serviceValuesByDeliveryId,
|
||||
}) {
|
||||
return TourDetails(
|
||||
tour: tour ?? this.tour,
|
||||
deliveries: deliveries ?? this.deliveries,
|
||||
customers: customers,
|
||||
contacts: contacts,
|
||||
articles: articles,
|
||||
warehouses: warehouses,
|
||||
notesByDeliveryId: notesByDeliveryId ?? this.notesByDeliveryId,
|
||||
creditsByDeliveryId: creditsByDeliveryId ?? this.creditsByDeliveryId,
|
||||
services: services,
|
||||
serviceValuesByDeliveryId:
|
||||
serviceValuesByDeliveryId ?? this.serviceValuesByDeliveryId,
|
||||
contactSourcesByDeliveryId: contactSourcesByDeliveryId,
|
||||
contactChannelsBySourceId: contactChannelsBySourceId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Ersetzt eine einzelne Lieferung im Aggregat. Reihenfolge bleibt erhalten.
|
||||
TourDetails replaceDelivery(Delivery updated) {
|
||||
final next = List<Delivery>.of(deliveries);
|
||||
final idx = next.indexWhere((d) => d.id == updated.id);
|
||||
if (idx == -1) return this;
|
||||
next[idx] = updated;
|
||||
return copyWith(deliveries: next);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user