Files
Holzleitner-Lieferservice-App/lib/data/repository/tour_repository_impl.dart
Dennis Nemec dd7fe9a8eb feat(zahlung): eigener Step "Zahlung" vor der Uebersicht
- Neuer Workflow-Step "Zahlung" zeigt ausgelieferte Artikel mit Menge und
  Preis, Betragsaufstellung, Zahlungsstatus und einen grossen Button
  "Zahlung abwickeln" (fest unten)
- Zahlungs-Modal: grosser offener Betrag, Methoden als Cards aus den
  Backend-Stammdaten, bei EC-Karte gross die Kundennummer; schliesst nur
  ueber Abbrechen, "Zahlung erhalten" oder "Lieferung abbrechen" (mit Grund)
- Bestaetigung wird am Server protokolliert (RecordDeliveryPayment); bis
  dahin sind "Weiter" und die Uebersicht gesperrt, ausser es ist nichts
  offen (bereits bezahlt). Aendert sich der Betrag danach, wird die Zahlung
  als veraltet markiert und muss erneut abgewickelt werden
- Uebersicht zeigt die abgewickelte Zahlung statt eigener Methodenauswahl;
  Unterschrift-Flow ohne doppelte Inkasso-Stufe; Abschluss sendet Methode
  und Inkasso-Flag der gueltigen Zahlung
- DeliveryPaymentStatus als einzige Quelle fuer offenen Betrag
  (gleiche Formel wie Backend); Artikelliste/Betragskarte als geteilte
  Widgets; Begruendungs-Dialog geteilt mit Info-Step
- API-Client aus aktueller Backend-Spec neu generiert
- Tests fuer Zahlungsstatus und Modal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:04:36 +02:00

612 lines
21 KiB
Dart

