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:
@ -83,6 +83,16 @@ pub trait DeliveryCompletionRepository: Send + Sync {
|
|||||||
input: CompleteDeliveryInput,
|
input: CompleteDeliveryInput,
|
||||||
) -> Result<Delivery, ApplicationError>;
|
) -> Result<Delivery, ApplicationError>;
|
||||||
|
|
||||||
|
/// Liefert die ERP-Belegnummer einer Lieferung. Der Abschluss-Use-Case
|
||||||
|
/// braucht sie, um die Unterschrifts-Dateien nach dem Schema
|
||||||
|
/// `delivery_{Belegnummer}_signature_{role}.png` zu benennen — also
|
||||||
|
/// *vor* dem eigentlichen `complete`-Aufruf. `NotFound`, wenn die
|
||||||
|
/// Lieferung nicht existiert.
|
||||||
|
async fn belegnummer_for(
|
||||||
|
&self,
|
||||||
|
delivery_id: Uuid,
|
||||||
|
) -> Result<String, ApplicationError>;
|
||||||
|
|
||||||
/// Lädt die für das ERP-Rückschreiben nötigen Daten einer **bereits
|
/// Lädt die für das ERP-Rückschreiben nötigen Daten einer **bereits
|
||||||
/// abgeschlossenen** Lieferung (Beleg-Key, ausgelieferte Mengen,
|
/// abgeschlossenen** Lieferung (Beleg-Key, ausgelieferte Mengen,
|
||||||
/// Geld-Gutschrift, Abschluss-Zeitpunkt).
|
/// Geld-Gutschrift, Abschluss-Zeitpunkt).
|
||||||
|
|||||||
@ -11,7 +11,6 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::error::ApplicationError;
|
use crate::error::ApplicationError;
|
||||||
|
|
||||||
@ -35,11 +34,13 @@ impl SignatureRole {
|
|||||||
pub trait SignatureStorage: Send + Sync {
|
pub trait SignatureStorage: Send + Sync {
|
||||||
/// Speichert eine Unterschrift (PNG-Bytes) für eine Lieferung+Rolle und
|
/// Speichert eine Unterschrift (PNG-Bytes) für eine Lieferung+Rolle und
|
||||||
/// liefert die persistente, relative Referenz (Dateiname) zurück.
|
/// liefert die persistente, relative Referenz (Dateiname) zurück.
|
||||||
/// Deterministisch über `delivery_id`+`role` — ein Retry überschreibt
|
/// Deterministisch über `belegnummer`+`role` — der Dateiname folgt dem
|
||||||
|
/// Schema `delivery_{belegnummer}_signature_{role}.png` (z. B.
|
||||||
|
/// `delivery_V-30690287_signature_customer.png`); ein Retry überschreibt
|
||||||
/// dieselbe Datei statt Müll anzuhäufen.
|
/// dieselbe Datei statt Müll anzuhäufen.
|
||||||
async fn save(
|
async fn save(
|
||||||
&self,
|
&self,
|
||||||
delivery_id: Uuid,
|
belegnummer: &str,
|
||||||
role: SignatureRole,
|
role: SignatureRole,
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
) -> Result<String, ApplicationError>;
|
) -> Result<String, ApplicationError>;
|
||||||
@ -49,12 +50,6 @@ pub trait SignatureStorage: Send + Sync {
|
|||||||
/// Einbetten in den PDF-Report. `None`, wenn die Datei fehlt.
|
/// Einbetten in den PDF-Report. `None`, wenn die Datei fehlt.
|
||||||
async fn load(&self, reference: &str) -> Result<Option<Vec<u8>>, ApplicationError>;
|
async fn load(&self, reference: &str) -> Result<Option<Vec<u8>>, ApplicationError>;
|
||||||
|
|
||||||
/// Löscht beide Unterschriften (Kunde + Fahrer) einer Lieferung. Idempotent
|
|
||||||
/// (fehlende Datei = ok). Hinweis: Wird NICHT mehr beim Report-Upload
|
|
||||||
/// aufgerufen — Unterschriften bleiben erhalten und werden per Cron über
|
|
||||||
/// [`delete_older_than`](Self::delete_older_than) nach Frist entfernt.
|
|
||||||
async fn delete_for_delivery(&self, delivery_id: Uuid) -> Result<(), ApplicationError>;
|
|
||||||
|
|
||||||
/// Löscht alle Unterschrifts-Dateien, deren letzte Änderung länger als
|
/// Löscht alle Unterschrifts-Dateien, deren letzte Änderung länger als
|
||||||
/// `max_age` zurückliegt (Aufbewahrungsfrist). Für den periodischen
|
/// `max_age` zurückliegt (Aufbewahrungsfrist). Für den periodischen
|
||||||
/// Cron-Cleanup. Liefert die Anzahl gelöschter Dateien.
|
/// Cron-Cleanup. Liefert die Anzahl gelöschter Dateien.
|
||||||
|
|||||||
@ -76,13 +76,16 @@ impl CompleteDeliveryUseCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Signaturen lokal speichern -----------------------------------
|
// --- Signaturen lokal speichern -----------------------------------
|
||||||
|
// Dateiname folgt dem Schema delivery_{Belegnummer}_signature_{role}.png
|
||||||
|
// → Belegnummer der Lieferung auflösen, bevor geschrieben wird.
|
||||||
|
let belegnummer = self.repository.belegnummer_for(delivery_id).await?;
|
||||||
let customer_signature_path = self
|
let customer_signature_path = self
|
||||||
.signatures
|
.signatures
|
||||||
.save(delivery_id, SignatureRole::Customer, customer_signature_png)
|
.save(&belegnummer, SignatureRole::Customer, customer_signature_png)
|
||||||
.await?;
|
.await?;
|
||||||
let driver_signature_path = self
|
let driver_signature_path = self
|
||||||
.signatures
|
.signatures
|
||||||
.save(delivery_id, SignatureRole::Driver, driver_signature_png)
|
.save(&belegnummer, SignatureRole::Driver, driver_signature_png)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// --- Atomarer Abschluss im Repository -----------------------------
|
// --- Atomarer Abschluss im Repository -----------------------------
|
||||||
|
|||||||
@ -331,6 +331,19 @@ impl DeliveryCompletionRepository for PgDeliveryCompletionRepository {
|
|||||||
Ok(build_delivery(row, DeliveryState::Completed, None, contacts))
|
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(
|
async fn load_erp_writeback(
|
||||||
&self,
|
&self,
|
||||||
delivery_id: Uuid,
|
delivery_id: Uuid,
|
||||||
|
|||||||
@ -1,16 +1,17 @@
|
|||||||
//! Lokaler Dateisystem-Adapter für `SignatureStorage`.
|
//! Lokaler Dateisystem-Adapter für `SignatureStorage`.
|
||||||
//!
|
//!
|
||||||
//! Schreibt jede Unterschrift als PNG unter
|
//! Schreibt jede Unterschrift als PNG unter
|
||||||
//! `<base_dir>/<delivery_id>_<role>.png`. Der Pfad ist deterministisch:
|
//! `<base_dir>/delivery_<belegnummer>_signature_<role>.png` (z. B.
|
||||||
//! ein Retry überschreibt dieselbe Datei, statt Müll anzuhäufen. Persistiert
|
//! `delivery_V-30690287_signature_customer.png`). Der Pfad ist
|
||||||
//! wird in der DB nur die relative Referenz (Dateiname), nicht der absolute
|
//! deterministisch: ein Retry überschreibt dieselbe Datei, statt Müll
|
||||||
//! Pfad — so bleibt ein Umzug des Verzeichnisses unkompliziert.
|
//! 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::path::PathBuf;
|
||||||
use std::time::{Duration, SystemTime};
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use holzleitner_application::error::ApplicationError;
|
use holzleitner_application::error::ApplicationError;
|
||||||
use holzleitner_application::ports::{SignatureRole, SignatureStorage};
|
use holzleitner_application::ports::{SignatureRole, SignatureStorage};
|
||||||
@ -28,8 +29,23 @@ impl LocalSignatureStorage {
|
|||||||
Ok(Self { base_dir })
|
Ok(Self { base_dir })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn file_name(delivery_id: Uuid, role: SignatureRole) -> String {
|
/// Baut den Dateinamen nach Schema
|
||||||
format!("{delivery_id}_{}.png", role.as_str())
|
/// `delivery_{belegnummer}_signature_{role}.png`. Die Belegnummer wird
|
||||||
|
/// dateisystem-sicher gemacht (alles außer `A–Z a–z 0–9 . _ -` 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 {
|
impl SignatureStorage for LocalSignatureStorage {
|
||||||
async fn save(
|
async fn save(
|
||||||
&self,
|
&self,
|
||||||
delivery_id: Uuid,
|
belegnummer: &str,
|
||||||
role: SignatureRole,
|
role: SignatureRole,
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
) -> Result<String, ApplicationError> {
|
) -> 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);
|
let path = self.base_dir.join(&name);
|
||||||
// Blocking-IO bewusst auf einen Blocking-Thread auslagern.
|
// Blocking-IO bewusst auf einen Blocking-Thread auslagern.
|
||||||
tokio::task::spawn_blocking(move || std::fs::write(&path, &bytes))
|
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> {
|
async fn delete_older_than(&self, max_age: Duration) -> Result<u64, ApplicationError> {
|
||||||
let base = self.base_dir.clone();
|
let base = self.base_dir.clone();
|
||||||
tokio::task::spawn_blocking(move || -> std::io::Result<u64> {
|
tokio::task::spawn_blocking(move || -> std::io::Result<u64> {
|
||||||
|
|||||||
Reference in New Issue
Block a user