Files
Holzleitner-Lieferservice-App/lib/feature/delivery/bloc/tour_bloc.dart
Dennis Nemec a9bf8ecdd1 Final commit.
2026-06-01 17:12:28 +02:00

1256 lines
43 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:uuid/uuid.dart';
import 'package:hl_lieferservice/data/cache/attachment_cache.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery_credit.dart';
import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
import 'package:hl_lieferservice/domain/entity/delivery_note.dart';
import 'package:hl_lieferservice/domain/entity/delivery_service_value.dart';
import 'package:hl_lieferservice/domain/entity/scan_intent.dart';
import 'package:hl_lieferservice/domain/entity/scan_progress.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
import 'package:hl_lieferservice/domain/repository/tour_repository.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
/// Bloc für die heutige Tour. Phase C+D-2: nur Lese-Pfad + Reorder +
/// Car-Assign. Spätere Schreib-Aktionen (Scan/Hold/Cancel/Complete/Notes)
/// landen in einem späteren Schritt **hier** und nicht in einem
/// Parallel-Bloc — der Plan ist ein Bloc pro Aggregat, nicht pro Aktion.
class TourBloc extends Bloc<TourEvent, TourState> {
TourBloc({
required this.tourRepository,
required this.opBloc,
required this.attachmentCache,
}) : super(const TourInitial()) {
on<LoadTour>(_onLoad);
on<RefreshTour>(_onRefresh);
on<ReorderDeliveries>(_onReorder);
on<AssignCarToDelivery>(_onAssignCar);
on<AssignCarToDeliveries>(_onAssignCarBulk);
on<ScanItem>(_onScanItem);
on<UnscanItem>(_onUnscanItem);
on<HoldItem>(_onHoldItem);
on<UnholdItem>(_onUnholdItem);
on<RemoveItem>(_onRemoveItem);
on<UnremoveItem>(_onUnremoveItem);
on<CancelDelivery>(_onCancelDelivery);
on<HoldDelivery>(_onHoldDelivery);
on<ResumeDelivery>(_onResumeDelivery);
on<CompleteDelivery>(_onCompleteDelivery);
on<AddDeliveryNote>(_onAddDeliveryNote);
on<UpdateDeliveryNote>(_onUpdateDeliveryNote);
on<DeleteDeliveryNote>(_onDeleteDeliveryNote);
on<UploadDeliveryNoteImage>(_onUploadDeliveryNoteImage);
on<SetDeliveryCredit>(_onSetDeliveryCredit);
on<RemoveDeliveryCredit>(_onRemoveDeliveryCredit);
on<SetDeliveryServiceValue>(_onSetDeliveryServiceValue);
on<RemoveDeliveryServiceValue>(_onRemoveDeliveryServiceValue);
}
final TourRepository tourRepository;
final OperationBloc opBloc;
/// Disk-Cache für Attachment-Vorschauen. Der Bloc nutzt ihn nur fürs
/// **Pruning**: bei jedem frischen Tour-Load wird der Cache auf die in der
/// Tour referenzierten Bild-IDs eingedampft, sodass Vorschauen gelöschter
/// Foto-Notizen lokal verschwinden.
final AttachmentCache attachmentCache;
final Uuid _uuid = const Uuid();
/// Räumt verwaiste Cache-Bilder weg, sobald frische Tour-Daten vorliegen.
/// Fire-and-forget — Cache-Pflege darf den Load nie verzögern oder
/// scheitern lassen.
void _pruneAttachmentCache(TourDetails details) {
unawaited(attachmentCache.retainOnly(details.referencedAttachmentIds));
}
// ─── LoadTour ────────────────────────────────────────────────────────
Future<void> _onLoad(LoadTour event, Emitter<TourState> emit) async {
emit(const TourLoading());
try {
final details = await tourRepository.getMyTourDetailsOfToday();
if (details == null) {
emit(const TourEmpty());
return;
}
emit(TourLoaded(details: details));
_pruneAttachmentCache(details);
} catch (e, st) {
debugPrint('TourBloc.LoadTour fehlgeschlagen: $e\n$st');
final message = _messageOf(e, 'Tour konnte nicht geladen werden');
emit(TourLoadFailed(message: message));
opBloc.add(FailOperation(message: message));
}
}
// ─── RefreshTour ─────────────────────────────────────────────────────
Future<void> _onRefresh(RefreshTour event, Emitter<TourState> emit) async {
final current = state;
if (current is! TourLoaded) {
// Keine sichtbare Tour zum „weichen" Refreshen — wie ein LoadTour
// behandeln.
await _onLoad(const LoadTour(), emit);
return;
}
emit(current.copyWith(isRefreshing: true, refreshError: null));
try {
final details = await tourRepository.getMyTourDetailsOfToday();
if (details == null) {
emit(const TourEmpty());
return;
}
emit(TourLoaded(details: details));
_pruneAttachmentCache(details);
} catch (e, st) {
debugPrint('TourBloc.RefreshTour fehlgeschlagen: $e\n$st');
final message = _messageOf(e, 'Tour konnte nicht neu geladen werden');
// alten Stand sichtbar lassen, Fehler oben mitführen
final latest = state;
if (latest is TourLoaded) {
emit(latest.copyWith(isRefreshing: false, refreshError: message));
} else {
emit(TourLoadFailed(message: message));
}
}
}
// ─── ReorderDeliveries ───────────────────────────────────────────────
Future<void> _onReorder(
ReorderDeliveries event,
Emitter<TourState> emit,
) async {
final current = state;
if (current is! TourLoaded) return;
emit(current.copyWith(isPersistingReorder: true, reorderError: null));
try {
final updated = await tourRepository.setDeliveryOrder(
tourId: current.details.tour.id,
orderedDeliveryIds: event.orderedDeliveryIds,
);
final patched = _applySortOrder(current.details, updated);
emit(TourLoaded(
details: patched,
isPersistingReorder: false,
));
} catch (e, st) {
debugPrint('TourBloc.ReorderDeliveries fehlgeschlagen: $e\n$st');
final message = _messageOf(
e,
'Reihenfolge konnte nicht gespeichert werden',
);
final latest = state;
if (latest is TourLoaded) {
emit(latest.copyWith(
isPersistingReorder: false,
reorderError: message,
));
}
opBloc.add(FailOperation(message: message));
}
}
TourDetails _applySortOrder(
TourDetails details,
Map<String, int> newOrderByDeliveryId,
) {
final next = <Delivery>[];
for (final d in details.deliveries) {
final newOrder = newOrderByDeliveryId[d.id];
next.add(newOrder == null ? d : d.copyWith(sortOrder: newOrder));
}
return details.copyWith(deliveries: next);
}
// ─── AssignCarToDelivery ─────────────────────────────────────────────
Future<void> _onAssignCar(
AssignCarToDelivery event,
Emitter<TourState> emit,
) async {
final current = state;
if (current is! TourLoaded) return;
opBloc.add(StartOperation());
try {
final remoteDelivery = await tourRepository.assignCarToDelivery(
deliveryId: event.deliveryId,
carId: event.carId,
);
// Server schickt die Delivery ohne Items zurück (Phase-1-Endpoint).
// Wir bauen die lokale Delivery so um, dass alle Stamm-Felder vom
// Server übernommen werden, Items und sortOrder aus dem lokalen
// Aggregat erhalten bleiben.
final localDelivery = current.details.deliveries
.firstWhere((d) => d.id == event.deliveryId, orElse: () => remoteDelivery);
final merged = remoteDelivery.copyWith(
sortOrder: localDelivery.sortOrder,
items: localDelivery.items,
);
emit(current.copyWith(details: current.details.replaceDelivery(merged)));
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint('TourBloc.AssignCarToDelivery fehlgeschlagen: $e\n$st');
opBloc.add(FailOperation(
message: _messageOf(e, 'Fahrzeug-Zuweisung fehlgeschlagen'),
));
}
}
// ─── AssignCarToDeliveries (Bulk) ────────────────────────────────────
/// Sequenzielle Variante von [_onAssignCar]: arbeitet die Liste der
/// Lieferungs-Ids in der gegebenen Reihenfolge ab, merged jedes
/// Server-Update mit dem Items-/sortOrder-Stand aus dem lokalen
/// Aggregat und emittiert genau **einen** State am Ende. Damit ist die
/// Aktion atomar — entweder sieht das UI alle erfolgreichen Zuweisungen
/// auf einmal, oder bei einem Fehler die bis dahin erfolgreichen
/// (Backend hat sie bereits committed) zusammen mit einer Fail-Meldung.
Future<void> _onAssignCarBulk(
AssignCarToDeliveries event,
Emitter<TourState> emit,
) async {
final current = state;
if (current is! TourLoaded) return;
if (event.deliveryIds.isEmpty) return;
opBloc.add(StartOperation());
var workingDetails = current.details;
var successCount = 0;
String? failureMessage;
for (final deliveryId in event.deliveryIds) {
try {
final remote = await tourRepository.assignCarToDelivery(
deliveryId: deliveryId,
carId: event.carId,
);
final local = workingDetails.deliveries.firstWhere(
(d) => d.id == deliveryId,
orElse: () => remote,
);
final merged = remote.copyWith(
sortOrder: local.sortOrder,
items: local.items,
);
workingDetails = workingDetails.replaceDelivery(merged);
successCount++;
} catch (e, st) {
debugPrint(
'TourBloc.AssignCarToDeliveries[$deliveryId] fehlgeschlagen: $e\n$st',
);
failureMessage = _messageOf(e, 'Fahrzeug-Zuweisung fehlgeschlagen');
break;
}
}
emit(current.copyWith(details: workingDetails));
if (failureMessage != null) {
opBloc.add(FailOperation(
message: successCount == 0
? failureMessage
: '$successCount von ${event.deliveryIds.length} Lieferungen '
'zugewiesen — $failureMessage',
));
} else {
opBloc.add(FinishOperation());
}
}
// ─── ScanItem / UnscanItem ───────────────────────────────────────────
/// Plus-Eins-Scan: lokal sofort hochzählen, anschließend Backend
/// bestätigen lassen. Bei `rejected` zurückrollen, bei `duplicate`
/// still bleiben (Server-/Client-State stimmen schon überein).
Future<void> _onScanItem(ScanItem event, Emitter<TourState> emit) async {
final current = state;
if (current is! TourLoaded) return;
final beforeItem = _findItem(current.details, event.deliveryItemId);
if (beforeItem == null) {
opBloc.add(FailOperation(
message: 'Artikel nicht in der aktuellen Tour gefunden',
));
return;
}
if (beforeItem.isDone) {
// Defensive: UI sollte „done" gar nicht zum Scan zulassen. Falls doch,
// hier still skippen statt einen rejected vom Server zu provozieren.
opBloc.add(FailOperation(message: 'Artikel ist bereits vollständig'));
return;
}
// Manuelle Bestätigung verbucht die GANZE Restmenge auf einmal; der
// reguläre (Barcode-)Scan zählt um +1 hoch.
final remaining =
beforeItem.requiredQuantity - beforeItem.scanProgress.scannedQuantity;
final delta = event.manual ? remaining : 1;
final newQuantity = beforeItem.scanProgress.scannedQuantity + delta;
// ── Optimistisches Update ──
final optimisticItem = beforeItem.copyWith(
scanProgress: beforeItem.scanProgress.copyWith(
scannedQuantity: newQuantity,
lastUpdatedAt: DateTime.now(),
status: newQuantity >= beforeItem.requiredQuantity
? ScanStatus.done
: ScanStatus.inProgress,
),
);
emit(current.copyWith(
details: _replaceItem(current.details, optimisticItem),
));
// ── Server-Apply ──
// Bei manueller Bestätigung schickt die App die Restmenge als `quantity`
// mit (Backend verbucht sie als ein Scan-Event, `manual=true`).
final intent = ScanIntent(
clientScanId: _uuid.v4(),
clientScannedAt: DateTime.now(),
deliveryItemId: event.deliveryItemId,
action: ScanAction.scan,
actorCarId: event.actorCarId,
quantity: event.manual ? remaining : null,
manual: event.manual,
);
await _applyAndReconcile(emit, intent, beforeItem);
}
Future<void> _onUnscanItem(
UnscanItem event,
Emitter<TourState> emit,
) async {
final current = state;
if (current is! TourLoaded) return;
final beforeItem = _findItem(current.details, event.deliveryItemId);
if (beforeItem == null) return;
if (beforeItem.scanProgress.scannedQuantity == 0) {
opBloc.add(FailOperation(
message: 'Artikel hat keine Scans zum Zurücknehmen',
));
return;
}
final optimisticItem = beforeItem.copyWith(
scanProgress: beforeItem.scanProgress.copyWith(
scannedQuantity: beforeItem.scanProgress.scannedQuantity - 1,
lastUpdatedAt: DateTime.now(),
status: ScanStatus.inProgress,
),
);
emit(current.copyWith(
details: _replaceItem(current.details, optimisticItem),
));
final intent = ScanIntent(
clientScanId: _uuid.v4(),
clientScannedAt: DateTime.now(),
deliveryItemId: event.deliveryItemId,
action: ScanAction.unscan,
actorCarId: event.actorCarId,
reason: event.reason,
);
await _applyAndReconcile(emit, intent, beforeItem);
}
Future<void> _onHoldItem(HoldItem event, Emitter<TourState> emit) async {
final current = state;
if (current is! TourLoaded) return;
final beforeItem = _findItem(current.details, event.deliveryItemId);
if (beforeItem == null) return;
final optimisticItem = beforeItem.copyWith(
scanProgress: beforeItem.scanProgress.copyWith(
status: ScanStatus.held,
heldReason: event.reason,
lastUpdatedAt: DateTime.now(),
),
);
emit(current.copyWith(
details: _replaceItem(current.details, optimisticItem),
));
final intent = ScanIntent(
clientScanId: _uuid.v4(),
clientScannedAt: DateTime.now(),
deliveryItemId: event.deliveryItemId,
action: ScanAction.hold,
actorCarId: event.actorCarId,
reason: event.reason,
);
await _applyAndReconcile(emit, intent, beforeItem);
}
Future<void> _onUnholdItem(
UnholdItem event,
Emitter<TourState> emit,
) async {
final current = state;
if (current is! TourLoaded) return;
final beforeItem = _findItem(current.details, event.deliveryItemId);
if (beforeItem == null) return;
// Beim Unhold lokal zurück auf in_progress; `done` wird beim nächsten
// Server-Refresh ggf. neu gesetzt, falls die scannedQuantity schon
// requiredQuantity erreicht hatte.
final optimisticItem = beforeItem.copyWith(
scanProgress: ScanProgress(
status: beforeItem.scanProgress.scannedQuantity >=
beforeItem.requiredQuantity
? ScanStatus.done
: ScanStatus.inProgress,
scannedQuantity: beforeItem.scanProgress.scannedQuantity,
lastUpdatedAt: DateTime.now(),
// heldReason explizit weglassen — Backend löscht ihn auch.
),
);
emit(current.copyWith(
details: _replaceItem(current.details, optimisticItem),
));
final intent = ScanIntent(
clientScanId: _uuid.v4(),
clientScannedAt: DateTime.now(),
deliveryItemId: event.deliveryItemId,
action: ScanAction.unhold,
actorCarId: event.actorCarId,
);
await _applyAndReconcile(emit, intent, beforeItem);
}
Future<void> _onRemoveItem(
RemoveItem event,
Emitter<TourState> emit,
) async {
final current = state;
if (current is! TourLoaded) return;
final beforeItem = _findItem(current.details, event.deliveryItemId);
if (beforeItem == null) return;
// Optimistische Mengen-Gutschrift: `quantity == null` heißt „ganze
// Restmenge". Status kippt erst auf `removed`, wenn voll gutgeschrieben.
final before = beforeItem.scanProgress;
final remaining = beforeItem.requiredQuantity - before.creditedQuantity;
final n = event.quantity ?? remaining;
final newCredited =
(before.creditedQuantity + n).clamp(0, beforeItem.requiredQuantity);
final fully = newCredited >= beforeItem.requiredQuantity;
final optimisticItem = beforeItem.copyWith(
scanProgress: before.copyWith(
creditedQuantity: newCredited,
status: fully ? ScanStatus.removed : before.status,
lastUpdatedAt: DateTime.now(),
),
);
emit(current.copyWith(
details: _replaceItem(current.details, optimisticItem),
));
final intent = ScanIntent(
clientScanId: _uuid.v4(),
clientScannedAt: DateTime.now(),
deliveryItemId: event.deliveryItemId,
action: ScanAction.remove,
actorCarId: event.actorCarId,
reason: event.reason,
quantity: event.quantity,
);
final applied = await _applyAndReconcile(emit, intent, beforeItem);
// Oberartikel voll entfernt → seine Komponenten lokal ebenfalls als
// entfernt zeigen. Das Backend hat sie in derselben Transaktion bereits
// cascade-entfernt; hier ziehen wir nur die UI nach (der Scan-Response
// liefert nur den State der einen Position). Erst nach bestätigtem
// `applied`, damit ein abgelehnter Versuch die Komponenten nicht fälschlich
// ausblendet.
if (applied && fully) {
final s = state;
if (s is TourLoaded) {
final parentNr =
s.details.articleOf(beforeItem.articleId)?.articleNumber;
Delivery? delivery;
for (final d in s.details.deliveries) {
if (d.id == beforeItem.deliveryId) {
delivery = d;
break;
}
}
if (parentNr != null && delivery != null) {
var details = s.details;
for (final it in delivery.items) {
if (it.parentArtikelNr == parentNr &&
it.belegzeilenNr == beforeItem.belegzeilenNr &&
it.scanProgress.status != ScanStatus.removed) {
details = _replaceItem(
details,
it.copyWith(
scanProgress: it.scanProgress.copyWith(
creditedQuantity: it.requiredQuantity,
status: ScanStatus.removed,
lastUpdatedAt: DateTime.now(),
),
),
);
}
}
emit(s.copyWith(details: details));
}
}
}
// Grund der Gutschrift zusätzlich als Notiz festhalten — aber nur, wenn
// das Entfernen wirklich durchging (kein Note-Eintrag für abgelehnte
// Versuche) und der Aufrufer das angefordert hat (Gutschrift-Flow).
if (applied && event.saveReasonAsNote) {
final articleName =
current.details.articleOf(beforeItem.articleId)?.name ??
'Artikel';
add(AddDeliveryNote(
deliveryId: beforeItem.deliveryId,
text: 'Gutschrift $articleName ($n×): ${event.reason}',
// Verknüpfung zur Belegzeile → beim Unremove gezielt löschbar.
creditDeliveryItemId: beforeItem.id,
));
}
}
Future<void> _onUnremoveItem(
UnremoveItem event,
Emitter<TourState> emit,
) async {
final current = state;
if (current is! TourLoaded) return;
final beforeItem = _findItem(current.details, event.deliveryItemId);
if (beforeItem == null) return;
// Optimistische Rücknahme der Gutschrift. `quantity == null` = alles
// zurück. Stand die Zeile auf `removed` (voll gutgeschrieben), kommt sie
// nach Scan-Menge zurück auf `done`/`in_progress`; bei Teil-Rücknahme
// bleibt der Status. `heldReason` wird gelöscht (analog Backend).
final before = beforeItem.scanProgress;
final n = event.quantity ?? before.creditedQuantity;
final newCredited =
(before.creditedQuantity - n).clamp(0, beforeItem.requiredQuantity);
final newStatus = before.status == ScanStatus.removed
? (before.scannedQuantity >= beforeItem.requiredQuantity
? ScanStatus.done
: ScanStatus.inProgress)
: before.status;
final optimisticItem = beforeItem.copyWith(
scanProgress: ScanProgress(
status: newStatus,
scannedQuantity: before.scannedQuantity,
creditedQuantity: newCredited,
lastUpdatedAt: DateTime.now(),
),
);
emit(current.copyWith(
details: _replaceItem(current.details, optimisticItem),
));
final intent = ScanIntent(
clientScanId: _uuid.v4(),
clientScannedAt: DateTime.now(),
deliveryItemId: event.deliveryItemId,
action: ScanAction.unremove,
actorCarId: event.actorCarId,
quantity: event.quantity,
);
final applied = await _applyAndReconcile(emit, intent, beforeItem);
// Spiegelbild zur Notiz-Erzeugung beim Entfernen: nach erfolgreicher
// Rücknahme die zugehörigen Gutschrift-Notizen (mit Bezug auf genau
// diese Belegzeile) wieder löschen. Aus der aktuellsten Notizliste
// gelesen, damit auch nach einem Tour-Reload die richtigen IDs erwischt
// werden.
if (applied) {
final latest = state;
if (latest is TourLoaded) {
final linked = latest.details
.notesOf(beforeItem.deliveryId)
.where((note) => note.creditDeliveryItemId == event.deliveryItemId)
.toList(growable: false);
for (final note in linked) {
add(DeleteDeliveryNote(
deliveryId: beforeItem.deliveryId,
noteId: note.id,
));
}
}
}
}
// ─── Betrags-Gutschrift ──────────────────────────────────────────────
/// Tauscht die Gutschrift einer Lieferung im Aggregat aus (`null` = entfernt).
TourDetails _withCredit(
TourDetails details,
String deliveryId,
DeliveryCredit? credit,
) {
final next = Map<String, DeliveryCredit>.from(details.creditsByDeliveryId);
if (credit == null) {
next.remove(deliveryId);
} else {
next[deliveryId] = credit;
}
return details.copyWith(creditsByDeliveryId: next);
}
/// Löscht alle Betrags-Gutschrift-Notizen einer Lieferung am Backend
/// (best-effort — die Notiz ist Beiwerk, die Gutschrift selbst ist führend).
Future<void> _deleteAmountCreditNotes(
String deliveryId,
Iterable<DeliveryNote> notes,
) async {
for (final note in notes.where((n) => n.isAmountCreditNote)) {
try {
await tourRepository.deleteDeliveryNote(
deliveryId: deliveryId,
noteId: note.id,
);
} catch (e, st) {
debugPrint('TourBloc: Betrags-Gutschrift-Notiz löschen fehlgeschlagen: $e\n$st');
}
}
}
/// State-Update: entfernt alle Betrags-Gutschrift-Notizen der Lieferung aus
/// der Notiz-Map und hängt optional [newNote] an. So bleibt pro Lieferung
/// genau eine aktuelle Grund-Notiz.
TourDetails _replaceAmountCreditNotes(
TourDetails details,
String deliveryId,
DeliveryNote? newNote,
) {
final nextNotes = Map<String, List<DeliveryNote>>.from(
details.notesByDeliveryId,
);
final existing = nextNotes[deliveryId] ?? const <DeliveryNote>[];
final kept = existing.where((n) => !n.isAmountCreditNote).toList();
if (newNote != null) kept.add(newNote);
nextNotes[deliveryId] = kept;
return details.copyWith(notesByDeliveryId: nextNotes);
}
Future<void> _onSetDeliveryCredit(
SetDeliveryCredit event,
Emitter<TourState> emit,
) async {
final current = state;
if (current is! TourLoaded) return;
final previous = current.details.creditOf(event.deliveryId);
// Bestehende Grund-Notizen merken (zum Ersetzen nach Erfolg).
final existingNotes = current.details.notesOf(event.deliveryId);
// Optimistisch sofort anzeigen.
emit(current.copyWith(
details: _withCredit(
current.details,
event.deliveryId,
DeliveryCredit(
deliveryId: event.deliveryId,
amountCents: event.amountCents,
reason: event.reason,
),
),
));
// Sichtbares „Request läuft"-Feedback (blockierender Spinner) + Erfolgs-/
// Fehler-SnackBar über den OperationBloc.
opBloc.add(StartOperation(message: 'Gutschrift wird gespeichert …'));
try {
final result = await tourRepository.setDeliveryCredit(
deliveryId: event.deliveryId,
clientEventId: _uuid.v4(),
amountCents: event.amountCents,
reason: event.reason,
actorCarId: event.actorCarId,
);
// Grund als Notiz festhalten (wie beim Artikel-Entfernen). Alte
// Betrags-Gutschrift-Notizen ersetzen, damit pro Lieferung genau eine
// aktuelle Grund-Notiz steht. Best-effort — schlägt es fehl, bleibt die
// Gutschrift trotzdem gesetzt.
DeliveryNote? newNote;
try {
await _deleteAmountCreditNotes(event.deliveryId, existingNotes);
newNote = await tourRepository.addDeliveryNote(
deliveryId: event.deliveryId,
text: 'Betrags-Gutschrift ${event.amountCents ~/ 100} €: ${event.reason}',
isAmountCreditNote: true,
);
} catch (e, st) {
debugPrint('TourBloc: Betrags-Gutschrift-Notiz anlegen fehlgeschlagen: $e\n$st');
}
// Server ist autoritativ — den zurückgelieferten Stand übernehmen.
final latest = state;
if (latest is TourLoaded) {
var details = _withCredit(latest.details, event.deliveryId, result);
details =
_replaceAmountCreditNotes(details, event.deliveryId, newNote);
emit(latest.copyWith(details: details));
}
opBloc.add(FinishOperation(message: 'Gutschrift gespeichert'));
} catch (e, st) {
debugPrint('TourBloc.SetDeliveryCredit fehlgeschlagen: $e\n$st');
final latest = state;
if (latest is TourLoaded) {
emit(latest.copyWith(
details: _withCredit(latest.details, event.deliveryId, previous),
));
}
opBloc.add(FailOperation(
message: _messageOf(e, 'Gutschrift konnte nicht gespeichert werden'),
));
}
}
Future<void> _onRemoveDeliveryCredit(
RemoveDeliveryCredit event,
Emitter<TourState> emit,
) async {
final current = state;
if (current is! TourLoaded) return;
final previous = current.details.creditOf(event.deliveryId);
if (previous == null) return; // nichts zu entfernen
final existingNotes = current.details.notesOf(event.deliveryId);
emit(current.copyWith(
details: _withCredit(current.details, event.deliveryId, null),
));
opBloc.add(StartOperation(message: 'Gutschrift wird entfernt …'));
try {
await tourRepository.removeDeliveryCredit(
deliveryId: event.deliveryId,
clientEventId: _uuid.v4(),
actorCarId: event.actorCarId,
);
// Spiegelbild zum Setzen: zugehörige Grund-Notiz(en) wieder löschen.
await _deleteAmountCreditNotes(event.deliveryId, existingNotes);
final latest = state;
if (latest is TourLoaded) {
emit(latest.copyWith(
details: _replaceAmountCreditNotes(
latest.details,
event.deliveryId,
null,
),
));
}
opBloc.add(FinishOperation(message: 'Gutschrift entfernt'));
} catch (e, st) {
debugPrint('TourBloc.RemoveDeliveryCredit fehlgeschlagen: $e\n$st');
final latest = state;
if (latest is TourLoaded) {
emit(latest.copyWith(
details: _withCredit(latest.details, event.deliveryId, previous),
));
}
opBloc.add(FailOperation(
message: _messageOf(e, 'Gutschrift konnte nicht entfernt werden'),
));
}
}
// ─── Services (Phase 4) ──────────────────────────────────────────────
/// Tauscht den Service-Wert einer Lieferung im Aggregat aus
/// (`null` = entfernt).
TourDetails _withServiceValue(
TourDetails details,
String deliveryId,
String serviceId,
DeliveryServiceValue? value,
) {
final outer = Map<String, Map<String, DeliveryServiceValue>>.from(
details.serviceValuesByDeliveryId,
);
final inner = Map<String, DeliveryServiceValue>.from(
outer[deliveryId] ?? const <String, DeliveryServiceValue>{},
);
if (value == null) {
inner.remove(serviceId);
} else {
inner[serviceId] = value;
}
outer[deliveryId] = inner;
return details.copyWith(serviceValuesByDeliveryId: outer);
}
Future<void> _onSetDeliveryServiceValue(
SetDeliveryServiceValue event,
Emitter<TourState> emit,
) async {
final current = state;
if (current is! TourLoaded) return;
final previous =
current.details.serviceValueOf(event.deliveryId, event.serviceId);
emit(current.copyWith(
details: _withServiceValue(
current.details,
event.deliveryId,
event.serviceId,
DeliveryServiceValue(
deliveryId: event.deliveryId,
serviceId: event.serviceId,
boolValue: event.boolValue,
numericValue: event.numericValue,
),
),
));
opBloc.add(StartOperation(message: 'Service wird gespeichert …'));
try {
final result = await tourRepository.setDeliveryService(
deliveryId: event.deliveryId,
serviceId: event.serviceId,
boolValue: event.boolValue,
numericValue: event.numericValue,
actorCarId: event.actorCarId,
);
final latest = state;
if (latest is TourLoaded) {
emit(latest.copyWith(
details: _withServiceValue(
latest.details,
event.deliveryId,
event.serviceId,
result,
),
));
}
opBloc.add(FinishOperation(message: 'Service gespeichert'));
} catch (e, st) {
debugPrint('TourBloc.SetDeliveryServiceValue fehlgeschlagen: $e\n$st');
final latest = state;
if (latest is TourLoaded) {
emit(latest.copyWith(
details: _withServiceValue(
latest.details,
event.deliveryId,
event.serviceId,
previous,
),
));
}
opBloc.add(FailOperation(
message: _messageOf(e, 'Service konnte nicht gespeichert werden'),
));
}
}
Future<void> _onRemoveDeliveryServiceValue(
RemoveDeliveryServiceValue event,
Emitter<TourState> emit,
) async {
final current = state;
if (current is! TourLoaded) return;
final previous =
current.details.serviceValueOf(event.deliveryId, event.serviceId);
if (previous == null) return;
emit(current.copyWith(
details: _withServiceValue(
current.details,
event.deliveryId,
event.serviceId,
null,
),
));
opBloc.add(StartOperation(message: 'Service wird entfernt …'));
try {
await tourRepository.removeDeliveryService(
deliveryId: event.deliveryId,
serviceId: event.serviceId,
);
opBloc.add(FinishOperation(message: 'Service entfernt'));
} catch (e, st) {
debugPrint('TourBloc.RemoveDeliveryServiceValue fehlgeschlagen: $e\n$st');
final latest = state;
if (latest is TourLoaded) {
emit(latest.copyWith(
details: _withServiceValue(
latest.details,
event.deliveryId,
event.serviceId,
previous,
),
));
}
opBloc.add(FailOperation(
message: _messageOf(e, 'Service konnte nicht entfernt werden'),
));
}
}
/// Liefert `true`, wenn der Scan serverseitig angewendet (oder als
/// `duplicate` akzeptiert) wurde, `false` bei Ablehnung/Fehler. Aufrufer,
/// die an den Erfolg eine Folgeaktion knüpfen (z. B. die Gutschrift-Notiz),
/// werten den Rückgabewert aus; die übrigen ignorieren ihn.
Future<bool> _applyAndReconcile(
Emitter<TourState> emit,
ScanIntent intent,
DeliveryItem before,
) async {
try {
final outcomes = await tourRepository.applyScans([intent]);
final outcome = outcomes[intent.clientScanId];
if (outcome == null) {
// Server hat keinen Eintrag für unseren Scan zurückgegeben —
// defensiv als rejected behandeln.
_rollback(emit, before, 'Unklare Server-Antwort');
return false;
}
switch (outcome.status) {
case ScanOutcomeStatus.applied:
case ScanOutcomeStatus.duplicate:
// Erfolg. Optimistisches Update bleibt stehen. (Bei `applied`
// könnten wir das Server-`newScanState` zurückmischen; das
// sparen wir uns, weil unsere lokale Inkrement-Logik exakt
// dem Backend-Verhalten entspricht.)
return true;
case ScanOutcomeStatus.rejected:
_rollback(emit, before, outcome.reason ?? 'Scan abgelehnt');
return false;
}
} catch (e, st) {
debugPrint('TourBloc.applyScans fehlgeschlagen: $e\n$st');
_rollback(emit, before, _messageOf(e, 'Scan fehlgeschlagen'));
return false;
}
}
void _rollback(Emitter<TourState> emit, DeliveryItem before, String reason) {
final current = state;
if (current is! TourLoaded) return;
emit(current.copyWith(
details: _replaceItem(current.details, before),
));
opBloc.add(FailOperation(message: reason));
}
// Lookups/Mutationen am Aggregat ---------------------------------------
DeliveryItem? _findItem(TourDetails details, String itemId) {
for (final d in details.deliveries) {
for (final it in d.items) {
if (it.id == itemId) return it;
}
}
return null;
}
TourDetails _replaceItem(TourDetails details, DeliveryItem updated) {
final nextDeliveries = <Delivery>[];
for (final d in details.deliveries) {
if (d.id != updated.deliveryId) {
nextDeliveries.add(d);
continue;
}
final nextItems = <DeliveryItem>[];
for (final it in d.items) {
nextItems.add(it.id == updated.id ? updated : it);
}
nextDeliveries.add(d.copyWith(items: nextItems));
}
return details.copyWith(deliveries: nextDeliveries);
}
// ─── Delivery-Lifecycle ──────────────────────────────────────────────
Future<void> _onCancelDelivery(
CancelDelivery event,
Emitter<TourState> emit,
) async {
await _runLifecycle(
emit: emit,
deliveryId: event.deliveryId,
operationLabel: 'Lieferung abbrechen',
apply: () => tourRepository.cancelDelivery(
deliveryId: event.deliveryId,
reason: event.reason,
),
);
}
Future<void> _onHoldDelivery(
HoldDelivery event,
Emitter<TourState> emit,
) async {
await _runLifecycle(
emit: emit,
deliveryId: event.deliveryId,
operationLabel: 'Lieferung pausieren',
apply: () => tourRepository.holdDelivery(
deliveryId: event.deliveryId,
reason: event.reason,
),
);
}
Future<void> _onResumeDelivery(
ResumeDelivery event,
Emitter<TourState> emit,
) async {
await _runLifecycle(
emit: emit,
deliveryId: event.deliveryId,
operationLabel: 'Lieferung fortsetzen',
apply: () =>
tourRepository.resumeDelivery(deliveryId: event.deliveryId),
);
}
Future<void> _onCompleteDelivery(
CompleteDelivery event,
Emitter<TourState> emit,
) async {
await _runLifecycle(
emit: emit,
deliveryId: event.deliveryId,
operationLabel: 'Lieferung abschließen',
apply: () => tourRepository.completeDelivery(
deliveryId: event.deliveryId,
customerSignaturePng: event.customerSignaturePng,
driverSignaturePng: event.driverSignaturePng,
receiptConfirmed: event.receiptConfirmed,
notesAcknowledged: event.notesAcknowledged,
acknowledgedNoteIds: event.acknowledgedNoteIds,
paymentMethodId: event.paymentMethodId,
actorCarId: event.actorCarId,
paymentCollected: event.paymentCollected,
),
);
}
/// Gemeinsamer Pfad für die drei Lifecycle-Operationen: globaler
/// Loading-Overlay an, Server-Call, lokales Aggregat mit der neuen
/// Stamm-Delivery mergen (Items/sortOrder aus dem aktuellen State
/// behalten), Overlay aus. Konservativ — kein optimistisches Update,
/// weil das selten genug passiert und der Fahrer eh bewusst auf den
/// Reason-Dialog reagiert.
Future<void> _runLifecycle({
required Emitter<TourState> emit,
required String deliveryId,
required String operationLabel,
required Future<Delivery> Function() apply,
}) async {
final current = state;
if (current is! TourLoaded) return;
opBloc.add(StartOperation());
try {
final remote = await apply();
final local = current.details.deliveries
.firstWhere((d) => d.id == deliveryId, orElse: () => remote);
final merged = remote.copyWith(
sortOrder: local.sortOrder,
items: local.items,
);
emit(current.copyWith(
details: current.details.replaceDelivery(merged),
));
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint('TourBloc.$operationLabel fehlgeschlagen: $e\n$st');
opBloc.add(FailOperation(
message: _messageOf(e, '$operationLabel fehlgeschlagen'),
));
}
}
// ─── AddDeliveryNote ─────────────────────────────────────────────────
/// Schreibt eine Notiz an einer Lieferung und hängt sie in das lokale
/// Tour-Aggregat ein. Bewusst kein optimistisches Update — die UI zeigt
/// einen kurzen Loader und bekommt erst danach die Notiz mit echter
/// Server-Id zurück; das vermeidet Geister-Notizen, wenn der Call kippt.
Future<void> _onAddDeliveryNote(
AddDeliveryNote event,
Emitter<TourState> emit,
) async {
final current = state;
if (current is! TourLoaded) return;
opBloc.add(StartOperation());
try {
final note = await tourRepository.addDeliveryNote(
deliveryId: event.deliveryId,
text: event.text,
imageAttachment: event.imageAttachment,
creditDeliveryItemId: event.creditDeliveryItemId,
);
final nextNotes = Map<String, List<DeliveryNote>>.from(
current.details.notesByDeliveryId,
);
final existing = nextNotes[event.deliveryId] ?? const <DeliveryNote>[];
// Backend liefert die Liste pro Lieferung nach `createdAt` sortiert —
// wir hängen die neue Notiz hinten an, weil sie als jüngste Notiz
// automatisch am Ende landet.
nextNotes[event.deliveryId] = [...existing, note];
emit(current.copyWith(
details: current.details.copyWith(notesByDeliveryId: nextNotes),
));
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint('TourBloc.AddDeliveryNote fehlgeschlagen: $e\n$st');
opBloc.add(FailOperation(
message: _messageOf(e, 'Notiz konnte nicht gespeichert werden'),
));
}
}
// ─── UpdateDeliveryNote ──────────────────────────────────────────────
Future<void> _onUpdateDeliveryNote(
UpdateDeliveryNote event,
Emitter<TourState> emit,
) async {
final current = state;
if (current is! TourLoaded) return;
opBloc.add(StartOperation());
try {
final updated = await tourRepository.updateDeliveryNote(
deliveryId: event.deliveryId,
noteId: event.noteId,
text: event.text,
imageAttachment: event.imageAttachment,
);
final nextNotes = Map<String, List<DeliveryNote>>.from(
current.details.notesByDeliveryId,
);
final list = List<DeliveryNote>.of(
nextNotes[event.deliveryId] ?? const <DeliveryNote>[],
);
final idx = list.indexWhere((n) => n.id == event.noteId);
if (idx != -1) {
list[idx] = updated;
}
nextNotes[event.deliveryId] = list;
emit(current.copyWith(
details: current.details.copyWith(notesByDeliveryId: nextNotes),
));
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint('TourBloc.UpdateDeliveryNote fehlgeschlagen: $e\n$st');
opBloc.add(FailOperation(
message: _messageOf(e, 'Notiz konnte nicht geändert werden'),
));
}
}
// ─── DeleteDeliveryNote ──────────────────────────────────────────────
Future<void> _onDeleteDeliveryNote(
DeleteDeliveryNote event,
Emitter<TourState> emit,
) async {
final current = state;
if (current is! TourLoaded) return;
opBloc.add(StartOperation());
try {
await tourRepository.deleteDeliveryNote(
deliveryId: event.deliveryId,
noteId: event.noteId,
);
final nextNotes = Map<String, List<DeliveryNote>>.from(
current.details.notesByDeliveryId,
);
final list = List<DeliveryNote>.of(
nextNotes[event.deliveryId] ?? const <DeliveryNote>[],
)..removeWhere((n) => n.id == event.noteId);
if (list.isEmpty) {
nextNotes.remove(event.deliveryId);
} else {
nextNotes[event.deliveryId] = list;
}
emit(current.copyWith(
details: current.details.copyWith(notesByDeliveryId: nextNotes),
));
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint('TourBloc.DeleteDeliveryNote fehlgeschlagen: $e\n$st');
opBloc.add(FailOperation(
message: _messageOf(e, 'Notiz konnte nicht gelöscht werden'),
));
}
}
// ─── UploadDeliveryNoteImage ─────────────────────────────────────────
Future<void> _onUploadDeliveryNoteImage(
UploadDeliveryNoteImage event,
Emitter<TourState> emit,
) async {
final current = state;
if (current is! TourLoaded) return;
opBloc.add(StartOperation());
try {
final note = await tourRepository.uploadDeliveryNoteImage(
deliveryId: event.deliveryId,
filename: event.filename,
mime: event.mime,
bytes: event.bytes,
);
final nextNotes = Map<String, List<DeliveryNote>>.from(
current.details.notesByDeliveryId,
);
final existing = nextNotes[event.deliveryId] ?? const <DeliveryNote>[];
nextNotes[event.deliveryId] = [...existing, note];
emit(current.copyWith(
details: current.details.copyWith(notesByDeliveryId: nextNotes),
));
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint('TourBloc.UploadDeliveryNoteImage fehlgeschlagen: $e\n$st');
opBloc.add(FailOperation(
message: _messageOf(e, 'Bild konnte nicht hochgeladen werden'),
));
}
}
// ─── Helpers ─────────────────────────────────────────────────────────
String _messageOf(Object e, String fallback) {
if (e is TourRepositoryException) return e.message;
return fallback;
}
}