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 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-25 14:41:05 +02:00
parent caaf88ac3d
commit 695ac39c19
9 changed files with 81 additions and 5 deletions

View File

@ -37,4 +37,9 @@ pub struct CompleteDeliveryAcknowledgements {
/// Fahrzeug des Akteurs (Audit-Spur). Muss zum Account gehören. /// Fahrzeug des Akteurs (Audit-Spur). Muss zum Account gehören.
#[serde(default)] #[serde(default)]
pub author_car_id: Option<Uuid>, pub author_car_id: Option<Uuid>,
/// 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<String>,
} }

View File

@ -111,6 +111,8 @@ pub struct ReportCompletion {
/// Die beim Abschluss gültige protokollierte Zahlung (None = ohne /// Die beim Abschluss gültige protokollierte Zahlung (None = ohne
/// Zahlungs-Step abgeschlossen, z. B. nichts offen oder alte App). /// Zahlungs-Step abgeschlossen, z. B. nichts offen oder alte App).
pub payment_id: Option<Uuid>, pub payment_id: Option<Uuid>,
/// Interne Notiz des Fahrers beim Abschluss (`None` = keine).
pub internal_note: Option<String>,
} }
/// Ein Eintrag im Zahlungsprotokoll („Abkassieren"). /// Ein Eintrag im Zahlungsprotokoll („Abkassieren").

View File

@ -76,6 +76,8 @@ pub struct CompleteDeliveryInput {
pub payment_method_id: Option<Uuid>, pub payment_method_id: Option<Uuid>,
pub completed_by_personalnummer: i64, pub completed_by_personalnummer: i64,
pub completed_by_car_id: Option<Uuid>, pub completed_by_car_id: Option<Uuid>,
/// Interne Notiz des Fahrers (bereits getrimmt, `None` = keine).
pub internal_note: Option<String>,
} }
#[async_trait] #[async_trait]

View File

@ -21,6 +21,10 @@ use crate::usecases::PushCompletionToErpUseCase;
/// Lock prüft). Schlägt das Repo-Gate fehl, bleiben höchstens die beiden /// Lock prüft). Schlägt das Repo-Gate fehl, bleiben höchstens die beiden
/// deterministisch benannten PNG-Dateien liegen — ein erneuter Versuch /// deterministisch benannten PNG-Dateien liegen — ein erneuter Versuch
/// überschreibt sie, es entsteht kein Müll. /// ü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 { pub struct CompleteDeliveryUseCase {
repository: Arc<dyn DeliveryCompletionRepository>, repository: Arc<dyn DeliveryCompletionRepository>,
signatures: Arc<dyn SignatureStorage>, signatures: Arc<dyn SignatureStorage>,
@ -69,6 +73,20 @@ impl CompleteDeliveryUseCase {
"driver signature is required".into(), "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 { if let Some(car_id) = acknowledgements.author_car_id {
self.cars self.cars
.assert_owned_by_account(&[car_id], author_personalnummer) .assert_owned_by_account(&[car_id], author_personalnummer)
@ -102,6 +120,7 @@ impl CompleteDeliveryUseCase {
payment_method_id: acknowledgements.payment_method_id, payment_method_id: acknowledgements.payment_method_id,
completed_by_personalnummer: author_personalnummer, completed_by_personalnummer: author_personalnummer,
completed_by_car_id: acknowledgements.author_car_id, completed_by_car_id: acknowledgements.author_car_id,
internal_note,
}) })
.await?; .await?;

View File

@ -295,8 +295,8 @@ impl DeliveryCompletionRepository for PgDeliveryCompletionRepository {
delivery_id, customer_signature_path, driver_signature_path, delivery_id, customer_signature_path, driver_signature_path,
receipt_confirmed, notes_acknowledged, acknowledged_note_ids, receipt_confirmed, notes_acknowledged, acknowledged_note_ids,
completed_by_personalnummer, completed_by_car_id, completed_by_personalnummer, completed_by_car_id,
payment_collected, collected_amount_cents, payment_id payment_collected, collected_amount_cents, payment_id, internal_note
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
"#, "#,
) )
.bind(delivery_id) .bind(delivery_id)
@ -310,6 +310,7 @@ impl DeliveryCompletionRepository for PgDeliveryCompletionRepository {
.bind(requires_collection && collected) .bind(requires_collection && collected)
.bind(collected_amount_cents) .bind(collected_amount_cents)
.bind(logged_payment_id) .bind(logged_payment_id)
.bind(input.internal_note.as_deref())
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(db)?; .map_err(db)?;

View File

@ -568,7 +568,7 @@ impl DeliveryReportRenderer for PdfDeliveryReportRenderer {
if let Some(collected) = c.collected_amount_cents { if let Some(collected) = c.collected_amount_cents {
p.kv( p.kv(
"Betrag erhalten", "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 // 7. Unterschriften
p.heading("Unterschriften"); p.heading("Unterschriften");
match &d.completion { match &d.completion {

View File

@ -104,6 +104,7 @@ struct CompletionRow {
payment_collected: bool, payment_collected: bool,
collected_amount_cents: Option<i64>, collected_amount_cents: Option<i64>,
payment_id: Option<Uuid>, payment_id: Option<Uuid>,
internal_note: Option<String>,
} }
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
@ -289,7 +290,7 @@ impl DeliveryReportRepository for PgDeliveryReportRepository {
r#" r#"
SELECT completed_at, completed_by_personalnummer, receipt_confirmed, SELECT completed_at, completed_by_personalnummer, receipt_confirmed,
notes_acknowledged, customer_signature_path, driver_signature_path, 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 FROM delivery_completions
WHERE delivery_id = $1 WHERE delivery_id = $1
"#, "#,
@ -466,6 +467,7 @@ impl DeliveryReportRepository for PgDeliveryReportRepository {
payment_collected: c.payment_collected, payment_collected: c.payment_collected,
collected_amount_cents: c.collected_amount_cents, collected_amount_cents: c.collected_amount_cents,
payment_id: c.payment_id, payment_id: c.payment_id,
internal_note: c.internal_note,
}), }),
scan_audit: scan_audit scan_audit: scan_audit
.into_iter() .into_iter()

View File

@ -81,6 +81,7 @@ fn input(delivery_id: Uuid, payment_collected: bool) -> CompleteDeliveryInput {
payment_method_id: None, payment_method_id: None,
completed_by_personalnummer: 1, completed_by_personalnummer: 1,
completed_by_car_id: None, 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)); assert_eq!(payment_id, Some(inv.id));
// 8) Alte App: Flag ohne Protokoll funktioniert weiterhin. // 8) Alte App: Flag ohne Protokoll funktioniert weiterhin.
// Dazu interne Notiz: wird am Abschluss gespeichert.
let d4 = seed(&pool, CASH).await; 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<String> =
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. // 9) Tour-Aggregat liefert die jüngste Zahlung; Einzel-Reset löscht sie.
let tours = PgTourRepository::new(pool.clone()); let tours = PgTourRepository::new(pool.clone());

View File

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