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 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-25 14:28:41 +02:00
parent 954c5f52b2
commit caaf88ac3d
4 changed files with 156 additions and 7 deletions

View File

@ -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<ReportCompletion>,
pub scan_audit: Vec<ReportScanAudit>,
pub credit_audit: Vec<ReportCreditAudit>,
/// Protokoll der Zahlungsabwicklung, chronologisch (inkl. Korrekturen).
pub payments: Vec<ReportPayment>,
pub attachments: Vec<ReportAttachment>,
// Bild-Bytes (vom Use Case aus dem lokalen Speicher nachgeladen):
pub customer_signature_png: Option<Vec<u8>>,
@ -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<i64>,
/// Die beim Abschluss gültige protokollierte Zahlung (None = ohne
/// Zahlungs-Step abgeschlossen, z. B. nichts offen oder alte App).
pub payment_id: Option<Uuid>,
}
/// Ein Eintrag im Zahlungsprotokoll („Abkassieren").
#[derive(Debug, Clone)]
pub struct ReportPayment {
pub id: Uuid,
pub recorded_at: DateTime<Utc>,
/// Anzeigename der Methode aus den Stammdaten (None = nicht mehr vorhanden).
pub method_name: Option<String>,
/// 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<String>,
}
#[derive(Debug, Clone)]

View File

@ -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};

View File

@ -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", &cents(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", &cents(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() {

View File

@ -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<i64>,
payment_id: Option<Uuid>,
}
#[derive(sqlx::FromRow)]
struct PaymentRow {
id: Uuid,
recorded_at: DateTime<Utc>,
method_name: Option<String>,
method_code: String,
amount_cents: i64,
recorded_by_personalnummer: i64,
car_plate: Option<String>,
}
#[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<PaymentRow> = 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<AttachmentRow> = 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 {