429 lines
18 KiB
Dart
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);
|
|
}
|
|
}
|