Backend-Arbeitsstand: ERP-Sync, Lieferlebenszyklus, Reports + config.toml

Bringt das Backend vom initialen Skeleton auf den aktuellen Arbeitsstand
(Clean Architecture: domain → application → infrastructure → api).

Wesentliche Bereiche:
- ERP-Anbindung (MSSQL-Pull der Touren, Import-Scheduler, Rückschreiben)
- Lieferlebenszyklus: Scan/Hold/Cancel/Complete, Gutschriften, Notizen,
  Bild-Anhänge, Unterschriften, PDF-Lieferreport → DOCUframe
- Stammdaten: Kunden, Artikel, Lager, Zahlungsarten, Services
- Keycloak-JWT-Gate + Fahrer-Provisionierung via Admin-API
- Admin-API-Key-Gate (X-Admin-Api-Key) für Maschinen-Endpunkte

Jüngste Änderungen dieser Session:
- Belegspezifische Kontaktdaten: alle ERP-Adressen (Beleg-/Liefer-/
  Rechnungsadresse, Ansprechpartner, Kundenstamm) mit Telefon/Mobil/
  E-Mail werden gesynct (Migration 0029, MSSQL-Query, TourDetails)
- Konfiguration von .env (envy/dotenvy) auf config.toml (toml/serde)
  umgestellt; Vorlage config.example.toml, Pfad via HOLZLEITNER_CONFIG

Nicht im Repo (per .gitignore): config.toml (Secrets), data/ (Laufzeit-/
Kundendaten), demo.mp4, .claude/, variocontrol-ai/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-06-01 17:52:58 +02:00
parent 438040acce
commit 6a9b5872e1
137 changed files with 13700 additions and 218 deletions

View File

@ -17,4 +17,6 @@ async-trait.workspace = true
thiserror.workspace = true
uuid.workspace = true
chrono.workspace = true
sha2.workspace = true
imagesize.workspace = true
utoipa = { workspace = true, optional = true }

View File

@ -0,0 +1,40 @@
//! Eingabe für den Lieferungs-Abschluss (`POST /deliveries/{id}/complete`).
//!
//! Der Endpoint nimmt `multipart/form-data` entgegen — zwei Signatur-PNGs
//! plus dieses JSON-Feld mit den Checkbox-Bestätigungen des Kunden. Die
//! Antwort ist die frisch abgeschlossene `Delivery` (`DeliveryResponse`).
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Dokumentierte Bestätigungen des Kunden zum Abschlusszeitpunkt.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct CompleteDeliveryAcknowledgements {
/// „Ware im ordnungsgemäßen Zustand erhalten / Aufbau korrekt." — Pflicht.
pub receipt_confirmed: bool,
/// „Anmerkungen zur Lieferung zur Kenntnis genommen." — Pflicht nur, wenn
/// Notizen existieren (das prüft der Server).
#[serde(default)]
pub notes_acknowledged: bool,
/// Notiz-IDs, die zum Abschlusszeitpunkt sichtbar waren und mit-bestätigt
/// wurden (Audit-Robustheit).
#[serde(default)]
pub acknowledged_note_ids: Vec<Uuid>,
/// Inkasso-Bestätigung des Fahrers: „der offene Betrag wurde erhalten
/// (bar) bzw. über das EC-Gerät abgerechnet." Pflicht nur, wenn beim
/// Abschluss ein offener Betrag > 0 besteht UND die Methode ein Vor-Ort-
/// Inkasso ist (Bar/EC) — das prüft der Server. Der kassierte Betrag wird
/// server-seitig autoritativ berechnet (nicht vom Client übernommen).
#[serde(default)]
pub payment_collected: bool,
/// Optionale Zahlungsmethode, die der Fahrer beim Abschluss gewählt hat.
/// `None` = die am Beleg hinterlegte Methode bleibt. Falls gesetzt, muss
/// sie existieren **und** aktiv sein (vom Server geprüft).
#[serde(default)]
pub payment_method_id: Option<Uuid>,
/// Fahrzeug des Akteurs (Audit-Spur). Muss zum Account gehören.
#[serde(default)]
pub author_car_id: Option<Uuid>,
}

View File

