From caaf88ac3dfcd7c89b08a22b347f7194f08fafa8 Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Fri, 25 Sep 2026 14:28:41 +0200 Subject: [PATCH] feat(report): Zahlungsabwicklung im Lieferbericht dokumentieren - Abschnitt "Zahlung": Zahlungsart mit Ergebnis (erhalten / bestaetigt), Zahlbetrag, Zeitpunkt, erfassender Fahrer und Fahrzeug der beim Abschluss gueltigen Zahlung (vor dem Abschluss: juengster Eintrag). Abschluesse ohne Zahlungs-Step behalten die bisherige Zeile "Betrag erhalten" - Neuer Abschnitt "Protokoll - Zahlungsabwicklung" mit allen Eintraegen inkl. Korrekturen; Status "beim Abschluss" / "aktuell" / "ersetzt" - Report-Daten: ReportPayment + ReportCompletion.payment_id Co-Authored-By: Claude Opus 5.5 --- crates/application/src/dto/delivery_report.rs | 20 ++++ crates/application/src/dto/mod.rs | 2 +- crates/infrastructure/src/report/renderer.rs | 94 ++++++++++++++++++- .../infrastructure/src/report/repository.rs | 47 +++++++++- 4 files changed, 156 insertions(+), 7 deletions(-) diff --git a/crates/application/src/dto/delivery_report.rs b/crates/application/src/dto/delivery_report.rs index 08436f4..a09b04d 100644 --- a/crates/application/src/dto/delivery_report.rs +++ b/crates/application/src/dto/delivery_report.rs @@ -7,6 +7,7 @@ //! damit der Renderer rein (ohne IO) bleibt. use chrono::{DateTime, NaiveDate, Utc}; +use uuid::Uuid; #[derive(Debug, Clone)] pub struct DeliveryReportData { @@ -39,6 +40,8 @@ pub struct DeliveryReportData { pub completion: Option, pub scan_audit: Vec, pub credit_audit: Vec, + /// Protokoll der Zahlungsabwicklung, chronologisch (inkl. Korrekturen). + pub payments: Vec, pub attachments: Vec, // Bild-Bytes (vom Use Case aus dem lokalen Speicher nachgeladen): pub customer_signature_png: Option>, @@ -105,6 +108,23 @@ pub struct ReportCompletion { pub payment_collected: bool, /// Snapshot des kassierten offenen Betrags in Cent (None = kein Inkasso). pub collected_amount_cents: Option, + /// Die beim Abschluss gültige protokollierte Zahlung (None = ohne + /// Zahlungs-Step abgeschlossen, z. B. nichts offen oder alte App). + pub payment_id: Option, +} + +/// Ein Eintrag im Zahlungsprotokoll („Abkassieren"). +#[derive(Debug, Clone)] +pub struct ReportPayment { + pub id: Uuid, + pub recorded_at: DateTime, + /// Anzeigename der Methode aus den Stammdaten (None = nicht mehr vorhanden). + pub method_name: Option, + /// Code-Snapshot zum Zeitpunkt der Abwicklung (cash/ec_card/invoice). + pub method_code: String, + pub amount_cents: i64, + pub recorded_by_personalnummer: i64, + pub car_plate: Option, } #[derive(Debug, Clone)] diff --git a/crates/application/src/dto/mod.rs b/crates/application/src/dto/mod.rs index e020bc2..886a763 100644 --- a/crates/application/src/dto/mod.rs +++ b/crates/application/src/dto/mod.rs @@ -32,7 +32,7 @@ pub use car::{ pub use complete::CompleteDeliveryAcknowledgements; pub use credit::{CreditAction, DeliveryCreditEventRequest, DeliveryCreditResponse}; pub use delivery_report::{ - DeliveryReportData, ReportAttachment, ReportCompletion, ReportContact, ReportCreditAudit, + DeliveryReportData, ReportAttachment, ReportCompletion, ReportContact, ReportCreditAudit, ReportPayment, ReportItem, ReportNote, ReportScanAudit, ReportService, }; pub use delivery_action::{CancelDeliveryRequest, DeliveryResponse, HoldDeliveryRequest}; diff --git a/crates/infrastructure/src/report/renderer.rs b/crates/infrastructure/src/report/renderer.rs index 26288c9..8b855f8 100644 --- a/crates/infrastructure/src/report/renderer.rs +++ b/crates/infrastructure/src/report/renderer.rs @@ -10,7 +10,7 @@ use printpdf::{ IndirectFontRef, Line, Mm, PdfDocument, PdfDocumentReference, PdfLayerReference, Point, Px, Rgb, }; -use holzleitner_application::dto::DeliveryReportData; +use holzleitner_application::dto::{DeliveryReportData, ReportPayment}; use holzleitner_application::error::ApplicationError; use holzleitner_application::ports::DeliveryReportRenderer; @@ -399,6 +399,32 @@ fn scan_action_de(s: &str) -> &str { } /// Deutsche Beschriftung der Gutschriftaktion. +/// Bar/EC = vor Ort kassiert; alles andere (Rechnung) bleibt offen. +fn is_collected_on_site(code: &str) -> bool { + code == "cash" || code == "ec_card" +} + +/// Anzeigename der Zahlungsart: Stammdaten-Name, sonst aus dem Code-Snapshot. +fn payment_method_label(pay: &ReportPayment) -> String { + if let Some(name) = pay.method_name.as_deref().filter(|n| !n.trim().is_empty()) { + return name.to_string(); + } + match pay.method_code.as_str() { + "cash" => "Bar".into(), + "ec_card" => "EC-Karte".into(), + "invoice" => "Rechnung".into(), + other => other.to_string(), + } +} + +/// „Fahrer 423, Fahrzeug AB-CD 12" (Fahrzeug nur, wenn bekannt). +fn payment_actor(pay: &ReportPayment) -> String { + match &pay.car_plate { + Some(plate) => format!("Fahrer {}, Fahrzeug {}", pay.recorded_by_personalnummer, plate), + None => format!("Fahrer {}", pay.recorded_by_personalnummer), + } +} + fn credit_action_de(s: &str) -> &str { match s { "set" => "Gesetzt", @@ -513,9 +539,32 @@ impl DeliveryReportRenderer for PdfDeliveryReportRenderer { p.kv("Gutschrift", ¢s(d.current_credit_cents)); p.kv("Offener Betrag", &money(open)); p.kv("Zahlungsmethode", opt(&d.payment_method)); - // Inkasso-Bestätigung: nur wenn beim Abschluss tatsächlich kassiert - // wurde (Bar/EC bei offenem Betrag). „Auf Rechnung"/voll bezahlt → keine. - if let Some(c) = &d.completion { + // Zahlungsabwicklung aus dem Step „Zahlung": nach dem Abschluss die + // dabei gültige Zahlung, vorher der jüngste Eintrag. + let settled = match &d.completion { + Some(c) => c + .payment_id + .and_then(|id| d.payments.iter().find(|pay| pay.id == id)), + None => d.payments.last(), + }; + if let Some(pay) = settled { + let verb = if is_collected_on_site(&pay.method_code) { + "erhalten" + } else { + // Name der Methode sagt bereits „Rechnung" — nicht doppeln. + "bestätigt" + }; + // Eigene Zeilen statt einer Satzzeile: Der Betrag steht so immer + // am Zeilenende (die eingebaute Helvetica setzt nach „€" zu eng). + p.kv( + "Zahlungsabwicklung", + &format!("{}, {}", payment_method_label(pay), verb), + ); + p.kv("Zahlbetrag", ¢s(pay.amount_cents)); + p.kv("Abgewickelt am", &dt(self.tz, &pay.recorded_at)); + p.kv("Erfasst von", &payment_actor(pay)); + } else if let Some(c) = &d.completion { + // Abschluss ohne Zahlungs-Step (ältere App): nur das Inkasso-Flag. if let Some(collected) = c.collected_amount_cents { p.kv( "Betrag erhalten", @@ -676,6 +725,43 @@ impl DeliveryReportRenderer for PdfDeliveryReportRenderer { } } + // 9b. Protokoll: Zahlungsabwicklung (alle Einträge inkl. Korrekturen) + p.heading("Protokoll — Zahlungsabwicklung"); + if d.payments.is_empty() { + p.text("— keine Einträge —", 9.0, false, 0.0); + } else { + p.row( + &[ + ("Zeit".to_string(), 30.0, true), + ("Zahlungsart".to_string(), 48.0, true), + ("Betrag".to_string(), 22.0, true), + ("Fahrer".to_string(), 16.0, true), + ("Fahrzeug".to_string(), 28.0, true), + ("Status".to_string(), 28.0, true), + ], + 7.5, + ); + let current = settled.map(|pay| pay.id); + for pay in &d.payments { + let status = match (current == Some(pay.id), d.completion.is_some()) { + (true, true) => "beim Abschluss", + (true, false) => "aktuell", + (false, _) => "ersetzt", + }; + p.row( + &[ + (dt(self.tz, &pay.recorded_at), 30.0, false), + (payment_method_label(pay), 48.0, false), + (cents(pay.amount_cents), 22.0, false), + (pay.recorded_by_personalnummer.to_string(), 16.0, false), + (pay.car_plate.clone().unwrap_or_else(|| "—".into()), 28.0, false), + (status.to_string(), 28.0, false), + ], + 7.5, + ); + } + } + // 10. Anhänge p.heading("Anhänge"); if d.attachments.is_empty() { diff --git a/crates/infrastructure/src/report/repository.rs b/crates/infrastructure/src/report/repository.rs index 06ba5e8..5689e56 100644 --- a/crates/infrastructure/src/report/repository.rs +++ b/crates/infrastructure/src/report/repository.rs @@ -10,7 +10,7 @@ use sqlx::PgPool; use uuid::Uuid; use holzleitner_application::dto::{ - DeliveryReportData, ReportAttachment, ReportCompletion, ReportContact, ReportCreditAudit, + DeliveryReportData, ReportAttachment, ReportCompletion, ReportContact, ReportCreditAudit, ReportPayment, ReportItem, ReportNote, ReportScanAudit, ReportService, }; use holzleitner_application::error::ApplicationError; @@ -103,6 +103,18 @@ struct CompletionRow { driver_signature_path: String, payment_collected: bool, collected_amount_cents: Option, + payment_id: Option, +} + +#[derive(sqlx::FromRow)] +struct PaymentRow { + id: Uuid, + recorded_at: DateTime, + method_name: Option, + method_code: String, + amount_cents: i64, + recorded_by_personalnummer: i64, + car_plate: Option, } #[derive(sqlx::FromRow)] @@ -277,7 +289,7 @@ impl DeliveryReportRepository for PgDeliveryReportRepository { r#" SELECT completed_at, completed_by_personalnummer, receipt_confirmed, notes_acknowledged, customer_signature_path, driver_signature_path, - payment_collected, collected_amount_cents + payment_collected, collected_amount_cents, payment_id FROM delivery_completions WHERE delivery_id = $1 "#, @@ -322,6 +334,24 @@ impl DeliveryReportRepository for PgDeliveryReportRepository { .await .map_err(db)?; + // --- Audit: Zahlungsabwicklung --- + let payments: Vec = sqlx::query_as( + r#" + SELECT p.id, p.recorded_at, pm.name AS method_name, + p.payment_method_code AS method_code, p.amount_cents, + p.recorded_by_personalnummer, c.plate AS car_plate + FROM delivery_payments p + LEFT JOIN payment_methods pm ON pm.id = p.payment_method_id + LEFT JOIN cars c ON c.id = p.recorded_by_car_id + WHERE p.delivery_id = $1 + ORDER BY p.recorded_at, p.id + "#, + ) + .bind(delivery_id) + .fetch_all(&self.pool) + .await + .map_err(db)?; + // --- Anhänge --- let attachments: Vec = sqlx::query_as( r#" @@ -435,6 +465,7 @@ impl DeliveryReportRepository for PgDeliveryReportRepository { driver_signature_path: c.driver_signature_path, payment_collected: c.payment_collected, collected_amount_cents: c.collected_amount_cents, + payment_id: c.payment_id, }), scan_audit: scan_audit .into_iter() @@ -464,6 +495,18 @@ impl DeliveryReportRepository for PgDeliveryReportRepository { author_personalnummer: c.author_personalnummer, }) .collect(), + payments: payments + .into_iter() + .map(|p| ReportPayment { + id: p.id, + recorded_at: p.recorded_at, + method_name: p.method_name, + method_code: p.method_code, + amount_cents: p.amount_cents, + recorded_by_personalnummer: p.recorded_by_personalnummer, + car_plate: p.car_plate, + }) + .collect(), attachments: attachments .into_iter() .map(|a| ReportAttachment {