import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:holzleitner_api/holzleitner_api.dart' as api;
import 'package:hl_lieferservice/data/mapper/tour_mapper.dart';
import 'package:hl_lieferservice/domain/entity/address.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_payment.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/tour.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
import 'package:hl_lieferservice/domain/repository/tour_repository.dart';
/// Implementierung gegen den generierten Dio-Client. Übersetzt
/// `DioException` in [TourRepositoryException]. 401 lassen wir
/// ungefangen durchfliegen — der TokenProvider erkennt 401 separat
/// und meldet `AuthSessionExpired`.
class TourRepositoryImpl implements TourRepository {
TourRepositoryImpl(this._api);
final api.HolzleitnerApi _api;
@override
Future<TourSummary?> getMyTourSummary({DateTime? date}) async {
try {
final response =
await _api.getToursApi().listMyTours(date: _formatDate(date));
final tours = response.data?.tours;
if (tours == null || tours.isEmpty) return null;
// Backend liefert die Liste sortiert; wir nehmen die erste Tour.
// Der Fahrer hat aktuell nur eine Tour pro Tag — falls sich das
// ändert, wird hier eine Auswahl-UI nötig.
return tours.first.toDomain();
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Laden der Tour-Übersicht'), e);
}
}
/// Formatiert das Datum als `YYYY-MM-DD` für den `?date=`-Query. `null`
/// bleibt `null` → der Server nimmt „heute".
static String? _formatDate(DateTime? date) {
if (date == null) return null;
final y = date.year.toString().padLeft(4, '0');
final m = date.month.toString().padLeft(2, '0');
final d = date.day.toString().padLeft(2, '0');
return '$y-$m-$d';
}
@override
Future<TourDetails> getTourDetails(String tourId) async {
try {
final response = await _api.getToursApi().getTour(tourId: tourId);
final details = response.data;
if (details == null) {
throw const TourRepositoryException(
'Server lieferte leere Tour-Antwort',
);
}
return details.toDomain();
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Laden der Tour'), e);
}
}
@override
Future<TourDetails?> getMyTourDetails({DateTime? date}) async {
final summary = await getMyTourSummary(date: date);
if (summary == null) return null;
return getTourDetails(summary.tourId);
}
@override
Future<Map<String, int>> setDeliveryOrder({
required String tourId,
required List<String> orderedDeliveryIds,
}) async {
try {
final request = api.SetDeliveryOrderRequest((b) {
b.deliveryIds.replace(orderedDeliveryIds);
});
final response = await _api.getToursApi().setDeliveryOrder(
tourId: tourId,
setDeliveryOrderRequest: request,
);
final order = response.data?.order;
if (order == null) {
throw const TourRepositoryException(
'Server lieferte leere Reihenfolge-Antwort',
);
}
return {for (final e in order) e.deliveryId: e.sortOrder};
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Speichern der Reihenfolge'), e);
}
}
@override
Future<Delivery> assignCarToDelivery({
required String deliveryId,
required String? carId,
}) async {
try {
final request = api.AssignCarRequest((b) {
// `null` ⇒ Backend hebt die Zuweisung auf. built_value lässt das
// im Wire als `"carId": null` durchgehen — vom OpenAPI-Schema so
// gewollt.
b.carId = carId;
});
final response = await _api.getDeliveriesApi().assignCar(
deliveryId: deliveryId,
assignCarRequest: request,
);
final delivery = response.data?.delivery;
if (delivery == null) {
throw const TourRepositoryException(
'Server lieferte leere Delivery-Antwort',
);
}
// Achtung: Der Endpoint gibt `Delivery` *ohne* Items zurück.
// Wir bauen hier eine Domain-Delivery mit leerer Item-Liste —
// der Bloc muss die echten Items aus dem lokalen Aggregat mergen.
return Delivery(
id: delivery.id,
tourId: delivery.tourId,
customerId: delivery.customerId,
contactPersonIds: delivery.contactPersonIds.toList(growable: false),
deliveryAddressSnapshot: delivery.deliveryAddressSnapshot.toDomain(),
erpBelegartId: delivery.erpBelegartId,
erpBelegnummer: delivery.erpBelegnummer,
state: delivery.state.toDomain(),
stateReason: delivery.stateReason,
// Stamm-Endpoint kennt `sortOrder` nicht — Bloc behält den Wert.
sortOrder: 0,
assignedCarId: delivery.assignedCarId,
desiredTime: delivery.desiredTime,
specialAgreements: delivery.specialAgreements,
items: const [],
prepaidAmount: delivery.prepaidAmount,
paymentMethodId: delivery.paymentMethodId,
);
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Fahrzeug-Zuweisung'), e);
}
}
@override
Future<Delivery> cancelDelivery({
required String deliveryId,
required String reason,
}) async {
try {
final request = api.CancelDeliveryRequest((b) => b..reason = reason);
final response = await _api.getDeliveriesApi().cancel(
deliveryId: deliveryId,
cancelDeliveryRequest: request,
);
return _liftDeliveryStub(response.data?.delivery, 'Abbruch');
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Lieferung abbrechen'), e);
}
}
@override
Future<Delivery> holdDelivery({
required String deliveryId,
required String reason,
}) async {
try {
final request = api.HoldDeliveryRequest((b) => b..reason = reason);
final response = await _api.getDeliveriesApi().hold(
deliveryId: deliveryId,
holdDeliveryRequest: request,
);
return _liftDeliveryStub(response.data?.delivery, 'Pausieren');
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Lieferung pausieren'), e);
}
}
@override
Future<Delivery> resumeDelivery({required String deliveryId}) async {
try {
final response =
await _api.getDeliveriesApi().resume(deliveryId: deliveryId);
return _liftDeliveryStub(response.data?.delivery, 'Fortsetzen');
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Lieferung fortsetzen'), e);
}
}
@override
Future<Delivery> completeDelivery({
required String deliveryId,
required List<int> customerSignaturePng,
required List<int> driverSignaturePng,
required bool receiptConfirmed,
required bool notesAcknowledged,
required List<String> acknowledgedNoteIds,
String? paymentMethodId,
String? actorCarId,
bool paymentCollected = false,
}) async {
// multipart/form-data: zwei Signatur-PNGs + ein JSON-Feld mit den
// Bestätigungen. Direkt über die Dio-Instanz, weil der dart-dio-Generator
// für multipart keinen typisierten Body erzeugt (wie beim Bild-Upload).
try {
final acknowledgements = <String, dynamic>{
'receiptConfirmed': receiptConfirmed,
'notesAcknowledged': notesAcknowledged,
'acknowledgedNoteIds': acknowledgedNoteIds,
'paymentCollected': paymentCollected,
if (paymentMethodId != null) 'paymentMethodId': paymentMethodId,
if (actorCarId != null) 'authorCarId': actorCarId,
};
final form = FormData.fromMap({
'customer_signature': MultipartFile.fromBytes(
customerSignaturePng,
filename: 'customer_signature.png',
contentType: DioMediaType.parse('image/png'),
),
'driver_signature': MultipartFile.fromBytes(
driverSignaturePng,
filename: 'driver_signature.png',
contentType: DioMediaType.parse('image/png'),
),
'acknowledgements': jsonEncode(acknowledgements),
});
final response = await _api.dio.post<Map<String, dynamic>>(
'/deliveries/$deliveryId/complete',
data: form,
);
final delivery = response.data?['delivery'] as Map<String, dynamic>?;
if (delivery == null) {
throw const TourRepositoryException(
'Server lieferte leere Delivery-Antwort beim Abschließen',
);
}
return _deliveryStubFromJson(delivery);
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Lieferung abschließen'), e);
}
}
/// Hebt einen Delivery-Stub (Stamm-Endpoint-Response **ohne** Items)
/// in die Domain. Aufrufer muss anschließend `copyWith(items: ..., sortOrder: ...)`
/// aus dem lokalen Aggregat mergen — der Bloc-Handler kümmert sich darum.
Delivery _liftDeliveryStub(api.Delivery? stub, String operation) {
if (stub == null) {
throw TourRepositoryException(
'Server lieferte leere Delivery-Antwort beim $operation',
);
}
return Delivery(
id: stub.id,
tourId: stub.tourId,
customerId: stub.customerId,
contactPersonIds: stub.contactPersonIds.toList(growable: false),
deliveryAddressSnapshot: stub.deliveryAddressSnapshot.toDomain(),
erpBelegartId: stub.erpBelegartId,
erpBelegnummer: stub.erpBelegnummer,
state: stub.state.toDomain(),
stateReason: stub.stateReason,
sortOrder: 0,
assignedCarId: stub.assignedCarId,
desiredTime: stub.desiredTime,
specialAgreements: stub.specialAgreements,
items: const [],
prepaidAmount: stub.prepaidAmount,
paymentMethodId: stub.paymentMethodId,
);
}
@override
Future<DeliveryNote> addDeliveryNote({
required String deliveryId,
String? text,
String? imageAttachment,
String? creditDeliveryItemId,
bool isAmountCreditNote = false,
}) async {
try {
final request = api.CreateDeliveryNoteRequest((b) {
if (text != null) b.text = text;
if (imageAttachment != null) b.imageAttachment = imageAttachment;
if (creditDeliveryItemId != null) {
b.creditDeliveryItemId = creditDeliveryItemId;
}
b.isAmountCreditNote = isAmountCreditNote;
});
final response = await _api.getDeliveriesApi().createNote(
deliveryId: deliveryId,
createDeliveryNoteRequest: request,
);
final note = response.data?.note;
if (note == null) {
throw const TourRepositoryException(
'Server lieferte leere Notiz-Antwort',
);
}
return note.toDomain();
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Notiz anlegen'), e);
}
}
@override
Future<DeliveryNote> updateDeliveryNote({
required String deliveryId,
required String noteId,
String? text,
String? imageAttachment,
}) async {
try {
final request = api.UpdateDeliveryNoteRequest((b) {
if (text != null) b.text = text;
if (imageAttachment != null) b.imageAttachment = imageAttachment;
});
final response = await _api.getDeliveriesApi().updateNote(
deliveryId: deliveryId,
noteId: noteId,
updateDeliveryNoteRequest: request,
);
final note = response.data?.note;
if (note == null) {
throw const TourRepositoryException(
'Server lieferte leere Notiz-Antwort',
);
}
return note.toDomain();
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Notiz aktualisieren'), e);
}
}
@override
Future<void> deleteDeliveryNote({
required String deliveryId,
required String noteId,
}) async {
try {
await _api.getDeliveriesApi().deleteNote(
deliveryId: deliveryId,
noteId: noteId,
);
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Notiz löschen'), e);
}
}
@override
Future<DeliveryNote> uploadDeliveryNoteImage({
required String deliveryId,
required String filename,
required String mime,
required List<int> bytes,
}) async {
// Bewusst direkt über die Dio-Instanz statt über den generierten Client:
// der dart-dio-Generator erzeugt für multipart/form-data keinen
// typisierten Body-Parameter. Der `HolzleitnerAuthInterceptor` an der
// Dio-Instanz hängt den Bearer-Token automatisch an.
try {
final form = FormData.fromMap({
'file': MultipartFile.fromBytes(
bytes,
filename: filename,
contentType: DioMediaType.parse(mime),
),
});
final response = await _api.dio.post<Map<String, dynamic>>(
'/deliveries/$deliveryId/notes/image',
data: form,
);
final note = (response.data?['note']) as Map<String, dynamic>?;
if (note == null) {
throw const TourRepositoryException(
'Server lieferte leere Notiz-Antwort beim Bild-Upload',
);
}
return _noteFromJson(note);
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Bild hochladen'), e);
}
}
@override
Future<DeliveryCredit?> setDeliveryCredit({
required String deliveryId,
required String clientEventId,
required int amountCents,
required String reason,
String? actorCarId,
}) async {
try {
final request = api.DeliveryCreditEventRequest((b) {
b
..clientEventId = clientEventId
..action = api.CreditAction.set_
..amountCents = amountCents
..reason = reason;
if (actorCarId != null) b.authorCarId = actorCarId;
});
final response = await _api.getDeliveriesApi().applyCredit(
deliveryId: deliveryId,
deliveryCreditEventRequest: request,
);
return response.data?.credit?.toDomain();
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Gutschrift setzen'), e);
}
}
@override
Future<DeliveryCredit?> removeDeliveryCredit({
required String deliveryId,
required String clientEventId,
String? actorCarId,
}) async {
try {
final request = api.DeliveryCreditEventRequest((b) {
b
..clientEventId = clientEventId
..action = api.CreditAction.remove;
if (actorCarId != null) b.authorCarId = actorCarId;
});
final response = await _api.getDeliveriesApi().applyCredit(
deliveryId: deliveryId,
deliveryCreditEventRequest: request,
);
return response.data?.credit?.toDomain();
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Gutschrift entfernen'), e);
}
}
@override
Future<DeliveryPayment> recordDeliveryPayment({
required String deliveryId,
required String clientEventId,
required String paymentMethodId,
required int expectedAmountCents,
String? actorCarId,
}) async {
try {
final request = api.RecordDeliveryPaymentRequest((b) {
b
..clientEventId = clientEventId
..paymentMethodId = paymentMethodId
..expectedAmountCents = expectedAmountCents;
if (actorCarId != null) b.authorCarId = actorCarId;
});
final response = await _api.getDeliveriesApi().recordPayment(
deliveryId: deliveryId,
recordDeliveryPaymentRequest: request,
);
final payment = response.data?.payment;
if (payment == null) {
throw TourRepositoryException('Zahlung protokollieren: leere Antwort');
}
return payment.toDomain();
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Zahlung protokollieren'), e);
}
}
@override
Future<DeliveryServiceValue> setDeliveryService({
required String deliveryId,
required String serviceId,
bool? boolValue,
int? numericValue,
String? actorCarId,
}) async {
try {
final request = api.SetDeliveryServiceRequest((b) {
if (boolValue != null) b.boolValue = boolValue;
if (numericValue != null) b.numericValue = numericValue;
if (actorCarId != null) b.authorCarId = actorCarId;
});
final response = await _api.getDeliveriesApi().setService(
deliveryId: deliveryId,
serviceId: serviceId,
setDeliveryServiceRequest: request,
);
final value = response.data?.value;
if (value == null) {
throw const TourRepositoryException(
'Server lieferte leeren Service-Wert',
);
}
return value.toDomain();
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Service setzen'), e);
}
}
@override
Future<void> removeDeliveryService({
required String deliveryId,
required String serviceId,
}) async {
try {
await _api.getDeliveriesApi().deleteServiceValue(
deliveryId: deliveryId,
serviceId: serviceId,
);
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Service entfernen'), e);
}
}
/// Mappt das rohe Note-JSON (camelCase) der Upload-Antwort in die Domain.
/// Eigene Mini-Deserialisierung, weil dieser Pfad nicht über den
/// generierten Client (mit built_value) läuft.
/// Baut aus der rohen JSON-Map (Stamm-Endpoint-Response **ohne** Items)
/// eine Domain-Delivery. Wie [_liftDeliveryStub], aber für die direkten
/// Dio-Calls (multipart), die keinen typisierten Body liefern. Aufrufer
/// merged Items/sortOrder aus dem lokalen Aggregat.
Delivery _deliveryStubFromJson(Map<String, dynamic> j) {
final snap = j['deliveryAddressSnapshot'] as Map<String, dynamic>;
return Delivery(
id: j['id'] as String,
tourId: j['tourId'] as String,
customerId: j['customerId'] as String,
contactPersonIds:
(j['contactPersonIds'] as List).cast<String>().toList(growable: false),
deliveryAddressSnapshot: Address(
street: snap['street'] as String,
houseNumber: snap['houseNumber'] as String,
postalCode: snap['postalCode'] as String,
city: snap['city'] as String,
country: snap['country'] as String,
),
erpBelegartId: (j['erpBelegartId'] as num).toInt(),
erpBelegnummer: j['erpBelegnummer'] as String,
state: _deliveryStateFromWire(j['state'] as String),
stateReason: j['stateReason'] as String?,
sortOrder: 0,
assignedCarId: j['assignedCarId'] as String?,
desiredTime: j['desiredTime'] as String?,
specialAgreements: j['specialAgreements'] as String?,
items: const [],
prepaidAmount: (j['prepaidAmount'] as num).toDouble(),
paymentMethodId: j['paymentMethodId'] as String,
);
}
DeliveryState _deliveryStateFromWire(String value) {
switch (value) {
case 'active':
return DeliveryState.active;
case 'held':
return DeliveryState.held;
case 'canceled':
return DeliveryState.canceled;
case 'completed':
return DeliveryState.completed;
default:
throw TourRepositoryException('Unbekannter DeliveryState: $value');
}
}
DeliveryNote _noteFromJson(Map<String, dynamic> j) => DeliveryNote(
id: j['id'] as String,
deliveryId: j['deliveryId'] as String,
text: j['text'] as String?,
imageAttachment: j['imageAttachment'] as String?,
authorPersonalnummer: (j['authorPersonalnummer'] as num).toInt(),
authorCarId: j['authorCarId'] as String?,
creditDeliveryItemId: j['creditDeliveryItemId'] as String?,
isAmountCreditNote: (j['isAmountCreditNote'] as bool?) ?? false,
createdAt: DateTime.parse(j['createdAt'] as String),
);
@override
Future<Map<String, ScanOutcome>> applyScans(List<ScanIntent> intents) async {
if (intents.isEmpty) return const {};
try {
final request = api.ApplyScansRequest((b) {
b.scans.replace(intents.map((i) => i.toWire()));
});
final response = await _api.getScansApi().applyScans(
applyScansRequest: request,
);
final results = response.data?.results;
if (results == null) {
throw const TourRepositoryException(
'Server lieferte leere Scan-Antwort',
);
}
return {for (final r in results) r.clientScanId: r.toDomain()};
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Scans anwenden'), e);
}
}
String _describe(DioException e, String operation) {
final status = e.response?.statusCode;
final body = e.response?.data;
if (status == 400 && body is Map && body['message'] != null) {
return '$operation fehlgeschlagen: ${body['message']}';
}
if (status == 401) return 'Sitzung abgelaufen';
if (status == 403) return 'Keine Berechtigung';
if (status == 404) return 'Tour oder Lieferung nicht gefunden';
return '$operation fehlgeschlagen (HTTP ${status ?? 'unbekannt'})';
}
}