feat(signature): Dateinamen-Schema delivery_{Belegnummer}_signature_{role}.png

Unterschrifts-Dateien folgen jetzt dem Schema
delivery_{Belegnummer}_signature_{customer|driver}.png
(z. B. delivery_V-30690287_signature_customer.png) statt zuvor
{delivery_id}_{role}.png.

- SignatureStorage::save nimmt Belegnummer statt delivery_id; Adapter
  baut den Dateinamen + saubere Sanitisierung des Belegnummer-Anteils
  (Schutz gegen Pfad-Ausbruch, übliche Werte wie V-30690287 bleiben).
- CompleteDeliveryUseCase löst die Belegnummer vor dem Speichern auf.
- Neue Lookup-Methode DeliveryCompletionRepository::belegnummer_for.
- Totes delete_for_delivery (Reconstruktion via delivery_id, keine
  Aufrufer mehr seit Cron-Cleanup) entfernt.

Abwärtskompatibel: bestehende Signaturen werden über die in
delivery_completions gespeicherte Referenz geladen, alte Dateinamen
bleiben lesbar. Nur neue Abschlüsse verwenden das neue Schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-06-18 16:56:58 +02:00
parent 47eb8ec57d
commit 1e6dfb10b0
5 changed files with 57 additions and 45 deletions

View File

@ -331,6 +331,19 @@ impl DeliveryCompletionRepository for PgDeliveryCompletionRepository {
Ok(build_delivery(row, DeliveryState::Completed, None, contacts))
}
async fn belegnummer_for(
&self,
delivery_id: Uuid,
) -> Result<String, ApplicationError> {
let belegnummer: Option<String> =
sqlx::query_scalar("SELECT erp_belegnummer FROM deliveries WHERE id = $1")
.bind(delivery_id)
.fetch_optional(&self.pool)
.await
.map_err(db)?;
belegnummer.ok_or(ApplicationError::NotFound)
}
async fn load_erp_writeback(
&self,
delivery_id: Uuid,

View File

@ -1,16 +1,17 @@
//! Lokaler Dateisystem-Adapter für `SignatureStorage`.
//!
//! Schreibt jede Unterschrift als PNG unter
//! `<base_dir>/<delivery_id>_<role>.png`. Der Pfad ist deterministisch:
//! ein Retry überschreibt dieselbe Datei, statt Müll anzuhäufen. Persistiert
//! wird in der DB nur die relative Referenz (Dateiname), nicht der absolute
//! Pfad — so bleibt ein Umzug des Verzeichnisses unkompliziert.
//! `<base_dir>/delivery_<belegnummer>_signature_<role>.png` (z. B.
//! `delivery_V-30690287_signature_customer.png`). Der Pfad ist
//! deterministisch: ein Retry überschreibt dieselbe Datei, statt Müll
//! anzuhäufen. Persistiert wird in der DB nur die relative Referenz
//! (Dateiname), nicht der absolute Pfad — so bleibt ein Umzug des
//! Verzeichnisses unkompliziert.
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
use async_trait::async_trait;
use uuid::Uuid;
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::{SignatureRole, SignatureStorage};
@ -28,8 +29,23 @@ impl LocalSignatureStorage {
Ok(Self { base_dir })
}
fn file_name(delivery_id: Uuid, role: SignatureRole) -> String {
format!("{delivery_id}_{}.png", role.as_str())
/// Baut den Dateinamen nach Schema
/// `delivery_{belegnummer}_signature_{role}.png`. Die Belegnummer wird
/// dateisystem-sicher gemacht (alles außer `AZ az 09 . _ -` wird zu
/// `_`), damit ungewöhnliche ERP-Werte keinen Pfad-Ausbruch ermöglichen.
/// Übliche Belegnummern wie `V-30690287` bleiben dabei unverändert.
fn file_name(belegnummer: &str, role: SignatureRole) -> String {
let safe: String = belegnummer
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
c
} else {
'_'
}
})
.collect();
format!("delivery_{safe}_signature_{}.png", role.as_str())
}
}
@ -37,11 +53,11 @@ impl LocalSignatureStorage {
impl SignatureStorage for LocalSignatureStorage {
async fn save(
&self,
delivery_id: Uuid,
belegnummer: &str,
role: SignatureRole,
bytes: Vec<u8>,
) -> Result<String, ApplicationError> {
let name = Self::file_name(delivery_id, role);
let name = Self::file_name(belegnummer, role);
let path = self.base_dir.join(&name);
// Blocking-IO bewusst auf einen Blocking-Thread auslagern.
tokio::task::spawn_blocking(move || std::fs::write(&path, &bytes))
@ -73,31 +89,6 @@ impl SignatureStorage for LocalSignatureStorage {
}
}
async fn delete_for_delivery(&self, delivery_id: Uuid) -> Result<(), ApplicationError> {
// Beide deterministisch benannten Dateien (Kunde + Fahrer) entfernen.
let paths = [
self.base_dir
.join(Self::file_name(delivery_id, SignatureRole::Customer)),
self.base_dir
.join(Self::file_name(delivery_id, SignatureRole::Driver)),
];
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
for path in paths {
match std::fs::remove_file(&path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
}
Ok(())
})
.await
.map_err(|e| ApplicationError::Repository(format!("join error: {e}")))?
.map_err(|e| {
ApplicationError::Repository(format!("signatur löschen fehlgeschlagen: {e}"))
})
}
async fn delete_older_than(&self, max_age: Duration) -> Result<u64, ApplicationError> {
let base = self.base_dir.clone();
tokio::task::spawn_blocking(move || -> std::io::Result<u64> {