Backend-Arbeitsstand: ERP-Sync, Lieferlebenszyklus, Reports + config.toml
Bringt das Backend vom initialen Skeleton auf den aktuellen Arbeitsstand (Clean Architecture: domain → application → infrastructure → api). Wesentliche Bereiche: - ERP-Anbindung (MSSQL-Pull der Touren, Import-Scheduler, Rückschreiben) - Lieferlebenszyklus: Scan/Hold/Cancel/Complete, Gutschriften, Notizen, Bild-Anhänge, Unterschriften, PDF-Lieferreport → DOCUframe - Stammdaten: Kunden, Artikel, Lager, Zahlungsarten, Services - Keycloak-JWT-Gate + Fahrer-Provisionierung via Admin-API - Admin-API-Key-Gate (X-Admin-Api-Key) für Maschinen-Endpunkte Jüngste Änderungen dieser Session: - Belegspezifische Kontaktdaten: alle ERP-Adressen (Beleg-/Liefer-/ Rechnungsadresse, Ansprechpartner, Kundenstamm) mit Telefon/Mobil/ E-Mail werden gesynct (Migration 0029, MSSQL-Query, TourDetails) - Konfiguration von .env (envy/dotenvy) auf config.toml (toml/serde) umgestellt; Vorlage config.example.toml, Pfad via HOLZLEITNER_CONFIG Nicht im Repo (per .gitignore): config.toml (Secrets), data/ (Laufzeit-/ Kundendaten), demo.mp4, .claude/, variocontrol-ai/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
169
crates/infrastructure/src/storage/local_attachment_storage.rs
Normal file
169
crates/infrastructure/src/storage/local_attachment_storage.rs
Normal file
@ -0,0 +1,169 @@
|
||||
//! Lokaler Dateisystem-Adapter für `AttachmentStorage`.
|
||||
//!
|
||||
//! Ersetzt (vorerst) den DOCUframe-Upload: hochgeladene Bild-Notizen landen
|
||||
//! als Dateien auf der Backend-Maschine — gruppiert pro **Belegnummer** in
|
||||
//! einem eigenen Unterordner:
|
||||
//!
|
||||
//! ```text
|
||||
//! <base_dir>/<Belegnummer>/<uuid>_<dateiname>
|
||||
//! ```
|
||||
//!
|
||||
//! Die in der DB (`attachments.docuframe_object_id`) gespeicherte Referenz ist
|
||||
//! der **relative** Pfad `<Belegnummer>/<datei>` — so bleibt ein Umzug des
|
||||
//! Verzeichnisses unkompliziert und der Download liest dieselbe Referenz
|
||||
//! wieder ein.
|
||||
//!
|
||||
//! Das DOCUframe-Pendant (`gsd::GsdService`) bleibt im Code erhalten und kann
|
||||
//! später wieder verdrahtet werden.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::{AttachmentStorage, PreviewImage};
|
||||
|
||||
pub struct LocalAttachmentStorage {
|
||||
base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl LocalAttachmentStorage {
|
||||
/// Legt das Basis-Verzeichnis (rekursiv) an, falls nötig. Ein
|
||||
/// nicht-anlegbares Verzeichnis ist ein Boot-Fehler.
|
||||
pub fn new(base_dir: impl Into<PathBuf>) -> std::io::Result<Self> {
|
||||
let base_dir = base_dir.into();
|
||||
std::fs::create_dir_all(&base_dir)?;
|
||||
Ok(Self { base_dir })
|
||||
}
|
||||
}
|
||||
|
||||
/// Macht aus einem beliebigen Segment einen dateisystem-sicheren Namen:
|
||||
/// erlaubt sind `[A-Za-z0-9._-]`, alles andere wird zu `_`. Verhindert damit
|
||||
/// auch Path-Traversal (`/`, `\`, `..` → `_`). Leeres Ergebnis → `unbenannt`.
|
||||
fn sanitize_segment(input: &str) -> String {
|
||||
let cleaned: String = input
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let trimmed = cleaned.trim_matches('.').trim();
|
||||
if trimmed.is_empty() {
|
||||
"unbenannt".to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Content-Type aus der Dateiendung (das lokale Storage rendert nicht, es
|
||||
/// liefert die Originalbytes — der Typ kommt aus der Endung).
|
||||
fn content_type_for(path: &Path) -> String {
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"png" => "image/png",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
"heic" | "heif" => "image/heic",
|
||||
"bmp" => "image/bmp",
|
||||
"pdf" => "application/pdf",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AttachmentStorage for LocalAttachmentStorage {
|
||||
async fn upload(
|
||||
&self,
|
||||
folder: &str,
|
||||
filename: &str,
|
||||
_mime: &str,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<String, ApplicationError> {
|
||||
let folder = sanitize_segment(folder);
|
||||
// Nur den reinen Dateinamen verwenden (etwaige Pfadanteile abschneiden).
|
||||
let raw_name = Path::new(filename)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(filename);
|
||||
let safe_name = sanitize_segment(raw_name);
|
||||
// UUID-Präfix → mehrere Bilder pro Beleg kollidieren nicht.
|
||||
let stored_name = format!("{}_{}", Uuid::new_v4(), safe_name);
|
||||
|
||||
let dir = self.base_dir.join(&folder);
|
||||
let path = dir.join(&stored_name);
|
||||
let reference = format!("{folder}/{stored_name}");
|
||||
|
||||
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
std::fs::write(&path, &bytes)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApplicationError::Repository(format!("join error: {e}")))?
|
||||
.map_err(|e| {
|
||||
ApplicationError::Repository(format!("bild speichern fehlgeschlagen: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(reference)
|
||||
}
|
||||
|
||||
async fn download_preview(
|
||||
&self,
|
||||
object_id: &str,
|
||||
_parameters: &str,
|
||||
_page: &str,
|
||||
) -> Result<PreviewImage, ApplicationError> {
|
||||
// `object_id` ist unsere relative Referenz `<Belegnummer>/<datei>`.
|
||||
// Defense-in-depth gegen Traversal: keine absoluten Pfade / `..`.
|
||||
if object_id.contains("..")
|
||||
|| object_id.starts_with('/')
|
||||
|| object_id.starts_with('\\')
|
||||
{
|
||||
return Err(ApplicationError::Validation(
|
||||
"ungültige Attachment-Referenz".into(),
|
||||
));
|
||||
}
|
||||
let path = self.base_dir.join(object_id);
|
||||
let content_type = content_type_for(&path);
|
||||
|
||||
let read_path = path.clone();
|
||||
let bytes = tokio::task::spawn_blocking(move || std::fs::read(&read_path))
|
||||
.await
|
||||
.map_err(|e| ApplicationError::Repository(format!("join error: {e}")))?
|
||||
.map_err(|_| ApplicationError::NotFound)?;
|
||||
|
||||
Ok(PreviewImage {
|
||||
bytes,
|
||||
content_type,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete(&self, reference: &str) -> Result<(), ApplicationError> {
|
||||
// `reference` = relative Referenz `<Belegnummer>/<datei>`. Traversal-Guard.
|
||||
if reference.contains("..") || reference.starts_with('/') || reference.starts_with('\\') {
|
||||
return Err(ApplicationError::Validation(
|
||||
"ungültige Attachment-Referenz".into(),
|
||||
));
|
||||
}
|
||||
let path = self.base_dir.join(reference);
|
||||
tokio::task::spawn_blocking(move || match std::fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
// Fehlende Datei ist kein Fehler (idempotent).
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApplicationError::Repository(format!("join error: {e}")))?
|
||||
.map_err(|e| ApplicationError::Repository(format!("bild löschen fehlgeschlagen: {e}")))
|
||||
}
|
||||
}
|
||||
11
crates/infrastructure/src/storage/mod.rs
Normal file
11
crates/infrastructure/src/storage/mod.rs
Normal file
@ -0,0 +1,11 @@
|
||||
//! Lokale Datei-Adapter.
|
||||
//!
|
||||
//! Im Gegensatz zu `gsd` (DOCUframe-Dokumentenspeicher) bleiben diese
|
||||
//! Daten auf der Backend-Maschine: Unterschriften-PNGs und (neu) die
|
||||
//! hochgeladenen Bild-Notizen pro Belegnummer.
|
||||
|
||||
pub mod local_attachment_storage;
|
||||
pub mod signature_storage;
|
||||
|
||||
pub use local_attachment_storage::LocalAttachmentStorage;
|
||||
pub use signature_storage::LocalSignatureStorage;
|
||||
99
crates/infrastructure/src/storage/signature_storage.rs
Normal file
99
crates/infrastructure/src/storage/signature_storage.rs
Normal file
@ -0,0 +1,99 @@
|
||||
//! 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.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::{SignatureRole, SignatureStorage};
|
||||
|
||||
pub struct LocalSignatureStorage {
|
||||
base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl LocalSignatureStorage {
|
||||
/// Legt das Basis-Verzeichnis (rekursiv) an, falls es noch nicht
|
||||
/// existiert. Ein nicht-anlegbares Verzeichnis ist ein Boot-Fehler.
|
||||
pub fn new(base_dir: impl Into<PathBuf>) -> std::io::Result<Self> {
|
||||
let base_dir = base_dir.into();
|
||||
std::fs::create_dir_all(&base_dir)?;
|
||||
Ok(Self { base_dir })
|
||||
}
|
||||
|
||||
fn file_name(delivery_id: Uuid, role: SignatureRole) -> String {
|
||||
format!("{delivery_id}_{}.png", role.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SignatureStorage for LocalSignatureStorage {
|
||||
async fn save(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
role: SignatureRole,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<String, ApplicationError> {
|
||||
let name = Self::file_name(delivery_id, 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))
|
||||
.await
|
||||
.map_err(|e| ApplicationError::Repository(format!("join error: {e}")))?
|
||||
.map_err(|e| {
|
||||
ApplicationError::Repository(format!("signatur speichern fehlgeschlagen: {e}"))
|
||||
})?;
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
async fn load(&self, reference: &str) -> Result<Option<Vec<u8>>, ApplicationError> {
|
||||
// Traversal-Schutz: Referenz ist ein einfacher Dateiname.
|
||||
if reference.contains('/') || reference.contains('\\') || reference.contains("..") {
|
||||
return Err(ApplicationError::Validation(
|
||||
"ungültige Signatur-Referenz".into(),
|
||||
));
|
||||
}
|
||||
let path = self.base_dir.join(reference);
|
||||
let bytes = tokio::task::spawn_blocking(move || std::fs::read(&path))
|
||||
.await
|
||||
.map_err(|e| ApplicationError::Repository(format!("join error: {e}")))?;
|
||||
match bytes {
|
||||
Ok(b) => Ok(Some(b)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(ApplicationError::Repository(format!(
|
||||
"signatur laden fehlgeschlagen: {e}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
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}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user