- 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>
427 lines
15 KiB
Dart
427 lines
15 KiB
Dart
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_payment.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(),
|
|
};
|
|
|
|
// Zahlungen: höchstens eine pro Lieferung (jüngster Eintrag).
|
|
final paymentsMap = <String, DeliveryPayment>{
|
|
for (final p in payments) p.deliveryId: p.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,
|
|
paymentsByDeliveryId: paymentsMap,
|
|
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,
|
|
);
|
|
}
|
|
|
|
extension ApiDeliveryPaymentMapper on api.DeliveryPayment {
|
|
DeliveryPayment toDomain() => DeliveryPayment(
|
|
id: id,
|
|
deliveryId: deliveryId,
|
|
paymentMethodId: paymentMethodId,
|
|
paymentMethodCode: paymentMethodCode,
|
|
amountCents: amountCents,
|
|
recordedAt: recordedAt.toLocal(),
|
|
);
|
|
}
|