From 695ac39c193562199d9e83b945843520a69df8a6 Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Fri, 25 Sep 2026 14:41:05 +0200 Subject: [PATCH] feat(abschluss): interne Notiz des Fahrers beim Abschluss - CompleteDeliveryAcknowledgements.internalNote (optional, getrimmt, max. 2000 Zeichen) wird atomar mit dem Abschluss gespeichert (delivery_completions.internal_note) - Bewusst keine delivery_notes-Zeile: loest weder die Kunden-Bestaetigung der Notizen aus noch erscheint sie im Notizen-Step der App - Lieferbericht: eigener Abschnitt "Interne Notiz" mit Zeitpunkt und Fahrer - Bericht: Betrag in "Betrag erhalten" ans Zeilenende (Helvetica setzt nach dem Euro-Zeichen zu eng) Co-Authored-By: Claude Opus 5.5 --- crates/application/src/dto/complete.rs | 5 ++++ crates/application/src/dto/delivery_report.rs | 2 ++ .../ports/delivery_completion_repository.rs | 2 ++ .../src/usecases/complete_delivery.rs | 19 ++++++++++++++ .../delivery_completion_repository.rs | 5 ++-- crates/infrastructure/src/report/renderer.rs | 26 ++++++++++++++++++- .../infrastructure/src/report/repository.rs | 4 ++- crates/infrastructure/tests/payment_flow.rs | 10 ++++++- migrations/0033_completion_internal_note.sql | 13 ++++++++++ 9 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 migrations/0033_completion_internal_note.sql diff --git a/crates/application/src/dto/complete.rs b/crates/application/src/dto/complete.rs index e39bdda..7c8c03c 100644 --- a/crates/application/src/dto/complete.rs +++ b/crates/application/src/dto/complete.rs @@ -37,4 +37,9 @@ pub struct CompleteDeliveryAcknowledgements { /// Fahrzeug des Akteurs (Audit-Spur). Muss zum Account gehören. #[serde(default)] pub author_car_id: Option, + /// Optionale interne Notiz des Fahrers, abgefragt nach beiden + /// Unterschriften. Nur für interne Zwecke (Lieferbericht), nicht Teil + /// der Kunden-Bestätigung. Leer/Whitespace = keine Notiz; max. 2000 Zeichen. + #[serde(default)] + pub internal_note: Option, } diff --git a/crates/application/src/dto/delivery_report.rs b/crates/application/src/dto/delivery_report.rs index a09b04d..e5e4e1c 100644 --- a/crates/application/src/dto/delivery_report.rs +++ b/crates/application/src/dto/delivery_report.rs @@ -111,6 +111,8 @@ pub struct ReportCompletion { /// Die beim Abschluss gültige protokollierte Zahlung (None = ohne /// Zahlungs-Step abgeschlossen, z. B. nichts offen oder alte App). pub payment_id: Option, + /// Interne Notiz des Fahrers beim Abschluss (`None` = keine). + pub internal_note: Option, } /// Ein Eintrag im Zahlungsprotokoll („Abkassieren"). diff --git a/crates/application/src/ports/delivery_completion_repository.rs b/crates/application/src/ports/delivery_completion_repository.rs index c6947fc..ecbfb17 100644 --- a/crates/application/src/ports/delivery_completion_repository.rs +++ b/crates/application/src/ports/delivery_completion_repository.rs @@ -76,6 +76,8 @@ pub struct CompleteDeliveryInput { pub payment_method_id: Option, pub completed_by_personalnummer: i64, pub completed_by_car_id: Option, + /// Interne Notiz des Fahrers (bereits getrimmt, `None` = keine). + pub internal_note: Option, } #[async_trait] diff --git a/crates/application/src/usecases/complete_delivery.rs b/crates/application/src/usecases/complete_delivery.rs index 94f417b..282ad03 100644 --- a/crates/application/src/usecases/complete_delivery.rs +++ b/crates/application/src/usecases/complete_delivery.rs @@ -21,6 +21,10 @@ use crate::usecases::PushCompletionToErpUseCase; /// 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. +/// Obergrenze der internen Abschluss-Notiz (Zeichen) — deckungsgleich mit +/// dem DB-CHECK in `0033_completion_internal_note.sql`. +const MAX_INTERNAL_NOTE_CHARS: usize = 2000; + pub struct CompleteDeliveryUseCase { repository: Arc, signatures: Arc, @@ -69,6 +73,20 @@ impl CompleteDeliveryUseCase { "driver signature is required".into(), )); } + let internal_note = acknowledgements + .internal_note + .as_deref() + .map(str::trim) + .filter(|n| !n.is_empty()) + .map(str::to_owned); + if internal_note + .as_ref() + .is_some_and(|n| n.chars().count() > MAX_INTERNAL_NOTE_CHARS) + { + return Err(ApplicationError::Validation(format!( + "internal note must not exceed {MAX_INTERNAL_NOTE_CHARS} characters" + ))); + } if let Some(car_id) = acknowledgements.author_car_id { self.cars .assert_owned_by_account(&[car_id], author_personalnummer) @@ -102,6 +120,7 @@ impl CompleteDeliveryUseCase { payment_method_id: acknowledgements.payment_method_id, completed_by_personalnummer: author_personalnummer, completed_by_car_id: acknowledgements.author_car_id, + internal_note, }) .await?; diff --git a/crates/infrastructure/src/persistence/delivery_completion_repository.rs b/crates/infrastructure/src/persistence/delivery_completion_repository.rs index de20508..08c4e6f 100644 --- a/crates/infrastructure/src/persistence/delivery_completion_repository.rs +++ b/crates/infrastructure/src/persistence/delivery_completion_repository.rs @@ -295,8 +295,8 @@ impl DeliveryCompletionRepository for PgDeliveryCompletionRepository { delivery_id, customer_signature_path, driver_signature_path, receipt_confirmed, notes_acknowledged, acknowledged_note_ids, completed_by_personalnummer, completed_by_car_id, - payment_collected, collected_amount_cents, payment_id - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + payment_collected, collected_amount_cents, payment_id, internal_note + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) "#, ) .bind(delivery_id) @@ -310,6 +310,7 @@ impl DeliveryCompletionRepository for PgDeliveryCompletionRepository { .bind(requires_collection && collected) .bind(collected_amount_cents) .bind(logged_payment_id) + .bind(input.internal_note.as_deref()) .execute(&mut *tx) .await .map_err(db)?; diff --git a/crates/infrastructure/src/report/renderer.rs b/crates/infrastructure/src/report/renderer.rs index 8b855f8..6a7b44a 100644 --- a/crates/infrastructure/src/report/renderer.rs +++ b/crates/infrastructure/src/report/renderer.rs @@ -568,7 +568,7 @@ impl DeliveryReportRenderer for PdfDeliveryReportRenderer { if let Some(collected) = c.collected_amount_cents { p.kv( "Betrag erhalten", - &format!("Ja — {} (am {})", cents(collected), dt(self.tz, &c.completed_at)), + &format!("Ja, am {} — {}", dt(self.tz, &c.completed_at), cents(collected)), ); } } @@ -620,6 +620,30 @@ impl DeliveryReportRenderer for PdfDeliveryReportRenderer { } } + // 6b. Interne Notiz des Fahrers (nach den Unterschriften erfasst, + // nicht Teil der Kunden-Bestätigung). + p.heading("Interne Notiz"); + match d + .completion + .as_ref() + .and_then(|c| c.internal_note.as_deref().map(|n| (c, n))) + { + Some((c, note)) => { + p.text( + &format!( + "{} · Fahrer {}", + dt(self.tz, &c.completed_at), + c.completed_by_personalnummer + ), + 8.0, + true, + 0.0, + ); + p.text(note, 9.0, false, 4.0); + } + None => p.text("— keine —", 9.0, false, 0.0), + } + // 7. Unterschriften p.heading("Unterschriften"); match &d.completion { diff --git a/crates/infrastructure/src/report/repository.rs b/crates/infrastructure/src/report/repository.rs index 5689e56..daf8aae 100644 --- a/crates/infrastructure/src/report/repository.rs +++ b/crates/infrastructure/src/report/repository.rs @@ -104,6 +104,7 @@ struct CompletionRow { payment_collected: bool, collected_amount_cents: Option, payment_id: Option, + internal_note: Option, } #[derive(sqlx::FromRow)] @@ -289,7 +290,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_id + payment_collected, collected_amount_cents, payment_id, internal_note FROM delivery_completions WHERE delivery_id = $1 "#, @@ -466,6 +467,7 @@ impl DeliveryReportRepository for PgDeliveryReportRepository { payment_collected: c.payment_collected, collected_amount_cents: c.collected_amount_cents, payment_id: c.payment_id, + internal_note: c.internal_note, }), scan_audit: scan_audit .into_iter() diff --git a/crates/infrastructure/tests/payment_flow.rs b/crates/infrastructure/tests/payment_flow.rs index f81d6ea..f082e9d 100644 --- a/crates/infrastructure/tests/payment_flow.rs +++ b/crates/infrastructure/tests/payment_flow.rs @@ -81,6 +81,7 @@ fn input(delivery_id: Uuid, payment_collected: bool) -> CompleteDeliveryInput { payment_method_id: None, completed_by_personalnummer: 1, completed_by_car_id: None, + internal_note: None, } } @@ -156,8 +157,15 @@ async fn payment_protocol_and_completion_gate() { assert_eq!(payment_id, Some(inv.id)); // 8) Alte App: Flag ohne Protokoll funktioniert weiterhin. + // Dazu interne Notiz: wird am Abschluss gespeichert. let d4 = seed(&pool, CASH).await; - completions.complete(input(d4, true)).await.unwrap(); + let mut with_note = input(d4, true); + with_note.internal_note = Some("Kunde wünscht Rückruf wegen Montage".into()); + completions.complete(with_note).await.unwrap(); + let note: Option = + sqlx::query_scalar("SELECT internal_note FROM delivery_completions WHERE delivery_id = $1") + .bind(d4).fetch_one(&pool).await.unwrap(); + assert_eq!(note.as_deref(), Some("Kunde wünscht Rückruf wegen Montage")); // 9) Tour-Aggregat liefert die jüngste Zahlung; Einzel-Reset löscht sie. let tours = PgTourRepository::new(pool.clone()); diff --git a/migrations/0033_completion_internal_note.sql b/migrations/0033_completion_internal_note.sql new file mode 100644 index 0000000..37c6812 --- /dev/null +++ b/migrations/0033_completion_internal_note.sql @@ -0,0 +1,13 @@ +-- 0033_completion_internal_note.sql +-- +-- Interne Notiz des Fahrers beim Abschluss. Wird nach beiden Unterschriften +-- optional abgefragt und im selben Abschluss-Aufruf gespeichert. +-- +-- Bewusst KEINE Zeile in `delivery_notes`: Diese Notizen muss der Kunde vor +-- seiner Unterschrift bestätigen, und die App zeigt sie im Notizen-Step. Die +-- interne Notiz entsteht erst nach der Kunden-Unterschrift und ist nur für +-- interne Zwecke (Lieferbericht für Archiv/Fakturierung). NULL = keine Notiz. + +ALTER TABLE delivery_completions + ADD COLUMN internal_note TEXT + CHECK (internal_note IS NULL OR char_length(internal_note) BETWEEN 1 AND 2000);