@ -0,0 +1,45 @@
//! Request/Response für `POST /deliveries/{id}/credit` — die
//! Betrags-Gutschrift (append-only, idempotent über `client_event_id`).
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use holzleitner_domain::DeliveryCredit;
/// Art des Gutschrift-Ereignisses.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum CreditAction {
/// Gutschrift setzen/ändern — `amount_cents` und `reason` Pflicht.
Set,
/// Gutschrift entfernen — `amount_cents`/`reason` werden ignoriert.
Remove,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeliveryCreditEventRequest {
/// Idempotenz-Schlüssel — pro erzeugtem Ereignis genau einmal vergeben.
/// Ein Retry mit derselben Id wendet nichts erneut an.
pub client_event_id: Uuid,
pub action: CreditAction,
/// Bei `Set` Pflicht: Betrag in Cent (> 0, ≤ 15000, Vielfaches von 1000).
#[serde(default)]
pub amount_cents: Option<i64>,
/// Bei `Set` Pflicht: Begründung.
#[serde(default)]
pub reason: Option<String>,
/// Fahrzeug des Akteurs (Audit-Spur). Muss zum Account gehören.
#[serde(default)]
pub author_car_id: Option<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeliveryCreditResponse {
/// Aktueller Stand nach dem Ereignis — `None`, wenn (zuletzt) entfernt.
pub credit: Option<DeliveryCredit>,
}

View File

@ -0,0 +1,149 @@
//! DTO-Aggregat für den PDF-Lieferreport.
//!
//! Bündelt **alle** Informationen zu einer Lieferung inkl. der beiden
//! Audit-Trails (`scan_audit`, `delivery_credit_audit`). Wird vom
//! `DeliveryReportRepository` (DB) befüllt; die Bild-Bytes (Unterschriften,
//! Anhänge) hängt der Use Case nachträglich aus dem lokalen Speicher an,
//! damit der Renderer rein (ohne IO) bleibt.
use chrono::{DateTime, NaiveDate, Utc};
#[derive(Debug, Clone)]
pub struct DeliveryReportData {
pub generated_at: DateTime<Utc>,
// Kopf
pub belegart_id: i64,
/// Belegart-Kurzcode (z. B. „VL5"), falls vom Sync befüllt.
pub belegart_code: Option<String>,
/// Belegart-Klartext (z. B. „Lieferschein EH").
pub belegart_name: Option<String>,
pub belegnummer: String,
pub state: String,
pub tour_date: NaiveDate,
pub driver_personalnummer: i64,
pub driver_name: String,
pub car_plate: Option<String>,
pub payment_method: Option<String>,
// Kunde + Adresse
pub customer_number: i64,
pub customer_name: String,
pub address: String,
pub desired_time: Option<String>,
pub special_agreements: Option<String>,
pub prepaid_amount: f64,
pub current_credit_cents: i64,
pub contacts: Vec<ReportContact>,
pub items: Vec<ReportItem>,
pub services: Vec<ReportService>,
pub notes: Vec<ReportNote>,
pub completion: Option<ReportCompletion>,
pub scan_audit: Vec<ReportScanAudit>,
pub credit_audit: Vec<ReportCreditAudit>,
pub attachments: Vec<ReportAttachment>,
// Bild-Bytes (vom Use Case aus dem lokalen Speicher nachgeladen):
pub customer_signature_png: Option<Vec<u8>>,
pub driver_signature_png: Option<Vec<u8>>,
}
#[derive(Debug, Clone)]
pub struct ReportContact {
pub name: String,
pub detail: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ReportItem {
pub belegzeilen_nr: i32,
pub komponenten_artikel_nr: Option<String>,
pub parent_artikel_nr: Option<String>,
pub article_number: String,
pub name: String,
pub required_quantity: i32,
pub credited_quantity: i32,
pub scanned_quantity: i32,
pub scan_status: String,
pub unit_price: f64,
pub warehouse_code: Option<String>,
pub warehouse_name: Option<String>,
}
impl ReportItem {
pub fn is_component(&self) -> bool {
self.komponenten_artikel_nr.is_some()
}
/// Tatsächlich ausgeliefert = Soll Gutschrift.
pub fn delivered(&self) -> i32 {
(self.required_quantity - self.credited_quantity).max(0)
}
}
#[derive(Debug, Clone)]
pub struct ReportService {
pub name: String,
pub bool_value: Option<bool>,
pub numeric_value: Option<i32>,
}
#[derive(Debug, Clone)]
pub struct ReportNote {
pub created_at: DateTime<Utc>,
pub author_personalnummer: i64,
pub text: Option<String>,
pub image_attachment: Option<String>,
pub is_amount_credit_note: bool,
}
#[derive(Debug, Clone)]
pub struct ReportCompletion {
pub completed_at: DateTime<Utc>,
pub completed_by_personalnummer: i64,
pub receipt_confirmed: bool,
pub notes_acknowledged: bool,
pub customer_signature_path: String,
pub driver_signature_path: String,
/// Fahrer hat das Inkasso (Bar/EC) bestätigt.
pub payment_collected: bool,
/// Snapshot des kassierten offenen Betrags in Cent (None = kein Inkasso).
pub collected_amount_cents: Option<i64>,
}
#[derive(Debug, Clone)]
pub struct ReportScanAudit {
pub server_recorded_at: DateTime<Utc>,
pub client_scanned_at: DateTime<Utc>,
pub action: String,
pub delta: i32,
pub resulting_quantity: i32,
pub resulting_status: String,
pub reason: Option<String>,
pub manual: bool,
pub credit_delta: Option<i32>,
pub actor_personalnummer: i64,
pub belegzeilen_nr: i32,
pub komponenten_artikel_nr: Option<String>,
pub article_name: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ReportCreditAudit {
pub recorded_at: DateTime<Utc>,
pub action: String,
pub amount_cents: i64,
pub reason: Option<String>,
pub author_personalnummer: i64,
}
#[derive(Debug, Clone)]
pub struct ReportAttachment {
pub filename: Option<String>,
/// Speicher-Referenz (lokaler relativer Pfad) — zum Nachladen der Bytes.
pub reference: String,
pub mime_type: String,
pub size_bytes: i64,
pub width: Option<i32>,
pub height: Option<i32>,
pub uploaded_at: DateTime<Utc>,
pub uploaded_by: i64,
/// Vom Use Case aus dem lokalen Speicher nachgeladen (fürs Einbetten).
pub bytes: Option<Vec<u8>>,
}

View File

@ -11,10 +11,15 @@
//! zweite Schicht handgeschriebener API-DTOs.
pub mod car;
pub mod complete;
pub mod credit;
pub mod delivery_action;
pub mod delivery_report;
pub mod delivery_order;
pub mod note;
pub mod payment_method;
pub mod scan;
pub mod service;
pub mod tour_details;
pub mod tour_summary;
pub mod tour_sync;
@ -22,14 +27,32 @@ pub mod tour_sync;
pub use car::{
AssignCarRequest, CarResponse, CarsList, CreateCarRequest, UpdateCarRequest,
};
pub use complete::CompleteDeliveryAcknowledgements;
pub use credit::{CreditAction, DeliveryCreditEventRequest, DeliveryCreditResponse};
pub use delivery_report::{
DeliveryReportData, ReportAttachment, ReportCompletion, ReportContact, ReportCreditAudit,
ReportItem, ReportNote, ReportScanAudit, ReportService,
};
pub use delivery_action::{CancelDeliveryRequest, DeliveryResponse, HoldDeliveryRequest};
pub use delivery_order::{
DeliveryOrderEntry, SetDeliveryOrderRequest, SetDeliveryOrderResponse,
};
pub use note::{CreateDeliveryNoteRequest, DeliveryNoteResponse};
pub use note::{
CreateDeliveryNoteRequest, DeliveryNoteResponse, UpdateDeliveryNoteRequest,
};
pub use payment_method::{
CreatePaymentMethodRequest, PaymentMethodResponse, PaymentMethodsList,
UpdatePaymentMethodRequest,
};
pub use scan::{
ApplyScansRequest, ApplyScansResponse, ScanEvent, ScanResult, ScanResultStatus,
};
pub use service::{
CreateServiceRequest, DeliveryServiceResponse, ServiceResponse, ServicesList,
SetDeliveryServiceRequest, UpdateServiceRequest,
};
pub use tour_details::{DeliveryWithItems, TourDetails};
pub use tour_summary::TourSummary;
pub use tour_sync::{SyncDelivery, SyncDeliveryItem, SyncTourRequest};
pub use tour_sync::{
SyncContactChannel, SyncContactSource, SyncDelivery, SyncDeliveryItem, SyncTourRequest,
};

View File

@ -19,6 +19,27 @@ pub struct CreateDeliveryNoteRequest {
/// Fahrzeug, das die Notiz erzeugt hat. Muss zum angemeldeten
/// Account gehören. `None` ist erlaubt.
pub author_car_id: Option<Uuid>,
/// Optionaler Gutschrift-Bezug: die Belegzeile, für die diese Notiz als
/// Gutschrift-Grund angelegt wird. Ermöglicht das gezielte Löschen beim
/// Unremove. `None` für normale Notizen.
#[serde(default)]
pub credit_delivery_item_id: Option<Uuid>,
/// `true` markiert die Notiz als Grund einer Betrags-Gutschrift
/// (Lieferungs-Ebene). Default `false`.
#[serde(default)]
pub is_amount_credit_note: bool,
}
/// Request für `PATCH /deliveries/{id}/notes/{note_id}`. Wie beim Create
/// muss mindestens eines von `text` / `image_attachment` inhaltlich gefüllt
/// sein — geprüft im Use Case.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct UpdateDeliveryNoteRequest {
pub text: Option<String>,
/// Object-Storage-Key oder URL eines vorab hochgeladenen Bildes.
pub image_attachment: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@ -0,0 +1,41 @@
//! Request- und Antwort-Typen für die Payment-Methods-Endpoints.
use serde::{Deserialize, Serialize};
use holzleitner_domain::PaymentMethod;
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct CreatePaymentMethodRequest {
/// Eindeutiger Programm-Identifier (z. B. `"paypal"`, `"klarna"`).
pub code: String,
/// Anzeige-Name in der UI.
pub name: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct UpdatePaymentMethodRequest {
/// Wenn gesetzt: neuer Anzeige-Name.
pub name: Option<String>,
/// Wenn gesetzt: aktiv/inaktiv. Inaktive Methoden bleiben für
/// historische Lieferungen referenzierbar, tauchen aber im
/// Default-Listing nicht auf.
pub active: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct PaymentMethodResponse {
pub method: PaymentMethod,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct PaymentMethodsList {
pub methods: Vec<PaymentMethod>,
}

View File

@ -27,6 +27,19 @@ pub struct ScanEvent {
pub action: AuditAction,
/// Pflicht bei `Hold` und `Remove`. Sonst ignoriert.
pub reason: Option<String>,
/// Menge für `Remove` / `Unremove` (Mengen-Gutschrift): wie viele Stück
/// der Belegzeile gutgeschrieben bzw. wieder hergestellt werden.
/// `None` = ganze Restmenge (abwärtskompatibel zum bisherigen
/// „ganze Zeile entfernen"). Bei `Scan`/`Unscan`/`Hold`/`Unhold`
/// ignoriert. Muss, wenn gesetzt, `> 0` sein.
#[serde(default)]
pub quantity: Option<i32>,
/// `true`, wenn der Fahrer die Position **manuell** als geladen bestätigt
/// hat (Fallback ohne Barcode-Scan). Wird nur im Audit (`scan_audit.manual`)
/// festgehalten; an der Mengen-/Status-Logik ändert es nichts. Default
/// `false` (regulärer Barcode-Scan).
#[serde(default)]
pub manual: bool,
pub client_scanned_at: DateTime<Utc>,
/// Fahrzeug, in dem der Scan gemacht wurde. Muss zum
/// angemeldeten Account gehören. `None` ist erlaubt, schwächt

View File

@ -0,0 +1,73 @@
//! Request-/Antwort-Typen für die Services-Endpoints (Stammdaten-CRUD +
//! Pro-Lieferung-Wert).
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use holzleitner_domain::{DeliveryServiceValue, Service, ServiceKind};
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct CreateServiceRequest {
/// Eindeutiger Programm-Identifier (z. B. `"podium_setup"`).
pub key: String,
pub name: String,
pub kind: ServiceKind,
/// Nur bei `Numeric` sinnvoll.
#[serde(default)]
pub min_value: Option<i32>,
#[serde(default)]
pub max_value: Option<i32>,
#[serde(default)]
pub sort_order: Option<i32>,
}
/// Teil-Update eines Service. `kind` ist bewusst **nicht** änderbar — ein
/// Wechsel boolean↔numeric würde bestehende Pro-Lieferung-Werte ungültig
/// machen (dann lieber deaktivieren + neu anlegen).
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct UpdateServiceRequest {
pub name: Option<String>,
pub min_value: Option<i32>,
pub max_value: Option<i32>,
pub active: Option<bool>,
pub sort_order: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct ServiceResponse {
pub service: Service,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct ServicesList {
pub services: Vec<Service>,
}
/// Setzt den Wert eines Service für eine Lieferung (Upsert). Es muss genau
/// das zum `ServiceKind` passende Feld gesetzt sein (Use Case prüft das).
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct SetDeliveryServiceRequest {
#[serde(default)]
pub bool_value: Option<bool>,
#[serde(default)]
pub numeric_value: Option<i32>,
#[serde(default)]
pub author_car_id: Option<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeliveryServiceResponse {
pub value: DeliveryServiceValue,
}

View File

@ -10,7 +10,8 @@
use serde::Serialize;
use holzleitner_domain::{
Article, Customer, CustomerContact, Delivery, DeliveryItem, DeliveryNote, Tour, Warehouse,
Article, ContactChannel, ContactSource, Customer, CustomerContact, Delivery, DeliveryCredit,
DeliveryItem, DeliveryNote, DeliveryServiceValue, Service, Tour, Warehouse,
};
#[derive(Debug, Clone, Serialize)]
@ -27,6 +28,24 @@ pub struct TourDetails {
/// Die App joint clientseitig per `delivery_id`. Reihenfolge:
/// pro Lieferung aufsteigend nach `created_at`.
pub notes: Vec<DeliveryNote>,
/// Aktuelle Betrags-Gutschriften (jüngster Stand pro Lieferung), nur für
/// Lieferungen, deren letztes Ereignis `set` war. Join per `delivery_id`.
pub credits: Vec<DeliveryCredit>,
/// Aktive Service-Definitionen (Stammdaten) — die App rendert daraus
/// Phase 4. Bewusst hier mitgeliefert, damit die Detailseite alles aus
/// dem Tour-Aggregat hat.
pub services: Vec<Service>,
/// Pro-Lieferung gesetzte Service-Werte. Join per `delivery_id` +
/// `service_id`.
pub delivery_services: Vec<DeliveryServiceValue>,
/// Alle vom ERP gespiegelten Kontaktquellen aller Lieferungen dieser
/// Tour. Die App joint clientseitig per `delivery_id` und gruppiert
/// nach `role` (Lieferadresse / Rechnungsadresse / Ansprechpartner /
/// Kundenstamm / Belegadresse).
pub contact_sources: Vec<ContactSource>,
/// Die zu `contact_sources` gehörenden Einzel-Kanäle (Telefon, Mobil,
/// E-Mail, Web). Join per `source_id`.
pub contact_channels: Vec<ContactChannel>,
}
#[derive(Debug, Clone, Serialize)]

View File

@ -13,7 +13,7 @@
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use holzleitner_domain::Address;
use holzleitner_domain::{Address, ContactKind, ContactRole};
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
@ -29,6 +29,12 @@ pub struct SyncTourRequest {
#[serde(rename_all = "camelCase")]
pub struct SyncDelivery {
pub belegart_id: i64,
/// Belegart-Kurzcode (z. B. „VL5"), aus `Belegarten.Belegart` (getrimmt).
#[serde(default)]
pub belegart_code: Option<String>,
/// Belegart-Klartext (z. B. „Lieferschein EH"), aus `Belegarten.Bezeichnung`.
#[serde(default)]
pub belegart_name: Option<String>,
pub belegnummer: String,
pub erp_customer_id: i64,
@ -44,7 +50,62 @@ pub struct SyncDelivery {
pub desired_time: Option<String>,
pub special_agreements: Option<String>,
/// Bei Bestellung schon bezahlter Betrag in EUR. Default `0.0`,
/// wenn der Kunde alles bei Lieferung zahlt. Der ERP-Sync liefert
/// den Wert mit.
#[serde(default)]
pub prepaid_amount: f64,
/// Für den Restbetrag gewählte Zahlungsart — Referenz per
/// `code` (z. B. `"cash"`, `"invoice"`). Das ERP kennt seine
/// Standard-Codes, der Sync-Code resolvet sie zur UUID. Wenn
/// `None`, fällt der Backend-Code auf `"cash"` zurück.
#[serde(default)]
pub payment_method_code: Option<String>,
pub items: Vec<SyncDeliveryItem>,
/// Alle vom ERP an diesem Beleg hängenden Kontakt-Adressen (Beleg-/
/// Liefer-/Rechnungsadresse, Ansprechpartner, Kundenstamm). Leere
/// Quellen (kein einziger ausgefüllter Kanal *und* kein Name) lässt
/// der Sync weg.
#[serde(default)]
pub contact_sources: Vec<SyncContactSource>,
}
/// Eine Adress-Rolle eines Belegs mit Namensblock und allen ausgefüllten
/// Telefon-/Mobil-/E-Mail-/Web-Einträgen.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct SyncContactSource {
pub role: ContactRole,
#[serde(default)]
pub anrede: Option<String>,
#[serde(default)]
pub titel: Option<String>,
#[serde(default)]
pub name1: Option<String>,
#[serde(default)]
pub name2: Option<String>,
#[serde(default)]
pub name3: Option<String>,
#[serde(default)]
pub abteilung: Option<String>,
#[serde(default)]
pub funktion: Option<String>,
#[serde(default)]
pub channels: Vec<SyncContactChannel>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct SyncContactChannel {
pub kind: ContactKind,
/// 1-basiert: spiegelt ERP-Spaltenposition (Telefon → 1, Telefon2 → 2, …).
pub position: i16,
pub value: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@ -53,7 +114,13 @@ pub struct SyncDelivery {
pub struct SyncDeliveryItem {
pub belegzeilen_nr: i32,
/// Komponenten-Artikelnummer bei aufgelösten Stücklisten, sonst leer.
/// Trägt die **eigene** Nummer der Komponente (eindeutig je Belegzeile).
pub komponenten_artikel_nr: Option<String>,
/// Artikelnummer des **Oberartikels**, zu dem diese Komponente gehört.
/// `None` bei Oberartikeln/regulären Zeilen. Erlaubt der App, Komponenten
/// unter ihrem Oberartikel einzurücken.
#[serde(default)]
pub parent_artikel_nr: Option<String>,
pub article_number: String,
pub article_name: String,
@ -65,4 +132,9 @@ pub struct SyncDeliveryItem {
pub warehouse_name: String,
pub required_quantity: i32,
/// Stückpreis (brutto, EUR). Default `0.0`. Liefert der ERP-Sync mit;
/// die App rechnet daraus den Warenwert.
#[serde(default)]
pub unit_price: f64,
}

View File

@ -20,6 +20,11 @@ pub enum ApplicationError {
#[error("validation: {0}")]
Validation(String),
/// Operation würde einen Daten-Konflikt erzeugen (z. B. FK-RESTRICT
/// beim Löschen, UNIQUE-Verletzung). Mappt auf HTTP `409`.
#[error("conflict: {0}")]
Conflict(String),
#[error("repository: {0}")]
Repository(String),

View File

@ -0,0 +1,71 @@
//! Port für die Attachment-Metadaten-Registry (Postgres).
//!
//! Hält Metadaten zu hochgeladenen Dateien + die DOCUframe-Referenz. Die
//! Bytes selbst liegen extern (DOCUframe, siehe [`super::AttachmentStorage`]).
use async_trait::async_trait;
use uuid::Uuid;
use crate::error::ApplicationError;
/// Eingabe zum Anlegen eines Attachment-Metadatensatzes. `id` und
/// `uploaded_at` werden von der DB vergeben.
pub struct NewAttachment {
/// DOCUframe `~ObjectID` — Referenz zum Abruf der Bytes.
pub docuframe_object_id: String,
pub mime_type: String,
pub size_bytes: i64,
pub filename: Option<String>,
/// SHA-256 der Bytes als Hex.
pub checksum_sha256: String,
pub width: Option<i32>,
pub height: Option<i32>,
pub uploaded_by: i64,
pub delivery_id: Uuid,
}
/// Für den Download relevante Felder eines Attachments.
pub struct AttachmentRef {
/// DOCUframe `~ObjectID` zum Laden der Bytes.
pub docuframe_object_id: String,
/// Ursprünglicher Upload-MIME-Typ (nur informativ — das Vorschau-
/// Rendering bestimmt das tatsächliche Ausgabeformat).
pub mime_type: String,
}
/// Lokale Datei-Referenz eines (noch nicht gelöschten) Attachments — fürs
/// Aufräumen nach erfolgreichem Report-Upload.
pub struct AttachmentLocalRef {
pub id: Uuid,
/// Speicher-Referenz (lokal = relativer Pfad in `docuframe_object_id`).
pub reference: String,
}
#[async_trait]
pub trait AttachmentRepository: Send + Sync {
/// Legt einen Metadatensatz an und liefert dessen neue Id zurück.
async fn create(&self, attachment: NewAttachment) -> Result<Uuid, ApplicationError>;
/// Lädt die Download-relevanten Felder eines Attachments. `None`, wenn
/// kein Attachment mit dieser Id existiert.
async fn get(&self, id: Uuid) -> Result<Option<AttachmentRef>, ApplicationError>;
/// Liefert die Belegnummer (`deliveries.erp_belegnummer`) zur Lieferung —
/// der lokale Speicher nutzt sie als Ordnernamen. `None`, wenn die
/// Lieferung nicht (mehr) existiert.
async fn delivery_belegnummer(
&self,
delivery_id: Uuid,
) -> Result<Option<String>, ApplicationError>;
/// Listet alle noch nicht gelöschten Attachments einer Lieferung
/// (`deleted_at IS NULL`) mit ihrer lokalen Referenz — fürs Aufräumen.
async fn list_active_for_delivery(
&self,
delivery_id: Uuid,
) -> Result<Vec<AttachmentLocalRef>, ApplicationError>;
/// Markiert ein Attachment als gelöscht (`deleted_at = now()`). Die
/// Metadaten-Zeile bleibt — so ist ersichtlich, dass es ein Bild gab.
async fn mark_deleted(&self, id: Uuid) -> Result<(), ApplicationError>;
}

View File

@ -0,0 +1,54 @@
//! Port für den externen Dokumenten-/Datei-Speicher.
//!
//! Konkrete Impl ist der DOCUframe-Adapter (GSD-REST-API). Der Use Case
//! lädt eine Datei hoch und erhält eine persistente Referenz zurück, die
//! als `image_attachment` an einer Notiz gespeichert wird.
use async_trait::async_trait;
use crate::error::ApplicationError;
/// Heruntergeladenes Vorschaubild: rohe Bytes + der vom Speicher gemeldete
/// Content-Type (das Vorschau-Rendering bestimmt das Ausgabeformat, nicht
/// der ursprüngliche Upload-MIME-Typ).
pub struct PreviewImage {
pub bytes: Vec<u8>,
pub content_type: String,
}
#[async_trait]
pub trait AttachmentStorage: Send + Sync {
/// Lädt eine Datei hoch und liefert die persistente Referenz.
///
/// `folder` ist ein logischer Ablage-Schlüssel (bei uns die Belegnummer):
/// der lokale Adapter legt die Datei in einem gleichnamigen Unterordner ab.
/// Der DOCUframe-Adapter ignoriert ihn (könnte ihn später als Kategorie
/// nutzen). Rückgabe: die Referenz für den späteren Abruf, die in
/// `attachments.docuframe_object_id` gespeichert wird (lokal = rel. Pfad,
/// DOCUframe = `~ObjectID`).
async fn upload(
&self,
folder: &str,
filename: &str,
mime: &str,
bytes: Vec<u8>,
) -> Result<String, ApplicationError>;
/// Lädt ein gerendertes Vorschaubild zur Referenz `object_id`.
///
/// `parameters` folgt dem DOCUframe-Schema
/// `width_height_quality_extension` (z. B. `1024_1024_85_jpeg`),
/// `page` ist die Seitennummer (für Bilder i. d. R. `1`).
async fn download_preview(
&self,
object_id: &str,
parameters: &str,
page: &str,
) -> Result<PreviewImage, ApplicationError>;
/// Löscht die Datei hinter `reference` (lokaler Adapter: die lokale Datei).
/// Wird beim Aufräumen nach erfolgreichem Report-Upload genutzt. Idempotent:
/// eine bereits fehlende Datei ist kein Fehler. Der DOCUframe-Adapter
/// implementiert das als No-Op (wir löschen dort nichts).
async fn delete(&self, reference: &str) -> Result<(), ApplicationError>;
}

View File

@ -0,0 +1,131 @@
//! Port für den Lieferungs-Abschluss (Unterschriften + Bestätigungen).
//!
//! Der Abschluss ist eine **atomare** Operation: Gate-Prüfungen (Lieferung
//! aktiv, alle scanbaren Positionen fertig, ggf. Notizen bestätigt),
//! Persistenz der Abschluss-Zeile und der Statuswechsel auf `completed`
//! laufen in genau einer Transaktion. Schlägt etwas fehl, bleibt nichts
//! halb-fertig zurück.
//!
//! Die Signatur-Dateien werden VOR dem Repository-Aufruf vom Use Case über
//! `SignatureStorage` geschrieben; hier kommen nur noch deren Referenzen an.
use async_trait::async_trait;
use chrono::{NaiveDate, NaiveDateTime};
use uuid::Uuid;
use holzleitner_domain::Delivery;
use crate::error::ApplicationError;
/// Eine Belegzeile für das ERP-Rückschreiben: Position + ausgelieferte Menge.
pub struct ErpWritebackLine {
pub belegzeilen_nr: i32,
/// `required_quantity credited_quantity` (Postgres-Stand).
pub delivered_quantity: i32,
}
/// Alles, was der ERP-Rückschreib-Use-Case aus Postgres braucht, um den
/// Abschluss ins ERP zu spiegeln. Liest den **aktuellen** lokalen Stand
/// (Mengen, Geld-Gutschrift, Abschluss-Zeitpunkt) — daher idempotent
/// wiederholbar.
pub struct ErpWritebackData {
pub belegart_id: i64,
pub belegnummer: String,
/// Abschluss-Zeitpunkt (lokale Zeit) aus `delivery_completions.completed_at`.
pub delivered_at: NaiveDateTime,
pub lines: Vec<ErpWritebackLine>,
/// Aktuelle Geld-Gutschrift in Cent (0 = keine).
pub credit_amount_cents: i64,
/// Code der beim Abschluss gewählten Zahlungsmethode
/// (`payment_methods.code`, z. B. `cash`/`ec_card`/`invoice`). `None`,
/// wenn keine zugeordnet ist. Der Adapter mappt das auf die ERP-
/// Zahlungsbedingung und setzt `Belegkopf.ZahlungsbedingungId`.
pub payment_method_code: Option<String>,
}
/// Vollständige Eingabe für den Abschluss — alles, was die Abschluss-Zeile
/// braucht plus die fachlichen Bestätigungs-Flags fürs Gate.
pub struct CompleteDeliveryInput {
pub delivery_id: Uuid,
pub customer_signature_path: String,
pub driver_signature_path: String,
/// Empfangsbestätigung des Kunden (immer Pflicht == true).
pub receipt_confirmed: bool,
/// Kenntnisnahme der Anmerkungen — Pflicht nur, wenn Notizen existieren
/// (das prüft das Repository gegen den DB-Stand).
pub notes_acknowledged: bool,
/// Notiz-IDs, die zum Abschlusszeitpunkt sichtbar/mit-bestätigt waren.
pub acknowledged_note_ids: Vec<Uuid>,
/// Inkasso-Bestätigung des Fahrers. Das Repository prüft gegen den
/// DB-Stand, ob sie nötig war (offener Betrag > 0 UND Methode Bar/EC), und
/// friert den kassierten Betrag als Snapshot ein.
pub payment_collected: bool,
/// Optionale Zahlungsmethode-Override. `None` = Methode am Beleg bleibt.
/// Falls gesetzt, prüft das Repository Existenz + Aktiv-Status.
pub payment_method_id: Option<Uuid>,
pub completed_by_personalnummer: i64,
pub completed_by_car_id: Option<Uuid>,
}
#[async_trait]
pub trait DeliveryCompletionRepository: Send + Sync {
/// Schließt eine Lieferung ab und liefert die frische `Delivery`
/// (`state == completed`) zurück.
///
/// Gates (alle in der Transaktion unter Lock):
/// * Lieferung existiert (`NotFound`).
/// * Bereits `completed` **mit** Abschluss-Zeile → idempotenter Erfolg.
/// * Sonst muss `state == active` sein (`Validation`).
/// * Alle scanbaren, nicht entfernten Positionen müssen fertig sein.
/// * Existieren Notizen, muss `notes_acknowledged == true` sein.
async fn complete(
&self,
input: CompleteDeliveryInput,
) -> Result<Delivery, ApplicationError>;
/// Lädt die für das ERP-Rückschreiben nötigen Daten einer **bereits
/// abgeschlossenen** Lieferung (Beleg-Key, ausgelieferte Mengen,
/// Geld-Gutschrift, Abschluss-Zeitpunkt).
///
/// `NotFound`, wenn die Lieferung oder ihre Abschluss-Zeile fehlt.
async fn load_erp_writeback(
&self,
delivery_id: Uuid,
) -> Result<ErpWritebackData, ApplicationError>;
/// Liefert die Belegnummern aller **ausgelieferten** (abgeschlossenen)
/// Lieferungen, **deren Liefermail noch NICHT versendet wurde**
/// (`mail_sent_at IS NULL`).
///
/// * `day = Some(d)` → nur Belege, deren Abschluss-Zeitpunkt
/// (`completed_at`) auf den Kalendertag `d` fällt. `completed_at` ist ein
/// UTC-Zeitstempel; der Kalendertag wird in der Zeitzone **Europe/Berlin**
/// bestimmt (Geschäftszeit), nicht in UTC.
/// * `day = None` → **alle** offenen (noch nicht versendeten) Belege über
/// alle Tage. Das ist der Modus des Mailclients, damit Belege, die über
/// Mitternacht nicht versendet wurden, nicht hängen bleiben.
///
/// Sortierung: aufsteigend nach Abschluss-Zeitpunkt.
async fn list_delivered_belegnummern(
&self,
day: Option<NaiveDate>,
) -> Result<Vec<String>, ApplicationError>;
/// Markiert die Liefermail der angegebenen Belegnummern als **versendet**
/// (`mail_sent_at = now()`), aber nur dort, wo sie noch offen ist
/// (`mail_sent_at IS NULL`) — bereits markierte bleiben unverändert
/// (idempotent, erster Versand-Zeitpunkt bleibt erhalten). Liefert die
/// Anzahl tatsächlich frisch markierter Belege zurück.
async fn mark_mail_sent(
&self,
belegnummern: &[String],
) -> Result<u64, ApplicationError>;
/// **DEV-ONLY**: Hebt die Mail-Versendet-Markierung der angegebenen
/// Belegnummern wieder auf (`mail_sent_at = NULL`), sodass sie erneut als
/// offen erscheinen. Liefert die Anzahl tatsächlich zurückgesetzter Belege.
async fn unmark_mail_sent(
&self,
belegnummern: &[String],
) -> Result<u64, ApplicationError>;
}

View File

@ -0,0 +1,36 @@
//! Port für die Betrags-Gutschrift (append-only).
//!
//! Schreibseite: jedes `set`/`remove` hängt eine Zeile ans Audit-Log. Die
//! Leseseite (aktueller Stand pro Lieferung) läuft als Teil des
//! Tour-Aggregats (`TourDetails.credits`), nicht über diesen Port.
use async_trait::async_trait;
use uuid::Uuid;
use holzleitner_domain::DeliveryCredit;
use crate::dto::CreditAction;
use crate::error::ApplicationError;
#[async_trait]
pub trait DeliveryCreditRepository: Send + Sync {
/// Hängt ein Gutschrift-Ereignis ans Log und liefert den **aktuellen
/// Stand** der Lieferung danach zurück (`None`, wenn zuletzt entfernt).
///
/// Idempotent über `client_event_id`: ist die Id bereits bekannt, wird
/// nichts erneut angehängt und der aktuelle Stand unverändert geliefert.
///
/// `NotFound`, wenn die Lieferung nicht existiert; `Validation`, wenn die
/// Lieferung nicht `active` ist (nur bei frischem Ereignis geprüft).
/// `amount_cents` ist bei `Set` der zu setzende Betrag, bei `Remove` `0`.
async fn apply_event(
&self,
delivery_id: Uuid,
client_event_id: Uuid,
action: CreditAction,
amount_cents: i64,
reason: Option<String>,
author_personalnummer: i64,
author_car_id: Option<Uuid>,
) -> Result<Option<DeliveryCredit>, ApplicationError>;
}

View File

@ -1,8 +1,7 @@
//! Port für Delivery-Notizen.
//!
//! Aktuell nur das Anlegen — der Read-Pfad läuft als Teil des Tour-
//! Aggregats (`TourDetails.notes`). Sollten irgendwann Listen-Reads
//! oder Updates an einzelnen Notizen nötig werden, kommen die hier rein.
//! Anlegen, Ändern, Löschen einzelner Notizen. Der Read-Pfad läuft als
//! Teil des Tour-Aggregats (`TourDetails.notes`).
use async_trait::async_trait;
use uuid::Uuid;
@ -13,6 +12,7 @@ use crate::error::ApplicationError;
#[async_trait]
pub trait DeliveryNoteRepository: Send + Sync {
#[allow(clippy::too_many_arguments)]
async fn create(
&self,
delivery_id: Uuid,
@ -20,5 +20,20 @@ pub trait DeliveryNoteRepository: Send + Sync {
author_car_id: Option<Uuid>,
text: Option<String>,
image_attachment: Option<String>,
credit_delivery_item_id: Option<Uuid>,
is_amount_credit_note: bool,
) -> Result<DeliveryNote, ApplicationError>;
/// Aktualisiert `text` / `image_attachment` einer bestehenden Notiz.
/// Autor und `created_at` bleiben unverändert (historische Metadaten).
/// `NotFound`, wenn die Notiz nicht existiert.
async fn update(
&self,
note_id: Uuid,
text: Option<String>,
image_attachment: Option<String>,
) -> Result<DeliveryNote, ApplicationError>;
/// Löscht eine Notiz. `NotFound`, wenn keine Zeile betroffen war.
async fn delete(&self, note_id: Uuid) -> Result<(), ApplicationError>;
}

View File

@ -0,0 +1,89 @@
//! Port: persistenter Zustand der Report-Übertragung an DOCUframe.
//!
//! Spiegelt die Tabelle `delivery_report_jobs`. Hält den Fortschritt der
//! mehrstufigen Übertragung (Upload → ~ObjectID → Makro) hart in Postgres,
//! damit fehlgeschlagene Schritte per Cron wiederholt werden können.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use uuid::Uuid;
use crate::error::ApplicationError;
/// Status der Übertragung — Resume-Marke für Retries.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReportJobStatus {
/// Angelegt, noch nichts übertragen.
Pending,
/// PDF liegt in DOCUframe (`docuframe_object_id` gesetzt), Makro offen.
Uploaded,
/// Makro erfolgreich, lokale Dateien aufgeräumt — terminal.
Done,
}
impl ReportJobStatus {
pub fn as_str(self) -> &'static str {
match self {
ReportJobStatus::Pending => "pending",
ReportJobStatus::Uploaded => "uploaded",
ReportJobStatus::Done => "done",
}
}
pub fn parse(s: &str) -> Self {
match s {
"uploaded" => ReportJobStatus::Uploaded,
"done" => ReportJobStatus::Done,
_ => ReportJobStatus::Pending,
}
}
}
/// Ein Report-Übertragungs-Job (eine Zeile aus `delivery_report_jobs`).
#[derive(Debug, Clone)]
pub struct ReportJob {
pub delivery_id: Uuid,
pub belegnummer: String,
pub status: ReportJobStatus,
pub docuframe_object_id: Option<String>,
pub report_uploaded_at: Option<DateTime<Utc>>,
pub attempts: i32,
pub last_error: Option<String>,
}
#[async_trait]
pub trait DeliveryReportJobRepository: Send + Sync {
/// Legt einen Job (`pending`) an, falls noch keiner existiert, und liefert
/// den aktuellen Stand. Idempotent — ein erneuter Abschluss-Versuch
/// überschreibt einen vorhandenen Job nicht.
async fn ensure(
&self,
delivery_id: Uuid,
belegnummer: &str,
) -> Result<ReportJob, ApplicationError>;
/// Lädt einen Job. `None`, wenn keiner existiert.
async fn get(&self, delivery_id: Uuid) -> Result<Option<ReportJob>, ApplicationError>;
/// Alle offenen Jobs (`status <> 'done'`) — für den Retry-Cron.
async fn list_open(&self) -> Result<Vec<ReportJob>, ApplicationError>;
/// Setzt die ~ObjectID nach erfolgreichem Upload und `status = 'uploaded'`.
async fn set_uploaded(
&self,
delivery_id: Uuid,
object_id: &str,
) -> Result<(), ApplicationError>;
/// Markiert den Job als `done` und setzt `report_uploaded_at` (Zeitpunkt
/// der erfolgreichen Makro-Zuordnung).
async fn mark_done(&self, delivery_id: Uuid) -> Result<(), ApplicationError>;
/// Vermerkt einen fehlgeschlagenen Versuch (`attempts++`, `last_error`,
/// `last_attempt_at = now()`). Lässt `status` unverändert (Resume-Marke).
async fn record_error(
&self,
delivery_id: Uuid,
error: &str,
) -> Result<(), ApplicationError>;
}

View File

@ -0,0 +1,10 @@
//! Port: rendert die Reportdaten zu einem PDF.
use crate::dto::DeliveryReportData;
use crate::error::ApplicationError;
pub trait DeliveryReportRenderer: Send + Sync {
/// Rendert den vollständigen Lieferreport als PDF-Bytes. Rein (kein IO):
/// alle Bild-Bytes liegen bereits im [`DeliveryReportData`].
fn render(&self, data: &DeliveryReportData) -> Result<Vec<u8>, ApplicationError>;
}

View File

@ -0,0 +1,19 @@
//! Port: lädt alle DB-Daten für den PDF-Lieferreport.
use async_trait::async_trait;
use uuid::Uuid;
use crate::dto::DeliveryReportData;
use crate::error::ApplicationError;
#[async_trait]
pub trait DeliveryReportRepository: Send + Sync {
/// Sammelt sämtliche Lieferungs-Daten inkl. beider Audit-Trails
/// (`scan_audit`, `delivery_credit_audit`) — **ohne** Bild-Bytes (die
/// hängt der Use Case aus dem lokalen Speicher an). `None`, wenn die
/// Lieferung nicht existiert.
async fn load(
&self,
delivery_id: Uuid,
) -> Result<Option<DeliveryReportData>, ApplicationError>;
}

View File

@ -0,0 +1,19 @@
//! Port: nimmt das fertige Report-PDF entgegen.
//!
//! Heute: lokaler Datei-Sink (temporäre Ablage). Später: DOCUframe-Sink, der
//! den Blob an ein Makro sendet (Stub vorhanden).
use async_trait::async_trait;
use crate::error::ApplicationError;
#[async_trait]
pub trait DeliveryReportSink: Send + Sync {
/// Übernimmt das fertige PDF. `folder` = Belegnummer (für die Ablage).
/// Gibt eine Referenz zurück (lokal: Dateipfad; DOCUframe: später).
async fn deliver(&self, folder: &str, pdf: Vec<u8>) -> Result<String, ApplicationError>;
/// Räumt alle lokal abgelegten Report-Dateien zu `folder` (Belegnummer)
/// auf — aufgerufen nach erfolgreichem DOCUframe-Upload. Idempotent.
async fn delete(&self, folder: &str) -> Result<(), ApplicationError>;
}

View File

@ -0,0 +1,36 @@
//! Port für die Pro-Lieferung-Service-Werte (Upsert).
//!
//! Die Leseseite (aktuelle Werte pro Lieferung) läuft als Teil des
//! Tour-Aggregats (`TourDetails.delivery_services`), nicht über diesen Port.
use async_trait::async_trait;
use uuid::Uuid;
use holzleitner_domain::DeliveryServiceValue;
use crate::error::ApplicationError;
#[async_trait]
pub trait DeliveryServiceRepository: Send + Sync {
/// Setzt (Upsert) den Wert eines Service für eine Lieferung. Prüft, dass
/// die Lieferung existiert (`NotFound`) und `active` ist (`Validation`).
/// Genau eines von `bool_value`/`numeric_value` ist gesetzt (Use Case
/// stellt das passend zum Service-Typ sicher).
async fn set(
&self,
delivery_id: Uuid,
service_id: Uuid,
bool_value: Option<bool>,
numeric_value: Option<i32>,
author_personalnummer: i64,
author_car_id: Option<Uuid>,
) -> Result<DeliveryServiceValue, ApplicationError>;
/// Entfernt den Wert (Service für diese Lieferung „nicht gesetzt").
/// Nur bei aktiver Lieferung.
async fn delete(
&self,
delivery_id: Uuid,
service_id: Uuid,
) -> Result<(), ApplicationError>;
}

View File

@ -0,0 +1,33 @@
//! Port: Übertragung des Report-PDFs nach DOCUframe.
//!
//! Zwei Schritte, getrennt, damit die Pipeline ihren Fortschritt nach jedem
//! Schritt persistieren kann (und ein Retry den schon erledigten Upload
//! überspringt):
//! 1. [`upload_report_pdf`] — PDF hochladen → `~ObjectID`.
//! 2. [`assign_report`] — DOCUframe-Makro `_SV_assignDeliveryReport`
//! aufrufen, das den Report dem Beleg/Vorgang zuordnet.
//!
//! Konkrete Impl: der GSD-/DOCUframe-Adapter (`GsdService`).
use async_trait::async_trait;
use crate::error::ApplicationError;
#[async_trait]
pub trait DocuframeReportGateway: Send + Sync {
/// Lädt das Report-PDF nach DOCUframe und liefert die `~ObjectID`.
async fn upload_report_pdf(
&self,
belegnummer: &str,
pdf: Vec<u8>,
) -> Result<String, ApplicationError>;
/// Ruft das Makro `_SV_assignDeliveryReport` mit `{objectId, belegnummer}`
/// auf und ordnet den hochgeladenen Report dem Beleg zu. Fehler, wenn das
/// Makro `succeeded != true` liefert oder DOCUframe nicht erreichbar ist.
async fn assign_report(
&self,
object_id: &str,
belegnummer: &str,
) -> Result<(), ApplicationError>;
}

View File

@ -0,0 +1,36 @@
//! Port für das **Provisionieren** von Fahrer-Konten im Identity-Provider
//! (Keycloak) beim ERP-Sync.
//!
//! Wenn der tägliche Touren-Import einen Fahrer (ERP-`Vertreter`, fachlich die
//! Account-/Vertragspartner-Nummer) sieht, soll im Realm ein Login-Konto
//! existieren: Benutzername = Fahrer-/Account-Nummer, ein **temporäres**
//! Passwort, das beim ersten Login zwingend geändert werden muss
//! (Keycloak-Required-Action `UPDATE_PASSWORD`), und die Rolle `driver`.
//!
//! Die konkrete Impl (Keycloak Admin-REST via reqwest) lebt in
//! `holzleitner-infrastructure` und MUSS **idempotent** sein: existiert der
//! User bereits, passiert nichts (kein Passwort-Reset, keine Doppelanlage).
use async_trait::async_trait;
use crate::error::ApplicationError;
/// Ergebnis einer Provisionierung — ob ein Konto **neu** angelegt wurde.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProvisionOutcome {
/// `true` ⇒ Konto wurde in diesem Aufruf erstellt; `false` ⇒ existierte
/// bereits (idempotenter No-Op).
pub created: bool,
}
#[async_trait]
pub trait DriverIdentityProvisioner: Send + Sync {
/// Stellt sicher, dass für `personalnummer` ein Login-Konto existiert.
/// Idempotent. `display_name` ist ein optionaler Anzeigename (z. B.
/// „Fahrer 423") — nur kosmetisch im IdP.
async fn ensure_driver(
&self,
personalnummer: i64,
display_name: Option<&str>,
) -> Result<ProvisionOutcome, ApplicationError>;
}

View File

@ -0,0 +1,24 @@
//! Port für die Lese-Anbindung an das ERP (ERPframe).
//!
//! Liefert die Tagestouren eines Datums als `SyncTourRequest`-DTOs — also
//! genau die Repräsentation, die auch der HTTP-Sync (`POST /sync/tour`)
//! nutzt. Eine `SyncTourRequest` = eine Fahrer-Tour. Die konkrete Impl
//! (MSSQL via tiberius) lebt in `holzleitner-infrastructure`; die
//! Application bleibt frei von DB-Treiber-Details.
use async_trait::async_trait;
use chrono::NaiveDate;
use crate::dto::SyncTourRequest;
use crate::error::ApplicationError;
#[async_trait]
pub trait ErpDeliverySource: Send + Sync {
/// Liest alle Lieferungen des gegebenen Tages aus dem ERP und gruppiert
/// sie zu **einer `SyncTourRequest` pro Fahrer** (driverPersonalnummer).
/// Reine Lese-Operation; schreibt nichts ins ERP zurück.
async fn fetch_tours_for_date(
&self,
date: NaiveDate,
) -> Result<Vec<SyncTourRequest>, ApplicationError>;
}

View File

@ -0,0 +1,64 @@
//! Port für das **Zurückschreiben** eines Lieferabschlusses ins ERP (ERPframe).
//!
//! Gegenstück zum lesenden [`ErpDeliverySource`](super::ErpDeliverySource):
//! wenn eine Lieferung lokal (Postgres) abgeschlossen wurde, spiegelt dieser
//! Port das Ergebnis direkt in die ERPframe-MSSQL-DB. Drei fachliche Effekte
//! (vgl. die Alt-Makros `_web_finishDelivery` / `_removeArticles` /
//! `_addDiscount`):
//!
//! 1. **Entfernte Artikel** — je Belegzeile die Menge auf die tatsächlich
//! ausgelieferte Menge setzen (`required credited`).
//! 2. **Gutschrift** — eine Belegzeile für den Gutschrift-Artikel
//! (`GUTSCHRIFT10`) hinzufügen/aktualisieren.
//! 3. **Liefer-Zeitpunkt** — `_SV_DELIVERY_DELIVERED_AT` + `_SV_DELIVERY_STATE`
//! setzen.
//!
//! Die konkrete Impl (MSSQL via tiberius) lebt in `holzleitner-infrastructure`
//! und MUSS **idempotent** sein: alle Mengen werden absolut gesetzt, die
//! Gutschrift als Upsert geführt — ein erneuter Aufruf (Admin-Retry) verändert
//! das Ergebnis nicht.
use async_trait::async_trait;
use chrono::NaiveDateTime;
use crate::error::ApplicationError;
/// Eine Belegzeile mit ihrer **neuen** (absoluten) Menge.
#[derive(Debug, Clone)]
pub struct ErpLineQuantity {
/// ERP-Position innerhalb des Belegs (`Belegzeilen.BelegzeilenNr`).
pub belegzeilen_nr: i32,
/// Tatsächlich ausgelieferte Menge = `required_quantity credited_quantity`.
/// Wird absolut gesetzt (nicht subtrahiert) → idempotent.
pub delivered_quantity: i32,
}
/// Vollständige Eingabe für das ERP-Rückschreiben eines Abschlusses.
#[derive(Debug, Clone)]
pub struct ErpFinishDeliveryCommand {
/// Beleg-Natural-Key (aus `deliveries.erp_belegart_id`/`erp_belegnummer`).
/// Der Adapter resolved daraus die `Belegkopf.row_id`.
pub belegart_id: i64,
pub belegnummer: String,
/// Liefer-Zeitpunkt (lokale Zeit), wird als ISO-8601 mit `T` geschrieben.
pub delivered_at: NaiveDateTime,
/// Belegzeilen mit ausgelieferten Mengen.
pub lines: Vec<ErpLineQuantity>,
/// Geld-Gutschrift in **Cent** (0 = keine). Der Adapter rechnet daraus die
/// Menge der 10-€-Gutschrift-Einheiten.
pub credit_amount_cents: i64,
/// Code der gewählten Zahlungsmethode (`cash`/`ec_card`/`invoice`). Der
/// Adapter mappt das auf die ERP-Zahlungsbedingung (D16/D53/D10) und setzt
/// `Belegkopf.ZahlungsbedingungId`. `None` ⇒ Zahlungsbedingung bleibt.
pub payment_method_code: Option<String>,
}
#[async_trait]
pub trait ErpDeliveryWriteback: Send + Sync {
/// Schreibt den Lieferabschluss ins ERP zurück (eine MSSQL-Transaktion).
/// Idempotent: erneuter Aufruf mit gleichem Command ⇒ gleicher Endzustand.
async fn finish_delivery(
&self,
cmd: ErpFinishDeliveryCommand,
) -> Result<(), ApplicationError>;
}

View File

@ -6,17 +6,57 @@
//! `holzleitner-infrastructure`.
pub mod account_repository;
pub mod attachment_repository;
pub mod attachment_storage;
pub mod auth_service;
pub mod car_repository;
pub mod delivery_credit_repository;
pub mod delivery_note_repository;
pub mod delivery_report_job_repository;
pub mod delivery_report_renderer;
pub mod delivery_report_repository;
pub mod delivery_report_sink;
pub mod delivery_repository;
pub mod delivery_service_repository;
pub mod docuframe_report_gateway;
pub mod driver_identity_provisioner;
pub mod payment_method_repository;
pub mod delivery_completion_repository;
pub mod erp_delivery_source;
pub mod erp_delivery_writeback;
pub mod scan_repository;
pub mod service_repository;
pub mod signature_storage;
pub mod tour_repository;
pub use account_repository::AccountRepository;
pub use attachment_repository::{
AttachmentLocalRef, AttachmentRef, AttachmentRepository, NewAttachment,
};
pub use attachment_storage::{AttachmentStorage, PreviewImage};
pub use auth_service::{AuthError, AuthService, Claims};
pub use car_repository::CarRepository;
pub use delivery_credit_repository::DeliveryCreditRepository;
pub use delivery_note_repository::DeliveryNoteRepository;
pub use delivery_report_job_repository::{
DeliveryReportJobRepository, ReportJob, ReportJobStatus,
};
pub use delivery_report_renderer::DeliveryReportRenderer;
pub use delivery_report_repository::DeliveryReportRepository;
pub use delivery_report_sink::DeliveryReportSink;
pub use delivery_repository::{DeliveryAction, DeliveryRepository};
pub use delivery_service_repository::DeliveryServiceRepository;
pub use docuframe_report_gateway::DocuframeReportGateway;
pub use driver_identity_provisioner::{DriverIdentityProvisioner, ProvisionOutcome};
pub use delivery_completion_repository::{
CompleteDeliveryInput, DeliveryCompletionRepository, ErpWritebackData, ErpWritebackLine,
};
pub use erp_delivery_source::ErpDeliverySource;
pub use erp_delivery_writeback::{
ErpDeliveryWriteback, ErpFinishDeliveryCommand, ErpLineQuantity,
};
pub use payment_method_repository::PaymentMethodRepository;
pub use scan_repository::{ApplyScanOutcome, ScanRepository};
pub use service_repository::ServiceRepository;
pub use signature_storage::{SignatureRole, SignatureStorage};
pub use tour_repository::TourRepository;

View File

@ -0,0 +1,54 @@
//! Port für Zahlungs-Stammdaten.
//!
//! Im Gegensatz zu `cars` sind Zahlungsmethoden **global** — sie hängen
//! nicht an einem Account, sondern gelten für die ganze App. Daher
//! keine `account_id`-Parameter.
//!
//! Lösch-Verhalten: `delete` wirft `ApplicationError::Validation`, wenn
//! eine Lieferung die Methode noch referenziert (entsprechend dem
//! Datenbank-`ON DELETE RESTRICT`). Für „weiches Entfernen" gibt es
//! das `active`-Flag — wird per `update(active: Some(false))` gesetzt.
use async_trait::async_trait;
use uuid::Uuid;
use holzleitner_domain::PaymentMethod;
use crate::error::ApplicationError;
#[async_trait]
pub trait PaymentMethodRepository: Send + Sync {
/// Listet alle Methoden. `include_inactive = false` filtert
/// deaktivierte raus (Default für die App-UI).
async fn list(
&self,
include_inactive: bool,
) -> Result<Vec<PaymentMethod>, ApplicationError>;
async fn find_by_id(
&self,
id: Uuid,
) -> Result<Option<PaymentMethod>, ApplicationError>;
/// Legt eine neue Methode an. `code` muss eindeutig sein —
/// Duplikat → `Conflict("…already exists")` (HTTP 409).
async fn create(
&self,
code: &str,
name: &str,
) -> Result<PaymentMethod, ApplicationError>;
/// Optional-Patch. Beide `None`s = no-op.
async fn update(
&self,
id: Uuid,
name: Option<&str>,
active: Option<bool>,
) -> Result<PaymentMethod, ApplicationError>;
/// Hart löschen. Wirft `Conflict("payment method is in use")`
/// (→ HTTP 409), wenn noch Lieferungen darauf zeigen — der
/// FK-RESTRICT regelt das auf DB-Ebene, der Adapter übersetzt den
/// Pg-Fehler.
async fn delete(&self, id: Uuid) -> Result<(), ApplicationError>;
}

View File

@ -0,0 +1,44 @@
//! Port für Service-Stammdaten (admin-konfigurierbar, global — keine
//! Account-Isolation, Muster wie `PaymentMethodRepository`).
use async_trait::async_trait;
use uuid::Uuid;
use holzleitner_domain::{Service, ServiceKind};
use crate::error::ApplicationError;
#[async_trait]
pub trait ServiceRepository: Send + Sync {
/// Listet Services, sortiert nach `sort_order`. `include_inactive = false`
/// filtert deaktivierte raus (Default für die App).
async fn list(&self, include_inactive: bool) -> Result<Vec<Service>, ApplicationError>;
async fn find_by_id(&self, id: Uuid) -> Result<Option<Service>, ApplicationError>;
/// Legt einen Service an. `key`-Duplikat → `Conflict`.
async fn create(
&self,
key: &str,
name: &str,
kind: ServiceKind,
min_value: Option<i32>,
max_value: Option<i32>,
sort_order: i32,
) -> Result<Service, ApplicationError>;
/// Teil-Update. `kind` ist nicht änderbar.
async fn update(
&self,
id: Uuid,
name: Option<&str>,
min_value: Option<i32>,
max_value: Option<i32>,
active: Option<bool>,
sort_order: Option<i32>,
) -> Result<Service, ApplicationError>;
/// Hart löschen. `Conflict`, wenn noch eine Lieferung den Service
/// referenziert (FK `ON DELETE RESTRICT`) — dann lieber deaktivieren.
async fn delete(&self, id: Uuid) -> Result<(), ApplicationError>;
}

View File

@ -0,0 +1,54 @@
//! Port für den Unterschriften-Speicher.
//!
//! Im Gegensatz zu Notiz-Bildern (die nach DOCUframe gehen) liegen
//! Unterschriften bewusst **lokal im Backend-Server** — ein einfacher
//! Datei-Speicher reicht, und die Daten verlassen die Maschine nicht.
//!
//! Die konkrete Impl (lokales Dateisystem) lebt in
//! `holzleitner-infrastructure`. Der Use Case erhält eine relative
//! Referenz zurück, die in `delivery_completions` persistiert wird.
use async_trait::async_trait;
use uuid::Uuid;
use crate::error::ApplicationError;
/// Wer hat unterschrieben — bestimmt den Dateinamen.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SignatureRole {
Customer,
Driver,
}
impl SignatureRole {
pub fn as_str(self) -> &'static str {
match self {
SignatureRole::Customer => "customer",
SignatureRole::Driver => "driver",
}
}
}
#[async_trait]
pub trait SignatureStorage: Send + Sync {
/// Speichert eine Unterschrift (PNG-Bytes) für eine Lieferung+Rolle und
/// liefert die persistente, relative Referenz (Dateiname) zurück.
/// Deterministisch über `delivery_id`+`role` — ein Retry überschreibt
/// dieselbe Datei statt Müll anzuhäufen.
async fn save(
&self,
delivery_id: Uuid,
role: SignatureRole,
bytes: Vec<u8>,
) -> Result<String, ApplicationError>;
/// Lädt die gespeicherten PNG-Bytes einer Unterschrift über ihre relative
/// Referenz (Dateiname, wie von [`save`](Self::save) geliefert) — fürs
/// Einbetten in den PDF-Report. `None`, wenn die Datei fehlt.
async fn load(&self, reference: &str) -> Result<Option<Vec<u8>>, ApplicationError>;
/// Löscht beide Unterschriften (Kunde + Fahrer) einer Lieferung — Aufräumen
/// nach erfolgreichem Report-Upload (die Unterschriften stecken dann
/// eingebettet im PDF in DOCUframe). Idempotent (fehlende Datei = ok).
async fn delete_for_delivery(&self, delivery_id: Uuid) -> Result<(), ApplicationError>;
}

View File

@ -49,4 +49,10 @@ pub trait TourRepository: Send + Sync {
tour_id: Uuid,
delivery_ids: &[Uuid],
) -> Result<Vec<DeliveryOrderEntry>, ApplicationError>;
/// **DEV-ONLY**: Löscht alle Touren (und per FK-Cascade alle Lieferungen,
/// Positionen, Scans, Abschlüsse, Gutschriften, Notizen). Dient dem
/// Dev-Resync, der die Postgres-Daten vor einem frischen Import platt
/// macht. Gibt die Anzahl gelöschter Touren zurück.
async fn delete_all_tours(&self) -> Result<u64, ApplicationError>;
}

View File

@ -0,0 +1,85 @@
use std::sync::Arc;
use uuid::Uuid;
use holzleitner_domain::DeliveryCredit;
use crate::dto::{CreditAction, DeliveryCreditEventRequest};
use crate::error::ApplicationError;
use crate::ports::{CarRepository, DeliveryCreditRepository};
/// Obergrenze der Betrags-Gutschrift in Cent (150 €).
const MAX_CREDIT_CENTS: i64 = 15_000;
/// Wendet ein Gutschrift-Ereignis (`set`/`remove`) auf eine Lieferung an.
///
/// Validierung (fachlich, ohne DB):
/// * `Set`: Betrag Pflicht, `0 < amount ≤ 150 €` (beliebiger Betrag, keine
/// Schrittweite); Begründung Pflicht (nicht leer).
/// * `author_car_id` muss — falls gesetzt — zum Account gehören.
///
/// Den `active`-Check der Lieferung und die Idempotenz (`client_event_id`)
/// übernimmt das Repository mit der gelockten Zeile.
pub struct ApplyDeliveryCreditEventUseCase {
repository: Arc<dyn DeliveryCreditRepository>,
cars: Arc<dyn CarRepository>,
}
impl ApplyDeliveryCreditEventUseCase {
pub fn new(
repository: Arc<dyn DeliveryCreditRepository>,
cars: Arc<dyn CarRepository>,
) -> Self {
Self { repository, cars }
}
pub async fn execute(
&self,
delivery_id: Uuid,
author_personalnummer: i64,
request: DeliveryCreditEventRequest,
) -> Result<Option<DeliveryCredit>, ApplicationError> {
if let Some(car_id) = request.author_car_id {
self.cars
.assert_owned_by_account(&[car_id], author_personalnummer)
.await?;
}
let (amount_cents, reason) = match request.action {
CreditAction::Set => {
let amount = request.amount_cents.ok_or_else(|| {
ApplicationError::Validation("amount_cents required for set".into())
})?;
if amount <= 0 || amount > MAX_CREDIT_CENTS {
return Err(ApplicationError::Validation(format!(
"amount_cents must be in (0, {MAX_CREDIT_CENTS}]"
)));
}
let reason = request
.reason
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
ApplicationError::Validation("reason required for set".into())
})?
.to_owned();
(amount, Some(reason))
}
// Remove: Betrag/Grund irrelevant.
CreditAction::Remove => (0, None),
};
self.repository
.apply_event(
delivery_id,
request.client_event_id,
request.action,
amount_cents,
reason,
author_personalnummer,
request.author_car_id,
)
.await
}
}

View File

@ -99,8 +99,21 @@ impl ApplyScansUseCase {
}
/// Validiert Pflichtfelder ohne DB-Aufruf. Liefert `Some(reason)`,
/// wenn das Event verworfen werden soll.
/// wenn das Event verworfen werden soll. Mengen- und Status-abhängige
/// Bounds (z. B. `credited + quantity <= required`, scannbar ⇒ done,
/// Lieferung aktiv) prüft erst das Repository mit dem gelockten Item.
fn pre_validate(event: &ScanEvent) -> Option<String> {
// Eine gesetzte Menge muss positiv sein — und ist nur für die
// Mengen-Gutschrift (Remove/Unremove) überhaupt sinnvoll.
if let Some(q) = event.quantity {
match event.action {
AuditAction::Remove | AuditAction::Unremove if q <= 0 => {
return Some("quantity must be > 0".into());
}
_ => {}
}
}
match event.action {
AuditAction::Hold | AuditAction::Remove => {
let trimmed = event.reason.as_deref().map(str::trim).unwrap_or("");
@ -113,6 +126,9 @@ fn pre_validate(event: &ScanEvent) -> Option<String> {
None
}
}
AuditAction::Scan | AuditAction::Unscan | AuditAction::Unhold => None,
AuditAction::Scan
| AuditAction::Unscan
| AuditAction::Unhold
| AuditAction::Unremove => None,
}
}

View File

@ -0,0 +1,115 @@
use std::sync::Arc;
use uuid::Uuid;
use holzleitner_domain::Delivery;
use crate::dto::CompleteDeliveryAcknowledgements;
use crate::error::ApplicationError;
use crate::ports::{
CarRepository, CompleteDeliveryInput, DeliveryCompletionRepository, SignatureRole,
SignatureStorage,
};
use crate::usecases::PushCompletionToErpUseCase;
/// Schließt eine Lieferung ab: speichert beide Unterschriften lokal und
/// schreibt — atomar im Repository — die Abschluss-Zeile + den Statuswechsel
/// auf `completed`.
///
/// Reihenfolge bewusst: erst die fachlichen Vor-Prüfungen ohne DB, dann die
/// Dateien schreiben, dann das Repository (das die DB-abhängigen Gates unter
/// Lock prüft). Schlägt das Repo-Gate fehl, bleiben höchstens die beiden
/// deterministisch benannten PNG-Dateien liegen — ein erneuter Versuch
/// überschreibt sie, es entsteht kein Müll.
pub struct CompleteDeliveryUseCase {
repository: Arc<dyn DeliveryCompletionRepository>,
signatures: Arc<dyn SignatureStorage>,
cars: Arc<dyn CarRepository>,
/// Optionales ERP-Rückschreiben. `None` ⇒ rein lokaler Abschluss
/// (ERP_WRITEBACK_ENABLED=false / Dev / Seed-Daten ohne ERP-Beleg).
erp_push: Option<Arc<PushCompletionToErpUseCase>>,
}
impl CompleteDeliveryUseCase {
pub fn new(
repository: Arc<dyn DeliveryCompletionRepository>,
signatures: Arc<dyn SignatureStorage>,
cars: Arc<dyn CarRepository>,
erp_push: Option<Arc<PushCompletionToErpUseCase>>,
) -> Self {
Self {
repository,
signatures,
cars,
erp_push,
}
}
pub async fn execute(
&self,
delivery_id: Uuid,
author_personalnummer: i64,
acknowledgements: CompleteDeliveryAcknowledgements,
customer_signature_png: Vec<u8>,
driver_signature_png: Vec<u8>,
) -> Result<Delivery, ApplicationError> {
// --- Vor-Prüfungen ohne DB ----------------------------------------
if !acknowledgements.receipt_confirmed {
return Err(ApplicationError::Validation(
"receipt must be confirmed before completion".into(),
));
}
if customer_signature_png.is_empty() {
return Err(ApplicationError::Validation(
"customer signature is required".into(),
));
}
if driver_signature_png.is_empty() {
return Err(ApplicationError::Validation(
"driver signature is required".into(),
));
}
if let Some(car_id) = acknowledgements.author_car_id {
self.cars
.assert_owned_by_account(&[car_id], author_personalnummer)
.await?;
}
// --- Signaturen lokal speichern -----------------------------------
let customer_signature_path = self
.signatures
.save(delivery_id, SignatureRole::Customer, customer_signature_png)
.await?;
let driver_signature_path = self
.signatures
.save(delivery_id, SignatureRole::Driver, driver_signature_png)
.await?;
// --- Atomarer Abschluss im Repository -----------------------------
let delivery = self
.repository
.complete(CompleteDeliveryInput {
delivery_id,
customer_signature_path,
driver_signature_path,
receipt_confirmed: acknowledgements.receipt_confirmed,
notes_acknowledged: acknowledgements.notes_acknowledged,
acknowledged_note_ids: acknowledgements.acknowledged_note_ids,
payment_collected: acknowledgements.payment_collected,
payment_method_id: acknowledgements.payment_method_id,
completed_by_personalnummer: author_personalnummer,
completed_by_car_id: acknowledgements.author_car_id,
})
.await?;
// --- ERP-Rückschreiben (optional, nach lokalem Commit) ------------
// Idempotent → ein Fehler hier lässt den lokalen Abschluss bestehen;
// der Aufrufer bekommt den Fehler (502) und kann via Admin-Endpunkt
// `POST /admin/push-completion` erneut pushen.
if let Some(push) = &self.erp_push {
push.execute(delivery_id).await?;
}
Ok(delivery)
}
}

View File

@ -55,6 +55,8 @@ impl CreateDeliveryNoteUseCase {
request.author_car_id,
text,
image,
request.credit_delivery_item_id,
request.is_amount_credit_note,
)
.await
}

View File

@ -0,0 +1,24 @@
use std::sync::Arc;
use uuid::Uuid;
use crate::error::ApplicationError;
use crate::ports::DeliveryNoteRepository;
/// Löscht eine Notiz. `NotFound`, wenn keine Zeile betroffen war.
///
/// Berechtigung: keine Autor-Prüfung (geteilter Account) — analog zu
/// [`super::update_delivery_note::UpdateDeliveryNoteUseCase`].
pub struct DeleteDeliveryNoteUseCase {
repository: Arc<dyn DeliveryNoteRepository>,
}
impl DeleteDeliveryNoteUseCase {
pub fn new(repository: Arc<dyn DeliveryNoteRepository>) -> Self {
Self { repository }
}
pub async fn execute(&self, note_id: Uuid) -> Result<(), ApplicationError> {
self.repository.delete(note_id).await
}
}

View File

@ -0,0 +1,37 @@
use std::sync::Arc;
use chrono::NaiveDate;
use crate::error::ApplicationError;
use crate::ports::TourRepository;
use crate::usecases::{ImportErpToursUseCase, ImportSummary};
/// **DEV-ONLY**: „Überschreibender" Sync für die lokale Entwicklung.
///
/// Anders als der produktive Import (idempotenter Upsert, der den Scan-/
/// Abschluss-Status bewusst erhält) macht dieser Use Case die Postgres-
/// Tourdaten zuerst **platt** (`delete_all_tours` → FK-Cascade) und importiert
/// dann frisch aus dem ERP. So liefert ein wiederholter Sync desselben Tages in
/// Dev garantiert einen sauberen Stand — ohne Reste aus vorherigen
/// Abschluss-Tests (Status `completed`, Gutschrift-Zeilen, Scans …).
///
/// In Produktion wird das **nicht** verwendet: dort läuft der Sync einmal
/// täglich für den Folgetag (zentral geplante, frische Belege).
pub struct DevResyncToursUseCase {
tours: Arc<dyn TourRepository>,
import: Arc<ImportErpToursUseCase>,
}
impl DevResyncToursUseCase {
pub fn new(tours: Arc<dyn TourRepository>, import: Arc<ImportErpToursUseCase>) -> Self {
Self { tours, import }
}
/// Wischt alle Tourdaten und importiert das Datum neu. Gibt die
/// Import-Zusammenfassung zurück. (Logging übernimmt die API-Schicht.)
pub async fn execute(&self, date: NaiveDate) -> Result<ImportSummary, ApplicationError> {
let _deleted = self.tours.delete_all_tours().await?;
let summary = self.import.execute(date).await?;
Ok(summary)
}
}

View File

@ -0,0 +1,92 @@
use std::sync::Arc;
use uuid::Uuid;
use crate::error::ApplicationError;
use crate::ports::{
AttachmentStorage, DeliveryReportRenderer, DeliveryReportRepository, DeliveryReportSink,
SignatureStorage,
};
/// Erzeugt den PDF-Lieferreport: lädt alle Daten + Audit-Trails, hängt die
/// Bild-Bytes (Unterschriften, Foto-Notizen) aus dem lokalen Speicher an,
/// rendert das PDF und übergibt es dem Sink (lokal ablegen / später DOCUframe).
///
/// Wird sowohl beim Lieferabschluss (best-effort) als auch vom Dev-Endpoint
/// genutzt. Gibt die Sink-Referenz (z. B. den Dateipfad) zurück.
pub struct GenerateDeliveryReportUseCase {
repo: Arc<dyn DeliveryReportRepository>,
renderer: Arc<dyn DeliveryReportRenderer>,
sink: Arc<dyn DeliveryReportSink>,
signatures: Arc<dyn SignatureStorage>,
attachments: Arc<dyn AttachmentStorage>,
}
impl GenerateDeliveryReportUseCase {
pub fn new(
repo: Arc<dyn DeliveryReportRepository>,
renderer: Arc<dyn DeliveryReportRenderer>,
sink: Arc<dyn DeliveryReportSink>,
signatures: Arc<dyn SignatureStorage>,
attachments: Arc<dyn AttachmentStorage>,
) -> Self {
Self {
repo,
renderer,
sink,
signatures,
attachments,
}
}
/// Lädt die Daten, bettet die lokalen Bild-/Signatur-Bytes ein und rendert
/// das PDF **in-memory**. Liefert `(Belegnummer, PDF)`. Wird vom Dev-Sink
/// und von der DOCUframe-Upload-Pipeline genutzt.
pub async fn render_pdf(
&self,
delivery_id: Uuid,
) -> Result<(String, Vec<u8>), ApplicationError> {
let mut data = self
.repo
.load(delivery_id)
.await?
.ok_or(ApplicationError::NotFound)?;
// Unterschriften-Bytes anhängen (best-effort — fehlt eine Datei,
// bleibt das Bild im Report einfach weg).
if let Some(completion) = &data.completion {
data.customer_signature_png = self
.signatures
.load(&completion.customer_signature_path)
.await
.ok()
.flatten();
data.driver_signature_png = self
.signatures
.load(&completion.driver_signature_path)
.await
.ok()
.flatten();
}
// Anhang-Bytes anhängen (best-effort).
for att in data.attachments.iter_mut() {
if let Ok(img) = self
.attachments
.download_preview(&att.reference, "", "1")
.await
{
att.bytes = Some(img.bytes);
}
}
let pdf = self.renderer.render(&data)?;
Ok((data.belegnummer, pdf))
}
pub async fn execute(&self, delivery_id: Uuid) -> Result<String, ApplicationError> {
let (belegnummer, pdf) = self.render_pdf(delivery_id).await?;
let reference = self.sink.deliver(&belegnummer, pdf).await?;
Ok(reference)
}
}

View File

@ -0,0 +1,43 @@
use std::sync::Arc;
use uuid::Uuid;
use crate::error::ApplicationError;
use crate::ports::{AttachmentRepository, AttachmentStorage, PreviewImage};
/// Lädt ein gerendertes Vorschaubild zu einem Attachment.
///
/// Löst unsere Attachment-Id zur DOCUframe-`~ObjectID` auf und holt darüber
/// die Bytes aus dem Speicher. `NotFound`, wenn die Id unbekannt ist.
pub struct GetAttachmentPreviewUseCase {
attachments: Arc<dyn AttachmentRepository>,
storage: Arc<dyn AttachmentStorage>,
}
impl GetAttachmentPreviewUseCase {
pub fn new(
attachments: Arc<dyn AttachmentRepository>,
storage: Arc<dyn AttachmentStorage>,
) -> Self {
Self {
attachments,
storage,
}
}
pub async fn execute(
&self,
id: Uuid,
parameters: String,
page: String,
) -> Result<PreviewImage, ApplicationError> {
let attachment = self
.attachments
.get(id)
.await?
.ok_or(ApplicationError::NotFound)?;
self.storage
.download_preview(&attachment.docuframe_object_id, &parameters, &page)
.await
}
}

View File

@ -0,0 +1,110 @@
use std::sync::Arc;
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use crate::error::ApplicationError;
use crate::ports::{DriverIdentityProvisioner, ErpDeliverySource};
use crate::usecases::SyncTourUseCase;
/// Ergebnis eines Import-Laufs — pro Fahrer-Tour Erfolg/Fehler getrennt,
/// damit ein einzelner kaputter Beleg nicht den ganzen Tag blockiert.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ImportSummary {
pub date: NaiveDate,
pub tours_total: usize,
pub tours_ok: usize,
pub tours_failed: usize,
/// Fehlertexte je fehlgeschlagener Fahrer-Tour (z. B. unbekannter Fahrer
/// → FK auf `accounts`, oder Validierungsfehler).
pub errors: Vec<String>,
/// Anzahl der **neu** im Identity-Provider (Keycloak) angelegten
/// Fahrer-Konten in diesem Lauf (0, wenn Provisionierung deaktiviert ist
/// oder alle Konten bereits existierten).
#[serde(default)]
pub drivers_provisioned: usize,
/// Fehlertexte der Konto-Provisionierung (Keycloak). Best-effort: ein
/// Fehler hier blockiert den Touren-Import **nicht**.
#[serde(default)]
pub provisioning_errors: Vec<String>,
}
/// Zieht die Tagestouren eines Datums aus dem ERP und schreibt sie über den
/// **bestehenden** Sync-Pfad (`SyncTourUseCase` → `upsert_from_sync`) in unser
/// Postgres. Damit teilt der Import dieselbe Validierung + Upsert-Logik wie der
/// HTTP-Endpoint `POST /sync/tour` — eine Wahrheit, kein zweiter Schreibweg.
///
/// Fehlertoleranz: jede Fahrer-Tour wird einzeln verarbeitet. Schlägt eine fehl
/// (häufigster Fall: `Vertreter` ist kein angelegter Account → FK-Fehler), wird
/// sie geloggt + übersprungen, der Rest läuft weiter.
pub struct ImportErpToursUseCase {
source: Arc<dyn ErpDeliverySource>,
sync_tour: Arc<SyncTourUseCase>,
/// Optionaler Identity-Provisioner (Keycloak). `None` ⇒ Konto-Anlage
/// deaktiviert (`KEYCLOAK_PROVISIONING_ENABLED=false`).
provisioner: Option<Arc<dyn DriverIdentityProvisioner>>,
}
impl ImportErpToursUseCase {
pub fn new(
source: Arc<dyn ErpDeliverySource>,
sync_tour: Arc<SyncTourUseCase>,
provisioner: Option<Arc<dyn DriverIdentityProvisioner>>,
) -> Self {
Self {
source,
sync_tour,
provisioner,
}
}
pub async fn execute(&self, date: NaiveDate) -> Result<ImportSummary, ApplicationError> {
let tours = self.source.fetch_tours_for_date(date).await?;
let tours_total = tours.len();
let mut tours_ok = 0usize;
let mut errors: Vec<String> = Vec::new();
let mut drivers_provisioned = 0usize;
let mut provisioning_errors: Vec<String> = Vec::new();
for request in tours {
let driver = request.driver_personalnummer;
let deliveries = request.deliveries.len();
match self.sync_tour.execute(request).await {
Ok(_) => {
tours_ok += 1;
// Fahrer-Konto im IdP sicherstellen (best-effort): ein
// Fehler hier wird protokolliert, blockiert aber den Import
// nicht — Logistik geht vor.
if let Some(provisioner) = &self.provisioner {
let name = format!("Fahrer {driver}");
match provisioner.ensure_driver(driver, Some(&name)).await {
Ok(outcome) => {
if outcome.created {
drivers_provisioned += 1;
}
}
Err(e) => {
provisioning_errors.push(format!("driver {driver}: {e}"));
}
}
}
}
Err(e) => {
errors.push(format!("driver {driver} ({deliveries} Lieferungen): {e}"));
}
}
}
Ok(ImportSummary {
date,
tours_total,
tours_ok,
tours_failed: errors.len(),
errors,
drivers_provisioned,
provisioning_errors,
})
}
}

View File

@ -0,0 +1,34 @@
//! Use Case: Belegnummern ausgelieferter (abgeschlossener) Lieferungen
//! auflisten, **deren Liefermail noch nicht versendet wurde**.
//!
//! Reine Lese-Operation für den Admin-/Betriebs-Endpunkt + den externen
//! Mailclient. „Ausgeliefert" = es existiert eine Abschluss-Zeile
//! (`delivery_completions`); „offen" = `mail_sent_at IS NULL`. Optionaler
//! Tagesfilter über den Abschluss-Zeitpunkt (`completed_at`, Zeitzone
//! Europe/Berlin); `None` ⇒ alle offenen Belege. TZ-/Filter-Logik im Repository.
use std::sync::Arc;
use chrono::NaiveDate;
use crate::error::ApplicationError;
use crate::ports::DeliveryCompletionRepository;
pub struct ListDeliveredBelegnummernUseCase {
completions: Arc<dyn DeliveryCompletionRepository>,
}
impl ListDeliveredBelegnummernUseCase {
pub fn new(completions: Arc<dyn DeliveryCompletionRepository>) -> Self {
Self { completions }
}
/// Liefert die Belegnummern offener (noch nicht versendeter) Lieferungen.
/// `Some(day)` ⇒ nur Abschlüsse dieses Tages, `None` ⇒ alle offenen.
pub async fn execute(
&self,
day: Option<NaiveDate>,
) -> Result<Vec<String>, ApplicationError> {
self.completions.list_delivered_belegnummern(day).await
}
}

View File

@ -1,6 +1,6 @@
use std::sync::Arc;
use chrono::Utc;
use chrono::{NaiveDate, Utc};
use crate::dto::TourSummary;
use crate::error::ApplicationError;
@ -9,17 +9,27 @@ use crate::ports::TourRepository;
/// Liste der heutigen Touren des angemeldeten Fahrers. Das "heute"
/// liegt **bewusst im Backend**: die App-Uhr ist nicht autoritativ
/// (Zeitzone, Falsch-Stand, Manipulation).
///
/// `today_override` ist eine **DEV-ONLY**-Hintertür zum Testen mit
/// historischen/importierten Touren: ist sie gesetzt, wird statt der echten
/// Uhr dieses Datum verwendet. In Produktion `None`.
pub struct ListMyToursTodayUseCase {
repository: Arc<dyn TourRepository>,
today_override: Option<NaiveDate>,
}
impl ListMyToursTodayUseCase {
pub fn new(repository: Arc<dyn TourRepository>) -> Self {
Self { repository }
pub fn new(repository: Arc<dyn TourRepository>, today_override: Option<NaiveDate>) -> Self {
Self {
repository,
today_override,
}
}
pub async fn execute(&self, personalnummer: i64) -> Result<Vec<TourSummary>, ApplicationError> {
let today = Utc::now().date_naive();
let today = self
.today_override
.unwrap_or_else(|| Utc::now().date_naive());
self.repository
.find_today_for_driver(personalnummer, today)
.await

View File

@ -0,0 +1,41 @@
//! Use Case: Liefermails von Belegnummern als **versendet** markieren.
//!
//! Wird vom externen Mailclient aufgerufen, NACHDEM ERPframe die Mails für die
//! Belege erfolgreich verschickt hat. Setzt `delivery_completions.mail_sent_at`
//! (nur wo noch NULL → idempotent) und sorgt damit dafür, dass dieselben Belege
//! beim nächsten Poll nicht erneut zurückgegeben werden (server-seitiges Dedup).
use std::sync::Arc;
use crate::error::ApplicationError;
use crate::ports::DeliveryCompletionRepository;
pub struct MarkMailSentUseCase {
completions: Arc<dyn DeliveryCompletionRepository>,
}
impl MarkMailSentUseCase {
pub fn new(completions: Arc<dyn DeliveryCompletionRepository>) -> Self {
Self { completions }
}
/// Markiert die angegebenen Belegnummern als mail-versendet und liefert die
/// Anzahl frisch markierter (vorher offener) Belege zurück. Leere Eingabe
/// ⇒ 0, ohne DB-Zugriff.
pub async fn execute(
&self,
belegnummern: Vec<String>,
) -> Result<u64, ApplicationError> {
self.completions.mark_mail_sent(&belegnummern).await
}
/// **DEV-ONLY**: hebt die Markierung wieder auf (`mail_sent_at = NULL`),
/// sodass die Belege erneut als offen erscheinen. Anzahl zurückgesetzter
/// Belege als Rückgabe.
pub async fn unmark(
&self,
belegnummern: Vec<String>,
) -> Result<u64, ApplicationError> {
self.completions.unmark_mail_sent(&belegnummern).await
}
}

View File

@ -6,23 +6,59 @@
//! entgegen und orchestrieren damit das Domänenmodell.
pub mod apply_delivery_action;
pub mod apply_delivery_credit_event;
pub mod apply_scans;
pub mod cars;
pub mod complete_delivery;
pub mod create_delivery_note;
pub mod delete_delivery_note;
pub mod dev_resync_tours;
pub mod generate_delivery_report;
pub mod get_account;
pub mod get_attachment_preview;
pub mod get_tour;
pub mod import_erp_tours;
pub mod list_delivered_belegnummern;
pub mod list_my_tours_today;
pub mod mark_mail_sent;
pub mod payment_methods;
pub mod process_delivery_report;
pub mod push_completion_to_erp;
pub mod services;
pub mod set_delivery_order;
pub mod sync_tour;
pub mod update_delivery_note;
pub mod upload_delivery_note_image;
pub use apply_delivery_action::ApplyDeliveryActionUseCase;
pub use apply_delivery_credit_event::ApplyDeliveryCreditEventUseCase;
pub use apply_scans::ApplyScansUseCase;
pub use cars::{
AssignCarToDeliveryUseCase, CreateMyCarUseCase, ListMyCarsUseCase, UpdateMyCarUseCase,
};
pub use complete_delivery::CompleteDeliveryUseCase;
pub use create_delivery_note::CreateDeliveryNoteUseCase;
pub use dev_resync_tours::DevResyncToursUseCase;
pub use generate_delivery_report::GenerateDeliveryReportUseCase;
pub use delete_delivery_note::DeleteDeliveryNoteUseCase;
pub use get_account::GetAccountUseCase;
pub use get_attachment_preview::GetAttachmentPreviewUseCase;
pub use get_tour::GetTourUseCase;
pub use import_erp_tours::{ImportErpToursUseCase, ImportSummary};
pub use list_delivered_belegnummern::ListDeliveredBelegnummernUseCase;
pub use list_my_tours_today::ListMyToursTodayUseCase;
pub use mark_mail_sent::MarkMailSentUseCase;
pub use payment_methods::{
CreatePaymentMethodUseCase, DeletePaymentMethodUseCase, ListPaymentMethodsUseCase,
UpdatePaymentMethodUseCase,
};
pub use process_delivery_report::ProcessDeliveryReportUseCase;
pub use push_completion_to_erp::PushCompletionToErpUseCase;
pub use services::{
CreateServiceUseCase, DeleteDeliveryServiceUseCase, DeleteServiceUseCase,
ListServicesUseCase, SetDeliveryServiceUseCase, UpdateServiceUseCase,
};
pub use set_delivery_order::SetDeliveryOrderUseCase;
pub use sync_tour::SyncTourUseCase;
pub use update_delivery_note::UpdateDeliveryNoteUseCase;
pub use upload_delivery_note_image::UploadDeliveryNoteImageUseCase;

View File

@ -0,0 +1,106 @@
//! Use Cases rund um Zahlungs-Stammdaten.
//!
//! Global — keine Account-Isolation, weil Methoden firmenweit gelten.
//! Validierung beschränkt sich auf nicht-leere Strings; Eindeutigkeit
//! des `code` ist DB-Constraint, nicht hier dupliziert.
use std::sync::Arc;
use uuid::Uuid;
use holzleitner_domain::PaymentMethod;
use crate::dto::{CreatePaymentMethodRequest, UpdatePaymentMethodRequest};
use crate::error::ApplicationError;
use crate::ports::PaymentMethodRepository;
pub struct ListPaymentMethodsUseCase {
repository: Arc<dyn PaymentMethodRepository>,
}
impl ListPaymentMethodsUseCase {
pub fn new(repository: Arc<dyn PaymentMethodRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
include_inactive: bool,
) -> Result<Vec<PaymentMethod>, ApplicationError> {
self.repository.list(include_inactive).await
}
}
pub struct CreatePaymentMethodUseCase {
repository: Arc<dyn PaymentMethodRepository>,
}
impl CreatePaymentMethodUseCase {
pub fn new(repository: Arc<dyn PaymentMethodRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
request: CreatePaymentMethodRequest,
) -> Result<PaymentMethod, ApplicationError> {
let code = request.code.trim();
let name = request.name.trim();
if code.is_empty() {
return Err(ApplicationError::Validation(
"code darf nicht leer sein".into(),
));
}
if name.is_empty() {
return Err(ApplicationError::Validation(
"name darf nicht leer sein".into(),
));
}
self.repository.create(code, name).await
}
}
pub struct UpdatePaymentMethodUseCase {
repository: Arc<dyn PaymentMethodRepository>,
}
impl UpdatePaymentMethodUseCase {
pub fn new(repository: Arc<dyn PaymentMethodRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
id: Uuid,
request: UpdatePaymentMethodRequest,
) -> Result<PaymentMethod, ApplicationError> {
if let Some(name) = request.name.as_deref() {
if name.trim().is_empty() {
return Err(ApplicationError::Validation(
"name darf nicht leer sein".into(),
));
}
}
self.repository
.update(
id,
request.name.as_deref().map(str::trim),
request.active,
)
.await
}
}
pub struct DeletePaymentMethodUseCase {
repository: Arc<dyn PaymentMethodRepository>,
}
impl DeletePaymentMethodUseCase {
pub fn new(repository: Arc<dyn PaymentMethodRepository>) -> Self {
Self { repository }
}
pub async fn execute(&self, id: Uuid) -> Result<(), ApplicationError> {
self.repository.delete(id).await
}
}

View File

@ -0,0 +1,122 @@
//! Überträgt den PDF-Lieferreport an DOCUframe — idempotent & resume-fähig.
//!
//! Schritte (Fortschritt nach jedem Schritt hart in `delivery_report_jobs`):
//! 1+2. PDF in-memory rendern → nach DOCUframe hochladen → `~ObjectID` hart
//! speichern (`status = 'uploaded'`). Bei Retry übersprungen, wenn die
//! ObjectId schon vorliegt (kein Doppel-Upload).
//! 3. Makro `_SV_assignDeliveryReport` aufrufen (ordnet Report dem Beleg zu).
//! 4. Erfolg → lokale Dateien aufräumen (Report-PDF, Unterschriften,
//! Bild-Notizen + `deleted_at`), dann `status = 'done'`.
//!
//! Reihenfolge bei Schritt 4: erst aufräumen, dann `done`. Ein Crash dazwischen
//! lässt den Job auf `uploaded` → der Cron ruft das (idempotente) Makro erneut
//! und räumt erneut auf. So bleiben keine verwaisten lokalen Dateien zurück.
//!
//! Fehler in 13 werden im Job vermerkt (`attempts`/`last_error`) und der
//! Status bleibt auf der erreichten Stufe — der Retry-Cron nimmt offene Jobs
//! erneut auf.
use std::sync::Arc;
use uuid::Uuid;
use crate::error::ApplicationError;
use crate::ports::{
AttachmentRepository, AttachmentStorage, DeliveryReportJobRepository, DeliveryReportSink,
DocuframeReportGateway, ReportJobStatus, SignatureStorage,
};
use crate::usecases::GenerateDeliveryReportUseCase;
pub struct ProcessDeliveryReportUseCase {
generate: Arc<GenerateDeliveryReportUseCase>,
jobs: Arc<dyn DeliveryReportJobRepository>,
gateway: Arc<dyn DocuframeReportGateway>,
attachment_repo: Arc<dyn AttachmentRepository>,
attachment_storage: Arc<dyn AttachmentStorage>,
signatures: Arc<dyn SignatureStorage>,
report_sink: Arc<dyn DeliveryReportSink>,
}
impl ProcessDeliveryReportUseCase {
#[allow(clippy::too_many_arguments)]
pub fn new(
generate: Arc<GenerateDeliveryReportUseCase>,
jobs: Arc<dyn DeliveryReportJobRepository>,
gateway: Arc<dyn DocuframeReportGateway>,
attachment_repo: Arc<dyn AttachmentRepository>,
attachment_storage: Arc<dyn AttachmentStorage>,
signatures: Arc<dyn SignatureStorage>,
report_sink: Arc<dyn DeliveryReportSink>,
) -> Self {
Self {
generate,
jobs,
gateway,
attachment_repo,
attachment_storage,
signatures,
report_sink,
}
}
/// Verarbeitet einen Job (anlegen, falls nötig). Fehler werden im Job
/// vermerkt und zusätzlich propagiert (der Aufrufer loggt).
pub async fn execute(&self, delivery_id: Uuid) -> Result<(), ApplicationError> {
match self.run(delivery_id).await {
Ok(()) => Ok(()),
Err(e) => {
// Best-effort: Fehler im Job festhalten (für Cron-Retry/Sicht).
let _ = self.jobs.record_error(delivery_id, &e.to_string()).await;
Err(e)
}
}
}
async fn run(&self, delivery_id: Uuid) -> Result<(), ApplicationError> {
let belegnummer = self
.attachment_repo
.delivery_belegnummer(delivery_id)
.await?
.ok_or(ApplicationError::NotFound)?;
let job = self.jobs.ensure(delivery_id, &belegnummer).await?;
if matches!(job.status, ReportJobStatus::Done) {
return Ok(());
}
// Schritt 1+2: rendern + hochladen (überspringen, wenn schon erledigt).
let object_id = match job.docuframe_object_id {
Some(oid) => oid,
None => {
let (_beleg, pdf) = self.generate.render_pdf(delivery_id).await?;
let oid = self.gateway.upload_report_pdf(&belegnummer, pdf).await?;
self.jobs.set_uploaded(delivery_id, &oid).await?;
oid
}
};
// Schritt 3: Makro-Zuordnung (muss succeeded == true liefern).
self.gateway.assign_report(&object_id, &belegnummer).await?;
// Schritt 4: erst aufräumen, dann als erledigt markieren.
self.cleanup_local(delivery_id, &belegnummer).await;
self.jobs.mark_done(delivery_id).await?;
Ok(())
}
/// Aufräumen nach erfolgreichem Upload — best-effort (Fehler werden
/// geschluckt; der Report liegt bereits sicher in DOCUframe):
/// * lokale Report-PDFs
/// * Unterschriften (Kunde + Fahrer)
/// * Bild-Notizen (Datei löschen + `deleted_at` setzen, Metadaten bleiben)
async fn cleanup_local(&self, delivery_id: Uuid, belegnummer: &str) {
let _ = self.report_sink.delete(belegnummer).await;
let _ = self.signatures.delete_for_delivery(delivery_id).await;
if let Ok(refs) = self.attachment_repo.list_active_for_delivery(delivery_id).await {
for r in refs {
let _ = self.attachment_storage.delete(&r.reference).await;
let _ = self.attachment_repo.mark_deleted(r.id).await;
}
}
}
}

View File

@ -0,0 +1,59 @@
//! Use Case: einen **bereits lokal abgeschlossenen** Lieferabschluss ins ERP
//! zurückschreiben.
//!
//! Liest den aktuellen Postgres-Stand (ausgelieferte Mengen, Geld-Gutschrift,
//! Abschluss-Zeitpunkt) und spiegelt ihn über den `ErpDeliveryWriteback`-Port
//! in die ERPframe-MSSQL-DB. Bewusst **getrennt** vom lokalen Abschluss:
//!
//! * Der normale Pfad ruft diesen Use Case direkt nach erfolgreichem
//! `complete()` auf (Fehler ⇒ 502, lokal bleibt `completed`).
//! * Der Admin-Retry-Endpunkt ruft denselben Use Case erneut — da das
//! Rückschreiben idempotent ist, ist das gefahrlos.
use std::sync::Arc;
use uuid::Uuid;
use crate::error::ApplicationError;
use crate::ports::{
DeliveryCompletionRepository, ErpDeliveryWriteback, ErpFinishDeliveryCommand, ErpLineQuantity,
};
pub struct PushCompletionToErpUseCase {
completions: Arc<dyn DeliveryCompletionRepository>,
erp: Arc<dyn ErpDeliveryWriteback>,
}
impl PushCompletionToErpUseCase {
pub fn new(
completions: Arc<dyn DeliveryCompletionRepository>,
erp: Arc<dyn ErpDeliveryWriteback>,
) -> Self {
Self { completions, erp }
}
/// Schreibt den Abschluss der Lieferung ins ERP zurück. `NotFound`, wenn
/// die Lieferung nicht abgeschlossen ist; sonstige Fehler reichen den
/// MSSQL-/Repository-Fehler durch.
pub async fn execute(&self, delivery_id: Uuid) -> Result<(), ApplicationError> {
let data = self.completions.load_erp_writeback(delivery_id).await?;
let cmd = ErpFinishDeliveryCommand {
belegart_id: data.belegart_id,
belegnummer: data.belegnummer,
delivered_at: data.delivered_at,
lines: data
.lines
.into_iter()
.map(|l| ErpLineQuantity {
belegzeilen_nr: l.belegzeilen_nr,
delivered_quantity: l.delivered_quantity,
})
.collect(),
credit_amount_cents: data.credit_amount_cents,
payment_method_code: data.payment_method_code,
};
self.erp.finish_delivery(cmd).await
}
}

View File

@ -0,0 +1,249 @@
//! Use Cases rund um Services (Stammdaten-CRUD + Pro-Lieferung-Wert).
//!
//! Global — keine Account-Isolation. Eindeutigkeit des `key` ist
//! DB-Constraint; hier nur fachliche Validierung (nicht-leer, kind↔min/max,
//! Wert passend zum Typ + in Grenzen).
use std::sync::Arc;
use uuid::Uuid;
use holzleitner_domain::{DeliveryServiceValue, Service, ServiceKind};
use crate::dto::{CreateServiceRequest, SetDeliveryServiceRequest, UpdateServiceRequest};
use crate::error::ApplicationError;
use crate::ports::{DeliveryServiceRepository, ServiceRepository};
// ─── Stammdaten-CRUD ──────────────────────────────────────────────────────
pub struct ListServicesUseCase {
repository: Arc<dyn ServiceRepository>,
}
impl ListServicesUseCase {
pub fn new(repository: Arc<dyn ServiceRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
include_inactive: bool,
) -> Result<Vec<Service>, ApplicationError> {
self.repository.list(include_inactive).await
}
}
pub struct CreateServiceUseCase {
repository: Arc<dyn ServiceRepository>,
}
impl CreateServiceUseCase {
pub fn new(repository: Arc<dyn ServiceRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
request: CreateServiceRequest,
) -> Result<Service, ApplicationError> {
let key = request.key.trim();
let name = request.name.trim();
if key.is_empty() {
return Err(ApplicationError::Validation("key darf nicht leer sein".into()));
}
if name.is_empty() {
return Err(ApplicationError::Validation("name darf nicht leer sein".into()));
}
// boolean trägt keine Grenzen.
let (min_value, max_value) = match request.kind {
ServiceKind::Boolean => {
if request.min_value.is_some() || request.max_value.is_some() {
return Err(ApplicationError::Validation(
"boolean-Service darf keine min/max-Werte haben".into(),
));
}
(None, None)
}
ServiceKind::Numeric => {
if let (Some(min), Some(max)) = (request.min_value, request.max_value) {
if min > max {
return Err(ApplicationError::Validation(
"min_value darf nicht größer als max_value sein".into(),
));
}
}
(request.min_value, request.max_value)
}
};
self.repository
.create(
key,
name,
request.kind,
min_value,
max_value,
request.sort_order.unwrap_or(0),
)
.await
}
}
pub struct UpdateServiceUseCase {
repository: Arc<dyn ServiceRepository>,
}
impl UpdateServiceUseCase {
pub fn new(repository: Arc<dyn ServiceRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
id: Uuid,
request: UpdateServiceRequest,
) -> Result<Service, ApplicationError> {
if let Some(name) = request.name.as_deref() {
if name.trim().is_empty() {
return Err(ApplicationError::Validation("name darf nicht leer sein".into()));
}
}
if let (Some(min), Some(max)) = (request.min_value, request.max_value) {
if min > max {
return Err(ApplicationError::Validation(
"min_value darf nicht größer als max_value sein".into(),
));
}
}
self.repository
.update(
id,
request.name.as_deref().map(str::trim),
request.min_value,
request.max_value,
request.active,
request.sort_order,
)
.await
}
}
pub struct DeleteServiceUseCase {
repository: Arc<dyn ServiceRepository>,
}
impl DeleteServiceUseCase {
pub fn new(repository: Arc<dyn ServiceRepository>) -> Self {
Self { repository }
}
pub async fn execute(&self, id: Uuid) -> Result<(), ApplicationError> {
self.repository.delete(id).await
}
}
// ─── Pro-Lieferung-Wert ───────────────────────────────────────────────────
pub struct SetDeliveryServiceUseCase {
services: Arc<dyn ServiceRepository>,
delivery_services: Arc<dyn DeliveryServiceRepository>,
}
impl SetDeliveryServiceUseCase {
pub fn new(
services: Arc<dyn ServiceRepository>,
delivery_services: Arc<dyn DeliveryServiceRepository>,
) -> Self {
Self {
services,
delivery_services,
}
}
pub async fn execute(
&self,
delivery_id: Uuid,
service_id: Uuid,
author_personalnummer: i64,
request: SetDeliveryServiceRequest,
) -> Result<DeliveryServiceValue, ApplicationError> {
let service = self
.services
.find_by_id(service_id)
.await?
.ok_or(ApplicationError::NotFound)?;
if !service.active {
return Err(ApplicationError::Validation(
"service is inactive".into(),
));
}
// Wert muss zum Typ passen.
let (bool_value, numeric_value) = match service.kind {
ServiceKind::Boolean => {
let b = request.bool_value.ok_or_else(|| {
ApplicationError::Validation("boolValue required for boolean service".into())
})?;
if request.numeric_value.is_some() {
return Err(ApplicationError::Validation(
"numericValue not allowed for boolean service".into(),
));
}
(Some(b), None)
}
ServiceKind::Numeric => {
let n = request.numeric_value.ok_or_else(|| {
ApplicationError::Validation("numericValue required for numeric service".into())
})?;
if request.bool_value.is_some() {
return Err(ApplicationError::Validation(
"boolValue not allowed for numeric service".into(),
));
}
if let Some(min) = service.min_value {
if n < min {
return Err(ApplicationError::Validation(format!(
"numericValue {n} below min {min}"
)));
}
}
if let Some(max) = service.max_value {
if n > max {
return Err(ApplicationError::Validation(format!(
"numericValue {n} above max {max}"
)));
}
}
(None, Some(n))
}
};
self.delivery_services
.set(
delivery_id,
service_id,
bool_value,
numeric_value,
author_personalnummer,
request.author_car_id,
)
.await
}
}
pub struct DeleteDeliveryServiceUseCase {
delivery_services: Arc<dyn DeliveryServiceRepository>,
}
impl DeleteDeliveryServiceUseCase {
pub fn new(delivery_services: Arc<dyn DeliveryServiceRepository>) -> Self {
Self { delivery_services }
}
pub async fn execute(
&self,
delivery_id: Uuid,
service_id: Uuid,
) -> Result<(), ApplicationError> {
self.delivery_services.delete(delivery_id, service_id).await
}
}

View File

@ -0,0 +1,58 @@
use std::sync::Arc;
use uuid::Uuid;
use holzleitner_domain::DeliveryNote;
use crate::dto::UpdateDeliveryNoteRequest;
use crate::error::ApplicationError;
use crate::ports::DeliveryNoteRepository;
/// Ändert `text` / `image_attachment` einer bestehenden Notiz.
///
/// Validierung wie beim Anlegen: mindestens eines von `text` (nicht-leer
/// nach trim) und `image_attachment` muss gesetzt sein. Autor und
/// `created_at` bleiben unverändert.
///
/// Berechtigung: keine Autor-Prüfung — innerhalb eines (geteilten) Accounts
/// darf jeder Fahrer Notizen pflegen. Das entspricht dem Modell der übrigen
/// Delivery-Aktionen (hold/cancel/complete), die ebenfalls keinen
/// Autor-Bezug erzwingen.
pub struct UpdateDeliveryNoteUseCase {
repository: Arc<dyn DeliveryNoteRepository>,
}
impl UpdateDeliveryNoteUseCase {
pub fn new(repository: Arc<dyn DeliveryNoteRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
note_id: Uuid,
request: UpdateDeliveryNoteRequest,
) -> Result<DeliveryNote, ApplicationError> {
let text = clean(request.text);
let image = clean(request.image_attachment);
if text.is_none() && image.is_none() {
return Err(ApplicationError::Validation(
"notiz braucht text oder image_attachment".into(),
));
}
self.repository.update(note_id, text, image).await
}
}
/// Trim + leerer-String → None.
fn clean(input: Option<String>) -> Option<String> {
input.and_then(|s| {
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
})
}

View File

@ -0,0 +1,129 @@
use std::sync::Arc;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use holzleitner_domain::DeliveryNote;
use crate::error::ApplicationError;
use crate::ports::{
AttachmentRepository, AttachmentStorage, CarRepository, DeliveryNoteRepository, NewAttachment,
};
/// Lädt ein Bild zu einer Lieferung hoch, registriert dessen Metadaten und
/// legt dafür eine Bild-Notiz an.
///
/// Ablauf:
/// 1. Bytes analysieren (Größe, SHA-256, Bildabmessungen).
/// 2. Belegnummer der Lieferung auflösen (= Ordnername im Speicher).
/// 3. Datei lokal ablegen (`<dir>/<Belegnummer>/<datei>`) → Speicher-Referenz.
/// 4. Metadatensatz in `attachments` anlegen → unsere Attachment-Id.
/// 5. Notiz mit `image_attachment = <attachment_id>` anlegen (kein Text).
///
/// Die App referenziert nur die Attachment-Id; der Download-Endpoint löst sie
/// zur Speicher-Referenz auf. (Der DOCUframe-Upload bleibt im `GsdService`
/// erhalten, ist hier aber nicht mehr verdrahtet — Bilder gehen lokal.)
pub struct UploadDeliveryNoteImageUseCase {
storage: Arc<dyn AttachmentStorage>,
attachments: Arc<dyn AttachmentRepository>,
notes: Arc<dyn DeliveryNoteRepository>,
cars: Arc<dyn CarRepository>,
}
impl UploadDeliveryNoteImageUseCase {
pub fn new(
storage: Arc<dyn AttachmentStorage>,
attachments: Arc<dyn AttachmentRepository>,
notes: Arc<dyn DeliveryNoteRepository>,
cars: Arc<dyn CarRepository>,
) -> Self {
Self {
storage,
attachments,
notes,
cars,
}
}
pub async fn execute(
&self,
delivery_id: Uuid,
author_personalnummer: i64,
author_car_id: Option<Uuid>,
filename: String,
mime: String,
bytes: Vec<u8>,
) -> Result<DeliveryNote, ApplicationError> {
if bytes.is_empty() {
return Err(ApplicationError::Validation("leere datei".into()));
}
if let Some(car_id) = author_car_id {
self.cars
.assert_owned_by_account(&[car_id], author_personalnummer)
.await?;
}
// 1. Metadaten aus den Bytes ableiten.
let size_bytes = bytes.len() as i64;
let checksum_sha256 = sha256_hex(&bytes);
let (width, height) = match imagesize::blob_size(&bytes) {
Ok(dim) => (Some(dim.width as i32), Some(dim.height as i32)),
Err(_) => (None, None),
};
// 2. Belegnummer der Lieferung auflösen (= Ordnername im Speicher).
let belegnummer = self
.attachments
.delivery_belegnummer(delivery_id)
.await?
.ok_or(ApplicationError::NotFound)?;
// 3. Bytes lokal ablegen (Ordner = Belegnummer) → Speicher-Referenz.
let storage_reference = self
.storage
.upload(&belegnummer, &filename, &mime, bytes)
.await?;
// 4. Metadatensatz anlegen. `docuframe_object_id` trägt jetzt die
// lokale relative Speicher-Referenz (Spaltenname bleibt vorerst).
let attachment_id = self
.attachments
.create(NewAttachment {
docuframe_object_id: storage_reference,
mime_type: mime,
size_bytes,
filename: Some(filename),
checksum_sha256,
width,
height,
uploaded_by: author_personalnummer,
delivery_id,
})
.await?;
// 5. Bild-Notiz mit Verweis auf den Metadatensatz.
self.notes
.create(
delivery_id,
author_personalnummer,
author_car_id,
None,
Some(attachment_id.to_string()),
None, // Bild-Notiz hat keinen Mengen-Gutschrift-Bezug
false, // und ist keine Betrags-Gutschrift-Notiz
)
.await
}
}
/// SHA-256 der Bytes als Hex-String.
fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}