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_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 getMyTourSummaryOfToday() async { try { final response = await _api.getToursApi().listMyToursToday(); 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); } } @override Future 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 getMyTourDetailsOfToday() async { final summary = await getMyTourSummaryOfToday(); if (summary == null) return null; return getTourDetails(summary.tourId); } @override Future> setDeliveryOrder({ required String tourId, required List 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 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 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 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 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 completeDelivery({ required String deliveryId, required List customerSignaturePng, required List driverSignaturePng, required bool receiptConfirmed, required bool notesAcknowledged, required List 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 = { '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>( '/deliveries/$deliveryId/complete', data: form, ); final delivery = response.data?['delivery'] as Map?; 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 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 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 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 uploadDeliveryNoteImage({ required String deliveryId, required String filename, required String mime, required List 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>( '/deliveries/$deliveryId/notes/image', data: form, ); final note = (response.data?['note']) as Map?; 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 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 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 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 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 j) { final snap = j['deliveryAddressSnapshot'] as Map; return Delivery( id: j['id'] as String, tourId: j['tourId'] as String, customerId: j['customerId'] as String, contactPersonIds: (j['contactPersonIds'] as List).cast().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 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> applyScans(List 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'})'; } }