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:
40
crates/application/src/dto/complete.rs
Normal file
40
crates/application/src/dto/complete.rs
Normal 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>,
|
||||
}
|
||||
45
crates/application/src/dto/credit.rs
Normal file
45
crates/application/src/dto/credit.rs
Normal 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>,
|
||||
}
|
||||
149
crates/application/src/dto/delivery_report.rs
Normal file
149
crates/application/src/dto/delivery_report.rs
Normal 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>>,
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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)]
|
||||
|
||||
41
crates/application/src/dto/payment_method.rs
Normal file
41
crates/application/src/dto/payment_method.rs
Normal 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>,
|
||||
}
|
||||
@ -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
|
||||
|
||||
73
crates/application/src/dto/service.rs
Normal file
73
crates/application/src/dto/service.rs
Normal 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,
|
||||
}
|
||||
@ -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)]
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
|
||||
71
crates/application/src/ports/attachment_repository.rs
Normal file
71
crates/application/src/ports/attachment_repository.rs
Normal 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>;
|
||||
}
|
||||
54
crates/application/src/ports/attachment_storage.rs
Normal file
54
crates/application/src/ports/attachment_storage.rs
Normal 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>;
|
||||
}
|
||||
131
crates/application/src/ports/delivery_completion_repository.rs
Normal file
131
crates/application/src/ports/delivery_completion_repository.rs
Normal 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>;
|
||||
}
|
||||
36
crates/application/src/ports/delivery_credit_repository.rs
Normal file
36
crates/application/src/ports/delivery_credit_repository.rs
Normal 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>;
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
10
crates/application/src/ports/delivery_report_renderer.rs
Normal file
10
crates/application/src/ports/delivery_report_renderer.rs
Normal 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>;
|
||||
}
|
||||
19
crates/application/src/ports/delivery_report_repository.rs
Normal file
19
crates/application/src/ports/delivery_report_repository.rs
Normal 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>;
|
||||
}
|
||||
19
crates/application/src/ports/delivery_report_sink.rs
Normal file
19
crates/application/src/ports/delivery_report_sink.rs
Normal 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>;
|
||||
}
|
||||
36
crates/application/src/ports/delivery_service_repository.rs
Normal file
36
crates/application/src/ports/delivery_service_repository.rs
Normal 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>;
|
||||
}
|
||||
33
crates/application/src/ports/docuframe_report_gateway.rs
Normal file
33
crates/application/src/ports/docuframe_report_gateway.rs
Normal 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>;
|
||||
}
|
||||
36
crates/application/src/ports/driver_identity_provisioner.rs
Normal file
36
crates/application/src/ports/driver_identity_provisioner.rs
Normal 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>;
|
||||
}
|
||||
24
crates/application/src/ports/erp_delivery_source.rs
Normal file
24
crates/application/src/ports/erp_delivery_source.rs
Normal 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>;
|
||||
}
|
||||
64
crates/application/src/ports/erp_delivery_writeback.rs
Normal file
64
crates/application/src/ports/erp_delivery_writeback.rs
Normal 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>;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
54
crates/application/src/ports/payment_method_repository.rs
Normal file
54
crates/application/src/ports/payment_method_repository.rs
Normal 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>;
|
||||
}
|
||||
44
crates/application/src/ports/service_repository.rs
Normal file
44
crates/application/src/ports/service_repository.rs
Normal 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>;
|
||||
}
|
||||
54
crates/application/src/ports/signature_storage.rs
Normal file
54
crates/application/src/ports/signature_storage.rs
Normal 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>;
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
115
crates/application/src/usecases/complete_delivery.rs
Normal file
115
crates/application/src/usecases/complete_delivery.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -55,6 +55,8 @@ impl CreateDeliveryNoteUseCase {
|
||||
request.author_car_id,
|
||||
text,
|
||||
image,
|
||||
request.credit_delivery_item_id,
|
||||
request.is_amount_credit_note,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
24
crates/application/src/usecases/delete_delivery_note.rs
Normal file
24
crates/application/src/usecases/delete_delivery_note.rs
Normal 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
|
||||
}
|
||||
}
|
||||
37
crates/application/src/usecases/dev_resync_tours.rs
Normal file
37
crates/application/src/usecases/dev_resync_tours.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
92
crates/application/src/usecases/generate_delivery_report.rs
Normal file
92
crates/application/src/usecases/generate_delivery_report.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
43
crates/application/src/usecases/get_attachment_preview.rs
Normal file
43
crates/application/src/usecases/get_attachment_preview.rs
Normal 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, ¶meters, &page)
|
||||
.await
|
||||
}
|
||||
}
|
||||
110
crates/application/src/usecases/import_erp_tours.rs
Normal file
110
crates/application/src/usecases/import_erp_tours.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
41
crates/application/src/usecases/mark_mail_sent.rs
Normal file
41
crates/application/src/usecases/mark_mail_sent.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
106
crates/application/src/usecases/payment_methods.rs
Normal file
106
crates/application/src/usecases/payment_methods.rs
Normal 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
|
||||
}
|
||||
}
|
||||
122
crates/application/src/usecases/process_delivery_report.rs
Normal file
122
crates/application/src/usecases/process_delivery_report.rs
Normal 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 1–3 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
59
crates/application/src/usecases/push_completion_to_erp.rs
Normal file
59
crates/application/src/usecases/push_completion_to_erp.rs
Normal 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
|
||||
}
|
||||
}
|
||||
249
crates/application/src/usecases/services.rs
Normal file
249
crates/application/src/usecases/services.rs
Normal 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
|
||||
}
|
||||
}
|
||||
58
crates/application/src/usecases/update_delivery_note.rs
Normal file
58
crates/application/src/usecases/update_delivery_note.rs
Normal 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())
|
||||
}
|
||||
})
|
||||
}
|
||||
129
crates/application/src/usecases/upload_delivery_note_image.rs
Normal file
129
crates/application/src/usecases/upload_delivery_note_image.rs
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user