Final commit.
This commit is contained in:
162
lib/data/cache/attachment_cache.dart
vendored
Normal file
162
lib/data/cache/attachment_cache.dart
vendored
Normal file
@ -0,0 +1,162 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// Persistenter Datei-Cache für heruntergeladene Attachment-Vorschauen.
|
||||
///
|
||||
/// **Warum überhaupt ein Cache:** Vorschaubilder werden über
|
||||
/// `GET /attachments/{id}` aus DOCUframe gerendert — das kostet Zeit und
|
||||
/// Bandbreite und funktioniert offline gar nicht. Einmal geholte Varianten
|
||||
/// landen deshalb auf der Platte und werden danach lokal bedient.
|
||||
///
|
||||
/// **Warum keine Content-Revalidierung:** Attachments sind unveränderlich.
|
||||
/// Ein hochgeladenes Bild (DOCUframe-Objekt) ändert seinen Inhalt nie. Eine
|
||||
/// einmal gecachte Variante ist daher dauerhaft gültig — kein ETag, kein
|
||||
/// If-None-Match, kein HEAD nötig. Das Einzige, was den Cache betrifft, ist
|
||||
/// das **Löschen** eines Attachments; dafür gibt es [retainOnly], das den
|
||||
/// Cache auf die Menge noch gültiger Attachment-IDs eindampft.
|
||||
///
|
||||
/// **Datei-Layout:** Pro Attachment können mehrere *Varianten* liegen
|
||||
/// (Thumbnail 600×600, Vollbild 2048×2048, …). Der Dateiname kodiert ID und
|
||||
/// Render-Parameter:
|
||||
///
|
||||
/// `{attachmentId}__{w}x{h}_q{q}_{ext}`
|
||||
///
|
||||
/// Die ID steht vorne und ist als UUID frei von `__`, sodass [retainOnly] sie
|
||||
/// zuverlässig wieder herausschneiden kann.
|
||||
///
|
||||
/// Der Cache ist durchweg **best-effort**: Lese-/Schreib-/Lösch-Fehler werden
|
||||
/// geschluckt und führen höchstens zu einem erneuten Download, nie zu einem
|
||||
/// Crash.
|
||||
class AttachmentCache {
|
||||
AttachmentCache();
|
||||
|
||||
static const _subdir = 'attachment_previews';
|
||||
static const _separator = '__';
|
||||
|
||||
/// Einmal aufgelöstes Verzeichnis — `getApplicationCacheDirectory` nicht
|
||||
/// bei jedem Zugriff neu abfragen.
|
||||
Future<Directory>? _dirFuture;
|
||||
|
||||
Future<Directory> _dir() => _dirFuture ??= _resolveDir();
|
||||
|
||||
Future<Directory> _resolveDir() async {
|
||||
final base = await getApplicationCacheDirectory();
|
||||
final dir = Directory('${base.path}/$_subdir');
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
String _fileName({
|
||||
required String attachmentId,
|
||||
required int w,
|
||||
required int h,
|
||||
required int q,
|
||||
required String ext,
|
||||
}) =>
|
||||
'$attachmentId$_separator${w}x${h}_q${q}_$ext';
|
||||
|
||||
Future<File> _file({
|
||||
required String attachmentId,
|
||||
required int w,
|
||||
required int h,
|
||||
required int q,
|
||||
required String ext,
|
||||
}) async {
|
||||
final dir = await _dir();
|
||||
return File(
|
||||
'${dir.path}/${_fileName(attachmentId: attachmentId, w: w, h: h, q: q, ext: ext)}',
|
||||
);
|
||||
}
|
||||
|
||||
/// Liest eine gecachte Variante. `null`, wenn nichts da ist oder das Lesen
|
||||
/// scheitert — der Aufrufer lädt dann frisch.
|
||||
Future<Uint8List?> read({
|
||||
required String attachmentId,
|
||||
required int w,
|
||||
required int h,
|
||||
required int q,
|
||||
required String ext,
|
||||
}) async {
|
||||
try {
|
||||
final f = await _file(
|
||||
attachmentId: attachmentId,
|
||||
w: w,
|
||||
h: h,
|
||||
q: q,
|
||||
ext: ext,
|
||||
);
|
||||
if (!await f.exists()) return null;
|
||||
final bytes = await f.readAsBytes();
|
||||
return bytes.isEmpty ? null : bytes;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Schreibt eine Variante. Atomar via temp-Datei + rename, damit ein
|
||||
/// paralleler Read nie ein halb geschriebenes File sieht. Leere Bytes
|
||||
/// werden ignoriert (kaputter Download soll keinen leeren Cache-Eintrag
|
||||
/// hinterlassen).
|
||||
Future<void> write({
|
||||
required String attachmentId,
|
||||
required int w,
|
||||
required int h,
|
||||
required int q,
|
||||
required String ext,
|
||||
required Uint8List bytes,
|
||||
}) async {
|
||||
if (bytes.isEmpty) return;
|
||||
try {
|
||||
final f = await _file(
|
||||
attachmentId: attachmentId,
|
||||
w: w,
|
||||
h: h,
|
||||
q: q,
|
||||
ext: ext,
|
||||
);
|
||||
final tmp = File('${f.path}.tmp');
|
||||
await tmp.writeAsBytes(bytes, flush: true);
|
||||
await tmp.rename(f.path);
|
||||
} catch (_) {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/// Entfernt alle gecachten Dateien, deren Attachment-ID **nicht** in
|
||||
/// [validAttachmentIds] vorkommt. So verschwinden die Vorschauen gelöschter
|
||||
/// Foto-Notizen beim nächsten Tour-Load aus dem Cache. Verwaiste
|
||||
/// temp-Dateien (abgebrochene Writes) werden immer mit entfernt.
|
||||
Future<void> retainOnly(Set<String> validAttachmentIds) async {
|
||||
try {
|
||||
final dir = await _dir();
|
||||
if (!await dir.exists()) return;
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is! File) continue;
|
||||
final name = entity.uri.pathSegments.last;
|
||||
if (name.endsWith('.tmp')) {
|
||||
await _deleteQuietly(entity);
|
||||
continue;
|
||||
}
|
||||
final sepIdx = name.indexOf(_separator);
|
||||
final id = sepIdx == -1 ? name : name.substring(0, sepIdx);
|
||||
if (!validAttachmentIds.contains(id)) {
|
||||
await _deleteQuietly(entity);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// best-effort — Pruning darf nie den Tour-Load stören
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteQuietly(File f) async {
|
||||
try {
|
||||
await f.delete();
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
408
lib/data/mapper/tour_mapper.dart
Normal file
408
lib/data/mapper/tour_mapper.dart
Normal file
@ -0,0 +1,408 @@
|
||||
import 'package:holzleitner_api/holzleitner_api.dart' as api;
|
||||
|
||||
import 'package:hl_lieferservice/domain/entity/address.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/article.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/contact_source.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/customer.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/service.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/payment_method.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.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/warehouse.dart';
|
||||
|
||||
/// Eine Schicht, ein Mapper-File: alle Übersetzungen vom generierten
|
||||
/// `built_value`-Client zur Domain. Bewusst pro DTO eine Extension, damit
|
||||
/// Aufrufer sich nicht in benamten Funktionen verlieren.
|
||||
|
||||
// ─── Primitive ────────────────────────────────────────────────────────────
|
||||
|
||||
extension ApiAddressMapper on api.Address {
|
||||
Address toDomain() => Address(
|
||||
street: street,
|
||||
houseNumber: houseNumber,
|
||||
postalCode: postalCode,
|
||||
city: city,
|
||||
country: country,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stammdaten ───────────────────────────────────────────────────────────
|
||||
|
||||
extension ApiWarehouseMapper on api.Warehouse {
|
||||
Warehouse toDomain() => Warehouse(
|
||||
id: id,
|
||||
name: name,
|
||||
code: code,
|
||||
isStandard: isStandard,
|
||||
);
|
||||
}
|
||||
|
||||
extension ApiArticleMapper on api.Article {
|
||||
Article toDomain() => Article(
|
||||
id: id,
|
||||
articleNumber: articleNumber,
|
||||
name: name,
|
||||
scannable: scannable,
|
||||
defaultWarehouseId: defaultWarehouseId,
|
||||
);
|
||||
}
|
||||
|
||||
extension ApiCustomerMapper on api.Customer {
|
||||
Customer toDomain() => Customer(
|
||||
id: id,
|
||||
name: name,
|
||||
erpCustomerId: erpCustomerId,
|
||||
address: address.toDomain(),
|
||||
);
|
||||
}
|
||||
|
||||
extension ApiCustomerContactMapper on api.CustomerContact {
|
||||
CustomerContact toDomain() => CustomerContact(
|
||||
id: id,
|
||||
customerId: customerId,
|
||||
name: name,
|
||||
phone: phone,
|
||||
email: email,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Beleg-Kontaktquellen (ContactSource / ContactChannel) ───────────────
|
||||
//
|
||||
// Der OpenAPI-Generator erzeugt für die snake-case-serde-Enums im Backend
|
||||
// `EnumClass`-Wrapper mit camelCase-Identifiern. Verglichen wird wie bei
|
||||
// `ScanStatus` per Identitäts-Check; Fallback ist ein StateError, damit
|
||||
// neue Backend-Werte sofort auffallen statt schweigend zu mappen.
|
||||
|
||||
extension ApiContactRoleMapper on api.ContactRole {
|
||||
ContactRole toDomain() {
|
||||
if (this == api.ContactRole.header) return ContactRole.header;
|
||||
if (this == api.ContactRole.delivery) return ContactRole.delivery;
|
||||
if (this == api.ContactRole.billing) return ContactRole.billing;
|
||||
if (this == api.ContactRole.contactPerson) return ContactRole.contactPerson;
|
||||
if (this == api.ContactRole.customerMaster) {
|
||||
return ContactRole.customerMaster;
|
||||
}
|
||||
throw StateError('Unbekannte ContactRole vom Backend: $this');
|
||||
}
|
||||
}
|
||||
|
||||
extension ApiContactKindMapper on api.ContactKind {
|
||||
ContactKind toDomain() {
|
||||
if (this == api.ContactKind.phone) return ContactKind.phone;
|
||||
if (this == api.ContactKind.mobile) return ContactKind.mobile;
|
||||
if (this == api.ContactKind.email) return ContactKind.email;
|
||||
if (this == api.ContactKind.web) return ContactKind.web;
|
||||
throw StateError('Unbekannter ContactKind vom Backend: $this');
|
||||
}
|
||||
}
|
||||
|
||||
extension ApiContactSourceMapper on api.ContactSource {
|
||||
ContactSource toDomain() => ContactSource(
|
||||
id: id,
|
||||
deliveryId: deliveryId,
|
||||
role: role.toDomain(),
|
||||
anrede: anrede,
|
||||
titel: titel,
|
||||
name1: name1,
|
||||
name2: name2,
|
||||
name3: name3,
|
||||
abteilung: abteilung,
|
||||
funktion: funktion,
|
||||
);
|
||||
}
|
||||
|
||||
extension ApiContactChannelMapper on api.ContactChannel {
|
||||
ContactChannel toDomain() => ContactChannel(
|
||||
id: id,
|
||||
sourceId: sourceId,
|
||||
kind: kind.toDomain(),
|
||||
position: position,
|
||||
value: value,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Scan-Progress & Delivery-Item ───────────────────────────────────────
|
||||
|
||||
extension ApiScanStateMapper on api.ScanState {
|
||||
ScanProgress toDomain() => ScanProgress(
|
||||
status: status.toDomain(),
|
||||
scannedQuantity: scannedQuantity,
|
||||
creditedQuantity: creditedQuantity,
|
||||
lastUpdatedAt: lastUpdatedAt,
|
||||
heldReason: heldReason,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Scan-Apply ──────────────────────────────────────────────────────────
|
||||
|
||||
extension DomainScanActionMapper on ScanAction {
|
||||
api.AuditAction toWire() {
|
||||
switch (this) {
|
||||
case ScanAction.scan:
|
||||
return api.AuditAction.scan;
|
||||
case ScanAction.unscan:
|
||||
return api.AuditAction.unscan;
|
||||
case ScanAction.hold:
|
||||
return api.AuditAction.hold;
|
||||
case ScanAction.unhold:
|
||||
return api.AuditAction.unhold;
|
||||
case ScanAction.remove:
|
||||
return api.AuditAction.remove;
|
||||
case ScanAction.unremove:
|
||||
return api.AuditAction.unremove;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension DomainScanIntentMapper on ScanIntent {
|
||||
api.ScanEvent toWire() => api.ScanEvent((b) {
|
||||
b
|
||||
..clientScanId = clientScanId
|
||||
..clientScannedAt = clientScannedAt.toUtc()
|
||||
..deliveryItemId = deliveryItemId
|
||||
..action = action.toWire()
|
||||
..actorCarId = actorCarId
|
||||
..reason = reason
|
||||
// Nur für remove/unremove relevant; null = ganze Restmenge.
|
||||
..quantity = quantity
|
||||
..manual = manual;
|
||||
});
|
||||
}
|
||||
|
||||
extension ApiScanResultStatusMapper on api.ScanResultStatus {
|
||||
ScanOutcomeStatus toDomain() {
|
||||
if (this == api.ScanResultStatus.applied) return ScanOutcomeStatus.applied;
|
||||
if (this == api.ScanResultStatus.duplicate) {
|
||||
return ScanOutcomeStatus.duplicate;
|
||||
}
|
||||
if (this == api.ScanResultStatus.rejected) {
|
||||
return ScanOutcomeStatus.rejected;
|
||||
}
|
||||
throw StateError('Unbekannter ScanResultStatus vom Backend: $this');
|
||||
}
|
||||
}
|
||||
|
||||
extension ApiScanResultMapper on api.ScanResult {
|
||||
ScanOutcome toDomain() => ScanOutcome(
|
||||
clientScanId: clientScanId,
|
||||
status: status.toDomain(),
|
||||
deliveryItemId: deliveryItemId,
|
||||
reason: reason,
|
||||
);
|
||||
}
|
||||
|
||||
extension ApiScanStatusMapper on api.ScanStatus {
|
||||
ScanStatus toDomain() {
|
||||
// EnumClass kennt keinen `switch`-Exhaustiveness-Check; deshalb explizit.
|
||||
if (this == api.ScanStatus.inProgress) return ScanStatus.inProgress;
|
||||
if (this == api.ScanStatus.done) return ScanStatus.done;
|
||||
if (this == api.ScanStatus.held) return ScanStatus.held;
|
||||
if (this == api.ScanStatus.removed) return ScanStatus.removed;
|
||||
throw StateError('Unbekannter ScanStatus vom Backend: $this');
|
||||
}
|
||||
}
|
||||
|
||||
extension ApiDeliveryItemMapper on api.DeliveryItem {
|
||||
DeliveryItem toDomain() => DeliveryItem(
|
||||
id: id,
|
||||
deliveryId: deliveryId,
|
||||
articleId: articleId,
|
||||
warehouseId: warehouseId,
|
||||
belegzeilenNr: belegzeilenNr,
|
||||
requiredQuantity: requiredQuantity,
|
||||
scanProgress: scanState.toDomain(),
|
||||
unitPrice: unitPrice,
|
||||
komponentenArtikelNr: komponentenArtikelNr,
|
||||
parentArtikelNr: parentArtikelNr,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Delivery ────────────────────────────────────────────────────────────
|
||||
|
||||
extension ApiDeliveryStateMapper on api.DeliveryState {
|
||||
DeliveryState toDomain() {
|
||||
if (this == api.DeliveryState.active) return DeliveryState.active;
|
||||
if (this == api.DeliveryState.held) return DeliveryState.held;
|
||||
if (this == api.DeliveryState.canceled) return DeliveryState.canceled;
|
||||
if (this == api.DeliveryState.completed) return DeliveryState.completed;
|
||||
throw StateError('Unbekannter DeliveryState vom Backend: $this');
|
||||
}
|
||||
}
|
||||
|
||||
extension ApiDeliveryWithItemsMapper on api.DeliveryWithItems {
|
||||
Delivery toDomain() => Delivery(
|
||||
id: id,
|
||||
tourId: tourId,
|
||||
customerId: customerId,
|
||||
contactPersonIds: contactPersonIds.toList(growable: false),
|
||||
deliveryAddressSnapshot: deliveryAddressSnapshot.toDomain(),
|
||||
erpBelegartId: erpBelegartId,
|
||||
erpBelegnummer: erpBelegnummer,
|
||||
state: state.toDomain(),
|
||||
stateReason: stateReason,
|
||||
sortOrder: sortOrder,
|
||||
assignedCarId: assignedCarId,
|
||||
desiredTime: desiredTime,
|
||||
specialAgreements: specialAgreements,
|
||||
items: items.map((it) => it.toDomain()).toList(growable: false),
|
||||
prepaidAmount: prepaidAmount,
|
||||
paymentMethodId: paymentMethodId,
|
||||
);
|
||||
}
|
||||
|
||||
extension ApiPaymentMethodMapper on api.PaymentMethod {
|
||||
PaymentMethod toDomain() => PaymentMethod(
|
||||
id: id,
|
||||
code: code,
|
||||
name: name,
|
||||
active: active,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tour-Notiz ──────────────────────────────────────────────────────────
|
||||
|
||||
extension ApiDeliveryNoteMapper on api.DeliveryNote {
|
||||
DeliveryNote toDomain() => DeliveryNote(
|
||||
id: id,
|
||||
deliveryId: deliveryId,
|
||||
text: text,
|
||||
imageAttachment: imageAttachment,
|
||||
authorPersonalnummer: authorPersonalnummer,
|
||||
authorCarId: authorCarId,
|
||||
creditDeliveryItemId: creditDeliveryItemId,
|
||||
isAmountCreditNote: isAmountCreditNote,
|
||||
imageAttachmentDeleted: imageAttachmentDeleted ?? false,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tour-Wurzel ─────────────────────────────────────────────────────────
|
||||
|
||||
extension ApiTourMapper on api.Tour {
|
||||
Tour toDomain() => Tour(
|
||||
id: id,
|
||||
accountId: accountId,
|
||||
date: date.toDateTime(),
|
||||
syncedAt: syncedAt,
|
||||
);
|
||||
}
|
||||
|
||||
extension ApiTourSummaryMapper on api.TourSummary {
|
||||
TourSummary toDomain() => TourSummary(
|
||||
tourId: tourId,
|
||||
tourDate: tourDate.toDateTime(),
|
||||
deliveryCount: deliveryCount,
|
||||
);
|
||||
}
|
||||
|
||||
extension ApiTourDetailsMapper on api.TourDetails {
|
||||
TourDetails toDomain() {
|
||||
final customersMap = <String, Customer>{
|
||||
for (final c in customers) c.id: c.toDomain(),
|
||||
};
|
||||
final contactsMap = <String, CustomerContact>{
|
||||
for (final c in customerContacts) c.id: c.toDomain(),
|
||||
};
|
||||
final articlesMap = <String, Article>{
|
||||
for (final a in articles) a.id: a.toDomain(),
|
||||
};
|
||||
final warehousesMap = <String, Warehouse>{
|
||||
for (final w in warehouses) w.id: w.toDomain(),
|
||||
};
|
||||
|
||||
// Notizen sind im Wire flach — pro Lieferung indizieren und aufsteigend
|
||||
// nach createdAt sortieren, damit das UI sich nicht jedes Mal selbst
|
||||
// sortieren muss.
|
||||
final notesGrouped = <String, List<DeliveryNote>>{};
|
||||
for (final n in notes) {
|
||||
final domain = n.toDomain();
|
||||
(notesGrouped[domain.deliveryId] ??= <DeliveryNote>[]).add(domain);
|
||||
}
|
||||
for (final list in notesGrouped.values) {
|
||||
list.sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
}
|
||||
|
||||
// Gutschriften: höchstens eine pro Lieferung (aktueller Stand).
|
||||
final creditsMap = <String, DeliveryCredit>{
|
||||
for (final c in credits) c.deliveryId: c.toDomain(),
|
||||
};
|
||||
|
||||
// Service-Definitionen (aktiv, sortiert) + Pro-Lieferung-Werte indizieren.
|
||||
final servicesList =
|
||||
services.map((s) => s.toDomain()).toList(growable: false);
|
||||
final serviceValues = <String, Map<String, DeliveryServiceValue>>{};
|
||||
for (final v in deliveryServices) {
|
||||
(serviceValues[v.deliveryId] ??= <String, DeliveryServiceValue>{})[
|
||||
v.serviceId] = v.toDomain();
|
||||
}
|
||||
|
||||
// Kontaktquellen pro Lieferung gruppieren; Kanäle pro Quelle gruppieren.
|
||||
// Backend liefert sie sortiert (Quellen nach Rolle, Kanäle nach kind +
|
||||
// position) — wir behalten die Reihenfolge bei.
|
||||
final sourcesGrouped = <String, List<ContactSource>>{};
|
||||
for (final s in contactSources) {
|
||||
final domain = s.toDomain();
|
||||
(sourcesGrouped[domain.deliveryId] ??= <ContactSource>[]).add(domain);
|
||||
}
|
||||
final channelsGrouped = <String, List<ContactChannel>>{};
|
||||
for (final c in contactChannels) {
|
||||
final domain = c.toDomain();
|
||||
(channelsGrouped[domain.sourceId] ??= <ContactChannel>[]).add(domain);
|
||||
}
|
||||
|
||||
return TourDetails(
|
||||
tour: tour.toDomain(),
|
||||
deliveries: deliveries.map((d) => d.toDomain()).toList(growable: false),
|
||||
customers: customersMap,
|
||||
contacts: contactsMap,
|
||||
articles: articlesMap,
|
||||
warehouses: warehousesMap,
|
||||
notesByDeliveryId: notesGrouped,
|
||||
creditsByDeliveryId: creditsMap,
|
||||
services: servicesList,
|
||||
serviceValuesByDeliveryId: serviceValues,
|
||||
contactSourcesByDeliveryId: sourcesGrouped,
|
||||
contactChannelsBySourceId: channelsGrouped,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension ApiServiceMapper on api.Service {
|
||||
Service toDomain() => Service(
|
||||
id: id,
|
||||
key: key,
|
||||
name: name,
|
||||
kind: kind == api.ServiceKind.numeric
|
||||
? ServiceKind.numeric
|
||||
: ServiceKind.boolean,
|
||||
active: active,
|
||||
sortOrder: sortOrder,
|
||||
minValue: minValue,
|
||||
maxValue: maxValue,
|
||||
);
|
||||
}
|
||||
|
||||
extension ApiDeliveryServiceValueMapper on api.DeliveryServiceValue {
|
||||
DeliveryServiceValue toDomain() => DeliveryServiceValue(
|
||||
deliveryId: deliveryId,
|
||||
serviceId: serviceId,
|
||||
boolValue: boolValue,
|
||||
numericValue: numericValue,
|
||||
);
|
||||
}
|
||||
|
||||
extension ApiDeliveryCreditMapper on api.DeliveryCredit {
|
||||
DeliveryCredit toDomain() => DeliveryCredit(
|
||||
deliveryId: deliveryId,
|
||||
amountCents: amountCents,
|
||||
reason: reason,
|
||||
);
|
||||
}
|
||||
@ -53,4 +53,44 @@ class BackendConfig {
|
||||
keycloakClientId: 'holzleitner-app',
|
||||
keycloakRedirectUrl: 'holzleitner://oauth2redirect',
|
||||
);
|
||||
|
||||
/// Konfiguration für USB-Tunnel via `adb reverse` — gedacht für Tests in
|
||||
/// fremden Netzwerken, in denen das Gerät den Mac nicht über eine LAN-IP
|
||||
/// erreicht. Alles zeigt auf `localhost`; der Traffic wird über den
|
||||
/// USB-Bus zum Host getunnelt.
|
||||
///
|
||||
/// **Setup vor dem Start (Gerät per USB angesteckt):**
|
||||
/// ```
|
||||
/// adb reverse tcp:3000 tcp:3000 # Rust-API
|
||||
/// adb reverse tcp:8080 tcp:8080 # Keycloak
|
||||
/// ```
|
||||
///
|
||||
/// **Backend-Voraussetzungen**, damit das OIDC-Login funktioniert:
|
||||
/// * Backend-Env `KEYCLOAK_ISSUER_URL=http://localhost:8080/realms/holzleitner`
|
||||
/// (muss exakt mit [keycloakIssuerUrl] matchen, sonst 401 `invalid issuer`).
|
||||
/// * Keycloak muss den Issuer als `localhost` ausgeben — z. B. via
|
||||
/// `KC_HOSTNAME_URL=http://localhost:8080` (oder Frontend-URL im Realm),
|
||||
/// sonst prägt es den Container-Hostnamen ins `iss`-Claim.
|
||||
/// * Der `holzleitner://oauth2redirect`-Redirect bleibt unverändert (das
|
||||
/// Custom-Scheme ist netzwerk-unabhängig).
|
||||
///
|
||||
/// Aktivieren ohne Code-Edit:
|
||||
/// ```
|
||||
/// flutter run --dart-define=HL_BACKEND=usb
|
||||
/// ```
|
||||
static const BackendConfig usbReverse = BackendConfig(
|
||||
apiBaseUrl: 'http://localhost:3000',
|
||||
keycloakIssuerUrl: 'http://localhost:8080/realms/holzleitner',
|
||||
keycloakClientId: 'holzleitner-app',
|
||||
keycloakRedirectUrl: 'holzleitner://oauth2redirect',
|
||||
);
|
||||
|
||||
/// Wählt die Config anhand des Compile-Time-Flags `HL_BACKEND`:
|
||||
/// * `usb` → [usbReverse] (adb-reverse-Tunnel über localhost)
|
||||
/// * sonst → [localDev] (LAN-IP, Default)
|
||||
///
|
||||
/// So muss für einen Netzwerkwechsel nur das Build-Flag gesetzt werden,
|
||||
/// nicht der Quellcode angefasst.
|
||||
static const BackendConfig fromEnvironment =
|
||||
String.fromEnvironment('HL_BACKEND') == 'usb' ? usbReverse : localDev;
|
||||
}
|
||||
|
||||
@ -55,6 +55,13 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
|
||||
String? _refreshToken;
|
||||
Map<String, dynamic>? _idTokenClaims;
|
||||
|
||||
/// Single-flight-Guard: hält den gerade laufenden Refresh, damit mehrere
|
||||
/// gleichzeitige Aufrufer (Bootstrap: Restore + PaymentMethodsCubit +
|
||||
/// Folge-Requests) sich EINEN Refresh teilen statt parallele
|
||||
/// `flutter_appauth.token()`-Calls auszulösen (die nativ blockieren/haken
|
||||
/// können → App hängt nach Hot-Restart am Splash/Login).
|
||||
Future<String?>? _refreshInFlight;
|
||||
|
||||
final StreamController<AuthSessionEvent> _events =
|
||||
StreamController<AuthSessionEvent>.broadcast();
|
||||
|
||||
@ -166,6 +173,19 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
|
||||
final rt = _refreshToken;
|
||||
if (rt == null) return null;
|
||||
|
||||
// Single-flight: läuft bereits ein Refresh, hängen wir uns dran, statt
|
||||
// einen zweiten `flutter_appauth.token()`-Call zu starten. `??=`
|
||||
// evaluiert die rechte Seite nur, wenn noch kein Refresh läuft.
|
||||
return _refreshInFlight ??= _performRefresh(rt).whenComplete(() {
|
||||
_refreshInFlight = null;
|
||||
});
|
||||
}
|
||||
|
||||
/// Führt EINEN Token-Refresh aus. Bei Erfolg werden die Tokens übernommen
|
||||
/// und der neue Access-Token zurückgegeben (ohne Event — stiller Refresh).
|
||||
/// Bei Fehler ist die Session tot: lokal aufräumen, `AuthSessionExpired`
|
||||
/// emittieren, `null` zurück.
|
||||
Future<String?> _performRefresh(String rt) async {
|
||||
try {
|
||||
final result = await _appAuth.token(
|
||||
TokenRequest(
|
||||
@ -187,11 +207,17 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
|
||||
return _accessToken;
|
||||
} on Exception {
|
||||
// Refresh hat nicht funktioniert — Session ist tot, nicht
|
||||
// wiederherstellbar. Aufrufer kriegen null zurück, AuthBloc
|
||||
// bekommt SessionExpired.
|
||||
// wiederherstellbar. Reihenfolge bewusst: erst State leeren + Event
|
||||
// feuern, DANN best-effort den Storage löschen — so kann ein
|
||||
// werfendes `delete` weder das Event verschlucken noch eine Exception
|
||||
// aus `currentAccessToken()` leaken.
|
||||
_clearSession();
|
||||
await _storage.delete(key: _refreshTokenStorageKey);
|
||||
_events.add(const AuthSessionExpired());
|
||||
try {
|
||||
await _storage.delete(key: _refreshTokenStorageKey);
|
||||
} catch (e) {
|
||||
debugPrint('currentAccessToken: Refresh-Token-Delete fehlgeschlagen: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ import 'keycloak_oidc_token_provider.dart';
|
||||
/// das reine dart-Smoke-Tool, siehe `tool/smoke_test_api.dart`).
|
||||
void registerNetworking({
|
||||
required GetIt locator,
|
||||
BackendConfig config = BackendConfig.localDev,
|
||||
BackendConfig config = BackendConfig.fromEnvironment,
|
||||
}) {
|
||||
locator.registerSingleton<BackendConfig>(config);
|
||||
|
||||
|
||||
125
lib/data/repository/payment_methods_repository_impl.dart
Normal file
125
lib/data/repository/payment_methods_repository_impl.dart
Normal file
@ -0,0 +1,125 @@
|
||||
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/payment_method.dart';
|
||||
import 'package:hl_lieferservice/domain/repository/payment_methods_repository.dart';
|
||||
|
||||
/// Dio-Impl gegen den generierten `PaymentMethodsApi`.
|
||||
///
|
||||
/// Fehler-Mapping:
|
||||
/// * `409 Conflict` (UNIQUE-Verletzung oder FK-RESTRICT beim Löschen)
|
||||
/// → `PaymentMethodsRepositoryException` mit klarer Meldung.
|
||||
/// * `404` → dito (NotFound-Hinweis im Text).
|
||||
/// * `401` lassen wir ungefangen durchfliegen — globaler Auth-Handler
|
||||
/// übernimmt.
|
||||
class PaymentMethodsRepositoryImpl implements PaymentMethodsRepository {
|
||||
PaymentMethodsRepositoryImpl(this._api);
|
||||
|
||||
final api.HolzleitnerApi _api;
|
||||
|
||||
@override
|
||||
Future<List<PaymentMethod>> list({bool includeInactive = false}) async {
|
||||
try {
|
||||
final response = await _api
|
||||
.getPaymentMethodsApi()
|
||||
.listPaymentMethods(includeInactive: includeInactive);
|
||||
final methods = response.data?.methods;
|
||||
if (methods == null) return const [];
|
||||
return methods.map((m) => m.toDomain()).toList(growable: false);
|
||||
} on DioException catch (e) {
|
||||
throw PaymentMethodsRepositoryException(
|
||||
_describe(e, 'Laden der Zahlungsmethoden'),
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PaymentMethod> create({
|
||||
required String code,
|
||||
required String name,
|
||||
}) async {
|
||||
try {
|
||||
final request = api.CreatePaymentMethodRequest((b) {
|
||||
b
|
||||
..code = code
|
||||
..name = name;
|
||||
});
|
||||
final response = await _api
|
||||
.getPaymentMethodsApi()
|
||||
.createPaymentMethod(createPaymentMethodRequest: request);
|
||||
final method = response.data?.method;
|
||||
if (method == null) {
|
||||
throw const PaymentMethodsRepositoryException(
|
||||
'Server lieferte leere Antwort beim Anlegen',
|
||||
);
|
||||
}
|
||||
return method.toDomain();
|
||||
} on DioException catch (e) {
|
||||
throw PaymentMethodsRepositoryException(
|
||||
_describe(e, 'Anlegen einer Zahlungsmethode'),
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PaymentMethod> update({
|
||||
required String id,
|
||||
String? name,
|
||||
bool? active,
|
||||
}) async {
|
||||
try {
|
||||
final request = api.UpdatePaymentMethodRequest((b) {
|
||||
if (name != null) b.name = name;
|
||||
if (active != null) b.active = active;
|
||||
});
|
||||
final response =
|
||||
await _api.getPaymentMethodsApi().updatePaymentMethod(
|
||||
id: id,
|
||||
updatePaymentMethodRequest: request,
|
||||
);
|
||||
final method = response.data?.method;
|
||||
if (method == null) {
|
||||
throw const PaymentMethodsRepositoryException(
|
||||
'Server lieferte leere Antwort beim Aktualisieren',
|
||||
);
|
||||
}
|
||||
return method.toDomain();
|
||||
} on DioException catch (e) {
|
||||
throw PaymentMethodsRepositoryException(
|
||||
_describe(e, 'Aktualisieren einer Zahlungsmethode'),
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String id) async {
|
||||
try {
|
||||
await _api.getPaymentMethodsApi().deletePaymentMethod(id: id);
|
||||
} on DioException catch (e) {
|
||||
throw PaymentMethodsRepositoryException(
|
||||
_describe(e, 'Löschen einer Zahlungsmethode'),
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _describe(DioException e, String operation) {
|
||||
final status = e.response?.statusCode;
|
||||
final body = e.response?.data;
|
||||
if ((status == 400 || status == 409) &&
|
||||
body is Map &&
|
||||
body['message'] != null) {
|
||||
return body['message'].toString();
|
||||
}
|
||||
if (status == 409) {
|
||||
return 'Zahlungsmethode wird noch von Lieferungen verwendet';
|
||||
}
|
||||
if (status == 404) return 'Zahlungsmethode nicht gefunden';
|
||||
if (status == 401) return 'Sitzung abgelaufen';
|
||||
return '$operation fehlgeschlagen (HTTP ${status ?? 'unbekannt'})';
|
||||
}
|
||||
}
|
||||
569
lib/data/repository/tour_repository_impl.dart
Normal file
569
lib/data/repository/tour_repository_impl.dart
Normal file
@ -0,0 +1,569 @@
|
||||
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'})';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user