570 lines
20 KiB
Dart
570 lines
20 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_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?> 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<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?> getMyTourDetailsOfToday() async {
|
|
final summary = await getMyTourSummaryOfToday();
|
|
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<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'})';
|
|
}
|
|
}
|