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:
Dennis Nemec
2026-06-01 17:52:58 +02:00
parent 438040acce
commit 6a9b5872e1
137 changed files with 13700 additions and 218 deletions

View File

@ -27,6 +27,6 @@ uuid.workspace = true
chrono.workspace = true
thiserror.workspace = true
anyhow.workspace = true
envy.workspace = true
dotenvy.workspace = true
toml.workspace = true
sqlx.workspace = true
tokio-cron-scheduler.workspace = true

View File

@ -1,27 +1,351 @@
//! Konfiguration aus Umgebungsvariablen (12-Factor-Stil).
//! Konfiguration aus einer `config.toml`-Datei.
//!
//! Für lokale Entwicklung lädt [`load`] zunächst eine optionale `.env`
//! Datei und parst dann pro Bereich (Server, Database, Keycloak) mit
//! Prefix-Filter über `envy`. So bleiben die Strukturen klar getrennt
//! und Fehlermeldungen verraten den genauen Bereich.
//! [`load`] liest die TOML-Datei (Pfad via `HOLZLEITNER_CONFIG`-Env
//! überschreibbar, sonst `config.toml` im Arbeitsverzeichnis) und
//! deserialisiert sie über `serde` in [`Config`]. Die Struktur ist nach
//! Bereichen in TOML-Sections gruppiert (`[server]`, `[database]`,
//! `[keycloak]`, …); fehlende optionale Sections/Felder fallen auf die
//! `#[serde(default = …)]`-Werte zurück.
//!
//! Die Vorlage liegt in `config.example.toml`; für lokale Entwicklung
//! `cp config.example.toml config.toml` und Werte anpassen. Die echte
//! `config.toml` enthält Secrets und gehört nicht in Git.
use std::path::PathBuf;
use chrono::NaiveDate;
use serde::Deserialize;
#[derive(Debug, Clone)]
/// Env-Variable, mit der der Pfad zur Config-Datei überschrieben werden
/// kann (z. B. im Deployment). Ohne sie wird `config.toml` im
/// Arbeitsverzeichnis erwartet.
const CONFIG_PATH_ENV: &str = "HOLZLEITNER_CONFIG";
const DEFAULT_CONFIG_PATH: &str = "config.toml";
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
pub server: ServerConfig,
pub database: DatabaseConfig,
#[allow(dead_code)] // wird in der Keycloak-Phase verdrahtet
pub keycloak: KeycloakConfig,
pub gsd: GsdConfig,
#[serde(default)]
pub signature: SignatureConfig,
#[serde(default)]
pub attachment: AttachmentConfig,
#[serde(default)]
pub report: ReportConfig,
#[serde(default)]
pub erp: ErpConfig,
#[serde(default)]
pub import: ImportConfig,
/// DEV-ONLY-Schalter (`today_override`, `sync_enabled`).
#[serde(default)]
pub dev: DevConfig,
/// Admin-API-Key-Gate auf `/admin`.
#[serde(default)]
pub admin: AdminConfig,
/// Log-Filter (Override via `RUST_LOG`-Env hat Vorrang, siehe main.rs).
#[serde(default)]
pub logging: LoggingConfig,
}
impl Config {
/// Loggt eine Zusammenfassung der geladenen Konfiguration beim Start.
/// Secrets (Passwörter, Client-Secrets, Hashes) werden **maskiert** —
/// nur „gesetzt/leer" ist sichtbar, nie der Klartext. Hilft, beim
/// Binary-Start auf einen Blick Fehlkonfigurationen zu erkennen (z. B.
/// einen aktiven `today_override`).
pub fn log_summary(&self) {
tracing::info!("════════════ Konfiguration (Start) ════════════");
tracing::info!(host = %self.server.host, port = self.server.port, "cfg.server");
tracing::info!(
url = %mask_db_url(&self.database.url),
max_connections = self.database.max_connections,
"cfg.database"
);
tracing::info!(
issuer_url = %self.keycloak.issuer_url,
audience = %self.keycloak.audience,
realm = %self.keycloak.realm,
admin_url = %display_or_unset(&self.keycloak.admin_url),
provisioning_enabled = self.keycloak.provisioning_enabled,
provisioner_client_id = %display_or_unset(&self.keycloak.provisioner_client_id),
provisioner_client_secret = %mask(&self.keycloak.provisioner_client_secret),
driver_role = %self.keycloak.driver_role,
driver_default_password = %mask(&self.keycloak.driver_default_password),
jwks_cache_ttl_seconds = self.keycloak.jwks_cache_ttl_seconds,
"cfg.keycloak"
);
tracing::info!(
host = %display_or_unset(&self.erp.host),
port = self.erp.port,
database = %display_or_unset(&self.erp.database),
user = %display_or_unset(&self.erp.user),
password = %mask(&self.erp.password),
trust_cert = self.erp.trust_cert,
writeback_enabled = self.erp.writeback_enabled,
"cfg.erp"
);
tracing::info!(
enabled = self.import.enabled,
cron = %self.import.cron,
date_offset_days = self.import.date_offset_days,
"cfg.import (scheduler)"
);
tracing::info!(
rest_url = %self.gsd.rest_url,
user = %self.gsd.user,
app_key = %mask(&self.gsd.app_key),
password_md5 = %mask(&self.gsd.password_md5),
app_names = ?self.gsd.app_names,
"cfg.gsd"
);
tracing::info!(storage_dir = %self.signature.storage_dir, "cfg.signature");
tracing::info!(storage_dir = %self.attachment.storage_dir, "cfg.attachment (Bild-Notizen lokal)");
tracing::info!(
storage_dir = %self.report.storage_dir,
upload_enabled = self.report.upload_enabled,
retry_cron = %self.report.retry_cron,
"cfg.report (PDF-Report → DOCUframe)"
);
if self.report.upload_enabled {
tracing::warn!("report.upload_enabled=true: Reports werden an DOCUframe übertragen + lokale Dateien danach gelöscht");
}
match &self.dev.today_override {
Some(date) => tracing::warn!(
today_override = %date,
"cfg.DEV: dev.today_override AKTIV — 'heute' ist überschrieben! \
In Produktion entfernen."
),
None => tracing::info!("cfg.dev.today_override = (echte Uhr)"),
}
if self.dev.sync_enabled {
tracing::warn!(
"cfg.DEV: dev.sync_enabled AKTIV — ungeschützter Endpoint \
POST /dev/resync löscht Postgres-Tourdaten! In Produktion aus."
);
}
tracing::info!(
admin_api_key = %mask(&self.admin.api_key),
"cfg.admin (X-Admin-Api-Key Gate auf /admin)"
);
if self.admin.api_key.is_empty() {
tracing::warn!(
"cfg.admin: admin.api_key LEER — alle /admin-Endpunkte sind \
GESPERRT (fail-closed). Zum Aktivieren admin.api_key setzen."
);
}
tracing::info!("═══════════════════════════════════════════════");
}
}
/// Maskiert ein Secret: zeigt nur, ob gesetzt — nie den Klartext.
fn mask(secret: &str) -> &'static str {
if secret.is_empty() {
"(leer)"
} else {
"***gesetzt***"
}
}
/// Zeigt den Wert oder `(leer)` für optionale, nicht-geheime Felder.
fn display_or_unset(value: &str) -> String {
if value.is_empty() {
"(leer)".to_string()
} else {
value.to_string()
}
}
/// Maskiert das Passwort in einer DB-URL: `scheme://user:pass@host` →
/// `scheme://user:***@host`. Lässt alles andere unverändert.
fn mask_db_url(url: &str) -> String {
let Some(scheme_end) = url.find("://") else {
return url.to_string();
};
let (scheme, rest) = url.split_at(scheme_end + 3);
let Some(at) = rest.find('@') else {
return url.to_string();
};
let (creds, host) = rest.split_at(at); // host beginnt mit '@'
let Some(colon) = creds.find(':') else {
return url.to_string();
};
let user = &creds[..colon];
format!("{scheme}{user}:***{host}")
}
/// Lese-Zugang zur ERPframe-MS-SQL-DB (für den täglichen Touren-Pull).
/// Alle Felder optional defaultet — der Server bootet auch ohne ERP-Config,
/// solange `import.enabled = false` (Default) ist.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ErpConfig {
#[serde(default)]
pub host: String,
#[serde(default = "default_mssql_port")]
pub port: u16,
#[serde(default)]
pub database: String,
#[serde(default)]
pub user: String,
#[serde(default)]
pub password: String,
/// Selbstsigniertes Server-Zertifikat akzeptieren (Intranet-DB).
#[serde(default = "default_true")]
pub trust_cert: bool,
/// ERP-**Rückschreiben** beim Lieferabschluss aktiv? Default `false` —
/// dann bleibt der Abschluss rein lokal (Dev/Seed ohne ERP-Beleg).
#[serde(default)]
pub writeback_enabled: bool,
}
impl Default for ErpConfig {
fn default() -> Self {
Self {
host: String::new(),
port: default_mssql_port(),
database: String::new(),
user: String::new(),
password: String::new(),
trust_cert: default_true(),
writeback_enabled: false,
}
}
}
fn default_mssql_port() -> u16 {
1433
}
fn default_true() -> bool {
true
}
/// Steuert den geplanten ERP-Import.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ImportConfig {
/// Default `false` — der Scheduler startet nur, wenn explizit aktiviert.
#[serde(default)]
pub enabled: bool,
/// Cron-Ausdruck (tokio-cron-scheduler, 6-stellig inkl. Sekunden).
/// Default: täglich 03:00:00.
#[serde(default = "default_import_cron")]
pub cron: String,
/// Versatz in Tagen relativ zu „heute" für das Ziel-Tourdatum.
/// Default `1` = morgen.
#[serde(default = "default_import_offset")]
pub date_offset_days: i64,
}
impl Default for ImportConfig {
fn default() -> Self {
Self {
enabled: false,
cron: default_import_cron(),
date_offset_days: default_import_offset(),
}
}
}
fn default_import_cron() -> String {
"0 0 3 * * *".to_string()
}
fn default_import_offset() -> i64 {
1
}
/// Lokaler Speicher für Unterschriften-PNGs (Kunde + Fahrer). Bewusst NICHT
/// DOCUframe — die Bilder bleiben auf der Backend-Maschine.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SignatureConfig {
/// Basis-Verzeichnis für die PNG-Dateien (wird beim Start angelegt).
#[serde(default = "default_signature_dir")]
pub storage_dir: String,
}
impl Default for SignatureConfig {
fn default() -> Self {
Self {
storage_dir: default_signature_dir(),
}
}
}
fn default_signature_dir() -> String {
"./data/signatures".to_string()
}
/// Lokaler Speicher für hochgeladene Bild-Notizen. Statt DOCUframe landen die
/// Bilder pro Belegnummer in einem Unterordner (`<dir>/<Belegnummer>/…`).
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AttachmentConfig {
/// Basis-Verzeichnis (wird beim Start angelegt).
#[serde(default = "default_attachment_dir")]
pub storage_dir: String,
}
impl Default for AttachmentConfig {
fn default() -> Self {
Self {
storage_dir: default_attachment_dir(),
}
}
}
fn default_attachment_dir() -> String {
"./data/attachments".to_string()
}
/// Lokaler (temporärer) Speicher für die PDF-Lieferreports. Später wird der
/// Blob direkt an ein DOCUframe-Makro gesendet (Sink-Stub vorhanden).
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReportConfig {
/// Basis-Verzeichnis (wird beim Start angelegt).
#[serde(default = "default_report_dir")]
pub storage_dir: String,
/// Schaltet die DOCUframe-Übertragung des Reports beim Abschluss + den
/// Retry-Cron frei. Aus = nur lokale Ablage (Dev / DOCUframe nicht erreichbar).
#[serde(default)]
pub upload_enabled: bool,
/// Cron-Ausdruck (6-stellig, mit Sekunden) für den Retry der offenen
/// Report-Jobs. Default: alle 5 Minuten.
#[serde(default = "default_report_retry_cron")]
pub retry_cron: String,
}
impl Default for ReportConfig {
fn default() -> Self {
Self {
storage_dir: default_report_dir(),
upload_enabled: false,
retry_cron: default_report_retry_cron(),
}
}
}
fn default_report_dir() -> String {
"./data/reports".to_string()
}
fn default_report_retry_cron() -> String {
"0 */5 * * * *".to_string()
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DatabaseConfig {
pub url: String,
#[serde(default = "default_max_connections")]
@ -36,62 +360,269 @@ fn default_max_connections() -> u32 {
/// Warnings unterdrücken.
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KeycloakConfig {
pub issuer_url: String,
pub audience: String,
#[serde(default = "default_jwks_cache_ttl")]
pub jwks_cache_ttl_seconds: u64,
// --- Provisioning via Admin-API (ERP-Sync legt Fahrer-Konten an) ------
/// Basis-URL der Keycloak-Instanz **ohne** `/realms/...`, z. B.
/// `http://localhost:8080`. Leer ⇒ Provisionierung nicht möglich.
#[serde(default)]
pub admin_url: String,
/// Realm-Name. Default `holzleitner`.
#[serde(default = "default_realm")]
pub realm: String,
/// Service-Account-Client (confidential) für die Admin-API.
#[serde(default)]
pub provisioner_client_id: String,
#[serde(default)]
pub provisioner_client_secret: String,
/// Temporäres Default-Passwort für neu angelegte Fahrer-Konten (muss beim
/// ersten Login geändert werden).
#[serde(default = "default_driver_password")]
pub driver_default_password: String,
/// Realm-Rolle, die jedem Fahrer zugewiesen wird. Default `driver`.
#[serde(default = "default_driver_role")]
pub driver_role: String,
/// Konto-Provisionierung beim Sync aktiv? Default `false`.
#[serde(default)]
pub provisioning_enabled: bool,
}
fn default_jwks_cache_ttl() -> u64 {
3600
}
fn default_realm() -> String {
"holzleitner".to_string()
}
fn default_driver_password() -> String {
"Holzleitner-Start1!".to_string()
}
fn default_driver_role() -> String {
"driver".to_string()
}
/// Lädt die Konfiguration aus Umgebungsvariablen.
/// Anbindung an die GSD/DOCUframe-REST-API (für Datei-Uploads).
///
/// Login läuft über einen technischen Service-Account; `password_md5` ist
/// bereits der MD5-Hash des Passworts (GSD erwartet `pass` als MD5) — so
/// liegt kein Klartext-Secret in der Konfiguration.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GsdConfig {
/// Basis-URL der GSD-REST-API (ohne abschließenden Slash), z. B.
/// `http://192.168.1.9:8334`.
pub rest_url: String,
pub app_key: String,
pub user: String,
/// MD5-Hash des Service-Account-Passworts.
pub password_md5: String,
/// App-Namen als TOML-Array, z. B. `["GSD-RestApi"]`.
pub app_names: Vec<String>,
}
/// DEV-ONLY-Schalter. Beide Felder defaulten auf „aus".
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct DevConfig {
/// Überschreibt das serverseitige „heute" für `GET /me/tours/today`.
/// Als **quotierter** String `"YYYY-MM-DD"` angeben — ein bloßes
/// `2026-06-01` würde TOML als nativen Datums-Typ lesen, den chrono
/// nicht direkt akzeptiert. `None`/weglassen = echte Uhr. Nur zum
/// Testen mit historischen/importierten Touren.
#[serde(default)]
pub today_override: Option<NaiveDate>,
/// Schaltet den ungeschützten Dev-Resync-Endpoint (`POST /dev/resync`)
/// frei, der Postgres platt macht und neu importiert. Default `false`
/// → Endpoint wird gar nicht erst gemountet.
#[serde(default)]
pub sync_enabled: bool,
}
/// Admin-API-Key-Gate auf `/admin`.
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct AdminConfig {
/// Statischer API-Key für die `/admin`-Endpunkte (Maschinen-Zugang per
/// Header `X-Admin-Api-Key`, KEIN Keycloak/JWT). **Leer ⇒ alle
/// Admin-Routen sind gesperrt** (fail-closed). In Produktion einen
/// hochentropischen Zufallswert setzen, nie committen.
#[serde(default)]
pub api_key: String,
}
/// Logging-Filter (tracing-subscriber `EnvFilter`-Syntax).
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LoggingConfig {
/// Default-Filter. Die `RUST_LOG`-Env-Variable hat Vorrang (siehe
/// `main.rs`), damit Ad-hoc-Debugging ohne Datei-Edit möglich bleibt.
#[serde(default = "default_log_filter")]
pub filter: String,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
filter: default_log_filter(),
}
}
}
fn default_log_filter() -> String {
// WICHTIG: Der Binary-Crate heißt `holzleitner_server` (vom `[[bin]]
// name`), NICHT `holzleitner_api` — sonst werden alle Logs aus
// main.rs/config.rs (Startup + Config-Übersicht) herausgefiltert.
"holzleitner_server=info,holzleitner_api=info,\
holzleitner_application=info,holzleitner_infrastructure=info,\
tower_http=info"
.to_string()
}
/// Pfad zur Config-Datei: `HOLZLEITNER_CONFIG`-Env, sonst `config.toml`.
fn config_path() -> PathBuf {
match std::env::var(CONFIG_PATH_ENV) {
Ok(p) if !p.trim().is_empty() => PathBuf::from(p.trim()),
_ => PathBuf::from(DEFAULT_CONFIG_PATH),
}
}
/// Lädt die Konfiguration aus der TOML-Datei.
///
/// Reihenfolge:
/// 1. Optionale `.env`-Datei im Arbeitsverzeichnis (für Local-Dev).
/// 2. Pro Bereich werden Variablen mit passendem Prefix gelesen:
/// * `SERVER_*` für [`ServerConfig`]
/// * `DATABASE_*` für [`DatabaseConfig`]
/// * `KEYCLOAK_*` für [`KeycloakConfig`]
/// 1. Pfad bestimmen (`HOLZLEITNER_CONFIG`-Env oder `config.toml`).
/// 2. Datei lesen und mit `toml`/`serde` in [`Config`] deserialisieren.
/// Fehlende optionale Sections/Felder fallen auf die `serde`-Defaults.
pub fn load() -> Result<Config, ConfigError> {
// `.env` ist optional — in Produktion kommen die Werte aus dem
// System-Environment.
let _ = dotenvy::dotenv();
let path = config_path();
let raw = std::fs::read_to_string(&path).map_err(|source| ConfigError::Read {
path: path.clone(),
source,
})?;
parse(&raw).map_err(|source| ConfigError::Parse { path, source })
}
let server = envy::prefixed("SERVER_")
.from_env::<ServerConfig>()
.map_err(|e| ConfigError::Section {
section: "SERVER",
source: e,
})?;
let database = envy::prefixed("DATABASE_")
.from_env::<DatabaseConfig>()
.map_err(|e| ConfigError::Section {
section: "DATABASE",
source: e,
})?;
let keycloak = envy::prefixed("KEYCLOAK_")
.from_env::<KeycloakConfig>()
.map_err(|e| ConfigError::Section {
section: "KEYCLOAK",
source: e,
})?;
Ok(Config {
server,
database,
keycloak,
})
/// Deserialisiert TOML-Text in [`Config`]. Ausgelagert, damit er in Tests
/// ohne Dateizugriff aufgerufen werden kann.
fn parse(raw: &str) -> Result<Config, toml::de::Error> {
toml::from_str::<Config>(raw)
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("missing or invalid env vars in section {section}: {source}")]
Section {
section: &'static str,
// PathBuf implementiert kein Display → Debug-Format `{path:?}`.
#[error("config-Datei {path:?} konnte nicht gelesen werden: {source}")]
Read {
path: PathBuf,
#[source]
source: envy::Error,
source: std::io::Error,
},
#[error("config-Datei {path:?} ist ungültig: {source}")]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
}
#[cfg(test)]
mod tests {
use super::*;
/// Minimal-Config mit nur den Pflicht-Sections — optionale Sections
/// fallen auf Defaults zurück.
#[test]
fn parses_minimal_config() {
let raw = r#"
[server]
host = "0.0.0.0"
port = 3000
[database]
url = "postgres://u:p@localhost/db"
[keycloak]
issuer_url = "http://localhost:8080/realms/holzleitner"
audience = "holzleitner-api"
[gsd]
rest_url = "http://localhost:8334"
app_key = "k"
user = "u"
password_md5 = "abc"
app_names = ["GSD-RestApi"]
"#;
let cfg = parse(raw).expect("minimal config should parse");
assert_eq!(cfg.server.port, 3000);
assert_eq!(cfg.database.max_connections, 10); // default
assert_eq!(cfg.report.storage_dir, "./data/reports"); // default section
assert!(!cfg.dev.sync_enabled); // default
assert!(cfg.dev.today_override.is_none());
assert!(cfg.admin.api_key.is_empty());
assert_eq!(cfg.erp.port, 1433); // default
}
/// `today_override` wird als `YYYY-MM-DD` geparst.
#[test]
fn parses_today_override_and_arrays() {
let raw = r#"
[server]
host = "0.0.0.0"
port = 3000
[database]
url = "postgres://u:p@localhost/db"
[keycloak]
issuer_url = "iss"
audience = "aud"
[gsd]
rest_url = "http://localhost:8334"
app_key = "k"
user = "u"
password_md5 = "abc"
app_names = ["A", "B"]
[dev]
today_override = "2026-06-01"
sync_enabled = true
"#;
let cfg = parse(raw).expect("config should parse");
assert_eq!(
cfg.dev.today_override,
Some(NaiveDate::from_ymd_opt(2026, 6, 1).unwrap())
);
assert!(cfg.dev.sync_enabled);
assert_eq!(cfg.gsd.app_names, vec!["A".to_string(), "B".to_string()]);
}
/// Unbekannte Schlüssel werden abgewiesen — schützt vor Tippfehlern.
#[test]
fn rejects_unknown_keys() {
let raw = r#"
[server]
host = "0.0.0.0"
port = 3000
bogus = true
[database]
url = "postgres://u:p@localhost/db"
[keycloak]
issuer_url = "iss"
audience = "aud"
[gsd]
rest_url = "r"
app_key = "k"
user = "u"
password_md5 = "abc"
app_names = []
"#;
assert!(parse(raw).is_err());
}
}

View File

@ -33,6 +33,7 @@ impl IntoResponse for ApiError {
ApplicationError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
ApplicationError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
ApplicationError::Validation(_) => (StatusCode::BAD_REQUEST, "validation"),
ApplicationError::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
ApplicationError::Repository(_)
| ApplicationError::External(_)
| ApplicationError::Unexpected(_) => {

View File

@ -1,8 +1,8 @@
//! Holzleitner-API — HTTP-Layer und Composition Root.
//!
//! Bootstrap-Reihenfolge:
//! 1. Tracing/Logging initialisieren
//! 2. Konfiguration aus Env-Variablen / `.env` laden
//! 1. Konfiguration aus `config.toml` laden (liefert u. a. den Log-Filter)
//! 2. Tracing/Logging initialisieren
//! 3. Postgres-Pool aufbauen und Migrations ausführen
//! 4. Keycloak-AuthService instanziieren
//! 5. Use Cases zusammenstellen und in `AppState` packen
@ -24,36 +24,73 @@ use anyhow::Context;
use axum::Router;
use axum::middleware::from_fn_with_state;
use holzleitner_application::usecases::{
ApplyDeliveryActionUseCase, ApplyScansUseCase, AssignCarToDeliveryUseCase,
CreateDeliveryNoteUseCase, CreateMyCarUseCase, GetAccountUseCase, GetTourUseCase,
ListMyCarsUseCase, ListMyToursTodayUseCase, SetDeliveryOrderUseCase, SyncTourUseCase,
UpdateMyCarUseCase,
ApplyDeliveryActionUseCase, ApplyDeliveryCreditEventUseCase, ApplyScansUseCase,
AssignCarToDeliveryUseCase, CompleteDeliveryUseCase, CreateDeliveryNoteUseCase,
CreateMyCarUseCase, CreatePaymentMethodUseCase, CreateServiceUseCase,
DeleteDeliveryNoteUseCase, DeleteDeliveryServiceUseCase, DeletePaymentMethodUseCase,
DeleteServiceUseCase, DevResyncToursUseCase, GenerateDeliveryReportUseCase, GetAccountUseCase,
GetAttachmentPreviewUseCase, GetTourUseCase,
ImportErpToursUseCase, ListDeliveredBelegnummernUseCase, ListMyCarsUseCase,
ListMyToursTodayUseCase, ListPaymentMethodsUseCase,
ListServicesUseCase, MarkMailSentUseCase, PushCompletionToErpUseCase,
SetDeliveryOrderUseCase, SetDeliveryServiceUseCase, SyncTourUseCase, UpdateDeliveryNoteUseCase,
ProcessDeliveryReportUseCase, UpdateMyCarUseCase, UpdatePaymentMethodUseCase,
UpdateServiceUseCase, UploadDeliveryNoteImageUseCase,
};
use holzleitner_application::ports::DeliveryReportJobRepository;
use holzleitner_infrastructure::auth::{
KeycloakAdapterConfig, KeycloakAdminClient, KeycloakAdminConfig, KeycloakAuthService,
};
use holzleitner_infrastructure::erp::{
MssqlErpConfig, MssqlErpDeliverySource, MssqlErpDeliveryWriteback,
};
use holzleitner_infrastructure::gsd::{GsdConfig, GsdService};
use holzleitner_infrastructure::report::{
LocalReportSink, PdfDeliveryReportRenderer, PgDeliveryReportRepository,
};
use holzleitner_infrastructure::auth::{KeycloakAdapterConfig, KeycloakAuthService};
use holzleitner_infrastructure::persistence::{
PgAccountRepository, PgCarRepository, PgDeliveryNoteRepository, PgDeliveryRepository,
PgScanRepository, PgTourRepository, PoolConfig, connect_and_migrate,
PgAccountRepository, PgAttachmentRepository, PgCarRepository, PgDeliveryCompletionRepository,
PgDeliveryCreditRepository, PgDeliveryNoteRepository, PgDeliveryReportJobRepository,
PgDeliveryRepository, PgDeliveryServiceRepository, PgPaymentMethodRepository, PgScanRepository,
PgServiceRepository, PgTourRepository, PoolConfig, connect_and_migrate,
};
use holzleitner_infrastructure::storage::{LocalAttachmentStorage, LocalSignatureStorage};
use tokio_cron_scheduler::{Job, JobScheduler};
use tower_http::trace::TraceLayer;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
use crate::middleware::jwt_middleware;
use crate::middleware::{admin_api_key_middleware, jwt_middleware};
use crate::openapi::ApiDoc;
use crate::state::AppState;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Config ZUERST laden — der Log-Filter steht jetzt in `config.toml`
// (`[logging] filter`), also brauchen wir die Config, bevor der
// Subscriber initialisiert wird. Fehler hier werden von anyhow auch
// ohne aktiven Subscriber sichtbar (stderr).
let cfg = config::load().context("config laden fehlgeschlagen")?;
tracing_subscriber::fmt()
// Auf stderr loggen statt stdout: stderr ist unbuffered und erscheint
// damit sofort — auch wenn die Ausgabe in eine Datei/Pipe/ein
// IDE-Terminal läuft (stdout wäre dort blockgepuffert → Logs würden
// erst verspätet/gar nicht sichtbar).
.with_writer(std::io::stderr)
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
"holzleitner_api=info,holzleitner_infrastructure=info,tower_http=info".into()
}),
// `RUST_LOG`-Env hat Vorrang (Ad-hoc-Debugging ohne Datei-Edit),
// sonst der Filter aus `config.toml` (`[logging] filter`).
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| cfg.logging.filter.clone().into()),
)
.init();
let cfg = config::load().context("config laden fehlgeschlagen")?;
tracing::info!(host = %cfg.server.host, port = cfg.server.port, "starting up");
tracing::info!("starting up");
// Vollständige (secret-maskierte) Konfig-Übersicht beim Start — damit
// Fehlkonfigurationen (z. B. ein aktiver dev.today_override) sofort
// im Log sichtbar sind.
cfg.log_summary();
// --- Persistence ---------------------------------------------------
let pool = connect_and_migrate(&PoolConfig {
@ -69,7 +106,74 @@ async fn main() -> anyhow::Result<()> {
let scan_repository = Arc::new(PgScanRepository::new(pool.clone()));
let delivery_repository = Arc::new(PgDeliveryRepository::new(pool.clone()));
let delivery_note_repository = Arc::new(PgDeliveryNoteRepository::new(pool.clone()));
let car_repository = Arc::new(PgCarRepository::new(pool));
let attachment_repository = Arc::new(PgAttachmentRepository::new(pool.clone()));
let car_repository = Arc::new(PgCarRepository::new(pool.clone()));
let payment_method_repository = Arc::new(PgPaymentMethodRepository::new(pool.clone()));
let delivery_credit_repository = Arc::new(PgDeliveryCreditRepository::new(pool.clone()));
let delivery_completion_repository =
Arc::new(PgDeliveryCompletionRepository::new(pool.clone()));
let service_repository = Arc::new(PgServiceRepository::new(pool.clone()));
let delivery_service_repository = Arc::new(PgDeliveryServiceRepository::new(pool.clone()));
let report_repository = Arc::new(PgDeliveryReportRepository::new(pool.clone()));
let report_job_repository = Arc::new(PgDeliveryReportJobRepository::new(pool.clone()));
// --- Lokaler Unterschriften-Speicher (Dateisystem) -----------------
let signature_storage = Arc::new(
LocalSignatureStorage::new(&cfg.signature.storage_dir)
.context("Signatur-Verzeichnis konnte nicht angelegt werden")?,
);
tracing::info!(dir = %cfg.signature.storage_dir, "signature storage ready");
// --- Lokaler Bild-Speicher (Bild-Notizen, Ordner = Belegnummer) ----
// Ersetzt den DOCUframe-Upload für Bild-Notizen. Der GSD-Service bleibt
// erhalten (s. u.), wird aber nicht mehr für Attachments verdrahtet.
let attachment_storage = Arc::new(
LocalAttachmentStorage::new(&cfg.attachment.storage_dir)
.context("Attachment-Verzeichnis konnte nicht angelegt werden")?,
);
tracing::info!(dir = %cfg.attachment.storage_dir, "attachment storage ready (lokal)");
// --- PDF-Lieferreport (lokaler/temporärer Sink; DOCUframe später) --
let report_sink = Arc::new(
LocalReportSink::new(&cfg.report.storage_dir)
.context("Report-Verzeichnis konnte nicht angelegt werden")?,
);
let generate_delivery_report = Arc::new(GenerateDeliveryReportUseCase::new(
report_repository,
Arc::new(PdfDeliveryReportRenderer),
report_sink.clone(),
signature_storage.clone(),
attachment_storage.clone(),
));
tracing::info!(dir = %cfg.report.storage_dir, "delivery report renderer ready (printpdf, lokal)");
// --- GSD/DOCUframe (Datei-Upload) ----------------------------------
// Bleibt für den Lizenz-Release beim Shutdown konstruiert. Der frühere
// Attachment-Upload/-Download über DOCUframe ist hier bewusst nicht mehr
// verdrahtet (Code in `gsd::GsdService` bleibt für später erhalten).
let gsd_service = Arc::new(GsdService::new(
pool,
GsdConfig {
rest_url: cfg.gsd.rest_url.clone(),
app_key: cfg.gsd.app_key.clone(),
user: cfg.gsd.user.clone(),
password_md5: cfg.gsd.password_md5.clone(),
app_names: cfg.gsd.app_names.clone(),
},
));
// --- Report-Upload-Pipeline (Report → DOCUframe, mit Retry-Job) ----
// Nutzt GsdService als DocuframeReportGateway (Upload + Makro). Räumt
// nach Erfolg lokale Dateien (Report, Unterschriften, Bild-Notizen) auf.
let process_delivery_report = Arc::new(ProcessDeliveryReportUseCase::new(
generate_delivery_report.clone(),
report_job_repository.clone(),
gsd_service.clone(),
attachment_repository.clone(),
attachment_storage.clone(),
signature_storage.clone(),
report_sink.clone(),
));
// --- Auth ----------------------------------------------------------
let auth_service = Arc::new(KeycloakAuthService::new(KeycloakAdapterConfig {
@ -86,8 +190,74 @@ async fn main() -> anyhow::Result<()> {
// --- Use Cases -----------------------------------------------------
let get_account = Arc::new(GetAccountUseCase::new(account_repository));
let get_tour = Arc::new(GetTourUseCase::new(tour_repository.clone()));
let list_my_tours_today = Arc::new(ListMyToursTodayUseCase::new(tour_repository.clone()));
let list_my_tours_today = Arc::new(ListMyToursTodayUseCase::new(
tour_repository.clone(),
cfg.dev.today_override,
));
let sync_tour = Arc::new(SyncTourUseCase::new(tour_repository.clone()));
// ERP-Import (täglicher Pull aus ERPframe-MSSQL) — nutzt denselben
// Sync-Pfad wie der HTTP-Endpoint. Geht NICHT in den AppState (kein
// HTTP-Endpoint), sondern nur in den Scheduler.
let erp_mssql_config = MssqlErpConfig {
host: cfg.erp.host.clone(),
port: cfg.erp.port,
database: cfg.erp.database.clone(),
user: cfg.erp.user.clone(),
password: cfg.erp.password.clone(),
trust_cert: cfg.erp.trust_cert,
};
let erp_source = Arc::new(MssqlErpDeliverySource::new(erp_mssql_config.clone()));
// Optionaler Keycloak-Provisioner: legt beim Sync fehlende Fahrer-Konten
// an (Username = Fahrer-/Account-Nummer, temporäres Passwort). Nur aktiv,
// wenn `KEYCLOAK_PROVISIONING_ENABLED=true`.
let driver_provisioner: Option<
Arc<dyn holzleitner_application::ports::DriverIdentityProvisioner>,
> = if cfg.keycloak.provisioning_enabled {
tracing::info!(
admin_url = %cfg.keycloak.admin_url,
realm = %cfg.keycloak.realm,
role = %cfg.keycloak.driver_role,
"keycloak driver provisioning ENABLED"
);
Some(Arc::new(KeycloakAdminClient::new(KeycloakAdminConfig {
base_url: cfg.keycloak.admin_url.clone(),
realm: cfg.keycloak.realm.clone(),
client_id: cfg.keycloak.provisioner_client_id.clone(),
client_secret: cfg.keycloak.provisioner_client_secret.clone(),
default_password: cfg.keycloak.driver_default_password.clone(),
driver_role: cfg.keycloak.driver_role.clone(),
})))
} else {
tracing::info!("keycloak driver provisioning disabled");
None
};
let import_erp_tours = Arc::new(ImportErpToursUseCase::new(
erp_source,
sync_tour.clone(),
driver_provisioner,
));
// DEV-ONLY: überschreibender Resync (löscht Postgres-Tourdaten + Import).
// Wird immer gebaut, der Endpoint aber nur bei dev.sync_enabled gemountet.
let dev_resync_tours = Arc::new(DevResyncToursUseCase::new(
tour_repository.clone(),
import_erp_tours.clone(),
));
// ERP-Rückschreiben beim Lieferabschluss. Der Push-Use-Case wird IMMER
// gebaut (Admin-Retry-Endpunkt nutzt ihn manuell). Ob der normale
// Abschluss-Pfad automatisch pusht, steuert `ERP_WRITEBACK_ENABLED`.
let erp_writeback = Arc::new(MssqlErpDeliveryWriteback::new(erp_mssql_config));
let push_completion_to_erp = Arc::new(PushCompletionToErpUseCase::new(
delivery_completion_repository.clone(),
erp_writeback,
));
// Admin-Lese-Use-Case: Belegnummern der an einem Tag ausgelieferten
// Lieferungen. `.clone()` VOR dem späteren Move in `complete_delivery`.
let list_delivered_belegnummern = Arc::new(ListDeliveredBelegnummernUseCase::new(
delivery_completion_repository.clone(),
));
let mark_mail_sent = Arc::new(MarkMailSentUseCase::new(
delivery_completion_repository.clone(),
));
let set_delivery_order = Arc::new(SetDeliveryOrderUseCase::new(tour_repository));
let apply_scans = Arc::new(ApplyScansUseCase::new(
scan_repository,
@ -95,10 +265,39 @@ async fn main() -> anyhow::Result<()> {
));
let apply_delivery_action =
Arc::new(ApplyDeliveryActionUseCase::new(delivery_repository.clone()));
let complete_delivery = Arc::new(CompleteDeliveryUseCase::new(
delivery_completion_repository,
signature_storage,
car_repository.clone(),
if cfg.erp.writeback_enabled {
Some(push_completion_to_erp.clone())
} else {
None
},
));
let apply_delivery_credit_event = Arc::new(ApplyDeliveryCreditEventUseCase::new(
delivery_credit_repository,
car_repository.clone(),
));
let create_delivery_note = Arc::new(CreateDeliveryNoteUseCase::new(
delivery_note_repository.clone(),
car_repository.clone(),
));
let update_delivery_note = Arc::new(UpdateDeliveryNoteUseCase::new(
delivery_note_repository.clone(),
));
let delete_delivery_note =
Arc::new(DeleteDeliveryNoteUseCase::new(delivery_note_repository.clone()));
let upload_delivery_note_image = Arc::new(UploadDeliveryNoteImageUseCase::new(
attachment_storage.clone(),
attachment_repository.clone(),
delivery_note_repository,
car_repository.clone(),
));
let get_attachment_preview = Arc::new(GetAttachmentPreviewUseCase::new(
attachment_repository,
attachment_storage.clone(),
));
let list_my_cars = Arc::new(ListMyCarsUseCase::new(car_repository.clone()));
let create_my_car = Arc::new(CreateMyCarUseCase::new(car_repository.clone()));
let update_my_car = Arc::new(UpdateMyCarUseCase::new(car_repository.clone()));
@ -106,6 +305,28 @@ async fn main() -> anyhow::Result<()> {
car_repository,
delivery_repository,
));
let list_payment_methods = Arc::new(ListPaymentMethodsUseCase::new(
payment_method_repository.clone(),
));
let create_payment_method = Arc::new(CreatePaymentMethodUseCase::new(
payment_method_repository.clone(),
));
let update_payment_method = Arc::new(UpdatePaymentMethodUseCase::new(
payment_method_repository.clone(),
));
let delete_payment_method =
Arc::new(DeletePaymentMethodUseCase::new(payment_method_repository));
let list_services = Arc::new(ListServicesUseCase::new(service_repository.clone()));
let create_service = Arc::new(CreateServiceUseCase::new(service_repository.clone()));
let update_service = Arc::new(UpdateServiceUseCase::new(service_repository.clone()));
let delete_service = Arc::new(DeleteServiceUseCase::new(service_repository.clone()));
let set_delivery_service = Arc::new(SetDeliveryServiceUseCase::new(
service_repository,
delivery_service_repository.clone(),
));
let delete_delivery_service = Arc::new(DeleteDeliveryServiceUseCase::new(
delivery_service_repository,
));
let state = AppState {
get_account,
@ -113,14 +334,39 @@ async fn main() -> anyhow::Result<()> {
list_my_tours_today,
sync_tour,
set_delivery_order,
import_erp_tours: import_erp_tours.clone(),
dev_resync_tours,
generate_delivery_report,
process_delivery_report: process_delivery_report.clone(),
report_upload_enabled: cfg.report.upload_enabled,
apply_scans,
apply_delivery_action,
complete_delivery,
push_completion_to_erp,
list_delivered_belegnummern,
mark_mail_sent,
apply_delivery_credit_event,
create_delivery_note,
update_delivery_note,
delete_delivery_note,
upload_delivery_note_image,
get_attachment_preview,
list_my_cars,
create_my_car,
update_my_car,
assign_car_to_delivery,
list_payment_methods,
create_payment_method,
update_payment_method,
delete_payment_method,
list_services,
create_service,
update_service,
delete_service,
set_delivery_service,
delete_delivery_service,
auth_service,
admin_api_key: cfg.admin.api_key.clone().into(),
};
// --- Router --------------------------------------------------------
@ -130,29 +376,156 @@ async fn main() -> anyhow::Result<()> {
// auf dem protected-Subtree. `route_layer` greift nur für die jetzt
// definierten Routen — neue Routen darunter müssten explizit
// nochmal angehängt werden.
let public = Router::new()
let mut public = Router::new()
.merge(routes::health::router())
.merge(SwaggerUi::new("/swagger-ui").url("/openapi.json", ApiDoc::openapi()));
// DEV-ONLY: ungeschützten Resync-Endpoint NUR bei explizit gesetztem Flag
// mounten — in Produktion existiert er nicht.
if cfg.dev.sync_enabled {
public = public.merge(routes::dev::router());
tracing::warn!("DEV: POST /dev/resync gemountet (unauthentifiziert, dev.sync_enabled=true)");
}
let protected = Router::new()
.merge(routes::accounts::router())
.merge(routes::tours::router())
.merge(routes::scans::router())
.merge(routes::deliveries::router())
.merge(routes::attachments::router())
.merge(routes::cars::router())
.merge(routes::payment_methods::router())
.merge(routes::services::router())
.route_layer(from_fn_with_state(state.clone(), jwt_middleware));
// `/admin`-Routen sind Maschinen-Endpunkte (Cron/ERPframe/Ops) und laufen
// bewusst NICHT über die JWT-Middleware, sondern über ein eigenes
// statisches-API-Key-Gate (Header `X-Admin-Api-Key`). Eigener Subtree mit
// eigener `route_layer`.
let admin = routes::admin::router()
.route_layer(from_fn_with_state(state.clone(), admin_api_key_middleware));
let app = Router::new()
.merge(public)
.merge(protected)
.merge(admin)
.layer(TraceLayer::new_for_http())
.with_state(state);
// --- ERP-Import-Scheduler (optional) -------------------------------
// Läuft im selben Prozess. Nur wenn IMPORT_ENABLED=true. Hält den
// JobScheduler am Leben, indem die Variable bis Programmende gebunden
// bleibt (Drop würde die Jobs stoppen).
let _erp_scheduler = if cfg.import.enabled {
let scheduler = JobScheduler::new()
.await
.context("JobScheduler konnte nicht erstellt werden")?;
let import = import_erp_tours.clone();
let offset = cfg.import.date_offset_days;
let job = Job::new_async(cfg.import.cron.as_str(), move |_uuid, _lock| {
let import = import.clone();
Box::pin(async move {
let date = (chrono::Utc::now() + chrono::Duration::days(offset)).date_naive();
match import.execute(date).await {
Ok(summary) => tracing::info!(
date = %summary.date,
total = summary.tours_total,
ok = summary.tours_ok,
failed = summary.tours_failed,
"erp_import.done"
),
Err(e) => tracing::error!(%date, error = %e, "erp_import.failed"),
}
})
})
.context("ERP-Import-Job konnte nicht erstellt werden")?;
scheduler.add(job).await.context("ERP-Import-Job add fehlgeschlagen")?;
scheduler.start().await.context("Scheduler-Start fehlgeschlagen")?;
tracing::info!(
cron = %cfg.import.cron,
offset_days = cfg.import.date_offset_days,
"erp_import scheduler gestartet"
);
Some(scheduler)
} else {
tracing::info!("erp_import deaktiviert (IMPORT_ENABLED!=true)");
None
};
// --- Report-Retry-Scheduler (offene DOCUframe-Übertragungen) -------
// Greift alle offenen `delivery_report_jobs` periodisch erneut auf
// (Upload/Makro, die zuvor fehlschlugen — z. B. DOCUframe nicht erreichbar).
let _report_scheduler = if cfg.report.upload_enabled {
let scheduler = JobScheduler::new()
.await
.context("Report-JobScheduler konnte nicht erstellt werden")?;
let process = process_delivery_report.clone();
let jobs = report_job_repository.clone();
let job = Job::new_async(cfg.report.retry_cron.as_str(), move |_uuid, _lock| {
let process = process.clone();
let jobs = jobs.clone();
Box::pin(async move {
match jobs.list_open().await {
Ok(open) => {
if !open.is_empty() {
tracing::info!(count = open.len(), "report_retry: offene Jobs werden erneut versucht");
}
for j in open {
if let Err(e) = process.execute(j.delivery_id).await {
tracing::warn!(delivery_id = %j.delivery_id, error = %e, "report_retry: Job weiterhin offen");
}
}
}
Err(e) => tracing::error!(error = %e, "report_retry: offene Jobs konnten nicht geladen werden"),
}
})
})
.context("Report-Retry-Job konnte nicht erstellt werden")?;
scheduler.add(job).await.context("Report-Retry-Job add fehlgeschlagen")?;
scheduler.start().await.context("Report-Scheduler-Start fehlgeschlagen")?;
tracing::info!(cron = %cfg.report.retry_cron, "report_retry scheduler gestartet");
Some(scheduler)
} else {
tracing::info!("report_upload deaktiviert (REPORT_UPLOAD_ENABLED!=true) — Reports nur lokal");
None
};
let addr: SocketAddr = format!("{}:{}", cfg.server.host, cfg.server.port)
.parse()
.with_context(|| format!("ungültige Adresse {}:{}", cfg.server.host, cfg.server.port))?;
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!("server läuft auf http://{}", addr);
axum::serve(listener, app).await?;
// Graceful Shutdown: bei Ctrl-C / SIGTERM die GSD-Lizenz aktiv freigeben,
// damit der Seat nicht bis zum Session-Ablauf geblockt bleibt.
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal(gsd_service))
.await?;
Ok(())
}
/// Wartet auf Ctrl-C bzw. SIGTERM und gibt davor die GSD-Lizenz frei.
async fn shutdown_signal(gsd_service: Arc<GsdService>) {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("Ctrl-C-Handler konnte nicht installiert werden");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("SIGTERM-Handler konnte nicht installiert werden")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("shutdown signal empfangen — gebe GSD-Lizenz frei");
gsd_service.release_license().await;
}

View File

@ -0,0 +1,102 @@
//! Admin-API-Key-Middleware: schützt die `/admin`-Routen mit einem
//! statischen Schlüssel (Maschinen-Zugang aus Cron/ERPframe-Makro/Skripten),
//! **ohne** Keycloak/JWT.
//!
//! Der Aufrufer schickt den Schlüssel im Header `X-Admin-Api-Key`. Verglichen
//! wird gegen `AppState::admin_api_key` (aus `[admin] api_key` in config.toml).
//!
//! Sicherheits-Eigenschaften:
//! * **Fail-closed**: ist kein Schlüssel konfiguriert (leer), wird *jede*
//! Admin-Anfrage abgelehnt — niemals „offen, weil unkonfiguriert".
//! * **Konstant-zeitlicher Vergleich**: kein früher Abbruch beim ersten
//! abweichenden Byte (reduziert Timing-Seitenkanäle).
//! * Bei fehlendem/falschem Schlüssel: `401 Unauthorized`, ohne den Grund
//! an den Client zu verraten (Detail nur ins Log).
use axum::extract::{Request, State};
use axum::http::HeaderMap;
use axum::middleware::Next;
use axum::response::Response;
use holzleitner_application::error::ApplicationError;
use crate::error::ApiError;
use crate::state::AppState;
/// Header-Name für den Admin-API-Key.
pub const ADMIN_API_KEY_HEADER: &str = "x-admin-api-key";
pub async fn admin_api_key_middleware(
State(state): State<AppState>,
req: Request,
next: Next,
) -> Result<Response, ApiError> {
let configured = state.admin_api_key.as_ref();
// Fail-closed: ohne konfigurierten Schlüssel ist /admin komplett dicht.
if configured.is_empty() {
tracing::warn!(
"admin-Zugriff abgelehnt: admin.api_key ist nicht gesetzt (fail-closed)"
);
return Err(ApiError(ApplicationError::Unauthorized));
}
let presented = extract_key(req.headers());
match presented {
Some(key) if constant_time_eq(key.as_bytes(), configured.as_bytes()) => {
tracing::debug!("admin-api-key ok");
Ok(next.run(req).await)
}
Some(_) => {
tracing::warn!("admin-Zugriff abgelehnt: falscher X-Admin-Api-Key");
Err(ApiError(ApplicationError::Unauthorized))
}
None => {
tracing::warn!("admin-Zugriff abgelehnt: Header X-Admin-Api-Key fehlt");
Err(ApiError(ApplicationError::Unauthorized))
}
}
}
fn extract_key(headers: &HeaderMap) -> Option<&str> {
headers.get(ADMIN_API_KEY_HEADER)?.to_str().ok()
}
/// Konstant-zeitlicher Byte-Vergleich. Die Längen-Vorprüfung verrät nur die
/// Schlüssellänge (unkritisch); der Inhalt wird ohne früh­zeitigen Abbruch
/// verglichen.
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff: u8 = 0;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
#[cfg(test)]
mod tests {
use super::constant_time_eq;
#[test]
fn eq_matches_identical() {
assert!(constant_time_eq(b"secret-key", b"secret-key"));
}
#[test]
fn eq_rejects_different_content_same_len() {
assert!(!constant_time_eq(b"secret-key", b"secret-kex"));
}
#[test]
fn eq_rejects_different_len() {
assert!(!constant_time_eq(b"short", b"longer-key"));
}
#[test]
fn eq_rejects_empty_vs_nonempty() {
assert!(!constant_time_eq(b"", b"x"));
}
}

View File

@ -1,6 +1,8 @@
//! Axum-Middleware — z. B. JWT-Validierung gegen den
//! `holzleitner_application::ports::AuthService`.
pub mod admin_key;
pub mod jwt;
pub use admin_key::admin_api_key_middleware;
pub use jwt::jwt_middleware;

View File

@ -8,7 +8,9 @@
use utoipa::Modify;
use utoipa::OpenApi;
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
use utoipa::openapi::security::{
ApiKey, ApiKeyValue, HttpAuthScheme, HttpBuilder, SecurityScheme,
};
#[derive(OpenApi)]
#[openapi(
@ -30,10 +32,29 @@ use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
crate::routes::deliveries::cancel,
crate::routes::deliveries::complete,
crate::routes::deliveries::create_note,
crate::routes::deliveries::update_note,
crate::routes::deliveries::delete_note,
crate::routes::deliveries::upload_note_image,
crate::routes::deliveries::apply_credit,
crate::routes::attachments::get_attachment,
crate::routes::deliveries::assign_car,
crate::routes::deliveries::set_service,
crate::routes::deliveries::delete_service_value,
crate::routes::services::list_services,
crate::routes::services::create_service,
crate::routes::services::update_service,
crate::routes::services::delete_service,
crate::routes::cars::list_my_cars,
crate::routes::cars::create_my_car,
crate::routes::cars::update_my_car,
crate::routes::payment_methods::list_payment_methods,
crate::routes::payment_methods::create_payment_method,
crate::routes::payment_methods::update_payment_method,
crate::routes::payment_methods::delete_payment_method,
crate::routes::admin::import_erp,
crate::routes::admin::push_completion,
crate::routes::admin::delivered_belegnummern,
crate::routes::admin::mark_mail_sent,
),
components(
schemas(
@ -42,20 +63,31 @@ use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
holzleitner_domain::Article,
holzleitner_domain::AuditAction,
holzleitner_domain::Car,
holzleitner_domain::ContactChannel,
holzleitner_domain::ContactKind,
holzleitner_domain::ContactRole,
holzleitner_domain::ContactSource,
holzleitner_domain::Customer,
holzleitner_domain::CustomerContact,
holzleitner_domain::Delivery,
holzleitner_domain::DeliveryItem,
holzleitner_domain::DeliveryNote,
holzleitner_domain::DeliveryCredit,
holzleitner_domain::DeliveryState,
holzleitner_domain::ScanState,
holzleitner_domain::ScanStatus,
holzleitner_domain::Tour,
holzleitner_domain::PaymentMethod,
holzleitner_domain::Service,
holzleitner_domain::ServiceKind,
holzleitner_domain::DeliveryServiceValue,
holzleitner_domain::Warehouse,
holzleitner_application::dto::TourDetails,
holzleitner_application::dto::DeliveryWithItems,
holzleitner_application::dto::TourSummary,
holzleitner_application::dto::SyncTourRequest,
holzleitner_application::dto::SyncContactChannel,
holzleitner_application::dto::SyncContactSource,
holzleitner_application::dto::SyncDelivery,
holzleitner_application::dto::SyncDeliveryItem,
holzleitner_application::dto::SetDeliveryOrderRequest,
@ -68,14 +100,33 @@ use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
holzleitner_application::dto::ScanResultStatus,
holzleitner_application::dto::HoldDeliveryRequest,
holzleitner_application::dto::CancelDeliveryRequest,
holzleitner_application::dto::CompleteDeliveryAcknowledgements,
holzleitner_application::dto::DeliveryResponse,
holzleitner_application::dto::CreateDeliveryNoteRequest,
holzleitner_application::dto::UpdateDeliveryNoteRequest,
holzleitner_application::dto::DeliveryNoteResponse,
holzleitner_application::dto::CreditAction,
holzleitner_application::dto::DeliveryCreditEventRequest,
holzleitner_application::dto::DeliveryCreditResponse,
holzleitner_application::dto::CreateCarRequest,
holzleitner_application::dto::UpdateCarRequest,
holzleitner_application::dto::CarResponse,
holzleitner_application::dto::CarsList,
holzleitner_application::dto::AssignCarRequest,
holzleitner_application::dto::CreatePaymentMethodRequest,
holzleitner_application::dto::UpdatePaymentMethodRequest,
holzleitner_application::dto::PaymentMethodResponse,
holzleitner_application::dto::PaymentMethodsList,
holzleitner_application::dto::CreateServiceRequest,
holzleitner_application::dto::UpdateServiceRequest,
holzleitner_application::dto::ServiceResponse,
holzleitner_application::dto::ServicesList,
holzleitner_application::dto::SetDeliveryServiceRequest,
holzleitner_application::dto::DeliveryServiceResponse,
holzleitner_application::usecases::ImportSummary,
crate::routes::admin::DeliveredBelegnummernResponse,
crate::routes::admin::MarkMailSentRequest,
crate::routes::admin::MarkMailSentResponse,
crate::routes::tours::TourSummaryList,
crate::routes::tours::SyncTourResponse,
)
@ -89,6 +140,9 @@ use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
(name = "scans", description = "Scan-Events (Beladung & Auslieferung)"),
(name = "deliveries", description = "Delivery-Lifecycle (hold / resume / cancel / complete)"),
(name = "cars", description = "Fahrzeug-Stammdaten pro Fahrer"),
(name = "payment-methods", description = "Zahlungsmethoden — globale Stammdaten"),
(name = "services", description = "Services / Lieferoptionen — globale Stammdaten"),
(name = "admin", description = "Betriebs-/Admin-Endpunkte (Maschinen-Zugang via X-Admin-Api-Key, kein JWT)"),
),
security(
("bearer_auth" = [])
@ -113,6 +167,11 @@ impl Modify for SecurityAddon {
.build(),
),
);
// Statischer API-Key für die /admin-Routen (Maschinen-Zugang).
components.add_security_scheme(
"admin_api_key",
SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("X-Admin-Api-Key"))),
);
}
}
}

View File

@ -0,0 +1,224 @@
//! Admin-/Betriebs-Endpunkte.
//!
//! Aktuell: manueller ERP-Import-Trigger. Derselbe Use Case, den auch der
//! tägliche Scheduler ruft — hier on-demand für ein konkretes Datum
//! (Testen + manuelle Nachläufe im Betrieb). JWT-geschützt wie alle
//! protected Routen.
use axum::Json;
use axum::Router;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::routing::{get, post};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use holzleitner_application::error::ApplicationError;
use holzleitner_application::usecases::ImportSummary;
use crate::error::ApiError;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/admin/import-erp", post(import_erp))
.route("/admin/push-completion", post(push_completion))
.route(
"/admin/delivered-belegnummern",
get(delivered_belegnummern),
)
.route("/admin/mark-mail-sent", post(mark_mail_sent))
}
#[derive(Debug, Deserialize)]
pub struct ImportErpQuery {
/// Ziel-Tourdatum `YYYY-MM-DD`. Fehlt der Parameter, wird **heute**
/// verwendet.
#[serde(default)]
pub date: Option<String>,
}
/// Stößt den ERP-Import für ein Datum an und liefert die Zusammenfassung.
#[utoipa::path(
post,
path = "/admin/import-erp",
tag = "admin",
params(
("date" = Option<String>, Query, description = "Ziel-Tourdatum YYYY-MM-DD (Default: heute)")
),
responses(
(status = 200, description = "Import durchgeführt", body = ImportSummary),
(status = 400, description = "Ungültiges Datum"),
(status = 401, description = "Admin-API-Key fehlt/ungültig"),
(status = 502, description = "ERP nicht erreichbar / Lesefehler")
),
security(("admin_api_key" = []))
)]
pub async fn import_erp(
State(state): State<AppState>,
Query(query): Query<ImportErpQuery>,
) -> Result<Json<ImportSummary>, ApiError> {
let date = match query.date {
Some(s) => NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"ungültiges Datum '{s}' (erwartet YYYY-MM-DD): {e}"
)))
})?,
None => chrono::Utc::now().date_naive(),
};
tracing::info!(%date, "admin.import_erp");
let summary = state.import_erp_tours.execute(date).await?;
tracing::info!(
%date,
total = summary.tours_total,
ok = summary.tours_ok,
failed = summary.tours_failed,
"admin.import_erp.done"
);
Ok(Json(summary))
}
#[derive(Debug, Deserialize)]
pub struct PushCompletionQuery {
/// UUID der bereits abgeschlossenen Lieferung.
pub delivery_id: String,
}
/// Stößt das ERP-Rückschreiben eines bereits lokal abgeschlossenen
/// Lieferabschlusses erneut an (idempotenter Retry, falls der automatische
/// Push beim Abschluss fehlschlug).
#[utoipa::path(
post,
path = "/admin/push-completion",
tag = "admin",
params(
("delivery_id" = String, Query, description = "UUID der abgeschlossenen Lieferung")
),
responses(
(status = 204, description = "Rückschreiben erfolgreich"),
(status = 400, description = "Ungültige delivery_id"),
(status = 401, description = "Admin-API-Key fehlt/ungültig"),
(status = 404, description = "Lieferung nicht gefunden / nicht abgeschlossen"),
(status = 502, description = "ERP nicht erreichbar / Schreibfehler")
),
security(("admin_api_key" = []))
)]
pub async fn push_completion(
State(state): State<AppState>,
Query(query): Query<PushCompletionQuery>,
) -> Result<StatusCode, ApiError> {
let delivery_id = Uuid::parse_str(query.delivery_id.trim()).map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"ungültige delivery_id '{}': {e}",
query.delivery_id
)))
})?;
tracing::info!(%delivery_id, "admin.push_completion");
state.push_completion_to_erp.execute(delivery_id).await?;
tracing::info!(%delivery_id, "admin.push_completion.done");
Ok(StatusCode::NO_CONTENT)
}
#[derive(Debug, Deserialize)]
pub struct DeliveredBelegnummernQuery {
/// Ziel-Tag im Format `DD-MM-YYYY`. **Fehlt der Parameter, werden ALLE**
/// offenen (noch nicht versendeten) Belege über alle Tage geliefert — das
/// ist der Modus des Mailclients.
#[serde(default)]
pub day: Option<String>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct DeliveredBelegnummernResponse {
/// Tag, nach dem gefiltert wurde (ISO `YYYY-MM-DD`), oder `"all"` wenn kein
/// `day` angegeben war.
pub day: String,
/// Anzahl der offenen (noch nicht versendeten) Belege.
pub count: usize,
/// Belegnummern aller **ausgelieferten** (abgeschlossenen) Lieferungen,
/// deren Liefermail noch **nicht versendet** wurde, aufsteigend nach
/// Abschluss-Zeitpunkt.
pub belegnummern: Vec<String>,
}
/// Liefert die Belegnummern ausgelieferter (abgeschlossener) Lieferungen,
/// **deren Liefermail noch nicht versendet wurde** (`mail_sent_at IS NULL`).
/// „Ausgeliefert" = es existiert ein Abschluss. Mit `day` (DD-MM-YYYY) nur
/// Abschlüsse dieses Berliner Kalendertages; **ohne `day` alle offenen** (über
/// alle Tage) — so bleiben Belege über Mitternacht nicht hängen.
#[utoipa::path(
get,
path = "/admin/delivered-belegnummern",
tag = "admin",
params(
("day" = Option<String>, Query, description = "Tag DD-MM-YYYY; ohne Angabe ALLE offenen Belege")
),
responses(
(status = 200, description = "Offene (nicht versendete) Belegnummern", body = DeliveredBelegnummernResponse),
(status = 400, description = "Ungültiger Tag"),
(status = 401, description = "Admin-API-Key fehlt/ungültig")
),
security(("admin_api_key" = []))
)]
pub async fn delivered_belegnummern(
State(state): State<AppState>,
Query(query): Query<DeliveredBelegnummernQuery>,
) -> Result<Json<DeliveredBelegnummernResponse>, ApiError> {
let day: Option<NaiveDate> = match query.day {
Some(s) => Some(NaiveDate::parse_from_str(s.trim(), "%d-%m-%Y").map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"ungültiger Tag '{s}' (erwartet DD-MM-YYYY): {e}"
)))
})?),
None => None,
};
tracing::info!(?day, "admin.delivered_belegnummern");
let belegnummern = state.list_delivered_belegnummern.execute(day).await?;
tracing::info!(?day, count = belegnummern.len(), "admin.delivered_belegnummern.done");
Ok(Json(DeliveredBelegnummernResponse {
day: day.map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_else(|| "all".into()),
count: belegnummern.len(),
belegnummern,
}))
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct MarkMailSentRequest {
/// Belegnummern, deren Liefermail erfolgreich versendet wurde und die als
/// versendet markiert werden sollen.
pub belegnummern: Vec<String>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct MarkMailSentResponse {
/// Anzahl frisch markierter (vorher offener) Belege. Bereits markierte
/// zählen nicht mit (idempotent).
pub marked: u64,
}
/// Markiert die Liefermails der angegebenen Belegnummern als **versendet**
/// (`mail_sent_at = now()`, nur wo noch offen). Vom Mailclient aufzurufen,
/// NACHDEM ERPframe die Mails erfolgreich verschickt hat — danach erscheinen
/// die Belege nicht mehr in `GET /admin/delivered-belegnummern`.
#[utoipa::path(
post,
path = "/admin/mark-mail-sent",
tag = "admin",
request_body = MarkMailSentRequest,
responses(
(status = 200, description = "Markierung durchgeführt", body = MarkMailSentResponse),
(status = 401, description = "Admin-API-Key fehlt/ungültig")
),
security(("admin_api_key" = []))
)]
pub async fn mark_mail_sent(
State(state): State<AppState>,
Json(body): Json<MarkMailSentRequest>,
) -> Result<Json<MarkMailSentResponse>, ApiError> {
tracing::info!(count = body.belegnummern.len(), "admin.mark_mail_sent");
let marked = state.mark_mail_sent.execute(body.belegnummern).await?;
tracing::info!(marked, "admin.mark_mail_sent.done");
Ok(Json(MarkMailSentResponse { marked }))
}

View File

@ -0,0 +1,88 @@
use axum::Router;
use axum::extract::{Path, Query, State};
use axum::http::header;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use serde::Deserialize;
use uuid::Uuid;
use crate::error::ApiError;
use crate::extractors::AuthenticatedUser;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new().route("/attachments/{id}", get(get_attachment))
}
/// Größen-/Format-Parameter für das gerenderte Vorschaubild. Alle optional
/// mit sinnvollen Defaults — die App kann pro Anwendungsfall (Thumbnail vs.
/// Vollbild) abweichende Werte anfragen.
#[derive(Debug, Deserialize)]
pub struct PreviewQuery {
#[serde(default = "default_dimension")]
pub w: u32,
#[serde(default = "default_dimension")]
pub h: u32,
#[serde(default = "default_quality")]
pub q: u32,
#[serde(default = "default_ext")]
pub ext: String,
#[serde(default = "default_page")]
pub page: String,
}
fn default_dimension() -> u32 {
1024
}
fn default_quality() -> u32 {
85
}
fn default_ext() -> String {
"jpeg".to_string()
}
fn default_page() -> String {
"1".to_string()
}
/// Liefert ein gerendertes Vorschaubild des Attachments (Bytes), geladen
/// aus DOCUframe. Auflösung/Format über Query-Parameter steuerbar
/// (`?w=&h=&q=&ext=&page=`).
#[utoipa::path(
get,
path = "/attachments/{id}",
tag = "attachments",
params(
("id" = Uuid, Path, description = "Attachment-Id (unsere UUID)"),
("w" = Option<u32>, Query, description = "Breite in Pixeln (Default 1024)"),
("h" = Option<u32>, Query, description = "Höhe in Pixeln (Default 1024)"),
("q" = Option<u32>, Query, description = "Qualität 0100 (Default 85)"),
("ext" = Option<String>, Query, description = "png|jpeg|jpg|webp|tiff (Default jpeg)"),
("page" = Option<String>, Query, description = "Seitennummer (Default 1)"),
),
responses(
(status = 200, description = "Vorschaubild (Bytes)", content_type = "image/jpeg"),
(status = 401, description = "Authentifizierung fehlgeschlagen"),
(status = 404, description = "Attachment nicht gefunden")
),
security(("bearer_auth" = []))
)]
pub async fn get_attachment(
State(state): State<AppState>,
AuthenticatedUser(claims): AuthenticatedUser,
Path(id): Path<Uuid>,
Query(query): Query<PreviewQuery>,
) -> Result<Response, ApiError> {
tracing::info!(actor = claims.personalnummer, %id, "attachment.preview");
// DOCUframe-Parameterschema: width_height_quality_extension.
let parameters = format!("{}_{}_{}_{}", query.w, query.h, query.q, query.ext);
let preview = state
.get_attachment_preview
.execute(id, parameters, query.page)
.await?;
Ok((
[(header::CONTENT_TYPE, preview.content_type)],
preview.bytes,
)
.into_response())
}

View File

@ -1,11 +1,15 @@
use axum::Json;
use axum::Router;
use axum::extract::{Path, State};
use axum::routing::{post, put};
use axum::extract::{DefaultBodyLimit, Multipart, Path, State};
use axum::http::StatusCode;
use axum::routing::{patch, post, put};
use holzleitner_application::dto::{
AssignCarRequest, CancelDeliveryRequest, CreateDeliveryNoteRequest, DeliveryNoteResponse,
DeliveryResponse, HoldDeliveryRequest,
AssignCarRequest, CancelDeliveryRequest, CompleteDeliveryAcknowledgements,
CreateDeliveryNoteRequest, DeliveryCreditEventRequest, DeliveryCreditResponse,
DeliveryNoteResponse, DeliveryResponse, DeliveryServiceResponse, HoldDeliveryRequest,
SetDeliveryServiceRequest, UpdateDeliveryNoteRequest,
};
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::DeliveryAction;
use uuid::Uuid;
@ -13,14 +17,40 @@ use crate::error::ApiError;
use crate::extractors::AuthenticatedUser;
use crate::state::AppState;
/// Maximale Größe eines multipart-Uploads. Axums Default-Body-Limit liegt
/// bei 2 MiB — Handy-Kamerafotos sprengen das regelmäßig, der
/// multipart-Stream wird dann abgeschnitten und multer wirft „Error parsing
/// multipart…". Daher heben wir das Limit **nur** für die multipart-Routen
/// an (Bild-Upload + Abschluss mit Signaturen); die JSON-Routen behalten ihr
/// sicheres Default.
const MULTIPART_BODY_LIMIT: usize = 25 * 1024 * 1024;
pub fn router() -> Router<AppState> {
// Eigener Sub-Router für multipart-Uploads mit angehobenem Body-Limit.
let multipart = Router::new()
.route(
"/deliveries/{delivery_id}/notes/image",
post(upload_note_image),
)
.route("/deliveries/{delivery_id}/complete", post(complete))
.layer(DefaultBodyLimit::max(MULTIPART_BODY_LIMIT));
Router::new()
.route("/deliveries/{delivery_id}/hold", post(hold))
.route("/deliveries/{delivery_id}/resume", post(resume))
.route("/deliveries/{delivery_id}/cancel", post(cancel))
.route("/deliveries/{delivery_id}/complete", post(complete))
.route("/deliveries/{delivery_id}/notes", post(create_note))
.route(
"/deliveries/{delivery_id}/notes/{note_id}",
patch(update_note).delete(delete_note),
)
.route("/deliveries/{delivery_id}/credit", post(apply_credit))
.route(
"/deliveries/{delivery_id}/services/{service_id}",
put(set_service).delete(delete_service_value),
)
.route("/deliveries/{delivery_id}/assigned-car", put(assign_car))
.merge(multipart)
}
/// Setzt die Lieferung auf `held`. Nur aus `active` zulässig.
@ -113,14 +143,30 @@ pub async fn cancel(
}
/// Schließt die Lieferung ab — `state = completed`. Nur aus `active`.
///
/// `multipart/form-data` mit drei Feldern:
/// * `customer_signature` — PNG der Kunden-Unterschrift (Pflicht)
/// * `driver_signature` — PNG der Fahrer-Unterschrift (Pflicht)
/// * `acknowledgements` — JSON (`CompleteDeliveryAcknowledgements`):
/// `receiptConfirmed` (Pflicht true), `notesAcknowledged`,
/// `acknowledgedNoteIds`, `authorCarId`.
///
/// Atomar: Signaturen werden lokal gespeichert, die Abschluss-Zeile
/// geschrieben und der Status auf `completed` gesetzt — alles oder nichts.
/// Gates: Lieferung aktiv, alle scanbaren Positionen fertig, Notizen
/// bestätigt (falls vorhanden).
#[utoipa::path(
post,
path = "/deliveries/{delivery_id}/complete",
tag = "deliveries",
params(("delivery_id" = Uuid, Path)),
request_body(
content_type = "multipart/form-data",
description = "Felder `customer_signature`, `driver_signature` (PNG) + `acknowledgements` (JSON)"
),
responses(
(status = 200, description = "Lieferung abgeschlossen", body = DeliveryResponse),
(status = 400, description = "Invalider Statusübergang"),
(status = 400, description = "Invalider Statusübergang / fehlende Signatur / offene Scans / Notizen unbestätigt"),
(status = 401, description = "Authentifizierung fehlgeschlagen"),
(status = 404, description = "Lieferung nicht gefunden")
),
@ -130,15 +176,107 @@ pub async fn complete(
State(state): State<AppState>,
AuthenticatedUser(claims): AuthenticatedUser,
Path(delivery_id): Path<Uuid>,
mut multipart: Multipart,
) -> Result<Json<DeliveryResponse>, ApiError> {
tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.complete");
let mut customer_png: Option<Vec<u8>> = None;
let mut driver_png: Option<Vec<u8>> = None;
let mut acknowledgements: Option<CompleteDeliveryAcknowledgements> = None;
while let Some(field) = multipart.next_field().await.map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"multipart konnte nicht gelesen werden: {e}"
)))
})? {
match field.name() {
Some("customer_signature") => {
let data = field.bytes().await.map_err(read_err)?;
customer_png = Some(data.to_vec());
}
Some("driver_signature") => {
let data = field.bytes().await.map_err(read_err)?;
driver_png = Some(data.to_vec());
}
Some("acknowledgements") => {
let text = field.text().await.map_err(read_err)?;
let parsed: CompleteDeliveryAcknowledgements =
serde_json::from_str(&text).map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"`acknowledgements` ist kein gültiges JSON: {e}"
)))
})?;
acknowledgements = Some(parsed);
}
_ => {}
}
}
let customer_png = customer_png.ok_or_else(|| {
ApiError(ApplicationError::Validation(
"Feld `customer_signature` fehlt".into(),
))
})?;
let driver_png = driver_png.ok_or_else(|| {
ApiError(ApplicationError::Validation(
"Feld `driver_signature` fehlt".into(),
))
})?;
let acknowledgements = acknowledgements.ok_or_else(|| {
ApiError(ApplicationError::Validation(
"Feld `acknowledgements` fehlt".into(),
))
})?;
let delivery = state
.apply_delivery_action
.execute(delivery_id, DeliveryAction::Complete)
.complete_delivery
.execute(
delivery_id,
claims.personalnummer,
acknowledgements,
customer_png,
driver_png,
)
.await?;
// PDF-Report (best-effort, NACH erfolgreichem Abschluss): ein Fehler hier
// darf die Abschluss-Antwort NIE kippen.
if state.report_upload_enabled {
// An DOCUframe übertragen — im Hintergrund, damit die Antwort schnell
// bleibt. Schlägt etwas fehl, bleibt ein Job in PG offen und der
// Retry-Cron versucht es erneut.
let process = state.process_delivery_report.clone();
tokio::spawn(async move {
match process.execute(delivery_id).await {
Ok(()) => tracing::info!(%delivery_id, "delivery.complete.report_uploaded"),
Err(e) => tracing::warn!(
%delivery_id, error = %e,
"delivery.complete.report_upload_failed (Retry-Cron übernimmt)"
),
}
});
} else {
// DOCUframe-Upload aus (Dev): Report nur lokal erzeugen.
match state.generate_delivery_report.execute(delivery_id).await {
Ok(reference) => {
tracing::info!(%delivery_id, reference, "delivery.complete.report_generated_local")
}
Err(e) => {
tracing::error!(%delivery_id, error = %e, "delivery.complete.report_failed")
}
}
}
Ok(Json(DeliveryResponse { delivery }))
}
/// Helfer: multipart-Feld-Lesefehler → `Validation`.
fn read_err(e: axum::extract::multipart::MultipartError) -> ApiError {
ApiError(ApplicationError::Validation(format!(
"feld konnte nicht gelesen werden: {e}"
)))
}
/// Legt eine neue Notiz an einer Lieferung an. Mindestens eines von
/// `text` und `imageAttachment` muss inhaltlich gefüllt sein
/// (Leerstrings werden serverseitig getrimmt und als leer behandelt).
@ -170,6 +308,238 @@ pub async fn create_note(
Ok(Json(DeliveryNoteResponse { note }))
}
/// Ändert Text/Bild einer Notiz. Innerhalb des (geteilten) Accounts darf
/// jeder Fahrer Notizen pflegen — kein Autor-Check. `delivery_id` ist Teil
/// des Pfads (REST-Konsistenz), die Notiz wird über `note_id` adressiert.
#[utoipa::path(
patch,
path = "/deliveries/{delivery_id}/notes/{note_id}",
tag = "deliveries",
params(
("delivery_id" = Uuid, Path),
("note_id" = Uuid, Path),
),
request_body = UpdateDeliveryNoteRequest,
responses(
(status = 200, description = "Notiz aktualisiert", body = DeliveryNoteResponse),
(status = 400, description = "Notiz ohne Inhalt"),
(status = 401, description = "Authentifizierung fehlgeschlagen"),
(status = 404, description = "Notiz nicht gefunden")
),
security(("bearer_auth" = []))
)]
pub async fn update_note(
State(state): State<AppState>,
AuthenticatedUser(claims): AuthenticatedUser,
Path((delivery_id, note_id)): Path<(Uuid, Uuid)>,
Json(req): Json<UpdateDeliveryNoteRequest>,
) -> Result<Json<DeliveryNoteResponse>, ApiError> {
tracing::info!(
actor = claims.personalnummer,
%delivery_id,
%note_id,
"delivery.update_note"
);
let note = state.update_delivery_note.execute(note_id, req).await?;
Ok(Json(DeliveryNoteResponse { note }))
}
/// Löscht eine Notiz. Antwortet mit `204 No Content`.
#[utoipa::path(
delete,
path = "/deliveries/{delivery_id}/notes/{note_id}",
tag = "deliveries",
params(
("delivery_id" = Uuid, Path),
("note_id" = Uuid, Path),
),
responses(
(status = 204, description = "Notiz gelöscht"),
(status = 401, description = "Authentifizierung fehlgeschlagen"),
(status = 404, description = "Notiz nicht gefunden")
),
security(("bearer_auth" = []))
)]
pub async fn delete_note(
State(state): State<AppState>,
AuthenticatedUser(claims): AuthenticatedUser,
Path((delivery_id, note_id)): Path<(Uuid, Uuid)>,
) -> Result<StatusCode, ApiError> {
tracing::info!(
actor = claims.personalnummer,
%delivery_id,
%note_id,
"delivery.delete_note"
);
state.delete_delivery_note.execute(note_id).await?;
Ok(StatusCode::NO_CONTENT)
}
/// Lädt ein Bild zu einer Lieferung hoch (multipart/form-data, Feld `file`)
/// und legt dafür eine Bild-Notiz an. Das Bild geht in den
/// DOCUframe-Dokumentenspeicher; gespeichert wird die zurückgelieferte
/// Referenz (`~ObjectID`) als `image_attachment` der Notiz.
#[utoipa::path(
post,
path = "/deliveries/{delivery_id}/notes/image",
tag = "deliveries",
params(("delivery_id" = Uuid, Path)),
request_body(
content_type = "multipart/form-data",
description = "Formularfeld `file` mit den Bilddaten"
),
responses(
(status = 200, description = "Bild hochgeladen, Notiz angelegt", body = DeliveryNoteResponse),
(status = 400, description = "Kein/leeres Datei-Feld"),
(status = 401, description = "Authentifizierung fehlgeschlagen"),
(status = 404, description = "Lieferung nicht gefunden"),
(status = 500, description = "Upload zu DOCUframe fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn upload_note_image(
State(state): State<AppState>,
AuthenticatedUser(claims): AuthenticatedUser,
Path(delivery_id): Path<Uuid>,
mut multipart: Multipart,
) -> Result<Json<DeliveryNoteResponse>, ApiError> {
tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.upload_note_image");
let mut bytes: Option<Vec<u8>> = None;
let mut filename = String::from("upload");
let mut mime = String::from("application/octet-stream");
while let Some(field) = multipart.next_field().await.map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"multipart konnte nicht gelesen werden: {e}"
)))
})? {
if field.name() == Some("file") {
if let Some(fname) = field.file_name() {
filename = fname.to_owned();
}
if let Some(ct) = field.content_type() {
mime = ct.to_owned();
}
let data = field.bytes().await.map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"datei konnte nicht gelesen werden: {e}"
)))
})?;
bytes = Some(data.to_vec());
}
}
let bytes = bytes.ok_or_else(|| {
ApiError(ApplicationError::Validation(
"kein `file`-Feld im multipart-Body".into(),
))
})?;
let note = state
.upload_delivery_note_image
.execute(delivery_id, claims.personalnummer, None, filename, mime, bytes)
.await?;
Ok(Json(DeliveryNoteResponse { note }))
}
/// Wendet ein Betrags-Gutschrift-Ereignis an (`set`/`remove`). Append-only,
/// idempotent über `clientEventId`. Nur bei aktiver Lieferung; bei `set` sind
/// Betrag (0 < x ≤ 150 €, 10-€-Schritte) und Grund Pflicht. Antwort: der
/// aktuelle Gutschrift-Stand (`null`, wenn entfernt).
#[utoipa::path(
post,
path = "/deliveries/{delivery_id}/credit",
tag = "deliveries",
params(("delivery_id" = Uuid, Path)),
request_body = DeliveryCreditEventRequest,
responses(
(status = 200, description = "Gutschrift gesetzt/entfernt", body = DeliveryCreditResponse),
(status = 400, description = "Ungültiger Betrag/Grund oder Lieferung nicht aktiv"),
(status = 401, description = "Authentifizierung fehlgeschlagen"),
(status = 404, description = "Lieferung nicht gefunden")
),
security(("bearer_auth" = []))
)]
pub async fn apply_credit(
State(state): State<AppState>,
AuthenticatedUser(claims): AuthenticatedUser,
Path(delivery_id): Path<Uuid>,
Json(req): Json<DeliveryCreditEventRequest>,
) -> Result<Json<DeliveryCreditResponse>, ApiError> {
tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.apply_credit");
let credit = state
.apply_delivery_credit_event
.execute(delivery_id, claims.personalnummer, req)
.await?;
Ok(Json(DeliveryCreditResponse { credit }))
}
/// Setzt (Upsert) den Wert eines Service für eine Lieferung. Genau das zum
/// Service-Typ passende Feld (`boolValue`/`numericValue`) muss gesetzt sein;
/// numerische Werte werden gegen min/max geprüft. Nur bei aktiver Lieferung.
#[utoipa::path(
put,
path = "/deliveries/{delivery_id}/services/{service_id}",
tag = "deliveries",
params(
("delivery_id" = Uuid, Path),
("service_id" = Uuid, Path),
),
request_body = SetDeliveryServiceRequest,
responses(
(status = 200, description = "Wert gesetzt", body = DeliveryServiceResponse),
(status = 400, description = "Wert passt nicht zum Service-Typ / außerhalb min-max / Lieferung nicht aktiv"),
(status = 401, description = "Authentifizierung fehlgeschlagen"),
(status = 404, description = "Service oder Lieferung nicht gefunden")
),
security(("bearer_auth" = []))
)]
pub async fn set_service(
State(state): State<AppState>,
AuthenticatedUser(claims): AuthenticatedUser,
Path((delivery_id, service_id)): Path<(Uuid, Uuid)>,
Json(req): Json<SetDeliveryServiceRequest>,
) -> Result<Json<DeliveryServiceResponse>, ApiError> {
tracing::info!(actor = claims.personalnummer, %delivery_id, %service_id, "delivery.set_service");
let value = state
.set_delivery_service
.execute(delivery_id, service_id, claims.personalnummer, req)
.await?;
Ok(Json(DeliveryServiceResponse { value }))
}
/// Entfernt den Service-Wert einer Lieferung (Service „nicht gesetzt").
/// Nur bei aktiver Lieferung. Antwort `204`.
#[utoipa::path(
delete,
path = "/deliveries/{delivery_id}/services/{service_id}",
tag = "deliveries",
params(
("delivery_id" = Uuid, Path),
("service_id" = Uuid, Path),
),
responses(
(status = 204, description = "Wert entfernt"),
(status = 400, description = "Lieferung nicht aktiv"),
(status = 401, description = "Authentifizierung fehlgeschlagen"),
(status = 404, description = "Lieferung nicht gefunden")
),
security(("bearer_auth" = []))
)]
pub async fn delete_service_value(
State(state): State<AppState>,
AuthenticatedUser(claims): AuthenticatedUser,
Path((delivery_id, service_id)): Path<(Uuid, Uuid)>,
) -> Result<StatusCode, ApiError> {
tracing::info!(actor = claims.personalnummer, %delivery_id, %service_id, "delivery.delete_service_value");
state
.delete_delivery_service
.execute(delivery_id, service_id)
.await?;
Ok(StatusCode::NO_CONTENT)
}
/// Setzt das `assigned_car_id` einer Lieferung. `carId: null` löst
/// die Zuordnung wieder. Der Use Case stellt sicher, dass das Fahrzeug
/// zum angemeldeten Account gehört.

View File

@ -0,0 +1,160 @@
//! DEV-ONLY Endpunkte. Werden in `main.rs` **nur** gemountet, wenn
//! `dev.sync_enabled = true` (config.toml) — in Produktion existieren sie nicht.
//!
//! `POST /dev/resync` ist bewusst **unauthentifiziert** (liegt auf dem
//! public Router), damit man ihn ohne JWT per `curl` triggern kann. Er macht
//! die Postgres-Tourdaten platt und importiert frisch aus dem ERP — der
//! „überschreibende" Sync für die lokale Entwicklung. NIEMALS in Produktion
//! aktivieren.
use axum::Json;
use axum::Router;
use axum::extract::{Query, State};
use axum::routing::post;
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use holzleitner_application::error::ApplicationError;
use holzleitner_application::usecases::ImportSummary;
use crate::error::ApiError;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/dev/resync", post(dev_resync))
.route("/dev/generate-report", post(dev_generate_report))
.route("/dev/process-report", post(dev_process_report))
.route("/dev/unmark-mail-sent", post(dev_unmark_mail_sent))
}
#[derive(Debug, Deserialize)]
pub struct DevResyncQuery {
/// Ziel-Tourdatum `YYYY-MM-DD`. Fehlt der Parameter, wird **heute**
/// (echte Uhr) verwendet.
#[serde(default)]
pub date: Option<String>,
}
/// DEV-ONLY, UNAUTHENTIFIZIERT: löscht alle Postgres-Tourdaten und importiert
/// das Datum frisch aus dem ERP. Liefert die Import-Zusammenfassung.
pub async fn dev_resync(
State(state): State<AppState>,
Query(query): Query<DevResyncQuery>,
) -> Result<Json<ImportSummary>, ApiError> {
let date = match query.date {
Some(s) => NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"ungültiges Datum '{s}' (erwartet YYYY-MM-DD): {e}"
)))
})?,
None => chrono::Utc::now().date_naive(),
};
tracing::warn!(%date, "dev.resync: Postgres wird überschrieben + neu importiert");
let summary = state.dev_resync_tours.execute(date).await?;
tracing::info!(
%date,
total = summary.tours_total,
ok = summary.tours_ok,
failed = summary.tours_failed,
provisioned = summary.drivers_provisioned,
"dev.resync.done"
);
Ok(Json(summary))
}
#[derive(Debug, Deserialize)]
pub struct DevReportQuery {
/// UUID der Lieferung, für die der Report erzeugt werden soll.
pub delivery_id: String,
}
#[derive(Debug, Serialize)]
pub struct DevReportResponse {
pub reference: String,
}
/// DEV-ONLY: erzeugt den PDF-Report für eine Lieferung (ohne echten Abschluss)
/// und gibt die Sink-Referenz (Dateipfad) zurück. Zum Iterieren am Layout.
pub async fn dev_generate_report(
State(state): State<AppState>,
Query(query): Query<DevReportQuery>,
) -> Result<Json<DevReportResponse>, ApiError> {
let delivery_id = Uuid::parse_str(query.delivery_id.trim()).map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"ungültige delivery_id '{}': {e}",
query.delivery_id
)))
})?;
tracing::warn!(%delivery_id, "dev.generate_report angestoßen");
let reference = state.generate_delivery_report.execute(delivery_id).await?;
tracing::info!(%delivery_id, reference, "dev.generate_report.done");
Ok(Json(DevReportResponse { reference }))
}
/// DEV-ONLY: stößt die volle DOCUframe-Übertragungs-Pipeline für eine Lieferung
/// an (Render → Upload → Makro → Cleanup). Solange das Makro fehlt, schlägt der
/// Makro-Schritt erwartungsgemäß fehl — der Job bleibt dann in PG offen und der
/// Retry-Cron versucht es erneut. Liefert eine kurze Status-Meldung.
pub async fn dev_process_report(
State(state): State<AppState>,
Query(query): Query<DevReportQuery>,
) -> Result<Json<DevProcessResponse>, ApiError> {
let delivery_id = Uuid::parse_str(query.delivery_id.trim()).map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"ungültige delivery_id '{}': {e}",
query.delivery_id
)))
})?;
tracing::warn!(%delivery_id, "dev.process_report angestoßen");
match state.process_delivery_report.execute(delivery_id).await {
Ok(()) => {
tracing::info!(%delivery_id, "dev.process_report.done");
Ok(Json(DevProcessResponse {
ok: true,
message: "Report an DOCUframe übertragen + lokale Dateien aufgeräumt".into(),
}))
}
Err(e) => {
tracing::warn!(%delivery_id, error = %e, "dev.process_report.failed (Job bleibt offen)");
Ok(Json(DevProcessResponse {
ok: false,
message: format!("fehlgeschlagen (Job in PG offen, Cron retried): {e}"),
}))
}
}
}
#[derive(Debug, Serialize)]
pub struct DevProcessResponse {
pub ok: bool,
pub message: String,
}
#[derive(Debug, Deserialize)]
pub struct DevUnmarkRequest {
/// Belegnummern, deren Mail-Versendet-Markierung wieder aufgehoben werden
/// soll (für erneutes Testen).
pub belegnummern: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct DevUnmarkResponse {
/// Anzahl tatsächlich zurückgesetzter (vorher markierter) Belege.
pub unmarked: u64,
}
/// DEV-ONLY, UNAUTHENTIFIZIERT: setzt `mail_sent_at` der angegebenen
/// Belegnummern wieder auf NULL, sodass sie erneut als offen in
/// `GET /admin/delivered-belegnummern` erscheinen. Zum wiederholten Testen
/// des Mailclients.
pub async fn dev_unmark_mail_sent(
State(state): State<AppState>,
Json(body): Json<DevUnmarkRequest>,
) -> Result<Json<DevUnmarkResponse>, ApiError> {
tracing::warn!(count = body.belegnummern.len(), "dev.unmark_mail_sent");
let unmarked = state.mark_mail_sent.unmark(body.belegnummern).await?;
tracing::info!(unmarked, "dev.unmark_mail_sent.done");
Ok(Json(DevUnmarkResponse { unmarked }))
}

View File

@ -2,8 +2,13 @@
//! zusammengesetzt.
pub mod accounts;
pub mod admin;
pub mod attachments;
pub mod cars;
pub mod deliveries;
pub mod dev;
pub mod health;
pub mod payment_methods;
pub mod scans;
pub mod services;
pub mod tours;

View File

@ -0,0 +1,142 @@
//! `/payment-methods` — globale Zahlungs-Stammdaten.
//!
//! Lese-Endpoint ist von der App frei nutzbar (Liste für die Auswahl in
//! der Auslieferungs-Phase). Schreib-Endpoints (POST/PATCH/DELETE) sind
//! Admin-Operationen — Authentifizierung schützt sie über die globale
//! Middleware, eine Rollen-Trennung kommt später (Phase H).
use axum::Json;
use axum::Router;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::get;
// `Path<Uuid>` für PATCH/DELETE — direkt aus axum::extract verwendet.
use holzleitner_application::dto::{
CreatePaymentMethodRequest, PaymentMethodResponse, PaymentMethodsList,
UpdatePaymentMethodRequest,
};
use serde::Deserialize;
use uuid::Uuid;
use crate::error::ApiError;
use crate::extractors::AuthenticatedUser;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route(
"/payment-methods",
get(list_payment_methods).post(create_payment_method),
)
.route(
"/payment-methods/{id}",
axum::routing::patch(update_payment_method).delete(delete_payment_method),
)
}
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ListPaymentMethodsQuery {
/// Default `false` — Endpoint liefert nur aktive Methoden.
#[serde(default)]
pub include_inactive: bool,
}
/// Listet die Zahlungsmethoden.
#[utoipa::path(
get,
path = "/payment-methods",
tag = "payment-methods",
params(
("includeInactive" = Option<bool>, Query,
description = "Wenn true, werden inaktive Methoden mitgeliefert (default: false)")
),
responses(
(status = 200, description = "Zahlungsmethoden", body = PaymentMethodsList),
(status = 401, description = "Authentifizierung fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn list_payment_methods(
State(state): State<AppState>,
AuthenticatedUser(_claims): AuthenticatedUser,
Query(query): Query<ListPaymentMethodsQuery>,
) -> Result<Json<PaymentMethodsList>, ApiError> {
let methods = state
.list_payment_methods
.execute(query.include_inactive)
.await?;
Ok(Json(PaymentMethodsList { methods }))
}
/// Legt eine neue Zahlungsmethode an.
#[utoipa::path(
post,
path = "/payment-methods",
tag = "payment-methods",
request_body = CreatePaymentMethodRequest,
responses(
(status = 200, body = PaymentMethodResponse),
(status = 400, description = "Validierungsfehler (z. B. doppelter code)"),
(status = 401, description = "Authentifizierung fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn create_payment_method(
State(state): State<AppState>,
AuthenticatedUser(_claims): AuthenticatedUser,
Json(req): Json<CreatePaymentMethodRequest>,
) -> Result<Json<PaymentMethodResponse>, ApiError> {
let method = state.create_payment_method.execute(req).await?;
Ok(Json(PaymentMethodResponse { method }))
}
/// Patcht Anzeige-Name und/oder Aktiv-Flag.
#[utoipa::path(
patch,
path = "/payment-methods/{id}",
tag = "payment-methods",
params(("id" = Uuid, Path, description = "Zahlungsmethoden-Id")),
request_body = UpdatePaymentMethodRequest,
responses(
(status = 200, body = PaymentMethodResponse),
(status = 404, description = "Methode nicht gefunden"),
(status = 401, description = "Authentifizierung fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn update_payment_method(
State(state): State<AppState>,
AuthenticatedUser(_claims): AuthenticatedUser,
Path(id): Path<Uuid>,
Json(req): Json<UpdatePaymentMethodRequest>,
) -> Result<Json<PaymentMethodResponse>, ApiError> {
let method = state.update_payment_method.execute(id, req).await?;
Ok(Json(PaymentMethodResponse { method }))
}
/// Hartes Löschen. `409 Conflict`, wenn die Methode von einer Lieferung
/// referenziert wird — der Admin soll dann den `active = false`-Pfad
/// nutzen.
#[utoipa::path(
delete,
path = "/payment-methods/{id}",
tag = "payment-methods",
params(("id" = Uuid, Path, description = "Zahlungsmethoden-Id")),
responses(
(status = 204, description = "Methode gelöscht"),
(status = 404, description = "Methode nicht gefunden"),
(status = 409, description = "Methode ist noch von Lieferungen referenziert"),
(status = 401, description = "Authentifizierung fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn delete_payment_method(
State(state): State<AppState>,
AuthenticatedUser(_claims): AuthenticatedUser,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
state.delete_payment_method.execute(id).await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@ -0,0 +1,133 @@
//! `/services` — admin-konfigurierbare Service-Stammdaten (früher
//! „Lieferoptionen").
//!
//! Lese-Endpoint nutzt die App (Phase 4). Schreib-Endpoints (POST/PATCH/
//! DELETE) sind Admin-Operationen — geschützt durch die globale JWT-
//! Middleware; Rollen-Trennung kommt später (Phase H).
use axum::Json;
use axum::Router;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::get;
use holzleitner_application::dto::{
CreateServiceRequest, ServiceResponse, ServicesList, UpdateServiceRequest,
};
use serde::Deserialize;
use uuid::Uuid;
use crate::error::ApiError;
use crate::extractors::AuthenticatedUser;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/services", get(list_services).post(create_service))
.route(
"/services/{id}",
axum::routing::patch(update_service).delete(delete_service),
)
}
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ListServicesQuery {
#[serde(default)]
pub include_inactive: bool,
}
/// Listet die Services (sortiert nach `sortOrder`).
#[utoipa::path(
get,
path = "/services",
tag = "services",
params(
("includeInactive" = Option<bool>, Query,
description = "Wenn true, werden inaktive Services mitgeliefert (default: false)")
),
responses(
(status = 200, description = "Services", body = ServicesList),
(status = 401, description = "Authentifizierung fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn list_services(
State(state): State<AppState>,
AuthenticatedUser(_claims): AuthenticatedUser,
Query(query): Query<ListServicesQuery>,
) -> Result<Json<ServicesList>, ApiError> {
let services = state.list_services.execute(query.include_inactive).await?;
Ok(Json(ServicesList { services }))
}
/// Legt einen neuen Service an.
#[utoipa::path(
post,
path = "/services",
tag = "services",
request_body = CreateServiceRequest,
responses(
(status = 200, body = ServiceResponse),
(status = 400, description = "Validierungsfehler (z. B. kind/min/max inkonsistent)"),
(status = 409, description = "key existiert bereits"),
(status = 401, description = "Authentifizierung fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn create_service(
State(state): State<AppState>,
AuthenticatedUser(_claims): AuthenticatedUser,
Json(req): Json<CreateServiceRequest>,
) -> Result<Json<ServiceResponse>, ApiError> {
let service = state.create_service.execute(req).await?;
Ok(Json(ServiceResponse { service }))
}
/// Patcht Name/Grenzen/Aktiv-Flag/Sortierung. `kind` ist nicht änderbar.
#[utoipa::path(
patch,
path = "/services/{id}",
tag = "services",
params(("id" = Uuid, Path, description = "Service-Id")),
request_body = UpdateServiceRequest,
responses(
(status = 200, body = ServiceResponse),
(status = 404, description = "Service nicht gefunden"),
(status = 401, description = "Authentifizierung fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn update_service(
State(state): State<AppState>,
AuthenticatedUser(_claims): AuthenticatedUser,
Path(id): Path<Uuid>,
Json(req): Json<UpdateServiceRequest>,
) -> Result<Json<ServiceResponse>, ApiError> {
let service = state.update_service.execute(id, req).await?;
Ok(Json(ServiceResponse { service }))
}
/// Hartes Löschen. `409 Conflict`, wenn der Service noch von einer Lieferung
/// referenziert wird — dann stattdessen deaktivieren.
#[utoipa::path(
delete,
path = "/services/{id}",
tag = "services",
params(("id" = Uuid, Path, description = "Service-Id")),
responses(
(status = 204, description = "Service gelöscht"),
(status = 404, description = "Service nicht gefunden"),
(status = 409, description = "Service ist noch referenziert"),
(status = 401, description = "Authentifizierung fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn delete_service(
State(state): State<AppState>,
AuthenticatedUser(_claims): AuthenticatedUser,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
state.delete_service.execute(id).await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@ -2,10 +2,19 @@ use std::sync::Arc;
use holzleitner_application::ports::AuthService;
use holzleitner_application::usecases::{
ApplyDeliveryActionUseCase, ApplyScansUseCase, AssignCarToDeliveryUseCase,
CreateDeliveryNoteUseCase, CreateMyCarUseCase, GetAccountUseCase, GetTourUseCase,
ListMyCarsUseCase, ListMyToursTodayUseCase, SetDeliveryOrderUseCase, SyncTourUseCase,
UpdateMyCarUseCase,
ApplyDeliveryActionUseCase, ApplyDeliveryCreditEventUseCase, ApplyScansUseCase,
AssignCarToDeliveryUseCase, CompleteDeliveryUseCase, CreateDeliveryNoteUseCase,
CreateMyCarUseCase, CreatePaymentMethodUseCase, CreateServiceUseCase,
DeleteDeliveryNoteUseCase, DeleteDeliveryServiceUseCase, DeletePaymentMethodUseCase,
DeleteServiceUseCase, DevResyncToursUseCase, GenerateDeliveryReportUseCase, GetAccountUseCase,
GetAttachmentPreviewUseCase, GetTourUseCase,
ImportErpToursUseCase, ListDeliveredBelegnummernUseCase, ListMyCarsUseCase,
ListMyToursTodayUseCase, ListPaymentMethodsUseCase,
ListServicesUseCase, MarkMailSentUseCase, ProcessDeliveryReportUseCase,
PushCompletionToErpUseCase,
SetDeliveryOrderUseCase, SetDeliveryServiceUseCase, SyncTourUseCase, UpdateDeliveryNoteUseCase,
UpdateMyCarUseCase, UpdatePaymentMethodUseCase, UpdateServiceUseCase,
UploadDeliveryNoteImageUseCase,
};
/// Shared application state, der per Axum's `State`-Extractor in alle
@ -21,13 +30,49 @@ pub struct AppState {
pub get_tour: Arc<GetTourUseCase>,
pub list_my_tours_today: Arc<ListMyToursTodayUseCase>,
pub sync_tour: Arc<SyncTourUseCase>,
pub import_erp_tours: Arc<ImportErpToursUseCase>,
/// DEV-ONLY: überschreibender Resync (löscht Postgres + importiert neu).
pub dev_resync_tours: Arc<DevResyncToursUseCase>,
/// Erzeugt den PDF-Lieferreport (lokal — Dev-Endpoint + Fallback ohne Upload).
pub generate_delivery_report: Arc<GenerateDeliveryReportUseCase>,
/// Überträgt den Report an DOCUframe (Upload → Makro → Cleanup) — beim
/// Abschluss (Hintergrund) + Retry-Cron + Dev-Endpoint.
pub process_delivery_report: Arc<ProcessDeliveryReportUseCase>,
/// Spiegelt `REPORT_UPLOAD_ENABLED`: steuert, ob beim Abschluss die
/// DOCUframe-Übertragung läuft (an) oder nur lokal erzeugt wird (aus).
pub report_upload_enabled: bool,
pub set_delivery_order: Arc<SetDeliveryOrderUseCase>,
pub apply_scans: Arc<ApplyScansUseCase>,
pub apply_delivery_action: Arc<ApplyDeliveryActionUseCase>,
pub complete_delivery: Arc<CompleteDeliveryUseCase>,
pub push_completion_to_erp: Arc<PushCompletionToErpUseCase>,
/// Admin: Belegnummern offener (noch nicht versendeter) Lieferungen.
pub list_delivered_belegnummern: Arc<ListDeliveredBelegnummernUseCase>,
/// Admin: Liefermails von Belegnummern als versendet markieren (Dedup).
pub mark_mail_sent: Arc<MarkMailSentUseCase>,
pub apply_delivery_credit_event: Arc<ApplyDeliveryCreditEventUseCase>,
pub create_delivery_note: Arc<CreateDeliveryNoteUseCase>,
pub update_delivery_note: Arc<UpdateDeliveryNoteUseCase>,
pub delete_delivery_note: Arc<DeleteDeliveryNoteUseCase>,
pub upload_delivery_note_image: Arc<UploadDeliveryNoteImageUseCase>,
pub get_attachment_preview: Arc<GetAttachmentPreviewUseCase>,
pub list_my_cars: Arc<ListMyCarsUseCase>,
pub create_my_car: Arc<CreateMyCarUseCase>,
pub update_my_car: Arc<UpdateMyCarUseCase>,
pub assign_car_to_delivery: Arc<AssignCarToDeliveryUseCase>,
pub list_payment_methods: Arc<ListPaymentMethodsUseCase>,
pub create_payment_method: Arc<CreatePaymentMethodUseCase>,
pub update_payment_method: Arc<UpdatePaymentMethodUseCase>,
pub delete_payment_method: Arc<DeletePaymentMethodUseCase>,
pub list_services: Arc<ListServicesUseCase>,
pub create_service: Arc<CreateServiceUseCase>,
pub update_service: Arc<UpdateServiceUseCase>,
pub delete_service: Arc<DeleteServiceUseCase>,
pub set_delivery_service: Arc<SetDeliveryServiceUseCase>,
pub delete_delivery_service: Arc<DeleteDeliveryServiceUseCase>,
pub auth_service: Arc<dyn AuthService>,
/// Statischer API-Key-Gate für die `/admin`-Routen (Header
/// `X-Admin-Api-Key`). Leer ⇒ alle Admin-Routen gesperrt (fail-closed).
/// Wird von der `admin_api_key`-Middleware konstant-zeitlich verglichen.
pub admin_api_key: Arc<str>,
}

View File

@ -17,4 +17,6 @@ async-trait.workspace = true
thiserror.workspace = true
uuid.workspace = true
chrono.workspace = true
sha2.workspace = true
imagesize.workspace = true
utoipa = { workspace = true, optional = true }

View File

@ -0,0 +1,40 @@
//! Eingabe für den Lieferungs-Abschluss (`POST /deliveries/{id}/complete`).
//!
//! Der Endpoint nimmt `multipart/form-data` entgegen — zwei Signatur-PNGs
//! plus dieses JSON-Feld mit den Checkbox-Bestätigungen des Kunden. Die
//! Antwort ist die frisch abgeschlossene `Delivery` (`DeliveryResponse`).
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Dokumentierte Bestätigungen des Kunden zum Abschlusszeitpunkt.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct CompleteDeliveryAcknowledgements {
/// „Ware im ordnungsgemäßen Zustand erhalten / Aufbau korrekt." — Pflicht.
pub receipt_confirmed: bool,
/// „Anmerkungen zur Lieferung zur Kenntnis genommen." — Pflicht nur, wenn
/// Notizen existieren (das prüft der Server).
#[serde(default)]
pub notes_acknowledged: bool,
/// Notiz-IDs, die zum Abschlusszeitpunkt sichtbar waren und mit-bestätigt
/// wurden (Audit-Robustheit).
#[serde(default)]
pub acknowledged_note_ids: Vec<Uuid>,
/// Inkasso-Bestätigung des Fahrers: „der offene Betrag wurde erhalten
/// (bar) bzw. über das EC-Gerät abgerechnet." Pflicht nur, wenn beim
/// Abschluss ein offener Betrag > 0 besteht UND die Methode ein Vor-Ort-
/// Inkasso ist (Bar/EC) — das prüft der Server. Der kassierte Betrag wird
/// server-seitig autoritativ berechnet (nicht vom Client übernommen).
#[serde(default)]
pub payment_collected: bool,
/// Optionale Zahlungsmethode, die der Fahrer beim Abschluss gewählt hat.
/// `None` = die am Beleg hinterlegte Methode bleibt. Falls gesetzt, muss
/// sie existieren **und** aktiv sein (vom Server geprüft).
#[serde(default)]
pub payment_method_id: Option<Uuid>,
/// Fahrzeug des Akteurs (Audit-Spur). Muss zum Account gehören.
#[serde(default)]
pub author_car_id: Option<Uuid>,
}

View File

@ -0,0 +1,45 @@
//! Request/Response für `POST /deliveries/{id}/credit` — die
//! Betrags-Gutschrift (append-only, idempotent über `client_event_id`).
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use holzleitner_domain::DeliveryCredit;
/// Art des Gutschrift-Ereignisses.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum CreditAction {
/// Gutschrift setzen/ändern — `amount_cents` und `reason` Pflicht.
Set,
/// Gutschrift entfernen — `amount_cents`/`reason` werden ignoriert.
Remove,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeliveryCreditEventRequest {
/// Idempotenz-Schlüssel — pro erzeugtem Ereignis genau einmal vergeben.
/// Ein Retry mit derselben Id wendet nichts erneut an.
pub client_event_id: Uuid,
pub action: CreditAction,
/// Bei `Set` Pflicht: Betrag in Cent (> 0, ≤ 15000, Vielfaches von 1000).
#[serde(default)]
pub amount_cents: Option<i64>,
/// Bei `Set` Pflicht: Begründung.
#[serde(default)]
pub reason: Option<String>,
/// Fahrzeug des Akteurs (Audit-Spur). Muss zum Account gehören.
#[serde(default)]
pub author_car_id: Option<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeliveryCreditResponse {
/// Aktueller Stand nach dem Ereignis — `None`, wenn (zuletzt) entfernt.
pub credit: Option<DeliveryCredit>,
}

View File

@ -0,0 +1,149 @@
//! DTO-Aggregat für den PDF-Lieferreport.
//!
//! Bündelt **alle** Informationen zu einer Lieferung inkl. der beiden
//! Audit-Trails (`scan_audit`, `delivery_credit_audit`). Wird vom
//! `DeliveryReportRepository` (DB) befüllt; die Bild-Bytes (Unterschriften,
//! Anhänge) hängt der Use Case nachträglich aus dem lokalen Speicher an,
//! damit der Renderer rein (ohne IO) bleibt.
use chrono::{DateTime, NaiveDate, Utc};
#[derive(Debug, Clone)]
pub struct DeliveryReportData {
pub generated_at: DateTime<Utc>,
// Kopf
pub belegart_id: i64,
/// Belegart-Kurzcode (z. B. „VL5"), falls vom Sync befüllt.
pub belegart_code: Option<String>,
/// Belegart-Klartext (z. B. „Lieferschein EH").
pub belegart_name: Option<String>,
pub belegnummer: String,
pub state: String,
pub tour_date: NaiveDate,
pub driver_personalnummer: i64,
pub driver_name: String,
pub car_plate: Option<String>,
pub payment_method: Option<String>,
// Kunde + Adresse
pub customer_number: i64,
pub customer_name: String,
pub address: String,
pub desired_time: Option<String>,
pub special_agreements: Option<String>,
pub prepaid_amount: f64,
pub current_credit_cents: i64,
pub contacts: Vec<ReportContact>,
pub items: Vec<ReportItem>,
pub services: Vec<ReportService>,
pub notes: Vec<ReportNote>,
pub completion: Option<ReportCompletion>,
pub scan_audit: Vec<ReportScanAudit>,
pub credit_audit: Vec<ReportCreditAudit>,
pub attachments: Vec<ReportAttachment>,
// Bild-Bytes (vom Use Case aus dem lokalen Speicher nachgeladen):
pub customer_signature_png: Option<Vec<u8>>,
pub driver_signature_png: Option<Vec<u8>>,
}
#[derive(Debug, Clone)]
pub struct ReportContact {
pub name: String,
pub detail: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ReportItem {
pub belegzeilen_nr: i32,
pub komponenten_artikel_nr: Option<String>,
pub parent_artikel_nr: Option<String>,
pub article_number: String,
pub name: String,
pub required_quantity: i32,
pub credited_quantity: i32,
pub scanned_quantity: i32,
pub scan_status: String,
pub unit_price: f64,
pub warehouse_code: Option<String>,
pub warehouse_name: Option<String>,
}
impl ReportItem {
pub fn is_component(&self) -> bool {
self.komponenten_artikel_nr.is_some()
}
/// Tatsächlich ausgeliefert = Soll Gutschrift.
pub fn delivered(&self) -> i32 {
(self.required_quantity - self.credited_quantity).max(0)
}
}
#[derive(Debug, Clone)]
pub struct ReportService {
pub name: String,
pub bool_value: Option<bool>,
pub numeric_value: Option<i32>,
}
#[derive(Debug, Clone)]
pub struct ReportNote {
pub created_at: DateTime<Utc>,
pub author_personalnummer: i64,
pub text: Option<String>,
pub image_attachment: Option<String>,
pub is_amount_credit_note: bool,
}
#[derive(Debug, Clone)]
pub struct ReportCompletion {
pub completed_at: DateTime<Utc>,
pub completed_by_personalnummer: i64,
pub receipt_confirmed: bool,
pub notes_acknowledged: bool,
pub customer_signature_path: String,
pub driver_signature_path: String,
/// Fahrer hat das Inkasso (Bar/EC) bestätigt.
pub payment_collected: bool,
/// Snapshot des kassierten offenen Betrags in Cent (None = kein Inkasso).
pub collected_amount_cents: Option<i64>,
}
#[derive(Debug, Clone)]
pub struct ReportScanAudit {
pub server_recorded_at: DateTime<Utc>,
pub client_scanned_at: DateTime<Utc>,
pub action: String,
pub delta: i32,
pub resulting_quantity: i32,
pub resulting_status: String,
pub reason: Option<String>,
pub manual: bool,
pub credit_delta: Option<i32>,
pub actor_personalnummer: i64,
pub belegzeilen_nr: i32,
pub komponenten_artikel_nr: Option<String>,
pub article_name: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ReportCreditAudit {
pub recorded_at: DateTime<Utc>,
pub action: String,
pub amount_cents: i64,
pub reason: Option<String>,
pub author_personalnummer: i64,
}
#[derive(Debug, Clone)]
pub struct ReportAttachment {
pub filename: Option<String>,
/// Speicher-Referenz (lokaler relativer Pfad) — zum Nachladen der Bytes.
pub reference: String,
pub mime_type: String,
pub size_bytes: i64,
pub width: Option<i32>,
pub height: Option<i32>,
pub uploaded_at: DateTime<Utc>,
pub uploaded_by: i64,
/// Vom Use Case aus dem lokalen Speicher nachgeladen (fürs Einbetten).
pub bytes: Option<Vec<u8>>,
}

View File

@ -11,10 +11,15 @@
//! zweite Schicht handgeschriebener API-DTOs.
pub mod car;
pub mod complete;
pub mod credit;
pub mod delivery_action;
pub mod delivery_report;
pub mod delivery_order;
pub mod note;
pub mod payment_method;
pub mod scan;
pub mod service;
pub mod tour_details;
pub mod tour_summary;
pub mod tour_sync;
@ -22,14 +27,32 @@ pub mod tour_sync;
pub use car::{
AssignCarRequest, CarResponse, CarsList, CreateCarRequest, UpdateCarRequest,
};
pub use complete::CompleteDeliveryAcknowledgements;
pub use credit::{CreditAction, DeliveryCreditEventRequest, DeliveryCreditResponse};
pub use delivery_report::{
DeliveryReportData, ReportAttachment, ReportCompletion, ReportContact, ReportCreditAudit,
ReportItem, ReportNote, ReportScanAudit, ReportService,
};
pub use delivery_action::{CancelDeliveryRequest, DeliveryResponse, HoldDeliveryRequest};
pub use delivery_order::{
DeliveryOrderEntry, SetDeliveryOrderRequest, SetDeliveryOrderResponse,
};
pub use note::{CreateDeliveryNoteRequest, DeliveryNoteResponse};
pub use note::{
CreateDeliveryNoteRequest, DeliveryNoteResponse, UpdateDeliveryNoteRequest,
};
pub use payment_method::{
CreatePaymentMethodRequest, PaymentMethodResponse, PaymentMethodsList,
UpdatePaymentMethodRequest,
};
pub use scan::{
ApplyScansRequest, ApplyScansResponse, ScanEvent, ScanResult, ScanResultStatus,
};
pub use service::{
CreateServiceRequest, DeliveryServiceResponse, ServiceResponse, ServicesList,
SetDeliveryServiceRequest, UpdateServiceRequest,
};
pub use tour_details::{DeliveryWithItems, TourDetails};
pub use tour_summary::TourSummary;
pub use tour_sync::{SyncDelivery, SyncDeliveryItem, SyncTourRequest};
pub use tour_sync::{
SyncContactChannel, SyncContactSource, SyncDelivery, SyncDeliveryItem, SyncTourRequest,
};

View File

@ -19,6 +19,27 @@ pub struct CreateDeliveryNoteRequest {
/// Fahrzeug, das die Notiz erzeugt hat. Muss zum angemeldeten
/// Account gehören. `None` ist erlaubt.
pub author_car_id: Option<Uuid>,
/// Optionaler Gutschrift-Bezug: die Belegzeile, für die diese Notiz als
/// Gutschrift-Grund angelegt wird. Ermöglicht das gezielte Löschen beim
/// Unremove. `None` für normale Notizen.
#[serde(default)]
pub credit_delivery_item_id: Option<Uuid>,
/// `true` markiert die Notiz als Grund einer Betrags-Gutschrift
/// (Lieferungs-Ebene). Default `false`.
#[serde(default)]
pub is_amount_credit_note: bool,
}
/// Request für `PATCH /deliveries/{id}/notes/{note_id}`. Wie beim Create
/// muss mindestens eines von `text` / `image_attachment` inhaltlich gefüllt
/// sein — geprüft im Use Case.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct UpdateDeliveryNoteRequest {
pub text: Option<String>,
/// Object-Storage-Key oder URL eines vorab hochgeladenen Bildes.
pub image_attachment: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@ -0,0 +1,41 @@
//! Request- und Antwort-Typen für die Payment-Methods-Endpoints.
use serde::{Deserialize, Serialize};
use holzleitner_domain::PaymentMethod;
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct CreatePaymentMethodRequest {
/// Eindeutiger Programm-Identifier (z. B. `"paypal"`, `"klarna"`).
pub code: String,
/// Anzeige-Name in der UI.
pub name: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct UpdatePaymentMethodRequest {
/// Wenn gesetzt: neuer Anzeige-Name.
pub name: Option<String>,
/// Wenn gesetzt: aktiv/inaktiv. Inaktive Methoden bleiben für
/// historische Lieferungen referenzierbar, tauchen aber im
/// Default-Listing nicht auf.
pub active: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct PaymentMethodResponse {
pub method: PaymentMethod,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct PaymentMethodsList {
pub methods: Vec<PaymentMethod>,
}

View File

@ -27,6 +27,19 @@ pub struct ScanEvent {
pub action: AuditAction,
/// Pflicht bei `Hold` und `Remove`. Sonst ignoriert.
pub reason: Option<String>,
/// Menge für `Remove` / `Unremove` (Mengen-Gutschrift): wie viele Stück
/// der Belegzeile gutgeschrieben bzw. wieder hergestellt werden.
/// `None` = ganze Restmenge (abwärtskompatibel zum bisherigen
/// „ganze Zeile entfernen"). Bei `Scan`/`Unscan`/`Hold`/`Unhold`
/// ignoriert. Muss, wenn gesetzt, `> 0` sein.
#[serde(default)]
pub quantity: Option<i32>,
/// `true`, wenn der Fahrer die Position **manuell** als geladen bestätigt
/// hat (Fallback ohne Barcode-Scan). Wird nur im Audit (`scan_audit.manual`)
/// festgehalten; an der Mengen-/Status-Logik ändert es nichts. Default
/// `false` (regulärer Barcode-Scan).
#[serde(default)]
pub manual: bool,
pub client_scanned_at: DateTime<Utc>,
/// Fahrzeug, in dem der Scan gemacht wurde. Muss zum
/// angemeldeten Account gehören. `None` ist erlaubt, schwächt

View File

@ -0,0 +1,73 @@
//! Request-/Antwort-Typen für die Services-Endpoints (Stammdaten-CRUD +
//! Pro-Lieferung-Wert).
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use holzleitner_domain::{DeliveryServiceValue, Service, ServiceKind};
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct CreateServiceRequest {
/// Eindeutiger Programm-Identifier (z. B. `"podium_setup"`).
pub key: String,
pub name: String,
pub kind: ServiceKind,
/// Nur bei `Numeric` sinnvoll.
#[serde(default)]
pub min_value: Option<i32>,
#[serde(default)]
pub max_value: Option<i32>,
#[serde(default)]
pub sort_order: Option<i32>,
}
/// Teil-Update eines Service. `kind` ist bewusst **nicht** änderbar — ein
/// Wechsel boolean↔numeric würde bestehende Pro-Lieferung-Werte ungültig
/// machen (dann lieber deaktivieren + neu anlegen).
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct UpdateServiceRequest {
pub name: Option<String>,
pub min_value: Option<i32>,
pub max_value: Option<i32>,
pub active: Option<bool>,
pub sort_order: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct ServiceResponse {
pub service: Service,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct ServicesList {
pub services: Vec<Service>,
}
/// Setzt den Wert eines Service für eine Lieferung (Upsert). Es muss genau
/// das zum `ServiceKind` passende Feld gesetzt sein (Use Case prüft das).
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct SetDeliveryServiceRequest {
#[serde(default)]
pub bool_value: Option<bool>,
#[serde(default)]
pub numeric_value: Option<i32>,
#[serde(default)]
pub author_car_id: Option<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeliveryServiceResponse {
pub value: DeliveryServiceValue,
}

View File

@ -10,7 +10,8 @@
use serde::Serialize;
use holzleitner_domain::{
Article, Customer, CustomerContact, Delivery, DeliveryItem, DeliveryNote, Tour, Warehouse,
Article, ContactChannel, ContactSource, Customer, CustomerContact, Delivery, DeliveryCredit,
DeliveryItem, DeliveryNote, DeliveryServiceValue, Service, Tour, Warehouse,
};
#[derive(Debug, Clone, Serialize)]
@ -27,6 +28,24 @@ pub struct TourDetails {
/// Die App joint clientseitig per `delivery_id`. Reihenfolge:
/// pro Lieferung aufsteigend nach `created_at`.
pub notes: Vec<DeliveryNote>,
/// Aktuelle Betrags-Gutschriften (jüngster Stand pro Lieferung), nur für
/// Lieferungen, deren letztes Ereignis `set` war. Join per `delivery_id`.
pub credits: Vec<DeliveryCredit>,
/// Aktive Service-Definitionen (Stammdaten) — die App rendert daraus
/// Phase 4. Bewusst hier mitgeliefert, damit die Detailseite alles aus
/// dem Tour-Aggregat hat.
pub services: Vec<Service>,
/// Pro-Lieferung gesetzte Service-Werte. Join per `delivery_id` +
/// `service_id`.
pub delivery_services: Vec<DeliveryServiceValue>,
/// Alle vom ERP gespiegelten Kontaktquellen aller Lieferungen dieser
/// Tour. Die App joint clientseitig per `delivery_id` und gruppiert
/// nach `role` (Lieferadresse / Rechnungsadresse / Ansprechpartner /
/// Kundenstamm / Belegadresse).
pub contact_sources: Vec<ContactSource>,
/// Die zu `contact_sources` gehörenden Einzel-Kanäle (Telefon, Mobil,
/// E-Mail, Web). Join per `source_id`.
pub contact_channels: Vec<ContactChannel>,
}
#[derive(Debug, Clone, Serialize)]

View File

@ -13,7 +13,7 @@
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use holzleitner_domain::Address;
use holzleitner_domain::{Address, ContactKind, ContactRole};
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
@ -29,6 +29,12 @@ pub struct SyncTourRequest {
#[serde(rename_all = "camelCase")]
pub struct SyncDelivery {
pub belegart_id: i64,
/// Belegart-Kurzcode (z. B. „VL5"), aus `Belegarten.Belegart` (getrimmt).
#[serde(default)]
pub belegart_code: Option<String>,
/// Belegart-Klartext (z. B. „Lieferschein EH"), aus `Belegarten.Bezeichnung`.
#[serde(default)]
pub belegart_name: Option<String>,
pub belegnummer: String,
pub erp_customer_id: i64,
@ -44,7 +50,62 @@ pub struct SyncDelivery {
pub desired_time: Option<String>,
pub special_agreements: Option<String>,
/// Bei Bestellung schon bezahlter Betrag in EUR. Default `0.0`,
/// wenn der Kunde alles bei Lieferung zahlt. Der ERP-Sync liefert
/// den Wert mit.
#[serde(default)]
pub prepaid_amount: f64,
/// Für den Restbetrag gewählte Zahlungsart — Referenz per
/// `code` (z. B. `"cash"`, `"invoice"`). Das ERP kennt seine
/// Standard-Codes, der Sync-Code resolvet sie zur UUID. Wenn
/// `None`, fällt der Backend-Code auf `"cash"` zurück.
#[serde(default)]
pub payment_method_code: Option<String>,
pub items: Vec<SyncDeliveryItem>,
/// Alle vom ERP an diesem Beleg hängenden Kontakt-Adressen (Beleg-/
/// Liefer-/Rechnungsadresse, Ansprechpartner, Kundenstamm). Leere
/// Quellen (kein einziger ausgefüllter Kanal *und* kein Name) lässt
/// der Sync weg.
#[serde(default)]
pub contact_sources: Vec<SyncContactSource>,
}
/// Eine Adress-Rolle eines Belegs mit Namensblock und allen ausgefüllten
/// Telefon-/Mobil-/E-Mail-/Web-Einträgen.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct SyncContactSource {
pub role: ContactRole,
#[serde(default)]
pub anrede: Option<String>,
#[serde(default)]
pub titel: Option<String>,
#[serde(default)]
pub name1: Option<String>,
#[serde(default)]
pub name2: Option<String>,
#[serde(default)]
pub name3: Option<String>,
#[serde(default)]
pub abteilung: Option<String>,
#[serde(default)]
pub funktion: Option<String>,
#[serde(default)]
pub channels: Vec<SyncContactChannel>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct SyncContactChannel {
pub kind: ContactKind,
/// 1-basiert: spiegelt ERP-Spaltenposition (Telefon → 1, Telefon2 → 2, …).
pub position: i16,
pub value: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@ -53,7 +114,13 @@ pub struct SyncDelivery {
pub struct SyncDeliveryItem {
pub belegzeilen_nr: i32,
/// Komponenten-Artikelnummer bei aufgelösten Stücklisten, sonst leer.
/// Trägt die **eigene** Nummer der Komponente (eindeutig je Belegzeile).
pub komponenten_artikel_nr: Option<String>,
/// Artikelnummer des **Oberartikels**, zu dem diese Komponente gehört.
/// `None` bei Oberartikeln/regulären Zeilen. Erlaubt der App, Komponenten
/// unter ihrem Oberartikel einzurücken.
#[serde(default)]
pub parent_artikel_nr: Option<String>,
pub article_number: String,
pub article_name: String,
@ -65,4 +132,9 @@ pub struct SyncDeliveryItem {
pub warehouse_name: String,
pub required_quantity: i32,
/// Stückpreis (brutto, EUR). Default `0.0`. Liefert der ERP-Sync mit;
/// die App rechnet daraus den Warenwert.
#[serde(default)]
pub unit_price: f64,
}

View File

@ -20,6 +20,11 @@ pub enum ApplicationError {
#[error("validation: {0}")]
Validation(String),
/// Operation würde einen Daten-Konflikt erzeugen (z. B. FK-RESTRICT
/// beim Löschen, UNIQUE-Verletzung). Mappt auf HTTP `409`.
#[error("conflict: {0}")]
Conflict(String),
#[error("repository: {0}")]
Repository(String),

View File

@ -0,0 +1,71 @@
//! Port für die Attachment-Metadaten-Registry (Postgres).
//!
//! Hält Metadaten zu hochgeladenen Dateien + die DOCUframe-Referenz. Die
//! Bytes selbst liegen extern (DOCUframe, siehe [`super::AttachmentStorage`]).
use async_trait::async_trait;
use uuid::Uuid;
use crate::error::ApplicationError;
/// Eingabe zum Anlegen eines Attachment-Metadatensatzes. `id` und
/// `uploaded_at` werden von der DB vergeben.
pub struct NewAttachment {
/// DOCUframe `~ObjectID` — Referenz zum Abruf der Bytes.
pub docuframe_object_id: String,
pub mime_type: String,
pub size_bytes: i64,
pub filename: Option<String>,
/// SHA-256 der Bytes als Hex.
pub checksum_sha256: String,
pub width: Option<i32>,
pub height: Option<i32>,
pub uploaded_by: i64,
pub delivery_id: Uuid,
}
/// Für den Download relevante Felder eines Attachments.
pub struct AttachmentRef {
/// DOCUframe `~ObjectID` zum Laden der Bytes.
pub docuframe_object_id: String,
/// Ursprünglicher Upload-MIME-Typ (nur informativ — das Vorschau-
/// Rendering bestimmt das tatsächliche Ausgabeformat).
pub mime_type: String,
}
/// Lokale Datei-Referenz eines (noch nicht gelöschten) Attachments — fürs
/// Aufräumen nach erfolgreichem Report-Upload.
pub struct AttachmentLocalRef {
pub id: Uuid,
/// Speicher-Referenz (lokal = relativer Pfad in `docuframe_object_id`).
pub reference: String,
}
#[async_trait]
pub trait AttachmentRepository: Send + Sync {
/// Legt einen Metadatensatz an und liefert dessen neue Id zurück.
async fn create(&self, attachment: NewAttachment) -> Result<Uuid, ApplicationError>;
/// Lädt die Download-relevanten Felder eines Attachments. `None`, wenn
/// kein Attachment mit dieser Id existiert.
async fn get(&self, id: Uuid) -> Result<Option<AttachmentRef>, ApplicationError>;
/// Liefert die Belegnummer (`deliveries.erp_belegnummer`) zur Lieferung —
/// der lokale Speicher nutzt sie als Ordnernamen. `None`, wenn die
/// Lieferung nicht (mehr) existiert.
async fn delivery_belegnummer(
&self,
delivery_id: Uuid,
) -> Result<Option<String>, ApplicationError>;
/// Listet alle noch nicht gelöschten Attachments einer Lieferung
/// (`deleted_at IS NULL`) mit ihrer lokalen Referenz — fürs Aufräumen.
async fn list_active_for_delivery(
&self,
delivery_id: Uuid,
) -> Result<Vec<AttachmentLocalRef>, ApplicationError>;
/// Markiert ein Attachment als gelöscht (`deleted_at = now()`). Die
/// Metadaten-Zeile bleibt — so ist ersichtlich, dass es ein Bild gab.
async fn mark_deleted(&self, id: Uuid) -> Result<(), ApplicationError>;
}

View File

@ -0,0 +1,54 @@
//! Port für den externen Dokumenten-/Datei-Speicher.
//!
//! Konkrete Impl ist der DOCUframe-Adapter (GSD-REST-API). Der Use Case
//! lädt eine Datei hoch und erhält eine persistente Referenz zurück, die
//! als `image_attachment` an einer Notiz gespeichert wird.
use async_trait::async_trait;
use crate::error::ApplicationError;
/// Heruntergeladenes Vorschaubild: rohe Bytes + der vom Speicher gemeldete
/// Content-Type (das Vorschau-Rendering bestimmt das Ausgabeformat, nicht
/// der ursprüngliche Upload-MIME-Typ).
pub struct PreviewImage {
pub bytes: Vec<u8>,
pub content_type: String,
}
#[async_trait]
pub trait AttachmentStorage: Send + Sync {
/// Lädt eine Datei hoch und liefert die persistente Referenz.
///
/// `folder` ist ein logischer Ablage-Schlüssel (bei uns die Belegnummer):
/// der lokale Adapter legt die Datei in einem gleichnamigen Unterordner ab.
/// Der DOCUframe-Adapter ignoriert ihn (könnte ihn später als Kategorie
/// nutzen). Rückgabe: die Referenz für den späteren Abruf, die in
/// `attachments.docuframe_object_id` gespeichert wird (lokal = rel. Pfad,
/// DOCUframe = `~ObjectID`).
async fn upload(
&self,
folder: &str,
filename: &str,
mime: &str,
bytes: Vec<u8>,
) -> Result<String, ApplicationError>;
/// Lädt ein gerendertes Vorschaubild zur Referenz `object_id`.
///
/// `parameters` folgt dem DOCUframe-Schema
/// `width_height_quality_extension` (z. B. `1024_1024_85_jpeg`),
/// `page` ist die Seitennummer (für Bilder i. d. R. `1`).
async fn download_preview(
&self,
object_id: &str,
parameters: &str,
page: &str,
) -> Result<PreviewImage, ApplicationError>;
/// Löscht die Datei hinter `reference` (lokaler Adapter: die lokale Datei).
/// Wird beim Aufräumen nach erfolgreichem Report-Upload genutzt. Idempotent:
/// eine bereits fehlende Datei ist kein Fehler. Der DOCUframe-Adapter
/// implementiert das als No-Op (wir löschen dort nichts).
async fn delete(&self, reference: &str) -> Result<(), ApplicationError>;
}

View File

@ -0,0 +1,131 @@
//! Port für den Lieferungs-Abschluss (Unterschriften + Bestätigungen).
//!
//! Der Abschluss ist eine **atomare** Operation: Gate-Prüfungen (Lieferung
//! aktiv, alle scanbaren Positionen fertig, ggf. Notizen bestätigt),
//! Persistenz der Abschluss-Zeile und der Statuswechsel auf `completed`
//! laufen in genau einer Transaktion. Schlägt etwas fehl, bleibt nichts
//! halb-fertig zurück.
//!
//! Die Signatur-Dateien werden VOR dem Repository-Aufruf vom Use Case über
//! `SignatureStorage` geschrieben; hier kommen nur noch deren Referenzen an.
use async_trait::async_trait;
use chrono::{NaiveDate, NaiveDateTime};
use uuid::Uuid;
use holzleitner_domain::Delivery;
use crate::error::ApplicationError;
/// Eine Belegzeile für das ERP-Rückschreiben: Position + ausgelieferte Menge.
pub struct ErpWritebackLine {
pub belegzeilen_nr: i32,
/// `required_quantity credited_quantity` (Postgres-Stand).
pub delivered_quantity: i32,
}
/// Alles, was der ERP-Rückschreib-Use-Case aus Postgres braucht, um den
/// Abschluss ins ERP zu spiegeln. Liest den **aktuellen** lokalen Stand
/// (Mengen, Geld-Gutschrift, Abschluss-Zeitpunkt) — daher idempotent
/// wiederholbar.
pub struct ErpWritebackData {
pub belegart_id: i64,
pub belegnummer: String,
/// Abschluss-Zeitpunkt (lokale Zeit) aus `delivery_completions.completed_at`.
pub delivered_at: NaiveDateTime,
pub lines: Vec<ErpWritebackLine>,
/// Aktuelle Geld-Gutschrift in Cent (0 = keine).
pub credit_amount_cents: i64,
/// Code der beim Abschluss gewählten Zahlungsmethode
/// (`payment_methods.code`, z. B. `cash`/`ec_card`/`invoice`). `None`,
/// wenn keine zugeordnet ist. Der Adapter mappt das auf die ERP-
/// Zahlungsbedingung und setzt `Belegkopf.ZahlungsbedingungId`.
pub payment_method_code: Option<String>,
}
/// Vollständige Eingabe für den Abschluss — alles, was die Abschluss-Zeile
/// braucht plus die fachlichen Bestätigungs-Flags fürs Gate.
pub struct CompleteDeliveryInput {
pub delivery_id: Uuid,
pub customer_signature_path: String,
pub driver_signature_path: String,
/// Empfangsbestätigung des Kunden (immer Pflicht == true).
pub receipt_confirmed: bool,
/// Kenntnisnahme der Anmerkungen — Pflicht nur, wenn Notizen existieren
/// (das prüft das Repository gegen den DB-Stand).
pub notes_acknowledged: bool,
/// Notiz-IDs, die zum Abschlusszeitpunkt sichtbar/mit-bestätigt waren.
pub acknowledged_note_ids: Vec<Uuid>,
/// Inkasso-Bestätigung des Fahrers. Das Repository prüft gegen den
/// DB-Stand, ob sie nötig war (offener Betrag > 0 UND Methode Bar/EC), und
/// friert den kassierten Betrag als Snapshot ein.
pub payment_collected: bool,
/// Optionale Zahlungsmethode-Override. `None` = Methode am Beleg bleibt.
/// Falls gesetzt, prüft das Repository Existenz + Aktiv-Status.
pub payment_method_id: Option<Uuid>,
pub completed_by_personalnummer: i64,
pub completed_by_car_id: Option<Uuid>,
}
#[async_trait]
pub trait DeliveryCompletionRepository: Send + Sync {
/// Schließt eine Lieferung ab und liefert die frische `Delivery`
/// (`state == completed`) zurück.
///
/// Gates (alle in der Transaktion unter Lock):
/// * Lieferung existiert (`NotFound`).
/// * Bereits `completed` **mit** Abschluss-Zeile → idempotenter Erfolg.
/// * Sonst muss `state == active` sein (`Validation`).
/// * Alle scanbaren, nicht entfernten Positionen müssen fertig sein.
/// * Existieren Notizen, muss `notes_acknowledged == true` sein.
async fn complete(
&self,
input: CompleteDeliveryInput,
) -> Result<Delivery, ApplicationError>;
/// Lädt die für das ERP-Rückschreiben nötigen Daten einer **bereits
/// abgeschlossenen** Lieferung (Beleg-Key, ausgelieferte Mengen,
/// Geld-Gutschrift, Abschluss-Zeitpunkt).
///
/// `NotFound`, wenn die Lieferung oder ihre Abschluss-Zeile fehlt.
async fn load_erp_writeback(
&self,
delivery_id: Uuid,
) -> Result<ErpWritebackData, ApplicationError>;
/// Liefert die Belegnummern aller **ausgelieferten** (abgeschlossenen)
/// Lieferungen, **deren Liefermail noch NICHT versendet wurde**
/// (`mail_sent_at IS NULL`).
///
/// * `day = Some(d)` → nur Belege, deren Abschluss-Zeitpunkt
/// (`completed_at`) auf den Kalendertag `d` fällt. `completed_at` ist ein
/// UTC-Zeitstempel; der Kalendertag wird in der Zeitzone **Europe/Berlin**
/// bestimmt (Geschäftszeit), nicht in UTC.
/// * `day = None` → **alle** offenen (noch nicht versendeten) Belege über
/// alle Tage. Das ist der Modus des Mailclients, damit Belege, die über
/// Mitternacht nicht versendet wurden, nicht hängen bleiben.
///
/// Sortierung: aufsteigend nach Abschluss-Zeitpunkt.
async fn list_delivered_belegnummern(
&self,
day: Option<NaiveDate>,
) -> Result<Vec<String>, ApplicationError>;
/// Markiert die Liefermail der angegebenen Belegnummern als **versendet**
/// (`mail_sent_at = now()`), aber nur dort, wo sie noch offen ist
/// (`mail_sent_at IS NULL`) — bereits markierte bleiben unverändert
/// (idempotent, erster Versand-Zeitpunkt bleibt erhalten). Liefert die
/// Anzahl tatsächlich frisch markierter Belege zurück.
async fn mark_mail_sent(
&self,
belegnummern: &[String],
) -> Result<u64, ApplicationError>;
/// **DEV-ONLY**: Hebt die Mail-Versendet-Markierung der angegebenen
/// Belegnummern wieder auf (`mail_sent_at = NULL`), sodass sie erneut als
/// offen erscheinen. Liefert die Anzahl tatsächlich zurückgesetzter Belege.
async fn unmark_mail_sent(
&self,
belegnummern: &[String],
) -> Result<u64, ApplicationError>;
}

View File

@ -0,0 +1,36 @@
//! Port für die Betrags-Gutschrift (append-only).
//!
//! Schreibseite: jedes `set`/`remove` hängt eine Zeile ans Audit-Log. Die
//! Leseseite (aktueller Stand pro Lieferung) läuft als Teil des
//! Tour-Aggregats (`TourDetails.credits`), nicht über diesen Port.
use async_trait::async_trait;
use uuid::Uuid;
use holzleitner_domain::DeliveryCredit;
use crate::dto::CreditAction;
use crate::error::ApplicationError;
#[async_trait]
pub trait DeliveryCreditRepository: Send + Sync {
/// Hängt ein Gutschrift-Ereignis ans Log und liefert den **aktuellen
/// Stand** der Lieferung danach zurück (`None`, wenn zuletzt entfernt).
///
/// Idempotent über `client_event_id`: ist die Id bereits bekannt, wird
/// nichts erneut angehängt und der aktuelle Stand unverändert geliefert.
///
/// `NotFound`, wenn die Lieferung nicht existiert; `Validation`, wenn die
/// Lieferung nicht `active` ist (nur bei frischem Ereignis geprüft).
/// `amount_cents` ist bei `Set` der zu setzende Betrag, bei `Remove` `0`.
async fn apply_event(
&self,
delivery_id: Uuid,
client_event_id: Uuid,
action: CreditAction,
amount_cents: i64,
reason: Option<String>,
author_personalnummer: i64,
author_car_id: Option<Uuid>,
) -> Result<Option<DeliveryCredit>, ApplicationError>;
}

View File

@ -1,8 +1,7 @@
//! Port für Delivery-Notizen.
//!
//! Aktuell nur das Anlegen — der Read-Pfad läuft als Teil des Tour-
//! Aggregats (`TourDetails.notes`). Sollten irgendwann Listen-Reads
//! oder Updates an einzelnen Notizen nötig werden, kommen die hier rein.
//! Anlegen, Ändern, Löschen einzelner Notizen. Der Read-Pfad läuft als
//! Teil des Tour-Aggregats (`TourDetails.notes`).
use async_trait::async_trait;
use uuid::Uuid;
@ -13,6 +12,7 @@ use crate::error::ApplicationError;
#[async_trait]
pub trait DeliveryNoteRepository: Send + Sync {
#[allow(clippy::too_many_arguments)]
async fn create(
&self,
delivery_id: Uuid,
@ -20,5 +20,20 @@ pub trait DeliveryNoteRepository: Send + Sync {
author_car_id: Option<Uuid>,
text: Option<String>,
image_attachment: Option<String>,
credit_delivery_item_id: Option<Uuid>,
is_amount_credit_note: bool,
) -> Result<DeliveryNote, ApplicationError>;
/// Aktualisiert `text` / `image_attachment` einer bestehenden Notiz.
/// Autor und `created_at` bleiben unverändert (historische Metadaten).
/// `NotFound`, wenn die Notiz nicht existiert.
async fn update(
&self,
note_id: Uuid,
text: Option<String>,
image_attachment: Option<String>,
) -> Result<DeliveryNote, ApplicationError>;
/// Löscht eine Notiz. `NotFound`, wenn keine Zeile betroffen war.
async fn delete(&self, note_id: Uuid) -> Result<(), ApplicationError>;
}

View File

@ -0,0 +1,89 @@
//! Port: persistenter Zustand der Report-Übertragung an DOCUframe.
//!
//! Spiegelt die Tabelle `delivery_report_jobs`. Hält den Fortschritt der
//! mehrstufigen Übertragung (Upload → ~ObjectID → Makro) hart in Postgres,
//! damit fehlgeschlagene Schritte per Cron wiederholt werden können.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use uuid::Uuid;
use crate::error::ApplicationError;
/// Status der Übertragung — Resume-Marke für Retries.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReportJobStatus {
/// Angelegt, noch nichts übertragen.
Pending,
/// PDF liegt in DOCUframe (`docuframe_object_id` gesetzt), Makro offen.
Uploaded,
/// Makro erfolgreich, lokale Dateien aufgeräumt — terminal.
Done,
}
impl ReportJobStatus {
pub fn as_str(self) -> &'static str {
match self {
ReportJobStatus::Pending => "pending",
ReportJobStatus::Uploaded => "uploaded",
ReportJobStatus::Done => "done",
}
}
pub fn parse(s: &str) -> Self {
match s {
"uploaded" => ReportJobStatus::Uploaded,
"done" => ReportJobStatus::Done,
_ => ReportJobStatus::Pending,
}
}
}
/// Ein Report-Übertragungs-Job (eine Zeile aus `delivery_report_jobs`).
#[derive(Debug, Clone)]
pub struct ReportJob {
pub delivery_id: Uuid,
pub belegnummer: String,
pub status: ReportJobStatus,
pub docuframe_object_id: Option<String>,
pub report_uploaded_at: Option<DateTime<Utc>>,
pub attempts: i32,
pub last_error: Option<String>,
}
#[async_trait]
pub trait DeliveryReportJobRepository: Send + Sync {
/// Legt einen Job (`pending`) an, falls noch keiner existiert, und liefert
/// den aktuellen Stand. Idempotent — ein erneuter Abschluss-Versuch
/// überschreibt einen vorhandenen Job nicht.
async fn ensure(
&self,
delivery_id: Uuid,
belegnummer: &str,
) -> Result<ReportJob, ApplicationError>;
/// Lädt einen Job. `None`, wenn keiner existiert.
async fn get(&self, delivery_id: Uuid) -> Result<Option<ReportJob>, ApplicationError>;
/// Alle offenen Jobs (`status <> 'done'`) — für den Retry-Cron.
async fn list_open(&self) -> Result<Vec<ReportJob>, ApplicationError>;
/// Setzt die ~ObjectID nach erfolgreichem Upload und `status = 'uploaded'`.
async fn set_uploaded(
&self,
delivery_id: Uuid,
object_id: &str,
) -> Result<(), ApplicationError>;
/// Markiert den Job als `done` und setzt `report_uploaded_at` (Zeitpunkt
/// der erfolgreichen Makro-Zuordnung).
async fn mark_done(&self, delivery_id: Uuid) -> Result<(), ApplicationError>;
/// Vermerkt einen fehlgeschlagenen Versuch (`attempts++`, `last_error`,
/// `last_attempt_at = now()`). Lässt `status` unverändert (Resume-Marke).
async fn record_error(
&self,
delivery_id: Uuid,
error: &str,
) -> Result<(), ApplicationError>;
}

View File

@ -0,0 +1,10 @@
//! Port: rendert die Reportdaten zu einem PDF.
use crate::dto::DeliveryReportData;
use crate::error::ApplicationError;
pub trait DeliveryReportRenderer: Send + Sync {
/// Rendert den vollständigen Lieferreport als PDF-Bytes. Rein (kein IO):
/// alle Bild-Bytes liegen bereits im [`DeliveryReportData`].
fn render(&self, data: &DeliveryReportData) -> Result<Vec<u8>, ApplicationError>;
}

View File

@ -0,0 +1,19 @@
//! Port: lädt alle DB-Daten für den PDF-Lieferreport.
use async_trait::async_trait;
use uuid::Uuid;
use crate::dto::DeliveryReportData;
use crate::error::ApplicationError;
#[async_trait]
pub trait DeliveryReportRepository: Send + Sync {
/// Sammelt sämtliche Lieferungs-Daten inkl. beider Audit-Trails
/// (`scan_audit`, `delivery_credit_audit`) — **ohne** Bild-Bytes (die
/// hängt der Use Case aus dem lokalen Speicher an). `None`, wenn die
/// Lieferung nicht existiert.
async fn load(
&self,
delivery_id: Uuid,
) -> Result<Option<DeliveryReportData>, ApplicationError>;
}

View File

@ -0,0 +1,19 @@
//! Port: nimmt das fertige Report-PDF entgegen.
//!
//! Heute: lokaler Datei-Sink (temporäre Ablage). Später: DOCUframe-Sink, der
//! den Blob an ein Makro sendet (Stub vorhanden).
use async_trait::async_trait;
use crate::error::ApplicationError;
#[async_trait]
pub trait DeliveryReportSink: Send + Sync {
/// Übernimmt das fertige PDF. `folder` = Belegnummer (für die Ablage).
/// Gibt eine Referenz zurück (lokal: Dateipfad; DOCUframe: später).
async fn deliver(&self, folder: &str, pdf: Vec<u8>) -> Result<String, ApplicationError>;
/// Räumt alle lokal abgelegten Report-Dateien zu `folder` (Belegnummer)
/// auf — aufgerufen nach erfolgreichem DOCUframe-Upload. Idempotent.
async fn delete(&self, folder: &str) -> Result<(), ApplicationError>;
}

View File

@ -0,0 +1,36 @@
//! Port für die Pro-Lieferung-Service-Werte (Upsert).
//!
//! Die Leseseite (aktuelle Werte pro Lieferung) läuft als Teil des
//! Tour-Aggregats (`TourDetails.delivery_services`), nicht über diesen Port.
use async_trait::async_trait;
use uuid::Uuid;
use holzleitner_domain::DeliveryServiceValue;
use crate::error::ApplicationError;
#[async_trait]
pub trait DeliveryServiceRepository: Send + Sync {
/// Setzt (Upsert) den Wert eines Service für eine Lieferung. Prüft, dass
/// die Lieferung existiert (`NotFound`) und `active` ist (`Validation`).
/// Genau eines von `bool_value`/`numeric_value` ist gesetzt (Use Case
/// stellt das passend zum Service-Typ sicher).
async fn set(
&self,
delivery_id: Uuid,
service_id: Uuid,
bool_value: Option<bool>,
numeric_value: Option<i32>,
author_personalnummer: i64,
author_car_id: Option<Uuid>,
) -> Result<DeliveryServiceValue, ApplicationError>;
/// Entfernt den Wert (Service für diese Lieferung „nicht gesetzt").
/// Nur bei aktiver Lieferung.
async fn delete(
&self,
delivery_id: Uuid,
service_id: Uuid,
) -> Result<(), ApplicationError>;
}

View File

@ -0,0 +1,33 @@
//! Port: Übertragung des Report-PDFs nach DOCUframe.
//!
//! Zwei Schritte, getrennt, damit die Pipeline ihren Fortschritt nach jedem
//! Schritt persistieren kann (und ein Retry den schon erledigten Upload
//! überspringt):
//! 1. [`upload_report_pdf`] — PDF hochladen → `~ObjectID`.
//! 2. [`assign_report`] — DOCUframe-Makro `_SV_assignDeliveryReport`
//! aufrufen, das den Report dem Beleg/Vorgang zuordnet.
//!
//! Konkrete Impl: der GSD-/DOCUframe-Adapter (`GsdService`).
use async_trait::async_trait;
use crate::error::ApplicationError;
#[async_trait]
pub trait DocuframeReportGateway: Send + Sync {
/// Lädt das Report-PDF nach DOCUframe und liefert die `~ObjectID`.
async fn upload_report_pdf(
&self,
belegnummer: &str,
pdf: Vec<u8>,
) -> Result<String, ApplicationError>;
/// Ruft das Makro `_SV_assignDeliveryReport` mit `{objectId, belegnummer}`
/// auf und ordnet den hochgeladenen Report dem Beleg zu. Fehler, wenn das
/// Makro `succeeded != true` liefert oder DOCUframe nicht erreichbar ist.
async fn assign_report(
&self,
object_id: &str,
belegnummer: &str,
) -> Result<(), ApplicationError>;
}

View File

@ -0,0 +1,36 @@
//! Port für das **Provisionieren** von Fahrer-Konten im Identity-Provider
//! (Keycloak) beim ERP-Sync.
//!
//! Wenn der tägliche Touren-Import einen Fahrer (ERP-`Vertreter`, fachlich die
//! Account-/Vertragspartner-Nummer) sieht, soll im Realm ein Login-Konto
//! existieren: Benutzername = Fahrer-/Account-Nummer, ein **temporäres**
//! Passwort, das beim ersten Login zwingend geändert werden muss
//! (Keycloak-Required-Action `UPDATE_PASSWORD`), und die Rolle `driver`.
//!
//! Die konkrete Impl (Keycloak Admin-REST via reqwest) lebt in
//! `holzleitner-infrastructure` und MUSS **idempotent** sein: existiert der
//! User bereits, passiert nichts (kein Passwort-Reset, keine Doppelanlage).
use async_trait::async_trait;
use crate::error::ApplicationError;
/// Ergebnis einer Provisionierung — ob ein Konto **neu** angelegt wurde.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProvisionOutcome {
/// `true` ⇒ Konto wurde in diesem Aufruf erstellt; `false` ⇒ existierte
/// bereits (idempotenter No-Op).
pub created: bool,
}
#[async_trait]
pub trait DriverIdentityProvisioner: Send + Sync {
/// Stellt sicher, dass für `personalnummer` ein Login-Konto existiert.
/// Idempotent. `display_name` ist ein optionaler Anzeigename (z. B.
/// „Fahrer 423") — nur kosmetisch im IdP.
async fn ensure_driver(
&self,
personalnummer: i64,
display_name: Option<&str>,
) -> Result<ProvisionOutcome, ApplicationError>;
}

View File

@ -0,0 +1,24 @@
//! Port für die Lese-Anbindung an das ERP (ERPframe).
//!
//! Liefert die Tagestouren eines Datums als `SyncTourRequest`-DTOs — also
//! genau die Repräsentation, die auch der HTTP-Sync (`POST /sync/tour`)
//! nutzt. Eine `SyncTourRequest` = eine Fahrer-Tour. Die konkrete Impl
//! (MSSQL via tiberius) lebt in `holzleitner-infrastructure`; die
//! Application bleibt frei von DB-Treiber-Details.
use async_trait::async_trait;
use chrono::NaiveDate;
use crate::dto::SyncTourRequest;
use crate::error::ApplicationError;
#[async_trait]
pub trait ErpDeliverySource: Send + Sync {
/// Liest alle Lieferungen des gegebenen Tages aus dem ERP und gruppiert
/// sie zu **einer `SyncTourRequest` pro Fahrer** (driverPersonalnummer).
/// Reine Lese-Operation; schreibt nichts ins ERP zurück.
async fn fetch_tours_for_date(
&self,
date: NaiveDate,
) -> Result<Vec<SyncTourRequest>, ApplicationError>;
}

View File

@ -0,0 +1,64 @@
//! Port für das **Zurückschreiben** eines Lieferabschlusses ins ERP (ERPframe).
//!
//! Gegenstück zum lesenden [`ErpDeliverySource`](super::ErpDeliverySource):
//! wenn eine Lieferung lokal (Postgres) abgeschlossen wurde, spiegelt dieser
//! Port das Ergebnis direkt in die ERPframe-MSSQL-DB. Drei fachliche Effekte
//! (vgl. die Alt-Makros `_web_finishDelivery` / `_removeArticles` /
//! `_addDiscount`):
//!
//! 1. **Entfernte Artikel** — je Belegzeile die Menge auf die tatsächlich
//! ausgelieferte Menge setzen (`required credited`).
//! 2. **Gutschrift** — eine Belegzeile für den Gutschrift-Artikel
//! (`GUTSCHRIFT10`) hinzufügen/aktualisieren.
//! 3. **Liefer-Zeitpunkt** — `_SV_DELIVERY_DELIVERED_AT` + `_SV_DELIVERY_STATE`
//! setzen.
//!
//! Die konkrete Impl (MSSQL via tiberius) lebt in `holzleitner-infrastructure`
//! und MUSS **idempotent** sein: alle Mengen werden absolut gesetzt, die
//! Gutschrift als Upsert geführt — ein erneuter Aufruf (Admin-Retry) verändert
//! das Ergebnis nicht.
use async_trait::async_trait;
use chrono::NaiveDateTime;
use crate::error::ApplicationError;
/// Eine Belegzeile mit ihrer **neuen** (absoluten) Menge.
#[derive(Debug, Clone)]
pub struct ErpLineQuantity {
/// ERP-Position innerhalb des Belegs (`Belegzeilen.BelegzeilenNr`).
pub belegzeilen_nr: i32,
/// Tatsächlich ausgelieferte Menge = `required_quantity credited_quantity`.
/// Wird absolut gesetzt (nicht subtrahiert) → idempotent.
pub delivered_quantity: i32,
}
/// Vollständige Eingabe für das ERP-Rückschreiben eines Abschlusses.
#[derive(Debug, Clone)]
pub struct ErpFinishDeliveryCommand {
/// Beleg-Natural-Key (aus `deliveries.erp_belegart_id`/`erp_belegnummer`).
/// Der Adapter resolved daraus die `Belegkopf.row_id`.
pub belegart_id: i64,
pub belegnummer: String,
/// Liefer-Zeitpunkt (lokale Zeit), wird als ISO-8601 mit `T` geschrieben.
pub delivered_at: NaiveDateTime,
/// Belegzeilen mit ausgelieferten Mengen.
pub lines: Vec<ErpLineQuantity>,
/// Geld-Gutschrift in **Cent** (0 = keine). Der Adapter rechnet daraus die
/// Menge der 10-€-Gutschrift-Einheiten.
pub credit_amount_cents: i64,
/// Code der gewählten Zahlungsmethode (`cash`/`ec_card`/`invoice`). Der
/// Adapter mappt das auf die ERP-Zahlungsbedingung (D16/D53/D10) und setzt
/// `Belegkopf.ZahlungsbedingungId`. `None` ⇒ Zahlungsbedingung bleibt.
pub payment_method_code: Option<String>,
}
#[async_trait]
pub trait ErpDeliveryWriteback: Send + Sync {
/// Schreibt den Lieferabschluss ins ERP zurück (eine MSSQL-Transaktion).
/// Idempotent: erneuter Aufruf mit gleichem Command ⇒ gleicher Endzustand.
async fn finish_delivery(
&self,
cmd: ErpFinishDeliveryCommand,
) -> Result<(), ApplicationError>;
}

View File

@ -6,17 +6,57 @@
//! `holzleitner-infrastructure`.
pub mod account_repository;
pub mod attachment_repository;
pub mod attachment_storage;
pub mod auth_service;
pub mod car_repository;
pub mod delivery_credit_repository;
pub mod delivery_note_repository;
pub mod delivery_report_job_repository;
pub mod delivery_report_renderer;
pub mod delivery_report_repository;
pub mod delivery_report_sink;
pub mod delivery_repository;
pub mod delivery_service_repository;
pub mod docuframe_report_gateway;
pub mod driver_identity_provisioner;
pub mod payment_method_repository;
pub mod delivery_completion_repository;
pub mod erp_delivery_source;
pub mod erp_delivery_writeback;
pub mod scan_repository;
pub mod service_repository;
pub mod signature_storage;
pub mod tour_repository;
pub use account_repository::AccountRepository;
pub use attachment_repository::{
AttachmentLocalRef, AttachmentRef, AttachmentRepository, NewAttachment,
};
pub use attachment_storage::{AttachmentStorage, PreviewImage};
pub use auth_service::{AuthError, AuthService, Claims};
pub use car_repository::CarRepository;
pub use delivery_credit_repository::DeliveryCreditRepository;
pub use delivery_note_repository::DeliveryNoteRepository;
pub use delivery_report_job_repository::{
DeliveryReportJobRepository, ReportJob, ReportJobStatus,
};
pub use delivery_report_renderer::DeliveryReportRenderer;
pub use delivery_report_repository::DeliveryReportRepository;
pub use delivery_report_sink::DeliveryReportSink;
pub use delivery_repository::{DeliveryAction, DeliveryRepository};
pub use delivery_service_repository::DeliveryServiceRepository;
pub use docuframe_report_gateway::DocuframeReportGateway;
pub use driver_identity_provisioner::{DriverIdentityProvisioner, ProvisionOutcome};
pub use delivery_completion_repository::{
CompleteDeliveryInput, DeliveryCompletionRepository, ErpWritebackData, ErpWritebackLine,
};
pub use erp_delivery_source::ErpDeliverySource;
pub use erp_delivery_writeback::{
ErpDeliveryWriteback, ErpFinishDeliveryCommand, ErpLineQuantity,
};
pub use payment_method_repository::PaymentMethodRepository;
pub use scan_repository::{ApplyScanOutcome, ScanRepository};
pub use service_repository::ServiceRepository;
pub use signature_storage::{SignatureRole, SignatureStorage};
pub use tour_repository::TourRepository;

View File

@ -0,0 +1,54 @@
//! Port für Zahlungs-Stammdaten.
//!
//! Im Gegensatz zu `cars` sind Zahlungsmethoden **global** — sie hängen
//! nicht an einem Account, sondern gelten für die ganze App. Daher
//! keine `account_id`-Parameter.
//!
//! Lösch-Verhalten: `delete` wirft `ApplicationError::Validation`, wenn
//! eine Lieferung die Methode noch referenziert (entsprechend dem
//! Datenbank-`ON DELETE RESTRICT`). Für „weiches Entfernen" gibt es
//! das `active`-Flag — wird per `update(active: Some(false))` gesetzt.
use async_trait::async_trait;
use uuid::Uuid;
use holzleitner_domain::PaymentMethod;
use crate::error::ApplicationError;
#[async_trait]
pub trait PaymentMethodRepository: Send + Sync {
/// Listet alle Methoden. `include_inactive = false` filtert
/// deaktivierte raus (Default für die App-UI).
async fn list(
&self,
include_inactive: bool,
) -> Result<Vec<PaymentMethod>, ApplicationError>;
async fn find_by_id(
&self,
id: Uuid,
) -> Result<Option<PaymentMethod>, ApplicationError>;
/// Legt eine neue Methode an. `code` muss eindeutig sein —
/// Duplikat → `Conflict("…already exists")` (HTTP 409).
async fn create(
&self,
code: &str,
name: &str,
) -> Result<PaymentMethod, ApplicationError>;
/// Optional-Patch. Beide `None`s = no-op.
async fn update(
&self,
id: Uuid,
name: Option<&str>,
active: Option<bool>,
) -> Result<PaymentMethod, ApplicationError>;
/// Hart löschen. Wirft `Conflict("payment method is in use")`
/// (→ HTTP 409), wenn noch Lieferungen darauf zeigen — der
/// FK-RESTRICT regelt das auf DB-Ebene, der Adapter übersetzt den
/// Pg-Fehler.
async fn delete(&self, id: Uuid) -> Result<(), ApplicationError>;
}

View File

@ -0,0 +1,44 @@
//! Port für Service-Stammdaten (admin-konfigurierbar, global — keine
//! Account-Isolation, Muster wie `PaymentMethodRepository`).
use async_trait::async_trait;
use uuid::Uuid;
use holzleitner_domain::{Service, ServiceKind};
use crate::error::ApplicationError;
#[async_trait]
pub trait ServiceRepository: Send + Sync {
/// Listet Services, sortiert nach `sort_order`. `include_inactive = false`
/// filtert deaktivierte raus (Default für die App).
async fn list(&self, include_inactive: bool) -> Result<Vec<Service>, ApplicationError>;
async fn find_by_id(&self, id: Uuid) -> Result<Option<Service>, ApplicationError>;
/// Legt einen Service an. `key`-Duplikat → `Conflict`.
async fn create(
&self,
key: &str,
name: &str,
kind: ServiceKind,
min_value: Option<i32>,
max_value: Option<i32>,
sort_order: i32,
) -> Result<Service, ApplicationError>;
/// Teil-Update. `kind` ist nicht änderbar.
async fn update(
&self,
id: Uuid,
name: Option<&str>,
min_value: Option<i32>,
max_value: Option<i32>,
active: Option<bool>,
sort_order: Option<i32>,
) -> Result<Service, ApplicationError>;
/// Hart löschen. `Conflict`, wenn noch eine Lieferung den Service
/// referenziert (FK `ON DELETE RESTRICT`) — dann lieber deaktivieren.
async fn delete(&self, id: Uuid) -> Result<(), ApplicationError>;
}

View File

@ -0,0 +1,54 @@
//! Port für den Unterschriften-Speicher.
//!
//! Im Gegensatz zu Notiz-Bildern (die nach DOCUframe gehen) liegen
//! Unterschriften bewusst **lokal im Backend-Server** — ein einfacher
//! Datei-Speicher reicht, und die Daten verlassen die Maschine nicht.
//!
//! Die konkrete Impl (lokales Dateisystem) lebt in
//! `holzleitner-infrastructure`. Der Use Case erhält eine relative
//! Referenz zurück, die in `delivery_completions` persistiert wird.
use async_trait::async_trait;
use uuid::Uuid;
use crate::error::ApplicationError;
/// Wer hat unterschrieben — bestimmt den Dateinamen.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SignatureRole {
Customer,
Driver,
}
impl SignatureRole {
pub fn as_str(self) -> &'static str {
match self {
SignatureRole::Customer => "customer",
SignatureRole::Driver => "driver",
}
}
}
#[async_trait]
pub trait SignatureStorage: Send + Sync {
/// Speichert eine Unterschrift (PNG-Bytes) für eine Lieferung+Rolle und
/// liefert die persistente, relative Referenz (Dateiname) zurück.
/// Deterministisch über `delivery_id`+`role` — ein Retry überschreibt
/// dieselbe Datei statt Müll anzuhäufen.
async fn save(
&self,
delivery_id: Uuid,
role: SignatureRole,
bytes: Vec<u8>,
) -> Result<String, ApplicationError>;
/// Lädt die gespeicherten PNG-Bytes einer Unterschrift über ihre relative
/// Referenz (Dateiname, wie von [`save`](Self::save) geliefert) — fürs
/// Einbetten in den PDF-Report. `None`, wenn die Datei fehlt.
async fn load(&self, reference: &str) -> Result<Option<Vec<u8>>, ApplicationError>;
/// Löscht beide Unterschriften (Kunde + Fahrer) einer Lieferung — Aufräumen
/// nach erfolgreichem Report-Upload (die Unterschriften stecken dann
/// eingebettet im PDF in DOCUframe). Idempotent (fehlende Datei = ok).
async fn delete_for_delivery(&self, delivery_id: Uuid) -> Result<(), ApplicationError>;
}

View File

@ -49,4 +49,10 @@ pub trait TourRepository: Send + Sync {
tour_id: Uuid,
delivery_ids: &[Uuid],
) -> Result<Vec<DeliveryOrderEntry>, ApplicationError>;
/// **DEV-ONLY**: Löscht alle Touren (und per FK-Cascade alle Lieferungen,
/// Positionen, Scans, Abschlüsse, Gutschriften, Notizen). Dient dem
/// Dev-Resync, der die Postgres-Daten vor einem frischen Import platt
/// macht. Gibt die Anzahl gelöschter Touren zurück.
async fn delete_all_tours(&self) -> Result<u64, ApplicationError>;
}

View File

@ -0,0 +1,85 @@
use std::sync::Arc;
use uuid::Uuid;
use holzleitner_domain::DeliveryCredit;
use crate::dto::{CreditAction, DeliveryCreditEventRequest};
use crate::error::ApplicationError;
use crate::ports::{CarRepository, DeliveryCreditRepository};
/// Obergrenze der Betrags-Gutschrift in Cent (150 €).
const MAX_CREDIT_CENTS: i64 = 15_000;
/// Wendet ein Gutschrift-Ereignis (`set`/`remove`) auf eine Lieferung an.
///
/// Validierung (fachlich, ohne DB):
/// * `Set`: Betrag Pflicht, `0 < amount ≤ 150 €` (beliebiger Betrag, keine
/// Schrittweite); Begründung Pflicht (nicht leer).
/// * `author_car_id` muss — falls gesetzt — zum Account gehören.
///
/// Den `active`-Check der Lieferung und die Idempotenz (`client_event_id`)
/// übernimmt das Repository mit der gelockten Zeile.
pub struct ApplyDeliveryCreditEventUseCase {
repository: Arc<dyn DeliveryCreditRepository>,
cars: Arc<dyn CarRepository>,
}
impl ApplyDeliveryCreditEventUseCase {
pub fn new(
repository: Arc<dyn DeliveryCreditRepository>,
cars: Arc<dyn CarRepository>,
) -> Self {
Self { repository, cars }
}
pub async fn execute(
&self,
delivery_id: Uuid,
author_personalnummer: i64,
request: DeliveryCreditEventRequest,
) -> Result<Option<DeliveryCredit>, ApplicationError> {
if let Some(car_id) = request.author_car_id {
self.cars
.assert_owned_by_account(&[car_id], author_personalnummer)
.await?;
}
let (amount_cents, reason) = match request.action {
CreditAction::Set => {
let amount = request.amount_cents.ok_or_else(|| {
ApplicationError::Validation("amount_cents required for set".into())
})?;
if amount <= 0 || amount > MAX_CREDIT_CENTS {
return Err(ApplicationError::Validation(format!(
"amount_cents must be in (0, {MAX_CREDIT_CENTS}]"
)));
}
let reason = request
.reason
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
ApplicationError::Validation("reason required for set".into())
})?
.to_owned();
(amount, Some(reason))
}
// Remove: Betrag/Grund irrelevant.
CreditAction::Remove => (0, None),
};
self.repository
.apply_event(
delivery_id,
request.client_event_id,
request.action,
amount_cents,
reason,
author_personalnummer,
request.author_car_id,
)
.await
}
}

View File

@ -99,8 +99,21 @@ impl ApplyScansUseCase {
}
/// Validiert Pflichtfelder ohne DB-Aufruf. Liefert `Some(reason)`,
/// wenn das Event verworfen werden soll.
/// wenn das Event verworfen werden soll. Mengen- und Status-abhängige
/// Bounds (z. B. `credited + quantity <= required`, scannbar ⇒ done,
/// Lieferung aktiv) prüft erst das Repository mit dem gelockten Item.
fn pre_validate(event: &ScanEvent) -> Option<String> {
// Eine gesetzte Menge muss positiv sein — und ist nur für die
// Mengen-Gutschrift (Remove/Unremove) überhaupt sinnvoll.
if let Some(q) = event.quantity {
match event.action {
AuditAction::Remove | AuditAction::Unremove if q <= 0 => {
return Some("quantity must be > 0".into());
}
_ => {}
}
}
match event.action {
AuditAction::Hold | AuditAction::Remove => {
let trimmed = event.reason.as_deref().map(str::trim).unwrap_or("");
@ -113,6 +126,9 @@ fn pre_validate(event: &ScanEvent) -> Option<String> {
None
}
}
AuditAction::Scan | AuditAction::Unscan | AuditAction::Unhold => None,
AuditAction::Scan
| AuditAction::Unscan
| AuditAction::Unhold
| AuditAction::Unremove => None,
}
}

View File

@ -0,0 +1,115 @@
use std::sync::Arc;
use uuid::Uuid;
use holzleitner_domain::Delivery;
use crate::dto::CompleteDeliveryAcknowledgements;
use crate::error::ApplicationError;
use crate::ports::{
CarRepository, CompleteDeliveryInput, DeliveryCompletionRepository, SignatureRole,
SignatureStorage,
};
use crate::usecases::PushCompletionToErpUseCase;
/// Schließt eine Lieferung ab: speichert beide Unterschriften lokal und
/// schreibt — atomar im Repository — die Abschluss-Zeile + den Statuswechsel
/// auf `completed`.
///
/// Reihenfolge bewusst: erst die fachlichen Vor-Prüfungen ohne DB, dann die
/// Dateien schreiben, dann das Repository (das die DB-abhängigen Gates unter
/// 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.
pub struct CompleteDeliveryUseCase {
repository: Arc<dyn DeliveryCompletionRepository>,
signatures: Arc<dyn SignatureStorage>,
cars: Arc<dyn CarRepository>,
/// Optionales ERP-Rückschreiben. `None` ⇒ rein lokaler Abschluss
/// (ERP_WRITEBACK_ENABLED=false / Dev / Seed-Daten ohne ERP-Beleg).
erp_push: Option<Arc<PushCompletionToErpUseCase>>,
}
impl CompleteDeliveryUseCase {
pub fn new(
repository: Arc<dyn DeliveryCompletionRepository>,
signatures: Arc<dyn SignatureStorage>,
cars: Arc<dyn CarRepository>,
erp_push: Option<Arc<PushCompletionToErpUseCase>>,
) -> Self {
Self {
repository,
signatures,
cars,
erp_push,
}
}
pub async fn execute(
&self,
delivery_id: Uuid,
author_personalnummer: i64,
acknowledgements: CompleteDeliveryAcknowledgements,
customer_signature_png: Vec<u8>,
driver_signature_png: Vec<u8>,
) -> Result<Delivery, ApplicationError> {
// --- Vor-Prüfungen ohne DB ----------------------------------------
if !acknowledgements.receipt_confirmed {
return Err(ApplicationError::Validation(
"receipt must be confirmed before completion".into(),
));
}
if customer_signature_png.is_empty() {
return Err(ApplicationError::Validation(
"customer signature is required".into(),
));
}
if driver_signature_png.is_empty() {
return Err(ApplicationError::Validation(
"driver signature is required".into(),
));
}
if let Some(car_id) = acknowledgements.author_car_id {
self.cars
.assert_owned_by_account(&[car_id], author_personalnummer)
.await?;
}
// --- Signaturen lokal speichern -----------------------------------
let customer_signature_path = self
.signatures
.save(delivery_id, SignatureRole::Customer, customer_signature_png)
.await?;
let driver_signature_path = self
.signatures
.save(delivery_id, SignatureRole::Driver, driver_signature_png)
.await?;
// --- Atomarer Abschluss im Repository -----------------------------
let delivery = self
.repository
.complete(CompleteDeliveryInput {
delivery_id,
customer_signature_path,
driver_signature_path,
receipt_confirmed: acknowledgements.receipt_confirmed,
notes_acknowledged: acknowledgements.notes_acknowledged,
acknowledged_note_ids: acknowledgements.acknowledged_note_ids,
payment_collected: acknowledgements.payment_collected,
payment_method_id: acknowledgements.payment_method_id,
completed_by_personalnummer: author_personalnummer,
completed_by_car_id: acknowledgements.author_car_id,
})
.await?;
// --- ERP-Rückschreiben (optional, nach lokalem Commit) ------------
// Idempotent → ein Fehler hier lässt den lokalen Abschluss bestehen;
// der Aufrufer bekommt den Fehler (502) und kann via Admin-Endpunkt
// `POST /admin/push-completion` erneut pushen.
if let Some(push) = &self.erp_push {
push.execute(delivery_id).await?;
}
Ok(delivery)
}
}

View File

@ -55,6 +55,8 @@ impl CreateDeliveryNoteUseCase {
request.author_car_id,
text,
image,
request.credit_delivery_item_id,
request.is_amount_credit_note,
)
.await
}

View File

@ -0,0 +1,24 @@
use std::sync::Arc;
use uuid::Uuid;
use crate::error::ApplicationError;
use crate::ports::DeliveryNoteRepository;
/// Löscht eine Notiz. `NotFound`, wenn keine Zeile betroffen war.
///
/// Berechtigung: keine Autor-Prüfung (geteilter Account) — analog zu
/// [`super::update_delivery_note::UpdateDeliveryNoteUseCase`].
pub struct DeleteDeliveryNoteUseCase {
repository: Arc<dyn DeliveryNoteRepository>,
}
impl DeleteDeliveryNoteUseCase {
pub fn new(repository: Arc<dyn DeliveryNoteRepository>) -> Self {
Self { repository }
}
pub async fn execute(&self, note_id: Uuid) -> Result<(), ApplicationError> {
self.repository.delete(note_id).await
}
}

View File

@ -0,0 +1,37 @@
use std::sync::Arc;
use chrono::NaiveDate;
use crate::error::ApplicationError;
use crate::ports::TourRepository;
use crate::usecases::{ImportErpToursUseCase, ImportSummary};
/// **DEV-ONLY**: „Überschreibender" Sync für die lokale Entwicklung.
///
/// Anders als der produktive Import (idempotenter Upsert, der den Scan-/
/// Abschluss-Status bewusst erhält) macht dieser Use Case die Postgres-
/// Tourdaten zuerst **platt** (`delete_all_tours` → FK-Cascade) und importiert
/// dann frisch aus dem ERP. So liefert ein wiederholter Sync desselben Tages in
/// Dev garantiert einen sauberen Stand — ohne Reste aus vorherigen
/// Abschluss-Tests (Status `completed`, Gutschrift-Zeilen, Scans …).
///
/// In Produktion wird das **nicht** verwendet: dort läuft der Sync einmal
/// täglich für den Folgetag (zentral geplante, frische Belege).
pub struct DevResyncToursUseCase {
tours: Arc<dyn TourRepository>,
import: Arc<ImportErpToursUseCase>,
}
impl DevResyncToursUseCase {
pub fn new(tours: Arc<dyn TourRepository>, import: Arc<ImportErpToursUseCase>) -> Self {
Self { tours, import }
}
/// Wischt alle Tourdaten und importiert das Datum neu. Gibt die
/// Import-Zusammenfassung zurück. (Logging übernimmt die API-Schicht.)
pub async fn execute(&self, date: NaiveDate) -> Result<ImportSummary, ApplicationError> {
let _deleted = self.tours.delete_all_tours().await?;
let summary = self.import.execute(date).await?;
Ok(summary)
}
}

View File

@ -0,0 +1,92 @@
use std::sync::Arc;
use uuid::Uuid;
use crate::error::ApplicationError;
use crate::ports::{
AttachmentStorage, DeliveryReportRenderer, DeliveryReportRepository, DeliveryReportSink,
SignatureStorage,
};
/// Erzeugt den PDF-Lieferreport: lädt alle Daten + Audit-Trails, hängt die
/// Bild-Bytes (Unterschriften, Foto-Notizen) aus dem lokalen Speicher an,
/// rendert das PDF und übergibt es dem Sink (lokal ablegen / später DOCUframe).
///
/// Wird sowohl beim Lieferabschluss (best-effort) als auch vom Dev-Endpoint
/// genutzt. Gibt die Sink-Referenz (z. B. den Dateipfad) zurück.
pub struct GenerateDeliveryReportUseCase {
repo: Arc<dyn DeliveryReportRepository>,
renderer: Arc<dyn DeliveryReportRenderer>,
sink: Arc<dyn DeliveryReportSink>,
signatures: Arc<dyn SignatureStorage>,
attachments: Arc<dyn AttachmentStorage>,
}
impl GenerateDeliveryReportUseCase {
pub fn new(
repo: Arc<dyn DeliveryReportRepository>,
renderer: Arc<dyn DeliveryReportRenderer>,
sink: Arc<dyn DeliveryReportSink>,
signatures: Arc<dyn SignatureStorage>,
attachments: Arc<dyn AttachmentStorage>,
) -> Self {
Self {
repo,
renderer,
sink,
signatures,
attachments,
}
}
/// Lädt die Daten, bettet die lokalen Bild-/Signatur-Bytes ein und rendert
/// das PDF **in-memory**. Liefert `(Belegnummer, PDF)`. Wird vom Dev-Sink
/// und von der DOCUframe-Upload-Pipeline genutzt.
pub async fn render_pdf(
&self,
delivery_id: Uuid,
) -> Result<(String, Vec<u8>), ApplicationError> {
let mut data = self
.repo
.load(delivery_id)
.await?
.ok_or(ApplicationError::NotFound)?;
// Unterschriften-Bytes anhängen (best-effort — fehlt eine Datei,
// bleibt das Bild im Report einfach weg).
if let Some(completion) = &data.completion {
data.customer_signature_png = self
.signatures
.load(&completion.customer_signature_path)
.await
.ok()
.flatten();
data.driver_signature_png = self
.signatures
.load(&completion.driver_signature_path)
.await
.ok()
.flatten();
}
// Anhang-Bytes anhängen (best-effort).
for att in data.attachments.iter_mut() {
if let Ok(img) = self
.attachments
.download_preview(&att.reference, "", "1")
.await
{
att.bytes = Some(img.bytes);
}
}
let pdf = self.renderer.render(&data)?;
Ok((data.belegnummer, pdf))
}
pub async fn execute(&self, delivery_id: Uuid) -> Result<String, ApplicationError> {
let (belegnummer, pdf) = self.render_pdf(delivery_id).await?;
let reference = self.sink.deliver(&belegnummer, pdf).await?;
Ok(reference)
}
}

View File

@ -0,0 +1,43 @@
use std::sync::Arc;
use uuid::Uuid;
use crate::error::ApplicationError;
use crate::ports::{AttachmentRepository, AttachmentStorage, PreviewImage};
/// Lädt ein gerendertes Vorschaubild zu einem Attachment.
///
/// Löst unsere Attachment-Id zur DOCUframe-`~ObjectID` auf und holt darüber
/// die Bytes aus dem Speicher. `NotFound`, wenn die Id unbekannt ist.
pub struct GetAttachmentPreviewUseCase {
attachments: Arc<dyn AttachmentRepository>,
storage: Arc<dyn AttachmentStorage>,
}
impl GetAttachmentPreviewUseCase {
pub fn new(
attachments: Arc<dyn AttachmentRepository>,
storage: Arc<dyn AttachmentStorage>,
) -> Self {
Self {
attachments,
storage,
}
}
pub async fn execute(
&self,
id: Uuid,
parameters: String,
page: String,
) -> Result<PreviewImage, ApplicationError> {
let attachment = self
.attachments
.get(id)
.await?
.ok_or(ApplicationError::NotFound)?;
self.storage
.download_preview(&attachment.docuframe_object_id, &parameters, &page)
.await
}
}

View File

@ -0,0 +1,110 @@
use std::sync::Arc;
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use crate::error::ApplicationError;
use crate::ports::{DriverIdentityProvisioner, ErpDeliverySource};
use crate::usecases::SyncTourUseCase;
/// Ergebnis eines Import-Laufs — pro Fahrer-Tour Erfolg/Fehler getrennt,
/// damit ein einzelner kaputter Beleg nicht den ganzen Tag blockiert.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ImportSummary {
pub date: NaiveDate,
pub tours_total: usize,
pub tours_ok: usize,
pub tours_failed: usize,
/// Fehlertexte je fehlgeschlagener Fahrer-Tour (z. B. unbekannter Fahrer
/// → FK auf `accounts`, oder Validierungsfehler).
pub errors: Vec<String>,
/// Anzahl der **neu** im Identity-Provider (Keycloak) angelegten
/// Fahrer-Konten in diesem Lauf (0, wenn Provisionierung deaktiviert ist
/// oder alle Konten bereits existierten).
#[serde(default)]
pub drivers_provisioned: usize,
/// Fehlertexte der Konto-Provisionierung (Keycloak). Best-effort: ein
/// Fehler hier blockiert den Touren-Import **nicht**.
#[serde(default)]
pub provisioning_errors: Vec<String>,
}
/// Zieht die Tagestouren eines Datums aus dem ERP und schreibt sie über den
/// **bestehenden** Sync-Pfad (`SyncTourUseCase` → `upsert_from_sync`) in unser
/// Postgres. Damit teilt der Import dieselbe Validierung + Upsert-Logik wie der
/// HTTP-Endpoint `POST /sync/tour` — eine Wahrheit, kein zweiter Schreibweg.
///
/// Fehlertoleranz: jede Fahrer-Tour wird einzeln verarbeitet. Schlägt eine fehl
/// (häufigster Fall: `Vertreter` ist kein angelegter Account → FK-Fehler), wird
/// sie geloggt + übersprungen, der Rest läuft weiter.
pub struct ImportErpToursUseCase {
source: Arc<dyn ErpDeliverySource>,
sync_tour: Arc<SyncTourUseCase>,
/// Optionaler Identity-Provisioner (Keycloak). `None` ⇒ Konto-Anlage
/// deaktiviert (`KEYCLOAK_PROVISIONING_ENABLED=false`).
provisioner: Option<Arc<dyn DriverIdentityProvisioner>>,
}
impl ImportErpToursUseCase {
pub fn new(
source: Arc<dyn ErpDeliverySource>,
sync_tour: Arc<SyncTourUseCase>,
provisioner: Option<Arc<dyn DriverIdentityProvisioner>>,
) -> Self {
Self {
source,
sync_tour,
provisioner,
}
}
pub async fn execute(&self, date: NaiveDate) -> Result<ImportSummary, ApplicationError> {
let tours = self.source.fetch_tours_for_date(date).await?;
let tours_total = tours.len();
let mut tours_ok = 0usize;
let mut errors: Vec<String> = Vec::new();
let mut drivers_provisioned = 0usize;
let mut provisioning_errors: Vec<String> = Vec::new();
for request in tours {
let driver = request.driver_personalnummer;
let deliveries = request.deliveries.len();
match self.sync_tour.execute(request).await {
Ok(_) => {
tours_ok += 1;
// Fahrer-Konto im IdP sicherstellen (best-effort): ein
// Fehler hier wird protokolliert, blockiert aber den Import
// nicht — Logistik geht vor.
if let Some(provisioner) = &self.provisioner {
let name = format!("Fahrer {driver}");
match provisioner.ensure_driver(driver, Some(&name)).await {
Ok(outcome) => {
if outcome.created {
drivers_provisioned += 1;
}
}
Err(e) => {
provisioning_errors.push(format!("driver {driver}: {e}"));
}
}
}
}
Err(e) => {
errors.push(format!("driver {driver} ({deliveries} Lieferungen): {e}"));
}
}
}
Ok(ImportSummary {
date,
tours_total,
tours_ok,
tours_failed: errors.len(),
errors,
drivers_provisioned,
provisioning_errors,
})
}
}

View File

@ -0,0 +1,34 @@
//! Use Case: Belegnummern ausgelieferter (abgeschlossener) Lieferungen
//! auflisten, **deren Liefermail noch nicht versendet wurde**.
//!
//! Reine Lese-Operation für den Admin-/Betriebs-Endpunkt + den externen
//! Mailclient. „Ausgeliefert" = es existiert eine Abschluss-Zeile
//! (`delivery_completions`); „offen" = `mail_sent_at IS NULL`. Optionaler
//! Tagesfilter über den Abschluss-Zeitpunkt (`completed_at`, Zeitzone
//! Europe/Berlin); `None` ⇒ alle offenen Belege. TZ-/Filter-Logik im Repository.
use std::sync::Arc;
use chrono::NaiveDate;
use crate::error::ApplicationError;
use crate::ports::DeliveryCompletionRepository;
pub struct ListDeliveredBelegnummernUseCase {
completions: Arc<dyn DeliveryCompletionRepository>,
}
impl ListDeliveredBelegnummernUseCase {
pub fn new(completions: Arc<dyn DeliveryCompletionRepository>) -> Self {
Self { completions }
}
/// Liefert die Belegnummern offener (noch nicht versendeter) Lieferungen.
/// `Some(day)` ⇒ nur Abschlüsse dieses Tages, `None` ⇒ alle offenen.
pub async fn execute(
&self,
day: Option<NaiveDate>,
) -> Result<Vec<String>, ApplicationError> {
self.completions.list_delivered_belegnummern(day).await
}
}

View File

@ -1,6 +1,6 @@
use std::sync::Arc;
use chrono::Utc;
use chrono::{NaiveDate, Utc};
use crate::dto::TourSummary;
use crate::error::ApplicationError;
@ -9,17 +9,27 @@ use crate::ports::TourRepository;
/// Liste der heutigen Touren des angemeldeten Fahrers. Das "heute"
/// liegt **bewusst im Backend**: die App-Uhr ist nicht autoritativ
/// (Zeitzone, Falsch-Stand, Manipulation).
///
/// `today_override` ist eine **DEV-ONLY**-Hintertür zum Testen mit
/// historischen/importierten Touren: ist sie gesetzt, wird statt der echten
/// Uhr dieses Datum verwendet. In Produktion `None`.
pub struct ListMyToursTodayUseCase {
repository: Arc<dyn TourRepository>,
today_override: Option<NaiveDate>,
}
impl ListMyToursTodayUseCase {
pub fn new(repository: Arc<dyn TourRepository>) -> Self {
Self { repository }
pub fn new(repository: Arc<dyn TourRepository>, today_override: Option<NaiveDate>) -> Self {
Self {
repository,
today_override,
}
}
pub async fn execute(&self, personalnummer: i64) -> Result<Vec<TourSummary>, ApplicationError> {
let today = Utc::now().date_naive();
let today = self
.today_override
.unwrap_or_else(|| Utc::now().date_naive());
self.repository
.find_today_for_driver(personalnummer, today)
.await

View File

@ -0,0 +1,41 @@
//! Use Case: Liefermails von Belegnummern als **versendet** markieren.
//!
//! Wird vom externen Mailclient aufgerufen, NACHDEM ERPframe die Mails für die
//! Belege erfolgreich verschickt hat. Setzt `delivery_completions.mail_sent_at`
//! (nur wo noch NULL → idempotent) und sorgt damit dafür, dass dieselben Belege
//! beim nächsten Poll nicht erneut zurückgegeben werden (server-seitiges Dedup).
use std::sync::Arc;
use crate::error::ApplicationError;
use crate::ports::DeliveryCompletionRepository;
pub struct MarkMailSentUseCase {
completions: Arc<dyn DeliveryCompletionRepository>,
}
impl MarkMailSentUseCase {
pub fn new(completions: Arc<dyn DeliveryCompletionRepository>) -> Self {
Self { completions }
}
/// Markiert die angegebenen Belegnummern als mail-versendet und liefert die
/// Anzahl frisch markierter (vorher offener) Belege zurück. Leere Eingabe
/// ⇒ 0, ohne DB-Zugriff.
pub async fn execute(
&self,
belegnummern: Vec<String>,
) -> Result<u64, ApplicationError> {
self.completions.mark_mail_sent(&belegnummern).await
}
/// **DEV-ONLY**: hebt die Markierung wieder auf (`mail_sent_at = NULL`),
/// sodass die Belege erneut als offen erscheinen. Anzahl zurückgesetzter
/// Belege als Rückgabe.
pub async fn unmark(
&self,
belegnummern: Vec<String>,
) -> Result<u64, ApplicationError> {
self.completions.unmark_mail_sent(&belegnummern).await
}
}

View File

@ -6,23 +6,59 @@
//! entgegen und orchestrieren damit das Domänenmodell.
pub mod apply_delivery_action;
pub mod apply_delivery_credit_event;
pub mod apply_scans;
pub mod cars;
pub mod complete_delivery;
pub mod create_delivery_note;
pub mod delete_delivery_note;
pub mod dev_resync_tours;
pub mod generate_delivery_report;
pub mod get_account;
pub mod get_attachment_preview;
pub mod get_tour;
pub mod import_erp_tours;
pub mod list_delivered_belegnummern;
pub mod list_my_tours_today;
pub mod mark_mail_sent;
pub mod payment_methods;
pub mod process_delivery_report;
pub mod push_completion_to_erp;
pub mod services;
pub mod set_delivery_order;
pub mod sync_tour;
pub mod update_delivery_note;
pub mod upload_delivery_note_image;
pub use apply_delivery_action::ApplyDeliveryActionUseCase;
pub use apply_delivery_credit_event::ApplyDeliveryCreditEventUseCase;
pub use apply_scans::ApplyScansUseCase;
pub use cars::{
AssignCarToDeliveryUseCase, CreateMyCarUseCase, ListMyCarsUseCase, UpdateMyCarUseCase,
};
pub use complete_delivery::CompleteDeliveryUseCase;
pub use create_delivery_note::CreateDeliveryNoteUseCase;
pub use dev_resync_tours::DevResyncToursUseCase;
pub use generate_delivery_report::GenerateDeliveryReportUseCase;
pub use delete_delivery_note::DeleteDeliveryNoteUseCase;
pub use get_account::GetAccountUseCase;
pub use get_attachment_preview::GetAttachmentPreviewUseCase;
pub use get_tour::GetTourUseCase;
pub use import_erp_tours::{ImportErpToursUseCase, ImportSummary};
pub use list_delivered_belegnummern::ListDeliveredBelegnummernUseCase;
pub use list_my_tours_today::ListMyToursTodayUseCase;
pub use mark_mail_sent::MarkMailSentUseCase;
pub use payment_methods::{
CreatePaymentMethodUseCase, DeletePaymentMethodUseCase, ListPaymentMethodsUseCase,
UpdatePaymentMethodUseCase,
};
pub use process_delivery_report::ProcessDeliveryReportUseCase;
pub use push_completion_to_erp::PushCompletionToErpUseCase;
pub use services::{
CreateServiceUseCase, DeleteDeliveryServiceUseCase, DeleteServiceUseCase,
ListServicesUseCase, SetDeliveryServiceUseCase, UpdateServiceUseCase,
};
pub use set_delivery_order::SetDeliveryOrderUseCase;
pub use sync_tour::SyncTourUseCase;
pub use update_delivery_note::UpdateDeliveryNoteUseCase;
pub use upload_delivery_note_image::UploadDeliveryNoteImageUseCase;

View File

@ -0,0 +1,106 @@
//! Use Cases rund um Zahlungs-Stammdaten.
//!
//! Global — keine Account-Isolation, weil Methoden firmenweit gelten.
//! Validierung beschränkt sich auf nicht-leere Strings; Eindeutigkeit
//! des `code` ist DB-Constraint, nicht hier dupliziert.
use std::sync::Arc;
use uuid::Uuid;
use holzleitner_domain::PaymentMethod;
use crate::dto::{CreatePaymentMethodRequest, UpdatePaymentMethodRequest};
use crate::error::ApplicationError;
use crate::ports::PaymentMethodRepository;
pub struct ListPaymentMethodsUseCase {
repository: Arc<dyn PaymentMethodRepository>,
}
impl ListPaymentMethodsUseCase {
pub fn new(repository: Arc<dyn PaymentMethodRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
include_inactive: bool,
) -> Result<Vec<PaymentMethod>, ApplicationError> {
self.repository.list(include_inactive).await
}
}
pub struct CreatePaymentMethodUseCase {
repository: Arc<dyn PaymentMethodRepository>,
}
impl CreatePaymentMethodUseCase {
pub fn new(repository: Arc<dyn PaymentMethodRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
request: CreatePaymentMethodRequest,
) -> Result<PaymentMethod, ApplicationError> {
let code = request.code.trim();
let name = request.name.trim();
if code.is_empty() {
return Err(ApplicationError::Validation(
"code darf nicht leer sein".into(),
));
}
if name.is_empty() {
return Err(ApplicationError::Validation(
"name darf nicht leer sein".into(),
));
}
self.repository.create(code, name).await
}
}
pub struct UpdatePaymentMethodUseCase {
repository: Arc<dyn PaymentMethodRepository>,
}
impl UpdatePaymentMethodUseCase {
pub fn new(repository: Arc<dyn PaymentMethodRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
id: Uuid,
request: UpdatePaymentMethodRequest,
) -> Result<PaymentMethod, ApplicationError> {
if let Some(name) = request.name.as_deref() {
if name.trim().is_empty() {
return Err(ApplicationError::Validation(
"name darf nicht leer sein".into(),
));
}
}
self.repository
.update(
id,
request.name.as_deref().map(str::trim),
request.active,
)
.await
}
}
pub struct DeletePaymentMethodUseCase {
repository: Arc<dyn PaymentMethodRepository>,
}
impl DeletePaymentMethodUseCase {
pub fn new(repository: Arc<dyn PaymentMethodRepository>) -> Self {
Self { repository }
}
pub async fn execute(&self, id: Uuid) -> Result<(), ApplicationError> {
self.repository.delete(id).await
}
}

View File

@ -0,0 +1,122 @@
//! Überträgt den PDF-Lieferreport an DOCUframe — idempotent & resume-fähig.
//!
//! Schritte (Fortschritt nach jedem Schritt hart in `delivery_report_jobs`):
//! 1+2. PDF in-memory rendern → nach DOCUframe hochladen → `~ObjectID` hart
//! speichern (`status = 'uploaded'`). Bei Retry übersprungen, wenn die
//! ObjectId schon vorliegt (kein Doppel-Upload).
//! 3. Makro `_SV_assignDeliveryReport` aufrufen (ordnet Report dem Beleg zu).
//! 4. Erfolg → lokale Dateien aufräumen (Report-PDF, Unterschriften,
//! Bild-Notizen + `deleted_at`), dann `status = 'done'`.
//!
//! Reihenfolge bei Schritt 4: erst aufräumen, dann `done`. Ein Crash dazwischen
//! lässt den Job auf `uploaded` → der Cron ruft das (idempotente) Makro erneut
//! und räumt erneut auf. So bleiben keine verwaisten lokalen Dateien zurück.
//!
//! Fehler in 13 werden im Job vermerkt (`attempts`/`last_error`) und der
//! Status bleibt auf der erreichten Stufe — der Retry-Cron nimmt offene Jobs
//! erneut auf.
use std::sync::Arc;
use uuid::Uuid;
use crate::error::ApplicationError;
use crate::ports::{
AttachmentRepository, AttachmentStorage, DeliveryReportJobRepository, DeliveryReportSink,
DocuframeReportGateway, ReportJobStatus, SignatureStorage,
};
use crate::usecases::GenerateDeliveryReportUseCase;
pub struct ProcessDeliveryReportUseCase {
generate: Arc<GenerateDeliveryReportUseCase>,
jobs: Arc<dyn DeliveryReportJobRepository>,
gateway: Arc<dyn DocuframeReportGateway>,
attachment_repo: Arc<dyn AttachmentRepository>,
attachment_storage: Arc<dyn AttachmentStorage>,
signatures: Arc<dyn SignatureStorage>,
report_sink: Arc<dyn DeliveryReportSink>,
}
impl ProcessDeliveryReportUseCase {
#[allow(clippy::too_many_arguments)]
pub fn new(
generate: Arc<GenerateDeliveryReportUseCase>,
jobs: Arc<dyn DeliveryReportJobRepository>,
gateway: Arc<dyn DocuframeReportGateway>,
attachment_repo: Arc<dyn AttachmentRepository>,
attachment_storage: Arc<dyn AttachmentStorage>,
signatures: Arc<dyn SignatureStorage>,
report_sink: Arc<dyn DeliveryReportSink>,
) -> Self {
Self {
generate,
jobs,
gateway,
attachment_repo,
attachment_storage,
signatures,
report_sink,
}
}
/// Verarbeitet einen Job (anlegen, falls nötig). Fehler werden im Job
/// vermerkt und zusätzlich propagiert (der Aufrufer loggt).
pub async fn execute(&self, delivery_id: Uuid) -> Result<(), ApplicationError> {
match self.run(delivery_id).await {
Ok(()) => Ok(()),
Err(e) => {
// Best-effort: Fehler im Job festhalten (für Cron-Retry/Sicht).
let _ = self.jobs.record_error(delivery_id, &e.to_string()).await;
Err(e)
}
}
}
async fn run(&self, delivery_id: Uuid) -> Result<(), ApplicationError> {
let belegnummer = self
.attachment_repo
.delivery_belegnummer(delivery_id)
.await?
.ok_or(ApplicationError::NotFound)?;
let job = self.jobs.ensure(delivery_id, &belegnummer).await?;
if matches!(job.status, ReportJobStatus::Done) {
return Ok(());
}
// Schritt 1+2: rendern + hochladen (überspringen, wenn schon erledigt).
let object_id = match job.docuframe_object_id {
Some(oid) => oid,
None => {
let (_beleg, pdf) = self.generate.render_pdf(delivery_id).await?;
let oid = self.gateway.upload_report_pdf(&belegnummer, pdf).await?;
self.jobs.set_uploaded(delivery_id, &oid).await?;
oid
}
};
// Schritt 3: Makro-Zuordnung (muss succeeded == true liefern).
self.gateway.assign_report(&object_id, &belegnummer).await?;
// Schritt 4: erst aufräumen, dann als erledigt markieren.
self.cleanup_local(delivery_id, &belegnummer).await;
self.jobs.mark_done(delivery_id).await?;
Ok(())
}
/// Aufräumen nach erfolgreichem Upload — best-effort (Fehler werden
/// geschluckt; der Report liegt bereits sicher in DOCUframe):
/// * lokale Report-PDFs
/// * Unterschriften (Kunde + Fahrer)
/// * Bild-Notizen (Datei löschen + `deleted_at` setzen, Metadaten bleiben)
async fn cleanup_local(&self, delivery_id: Uuid, belegnummer: &str) {
let _ = self.report_sink.delete(belegnummer).await;
let _ = self.signatures.delete_for_delivery(delivery_id).await;
if let Ok(refs) = self.attachment_repo.list_active_for_delivery(delivery_id).await {
for r in refs {
let _ = self.attachment_storage.delete(&r.reference).await;
let _ = self.attachment_repo.mark_deleted(r.id).await;
}
}
}
}

View File

@ -0,0 +1,59 @@
//! Use Case: einen **bereits lokal abgeschlossenen** Lieferabschluss ins ERP
//! zurückschreiben.
//!
//! Liest den aktuellen Postgres-Stand (ausgelieferte Mengen, Geld-Gutschrift,
//! Abschluss-Zeitpunkt) und spiegelt ihn über den `ErpDeliveryWriteback`-Port
//! in die ERPframe-MSSQL-DB. Bewusst **getrennt** vom lokalen Abschluss:
//!
//! * Der normale Pfad ruft diesen Use Case direkt nach erfolgreichem
//! `complete()` auf (Fehler ⇒ 502, lokal bleibt `completed`).
//! * Der Admin-Retry-Endpunkt ruft denselben Use Case erneut — da das
//! Rückschreiben idempotent ist, ist das gefahrlos.
use std::sync::Arc;
use uuid::Uuid;
use crate::error::ApplicationError;
use crate::ports::{
DeliveryCompletionRepository, ErpDeliveryWriteback, ErpFinishDeliveryCommand, ErpLineQuantity,
};
pub struct PushCompletionToErpUseCase {
completions: Arc<dyn DeliveryCompletionRepository>,
erp: Arc<dyn ErpDeliveryWriteback>,
}
impl PushCompletionToErpUseCase {
pub fn new(
completions: Arc<dyn DeliveryCompletionRepository>,
erp: Arc<dyn ErpDeliveryWriteback>,
) -> Self {
Self { completions, erp }
}
/// Schreibt den Abschluss der Lieferung ins ERP zurück. `NotFound`, wenn
/// die Lieferung nicht abgeschlossen ist; sonstige Fehler reichen den
/// MSSQL-/Repository-Fehler durch.
pub async fn execute(&self, delivery_id: Uuid) -> Result<(), ApplicationError> {
let data = self.completions.load_erp_writeback(delivery_id).await?;
let cmd = ErpFinishDeliveryCommand {
belegart_id: data.belegart_id,
belegnummer: data.belegnummer,
delivered_at: data.delivered_at,
lines: data
.lines
.into_iter()
.map(|l| ErpLineQuantity {
belegzeilen_nr: l.belegzeilen_nr,
delivered_quantity: l.delivered_quantity,
})
.collect(),
credit_amount_cents: data.credit_amount_cents,
payment_method_code: data.payment_method_code,
};
self.erp.finish_delivery(cmd).await
}
}

View File

@ -0,0 +1,249 @@
//! Use Cases rund um Services (Stammdaten-CRUD + Pro-Lieferung-Wert).
//!
//! Global — keine Account-Isolation. Eindeutigkeit des `key` ist
//! DB-Constraint; hier nur fachliche Validierung (nicht-leer, kind↔min/max,
//! Wert passend zum Typ + in Grenzen).
use std::sync::Arc;
use uuid::Uuid;
use holzleitner_domain::{DeliveryServiceValue, Service, ServiceKind};
use crate::dto::{CreateServiceRequest, SetDeliveryServiceRequest, UpdateServiceRequest};
use crate::error::ApplicationError;
use crate::ports::{DeliveryServiceRepository, ServiceRepository};
// ─── Stammdaten-CRUD ──────────────────────────────────────────────────────
pub struct ListServicesUseCase {
repository: Arc<dyn ServiceRepository>,
}
impl ListServicesUseCase {
pub fn new(repository: Arc<dyn ServiceRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
include_inactive: bool,
) -> Result<Vec<Service>, ApplicationError> {
self.repository.list(include_inactive).await
}
}
pub struct CreateServiceUseCase {
repository: Arc<dyn ServiceRepository>,
}
impl CreateServiceUseCase {
pub fn new(repository: Arc<dyn ServiceRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
request: CreateServiceRequest,
) -> Result<Service, ApplicationError> {
let key = request.key.trim();
let name = request.name.trim();
if key.is_empty() {
return Err(ApplicationError::Validation("key darf nicht leer sein".into()));
}
if name.is_empty() {
return Err(ApplicationError::Validation("name darf nicht leer sein".into()));
}
// boolean trägt keine Grenzen.
let (min_value, max_value) = match request.kind {
ServiceKind::Boolean => {
if request.min_value.is_some() || request.max_value.is_some() {
return Err(ApplicationError::Validation(
"boolean-Service darf keine min/max-Werte haben".into(),
));
}
(None, None)
}
ServiceKind::Numeric => {
if let (Some(min), Some(max)) = (request.min_value, request.max_value) {
if min > max {
return Err(ApplicationError::Validation(
"min_value darf nicht größer als max_value sein".into(),
));
}
}
(request.min_value, request.max_value)
}
};
self.repository
.create(
key,
name,
request.kind,
min_value,
max_value,
request.sort_order.unwrap_or(0),
)
.await
}
}
pub struct UpdateServiceUseCase {
repository: Arc<dyn ServiceRepository>,
}
impl UpdateServiceUseCase {
pub fn new(repository: Arc<dyn ServiceRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
id: Uuid,
request: UpdateServiceRequest,
) -> Result<Service, ApplicationError> {
if let Some(name) = request.name.as_deref() {
if name.trim().is_empty() {
return Err(ApplicationError::Validation("name darf nicht leer sein".into()));
}
}
if let (Some(min), Some(max)) = (request.min_value, request.max_value) {
if min > max {
return Err(ApplicationError::Validation(
"min_value darf nicht größer als max_value sein".into(),
));
}
}
self.repository
.update(
id,
request.name.as_deref().map(str::trim),
request.min_value,
request.max_value,
request.active,
request.sort_order,
)
.await
}
}
pub struct DeleteServiceUseCase {
repository: Arc<dyn ServiceRepository>,
}
impl DeleteServiceUseCase {
pub fn new(repository: Arc<dyn ServiceRepository>) -> Self {
Self { repository }
}
pub async fn execute(&self, id: Uuid) -> Result<(), ApplicationError> {
self.repository.delete(id).await
}
}
// ─── Pro-Lieferung-Wert ───────────────────────────────────────────────────
pub struct SetDeliveryServiceUseCase {
services: Arc<dyn ServiceRepository>,
delivery_services: Arc<dyn DeliveryServiceRepository>,
}
impl SetDeliveryServiceUseCase {
pub fn new(
services: Arc<dyn ServiceRepository>,
delivery_services: Arc<dyn DeliveryServiceRepository>,
) -> Self {
Self {
services,
delivery_services,
}
}
pub async fn execute(
&self,
delivery_id: Uuid,
service_id: Uuid,
author_personalnummer: i64,
request: SetDeliveryServiceRequest,
) -> Result<DeliveryServiceValue, ApplicationError> {
let service = self
.services
.find_by_id(service_id)
.await?
.ok_or(ApplicationError::NotFound)?;
if !service.active {
return Err(ApplicationError::Validation(
"service is inactive".into(),
));
}
// Wert muss zum Typ passen.
let (bool_value, numeric_value) = match service.kind {
ServiceKind::Boolean => {
let b = request.bool_value.ok_or_else(|| {
ApplicationError::Validation("boolValue required for boolean service".into())
})?;
if request.numeric_value.is_some() {
return Err(ApplicationError::Validation(
"numericValue not allowed for boolean service".into(),
));
}
(Some(b), None)
}
ServiceKind::Numeric => {
let n = request.numeric_value.ok_or_else(|| {
ApplicationError::Validation("numericValue required for numeric service".into())
})?;
if request.bool_value.is_some() {
return Err(ApplicationError::Validation(
"boolValue not allowed for numeric service".into(),
));
}
if let Some(min) = service.min_value {
if n < min {
return Err(ApplicationError::Validation(format!(
"numericValue {n} below min {min}"
)));
}
}
if let Some(max) = service.max_value {
if n > max {
return Err(ApplicationError::Validation(format!(
"numericValue {n} above max {max}"
)));
}
}
(None, Some(n))
}
};
self.delivery_services
.set(
delivery_id,
service_id,
bool_value,
numeric_value,
author_personalnummer,
request.author_car_id,
)
.await
}
}
pub struct DeleteDeliveryServiceUseCase {
delivery_services: Arc<dyn DeliveryServiceRepository>,
}
impl DeleteDeliveryServiceUseCase {
pub fn new(delivery_services: Arc<dyn DeliveryServiceRepository>) -> Self {
Self { delivery_services }
}
pub async fn execute(
&self,
delivery_id: Uuid,
service_id: Uuid,
) -> Result<(), ApplicationError> {
self.delivery_services.delete(delivery_id, service_id).await
}
}

View File

@ -0,0 +1,58 @@
use std::sync::Arc;
use uuid::Uuid;
use holzleitner_domain::DeliveryNote;
use crate::dto::UpdateDeliveryNoteRequest;
use crate::error::ApplicationError;
use crate::ports::DeliveryNoteRepository;
/// Ändert `text` / `image_attachment` einer bestehenden Notiz.
///
/// Validierung wie beim Anlegen: mindestens eines von `text` (nicht-leer
/// nach trim) und `image_attachment` muss gesetzt sein. Autor und
/// `created_at` bleiben unverändert.
///
/// Berechtigung: keine Autor-Prüfung — innerhalb eines (geteilten) Accounts
/// darf jeder Fahrer Notizen pflegen. Das entspricht dem Modell der übrigen
/// Delivery-Aktionen (hold/cancel/complete), die ebenfalls keinen
/// Autor-Bezug erzwingen.
pub struct UpdateDeliveryNoteUseCase {
repository: Arc<dyn DeliveryNoteRepository>,
}
impl UpdateDeliveryNoteUseCase {
pub fn new(repository: Arc<dyn DeliveryNoteRepository>) -> Self {
Self { repository }
}
pub async fn execute(
&self,
note_id: Uuid,
request: UpdateDeliveryNoteRequest,
) -> Result<DeliveryNote, ApplicationError> {
let text = clean(request.text);
let image = clean(request.image_attachment);
if text.is_none() && image.is_none() {
return Err(ApplicationError::Validation(
"notiz braucht text oder image_attachment".into(),
));
}
self.repository.update(note_id, text, image).await
}
}
/// Trim + leerer-String → None.
fn clean(input: Option<String>) -> Option<String> {
input.and_then(|s| {
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
})
}

View File

@ -0,0 +1,129 @@
use std::sync::Arc;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use holzleitner_domain::DeliveryNote;
use crate::error::ApplicationError;
use crate::ports::{
AttachmentRepository, AttachmentStorage, CarRepository, DeliveryNoteRepository, NewAttachment,
};
/// Lädt ein Bild zu einer Lieferung hoch, registriert dessen Metadaten und
/// legt dafür eine Bild-Notiz an.
///
/// Ablauf:
/// 1. Bytes analysieren (Größe, SHA-256, Bildabmessungen).
/// 2. Belegnummer der Lieferung auflösen (= Ordnername im Speicher).
/// 3. Datei lokal ablegen (`<dir>/<Belegnummer>/<datei>`) → Speicher-Referenz.
/// 4. Metadatensatz in `attachments` anlegen → unsere Attachment-Id.
/// 5. Notiz mit `image_attachment = <attachment_id>` anlegen (kein Text).
///
/// Die App referenziert nur die Attachment-Id; der Download-Endpoint löst sie
/// zur Speicher-Referenz auf. (Der DOCUframe-Upload bleibt im `GsdService`
/// erhalten, ist hier aber nicht mehr verdrahtet — Bilder gehen lokal.)
pub struct UploadDeliveryNoteImageUseCase {
storage: Arc<dyn AttachmentStorage>,
attachments: Arc<dyn AttachmentRepository>,
notes: Arc<dyn DeliveryNoteRepository>,
cars: Arc<dyn CarRepository>,
}
impl UploadDeliveryNoteImageUseCase {
pub fn new(
storage: Arc<dyn AttachmentStorage>,
attachments: Arc<dyn AttachmentRepository>,
notes: Arc<dyn DeliveryNoteRepository>,
cars: Arc<dyn CarRepository>,
) -> Self {
Self {
storage,
attachments,
notes,
cars,
}
}
pub async fn execute(
&self,
delivery_id: Uuid,
author_personalnummer: i64,
author_car_id: Option<Uuid>,
filename: String,
mime: String,
bytes: Vec<u8>,
) -> Result<DeliveryNote, ApplicationError> {
if bytes.is_empty() {
return Err(ApplicationError::Validation("leere datei".into()));
}
if let Some(car_id) = author_car_id {
self.cars
.assert_owned_by_account(&[car_id], author_personalnummer)
.await?;
}
// 1. Metadaten aus den Bytes ableiten.
let size_bytes = bytes.len() as i64;
let checksum_sha256 = sha256_hex(&bytes);
let (width, height) = match imagesize::blob_size(&bytes) {
Ok(dim) => (Some(dim.width as i32), Some(dim.height as i32)),
Err(_) => (None, None),
};
// 2. Belegnummer der Lieferung auflösen (= Ordnername im Speicher).
let belegnummer = self
.attachments
.delivery_belegnummer(delivery_id)
.await?
.ok_or(ApplicationError::NotFound)?;
// 3. Bytes lokal ablegen (Ordner = Belegnummer) → Speicher-Referenz.
let storage_reference = self
.storage
.upload(&belegnummer, &filename, &mime, bytes)
.await?;
// 4. Metadatensatz anlegen. `docuframe_object_id` trägt jetzt die
// lokale relative Speicher-Referenz (Spaltenname bleibt vorerst).
let attachment_id = self
.attachments
.create(NewAttachment {
docuframe_object_id: storage_reference,
mime_type: mime,
size_bytes,
filename: Some(filename),
checksum_sha256,
width,
height,
uploaded_by: author_personalnummer,
delivery_id,
})
.await?;
// 5. Bild-Notiz mit Verweis auf den Metadatensatz.
self.notes
.create(
delivery_id,
author_personalnummer,
author_car_id,
None,
Some(attachment_id.to_string()),
None, // Bild-Notiz hat keinen Mengen-Gutschrift-Bezug
false, // und ist keine Betrags-Gutschrift-Notiz
)
.await
}
}
/// SHA-256 der Bytes als Hex-String.
fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}

View File

@ -10,6 +10,11 @@ use super::delivery::ScanStatus;
/// * `Hold` / `Unhold` ändern nur den Status, keine Menge.
/// * `Remove` markiert die Position als entfernt (Status `Removed`,
/// z. B. weil der Kunde sie nicht annimmt).
/// * `Unremove` hebt ein `Remove` wieder auf — die Position landet
/// zurück in `InProgress` (oder `Done`, falls die `scanned_quantity`
/// schon `required_quantity` erreicht hatte). Der ursprüngliche
/// `Remove`-Audit-Eintrag bleibt unangetastet; das `Unremove` erzeugt
/// einen eigenen Audit-Eintrag — die Historie bleibt vollständig.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
@ -19,6 +24,7 @@ pub enum AuditAction {
Hold,
Unhold,
Remove,
Unremove,
}
/// Append-only Audit-Log-Eintrag: jedes Ereignis am Scan-Zustand einer

View File

@ -0,0 +1,66 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Rolle, mit der ein Kontakt-Datensatz an einer Lieferung hängt. Spiegelt
/// die fünf Adress-FKs von `Belegkopf` (bzw. den Umweg über den Kunden):
/// `header` = Belegadresse, `delivery` = Lieferadresse, `billing` =
/// Rechnungsadresse, `contact_person` = Ansprechpartner, `customer_master`
/// = Stammadresse des Kunden über `Kunden.AdressId`.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum ContactRole {
Header,
Delivery,
Billing,
ContactPerson,
CustomerMaster,
}
/// Art eines Kommunikationskanals. `fax` bewusst nicht mitgeführt — in der
/// App nicht verwendet.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum ContactKind {
Phone,
Mobile,
Email,
Web,
}
/// Snapshot eines ERP-Adress-Datensatzes, der zum Zeitpunkt des Tour-Syncs
/// an einer Lieferung hing — Namensblock ohne Anschrift, weil die Adresse
/// ihrerseits schon im Lieferungs-Snapshot steckt (`snap_*`-Spalten). Die
/// eigentlichen Telefonnummern, E-Mails etc. liegen in den
/// zugehörigen [`ContactChannel`]s.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct ContactSource {
pub id: Uuid,
pub delivery_id: Uuid,
pub role: ContactRole,
pub anrede: Option<String>,
pub titel: Option<String>,
pub name1: Option<String>,
pub name2: Option<String>,
pub name3: Option<String>,
pub abteilung: Option<String>,
pub funktion: Option<String>,
}
/// Ein einzelner Kontaktkanal (Telefonnummer / Mobil / E-Mail / Web).
/// Mehrere pro [`ContactSource`] möglich, die `position` hält die
/// 1-basierte ERP-Reihenfolge (`Telefon` → 1, `Telefon2` → 2 usw.) fest,
/// damit der „primäre" Kanal je Art stabil identifizierbar bleibt.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct ContactChannel {
pub id: Uuid,
pub source_id: Uuid,
pub kind: ContactKind,
pub position: i16,
pub value: String,
}

View File

@ -21,7 +21,10 @@ pub enum DeliveryState {
/// Eine einzelne Lieferung an einen Kunden. Aggregat-Wurzel für die
/// Liefer-Items, Notizen und das ggf. zugeordnete Fahrzeug.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
//
// Kein `Eq`-Derive, weil `prepaid_amount: f64` (Float kennt kein Eq —
// NaN-Verhalten). `PartialEq` reicht für unsere Vergleiche.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct Delivery {
@ -58,6 +61,16 @@ pub struct Delivery {
/// Begründung bei `state == Held` oder `state == Canceled`. Beim
/// Resume / Complete wieder `None`.
pub state_reason: Option<String>,
/// Bei Bestellung schon bezahlter Betrag in EUR. `0.0` wenn der
/// Kunde alles bei Lieferung zahlt. Wird vom ERP-Sync gefüllt.
pub prepaid_amount: f64,
/// Für den Restbetrag gewählte Zahlungsart — FK auf `payment_methods`.
/// Vom Kunden bei Bestellung festgelegt, der Fahrer übernimmt nur
/// die Abwicklung. Aktiv-Flag und Anzeige-Name werden über die
/// Stammdaten-Tabelle aufgelöst, nicht hier embeddet.
pub payment_method_id: Uuid,
}
/// Status einer einzelnen Scan-Position innerhalb eines Items.
@ -80,6 +93,11 @@ pub enum ScanStatus {
#[serde(rename_all = "camelCase")]
pub struct ScanState {
pub scanned_quantity: i32,
/// Als Gutschrift entfernte Menge (0..=required_quantity). Eigene
/// Dimension neben `scanned_quantity`: „wie viele Stück dieser Zeile hat
/// der Kunde nicht angenommen". `status == Removed` entspricht
/// `credited_quantity == required_quantity` (ganze Zeile gutgeschrieben).
pub credited_quantity: i32,
pub status: ScanStatus,
/// Grund bei `status == Held` oder `status == Removed`.
pub held_reason: Option<String>,
@ -92,7 +110,8 @@ pub struct ScanState {
///
/// Über die Felder `belegzeilen_nr` und `komponenten_artikel_nr` bleibt
/// die ERP-Herkunft auflösbar.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
// Kein `Eq`-Derive: `unit_price: f64` kennt kein `Eq`. `PartialEq` reicht.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeliveryItem {
@ -103,6 +122,10 @@ pub struct DeliveryItem {
pub required_quantity: i32,
pub warehouse_id: Uuid,
/// Stückpreis (brutto, EUR) aus dem ERP-Sync. Der Warenwert einer
/// Lieferung = Σ `unit_price` × ausgelieferte Menge.
pub unit_price: f64,
/// ERP-Belegzeilen-Nr (Position innerhalb des Belegs).
pub belegzeilen_nr: i32,
@ -110,9 +133,27 @@ pub struct DeliveryItem {
/// Bei regulären Belegzeilen: `None`.
pub komponenten_artikel_nr: Option<String>,
/// Artikelnummer des Oberartikels, zu dem diese Komponente gehört.
/// `None` bei Oberartikeln/regulären Zeilen — die App rückt Komponenten
/// darüber unter ihrem Oberartikel ein.
pub parent_artikel_nr: Option<String>,
pub scan_state: ScanState,
}
impl DeliveryItem {
/// Tatsächlich auszuliefernde Menge = Soll minus Gutschrift. Nie negativ
/// (die Gutschrift ist per Constraint auf `required_quantity` gedeckelt).
pub fn delivered_quantity(&self) -> i32 {
(self.required_quantity - self.scan_state.credited_quantity).max(0)
}
/// Wert der ausgelieferten Menge dieser Position (brutto, EUR).
pub fn line_total(&self) -> f64 {
self.unit_price * self.delivered_quantity() as f64
}
}
/// Notiz an einer Lieferung — frei eingegeben durch den Fahrer.
///
/// Mindestens eines von `text` oder `image_attachment` muss gesetzt
@ -131,5 +172,35 @@ pub struct DeliveryNote {
pub author_personalnummer: i64,
/// Fahrzeug, falls bekannt — nullable bis das Backend Cars verwaltet.
pub author_car_id: Option<Uuid>,
/// Wenn die Notiz als Gutschrift-Grund zu einer Belegzeile angelegt
/// wurde: deren `DeliveryItem`-Id. Erlaubt dem Client, die Notiz beim
/// Zurücknehmen der Gutschrift (Unremove) gezielt wieder zu löschen.
/// `None` bei normalen Text-/Foto-Notizen.
pub credit_delivery_item_id: Option<Uuid>,
/// `true`, wenn die Notiz den Grund einer **Betrags-Gutschrift**
/// (Geld-Nachlass, Lieferungs-Ebene) dokumentiert. Erlaubt dem Client,
/// sie beim Entfernen der Gutschrift gezielt zu löschen.
pub is_amount_credit_note: bool,
/// `true`, wenn die lokale Bilddatei nach erfolgreichem Report-Upload
/// gelöscht wurde (das Bild steckt nun im Lieferbericht in DOCUframe).
/// Read-only; die App zeigt dann statt der Vorschau einen Hinweis.
/// Bei Text-Notizen / vorhandenem Bild: `false`.
#[serde(default)]
pub image_attachment_deleted: bool,
pub created_at: DateTime<Utc>,
}
/// Aktuelle Betrags-Gutschrift einer Lieferung (Geld-Nachlass, unabhängig von
/// Stückzahl). Abgeleitet aus dem jüngsten Ereignis im append-only
/// `delivery_credit_audit`; existiert nur, solange der letzte Stand `set`
/// (und nicht `remove`) ist. `delivery_id` macht den Eintrag — wie eine
/// Notiz — clientseitig per Lieferung join-bar.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeliveryCredit {
pub delivery_id: Uuid,
/// Gutschrift-Betrag in Cent (> 0, ≤ 15000).
pub amount_cents: i64,
pub reason: String,
}

View File

@ -20,9 +20,12 @@ mod article;
mod audit;
mod car;
mod common;
mod contact;
mod customer;
mod delivery;
mod payment;
mod process_state;
mod service;
mod tour;
mod warehouse;
@ -31,8 +34,13 @@ pub use article::Article;
pub use audit::{AuditAction, ScanAuditEntry};
pub use car::Car;
pub use common::Address;
pub use contact::{ContactChannel, ContactKind, ContactRole, ContactSource};
pub use customer::{Customer, CustomerContact};
pub use delivery::{Delivery, DeliveryItem, DeliveryNote, DeliveryState, ScanState, ScanStatus};
pub use delivery::{
Delivery, DeliveryCredit, DeliveryItem, DeliveryNote, DeliveryState, ScanState, ScanStatus,
};
pub use payment::PaymentMethod;
pub use process_state::{DeliveryPhase, DeliveryProcessState};
pub use service::{DeliveryServiceValue, Service, ServiceKind};
pub use tour::Tour;
pub use warehouse::Warehouse;

View File

@ -0,0 +1,30 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Zahlungs-Stammdatensatz.
///
/// Bewusst eine Tabelle und kein Enum: neue Anbieter (PayPal, Klarna, …)
/// kommen über den `POST /payment-methods`-Endpoint hinzu. Domain-Code
/// kann trotzdem fachliche Sonderfälle über den stabilen `code` (z. B.
/// `"invoice"` braucht Bonitätsprüfung) referenzieren — die UUID dient
/// nur als FK in `deliveries`.
///
/// `active = false` ist Soft-Delete: die Methode bleibt referenzierbar
/// für historische Lieferungen, taucht aber in der UI-Auswahl nicht
/// mehr auf. Echtes Löschen ist nur möglich, wenn keine Lieferung sie
/// referenziert — Datenbank-Constraint regelt das via
/// `ON DELETE RESTRICT`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct PaymentMethod {
pub id: Uuid,
/// Stabiler Programm-Identifier — z. B. `"cash"`, `"ec_card"`.
/// Eindeutig pro Eintrag. Wird vom Aufrufer beim Anlegen gesetzt.
pub code: String,
/// Display-Name in der UI — frei via PATCH änderbar.
pub name: String,
pub active: bool,
pub created_at: DateTime<Utc>,
}

View File

@ -0,0 +1,45 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Eingabetyp eines Service (früher „Lieferoption"). `Boolean` rendert als
/// Checkbox, `Numeric` als Zahlenfeld mit optionalen Grenzen.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum ServiceKind {
Boolean,
Numeric,
}
/// Service-Stammdatensatz — admin-konfigurierbar (Muster wie `PaymentMethod`).
///
/// `key` ist der stabile Programm-Identifier (eindeutig), `name` der
/// Anzeige-Name. `min_value`/`max_value` sind nur für `Numeric` relevant.
/// `active = false` ist Soft-Delete (bleibt für historische Lieferungen
/// referenzierbar, fällt aus dem Default-Listing).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct Service {
pub id: Uuid,
pub key: String,
pub name: String,
pub kind: ServiceKind,
pub min_value: Option<i32>,
pub max_value: Option<i32>,
pub active: bool,
pub sort_order: i32,
}
/// Pro-Lieferung gewählter Wert eines Service. Genau einer der beiden
/// Wert-Slots ist je nach `ServiceKind` gesetzt; per `service_id`/`delivery_id`
/// clientseitig join-bar (wie Notizen/Gutschriften).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeliveryServiceValue {
pub delivery_id: Uuid,
pub service_id: Uuid,
pub bool_value: Option<bool>,
pub numeric_value: Option<i32>,
}

View File

@ -15,7 +15,14 @@ serde_json.workspace = true
uuid.workspace = true
chrono.workspace = true
tokio.workspace = true
tokio-util.workspace = true
sqlx.workspace = true
reqwest.workspace = true
tiberius.workspace = true
jsonwebtoken.workspace = true
tracing.workspace = true
# PDF-Report-Generierung (Lieferabschluss). printpdf nutzt eingebaute
# Standard-Fonts (Helvetica, WinAnsi → deutsche Umlaute) → kein Font-Asset.
# `image` dekodiert Unterschriften/Foto-Notizen zu Roh-RGB fürs Einbetten.
printpdf = "0.7"
image = "0.25"

View File

@ -0,0 +1,298 @@
//! Keycloak-Admin-Adapter — Implementierung von [`DriverIdentityProvisioner`].
//!
//! Legt beim ERP-Sync Fahrer-Konten im Realm an. Authentifiziert sich als
//! **Service-Account** (confidential Client `holzleitner-provisioner`,
//! `client_credentials`) mit der `realm-management`-Rolle `manage-users`.
//!
//! Ablauf je Fahrer (idempotent):
//! 1. Admin-Token holen (`client_credentials`).
//! 2. User per `?username=<nr>&exact=true` suchen → existiert ⇒ No-Op.
//! 3. `POST users` mit `username=<nr>`, Attribut `personalnummer=[<nr>]`,
//! temporärem Passwort (`temporary:true`) und Required-Action
//! `UPDATE_PASSWORD` (Zwangsänderung beim ersten Login).
//! 4. Realm-Rolle `driver` zuweisen (`role-mappings/realm`).
//!
//! Bewusst **kein** Passwort-Reset für bestehende User — wer sein Passwort
//! schon gesetzt hat, behält es.
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{json, Value};
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::{DriverIdentityProvisioner, ProvisionOutcome};
/// Konfiguration des Keycloak-Admin-Adapters (aus `KEYCLOAK_*`-Env).
#[derive(Debug, Clone)]
pub struct KeycloakAdminConfig {
/// Basis-URL der Keycloak-Instanz **ohne** `/realms/...`, z. B.
/// `http://localhost:8080`.
pub base_url: String,
/// Realm-Name, z. B. `holzleitner`.
pub realm: String,
/// Service-Account-Client (confidential) für die Admin-API.
pub client_id: String,
pub client_secret: String,
/// Default-Passwort, das neuen Konten als **temporär** gesetzt wird
/// (muss beim ersten Login geändert werden).
pub default_password: String,
/// Realm-Rolle, die jedem Fahrer zugewiesen wird (z. B. `driver`).
pub driver_role: String,
}
pub struct KeycloakAdminClient {
config: KeycloakAdminConfig,
http: reqwest::Client,
}
impl KeycloakAdminClient {
pub fn new(config: KeycloakAdminConfig) -> Self {
Self {
config,
http: reqwest::Client::new(),
}
}
fn base(&self) -> &str {
self.config.base_url.trim_end_matches('/')
}
/// Holt ein Admin-Access-Token via `client_credentials`.
async fn admin_token(&self) -> Result<String, ApplicationError> {
let url = format!(
"{}/realms/{}/protocol/openid-connect/token",
self.base(),
self.config.realm
);
let resp = self
.http
.post(&url)
.form(&[
("grant_type", "client_credentials"),
("client_id", self.config.client_id.as_str()),
("client_secret", self.config.client_secret.as_str()),
])
.send()
.await
.map_err(ext)?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(ApplicationError::External(format!(
"keycloak token ({status}): {body}"
)));
}
let token: TokenResponse = resp.json().await.map_err(ext)?;
Ok(token.access_token)
}
/// Sucht einen User per exaktem Benutzernamen. `Some(id)` ⇒ existiert.
async fn find_user_id(
&self,
token: &str,
username: &str,
) -> Result<Option<String>, ApplicationError> {
let url = format!("{}/admin/realms/{}/users", self.base(), self.config.realm);
let resp = self
.http
.get(&url)
.bearer_auth(token)
.query(&[("username", username), ("exact", "true")])
.send()
.await
.map_err(ext)?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(ApplicationError::External(format!(
"keycloak user lookup ({status}): {body}"
)));
}
let users: Vec<UserRep> = resp.json().await.map_err(ext)?;
Ok(users.into_iter().find_map(|u| u.id))
}
/// Legt einen neuen User an und gibt dessen ID zurück.
async fn create_user(
&self,
token: &str,
username: &str,
display_name: Option<&str>,
) -> Result<String, ApplicationError> {
let url = format!("{}/admin/realms/{}/users", self.base(), self.config.realm);
let body = json!({
"username": username,
"enabled": true,
"firstName": display_name.unwrap_or(username),
"attributes": { "personalnummer": [username] },
"requiredActions": ["UPDATE_PASSWORD"],
"credentials": [{
"type": "password",
"value": self.config.default_password,
"temporary": true
}]
});
let resp = self
.http
.post(&url)
.bearer_auth(token)
.json(&body)
.send()
.await
.map_err(ext)?;
let status = resp.status();
if status.as_u16() == 409 {
// Race: zwischen Lookup und Create angelegt — als „existiert"
// behandeln und ID nachschlagen.
return self
.find_user_id(token, username)
.await?
.ok_or_else(|| ApplicationError::External("keycloak 409 ohne User".into()));
}
if !status.is_success() {
let txt = resp.text().await.unwrap_or_default();
return Err(ApplicationError::External(format!(
"keycloak create user ({status}): {txt}"
)));
}
// Keycloak liefert die ID im `Location`-Header (.../users/{id}).
if let Some(loc) = resp.headers().get(reqwest::header::LOCATION) {
if let Ok(s) = loc.to_str() {
if let Some(id) = s.rsplit('/').next() {
if !id.is_empty() {
return Ok(id.to_string());
}
}
}
}
// Fallback: erneut suchen.
self.find_user_id(token, username)
.await?
.ok_or_else(|| ApplicationError::External("keycloak: User nach Create nicht gefunden".into()))
}
/// Weist dem User die Realm-Rolle `driver_role` zu (idempotent).
///
/// Bewusst über den **user-scoped** Endpoint `role-mappings/realm/available`
/// statt `GET /roles/{name}` — letzterer bräuchte `view-realm`; ersterer
/// kommt mit `manage-users` aus (least privilege). Ist die Rolle nicht mehr
/// „available", ist sie bereits zugewiesen (oder existiert nicht → wir
/// prüfen die effektiven Zuweisungen und liefern sonst einen Fehler).
async fn assign_driver_role(
&self,
token: &str,
user_id: &str,
) -> Result<(), ApplicationError> {
let role_name = self.config.driver_role.as_str();
let base_map = format!(
"{}/admin/realms/{}/users/{}/role-mappings/realm",
self.base(),
self.config.realm,
user_id
);
// 1. Zuweisbare Realm-Rollen des Users holen, `driver` suchen.
let available: Vec<Value> = self
.http
.get(format!("{base_map}/available"))
.bearer_auth(token)
.send()
.await
.map_err(ext)?
.error_for_status()
.map_err(ext)?
.json()
.await
.map_err(ext)?;
let role = available
.into_iter()
.find(|r| r.get("name").and_then(Value::as_str) == Some(role_name));
let Some(role) = role else {
// Nicht „available" → entweder schon zugewiesen (idempotenter
// No-Op) oder Rolle existiert nicht. Effektive Zuweisungen prüfen.
let assigned: Vec<Value> = self
.http
.get(&base_map)
.bearer_auth(token)
.send()
.await
.map_err(ext)?
.error_for_status()
.map_err(ext)?
.json()
.await
.map_err(ext)?;
let has = assigned
.iter()
.any(|r| r.get("name").and_then(Value::as_str) == Some(role_name));
return if has {
Ok(())
} else {
Err(ApplicationError::External(format!(
"keycloak: Realm-Rolle '{role_name}' existiert nicht"
)))
};
};
// 2. Rolle zuweisen.
let resp = self
.http
.post(&base_map)
.bearer_auth(token)
.json(&json!([role]))
.send()
.await
.map_err(ext)?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(ApplicationError::External(format!(
"keycloak assign role ({status}): {body}"
)));
}
Ok(())
}
}
#[async_trait]
impl DriverIdentityProvisioner for KeycloakAdminClient {
async fn ensure_driver(
&self,
personalnummer: i64,
display_name: Option<&str>,
) -> Result<ProvisionOutcome, ApplicationError> {
let username = personalnummer.to_string();
let token = self.admin_token().await?;
if self.find_user_id(&token, &username).await?.is_some() {
tracing::debug!(personalnummer, "keycloak_provision: user existiert bereits");
return Ok(ProvisionOutcome { created: false });
}
let user_id = self.create_user(&token, &username, display_name).await?;
self.assign_driver_role(&token, &user_id).await?;
tracing::info!(personalnummer, user_id, "keycloak_provision: Fahrer-Konto angelegt");
Ok(ProvisionOutcome { created: true })
}
}
fn ext<E: std::fmt::Display>(e: E) -> ApplicationError {
ApplicationError::External(e.to_string())
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
}
#[derive(Deserialize)]
struct UserRep {
id: Option<String>,
}

View File

@ -6,5 +6,7 @@
//! auf das Domänenmodell (Personalnummer, Rollen).
pub mod keycloak;
pub mod keycloak_admin;
pub use keycloak::{KeycloakAdapterConfig, KeycloakAuthService};
pub use keycloak_admin::{KeycloakAdminClient, KeycloakAdminConfig};

View File

@ -0,0 +1,11 @@
//! ERP-Lese-Adapter (ERPframe / MS SQL Server).
//!
//! Implementiert den `ErpDeliverySource`-Port gegen die ERPframe-Datenbank
//! via `tiberius` (nativer async MSSQL-Treiber). Reine Lese-Operation; das
//! ERP wird nicht zurückgeschrieben.
pub mod mssql_delivery_source;
pub mod mssql_delivery_writeback;
pub use mssql_delivery_source::{MssqlErpConfig, MssqlErpDeliverySource};
pub use mssql_delivery_writeback::MssqlErpDeliveryWriteback;

View File

@ -0,0 +1,586 @@
//! MSSQL-Adapter für den `ErpDeliverySource`-Port.
//!
//! Liest die Lieferungen eines Tages direkt aus den ERPframe-Basistabellen
//! (Belegart `VL5` / Lieferschein) und gruppiert sie zu `SyncTourRequest`-
//! DTOs (eine pro Fahrer). Kein Connection-Pool: der Pull läuft einmal
//! täglich, eine frische Verbindung pro Lauf genügt.
//!
//! Die SELECT-Query löst **Stücklisten** auf (`UNION ALL`, analog zur Alt-View
//! `_SV_APP_DELIVERIES_TODAY`): Oberartikel werden non-scannable als Preis-/
//! Gruppenträger geführt, jede Komponente als eigener (scanbarer) Artikel. Das
//! Lager kommt pro Zeile aus `bz.Lagerverteilung` (XML), Fallback `bk.Lager`.
//! Numerische Spalten werden serverseitig auf feste Typen gecastet
//! (BIGINT/INT/FLOAT/BIT), damit die tiberius-Reads deterministisch sind.
use async_trait::async_trait;
use chrono::NaiveDate;
use tiberius::{AuthMethod, Client, Config};
use tokio::net::TcpStream;
use tokio_util::compat::TokioAsyncWriteCompatExt;
use holzleitner_application::dto::{
SyncContactChannel, SyncContactSource, SyncDelivery, SyncDeliveryItem, SyncTourRequest,
};
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::ErpDeliverySource;
use holzleitner_domain::{Address, ContactKind, ContactRole};
/// Verbindungsparameter zur ERPframe-MSSQL.
#[derive(Debug, Clone)]
pub struct MssqlErpConfig {
pub host: String,
pub port: u16,
pub database: String,
pub user: String,
pub password: String,
/// Selbstsigniertes Server-Zertifikat akzeptieren (lokale/Intranet-DB).
pub trust_cert: bool,
}
pub struct MssqlErpDeliverySource {
config: MssqlErpConfig,
}
impl MssqlErpDeliverySource {
pub fn new(config: MssqlErpConfig) -> Self {
Self { config }
}
fn tiberius_config(&self) -> Config {
let mut cfg = Config::new();
cfg.host(&self.config.host);
cfg.port(self.config.port);
cfg.database(&self.config.database);
cfg.authentication(AuthMethod::sql_server(
&self.config.user,
&self.config.password,
));
if self.config.trust_cert {
cfg.trust_cert();
}
cfg
}
}
fn repo<E: std::fmt::Display>(e: E) -> ApplicationError {
ApplicationError::Repository(e.to_string())
}
/// Adress-Aliases, ihre FK-Quelle am Belegkopf und der Präfix, mit dem alle
/// Kontaktspalten der jeweiligen Adresse in der SELECT-Liste verbiegt werden.
/// `adr` ist die Belegadresse (auch heute schon Pflicht-JOIN für die
/// Anschrift); die übrigen vier sind LEFT-JOINs. Reihenfolge entspricht der
/// `ContactRole`-Enum.
const ADDRESS_ALIASES: &[(&str, &str)] = &[
("adr", "hdr"), // header bk.AdressId
("dadr", "dlv"), // delivery bk.LieferAdressId
("radr", "bll"), // billing bk.RechnungsAdressId
("kadr", "ctp"), // contact_person bk.AnsprechpartnerId
("sadr", "cms"), // customer_master Kunden.AdressId (über bk.KundenId)
];
/// Spalten, die wir pro Adresse selektieren. Reihenfolge irrelevant; Aliase
/// werden im Mapper über `<präfix>_<spalte>` aufgelöst.
const ADDRESS_CONTACT_COLUMNS: &[&str] = &[
"Anrede", "Titel", "Name1", "Name2", "Name3", "Abteilung", "Funktion", "Telefon", "Telefon2",
"Telefon3", "Telefon4", "Mobiltel", "Mobiltel2", "EMail", "EMail2", "EMail3", "InternetAdresse",
];
/// Erzeugt den SELECT-Block für alle 5 Adressen — eine Zeile pro Spalte mit
/// `<alias>.<col> AS <präfix>_<col>`. Wird in beide UNION-Hälften identisch
/// einkopiert.
fn address_select_block() -> String {
let mut out = String::new();
for (alias, prefix) in ADDRESS_ALIASES {
for col in ADDRESS_CONTACT_COLUMNS {
out.push_str(&format!(" {alias}.{col} AS {prefix}_{col},\n"));
}
}
out
}
/// SELECT gegen die Basistabellen, **mit Stücklisten-Auflösung** (analog zur
/// Alt-View `_SV_APP_DELIVERIES_TODAY`). Datum als positionaler Parameter
/// `@P1`. Zwei `UNION ALL`-Teile:
///
/// * **Teil 1** — alle Belegzeilen als Items. Ein Oberartikel (= Artikel mit
/// Stücklisten-Kopf `Stuecklisten.StueckListenId=0`) wird **non-scannable**
/// (`articleScannable=0`) ausgegeben: er ist nur Preis-/Gruppen-Träger, die
/// physisch zu scannenden Einheiten sind seine Komponenten. `komponenten-
/// ArtikelNr = NULL`.
/// * **Teil 2** — die Stücklisten-Komponenten je Oberartikel, **jede als
/// eigener Artikel** (eigene Artikelnummer/-bezeichnung/-scanbarkeit), Menge
/// = Zeilenmenge × Komponentenmenge, `unitPrice=0` (der Preis liegt im ERP
/// nur auf der Oberartikel-Zeile). `komponentenArtikelNr` = eigene Nummer der
/// Komponente (eindeutig je `belegzeilenNr`; markiert die Zeile als
/// Komponente — der Parent ist die `NULL`-Zeile gleicher `belegzeilenNr`).
///
/// **Lager pro Zeile** aus `bz.Lagerverteilung` (XML `(/Root/Row/Lager)[1]`),
/// Fallback `bk.Lager`; Name aus `Lagerstammdaten`. Komponenten erben das
/// Lager ihrer Oberartikel-Zeile. Das Lager wird je Zeile einmal per
/// `CROSS APPLY lv` berechnet.
///
/// **Adressen für Kontaktdaten:** Wir joinen zusätzlich zur Belegadresse
/// (`adr`) und der Lieferadresse (`dadr`) auch Rechnungsadresse (`radr`),
/// Ansprechpartner (`kadr`) und die Stamm-Adresse des Kunden (`sadr` über
/// `Kunden.AdressId`). Pro Adresse werden Name/Anrede/Titel und alle
/// Telefon-/Mobil-/E-Mail-/Web-Spalten selektiert (siehe
/// [`address_select_block`]); der Mapper baut daraus die `contact_sources`
/// der Lieferung.
const SQL_TEMPLATE: &str = r#"
SELECT
TRY_CAST(LTRIM(RTRIM(bk.Vertreter)) AS BIGINT) AS driverPersonalnummer,
CAST(bk.BelegartId AS BIGINT) AS belegartId,
LTRIM(RTRIM(ba.Belegart)) AS belegartCode,
LTRIM(RTRIM(ba.Bezeichnung)) AS belegartName,
LTRIM(RTRIM(bk.Belegnummer)) AS belegnummer,
COALESCE(TRY_CAST(LTRIM(RTRIM(k.Kundennummer)) AS BIGINT),
CAST(bk.KundenId AS BIGINT)) AS erpCustomerId,
adr.Name1 AS customerName,
adr.Strasse AS custStreet,
adr.Hausnummer AS custHouseNumber,
LTRIM(RTRIM(adr.PLZ)) AS custPostalCode,
adr.Ort AS custCity,
adr.Land AS custCountry,
COALESCE(dadr.Strasse, adr.Strasse) AS delivStreet,
COALESCE(dadr.Hausnummer, adr.Hausnummer) AS delivHouseNumber,
LTRIM(RTRIM(COALESCE(dadr.PLZ, adr.PLZ))) AS delivPostalCode,
COALESCE(dadr.Ort, adr.Ort) AS delivCity,
COALESCE(dadr.Land, adr.Land) AS delivCountry,
LTRIM(RTRIM(CAST(bk._Uhrzeit_Txt AS varchar(200)))) AS desiredTime,
LTRIM(RTRIM(CAST(bk.Kopftext AS varchar(MAX)))) AS specialAgreements,
CAST(ISNULL(bk._Anz_Bestellung, 0) AS FLOAT) AS prepaidAmount,
LTRIM(RTRIM(z.Zahlungsbedingung)) AS paymentZahlbed,
z.Bezeichnung AS paymentZahlbedText,
CAST(bz.BelegzeilenNr AS INT) AS belegzeilenNr,
CAST(NULL AS varchar(50)) AS komponentenArtikelNr,
CAST(NULL AS varchar(50)) AS parentArtikelNr,
a.Artikelnummer AS articleNumber,
COALESCE(NULLIF(LTRIM(RTRIM(bz.ArtikelBezeichnung)), ''), a.Artikelbezeichnung)
AS articleName,
CASE WHEN EXISTS (SELECT 1 FROM Stuecklisten sk
WHERE sk.ArtikelID = a.row_id AND sk.StueckListenId = 0)
THEN CAST(0 AS BIT)
WHEN UPPER(LTRIM(RTRIM(ag.[Bestandsführung]))) IN ('1','J','Y','T','X')
THEN CAST(1 AS BIT)
ELSE CAST(0 AS BIT) END AS articleScannable,
lv.code AS warehouseCode,
COALESCE(ls.lagerbezeichnung, lv.code) AS warehouseName,
CAST(bz.Menge AS INT) AS requiredQuantity,
CAST(bz.EinzelPreisBrutto AS FLOAT) AS unitPrice,
{ADDR}
-- Trailing-Anker: damit der letzte `,` im Adressblock syntaktisch ok ist.
CAST(1 AS BIT) AS _addrBlockMarker
FROM Belegkopf bk
JOIN Adressen adr ON bk.AdressId = adr.ROW_ID
LEFT JOIN Adressen dadr ON bk.LieferAdressId = dadr.ROW_ID
LEFT JOIN Adressen radr ON bk.RechnungsAdressId = radr.ROW_ID
LEFT JOIN Adressen kadr ON bk.AnsprechpartnerId = kadr.ROW_ID
JOIN Belegzeilen bz ON bk.row_id = bz.ParentID
JOIN Personalstamm ps ON ps.Personalnummer = bk.Vertreter
JOIN Artikel a ON a.row_id = bz.ArtikelId
JOIN Artikelgruppen ag ON a.ArtikelGruppenId = ag.ROW_ID
JOIN Zahlungsbedingungen z ON z.ROW_ID = bk.ZahlungsbedingungId
JOIN Belegarten ba ON ba.row_id = bk.BelegartId
LEFT JOIN Kunden k ON k.row_id = bk.KundenId
LEFT JOIN Adressen sadr ON k.AdressId = sadr.ROW_ID
CROSS APPLY (SELECT COALESCE(
NULLIF(LTRIM(RTRIM(
CAST(CAST(bz.Lagerverteilung AS varchar(max)) AS XML)
.value('(/Root/Row/Lager)[1]', 'varchar(20)'))), ''),
LTRIM(RTRIM(bk.Lager))) AS code) lv
LEFT JOIN Lagerstammdaten ls ON ls.lagernummer = lv.code
WHERE ba.Belegart = 'VL5'
AND bz.ArtikelId IS NOT NULL
AND CAST(bk.Termin AS DATE) = @P1
-- Abschluss-Artefakte ausschließen, damit ein erneuter Sync auch nach
-- Rückschreiben eines Abschlusses robust bleibt: Gutschrift-/Storno-
-- Zeilen (negativer Bruttopreis) und vollständig entfernte Positionen
-- (Menge 0). Sonst Crash gegen unit_price>=0 / required_quantity>0.
AND CAST(ISNULL(bz.EinzelPreisBrutto, 0) AS FLOAT) >= 0
AND ISNULL(bz.Menge, 0) > 0
UNION ALL
SELECT
TRY_CAST(LTRIM(RTRIM(bk.Vertreter)) AS BIGINT) AS driverPersonalnummer,
CAST(bk.BelegartId AS BIGINT) AS belegartId,
LTRIM(RTRIM(ba.Belegart)) AS belegartCode,
LTRIM(RTRIM(ba.Bezeichnung)) AS belegartName,
LTRIM(RTRIM(bk.Belegnummer)) AS belegnummer,
COALESCE(TRY_CAST(LTRIM(RTRIM(k.Kundennummer)) AS BIGINT),
CAST(bk.KundenId AS BIGINT)) AS erpCustomerId,
adr.Name1 AS customerName,
adr.Strasse AS custStreet,
adr.Hausnummer AS custHouseNumber,
LTRIM(RTRIM(adr.PLZ)) AS custPostalCode,
adr.Ort AS custCity,
adr.Land AS custCountry,
COALESCE(dadr.Strasse, adr.Strasse) AS delivStreet,
COALESCE(dadr.Hausnummer, adr.Hausnummer) AS delivHouseNumber,
LTRIM(RTRIM(COALESCE(dadr.PLZ, adr.PLZ))) AS delivPostalCode,
COALESCE(dadr.Ort, adr.Ort) AS delivCity,
COALESCE(dadr.Land, adr.Land) AS delivCountry,
LTRIM(RTRIM(CAST(bk._Uhrzeit_Txt AS varchar(200)))) AS desiredTime,
LTRIM(RTRIM(CAST(bk.Kopftext AS varchar(MAX)))) AS specialAgreements,
CAST(ISNULL(bk._Anz_Bestellung, 0) AS FLOAT) AS prepaidAmount,
LTRIM(RTRIM(z.Zahlungsbedingung)) AS paymentZahlbed,
z.Bezeichnung AS paymentZahlbedText,
CAST(bz.BelegzeilenNr AS INT) AS belegzeilenNr,
LTRIM(RTRIM(ka.Artikelnummer)) AS komponentenArtikelNr,
LTRIM(RTRIM(a.Artikelnummer)) AS parentArtikelNr,
ka.Artikelnummer AS articleNumber,
COALESCE(NULLIF(LTRIM(RTRIM(ka.Artikelbezeichnung)), ''), ka.Artikelnummer)
AS articleName,
CASE WHEN UPPER(LTRIM(RTRIM(kag.[Bestandsführung]))) IN ('1','J','Y','T','X')
THEN CAST(1 AS BIT) ELSE CAST(0 AS BIT) END AS articleScannable,
lv.code AS warehouseCode,
COALESCE(ls.lagerbezeichnung, lv.code) AS warehouseName,
CAST(bz.Menge * stl_pos.Menge AS INT) AS requiredQuantity,
CAST(0 AS FLOAT) AS unitPrice,
{ADDR}
CAST(1 AS BIT) AS _addrBlockMarker
FROM Belegkopf bk
JOIN Adressen adr ON bk.AdressId = adr.ROW_ID
LEFT JOIN Adressen dadr ON bk.LieferAdressId = dadr.ROW_ID
LEFT JOIN Adressen radr ON bk.RechnungsAdressId = radr.ROW_ID
LEFT JOIN Adressen kadr ON bk.AnsprechpartnerId = kadr.ROW_ID
JOIN Belegzeilen bz ON bk.row_id = bz.ParentID
JOIN Personalstamm ps ON ps.Personalnummer = bk.Vertreter
JOIN Artikel a ON a.row_id = bz.ArtikelId
JOIN Zahlungsbedingungen z ON z.ROW_ID = bk.ZahlungsbedingungId
JOIN Belegarten ba ON ba.row_id = bk.BelegartId
LEFT JOIN Kunden k ON k.row_id = bk.KundenId
LEFT JOIN Adressen sadr ON k.AdressId = sadr.ROW_ID
JOIN Stuecklisten stl_kopf ON stl_kopf.ArtikelID = a.row_id
AND stl_kopf.StueckListenId = 0
JOIN Stuecklisten stl_pos ON stl_pos.StueckListenId = stl_kopf.ROW_ID
JOIN Artikel ka ON ka.row_id = stl_pos.ArtikelID
LEFT JOIN Artikelgruppen kag ON ka.ArtikelGruppenId = kag.ROW_ID
CROSS APPLY (SELECT COALESCE(
NULLIF(LTRIM(RTRIM(
CAST(CAST(bz.Lagerverteilung AS varchar(max)) AS XML)
.value('(/Root/Row/Lager)[1]', 'varchar(20)'))), ''),
LTRIM(RTRIM(bk.Lager))) AS code) lv
LEFT JOIN Lagerstammdaten ls ON ls.lagernummer = lv.code
WHERE ba.Belegart = 'VL5'
AND bz.ArtikelId IS NOT NULL
AND CAST(bk.Termin AS DATE) = @P1
-- Abschluss-Artefakte ausschließen, damit ein erneuter Sync auch nach
-- Rückschreiben eines Abschlusses robust bleibt: Gutschrift-/Storno-
-- Zeilen (negativer Bruttopreis) und vollständig entfernte Positionen
-- (Menge 0). Sonst Crash gegen unit_price>=0 / required_quantity>0.
AND CAST(ISNULL(bz.EinzelPreisBrutto, 0) AS FLOAT) >= 0
AND ISNULL(bz.Menge, 0) > 0
ORDER BY driverPersonalnummer, belegartId, belegnummer, belegzeilenNr, komponentenArtikelNr
"#;
/// Eine flache Belegzeile, wie sie aus der Query kommt.
struct ErpRow {
driver_personalnummer: i64,
belegart_id: i64,
belegart_code: Option<String>,
belegart_name: Option<String>,
belegnummer: String,
erp_customer_id: i64,
customer_name: String,
cust: Address,
deliv: Address,
desired_time: Option<String>,
special_agreements: Option<String>,
prepaid_amount: f64,
payment_zahlbed: String,
payment_zahlbed_text: String,
belegzeilen_nr: i32,
komponenten_artikel_nr: Option<String>,
parent_artikel_nr: Option<String>,
article_number: String,
article_name: String,
article_scannable: bool,
warehouse_code: String,
warehouse_name: String,
required_quantity: i32,
unit_price: f64,
/// Pro-Beleg Kontaktquellen (aus allen 5 Adress-FKs). Auf jeder Item-Zeile
/// derselben Lieferung identisch; der Grouper übernimmt sie aus der
/// ersten Zeile und ignoriert die der Folgezeilen.
contact_sources: Vec<SyncContactSource>,
}
fn s(row: &tiberius::Row, col: &str) -> String {
row.get::<&str, _>(col).unwrap_or("").trim().to_string()
}
fn opt_s(row: &tiberius::Row, col: &str) -> Option<String> {
match row.get::<&str, _>(col) {
Some(v) if !v.trim().is_empty() => Some(v.trim().to_string()),
_ => None,
}
}
fn i64c(row: &tiberius::Row, col: &str) -> i64 {
row.get::<i64, _>(col).unwrap_or(0)
}
fn i32c(row: &tiberius::Row, col: &str) -> i32 {
row.get::<i32, _>(col).unwrap_or(0)
}
fn f64c(row: &tiberius::Row, col: &str) -> f64 {
row.get::<f64, _>(col).unwrap_or(0.0)
}
fn boolc(row: &tiberius::Row, col: &str) -> bool {
row.get::<bool, _>(col).unwrap_or(false)
}
/// Liest aus einem Tiberius-Row die Kontaktdaten **einer** Adress-Rolle aus
/// (alle Spalten heißen `<präfix>_<spalte>`, siehe [`address_select_block`]).
/// Gibt `None` zurück, wenn weder ein Namensfeld noch ein Kontaktkanal
/// belegt ist — leere Adressen werden so vom Sync nicht mitgeschleppt.
fn read_contact_source(
row: &tiberius::Row,
prefix: &str,
role: ContactRole,
) -> Option<SyncContactSource> {
let get = |col: &str| opt_s(row, &format!("{prefix}_{col}"));
let anrede = get("Anrede");
let titel = get("Titel");
let name1 = get("Name1");
let name2 = get("Name2");
let name3 = get("Name3");
let abteilung = get("Abteilung");
let funktion = get("Funktion");
let mut channels: Vec<SyncContactChannel> = Vec::new();
// 1-basierte Position spiegelt die ERP-Spaltennummerierung
// (`Telefon` → 1, `Telefon2` → 2, …). Spalten-Reihenfolge identisch zu
// [`ADDRESS_CONTACT_COLUMNS`].
let push = |chans: &mut Vec<SyncContactChannel>,
kind: ContactKind,
pos: i16,
value: Option<String>| {
if let Some(v) = value {
chans.push(SyncContactChannel {
kind,
position: pos,
value: v,
});
}
};
push(&mut channels, ContactKind::Phone, 1, get("Telefon"));
push(&mut channels, ContactKind::Phone, 2, get("Telefon2"));
push(&mut channels, ContactKind::Phone, 3, get("Telefon3"));
push(&mut channels, ContactKind::Phone, 4, get("Telefon4"));
push(&mut channels, ContactKind::Mobile, 1, get("Mobiltel"));
push(&mut channels, ContactKind::Mobile, 2, get("Mobiltel2"));
push(&mut channels, ContactKind::Email, 1, get("EMail"));
push(&mut channels, ContactKind::Email, 2, get("EMail2"));
push(&mut channels, ContactKind::Email, 3, get("EMail3"));
push(&mut channels, ContactKind::Web, 1, get("InternetAdresse"));
let has_name = anrede.is_some()
|| titel.is_some()
|| name1.is_some()
|| name2.is_some()
|| name3.is_some()
|| abteilung.is_some()
|| funktion.is_some();
if channels.is_empty() && !has_name {
return None;
}
Some(SyncContactSource {
role,
anrede,
titel,
name1,
name2,
name3,
abteilung,
funktion,
channels,
})
}
fn read_all_contact_sources(row: &tiberius::Row) -> Vec<SyncContactSource> {
[
("hdr", ContactRole::Header),
("dlv", ContactRole::Delivery),
("bll", ContactRole::Billing),
("ctp", ContactRole::ContactPerson),
("cms", ContactRole::CustomerMaster),
]
.into_iter()
.filter_map(|(prefix, role)| read_contact_source(row, prefix, role))
.collect()
}
fn map_row(row: &tiberius::Row) -> ErpRow {
ErpRow {
driver_personalnummer: i64c(row, "driverPersonalnummer"),
belegart_id: i64c(row, "belegartId"),
belegart_code: opt_s(row, "belegartCode"),
belegart_name: opt_s(row, "belegartName"),
belegnummer: s(row, "belegnummer"),
erp_customer_id: i64c(row, "erpCustomerId"),
customer_name: s(row, "customerName"),
cust: Address {
street: s(row, "custStreet"),
house_number: s(row, "custHouseNumber"),
postal_code: s(row, "custPostalCode"),
city: s(row, "custCity"),
country: s(row, "custCountry"),
},
deliv: Address {
street: s(row, "delivStreet"),
house_number: s(row, "delivHouseNumber"),
postal_code: s(row, "delivPostalCode"),
city: s(row, "delivCity"),
country: s(row, "delivCountry"),
},
desired_time: opt_s(row, "desiredTime"),
special_agreements: opt_s(row, "specialAgreements"),
prepaid_amount: f64c(row, "prepaidAmount"),
payment_zahlbed: s(row, "paymentZahlbed"),
payment_zahlbed_text: s(row, "paymentZahlbedText"),
belegzeilen_nr: i32c(row, "belegzeilenNr"),
komponenten_artikel_nr: opt_s(row, "komponentenArtikelNr"),
parent_artikel_nr: opt_s(row, "parentArtikelNr"),
article_number: s(row, "articleNumber"),
article_name: s(row, "articleName"),
article_scannable: boolc(row, "articleScannable"),
warehouse_code: s(row, "warehouseCode"),
warehouse_name: s(row, "warehouseName"),
required_quantity: i32c(row, "requiredQuantity"),
unit_price: f64c(row, "unitPrice"),
contact_sources: read_all_contact_sources(row),
}
}
/// Mapping ERP-Zahlungsbedingung-Code → Backend-Code.
///
/// Holzleitner bietet im Liefer-Flow exakt drei Zahlungsbedingungen an
/// (Allowlist in DOCUframe-Makro `_web_getPaymentMethods.dfm`); ihre
/// Bedeutung stammt aus der ERP-Tabelle `Zahlungsbedingungen` (live verifiziert):
/// * `D16` = „Zahlung bei Lieferung (Barzahlung)" → cash
/// * `d53` = „Zahlung bei Lieferung mit EC-Karte" → ec_card
/// * `D10` = „14 Tage netto" → invoice
/// `credit_card` kommt im Liefer-Flow nicht vor. Unbekannte Codes → `None`
/// (Backend defaultet dann auf `cash`).
fn map_payment_code(code: &str, _text: &str) -> Option<String> {
match code.trim().to_uppercase().as_str() {
"D16" => Some("cash".to_string()),
"D53" => Some("ec_card".to_string()),
"D10" => Some("invoice".to_string()),
_ => None,
}
}
fn item_from(row: &ErpRow) -> SyncDeliveryItem {
SyncDeliveryItem {
belegzeilen_nr: row.belegzeilen_nr,
komponenten_artikel_nr: row.komponenten_artikel_nr.clone(),
parent_artikel_nr: row.parent_artikel_nr.clone(),
article_number: row.article_number.clone(),
article_name: row.article_name.clone(),
article_default_warehouse_code: None,
article_scannable: row.article_scannable,
warehouse_code: row.warehouse_code.clone(),
warehouse_name: row.warehouse_name.clone(),
required_quantity: row.required_quantity,
unit_price: row.unit_price,
}
}
fn delivery_from(row: &ErpRow, sort_order: i32) -> SyncDelivery {
SyncDelivery {
belegart_id: row.belegart_id,
belegart_code: row.belegart_code.clone(),
belegart_name: row.belegart_name.clone(),
belegnummer: row.belegnummer.clone(),
erp_customer_id: row.erp_customer_id,
customer_name: row.customer_name.clone(),
customer_address: row.cust.clone(),
delivery_address: row.deliv.clone(),
sort_order,
desired_time: row.desired_time.clone(),
special_agreements: row.special_agreements.clone(),
prepaid_amount: row.prepaid_amount,
payment_method_code: map_payment_code(&row.payment_zahlbed, &row.payment_zahlbed_text),
items: vec![item_from(row)],
contact_sources: row.contact_sources.clone(),
}
}
/// Gruppiert die (nach Fahrer/Beleg/Zeile sortierten) Zeilen zu
/// `SyncTourRequest` pro Fahrer. Nutzt die Sortierung aus dem ORDER BY:
/// gleiche Fahrer/Belege liegen kontiguös.
fn group(rows: Vec<ErpRow>, date: NaiveDate) -> Vec<SyncTourRequest> {
let mut tours: Vec<SyncTourRequest> = Vec::new();
for row in &rows {
// Tour (Fahrer) finden/anlegen.
let need_new_tour = tours
.last()
.map(|t| t.driver_personalnummer != row.driver_personalnummer)
.unwrap_or(true);
if need_new_tour {
tours.push(SyncTourRequest {
driver_personalnummer: row.driver_personalnummer,
tour_date: date,
deliveries: Vec::new(),
});
}
let tour = tours.last_mut().unwrap();
// Lieferung (Beleg) finden/anlegen.
let need_new_delivery = tour
.deliveries
.last()
.map(|d| d.belegart_id != row.belegart_id || d.belegnummer != row.belegnummer)
.unwrap_or(true);
if need_new_delivery {
let sort_order = tour.deliveries.len() as i32 + 1;
tour.deliveries.push(delivery_from(row, sort_order));
} else {
tour.deliveries.last_mut().unwrap().items.push(item_from(row));
}
}
tours
}
#[async_trait]
impl ErpDeliverySource for MssqlErpDeliverySource {
async fn fetch_tours_for_date(
&self,
date: NaiveDate,
) -> Result<Vec<SyncTourRequest>, ApplicationError> {
let cfg = self.tiberius_config();
let addr = format!("{}:{}", self.config.host, self.config.port);
let tcp = TcpStream::connect(&addr).await.map_err(repo)?;
tcp.set_nodelay(true).ok();
let mut client = Client::connect(cfg, tcp.compat_write())
.await
.map_err(repo)?;
let sql = SQL_TEMPLATE.replace("{ADDR}", &address_select_block());
let rows = client
.query(&sql, &[&date])
.await
.map_err(repo)?
.into_first_result()
.await
.map_err(repo)?;
let flat: Vec<ErpRow> = rows.iter().map(map_row).collect();
let tours = group(flat, date);
tracing::info!(
%date,
rows = rows.len(),
tours = tours.len(),
"erp_source.fetched"
);
Ok(tours)
}
}

View File

@ -0,0 +1,419 @@
//! MSSQL-Adapter für den `ErpDeliveryWriteback`-Port.
//!
//! Schreibt einen Lieferabschluss **direkt** in die ERPframe-Basistabellen
//! zurück — das Rust-Pendant zu den Alt-Makros `_web_finishDelivery`,
//! `_removeArticles` und `_addDiscount`. Alles läuft in **einer** MSSQL-
//! Transaktion und ist **idempotent** (Mengen werden absolut gesetzt, die
//! Gutschrift als Upsert geführt).
//!
//! Reihenfolge:
//! 1. `Belegkopf.row_id` aus (BelegartId, Belegnummer) auflösen.
//! 2. Je Belegzeile die `Menge` auf die ausgelieferte Menge setzen.
//! 3. Gutschrift-Zeile (`GUTSCHRIFT10`) anlegen/aktualisieren.
//! 4. Belegsummen neu berechnen (`Σ Einzelpreis × Menge`, `Σ Brutto × Menge`).
//! 5. `_SV_DELIVERY_DELIVERED_AT` + `_SV_DELIVERY_STATE='geliefert'`.
//!
//! TODO (bewusst hardcoded, später konfigurierbar/aus Stammdaten):
//! Gutschrift-Artikel `GUTSCHRIFT10`, Konto `8726`, Steuerschlüssel `M19`,
//! Steuersatz `19`. Die 10-€-Einheit steckt im Artikelpreis (`Preise`).
use async_trait::async_trait;
use tiberius::{Client, Config};
use tokio::net::TcpStream;
use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt};
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::{ErpDeliveryWriteback, ErpFinishDeliveryCommand};
use super::mssql_delivery_source::MssqlErpConfig;
/// Hardcodierte Gutschrift-Stammdaten (TODO: später konfigurierbar).
const GUTSCHRIFT_ARTICLE_NUMBER: &str = "GUTSCHRIFT10";
const GUTSCHRIFT_KONTO: &str = "8726";
const GUTSCHRIFT_STEUERSCHLUESSEL: &str = "M19";
const GUTSCHRIFT_STEUERSATZ: i32 = 19;
/// Brutto-Faktor (1 + Steuersatz/100) — zum Ableiten des Netto-Einzelpreises
/// aus dem Brutto-Gutschriftbetrag.
const GUTSCHRIFT_GROSS_FACTOR: f64 = 1.0 + (GUTSCHRIFT_STEUERSATZ as f64) / 100.0;
/// Belegzeilentyp (additive Bitmaske) der Gutschrift-Position:
/// Artikel (1) + Andruck (32768) + keine Provision (262144) = 294913.
/// Ermittelt aus einer manuell in ERPframe angelegten Gutschrift (goldene
/// Referenz) — exakt der Typ, den native Gutschrift-Zeilen tragen.
const GUTSCHRIFT_BELEGZEILENTYP: i32 = 294_913;
/// PG-Zahlungsmethode-Code → ERP-`Zahlungsbedingung`-Code. Spiegelt
/// `map_payment_code` im Lese-Adapter (Rückrichtung). TODO: später aus
/// Stammdaten/Config statt hardcoded.
fn erp_zahlbed_code(pg_code: &str) -> Option<&'static str> {
match pg_code.trim().to_lowercase().as_str() {
"cash" => Some("D16"),
"ec_card" => Some("D53"),
"invoice" => Some("D10"),
_ => None,
}
}
pub struct MssqlErpDeliveryWriteback {
config: MssqlErpConfig,
}
impl MssqlErpDeliveryWriteback {
pub fn new(config: MssqlErpConfig) -> Self {
Self { config }
}
fn tiberius_config(&self) -> Config {
let mut cfg = Config::new();
cfg.host(&self.config.host);
cfg.port(self.config.port);
cfg.database(&self.config.database);
cfg.authentication(tiberius::AuthMethod::sql_server(
&self.config.user,
&self.config.password,
));
if self.config.trust_cert {
cfg.trust_cert();
}
cfg
}
}
fn repo<E: std::fmt::Display>(e: E) -> ApplicationError {
ApplicationError::Repository(e.to_string())
}
type TiberiusClient = Client<Compat<TcpStream>>;
impl MssqlErpDeliveryWriteback {
/// Resolved die `Belegkopf.row_id` über den Beleg-Natural-Key.
async fn resolve_belegkopf(
client: &mut TiberiusClient,
belegart_id: i64,
belegnummer: &str,
) -> Result<i64, ApplicationError> {
let rows = client
.query(
r#"SELECT TOP 1 CAST(row_id AS BIGINT) AS rowId
FROM Belegkopf
WHERE BelegartId = @P1 AND LTRIM(RTRIM(Belegnummer)) = @P2"#,
&[&belegart_id, &belegnummer],
)
.await
.map_err(repo)?
.into_first_result()
.await
.map_err(repo)?;
let row = rows.first().ok_or_else(|| {
ApplicationError::Repository(format!(
"Belegkopf nicht gefunden (BelegartId={belegart_id}, Belegnummer='{belegnummer}')"
))
})?;
row.get::<i64, _>("rowId").ok_or_else(|| {
ApplicationError::Repository("Belegkopf.row_id ist NULL".to_string())
})
}
/// Setzt die Menge einer Belegzeile (absolut, idempotent).
async fn set_line_menge(
client: &mut TiberiusClient,
bk_row_id: i64,
belegzeilen_nr: i32,
menge: i32,
) -> Result<(), ApplicationError> {
client
.execute(
r#"UPDATE Belegzeilen SET Menge = @P1
WHERE ParentID = @P2 AND BelegzeilenNr = @P3"#,
&[&menge, &bk_row_id, &belegzeilen_nr],
)
.await
.map_err(repo)?;
Ok(())
}
/// Gutschrift-Upsert als EINE Belegzeile mit dem **tatsächlichen Betrag**
/// (keine 10-€-Einheiten mehr → beliebige Beträge ≤ 150 € exakt abbildbar).
/// `amount_cents` = Geld-Gutschrift in Cent (Brutto; 0 = keine).
///
/// Die Zeile bekommt `Menge = 1` und `Einzelpreis` = -(Brutto / 1,19)
/// (negativ → senkt den Belegwert). `EinzelPreisBrutto` ist eine berechnete
/// Spalte (Netto × (1+Steuersatz/100)) → ergibt automatisch -(Brutto).
async fn upsert_gutschrift(
client: &mut TiberiusClient,
bk_row_id: i64,
amount_cents: i64,
) -> Result<(), ApplicationError> {
// Netto-Einzelpreis aus dem Brutto-Betrag (negativ). 0 € → leere Zeile.
let menge: i32 = if amount_cents > 0 { 1 } else { 0 };
let net: f64 = if amount_cents > 0 {
-((amount_cents as f64 / 100.0) / GUTSCHRIFT_GROSS_FACTOR)
} else {
0.0
};
// Existiert bereits eine Gutschrift-Zeile (GUTSCHRIFT10) am Beleg?
let existing = client
.query(
r#"SELECT TOP 1 CAST(bz.row_id AS BIGINT) AS rowId
FROM Belegzeilen bz
JOIN Artikel a ON a.row_id = bz.ArtikelId
WHERE bz.ParentID = @P1 AND a.Artikelnummer = @P2"#,
&[&bk_row_id, &GUTSCHRIFT_ARTICLE_NUMBER],
)
.await
.map_err(repo)?
.into_first_result()
.await
.map_err(repo)?;
if let Some(row) = existing.first() {
// Vorhandene Zeile: Menge UND Einzelpreis aktualisieren (Betrag
// variabel). Menge 0 ⇒ Zeile zählt im Recalc nicht mehr.
let line_row_id = row.get::<i64, _>("rowId").ok_or_else(|| {
ApplicationError::Repository("Gutschrift-Belegzeile.row_id ist NULL".to_string())
})?;
client
.execute(
"UPDATE Belegzeilen SET Menge = @P1, Einzelpreis = @P2, \
Belegzeilentyp = @P3 WHERE row_id = @P4",
&[&menge, &net, &GUTSCHRIFT_BELEGZEILENTYP, &line_row_id],
)
.await
.map_err(repo)?;
return Ok(());
}
// Keine Gutschrift nötig und keine vorhandene Zeile → nichts tun.
if amount_cents <= 0 {
return Ok(());
}
// Neue Gutschrift-Zeile: Artikel-row_id + Name aus dem Stamm holen.
let art = client
.query(
r#"SELECT TOP 1 CAST(row_id AS BIGINT) AS rowId,
CAST(Artikelbezeichnung AS NVARCHAR(400)) AS name
FROM Artikel WHERE Artikelnummer = @P1"#,
&[&GUTSCHRIFT_ARTICLE_NUMBER],
)
.await
.map_err(repo)?
.into_first_result()
.await
.map_err(repo)?;
let art = art.first().ok_or_else(|| {
ApplicationError::Repository(format!(
"Gutschrift-Artikel '{GUTSCHRIFT_ARTICLE_NUMBER}' nicht im ERP gefunden"
))
})?;
let article_row_id = art
.get::<i64, _>("rowId")
.ok_or_else(|| ApplicationError::Repository("Artikel.row_id ist NULL".into()))?;
let article_name = art.get::<&str, _>("name").unwrap_or("Gutschrift").to_string();
client
.execute(
r#"INSERT INTO Belegzeilen
(ArtikelId, Artikelbezeichnung, Menge, ParentID,
Einzelpreis, Steuersatz, Steuerschluessel,
Konto, ParentBelegzeilenId, PositionNr, Belegzeilentext,
Belegzeilentyp)
VALUES (@P1, @P2, @P3, @P4, @P5, @P6, @P7, @P8, 0, 0, @P9, @P10)"#,
&[
&article_row_id,
&article_name,
&menge,
&bk_row_id,
&net,
&GUTSCHRIFT_STEUERSATZ,
&GUTSCHRIFT_STEUERSCHLUESSEL,
&GUTSCHRIFT_KONTO,
&"Gutschrift",
&GUTSCHRIFT_BELEGZEILENTYP,
],
)
.await
.map_err(repo)?;
Ok(())
}
/// Summiert alle Belegzeilen neu und schreibt die Kopf-Summen.
async fn recalc_head(
client: &mut TiberiusClient,
bk_row_id: i64,
) -> Result<(), ApplicationError> {
let rows = client
.query(
r#"SELECT CAST(ISNULL(Einzelpreis,0) AS FLOAT) AS net,
CAST(ISNULL(EinzelPreisBrutto,0) AS FLOAT) AS gross,
CAST(ISNULL(Menge,0) AS FLOAT) AS menge
FROM Belegzeilen WHERE ParentID = @P1"#,
&[&bk_row_id],
)
.await
.map_err(repo)?
.into_first_result()
.await
.map_err(repo)?;
let mut net_sum = 0.0_f64;
let mut gross_sum = 0.0_f64;
for r in &rows {
let net = r.get::<f64, _>("net").unwrap_or(0.0);
let gross = r.get::<f64, _>("gross").unwrap_or(0.0);
let menge = r.get::<f64, _>("menge").unwrap_or(0.0);
net_sum += net * menge;
gross_sum += gross * menge;
}
client
.execute(
r#"UPDATE Belegkopf
SET WarenwertNetto = @P1, WarenwertBrutto = @P2, PosSummeNetto = @P3
WHERE row_id = @P4"#,
&[&net_sum, &gross_sum, &net_sum, &bk_row_id],
)
.await
.map_err(repo)?;
Ok(())
}
/// Setzt Liefer-Zeitpunkt + Status. ISO-8601 mit `T` als String
/// (impliziter SQL-Cast), exakt wie das Alt-Makro.
async fn mark_delivered(
client: &mut TiberiusClient,
bk_row_id: i64,
delivered_at_iso: &str,
) -> Result<(), ApplicationError> {
client
.execute(
r#"UPDATE Belegkopf
SET _SV_DELIVERY_DELIVERED_AT = @P1, _SV_DELIVERY_STATE = 'geliefert'
WHERE row_id = @P2"#,
&[&delivered_at_iso, &bk_row_id],
)
.await
.map_err(repo)?;
Ok(())
}
/// Setzt `Belegkopf.ZahlungsbedingungId` anhand der gewählten Zahlungs-
/// methode (Code → ERP-Zahlungsbedingung → deren `row_id`). Idempotent
/// (absolutes Setzen). Kein Mapping bzw. keine passende Zahlungsbedingung
/// im ERP → überspringen (kein Fehler), der Beleg behält seine bisherige.
async fn set_payment_condition(
client: &mut TiberiusClient,
bk_row_id: i64,
pg_code: &str,
) -> Result<(), ApplicationError> {
let Some(erp_code) = erp_zahlbed_code(pg_code) else {
tracing::warn!(
pg_code,
"erp_writeback: kein Zahlungsbedingung-Mapping — übersprungen"
);
return Ok(());
};
let rows = client
.query(
r#"SELECT TOP 1 CAST(row_id AS BIGINT) AS rid
FROM Zahlungsbedingungen
WHERE LTRIM(RTRIM(Zahlungsbedingung)) = @P1"#,
&[&erp_code],
)
.await
.map_err(repo)?
.into_first_result()
.await
.map_err(repo)?;
let Some(rid) = rows.first().and_then(|r| r.get::<i64, _>("rid")) else {
tracing::warn!(
erp_code,
"erp_writeback: Zahlungsbedingung nicht im ERP gefunden — übersprungen"
);
return Ok(());
};
client
.execute(
"UPDATE Belegkopf SET ZahlungsbedingungId = @P1 WHERE row_id = @P2",
&[&rid, &bk_row_id],
)
.await
.map_err(repo)?;
Ok(())
}
/// Führt alle Schritte aus (innerhalb der bereits geöffneten Transaktion).
async fn run(
client: &mut TiberiusClient,
cmd: &ErpFinishDeliveryCommand,
) -> Result<(), ApplicationError> {
let bk = Self::resolve_belegkopf(client, cmd.belegart_id, &cmd.belegnummer).await?;
for line in &cmd.lines {
Self::set_line_menge(client, bk, line.belegzeilen_nr, line.delivered_quantity).await?;
}
Self::upsert_gutschrift(client, bk, cmd.credit_amount_cents).await?;
Self::recalc_head(client, bk).await?;
// Gewählte Zahlungsmethode → Zahlungsbedingung im Belegkopf.
if let Some(code) = &cmd.payment_method_code {
Self::set_payment_condition(client, bk, code).await?;
}
let iso = cmd.delivered_at.format("%Y-%m-%dT%H:%M:%S").to_string();
Self::mark_delivered(client, bk, &iso).await?;
Ok(())
}
}
#[async_trait]
impl ErpDeliveryWriteback for MssqlErpDeliveryWriteback {
async fn finish_delivery(
&self,
cmd: ErpFinishDeliveryCommand,
) -> Result<(), ApplicationError> {
let cfg = self.tiberius_config();
let addr = format!("{}:{}", self.config.host, self.config.port);
let tcp = TcpStream::connect(&addr).await.map_err(repo)?;
tcp.set_nodelay(true).ok();
let mut client = Client::connect(cfg, tcp.compat_write()).await.map_err(repo)?;
client
.simple_query("BEGIN TRANSACTION")
.await
.map_err(repo)?;
match Self::run(&mut client, &cmd).await {
Ok(()) => {
client.simple_query("COMMIT").await.map_err(repo)?;
tracing::info!(
belegart = cmd.belegart_id,
belegnummer = %cmd.belegnummer,
lines = cmd.lines.len(),
credit_cents = cmd.credit_amount_cents,
"erp_writeback.committed"
);
Ok(())
}
Err(e) => {
// Best-effort Rollback; der ursprüngliche Fehler bleibt maßgeblich.
let _ = client.simple_query("ROLLBACK").await;
tracing::error!(
belegart = cmd.belegart_id,
belegnummer = %cmd.belegnummer,
error = %e,
"erp_writeback.rolled_back"
);
Err(e)
}
}
}
}

View File

@ -0,0 +1,100 @@
//! Wire-DTOs der GSD/DOCUframe-REST-API.
//!
//! Alle Antworten sind in einen Umschlag `{ status, data }` verpackt.
//! `status.internalStatus` ist der fachliche Status: `"0"` = ok,
//! `"201"` = Session ungültig/abgelaufen (→ Re-Login).
use serde::{Deserialize, Serialize};
pub const STATUS_OK: &str = "0";
pub const STATUS_INVALID_SESSION: &str = "201";
#[derive(Debug, Deserialize)]
pub struct GsdStatus {
#[serde(rename = "internalStatus")]
pub internal_status: String,
#[serde(rename = "statusMessage", default)]
pub status_message: String,
}
#[derive(Debug, Deserialize)]
pub struct GsdEnvelope<T> {
pub status: Option<GsdStatus>,
pub data: Option<T>,
}
impl<T> GsdEnvelope<T> {
/// `true`, wenn der Server eine ungültige Session signalisiert.
pub fn is_invalid_session(&self) -> bool {
self.status
.as_ref()
.map(|s| s.internal_status == STATUS_INVALID_SESSION)
.unwrap_or(false)
}
/// `true`, wenn der fachliche Status ok ist (oder gar kein Status kam).
pub fn is_ok(&self) -> bool {
match &self.status {
Some(s) => s.internal_status == STATUS_OK,
None => true,
}
}
pub fn status_message(&self) -> String {
self.status
.as_ref()
.map(|s| s.status_message.clone())
.unwrap_or_default()
}
}
// ─── Login ────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
pub struct LoginRequest<'a> {
pub user: &'a str,
/// MD5-Hash des Passworts (GSD-Vorgabe).
pub pass: &'a str,
#[serde(rename = "appNames")]
pub app_names: &'a [String],
}
#[derive(Debug, Deserialize)]
pub struct LoginData {
#[serde(rename = "sessionId")]
pub session_id: Option<String>,
}
// ─── License release ────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
pub struct ReleaseRequest<'a> {
#[serde(rename = "appNames")]
pub app_names: &'a [String],
}
// ─── Upload (3-stufig) ────────────────────────────────────────────────────
/// Antwort von `GET /v1/uploadFile` — reservierter Upload-Slot.
#[derive(Debug, Deserialize)]
pub struct UploadIdData {
#[serde(rename = "uploadId")]
pub upload_id: Option<String>,
}
/// Antwort von `PATCH /v1/uploadFile/{uploadId}` — committetes Attachment.
#[derive(Debug, Deserialize)]
pub struct CommitData {
#[serde(rename = "~ObjectID")]
pub object_id: Option<String>,
}
// ─── Makro-Aufruf (`/v1/execute/<name>`) ────────────────────────────────────
/// Request-Body für das Makro `_SV_assignDeliveryReport`.
#[derive(Debug, Serialize)]
pub struct AssignReportRequest<'a> {
#[serde(rename = "objectId")]
pub object_id: &'a str,
pub belegnummer: &'a str,
}

View File

@ -0,0 +1,6 @@
//! DOCUframe/GSD-Adapter — Datei-Upload gegen die GSD-REST-API.
mod dto;
pub mod service;
pub use service::{GsdConfig, GsdService};

View File

@ -0,0 +1,539 @@
//! DOCUframe-Anbindung (GSD-REST-API) — Datei-Upload + Session-Verwaltung.
//!
//! Recycelt aus dem alten Proxy-Backend, aber als **typisierter Adapter**
//! statt transparentem Proxy. Eine einzige technische Service-Account-Session
//! wird wiederverwendet und **durabel in Postgres** (`app_state`) gehalten —
//! der GSD-Server blockt pro Session einen Lizenz-Seat bis Ablauf/Release,
//! darum darf die Id einen Backend-Neustart nicht verlieren (sonst verwaiste
//! Blocks → Lizenz-Lockout).
//!
//! Session-Lebenszyklus:
//! * Erstzugriff: Id aus `app_state` lesen, sonst einloggen.
//! * `internalStatus == "201"` (Session tot): Single-Flight-Re-Login,
//! neue Id überschreiben.
//! * Graceful Shutdown: [`GsdService::release_license`] gibt den Seat frei.
use async_trait::async_trait;
use sqlx::PgPool;
use tokio::sync::{Mutex, RwLock};
use tracing::{error, info, warn};
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::{AttachmentStorage, DocuframeReportGateway, PreviewImage};
use super::dto::*;
const SESSION_KEY: &str = "gsd_session_id";
/// Interner Fehlertyp, der „Session ungültig" von echten Fehlern trennt,
/// damit der Aufrufer gezielt einen Re-Login auslösen kann.
enum GsdError {
InvalidSession,
Other(ApplicationError),
}
impl From<ApplicationError> for GsdError {
fn from(e: ApplicationError) -> Self {
GsdError::Other(e)
}
}
pub struct GsdConfig {
pub rest_url: String,
pub app_key: String,
pub user: String,
pub password_md5: String,
pub app_names: Vec<String>,
}
pub struct GsdService {
pool: PgPool,
http: reqwest::Client,
config: GsdConfig,
/// Heißer Cache der Session-Id; Quelle der Wahrheit ist `app_state`.
cache: RwLock<Option<String>>,
/// Single-Flight-Guard: nie zwei parallele Logins (= zwei Lizenz-Seats).
login_lock: Mutex<()>,
}
impl GsdService {
pub fn new(pool: PgPool, config: GsdConfig) -> Self {
Self {
pool,
http: reqwest::Client::new(),
config,
cache: RwLock::new(None),
login_lock: Mutex::new(()),
}
}
fn ext<E: std::fmt::Display>(e: E) -> ApplicationError {
ApplicationError::External(e.to_string())
}
fn repo<E: std::fmt::Display>(e: E) -> ApplicationError {
ApplicationError::Repository(e.to_string())
}
// ─── Session-Store (Postgres-KV) ─────────────────────────────────────
async fn load_session_from_db(&self) -> Result<Option<String>, ApplicationError> {
sqlx::query_scalar("SELECT value FROM app_state WHERE key = $1")
.bind(SESSION_KEY)
.fetch_optional(&self.pool)
.await
.map_err(Self::repo)
}
async fn store_session_in_db(&self, session: &str) -> Result<(), ApplicationError> {
sqlx::query(
r#"
INSERT INTO app_state (key, value, updated_at)
VALUES ($1, $2, now())
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value, updated_at = now()
"#,
)
.bind(SESSION_KEY)
.bind(session)
.execute(&self.pool)
.await
.map_err(Self::repo)?;
Ok(())
}
async fn clear_session_in_db(&self) -> Result<(), ApplicationError> {
sqlx::query("DELETE FROM app_state WHERE key = $1")
.bind(SESSION_KEY)
.execute(&self.pool)
.await
.map_err(Self::repo)?;
Ok(())
}
// ─── Session-Beschaffung ─────────────────────────────────────────────
/// Liefert die aktuelle Session-Id — aus dem Cache, sonst aus der DB,
/// sonst per Login (Single-Flight).
async fn current_session(&self) -> Result<String, ApplicationError> {
if let Some(s) = self.cache.read().await.clone() {
return Ok(s);
}
if let Some(s) = self.load_session_from_db().await? {
*self.cache.write().await = Some(s.clone());
return Ok(s);
}
// Keine Session bekannt → einloggen (unter Lock, mit Double-Check).
let _guard = self.login_lock.lock().await;
if let Some(s) = self.cache.read().await.clone() {
return Ok(s);
}
self.do_login().await
}
/// Re-Login nach einer als ungültig erkannten Session. Prüft unter Lock,
/// ob inzwischen schon ein anderer Task eine neue Session geholt hat
/// (dann wird die genutzt — kein zweiter Seat).
async fn relogin(&self, stale: &str) -> Result<String, ApplicationError> {
let _guard = self.login_lock.lock().await;
if let Some(current) = self.cache.read().await.clone() {
if current != stale {
return Ok(current);
}
}
self.do_login().await
}
/// Führt den eigentlichen Login aus (Aufrufer hält `login_lock`).
async fn do_login(&self) -> Result<String, ApplicationError> {
info!("GSD: Login gegen {}", self.config.rest_url);
let body = LoginRequest {
user: &self.config.user,
pass: &self.config.password_md5,
app_names: &self.config.app_names,
};
let resp = self
.http
.post(format!("{}/v1/login", self.config.rest_url))
.header("appKey", &self.config.app_key)
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(Self::ext)?;
let env: GsdEnvelope<LoginData> = resp.json().await.map_err(Self::ext)?;
if !env.is_ok() {
error!("GSD: Login fehlgeschlagen: {}", env.status_message());
return Err(ApplicationError::External(format!(
"GSD-Login fehlgeschlagen: {}",
env.status_message()
)));
}
let session = env
.data
.and_then(|d| d.session_id)
.ok_or_else(|| ApplicationError::External("GSD-Login ohne sessionId".into()))?;
*self.cache.write().await = Some(session.clone());
self.store_session_in_db(&session).await?;
info!("GSD: Neue Session etabliert");
Ok(session)
}
// ─── Lizenz freigeben (Graceful Shutdown) ────────────────────────────
/// Gibt den Lizenz-Seat der aktuellen Session via `/v1/license/release`
/// frei und vergisst die Session (Cache + DB). Best-Effort: Fehler
/// werden geloggt, aber nicht propagiert — beim Shutdown soll nichts
/// hängenbleiben.
pub async fn release_license(&self) {
let session = match self.cache.read().await.clone() {
Some(s) => Some(s),
None => self.load_session_from_db().await.ok().flatten(),
};
let Some(session) = session else {
return;
};
let body = ReleaseRequest {
app_names: &self.config.app_names,
};
let result = self
.http
.post(format!("{}/v1/license/release", self.config.rest_url))
.header("appKey", &self.config.app_key)
.header("sessionId", &session)
.header("Content-Type", "application/json")
.json(&body)
.send()
.await;
match result {
Ok(_) => info!("GSD: Lizenz freigegeben"),
Err(e) => warn!("GSD: Lizenz-Freigabe fehlgeschlagen (ignoriert): {}", e),
}
*self.cache.write().await = None;
if let Err(e) = self.clear_session_in_db().await {
warn!("GSD: Session konnte nicht aus DB gelöscht werden: {}", e);
}
}
// ─── Datei-Upload (3-stufig) ─────────────────────────────────────────
/// Ein vollständiger Upload-Versuch mit einer festen Session. Bei
/// `201` in irgendeinem Schritt → `GsdError::InvalidSession`, damit der
/// Aufrufer den ganzen Vorgang nach Re-Login wiederholen kann (die
/// `uploadId` ist an die Session gebunden).
async fn upload_once(
&self,
session: &str,
filename: &str,
mime: &str,
bytes: &[u8],
) -> Result<String, GsdError> {
// Schritt 1: Slot anfordern.
let url = format!("{}/v1/uploadFile", self.config.rest_url);
let resp = self
.http
.get(&url)
.header("appkey", &self.config.app_key)
.header("sessionId", session)
.send()
.await
.map_err(|e| GsdError::Other(Self::ext(e)))?;
let env: GsdEnvelope<UploadIdData> =
resp.json().await.map_err(|e| GsdError::Other(Self::ext(e)))?;
if env.is_invalid_session() {
return Err(GsdError::InvalidSession);
}
let upload_id = env
.data
.and_then(|d| d.upload_id)
.ok_or_else(|| GsdError::Other(ApplicationError::External("GSD: keine uploadId".into())))?;
// Schritt 2: Bytes als multipart/form-data senden (Feld `file`).
let part = reqwest::multipart::Part::bytes(bytes.to_vec())
.file_name(filename.to_owned())
.mime_str(mime)
.map_err(|e| GsdError::Other(Self::ext(e)))?;
let form = reqwest::multipart::Form::new().part("file", part);
let upload_url = format!("{}/v1/uploadFile/{}", self.config.rest_url, upload_id);
let resp = self
.http
.post(&upload_url)
.header("appkey", &self.config.app_key)
.header("sessionId", session)
.multipart(form)
.send()
.await
.map_err(|e| GsdError::Other(Self::ext(e)))?;
let env: GsdEnvelope<serde_json::Value> =
resp.json().await.map_err(|e| GsdError::Other(Self::ext(e)))?;
if env.is_invalid_session() {
return Err(GsdError::InvalidSession);
}
if !env.is_ok() {
return Err(GsdError::Other(ApplicationError::External(format!(
"GSD-Upload fehlgeschlagen: {}",
env.status_message()
))));
}
// Schritt 3: Commit → liefert ~ObjectID.
let resp = self
.http
.patch(&upload_url)
.header("appkey", &self.config.app_key)
.header("sessionId", session)
.send()
.await
.map_err(|e| GsdError::Other(Self::ext(e)))?;
let env: GsdEnvelope<CommitData> =
resp.json().await.map_err(|e| GsdError::Other(Self::ext(e)))?;
if env.is_invalid_session() {
return Err(GsdError::InvalidSession);
}
env.data
.and_then(|d| d.object_id)
.ok_or_else(|| GsdError::Other(ApplicationError::External("GSD: kein ~ObjectID".into())))
}
/// Ein Vorschau-Download-Versuch mit fester Session. Liefert bei einem
/// Bild-Content-Type die Bytes; bei JSON-Antwort (typisch für eine
/// ungültige Session) → `GsdError::InvalidSession` bzw. ein Fehler.
async fn download_preview_once(
&self,
session: &str,
object_id: &str,
parameters: &str,
page: &str,
) -> Result<PreviewImage, GsdError> {
let url = format!(
"{}/v1/preview/{}/{}/{}",
self.config.rest_url, parameters, object_id, page
);
let resp = self
.http
.get(&url)
.header("appkey", &self.config.app_key)
.header("sessionId", session)
.send()
.await
.map_err(|e| GsdError::Other(Self::ext(e)))?;
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_owned();
if content_type.starts_with("image/") {
let bytes = resp
.bytes()
.await
.map_err(|e| GsdError::Other(Self::ext(e)))?
.to_vec();
return Ok(PreviewImage {
bytes,
content_type,
});
}
// Kein Bild → vermutlich JSON-Fehlerumschlag (z. B. Session ungültig).
let env: GsdEnvelope<serde_json::Value> =
resp.json().await.map_err(|e| GsdError::Other(Self::ext(e)))?;
if env.is_invalid_session() {
return Err(GsdError::InvalidSession);
}
Err(GsdError::Other(ApplicationError::External(format!(
"GSD-Preview fehlgeschlagen: {}",
env.status_message()
))))
}
// ─── Makro-Aufruf (`POST /v1/execute/<name>`) ────────────────────────
/// Ein Makro-Aufruf mit fester Session. Liefert die rohe JSON-Antwort
/// (Envelope ODER flach — der Aufrufer extrahiert die Felder). Bei
/// `internalStatus == "201"` → `GsdError::InvalidSession` (Re-Login).
async fn execute_macro_once(
&self,
session: &str,
macro_name: &str,
body: &serde_json::Value,
) -> Result<serde_json::Value, GsdError> {
let url = format!("{}/v1/execute/{}", self.config.rest_url, macro_name);
let resp = self
.http
.post(&url)
.header("appkey", &self.config.app_key)
.header("sessionId", session)
.header("Content-Type", "application/json")
.json(body)
.send()
.await
.map_err(|e| GsdError::Other(Self::ext(e)))?;
let val: serde_json::Value =
resp.json().await.map_err(|e| GsdError::Other(Self::ext(e)))?;
let invalid = val
.get("status")
.and_then(|s| s.get("internalStatus"))
.and_then(|v| v.as_str())
== Some(super::dto::STATUS_INVALID_SESSION);
if invalid {
return Err(GsdError::InvalidSession);
}
Ok(val)
}
/// Ruft ein DOCUframe-Makro auf (mit Session + Re-Login-Retry). Liefert die
/// rohe JSON-Antwort.
pub async fn execute_macro(
&self,
macro_name: &str,
body: &serde_json::Value,
) -> Result<serde_json::Value, ApplicationError> {
let session = self.current_session().await?;
match self.execute_macro_once(&session, macro_name, body).await {
Ok(v) => Ok(v),
Err(GsdError::InvalidSession) => {
info!("GSD: Session ungültig, Re-Login und erneuter Makro-Aufruf");
let fresh = self.relogin(&session).await?;
self.execute_macro_once(&fresh, macro_name, body)
.await
.map_err(|e| match e {
GsdError::InvalidSession => ApplicationError::External(
"GSD: Session nach Re-Login weiterhin ungültig".into(),
),
GsdError::Other(inner) => inner,
})
}
Err(GsdError::Other(inner)) => Err(inner),
}
}
}
#[async_trait]
impl AttachmentStorage for GsdService {
async fn upload(
&self,
// DOCUframe legt keine Ordner nach Belegnummer an — der `folder`-
// Schlüssel wird hier (vorerst) ignoriert; später ggf. als Kategorie.
_folder: &str,
filename: &str,
mime: &str,
bytes: Vec<u8>,
) -> Result<String, ApplicationError> {
let session = self.current_session().await?;
match self.upload_once(&session, filename, mime, &bytes).await {
Ok(oid) => Ok(oid),
Err(GsdError::InvalidSession) => {
info!("GSD: Session ungültig, Re-Login und erneuter Upload");
let fresh = self.relogin(&session).await?;
self.upload_once(&fresh, filename, mime, &bytes)
.await
.map_err(|e| match e {
GsdError::InvalidSession => ApplicationError::External(
"GSD: Session nach Re-Login weiterhin ungültig".into(),
),
GsdError::Other(inner) => inner,
})
}
Err(GsdError::Other(inner)) => Err(inner),
}
}
async fn download_preview(
&self,
object_id: &str,
parameters: &str,
page: &str,
) -> Result<PreviewImage, ApplicationError> {
let session = self.current_session().await?;
match self
.download_preview_once(&session, object_id, parameters, page)
.await
{
Ok(img) => Ok(img),
Err(GsdError::InvalidSession) => {
info!("GSD: Session ungültig, Re-Login und erneuter Preview-Download");
let fresh = self.relogin(&session).await?;
self.download_preview_once(&fresh, object_id, parameters, page)
.await
.map_err(|e| match e {
GsdError::InvalidSession => ApplicationError::External(
"GSD: Session nach Re-Login weiterhin ungültig".into(),
),
GsdError::Other(inner) => inner,
})
}
Err(GsdError::Other(inner)) => Err(inner),
}
}
/// No-Op: In DOCUframe löschen wir nichts (der Report bleibt dort liegen).
async fn delete(&self, _reference: &str) -> Result<(), ApplicationError> {
Ok(())
}
}
#[async_trait]
impl DocuframeReportGateway for GsdService {
async fn upload_report_pdf(
&self,
belegnummer: &str,
pdf: Vec<u8>,
) -> Result<String, ApplicationError> {
// Report wie ein Bild hochladen — der 3-stufige Upload liefert die
// ~ObjectID. `folder` ist für DOCUframe irrelevant.
let filename = format!("Lieferbericht-{belegnummer}.pdf");
AttachmentStorage::upload(self, belegnummer, &filename, "application/pdf", pdf).await
}
async fn assign_report(
&self,
object_id: &str,
belegnummer: &str,
) -> Result<(), ApplicationError> {
let body = serde_json::to_value(super::dto::AssignReportRequest {
object_id,
belegnummer,
})
.map_err(Self::ext)?;
let val = self
.execute_macro("_SV_assignDeliveryReport", &body)
.await?;
// Das Makro liefert `{succeeded, message}` per RETURN(STRING). Wie
// `/v1/execute` das verpackt, ist nicht garantiert — daher robust gegen
// alle drei Formen: flach, Envelope mit `data`-Objekt, oder `data` als
// (escaptem) JSON-String. Wir prüfen die Kandidaten der Reihe nach.
let mut candidates: Vec<serde_json::Value> = vec![val.clone()];
if let Some(d) = val.get("data") {
if d.is_object() {
candidates.push(d.clone());
} else if let Some(s) = d.as_str() {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(s) {
candidates.push(parsed);
}
}
}
let succeeded = candidates
.iter()
.find_map(|c| c.get("succeeded").and_then(|v| v.as_bool()))
.unwrap_or(false);
if succeeded {
Ok(())
} else {
let msg = candidates
.iter()
.find_map(|c| c.get("message").and_then(|v| v.as_str()))
.unwrap_or("unbekannte/keine Antwort (Makro evtl. nicht vorhanden)");
Err(ApplicationError::External(format!(
"DOCUframe-Makro _SV_assignDeliveryReport succeeded=false: {msg}"
)))
}
}
}

View File

@ -10,4 +10,8 @@
//! passenden Application-Ports.
pub mod auth;
pub mod erp;
pub mod gsd;
pub mod persistence;
pub mod report;
pub mod storage;

View File

@ -0,0 +1,107 @@
//! Postgres-Implementierung des `AttachmentRepository`-Ports.
use async_trait::async_trait;
use sqlx::PgPool;
use uuid::Uuid;
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::{
AttachmentLocalRef, AttachmentRef, AttachmentRepository, NewAttachment,
};
pub struct PgAttachmentRepository {
pool: PgPool,
}
impl PgAttachmentRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
ApplicationError::Repository(e.to_string())
}
#[async_trait]
impl AttachmentRepository for PgAttachmentRepository {
async fn create(&self, attachment: NewAttachment) -> Result<Uuid, ApplicationError> {
let id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO attachments (
docuframe_object_id, mime_type, size_bytes, filename,
checksum_sha256, width, height, uploaded_by, delivery_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
"#,
)
.bind(attachment.docuframe_object_id)
.bind(attachment.mime_type)
.bind(attachment.size_bytes)
.bind(attachment.filename)
.bind(attachment.checksum_sha256)
.bind(attachment.width)
.bind(attachment.height)
.bind(attachment.uploaded_by)
.bind(attachment.delivery_id)
.fetch_one(&self.pool)
.await
.map_err(db)?;
Ok(id)
}
async fn get(&self, id: Uuid) -> Result<Option<AttachmentRef>, ApplicationError> {
let row: Option<(String, String)> = sqlx::query_as(
"SELECT docuframe_object_id, mime_type FROM attachments WHERE id = $1",
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(db)?;
Ok(row.map(|(docuframe_object_id, mime_type)| AttachmentRef {
docuframe_object_id,
mime_type,
}))
}
async fn delivery_belegnummer(
&self,
delivery_id: Uuid,
) -> Result<Option<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)?;
Ok(belegnummer)
}
async fn list_active_for_delivery(
&self,
delivery_id: Uuid,
) -> Result<Vec<AttachmentLocalRef>, ApplicationError> {
let rows: Vec<(Uuid, String)> = sqlx::query_as(
"SELECT id, docuframe_object_id FROM attachments \
WHERE delivery_id = $1 AND deleted_at IS NULL",
)
.bind(delivery_id)
.fetch_all(&self.pool)
.await
.map_err(db)?;
Ok(rows
.into_iter()
.map(|(id, reference)| AttachmentLocalRef { id, reference })
.collect())
}
async fn mark_deleted(&self, id: Uuid) -> Result<(), ApplicationError> {
sqlx::query("UPDATE attachments SET deleted_at = now() WHERE id = $1")
.bind(id)
.execute(&self.pool)
.await
.map_err(db)?;
Ok(())
}
}

View File

@ -0,0 +1,515 @@
//! Postgres-Implementierung des `DeliveryCompletionRepository`-Ports.
//!
//! Eine Transaktion, ein Abschluss. Ablauf:
//! 1. `SELECT … FOR UPDATE` auf die Lieferung (Lock + aktueller State).
//! 2. Idempotenz: schon `completed` mit Abschluss-Zeile → Erfolg zurück.
//! 3. Gates: `active`, alle scanbaren Positionen fertig, Notizen bestätigt.
//! 4. `INSERT INTO delivery_completions` + `UPDATE deliveries SET state`.
//! 5. Frische `Delivery` bauen.
use async_trait::async_trait;
use chrono::NaiveDate;
use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::{
CompleteDeliveryInput, DeliveryCompletionRepository, ErpWritebackData, ErpWritebackLine,
};
use holzleitner_domain::{Address, Delivery, DeliveryState};
pub struct PgDeliveryCompletionRepository {
pool: PgPool,
}
impl PgDeliveryCompletionRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)]
struct DeliveryRow {
id: Uuid,
tour_id: Uuid,
erp_belegart_id: i64,
erp_belegnummer: String,
customer_id: Uuid,
snap_street: String,
snap_house_number: String,
snap_postal_code: String,
snap_city: String,
snap_country: String,
assigned_car_id: Option<Uuid>,
desired_time: Option<String>,
special_agreements: Option<String>,
state: String,
prepaid_amount: f64,
payment_method_id: Uuid,
}
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
ApplicationError::Repository(e.to_string())
}
async fn lock_delivery(
tx: &mut Transaction<'_, Postgres>,
delivery_id: Uuid,
) -> Result<Option<DeliveryRow>, ApplicationError> {
sqlx::query_as::<_, DeliveryRow>(
r#"
SELECT
id, tour_id, erp_belegart_id, erp_belegnummer, customer_id,
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
assigned_car_id, desired_time, special_agreements,
state, prepaid_amount, payment_method_id
FROM deliveries
WHERE id = $1
FOR UPDATE
"#,
)
.bind(delivery_id)
.fetch_optional(&mut **tx)
.await
.map_err(db)
}
async fn load_contacts(
tx: &mut Transaction<'_, Postgres>,
delivery_id: Uuid,
) -> Result<Vec<Uuid>, ApplicationError> {
let rows: Vec<(Uuid,)> = sqlx::query_as(
"SELECT customer_contact_id FROM delivery_contact_persons WHERE delivery_id = $1",
)
.bind(delivery_id)
.fetch_all(&mut **tx)
.await
.map_err(db)?;
Ok(rows.into_iter().map(|(id,)| id).collect())
}
fn build_delivery(row: DeliveryRow, state: DeliveryState, state_reason: Option<String>, contacts: Vec<Uuid>) -> Delivery {
Delivery {
id: row.id,
tour_id: row.tour_id,
erp_belegart_id: row.erp_belegart_id,
erp_belegnummer: row.erp_belegnummer,
customer_id: row.customer_id,
delivery_address_snapshot: Address {
street: row.snap_street,
house_number: row.snap_house_number,
postal_code: row.snap_postal_code,
city: row.snap_city,
country: row.snap_country,
},
assigned_car_id: row.assigned_car_id,
contact_person_ids: contacts,
desired_time: row.desired_time,
special_agreements: row.special_agreements,
state,
state_reason,
prepaid_amount: row.prepaid_amount,
payment_method_id: row.payment_method_id,
}
}
#[async_trait]
impl DeliveryCompletionRepository for PgDeliveryCompletionRepository {
async fn complete(
&self,
input: CompleteDeliveryInput,
) -> Result<Delivery, ApplicationError> {
let delivery_id = input.delivery_id;
let mut tx = self.pool.begin().await.map_err(db)?;
let Some(mut row) = lock_delivery(&mut tx, delivery_id).await? else {
tx.rollback().await.map_err(db)?;
return Err(ApplicationError::NotFound);
};
// Idempotenz: bereits abgeschlossen + Abschluss-Zeile vorhanden →
// unveränderten Erfolg liefern (Netz-Retry nach erfolgreichem Commit).
if row.state == "completed" {
let exists: Option<Uuid> = sqlx::query_scalar(
"SELECT delivery_id FROM delivery_completions WHERE delivery_id = $1",
)
.bind(delivery_id)
.fetch_optional(&mut *tx)
.await
.map_err(db)?;
if exists.is_some() {
let contacts = load_contacts(&mut tx, delivery_id).await?;
tx.commit().await.map_err(db)?;
return Ok(build_delivery(row, DeliveryState::Completed, None, contacts));
}
tx.rollback().await.map_err(db)?;
return Err(ApplicationError::Validation(
"delivery already completed".into(),
));
}
if row.state != "active" {
tx.rollback().await.map_err(db)?;
return Err(ApplicationError::Validation(
"delivery is not active; cannot complete".into(),
));
}
// Empfangsbestätigung ist immer Pflicht (Doppel-Guard zum Use Case).
if !input.receipt_confirmed {
tx.rollback().await.map_err(db)?;
return Err(ApplicationError::Validation(
"receipt must be confirmed before completion".into(),
));
}
// Gate 1: alle scanbaren, nicht entfernten Positionen müssen fertig
// sein (`done`). Entfernte (`removed`) zählen nicht.
let open_scannables: i64 = sqlx::query_scalar(
r#"
SELECT COUNT(*)
FROM delivery_items di
JOIN articles a ON a.id = di.article_id
WHERE di.delivery_id = $1
AND a.scannable = true
AND di.scan_status NOT IN ('done', 'removed')
"#,
)
.bind(delivery_id)
.fetch_one(&mut *tx)
.await
.map_err(db)?;
if open_scannables > 0 {
tx.rollback().await.map_err(db)?;
return Err(ApplicationError::Validation(format!(
"{open_scannables} scannable item(s) not yet done; cannot complete"
)));
}
// Gate 2: existieren Notizen, muss der Kunde sie bestätigt haben.
let note_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM delivery_notes WHERE delivery_id = $1")
.bind(delivery_id)
.fetch_one(&mut *tx)
.await
.map_err(db)?;
if note_count > 0 && !input.notes_acknowledged {
tx.rollback().await.map_err(db)?;
return Err(ApplicationError::Validation(
"notes must be acknowledged before completion".into(),
));
}
// Gate 3: Zahlungsmethode-Override (falls gesetzt) muss existieren UND
// aktiv sein. `None` lässt die am Beleg hinterlegte Methode unangetastet.
let effective_payment_method_id = match input.payment_method_id {
Some(pm_id) => {
let active: Option<bool> =
sqlx::query_scalar("SELECT active FROM payment_methods WHERE id = $1")
.bind(pm_id)
.fetch_optional(&mut *tx)
.await
.map_err(db)?;
match active {
None => {
tx.rollback().await.map_err(db)?;
return Err(ApplicationError::Validation(
"unknown payment method".into(),
));
}
Some(false) => {
tx.rollback().await.map_err(db)?;
return Err(ApplicationError::Validation(
"payment method is not active".into(),
));
}
Some(true) => pm_id,
}
}
None => row.payment_method_id,
};
// Gate 4: Inkasso-Bestätigung. Besteht beim Abschluss ein offener
// Betrag (> 0) UND ist die Methode ein Vor-Ort-Inkasso (Bar/EC), muss
// der Fahrer bestätigt haben, dass kassiert wurde. „Auf Rechnung"
// (oder offen == 0) ⇒ kein Inkasso, keine Pflicht.
//
// Offener Betrag = Σ unit_price·(required credited) Anzahlung
// Gutschrift — exakt dieselbe Formel wie App-Übersicht & PDF-Report.
let warenwert: f64 = sqlx::query_scalar(
r#"
SELECT COALESCE(
SUM(unit_price * GREATEST(required_quantity - credited_quantity, 0)),
0
)::float8
FROM delivery_items
WHERE delivery_id = $1
"#,
)
.bind(delivery_id)
.fetch_one(&mut *tx)
.await
.map_err(db)?;
// Aktuelle Geld-Gutschrift: jüngstes Audit-Event ('set' → Betrag, sonst 0).
let credit_cents: i64 = sqlx::query_scalar(
r#"
SELECT COALESCE((
SELECT CASE WHEN action = 'set' THEN amount_cents ELSE 0 END
FROM delivery_credit_audit
WHERE delivery_id = $1
ORDER BY recorded_at DESC
LIMIT 1
), 0)
"#,
)
.bind(delivery_id)
.fetch_one(&mut *tx)
.await
.map_err(db)?;
let method_code: String =
sqlx::query_scalar("SELECT code FROM payment_methods WHERE id = $1")
.bind(effective_payment_method_id)
.fetch_one(&mut *tx)
.await
.map_err(db)?;
let open_euros =
(warenwert - row.prepaid_amount - (credit_cents as f64) / 100.0).max(0.0);
let open_cents = (open_euros * 100.0).round() as i64;
let requires_collection =
open_cents > 0 && (method_code == "cash" || method_code == "ec_card");
if requires_collection && !input.payment_collected {
tx.rollback().await.map_err(db)?;
return Err(ApplicationError::Validation(
"offener Betrag nicht als kassiert bestätigt; Abschluss nicht möglich".into(),
));
}
// Snapshot des kassierten Betrags nur, wenn tatsächlich Inkasso anfiel.
let collected_amount_cents: Option<i64> =
if requires_collection { Some(open_cents) } else { None };
sqlx::query(
r#"
INSERT INTO delivery_completions (
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
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
"#,
)
.bind(delivery_id)
.bind(&input.customer_signature_path)
.bind(&input.driver_signature_path)
.bind(input.receipt_confirmed)
.bind(input.notes_acknowledged)
.bind(&input.acknowledged_note_ids)
.bind(input.completed_by_personalnummer)
.bind(input.completed_by_car_id)
.bind(requires_collection && input.payment_collected)
.bind(collected_amount_cents)
.execute(&mut *tx)
.await
.map_err(db)?;
sqlx::query(
"UPDATE deliveries SET state = 'completed', state_reason = NULL, payment_method_id = $2 WHERE id = $1",
)
.bind(delivery_id)
.bind(effective_payment_method_id)
.execute(&mut *tx)
.await
.map_err(db)?;
let contacts = load_contacts(&mut tx, delivery_id).await?;
tx.commit().await.map_err(db)?;
// Rückgabe spiegelt den (ggf. überschriebenen) Stand.
row.payment_method_id = effective_payment_method_id;
Ok(build_delivery(row, DeliveryState::Completed, None, contacts))
}
async fn load_erp_writeback(
&self,
delivery_id: Uuid,
) -> Result<ErpWritebackData, ApplicationError> {
// Beleg-Key + Abschluss-Zeitpunkt in einem JOIN. INNER JOIN auf
// delivery_completions ⇒ ohne Abschluss-Zeile keine Zeile (NotFound).
let head: Option<(i64, String, chrono::DateTime<chrono::Utc>)> = sqlx::query_as(
r#"
SELECT d.erp_belegart_id, d.erp_belegnummer, c.completed_at
FROM deliveries d
JOIN delivery_completions c ON c.delivery_id = d.id
WHERE d.id = $1
"#,
)
.bind(delivery_id)
.fetch_optional(&self.pool)
.await
.map_err(db)?;
let Some((belegart_id, belegnummer, completed_at)) = head else {
return Err(ApplicationError::NotFound);
};
// Ausgelieferte Menge je Belegzeile = required credited.
// NUR Oberartikel/Normalzeilen (komponenten_artikel_nr IS NULL) — sie
// entsprechen 1:1 einer ERP-Belegzeile. Stücklisten-Komponenten teilen
// sich die belegzeilen_nr des Oberartikels und haben KEINE eigene
// ERP-Belegzeile; sie würden sonst mehrfache, widersprüchliche
// Mengen-Updates auf dieselbe Zeile auslösen.
let line_rows: Vec<(i32, i32)> = sqlx::query_as(
r#"
SELECT belegzeilen_nr, (required_quantity - credited_quantity)::int
FROM delivery_items
WHERE delivery_id = $1
AND komponenten_artikel_nr IS NULL
ORDER BY belegzeilen_nr
"#,
)
.bind(delivery_id)
.fetch_all(&self.pool)
.await
.map_err(db)?;
let lines = line_rows
.into_iter()
.map(|(belegzeilen_nr, delivered_quantity)| ErpWritebackLine {
belegzeilen_nr,
delivered_quantity,
})
.collect();
// Aktuelle Geld-Gutschrift: jüngstes Audit-Event. 'set' → Betrag,
// 'remove' (oder keine Zeile) → 0.
let credit: Option<(String, i64)> = sqlx::query_as(
r#"
SELECT action, amount_cents
FROM delivery_credit_audit
WHERE delivery_id = $1
ORDER BY recorded_at DESC
LIMIT 1
"#,
)
.bind(delivery_id)
.fetch_optional(&self.pool)
.await
.map_err(db)?;
let credit_amount_cents = match credit {
Some((action, cents)) if action == "set" => cents,
_ => 0,
};
// Beim Abschluss gewählte Zahlungsmethode → Code (cash/ec_card/invoice).
let payment_method_code: Option<String> = sqlx::query_scalar(
r#"
SELECT pm.code
FROM deliveries d
JOIN payment_methods pm ON pm.id = d.payment_method_id
WHERE d.id = $1
"#,
)
.bind(delivery_id)
.fetch_optional(&self.pool)
.await
.map_err(db)?;
Ok(ErpWritebackData {
belegart_id,
belegnummer,
// ERP erwartet lokale Zeit; completed_at ist UTC → in lokale
// Wanduhrzeit umrechnen und die TZ-Info fallenlassen.
delivered_at: completed_at.with_timezone(&chrono::Local).naive_local(),
lines,
credit_amount_cents,
payment_method_code,
})
}
async fn list_delivered_belegnummern(
&self,
day: Option<NaiveDate>,
) -> Result<Vec<String>, ApplicationError> {
// INNER JOIN auf delivery_completions ⇒ nur ausgelieferte (abgeschlossene)
// Lieferungen. `mail_sent_at IS NULL` ⇒ nur noch nicht versendete
// (server-seitiges Dedup für den Mailclient). Der optionale Tagesfilter:
// bei NULL ($1) ⇒ ALLE offenen über alle Tage; sonst der Berliner
// Kalendertag von completed_at (TIMESTAMPTZ = UTC-Instant → AT TIME ZONE
// 'Europe/Berlin' → ::date), damit ein Abschluss um 23:30 Ortszeit nicht
// fälschlich dem UTC-Folgetag zugeordnet wird.
let belegnummern: Vec<String> = sqlx::query_scalar(
r#"
SELECT d.erp_belegnummer
FROM deliveries d
JOIN delivery_completions c ON c.delivery_id = d.id
WHERE c.mail_sent_at IS NULL
AND ( $1::date IS NULL
OR (c.completed_at AT TIME ZONE 'Europe/Berlin')::date = $1 )
ORDER BY c.completed_at
"#,
)
.bind(day)
.fetch_all(&self.pool)
.await
.map_err(db)?;
Ok(belegnummern)
}
async fn mark_mail_sent(
&self,
belegnummern: &[String],
) -> Result<u64, ApplicationError> {
if belegnummern.is_empty() {
return Ok(0);
}
// Setzt mail_sent_at nur dort, wo noch NULL (idempotent — erster
// Versand-Zeitpunkt bleibt erhalten, mehrfaches Markieren ist harmlos).
// Match über die Belegnummer (was der GET-Endpoint zurückgibt).
let result = sqlx::query(
r#"
UPDATE delivery_completions c
SET mail_sent_at = now()
FROM deliveries d
WHERE c.delivery_id = d.id
AND d.erp_belegnummer = ANY($1)
AND c.mail_sent_at IS NULL
"#,
)
.bind(belegnummern)
.execute(&self.pool)
.await
.map_err(db)?;
Ok(result.rows_affected())
}
async fn unmark_mail_sent(
&self,
belegnummern: &[String],
) -> Result<u64, ApplicationError> {
if belegnummern.is_empty() {
return Ok(0);
}
// DEV: mail_sent_at zurück auf NULL, nur wo aktuell gesetzt.
let result = sqlx::query(
r#"
UPDATE delivery_completions c
SET mail_sent_at = NULL
FROM deliveries d
WHERE c.delivery_id = d.id
AND d.erp_belegnummer = ANY($1)
AND c.mail_sent_at IS NOT NULL
"#,
)
.bind(belegnummern)
.execute(&self.pool)
.await
.map_err(db)?;
Ok(result.rows_affected())
}
}

View File

@ -0,0 +1,139 @@
//! Postgres-Implementierung des `DeliveryCreditRepository`-Ports.
//!
//! Append-only: `apply_event` hängt eine Zeile ans `delivery_credit_audit`
//! und liest danach den aktuellen Stand (jüngstes Ereignis). Idempotent über
//! `client_event_id`; `set`/`remove` nur bei `active`er Lieferung.
use async_trait::async_trait;
use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;
use holzleitner_application::dto::CreditAction;
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::DeliveryCreditRepository;
use holzleitner_domain::DeliveryCredit;
pub struct PgDeliveryCreditRepository {
pool: PgPool,
}
impl PgDeliveryCreditRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
ApplicationError::Repository(e.to_string())
}
fn action_str(a: CreditAction) -> &'static str {
match a {
CreditAction::Set => "set",
CreditAction::Remove => "remove",
}
}
/// Liest den aktuellen Gutschrift-Stand einer Lieferung = jüngstes Ereignis.
/// `set` → `Some(..)`, `remove` (oder kein Ereignis) → `None`.
async fn current_credit(
tx: &mut Transaction<'_, Postgres>,
delivery_id: Uuid,
) -> Result<Option<DeliveryCredit>, ApplicationError> {
let row: Option<(String, i64, Option<String>)> = sqlx::query_as(
r#"
SELECT action, amount_cents, reason
FROM delivery_credit_audit
WHERE delivery_id = $1
ORDER BY recorded_at DESC, id DESC
LIMIT 1
"#,
)
.bind(delivery_id)
.fetch_optional(&mut **tx)
.await
.map_err(db)?;
Ok(match row {
Some((action, amount_cents, reason)) if action == "set" => Some(DeliveryCredit {
delivery_id,
amount_cents,
reason: reason.unwrap_or_default(),
}),
_ => None,
})
}
#[async_trait]
impl DeliveryCreditRepository for PgDeliveryCreditRepository {
async fn apply_event(
&self,
delivery_id: Uuid,
client_event_id: Uuid,
action: CreditAction,
amount_cents: i64,
reason: Option<String>,
author_personalnummer: i64,
author_car_id: Option<Uuid>,
) -> Result<Option<DeliveryCredit>, ApplicationError> {
let mut tx = self.pool.begin().await.map_err(db)?;
// Idempotenz: ist die client_event_id schon bekannt, nichts erneut
// anwenden — nur den aktuellen Stand liefern (ohne active-Check, das
// Ereignis wurde ja bereits akzeptiert).
let already: Option<Uuid> = sqlx::query_scalar(
"SELECT id FROM delivery_credit_audit WHERE client_event_id = $1",
)
.bind(client_event_id)
.fetch_optional(&mut *tx)
.await
.map_err(db)?;
if already.is_some() {
let current = current_credit(&mut tx, delivery_id).await?;
tx.rollback().await.map_err(db)?;
return Ok(current);
}
// Frisches Ereignis: Lieferung muss existieren und aktiv sein.
let state: Option<String> =
sqlx::query_scalar("SELECT state FROM deliveries WHERE id = $1 FOR UPDATE")
.bind(delivery_id)
.fetch_optional(&mut *tx)
.await
.map_err(db)?;
let Some(state) = state else {
tx.rollback().await.map_err(db)?;
return Err(ApplicationError::NotFound);
};
if state != "active" {
tx.rollback().await.map_err(db)?;
return Err(ApplicationError::Validation(
"delivery is not active; cannot change credit".into(),
));
}
sqlx::query(
r#"
INSERT INTO delivery_credit_audit (
client_event_id, delivery_id, action, amount_cents, reason,
author_personalnummer, author_car_id
) VALUES ($1, $2, $3, $4, $5, $6, $7)
"#,
)
.bind(client_event_id)
.bind(delivery_id)
.bind(action_str(action))
.bind(amount_cents)
.bind(reason.as_deref())
.bind(author_personalnummer)
.bind(author_car_id)
.execute(&mut *tx)
.await
.map_err(db)?;
let current = current_credit(&mut tx, delivery_id).await?;
tx.commit().await.map_err(db)?;
Ok(current)
}
}

View File

@ -28,6 +28,7 @@ fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
#[async_trait]
impl DeliveryNoteRepository for PgDeliveryNoteRepository {
#[allow(clippy::too_many_arguments)]
async fn create(
&self,
delivery_id: Uuid,
@ -35,6 +36,8 @@ impl DeliveryNoteRepository for PgDeliveryNoteRepository {
author_car_id: Option<Uuid>,
text: Option<String>,
image_attachment: Option<String>,
credit_delivery_item_id: Option<Uuid>,
is_amount_credit_note: bool,
) -> Result<DeliveryNote, ApplicationError> {
let exists: Option<Uuid> =
sqlx::query_scalar("SELECT id FROM deliveries WHERE id = $1")
@ -49,8 +52,9 @@ impl DeliveryNoteRepository for PgDeliveryNoteRepository {
let (id, created_at): (Uuid, DateTime<Utc>) = sqlx::query_as(
r#"
INSERT INTO delivery_notes (
delivery_id, text, image_attachment, author_personalnummer, author_car_id
) VALUES ($1, $2, $3, $4, $5)
delivery_id, text, image_attachment, author_personalnummer,
author_car_id, credit_delivery_item_id, is_amount_credit_note
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, created_at
"#,
)
@ -59,6 +63,8 @@ impl DeliveryNoteRepository for PgDeliveryNoteRepository {
.bind(image_attachment.as_deref())
.bind(author_personalnummer)
.bind(author_car_id)
.bind(credit_delivery_item_id)
.bind(is_amount_credit_note)
.fetch_one(&self.pool)
.await
.map_err(db)?;
@ -70,7 +76,86 @@ impl DeliveryNoteRepository for PgDeliveryNoteRepository {
image_attachment,
author_personalnummer,
author_car_id,
credit_delivery_item_id,
is_amount_credit_note,
// Frisch angelegt → Bild (falls vorhanden) liegt lokal vor.
image_attachment_deleted: false,
created_at,
})
}
async fn update(
&self,
note_id: Uuid,
text: Option<String>,
image_attachment: Option<String>,
) -> Result<DeliveryNote, ApplicationError> {
// RETURNING liefert die vollständige Zeile zurück — kein zweiter
// Read nötig. `fetch_optional` unterscheidet „nicht gefunden" sauber
// von DB-Fehlern.
let row: Option<(
Uuid,
Uuid,
Option<String>,
Option<String>,
i64,
Option<Uuid>,
Option<Uuid>,
bool,
DateTime<Utc>,
)> = sqlx::query_as(
r#"
UPDATE delivery_notes
SET text = $2, image_attachment = $3
WHERE id = $1
RETURNING id, delivery_id, text, image_attachment,
author_personalnummer, author_car_id,
credit_delivery_item_id, is_amount_credit_note, created_at
"#,
)
.bind(note_id)
.bind(text.as_deref())
.bind(image_attachment.as_deref())
.fetch_optional(&self.pool)
.await
.map_err(db)?;
match row {
None => Err(ApplicationError::NotFound),
Some((
id,
delivery_id,
text,
image_attachment,
author_personalnummer,
author_car_id,
credit_delivery_item_id,
is_amount_credit_note,
created_at,
)) => Ok(DeliveryNote {
id,
delivery_id,
text,
image_attachment,
author_personalnummer,
author_car_id,
credit_delivery_item_id,
is_amount_credit_note,
image_attachment_deleted: false,
created_at,
}),
}
}
async fn delete(&self, note_id: Uuid) -> Result<(), ApplicationError> {
let result = sqlx::query("DELETE FROM delivery_notes WHERE id = $1")
.bind(note_id)
.execute(&self.pool)
.await
.map_err(db)?;
if result.rows_affected() == 0 {
return Err(ApplicationError::NotFound);
}
Ok(())
}
}

View File

@ -0,0 +1,150 @@
//! Postgres-Implementierung von `DeliveryReportJobRepository`.
//!
//! Spiegelt `delivery_report_jobs` — der harte Zustandsanker der
//! Report-Übertragung an DOCUframe (für Resume + Cron-Retry).
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use uuid::Uuid;
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::{DeliveryReportJobRepository, ReportJob, ReportJobStatus};
pub struct PgDeliveryReportJobRepository {
pool: PgPool,
}
impl PgDeliveryReportJobRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
ApplicationError::Repository(e.to_string())
}
#[derive(sqlx::FromRow)]
struct JobRow {
delivery_id: Uuid,
belegnummer: String,
status: String,
docuframe_object_id: Option<String>,
report_uploaded_at: Option<DateTime<Utc>>,
attempts: i32,
last_error: Option<String>,
}
impl From<JobRow> for ReportJob {
fn from(r: JobRow) -> Self {
ReportJob {
delivery_id: r.delivery_id,
belegnummer: r.belegnummer,
status: ReportJobStatus::parse(&r.status),
docuframe_object_id: r.docuframe_object_id,
report_uploaded_at: r.report_uploaded_at,
attempts: r.attempts,
last_error: r.last_error,
}
}
}
const SELECT: &str = "SELECT delivery_id, belegnummer, status, docuframe_object_id, \
report_uploaded_at, attempts, last_error FROM delivery_report_jobs";
#[async_trait]
impl DeliveryReportJobRepository for PgDeliveryReportJobRepository {
async fn ensure(
&self,
delivery_id: Uuid,
belegnummer: &str,
) -> Result<ReportJob, ApplicationError> {
// Idempotent: vorhandenen Job NICHT zurücksetzen. DO UPDATE (no-op auf
// belegnummer) nur, damit RETURNING auch bei Konflikt die Zeile liefert.
let row: JobRow = sqlx::query_as(
r#"
INSERT INTO delivery_report_jobs (delivery_id, belegnummer)
VALUES ($1, $2)
ON CONFLICT (delivery_id)
DO UPDATE SET belegnummer = EXCLUDED.belegnummer
RETURNING delivery_id, belegnummer, status, docuframe_object_id,
report_uploaded_at, attempts, last_error
"#,
)
.bind(delivery_id)
.bind(belegnummer)
.fetch_one(&self.pool)
.await
.map_err(db)?;
Ok(row.into())
}
async fn get(&self, delivery_id: Uuid) -> Result<Option<ReportJob>, ApplicationError> {
let row: Option<JobRow> = sqlx::query_as(&format!("{SELECT} WHERE delivery_id = $1"))
.bind(delivery_id)
.fetch_optional(&self.pool)
.await
.map_err(db)?;
Ok(row.map(Into::into))
}
async fn list_open(&self) -> Result<Vec<ReportJob>, ApplicationError> {
let rows: Vec<JobRow> =
sqlx::query_as(&format!("{SELECT} WHERE status <> 'done' ORDER BY created_at"))
.fetch_all(&self.pool)
.await
.map_err(db)?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn set_uploaded(
&self,
delivery_id: Uuid,
object_id: &str,
) -> Result<(), ApplicationError> {
sqlx::query(
"UPDATE delivery_report_jobs \
SET status = 'uploaded', docuframe_object_id = $2, updated_at = now() \
WHERE delivery_id = $1",
)
.bind(delivery_id)
.bind(object_id)
.execute(&self.pool)
.await
.map_err(db)?;
Ok(())
}
async fn mark_done(&self, delivery_id: Uuid) -> Result<(), ApplicationError> {
sqlx::query(
"UPDATE delivery_report_jobs \
SET status = 'done', report_uploaded_at = now(), updated_at = now() \
WHERE delivery_id = $1",
)
.bind(delivery_id)
.execute(&self.pool)
.await
.map_err(db)?;
Ok(())
}
async fn record_error(
&self,
delivery_id: Uuid,
error: &str,
) -> Result<(), ApplicationError> {
sqlx::query(
"UPDATE delivery_report_jobs \
SET attempts = attempts + 1, last_error = $2, last_attempt_at = now(), \
updated_at = now() \
WHERE delivery_id = $1",
)
.bind(delivery_id)
.bind(error)
.execute(&self.pool)
.await
.map_err(db)?;
Ok(())
}
}

View File

@ -43,6 +43,8 @@ struct DeliveryRow {
desired_time: Option<String>,
special_agreements: Option<String>,
state: String,
prepaid_amount: f64,
payment_method_id: Uuid,
}
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
@ -81,7 +83,15 @@ fn next_state(
use DeliveryState as S;
match (current, action) {
(S::Active, A::Hold { reason }) => Ok((S::Held, Some(reason.clone()))),
(S::Held, A::Resume) => Ok((S::Active, None)),
// `Resume` führt sowohl aus `Held` als auch aus `Canceled`
// zurück auf `Active`. Die App erzwingt vor Cancel-Recovery
// einen extra Bestätigungsdialog; technisch sind beide Pfade
// identisch. Der `state_reason` wird in beiden Fällen
// gelöscht — der Audit-Trail dazu lebt aktuell nur am Reason
// selbst und geht damit verloren. Schließt sich in Phase G
// (siehe `docs/BACKEND_MIGRATION.md`): eigenes
// `delivery_audit`-Log analog zu `scan_audit`.
(S::Held | S::Canceled, A::Resume) => Ok((S::Active, None)),
(S::Active | S::Held, A::Cancel { reason }) => Ok((S::Canceled, Some(reason.clone()))),
(S::Active, A::Complete) => Ok((S::Completed, None)),
@ -104,7 +114,7 @@ async fn lock_delivery(
id, tour_id, erp_belegart_id, erp_belegnummer, customer_id,
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
assigned_car_id, desired_time, special_agreements,
state
state, prepaid_amount, payment_method_id
FROM deliveries
WHERE id = $1
FOR UPDATE
@ -190,6 +200,8 @@ impl DeliveryRepository for PgDeliveryRepository {
special_agreements: row.special_agreements,
state: target,
state_reason: new_reason,
prepaid_amount: row.prepaid_amount,
payment_method_id: row.payment_method_id,
})
}
@ -245,6 +257,8 @@ impl DeliveryRepository for PgDeliveryRepository {
special_agreements: row.special_agreements,
state: current,
state_reason,
prepaid_amount: row.prepaid_amount,
payment_method_id: row.payment_method_id,
})
}
}

View File

@ -0,0 +1,111 @@
//! Postgres-Implementierung des `DeliveryServiceRepository`-Ports (Upsert).
use async_trait::async_trait;
use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::DeliveryServiceRepository;
use holzleitner_domain::DeliveryServiceValue;
pub struct PgDeliveryServiceRepository {
pool: PgPool,
}
impl PgDeliveryServiceRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
ApplicationError::Repository(e.to_string())
}
/// Lieferung muss existieren und `active` sein, sonst Reject. Lock auf der
/// deliveries-Zeile innerhalb der Transaktion.
async fn assert_delivery_active(
tx: &mut Transaction<'_, Postgres>,
delivery_id: Uuid,
) -> Result<(), ApplicationError> {
let state: Option<String> =
sqlx::query_scalar("SELECT state FROM deliveries WHERE id = $1 FOR UPDATE")
.bind(delivery_id)
.fetch_optional(&mut **tx)
.await
.map_err(db)?;
match state {
None => Err(ApplicationError::NotFound),
Some(s) if s != "active" => Err(ApplicationError::Validation(
"delivery is not active; cannot change services".into(),
)),
Some(_) => Ok(()),
}
}
#[async_trait]
impl DeliveryServiceRepository for PgDeliveryServiceRepository {
async fn set(
&self,
delivery_id: Uuid,
service_id: Uuid,
bool_value: Option<bool>,
numeric_value: Option<i32>,
author_personalnummer: i64,
author_car_id: Option<Uuid>,
) -> Result<DeliveryServiceValue, ApplicationError> {
let mut tx = self.pool.begin().await.map_err(db)?;
assert_delivery_active(&mut tx, delivery_id).await?;
sqlx::query(
r#"
INSERT INTO delivery_services (
delivery_id, service_id, bool_value, numeric_value,
author_personalnummer, author_car_id, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, now())
ON CONFLICT (delivery_id, service_id) DO UPDATE SET
bool_value = EXCLUDED.bool_value,
numeric_value = EXCLUDED.numeric_value,
author_personalnummer = EXCLUDED.author_personalnummer,
author_car_id = EXCLUDED.author_car_id,
updated_at = now()
"#,
)
.bind(delivery_id)
.bind(service_id)
.bind(bool_value)
.bind(numeric_value)
.bind(author_personalnummer)
.bind(author_car_id)
.execute(&mut *tx)
.await
.map_err(db)?;
tx.commit().await.map_err(db)?;
Ok(DeliveryServiceValue {
delivery_id,
service_id,
bool_value,
numeric_value,
})
}
async fn delete(
&self,
delivery_id: Uuid,
service_id: Uuid,
) -> Result<(), ApplicationError> {
let mut tx = self.pool.begin().await.map_err(db)?;
assert_delivery_active(&mut tx, delivery_id).await?;
sqlx::query(
"DELETE FROM delivery_services WHERE delivery_id = $1 AND service_id = $2",
)
.bind(delivery_id)
.bind(service_id)
.execute(&mut *tx)
.await
.map_err(db)?;
tx.commit().await.map_err(db)?;
Ok(())
}
}

View File

@ -5,17 +5,31 @@
//! und Migrations werden ebenfalls hier verwaltet.
pub mod account_repository;
pub mod attachment_repository;
pub mod car_repository;
pub mod delivery_completion_repository;
pub mod delivery_credit_repository;
pub mod delivery_note_repository;
pub mod delivery_report_job_repository;
pub mod delivery_repository;
pub mod delivery_service_repository;
pub mod payment_method_repository;
pub mod pool;
pub mod scan_repository;
pub mod service_repository;
pub mod tour_repository;
pub use account_repository::PgAccountRepository;
pub use attachment_repository::PgAttachmentRepository;
pub use car_repository::PgCarRepository;
pub use delivery_completion_repository::PgDeliveryCompletionRepository;
pub use delivery_credit_repository::PgDeliveryCreditRepository;
pub use delivery_note_repository::PgDeliveryNoteRepository;
pub use delivery_report_job_repository::PgDeliveryReportJobRepository;
pub use delivery_repository::PgDeliveryRepository;
pub use delivery_service_repository::PgDeliveryServiceRepository;
pub use payment_method_repository::PgPaymentMethodRepository;
pub use pool::{connect_and_migrate, PoolConfig};
pub use scan_repository::PgScanRepository;
pub use service_repository::PgServiceRepository;
pub use tour_repository::PgTourRepository;

View File

@ -0,0 +1,174 @@
//! Postgres-Implementierung des `PaymentMethodRepository`-Ports.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use uuid::Uuid;
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::PaymentMethodRepository;
use holzleitner_domain::PaymentMethod;
pub struct PgPaymentMethodRepository {
pool: PgPool,
}
impl PgPaymentMethodRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)]
struct PaymentMethodRow {
id: Uuid,
code: String,
name: String,
active: bool,
created_at: DateTime<Utc>,
}
impl From<PaymentMethodRow> for PaymentMethod {
fn from(r: PaymentMethodRow) -> Self {
PaymentMethod {
id: r.id,
code: r.code,
name: r.name,
active: r.active,
created_at: r.created_at,
}
}
}
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
ApplicationError::Repository(e.to_string())
}
/// Postgres-SQLSTATE-Codes — wir interessieren uns für zwei:
///
/// * `23505 unique_violation` — `code`-Duplikat beim INSERT
/// * `23503 foreign_key_violation` — Lieferungen zeigen noch auf die
/// Methode, die gerade gelöscht werden soll (RESTRICT)
fn pg_sqlstate(err: &sqlx::Error) -> Option<String> {
if let sqlx::Error::Database(db_err) = err {
return db_err.code().map(|c| c.into_owned());
}
None
}
#[async_trait]
impl PaymentMethodRepository for PgPaymentMethodRepository {
async fn list(
&self,
include_inactive: bool,
) -> Result<Vec<PaymentMethod>, ApplicationError> {
let rows = sqlx::query_as::<_, PaymentMethodRow>(
r#"
SELECT id, code, name, active, created_at
FROM payment_methods
WHERE (active = TRUE OR $1)
ORDER BY name
"#,
)
.bind(include_inactive)
.fetch_all(&self.pool)
.await
.map_err(db)?;
Ok(rows.into_iter().map(PaymentMethod::from).collect())
}
async fn find_by_id(
&self,
id: Uuid,
) -> Result<Option<PaymentMethod>, ApplicationError> {
let row = sqlx::query_as::<_, PaymentMethodRow>(
r#"
SELECT id, code, name, active, created_at
FROM payment_methods
WHERE id = $1
"#,
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(db)?;
Ok(row.map(PaymentMethod::from))
}
async fn create(
&self,
code: &str,
name: &str,
) -> Result<PaymentMethod, ApplicationError> {
match sqlx::query_as::<_, PaymentMethodRow>(
r#"
INSERT INTO payment_methods (code, name)
VALUES ($1, $2)
RETURNING id, code, name, active, created_at
"#,
)
.bind(code)
.bind(name)
.fetch_one(&self.pool)
.await
{
Ok(row) => Ok(row.into()),
Err(e) if pg_sqlstate(&e).as_deref() == Some("23505") => Err(
ApplicationError::Conflict(format!(
"payment method with code '{code}' already exists"
)),
),
Err(e) => Err(db(e)),
}
}
async fn update(
&self,
id: Uuid,
name: Option<&str>,
active: Option<bool>,
) -> Result<PaymentMethod, ApplicationError> {
// COALESCE-Pattern lässt unveränderte Felder durch die DB-Spalte
// fließen — wir senden für „nicht ändern" einfach NULL.
let row = sqlx::query_as::<_, PaymentMethodRow>(
r#"
UPDATE payment_methods
SET name = COALESCE($2, name),
active = COALESCE($3, active)
WHERE id = $1
RETURNING id, code, name, active, created_at
"#,
)
.bind(id)
.bind(name)
.bind(active)
.fetch_optional(&self.pool)
.await
.map_err(db)?;
row.map(PaymentMethod::from)
.ok_or(ApplicationError::NotFound)
}
async fn delete(&self, id: Uuid) -> Result<(), ApplicationError> {
match sqlx::query("DELETE FROM payment_methods WHERE id = $1")
.bind(id)
.execute(&self.pool)
.await
{
Ok(result) => {
if result.rows_affected() == 0 {
Err(ApplicationError::NotFound)
} else {
Ok(())
}
}
Err(e) if pg_sqlstate(&e).as_deref() == Some("23503") => Err(
ApplicationError::Conflict(
"payment method is in use by at least one delivery"
.to_string(),
),
),
Err(e) => Err(db(e)),
}
}
}

View File

@ -38,8 +38,10 @@ impl PgScanRepository {
#[derive(sqlx::FromRow)]
struct ItemLockRow {
id: Uuid,
delivery_id: Uuid,
required_quantity: i32,
scanned_quantity: i32,
credited_quantity: i32,
scan_status: String,
held_reason: Option<String>,
scan_last_updated_at: DateTime<Utc>,
@ -47,6 +49,11 @@ struct ItemLockRow {
komponenten_artikel_nr: Option<String>,
erp_belegart_id: i64,
erp_belegnummer: String,
/// `articles.scannable` der Position — entscheidet, ob für eine
/// Gutschrift erst gescannt (`Done`) sein muss.
scannable: bool,
/// `deliveries.state` — Gutschriften nur bei `active`.
delivery_state: String,
}
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
@ -81,40 +88,86 @@ fn action_str(a: AuditAction) -> &'static str {
AuditAction::Hold => "hold",
AuditAction::Unhold => "unhold",
AuditAction::Remove => "remove",
AuditAction::Unremove => "unremove",
}
}
/// Ergebnis einer reinen Zustandsübergangs-Rechnung (ohne DB).
struct Transition {
/// Signed Δ der SCAN-Menge (+1/-1 bei Scan/Unscan, sonst 0).
delta: i32,
new_quantity: i32,
new_status: ScanStatus,
new_held_reason: Option<String>,
/// Signed Δ der GUTSCHRIFT-Menge: `Some(+n)` bei Remove, `Some(-n)` bei
/// Unremove, sonst `None` (Audit-Spalte bleibt dann NULL).
credit_delta: Option<i32>,
/// Neuer Stand `credited_quantity` nach der Aktion (für das Item-Update).
new_credited_quantity: i32,
}
/// Schnappschuss des relevanten Item-Zustands für die reine
/// Übergangs-Rechnung — bündelt die vielen Parameter, die Remove/Unremove
/// jetzt brauchen.
struct ItemSnapshot<'a> {
current_qty: i32,
current_credited: i32,
current_status: ScanStatus,
required_qty: i32,
scannable: bool,
/// `deliveries.state` als String ('active' / 'held' / …).
delivery_state: &'a str,
}
/// Berechnet den nächsten Zustand. Bei `Err` enthält der String die
/// fachliche Ablehnungs-Begründung, die 1:1 an die App geht.
///
/// `quantity` ist nur für Remove/Unremove relevant (Mengen-Gutschrift);
/// `None` heißt dort „ganze Restmenge".
fn apply_transition(
action: AuditAction,
current_qty: i32,
current_status: ScanStatus,
required_qty: i32,
item: &ItemSnapshot<'_>,
quantity: Option<i32>,
reason: Option<&str>,
) -> Result<Transition, String> {
let current_qty = item.current_qty;
let current_status = item.current_status;
let required_qty = item.required_qty;
let current_credited = item.current_credited;
match action {
AuditAction::Scan => match current_status {
ScanStatus::InProgress | ScanStatus::Done => {
let new_qty = current_qty + 1;
// `quantity = None` → +1 (regulärer Einzel-Barcode-Scan).
// `quantity = Some(n)` → n Stück auf einmal (manuelle
// Zeilen-Bestätigung der Restmenge). n muss in
// [1 .. required scanned] liegen, damit nicht über das Soll
// hinaus „gescannt" wird.
let n = match quantity {
None => 1,
Some(n) => {
let remaining = required_qty - current_qty;
if n <= 0 || n > remaining {
return Err(format!(
"invalid scan quantity {n}; remaining scannable {remaining}"
));
}
n
}
};
let new_qty = current_qty + n;
let new_status = if new_qty >= required_qty {
ScanStatus::Done
} else {
ScanStatus::InProgress
};
Ok(Transition {
delta: 1,
delta: n,
new_quantity: new_qty,
new_status,
new_held_reason: None,
credit_delta: None,
new_credited_quantity: current_credited,
})
}
ScanStatus::Held => Err("item is on hold; unhold before scanning".into()),
@ -131,6 +184,8 @@ fn apply_transition(
new_quantity: new_qty,
new_status: ScanStatus::InProgress,
new_held_reason: None,
credit_delta: None,
new_credited_quantity: current_credited,
})
}
ScanStatus::Held => Err("item is on hold".into()),
@ -142,6 +197,8 @@ fn apply_transition(
new_quantity: current_qty,
new_status: ScanStatus::Held,
new_held_reason: reason.map(str::to_owned),
credit_delta: None,
new_credited_quantity: current_credited,
}),
ScanStatus::Held => Err("item is already held".into()),
ScanStatus::Removed => Err("item is removed".into()),
@ -158,19 +215,101 @@ fn apply_transition(
new_quantity: current_qty,
new_status,
new_held_reason: None,
credit_delta: None,
new_credited_quantity: current_credited,
})
}
_ => Err("item is not held".into()),
},
AuditAction::Remove => match current_status {
ScanStatus::Removed => Err("item is already removed".into()),
_ => Ok(Transition {
// ── Mengen-Gutschrift ───────────────────────────────────────────
// Entscheidungstabelle (siehe Design):
// * Lieferung muss `active` sein
// * scannbare Position muss `Done` sein (erst verladen)
// * Menge muss in [1 .. Restmenge] liegen
// * Status → `Removed` erst wenn voll gutgeschrieben
AuditAction::Remove => {
if item.delivery_state != "active" {
return Err("delivery is not active; cannot credit".into());
}
if item.scannable && current_status != ScanStatus::Done {
return Err(
"scannable item must be scanned (done) before it can be credited".into(),
);
}
if current_status == ScanStatus::Held {
return Err("item is on hold; resume before crediting".into());
}
let remaining = required_qty - current_credited;
let n = quantity.unwrap_or(remaining);
if n <= 0 || n > remaining {
return Err(format!(
"invalid credit quantity {n}; remaining creditable {remaining}"
));
}
let new_credited = current_credited + n;
// Teil-Gutschrift lässt den Status unangetastet (Zeile wird
// weiter teilweise ausgeliefert); erst die volle Menge macht
// die Zeile zu `Removed`. `held_reason` trägt den Grund nur im
// Removed-Fall (Embed); die volle Historie steht ohnehin im Audit.
let fully = new_credited >= required_qty;
let new_status = if fully {
ScanStatus::Removed
} else {
current_status
};
Ok(Transition {
delta: 0,
new_quantity: current_qty,
new_status: ScanStatus::Removed,
new_held_reason: reason.map(str::to_owned),
}),
},
new_status,
new_held_reason: if fully {
reason.map(str::to_owned)
} else {
None
},
credit_delta: Some(n),
new_credited_quantity: new_credited,
})
}
// Gutschrift (teilweise) zurücknehmen. Greift jetzt mengenbasiert:
// solange `credited_quantity > 0`, lässt sich etwas wiederherstellen
// — unabhängig davon, ob die Zeile schon ganz auf `Removed` stand.
AuditAction::Unremove => {
if item.delivery_state != "active" {
return Err("delivery is not active; cannot restore".into());
}
if current_credited <= 0 {
return Err("nothing credited; nothing to restore".into());
}
let n = quantity.unwrap_or(current_credited);
if n <= 0 || n > current_credited {
return Err(format!(
"invalid restore quantity {n}; credited {current_credited}"
));
}
let new_credited = current_credited - n;
// Aus `Removed` zurück: Status nach Scan-Menge bestimmen.
// War die Zeile nur teil-gutgeschrieben (Status z. B. `Done`),
// bleibt er, was er war.
let new_status = if current_status == ScanStatus::Removed {
if current_qty >= required_qty {
ScanStatus::Done
} else {
ScanStatus::InProgress
}
} else {
current_status
};
Ok(Transition {
delta: 0,
new_quantity: current_qty,
new_status,
new_held_reason: None,
credit_delta: Some(-n),
new_credited_quantity: new_credited,
})
}
}
}
@ -182,17 +321,22 @@ async fn lock_item(
r#"
SELECT
di.id,
di.delivery_id,
di.required_quantity,
di.scanned_quantity,
di.credited_quantity,
di.scan_status,
di.held_reason,
di.scan_last_updated_at,
di.belegzeilen_nr,
di.komponenten_artikel_nr,
d.erp_belegart_id,
d.erp_belegnummer
d.erp_belegnummer,
a.scannable,
d.state AS delivery_state
FROM delivery_items di
JOIN deliveries d ON d.id = di.delivery_id
JOIN articles a ON a.id = di.article_id
WHERE di.id = $1
FOR UPDATE OF di
"#,
@ -203,6 +347,104 @@ async fn lock_item(
.map_err(db)
}
/// Markiert alle (noch nicht entfernten) Komponenten eines Oberartikels als
/// entfernt — gleiche Belegzeile, `komponenten_artikel_nr IS NOT NULL`. Setzt
/// volle Gutschrift (credited = required, Status `removed`) und schreibt je
/// Komponente einen Audit-Eintrag. Läuft in der Transaktion des auslösenden
/// Oberartikel-Removes (durch dessen Idempotenz genau einmal). Komponenten
/// haben keine eigene ERP-Belegzeile → kein Einfluss aufs ERP-Rückschreiben.
async fn cascade_remove_components(
tx: &mut Transaction<'_, Postgres>,
parent: &ItemLockRow,
event: &ScanEvent,
actor_personalnummer: i64,
) -> Result<(), ApplicationError> {
#[derive(sqlx::FromRow)]
struct CompRow {
id: Uuid,
required_quantity: i32,
credited_quantity: i32,
scanned_quantity: i32,
komponenten_artikel_nr: Option<String>,
}
let components = sqlx::query_as::<_, CompRow>(
r#"
SELECT id, required_quantity, credited_quantity, scanned_quantity,
komponenten_artikel_nr
FROM delivery_items
WHERE delivery_id = $1
AND belegzeilen_nr = $2
AND komponenten_artikel_nr IS NOT NULL
AND scan_status <> 'removed'
FOR UPDATE
"#,
)
.bind(parent.delivery_id)
.bind(parent.belegzeilen_nr)
.fetch_all(&mut **tx)
.await
.map_err(db)?;
let reason = event
.reason
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.unwrap_or_else(|| "Oberartikel entfernt".to_string());
for comp in components {
let credit_delta = comp.required_quantity - comp.credited_quantity;
sqlx::query(
r#"
UPDATE delivery_items
SET credited_quantity = required_quantity,
scan_status = 'removed',
scan_last_updated_at = now()
WHERE id = $1
"#,
)
.bind(comp.id)
.execute(&mut **tx)
.await
.map_err(db)?;
sqlx::query(
r#"
INSERT INTO scan_audit (
client_scan_id, delivery_item_id, action,
delta, resulting_quantity, resulting_status,
reason, actor_personalnummer, actor_car_id, client_scanned_at,
erp_belegart_id, erp_belegnummer, erp_belegzeilen_nr,
erp_komponenten_artikel_nr,
credit_delta, resulting_credited_quantity, manual
) VALUES ($1, $2, 'remove', 0, $3, 'removed', $4, $5, $6, $7,
$8, $9, $10, $11, $12, $13, false)
"#,
)
.bind(Uuid::new_v4())
.bind(comp.id)
.bind(comp.scanned_quantity)
.bind(&reason)
.bind(actor_personalnummer)
.bind(event.actor_car_id)
.bind(event.client_scanned_at)
.bind(parent.erp_belegart_id)
.bind(&parent.erp_belegnummer)
.bind(parent.belegzeilen_nr)
.bind(comp.komponenten_artikel_nr.as_deref())
.bind(credit_delta)
.bind(comp.required_quantity)
.execute(&mut **tx)
.await
.map_err(db)?;
}
Ok(())
}
#[async_trait]
impl ScanRepository for PgScanRepository {
async fn apply_one(
@ -222,16 +464,46 @@ impl ScanRepository for PgScanRepository {
let current_status = parse_status(&item.scan_status)?;
let current_state = ScanState {
scanned_quantity: item.scanned_quantity,
credited_quantity: item.credited_quantity,
status: current_status,
held_reason: item.held_reason.clone(),
last_updated_at: item.scan_last_updated_at,
};
// Idempotenz ZUERST — vor der Transition. Ein Netz-Retry desselben
// `client_scan_id` muss „Duplicate" liefern, auch wenn die
// (mengenabhängige) Transition inzwischen ablehnen würde (z. B. Item
// bereits `done`, Restmenge 0 bei manueller Bestätigung oder
// Remove/Unremove). Sonst würde die App ihr optimistisches Update
// fälschlich zurückrollen.
let already_applied: Option<Uuid> = sqlx::query_scalar(
"SELECT id FROM scan_audit WHERE client_scan_id = $1",
)
.bind(event.client_scan_id)
.fetch_optional(&mut *tx)
.await
.map_err(db)?;
if already_applied.is_some() {
tx.rollback().await.map_err(db)?;
return Ok(ApplyScanOutcome::Duplicate {
delivery_item_id: item.id,
current_state,
});
}
let snapshot = ItemSnapshot {
current_qty: item.scanned_quantity,
current_credited: item.credited_quantity,
current_status,
required_qty: item.required_quantity,
scannable: item.scannable,
delivery_state: &item.delivery_state,
};
let transition = match apply_transition(
event.action,
item.scanned_quantity,
current_status,
item.required_quantity,
&snapshot,
event.quantity,
event.reason.as_deref(),
) {
Ok(t) => t,
@ -250,8 +522,9 @@ impl ScanRepository for PgScanRepository {
delta, resulting_quantity, resulting_status,
reason, actor_personalnummer, actor_car_id, client_scanned_at,
erp_belegart_id, erp_belegnummer, erp_belegzeilen_nr,
erp_komponenten_artikel_nr
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
erp_komponenten_artikel_nr,
credit_delta, resulting_credited_quantity, manual
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
ON CONFLICT (client_scan_id) DO NOTHING
RETURNING id
"#,
@ -270,6 +543,11 @@ impl ScanRepository for PgScanRepository {
.bind(&item.erp_belegnummer)
.bind(item.belegzeilen_nr)
.bind(item.komponenten_artikel_nr.as_deref())
// credit_delta / resulting_credited_quantity: nur bei Remove/Unremove
// gesetzt, sonst NULL (credit_delta == None).
.bind(transition.credit_delta)
.bind(transition.credit_delta.map(|_| transition.new_credited_quantity))
.bind(event.manual)
.fetch_optional(&mut *tx)
.await
.map_err(db)?;
@ -289,14 +567,16 @@ impl ScanRepository for PgScanRepository {
r#"
UPDATE delivery_items
SET scanned_quantity = $1,
scan_status = $2,
held_reason = $3,
credited_quantity = $2,
scan_status = $3,
held_reason = $4,
scan_last_updated_at = now()
WHERE id = $4
WHERE id = $5
RETURNING scan_last_updated_at
"#,
)
.bind(transition.new_quantity)
.bind(transition.new_credited_quantity)
.bind(status_str(transition.new_status))
.bind(transition.new_held_reason.as_deref())
.bind(item.id)
@ -304,12 +584,26 @@ impl ScanRepository for PgScanRepository {
.await
.map_err(db)?;
// Cascade: wird ein **Oberartikel** entfernt (Position ohne eigene
// Komponenten-Nr, die selbst Komponenten unter derselben Belegzeile
// hat) und ist dadurch voll `removed`, werden ALLE seine Komponenten
// ebenfalls als entfernt markiert — auch wenn sie noch nicht gescannt
// sind. Die Set-Entscheidung überschreibt das „scannbar muss done"-Gate.
if event.action == AuditAction::Remove
&& transition.new_status == ScanStatus::Removed
&& item.komponenten_artikel_nr.is_none()
{
cascade_remove_components(&mut tx, &item, event, actor_personalnummer)
.await?;
}
tx.commit().await.map_err(db)?;
Ok(ApplyScanOutcome::Applied {
delivery_item_id: item.id,
new_state: ScanState {
scanned_quantity: transition.new_quantity,
credited_quantity: transition.new_credited_quantity,
status: transition.new_status,
held_reason: transition.new_held_reason,
last_updated_at: new_last_updated,

View File

@ -0,0 +1,196 @@
//! Postgres-Implementierung des `ServiceRepository`-Ports.
use async_trait::async_trait;
use sqlx::PgPool;
use uuid::Uuid;
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::ServiceRepository;
use holzleitner_domain::{Service, ServiceKind};
pub struct PgServiceRepository {
pool: PgPool,
}
impl PgServiceRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)]
struct ServiceRow {
id: Uuid,
key: String,
name: String,
kind: String,
min_value: Option<i32>,
max_value: Option<i32>,
active: bool,
sort_order: i32,
}
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
ApplicationError::Repository(e.to_string())
}
fn pg_sqlstate(err: &sqlx::Error) -> Option<String> {
if let sqlx::Error::Database(db_err) = err {
return db_err.code().map(|c| c.into_owned());
}
None
}
fn kind_str(k: ServiceKind) -> &'static str {
match k {
ServiceKind::Boolean => "boolean",
ServiceKind::Numeric => "numeric",
}
}
fn parse_kind(s: &str) -> Result<ServiceKind, ApplicationError> {
match s {
"boolean" => Ok(ServiceKind::Boolean),
"numeric" => Ok(ServiceKind::Numeric),
other => Err(ApplicationError::Repository(format!(
"unknown service kind '{other}'"
))),
}
}
fn map_service(r: ServiceRow) -> Result<Service, ApplicationError> {
Ok(Service {
id: r.id,
key: r.key,
name: r.name,
kind: parse_kind(&r.kind)?,
min_value: r.min_value,
max_value: r.max_value,
active: r.active,
sort_order: r.sort_order,
})
}
const COLS: &str =
"id, key, name, kind, min_value, max_value, active, sort_order";
#[async_trait]
impl ServiceRepository for PgServiceRepository {
async fn list(&self, include_inactive: bool) -> Result<Vec<Service>, ApplicationError> {
let rows = sqlx::query_as::<_, ServiceRow>(
r#"
SELECT id, key, name, kind, min_value, max_value, active, sort_order
FROM services
WHERE (active = TRUE OR $1)
ORDER BY sort_order, name
"#,
)
.bind(include_inactive)
.fetch_all(&self.pool)
.await
.map_err(db)?;
rows.into_iter().map(map_service).collect()
}
async fn find_by_id(&self, id: Uuid) -> Result<Option<Service>, ApplicationError> {
let row = sqlx::query_as::<_, ServiceRow>(&format!(
"SELECT {COLS} FROM services WHERE id = $1"
))
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(db)?;
row.map(map_service).transpose()
}
async fn create(
&self,
key: &str,
name: &str,
kind: ServiceKind,
min_value: Option<i32>,
max_value: Option<i32>,
sort_order: i32,
) -> Result<Service, ApplicationError> {
match sqlx::query_as::<_, ServiceRow>(
r#"
INSERT INTO services (key, name, kind, min_value, max_value, sort_order)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, key, name, kind, min_value, max_value, active, sort_order
"#,
)
.bind(key)
.bind(name)
.bind(kind_str(kind))
.bind(min_value)
.bind(max_value)
.bind(sort_order)
.fetch_one(&self.pool)
.await
{
Ok(row) => map_service(row),
Err(e) if pg_sqlstate(&e).as_deref() == Some("23505") => Err(
ApplicationError::Conflict(format!("service with key '{key}' already exists")),
),
Err(e) => Err(db(e)),
}
}
async fn update(
&self,
id: Uuid,
name: Option<&str>,
min_value: Option<i32>,
max_value: Option<i32>,
active: Option<bool>,
sort_order: Option<i32>,
) -> Result<Service, ApplicationError> {
let row = sqlx::query_as::<_, ServiceRow>(
r#"
UPDATE services
SET name = COALESCE($2, name),
min_value = COALESCE($3, min_value),
max_value = COALESCE($4, max_value),
active = COALESCE($5, active),
sort_order = COALESCE($6, sort_order)
WHERE id = $1
RETURNING id, key, name, kind, min_value, max_value, active, sort_order
"#,
)
.bind(id)
.bind(name)
.bind(min_value)
.bind(max_value)
.bind(active)
.bind(sort_order)
.fetch_optional(&self.pool)
.await
.map_err(db)?;
match row {
Some(r) => map_service(r),
None => Err(ApplicationError::NotFound),
}
}
async fn delete(&self, id: Uuid) -> Result<(), ApplicationError> {
match sqlx::query("DELETE FROM services WHERE id = $1")
.bind(id)
.execute(&self.pool)
.await
{
Ok(result) => {
if result.rows_affected() == 0 {
Err(ApplicationError::NotFound)
} else {
Ok(())
}
}
Err(e) if pg_sqlstate(&e).as_deref() == Some("23503") => Err(
ApplicationError::Conflict(
"service is in use by at least one delivery — deactivate instead".to_string(),
),
),
Err(e) => Err(db(e)),
}
}
}

View File

@ -20,14 +20,15 @@ use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;
use holzleitner_application::dto::{
DeliveryOrderEntry, DeliveryWithItems, SyncDelivery, SyncDeliveryItem, SyncTourRequest,
TourDetails, TourSummary,
DeliveryOrderEntry, DeliveryWithItems, SyncContactSource, SyncDelivery, SyncDeliveryItem,
SyncTourRequest, TourDetails, TourSummary,
};
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::TourRepository;
use holzleitner_domain::{
Address, Article, Customer, CustomerContact, Delivery, DeliveryItem, DeliveryNote,
DeliveryState, ScanState, ScanStatus, Tour, Warehouse,
Address, Article, ContactChannel, ContactKind, ContactRole, ContactSource, Customer,
CustomerContact, Delivery, DeliveryCredit, DeliveryItem, DeliveryNote, DeliveryServiceValue,
DeliveryState, ScanState, ScanStatus, Service, ServiceKind, Tour, Warehouse,
};
pub struct PgTourRepository {
@ -78,6 +79,8 @@ struct DeliveryRow {
state: String,
state_reason: Option<String>,
sort_order: i32,
prepaid_amount: f64,
payment_method_id: Uuid,
}
#[derive(sqlx::FromRow)]
@ -87,9 +90,12 @@ struct DeliveryItemRow {
article_id: Uuid,
required_quantity: i32,
warehouse_id: Uuid,
unit_price: f64,
belegzeilen_nr: i32,
komponenten_artikel_nr: Option<String>,
parent_artikel_nr: Option<String>,
scanned_quantity: i32,
credited_quantity: i32,
scan_status: String,
held_reason: Option<String>,
scan_last_updated_at: DateTime<Utc>,
@ -139,6 +145,29 @@ struct ContactLinkRow {
customer_contact_id: Uuid,
}
#[derive(sqlx::FromRow)]
struct ContactSourceRow {
id: Uuid,
delivery_id: Uuid,
role: String,
anrede: Option<String>,
titel: Option<String>,
name1: Option<String>,
name2: Option<String>,
name3: Option<String>,
abteilung: Option<String>,
funktion: Option<String>,
}
#[derive(sqlx::FromRow)]
struct ContactChannelRow {
id: Uuid,
source_id: Uuid,
kind: String,
position: i16,
value: String,
}
#[derive(sqlx::FromRow)]
struct DeliveryNoteRow {
id: Uuid,
@ -147,6 +176,9 @@ struct DeliveryNoteRow {
image_attachment: Option<String>,
author_personalnummer: i64,
author_car_id: Option<Uuid>,
credit_delivery_item_id: Option<Uuid>,
is_amount_credit_note: bool,
image_attachment_deleted: bool,
created_at: DateTime<Utc>,
}
@ -192,10 +224,13 @@ fn map_item(row: DeliveryItemRow) -> Result<DeliveryItem, ApplicationError> {
article_id: row.article_id,
required_quantity: row.required_quantity,
warehouse_id: row.warehouse_id,
unit_price: row.unit_price,
belegzeilen_nr: row.belegzeilen_nr,
komponenten_artikel_nr: row.komponenten_artikel_nr,
parent_artikel_nr: row.parent_artikel_nr,
scan_state: ScanState {
scanned_quantity: row.scanned_quantity,
credited_quantity: row.credited_quantity,
status: parse_scan_status(&row.scan_status)?,
held_reason: row.held_reason,
last_updated_at: row.scan_last_updated_at,
@ -228,6 +263,31 @@ fn map_contact(row: CustomerContactRow) -> CustomerContact {
}
}
fn map_contact_source(row: ContactSourceRow) -> Result<ContactSource, ApplicationError> {
Ok(ContactSource {
id: row.id,
delivery_id: row.delivery_id,
role: role_from_db(&row.role)?,
anrede: row.anrede,
titel: row.titel,
name1: row.name1,
name2: row.name2,
name3: row.name3,
abteilung: row.abteilung,
funktion: row.funktion,
})
}
fn map_contact_channel(row: ContactChannelRow) -> Result<ContactChannel, ApplicationError> {
Ok(ContactChannel {
id: row.id,
source_id: row.source_id,
kind: kind_from_db(&row.kind)?,
position: row.position,
value: row.value,
})
}
fn map_article(row: ArticleRow) -> Article {
Article {
id: row.id,
@ -246,10 +306,85 @@ fn map_note(row: DeliveryNoteRow) -> DeliveryNote {
image_attachment: row.image_attachment,
author_personalnummer: row.author_personalnummer,
author_car_id: row.author_car_id,
credit_delivery_item_id: row.credit_delivery_item_id,
is_amount_credit_note: row.is_amount_credit_note,
image_attachment_deleted: row.image_attachment_deleted,
created_at: row.created_at,
}
}
#[derive(sqlx::FromRow)]
struct CreditRow {
delivery_id: Uuid,
action: String,
amount_cents: i64,
reason: Option<String>,
}
/// Aktuelles Gutschrift-Ereignis → Domänenobjekt. `remove` (oder unbekannte
/// Action) liefert `None`, sodass entfernte Gutschriften nicht erscheinen.
fn map_credit(row: CreditRow) -> Option<DeliveryCredit> {
if row.action != "set" {
return None;
}
Some(DeliveryCredit {
delivery_id: row.delivery_id,
amount_cents: row.amount_cents,
reason: row.reason.unwrap_or_default(),
})
}
#[derive(sqlx::FromRow)]
struct ServiceRow {
id: Uuid,
key: String,
name: String,
kind: String,
min_value: Option<i32>,
max_value: Option<i32>,
active: bool,
sort_order: i32,
}
fn map_service(row: ServiceRow) -> Result<Service, ApplicationError> {
let kind = match row.kind.as_str() {
"boolean" => ServiceKind::Boolean,
"numeric" => ServiceKind::Numeric,
other => {
return Err(ApplicationError::Repository(format!(
"unknown service kind '{other}'"
)));
}
};
Ok(Service {
id: row.id,
key: row.key,
name: row.name,
kind,
min_value: row.min_value,
max_value: row.max_value,
active: row.active,
sort_order: row.sort_order,
})
}
#[derive(sqlx::FromRow)]
struct DeliveryServiceRow {
delivery_id: Uuid,
service_id: Uuid,
bool_value: Option<bool>,
numeric_value: Option<i32>,
}
fn map_delivery_service(row: DeliveryServiceRow) -> DeliveryServiceValue {
DeliveryServiceValue {
delivery_id: row.delivery_id,
service_id: row.service_id,
bool_value: row.bool_value,
numeric_value: row.numeric_value,
}
}
fn map_warehouse(row: WarehouseRow) -> Warehouse {
Warehouse {
id: row.id,
@ -283,6 +418,8 @@ fn map_delivery(
special_agreements: row.special_agreements,
state,
state_reason: row.state_reason,
prepaid_amount: row.prepaid_amount,
payment_method_id: row.payment_method_id,
};
Ok((delivery, row.sort_order))
}
@ -352,7 +489,8 @@ impl TourRepository for PgTourRepository {
id, tour_id, erp_belegart_id, erp_belegnummer, customer_id,
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
assigned_car_id, desired_time, special_agreements,
state, state_reason, sort_order
state, state_reason, sort_order,
prepaid_amount, payment_method_id
FROM deliveries
WHERE tour_id = $1
ORDER BY sort_order, erp_belegnummer
@ -391,8 +529,9 @@ impl TourRepository for PgTourRepository {
r#"
SELECT
id, delivery_id, article_id, required_quantity, warehouse_id,
belegzeilen_nr, komponenten_artikel_nr,
scanned_quantity, scan_status, held_reason, scan_last_updated_at
unit_price, belegzeilen_nr, komponenten_artikel_nr, parent_artikel_nr,
scanned_quantity, credited_quantity, scan_status, held_reason,
scan_last_updated_at
FROM delivery_items
WHERE delivery_id = ANY($1)
ORDER BY delivery_id, belegzeilen_nr, komponenten_artikel_nr NULLS FIRST
@ -504,11 +643,15 @@ impl TourRepository for PgTourRepository {
// 7. Notizen aller Lieferungen dieser Tour.
let notes = sqlx::query_as::<_, DeliveryNoteRow>(
r#"
SELECT id, delivery_id, text, image_attachment,
author_personalnummer, author_car_id, created_at
FROM delivery_notes
WHERE delivery_id = ANY($1)
ORDER BY delivery_id, created_at
SELECT dn.id, dn.delivery_id, dn.text, dn.image_attachment,
dn.author_personalnummer, dn.author_car_id,
dn.credit_delivery_item_id, dn.is_amount_credit_note,
(att.deleted_at IS NOT NULL) AS image_attachment_deleted,
dn.created_at
FROM delivery_notes dn
LEFT JOIN attachments att ON att.id = dn.image_attachment::uuid
WHERE dn.delivery_id = ANY($1)
ORDER BY dn.delivery_id, dn.created_at
"#,
)
.bind(&delivery_ids)
@ -519,6 +662,99 @@ impl TourRepository for PgTourRepository {
.map(map_note)
.collect::<Vec<_>>();
// 8. Aktuelle Betrags-Gutschriften: jüngstes Ereignis pro Lieferung,
// nur solange der letzte Stand `set` ist.
let credits = sqlx::query_as::<_, CreditRow>(
r#"
SELECT DISTINCT ON (delivery_id)
delivery_id, action, amount_cents, reason
FROM delivery_credit_audit
WHERE delivery_id = ANY($1)
ORDER BY delivery_id, recorded_at DESC, id DESC
"#,
)
.bind(&delivery_ids)
.fetch_all(&self.pool)
.await
.map_err(db)?
.into_iter()
.filter_map(map_credit)
.collect::<Vec<_>>();
// 9. Aktive Service-Definitionen (Stammdaten) — die App rendert daraus
// Phase 4.
let services = sqlx::query_as::<_, ServiceRow>(
r#"
SELECT id, key, name, kind, min_value, max_value, active, sort_order
FROM services
WHERE active = TRUE
ORDER BY sort_order, name
"#,
)
.fetch_all(&self.pool)
.await
.map_err(db)?
.into_iter()
.map(map_service)
.collect::<Result<Vec<_>, _>>()?;
// 10. Pro-Lieferung gesetzte Service-Werte.
let delivery_services = sqlx::query_as::<_, DeliveryServiceRow>(
r#"
SELECT delivery_id, service_id, bool_value, numeric_value
FROM delivery_services
WHERE delivery_id = ANY($1)
"#,
)
.bind(&delivery_ids)
.fetch_all(&self.pool)
.await
.map_err(db)?
.into_iter()
.map(map_delivery_service)
.collect::<Vec<_>>();
// 11. Kontaktdaten-Snapshots aller Lieferungen + ihre Kanäle.
// Reihenfolge: Quellen pro Lieferung nach Rolle, Kanäle pro
// Quelle nach Art und ERP-Position — so kommt „Telefon"
// vor „Telefon2", die App muss nicht extra sortieren.
let source_rows = sqlx::query_as::<_, ContactSourceRow>(
r#"
SELECT id, delivery_id, role,
anrede, titel, name1, name2, name3, abteilung, funktion
FROM delivery_contact_sources
WHERE delivery_id = ANY($1)
ORDER BY delivery_id, role
"#,
)
.bind(&delivery_ids)
.fetch_all(&self.pool)
.await
.map_err(db)?;
let source_ids: Vec<Uuid> = source_rows.iter().map(|r| r.id).collect();
let contact_sources = source_rows
.into_iter()
.map(map_contact_source)
.collect::<Result<Vec<_>, _>>()?;
let channel_rows = sqlx::query_as::<_, ContactChannelRow>(
r#"
SELECT id, source_id, kind, position, value
FROM delivery_contact_channels
WHERE source_id = ANY($1)
ORDER BY source_id, kind, position
"#,
)
.bind(&source_ids)
.fetch_all(&self.pool)
.await
.map_err(db)?;
let contact_channels = channel_rows
.into_iter()
.map(map_contact_channel)
.collect::<Result<Vec<_>, _>>()?;
Ok(Some(TourDetails {
tour,
deliveries,
@ -527,6 +763,11 @@ impl TourRepository for PgTourRepository {
articles,
warehouses,
notes,
credits,
services,
delivery_services,
contact_sources,
contact_channels,
}))
}
@ -603,6 +844,23 @@ impl TourRepository for PgTourRepository {
) -> Result<Uuid, ApplicationError> {
let mut tx = self.pool.begin().await.map_err(db)?;
// 0. Fahrer-/Account-Konto sicherstellen — der ERP-`Vertreter` muss als
// `accounts`-Zeile existieren (FK von `tours`). Auto-Provisionierung:
// fehlende Konten werden mit Default-Namen angelegt; bestehende
// bleiben unangetastet (DO NOTHING überschreibt keinen Namen).
sqlx::query(
r#"
INSERT INTO accounts (personalnummer, name)
VALUES ($1, $2)
ON CONFLICT (personalnummer) DO NOTHING
"#,
)
.bind(request.driver_personalnummer)
.bind(format!("Fahrer {}", request.driver_personalnummer))
.execute(&mut *tx)
.await
.map_err(db)?;
// 1. Tour upserten — Identität: (account_id, tour_date)
let tour_id: Uuid = sqlx::query_scalar(
r#"
@ -628,6 +886,11 @@ impl TourRepository for PgTourRepository {
// erhalten; nur Stammdaten + sort_order werden refresht.
let delivery_id = upsert_delivery(&mut tx, tour_id, customer_id, delivery).await?;
// 3a. Kontaktdaten-Snapshot neu schreiben. Snapshot-Semantik:
// beim Sync wird der Stand vom ERP übernommen, ältere Stände
// verworfen. Der CASCADE-DELETE räumt auch die Channels mit.
replace_contact_sources(&mut tx, delivery_id, &delivery.contact_sources).await?;
for item in &delivery.items {
let warehouse_id = upsert_warehouse(&mut tx, item).await?;
let article_id = upsert_article(&mut tx, item, warehouse_id).await?;
@ -638,6 +901,17 @@ impl TourRepository for PgTourRepository {
tx.commit().await.map_err(db)?;
Ok(tour_id)
}
async fn delete_all_tours(&self) -> Result<u64, ApplicationError> {
// DELETE FROM tours cascadet per FK auf deliveries → delivery_items →
// scan_audit, delivery_notes, delivery_credit_audit, delivery_services,
// delivery_completions, attachments, delivery_contact_persons.
let res = sqlx::query("DELETE FROM tours")
.execute(&self.pool)
.await
.map_err(db)?;
Ok(res.rows_affected())
}
}
// ===== Upsert-Helfer =====================================================
@ -741,15 +1015,39 @@ async fn upsert_delivery(
customer_id: Uuid,
delivery: &SyncDelivery,
) -> Result<Uuid, ApplicationError> {
// Payment-Method-Code → UUID auflösen. Fallback `"cash"` falls vom
// ERP nichts gekommen ist — `"cash"` ist Default-Stamm aus
// Migration 0008 und damit garantiert vorhanden.
let payment_code = delivery
.payment_method_code
.as_deref()
.unwrap_or("cash");
let payment_method_id: Uuid = sqlx::query_scalar(
"SELECT id FROM payment_methods WHERE code = $1",
)
.bind(payment_code)
.fetch_optional(&mut **tx)
.await
.map_err(db)?
.ok_or_else(|| {
ApplicationError::Validation(format!(
"unknown payment method code '{payment_code}'"
))
})?;
let id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO deliveries (
tour_id, erp_belegart_id, erp_belegnummer, customer_id,
tour_id, erp_belegart_id, erp_belegart_code, erp_belegart_name,
erp_belegnummer, customer_id,
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
sort_order, desired_time, special_agreements
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
sort_order, desired_time, special_agreements,
prepaid_amount, payment_method_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
ON CONFLICT (erp_belegart_id, erp_belegnummer) DO UPDATE SET
tour_id = EXCLUDED.tour_id,
erp_belegart_code = EXCLUDED.erp_belegart_code,
erp_belegart_name = EXCLUDED.erp_belegart_name,
customer_id = EXCLUDED.customer_id,
snap_street = EXCLUDED.snap_street,
snap_house_number = EXCLUDED.snap_house_number,
@ -758,12 +1056,16 @@ async fn upsert_delivery(
snap_country = EXCLUDED.snap_country,
sort_order = EXCLUDED.sort_order,
desired_time = EXCLUDED.desired_time,
special_agreements = EXCLUDED.special_agreements
special_agreements = EXCLUDED.special_agreements,
prepaid_amount = EXCLUDED.prepaid_amount,
payment_method_id = EXCLUDED.payment_method_id
RETURNING id
"#,
)
.bind(tour_id)
.bind(delivery.belegart_id)
.bind(delivery.belegart_code.as_deref())
.bind(delivery.belegart_name.as_deref())
.bind(&delivery.belegnummer)
.bind(customer_id)
.bind(&delivery.delivery_address.street)
@ -774,6 +1076,8 @@ async fn upsert_delivery(
.bind(delivery.sort_order)
.bind(delivery.desired_time.as_deref())
.bind(delivery.special_agreements.as_deref())
.bind(delivery.prepaid_amount)
.bind(payment_method_id)
.fetch_one(&mut **tx)
.await
.map_err(db)?;
@ -794,22 +1098,129 @@ async fn upsert_delivery_item(
r#"
INSERT INTO delivery_items (
delivery_id, article_id, required_quantity, warehouse_id,
belegzeilen_nr, komponenten_artikel_nr
) VALUES ($1, $2, $3, $4, $5, $6)
unit_price, belegzeilen_nr, komponenten_artikel_nr, parent_artikel_nr
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (delivery_id, belegzeilen_nr, komponenten_artikel_nr) DO UPDATE SET
article_id = EXCLUDED.article_id,
required_quantity = EXCLUDED.required_quantity,
warehouse_id = EXCLUDED.warehouse_id
warehouse_id = EXCLUDED.warehouse_id,
unit_price = EXCLUDED.unit_price,
parent_artikel_nr = EXCLUDED.parent_artikel_nr
"#,
)
.bind(delivery_id)
.bind(article_id)
.bind(item.required_quantity)
.bind(warehouse_id)
.bind(item.unit_price)
.bind(item.belegzeilen_nr)
.bind(item.komponenten_artikel_nr.as_deref())
.bind(item.parent_artikel_nr.as_deref())
.execute(&mut **tx)
.await
.map_err(db)?;
Ok(())
}
// ===== Kontaktdaten ======================================================
fn role_to_db(role: ContactRole) -> &'static str {
match role {
ContactRole::Header => "header",
ContactRole::Delivery => "delivery",
ContactRole::Billing => "billing",
ContactRole::ContactPerson => "contact_person",
ContactRole::CustomerMaster => "customer_master",
}
}
fn role_from_db(value: &str) -> Result<ContactRole, ApplicationError> {
match value {
"header" => Ok(ContactRole::Header),
"delivery" => Ok(ContactRole::Delivery),
"billing" => Ok(ContactRole::Billing),
"contact_person" => Ok(ContactRole::ContactPerson),
"customer_master" => Ok(ContactRole::CustomerMaster),
other => Err(ApplicationError::Repository(format!(
"unknown contact role in DB: {other}"
))),
}
}
fn kind_to_db(kind: ContactKind) -> &'static str {
match kind {
ContactKind::Phone => "phone",
ContactKind::Mobile => "mobile",
ContactKind::Email => "email",
ContactKind::Web => "web",
}
}
fn kind_from_db(value: &str) -> Result<ContactKind, ApplicationError> {
match value {
"phone" => Ok(ContactKind::Phone),
"mobile" => Ok(ContactKind::Mobile),
"email" => Ok(ContactKind::Email),
"web" => Ok(ContactKind::Web),
other => Err(ApplicationError::Repository(format!(
"unknown contact kind in DB: {other}"
))),
}
}
/// Snapshot-Refresh: vorhandene Sources der Lieferung löschen (Channels
/// fliegen per ON DELETE CASCADE mit), neue einfügen. Idempotent: leerer
/// Input ⇒ Lieferung hat nach dem Aufruf 0 Sources.
async fn replace_contact_sources(
tx: &mut Transaction<'_, Postgres>,
delivery_id: Uuid,
sources: &[SyncContactSource],
) -> Result<(), ApplicationError> {
sqlx::query("DELETE FROM delivery_contact_sources WHERE delivery_id = $1")
.bind(delivery_id)
.execute(&mut **tx)
.await
.map_err(db)?;
for src in sources {
let source_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO delivery_contact_sources (
delivery_id, role, anrede, titel, name1, name2, name3,
abteilung, funktion
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
"#,
)
.bind(delivery_id)
.bind(role_to_db(src.role))
.bind(src.anrede.as_deref())
.bind(src.titel.as_deref())
.bind(src.name1.as_deref())
.bind(src.name2.as_deref())
.bind(src.name3.as_deref())
.bind(src.abteilung.as_deref())
.bind(src.funktion.as_deref())
.fetch_one(&mut **tx)
.await
.map_err(db)?;
for ch in &src.channels {
sqlx::query(
r#"
INSERT INTO delivery_contact_channels (
source_id, kind, position, value
) VALUES ($1, $2, $3, $4)
"#,
)
.bind(source_id)
.bind(kind_to_db(ch.kind))
.bind(ch.position)
.bind(&ch.value)
.execute(&mut **tx)
.await
.map_err(db)?;
}
}
Ok(())
}

View File

@ -0,0 +1,10 @@
//! PDF-Lieferreport: Daten sammeln (PG) → rendern (printpdf) → ablegen/senden
//! (Sink). Adapter zu den `DeliveryReport*`-Ports der Application-Schicht.
pub mod renderer;
pub mod repository;
pub mod sink;
pub use renderer::PdfDeliveryReportRenderer;
pub use repository::PgDeliveryReportRepository;
pub use sink::{DocuframeReportSink, LocalReportSink};

View File

@ -0,0 +1,699 @@
//! printpdf-Renderer für den Lieferreport.
//!
//! Nutzt die eingebaute **Helvetica** (WinAnsi → deutsche Umlaute) — kein
//! Font-Asset. Einfache, eigene Layout-Engine: Cursor `y` von oben nach unten,
//! automatischer Seitenumbruch, klippende Tabellen, eingebettete Bilder
//! (Unterschriften + Foto-Notizen) via roher RGB-Daten.
use printpdf::{
BuiltinFont, Color, ColorBits, ColorSpace, Image, ImageFilter, ImageTransform, ImageXObject,
IndirectFontRef, Line, Mm, PdfDocument, PdfDocumentReference, PdfLayerReference, Point, Px, Rgb,
};
use holzleitner_application::dto::DeliveryReportData;
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::DeliveryReportRenderer;
const PAGE_W: f32 = 210.0;
const PAGE_H: f32 = 297.0;
const MARGIN_L: f32 = 15.0;
const MARGIN_R: f32 = 15.0;
const MARGIN_TOP: f32 = 15.0;
const MARGIN_BOTTOM: f32 = 16.0;
const CONTENT_W: f32 = PAGE_W - MARGIN_L - MARGIN_R; // 180
fn pt2mm(pt: f32) -> f32 {
pt * 0.352_778
}
/// Grobe Helvetica-Zeichenbreite (≈0.5em) — reicht für Umbruch/Klippen.
fn char_w(size: f32) -> f32 {
pt2mm(size) * 0.5
}
/// Geschätzte Breite (mm) der fetten Schlüssel-Spalte inkl. 4mm Luft.
/// Konservative Obergrenze für Helvetica-Bold (~0.6em → hier 0.66em).
fn kv_col(key: &str) -> f32 {
pt2mm(9.0) * 0.66 * key.chars().count() as f32 + 4.0
}
fn money(v: f64) -> String {
format!("{:.2}", v).replace('.', ",")
}
fn cents(c: i64) -> String {
money(c as f64 / 100.0)
}
pub struct PdfDeliveryReportRenderer;
struct Pdf {
doc: PdfDocumentReference,
font: IndirectFontRef,
bold: IndirectFontRef,
layer: PdfLayerReference,
y: f32,
}
impl Pdf {
fn new(title: &str) -> Result<Self, ApplicationError> {
let (doc, page, layer) = PdfDocument::new(title, Mm(PAGE_W), Mm(PAGE_H), "Layer 1");
let font = doc
.add_builtin_font(BuiltinFont::Helvetica)
.map_err(ext)?;
let bold = doc
.add_builtin_font(BuiltinFont::HelveticaBold)
.map_err(ext)?;
let layer = doc.get_page(page).get_layer(layer);
Ok(Self {
doc,
font,
bold,
layer,
y: PAGE_H - MARGIN_TOP,
})
}
fn new_page(&mut self) {
let (page, layer) = self.doc.add_page(Mm(PAGE_W), Mm(PAGE_H), "Layer 1");
self.layer = self.doc.get_page(page).get_layer(layer);
self.y = PAGE_H - MARGIN_TOP;
}
fn ensure(&mut self, needed: f32) {
if self.y - needed < MARGIN_BOTTOM {
self.new_page();
}
}
/// Schreibt eine Zeile ab x (mm von links innerhalb des Inhalts).
fn put(&mut self, text: &str, size: f32, bold: bool, x: f32, baseline_y: f32) {
let font = if bold { &self.bold } else { &self.font };
self.layer
.use_text(text, size, Mm(MARGIN_L + x), Mm(baseline_y), font);
}
fn max_chars(&self, width: f32, size: f32) -> usize {
((width / char_w(size)).floor() as usize).max(1)
}
fn clip(&self, text: &str, width: f32, size: f32) -> String {
let max = self.max_chars(width, size);
if text.chars().count() <= max {
text.to_string()
} else if max <= 1 {
"".to_string()
} else {
let truncated: String = text.chars().take(max - 1).collect();
format!("{truncated}")
}
}
/// Mehrzeiliger, umbrechender Text. `indent` in mm.
fn text(&mut self, text: &str, size: f32, bold: bool, indent: f32) {
let avail = CONTENT_W - indent;
let max = self.max_chars(avail, size);
for raw_line in text.split('\n') {
if raw_line.is_empty() {
self.y -= pt2mm(size) * 1.3;
continue;
}
let mut current = String::new();
for word in raw_line.split_whitespace() {
let candidate = if current.is_empty() {
word.to_string()
} else {
format!("{current} {word}")
};
if candidate.chars().count() > max && !current.is_empty() {
self.write_line(&current, size, bold, indent);
current = word.to_string();
} else {
current = candidate;
}
}
if !current.is_empty() {
self.write_line(&current, size, bold, indent);
}
}
}
fn write_line(&mut self, text: &str, size: f32, bold: bool, indent: f32) {
let lh = pt2mm(size) * 1.35;
self.ensure(lh);
let baseline = self.y - pt2mm(size);
self.put(text, size, bold, indent, baseline);
self.y -= lh;
}
fn gap(&mut self, mm: f32) {
self.y -= mm;
}
fn title(&mut self, text: &str) {
self.write_line(text, 18.0, true, 0.0);
self.gap(2.0);
}
fn heading(&mut self, text: &str) {
self.gap(4.0);
self.ensure(8.0);
self.write_line(text, 12.0, true, 0.0);
self.gap(1.0);
}
fn kv(&mut self, key: &str, value: &str) {
// Wert-Spalte normal bei 42mm; ist der (fette) Schlüssel breiter,
// wird der Wert nach rechts geschoben, damit nichts überlappt.
let val_x = 42.0_f32.max(kv_col(key));
self.kv_at(key, value, val_x);
}
/// Wie `kv`, aber mit fest vorgegebener Wert-Spalte `val_x` (mm) — für
/// bündig ausgerichtete Blöcke, in denen alle Werte an derselben Stelle
/// beginnen (Spaltenbreite = breitester Schlüssel des Blocks).
fn kv_at(&mut self, key: &str, value: &str, val_x: f32) {
// Key in der ersten Spalte (fett), Wert daneben.
let lh = pt2mm(9.0) * 1.35;
self.ensure(lh);
let baseline = self.y - pt2mm(9.0);
self.put(key, 9.0, true, 0.0, baseline);
let val = self.clip(value, CONTENT_W - val_x, 9.0);
self.put(&val, 9.0, false, val_x, baseline);
self.y -= lh;
}
/// Tabellenzeile: (Text, Spaltenbreite mm, fett). Klippt je Zelle.
fn row(&mut self, cells: &[(String, f32, bool)], size: f32) {
let lh = pt2mm(size) * 1.5;
self.ensure(lh);
let baseline = self.y - pt2mm(size);
let mut x = 0.0;
for (text, width, bold) in cells {
let clipped = self.clip(text, *width - 1.5, size);
self.put(&clipped, size, *bold, x, baseline);
x += width;
}
self.y -= lh;
}
/// Bettet ein Bild ein (Bytes → RGB). `max_w`/`max_h` in mm. Bewegt den
/// Cursor (für Anhänge/Foto-Notizen im Textfluss).
fn image(&mut self, bytes: &[u8], max_w: f32, max_h: f32) {
// `max_h` ist die obere Schranke der Zeichenhöhe → konservativ reservieren.
self.ensure(max_h + 2.0);
match self.draw_image_at(bytes, MARGIN_L, self.y, max_w, max_h) {
Some(h) => self.y -= h + 2.0,
None => self.text("[Bild konnte nicht gelesen werden]", 8.0, false, 0.0),
}
}
/// Zeichnet ein Bild an fester Position (x links, `top_y` Oberkante, mm),
/// **ohne** den Cursor zu verändern. Liefert die gezeichnete Höhe in mm
/// (oder `None`, wenn das Bild nicht dekodiert werden konnte).
fn draw_image_at(&self, bytes: &[u8], x_mm: f32, top_y: f32, max_w: f32, max_h: f32) -> Option<f32> {
let dynimg = image::load_from_memory(bytes).ok()?;
// Auf eine sinnvolle Maximal-Kantenlänge runterskalieren — ein Report
// braucht keine 12-MP-Fotos. Begrenzt die Pixelmenge VOR der Kompression.
const MAX_EDGE: u32 = 1600;
let dynimg = if dynimg.width().max(dynimg.height()) > MAX_EDGE {
dynimg.resize(MAX_EDGE, MAX_EDGE, image::imageops::FilterType::Triangle)
} else {
dynimg
};
let rgb = dynimg.to_rgb8();
let (w_px, h_px) = (rgb.width(), rgb.height());
if w_px == 0 || h_px == 0 {
return None;
}
// JPEG-komprimieren und als DCTDecode-Stream ins PDF legen (statt rohem
// RGB) → drastisch kleinere PDFs (12-MP-Foto: 37 MB roh → ~200 KB).
let mut jpeg: Vec<u8> = Vec::new();
{
let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 82);
enc.encode(rgb.as_raw(), w_px, h_px, image::ExtendedColorType::Rgb8)
.ok()?;
}
// Zielgröße unter Wahrung des Seitenverhältnisses.
let aspect = h_px as f32 / w_px as f32;
let mut draw_w = max_w;
let mut draw_h = draw_w * aspect;
if draw_h > max_h {
draw_h = max_h;
draw_w = draw_h / aspect;
}
let bottom = top_y - draw_h;
// Scale relativ zur Default-DPI (300): natürliche Breite in mm.
let natural_w_mm = (w_px as f32) / 300.0 * 25.4;
let scale = if natural_w_mm > 0.0 {
draw_w / natural_w_mm
} else {
1.0
};
let xobject = ImageXObject {
width: Px(w_px as usize),
height: Px(h_px as usize),
color_space: ColorSpace::Rgb,
bits_per_component: ColorBits::Bit8,
interpolate: false,
image_data: jpeg,
image_filter: Some(ImageFilter::DCT),
smask: None,
clipping_bbox: None,
};
Image::from(xobject).add_to_layer(
self.layer.clone(),
ImageTransform {
translate_x: Some(Mm(x_mm)),
translate_y: Some(Mm(bottom)),
scale_x: Some(scale),
scale_y: Some(scale),
..Default::default()
},
);
Some(draw_h)
}
/// Zeichnet einen Rechteck-Rahmen (nur Kontur, grau). `bottom_y` = Unterkante.
fn rect(&self, x_mm: f32, bottom_y: f32, w: f32, h: f32) {
self.layer
.set_outline_color(Color::Rgb(Rgb::new(0.6, 0.6, 0.6, None)));
self.layer.set_outline_thickness(0.5); // pt
let (x0, x1) = (x_mm, x_mm + w);
let (y0, y1) = (bottom_y, bottom_y + h);
let line = Line {
points: vec![
(Point::new(Mm(x0), Mm(y0)), false),
(Point::new(Mm(x1), Mm(y0)), false),
(Point::new(Mm(x1), Mm(y1)), false),
(Point::new(Mm(x0), Mm(y1)), false),
],
is_closed: true,
};
self.layer.add_line(line);
}
/// Unterschriftsfeld: Beschriftung + umrahmter Kasten mit dem (kleinen) Bild.
fn signature_box(&mut self, label: &str, png: &[u8]) {
const BOX_W: f32 = 75.0;
const BOX_H: f32 = 22.0;
const PAD: f32 = 2.5;
let label_lh = pt2mm(9.0) * 1.35;
// Beschriftung + Kasten zusammenhalten (kein Umbruch mittendrin).
self.ensure(label_lh + BOX_H + 3.0);
self.write_line(label, 9.0, true, 0.0);
let top = self.y;
let bottom = top - BOX_H;
self.rect(MARGIN_L, bottom, BOX_W, BOX_H);
self.draw_image_at(
png,
MARGIN_L + PAD,
top - PAD,
BOX_W - 2.0 * PAD,
BOX_H - 2.0 * PAD,
);
self.y = bottom - 3.0;
}
fn finish(self) -> Result<Vec<u8>, ApplicationError> {
self.doc.save_to_bytes().map_err(ext)
}
}
fn ext<E: std::fmt::Display>(e: E) -> ApplicationError {
ApplicationError::Repository(format!("pdf: {e}"))
}
fn dt(d: &chrono::DateTime<chrono::Utc>) -> String {
d.format("%d.%m.%Y %H:%M").to_string()
}
fn opt(s: &Option<String>) -> &str {
s.as_deref().unwrap_or("")
}
/// Lesbare Belegart: „VL5 — Lieferschein EH", sonst nur Code/Name, sonst die
/// nackte ERP-ID („Nr. 24") als Fallback für noch nicht nachsynchronisierte
/// Altbestände.
fn belegart_label(d: &DeliveryReportData) -> String {
let code = d
.belegart_code
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let name = d
.belegart_name
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
match (code, name) {
(Some(c), Some(n)) => format!("{c}{n}"),
(Some(c), None) => c.to_string(),
(None, Some(n)) => n.to_string(),
(None, None) => format!("Nr. {}", d.belegart_id),
}
}
/// Deutsche Beschriftung des Lieferungs-Status (DB-Werte sind technisch/englisch).
fn state_de(s: &str) -> &str {
match s {
"active" => "Aktiv",
"held" => "Zurückgestellt",
"canceled" => "Storniert",
"completed" => "Abgeschlossen",
other => other,
}
}
/// Deutsche Beschriftung des Positions-/Scanstatus.
fn status_de(s: &str) -> &str {
match s {
"in_progress" => "In Arbeit",
"done" => "Erledigt",
"held" => "Zurückgestellt",
"removed" => "Entfernt",
other => other,
}
}
/// Deutsche Beschriftung der Belade-/Scanaktion.
fn scan_action_de(s: &str) -> &str {
match s {
"scan" => "Erfasst",
"unscan" => "Zurückgesetzt",
"hold" => "Zurückgestellt",
"unhold" => "Freigegeben",
"remove" => "Entfernt",
other => other,
}
}
/// Deutsche Beschriftung der Gutschriftaktion.
fn credit_action_de(s: &str) -> &str {
match s {
"set" => "Gesetzt",
"remove" => "Entfernt",
other => other,
}
}
impl DeliveryReportRenderer for PdfDeliveryReportRenderer {
fn render(&self, d: &DeliveryReportData) -> Result<Vec<u8>, ApplicationError> {
let mut p = Pdf::new(&format!("Lieferbericht {}", d.belegnummer))?;
// 1. Kopf
p.title(&format!("Lieferbericht {}", d.belegnummer));
p.kv("Belegnummer", &d.belegnummer);
p.kv("Belegart", &belegart_label(d));
p.kv("Status", state_de(&d.state));
p.kv("Tourdatum", &d.tour_date.format("%d.%m.%Y").to_string());
match &d.completion {
Some(c) => p.kv("Abgeschlossen", &dt(&c.completed_at)),
None => p.kv("Abgeschlossen", "— (nicht abgeschlossen)"),
}
p.kv("Fahrer", &format!("{} ({})", d.driver_name, d.driver_personalnummer));
p.kv("Fahrzeug", opt(&d.car_plate));
p.kv("Erstellt am", &dt(&d.generated_at));
// 2. Kunde & Lieferadresse
p.heading("Kunde & Lieferadresse");
p.kv("Kunde", &format!("{} (Nr. {})", d.customer_name, d.customer_number));
p.kv("Adresse", &d.address);
p.kv("Wunschzeit", opt(&d.desired_time));
if let Some(sa) = &d.special_agreements {
if !sa.trim().is_empty() {
p.text(&format!("Sonderwünsche: {sa}"), 9.0, false, 0.0);
}
}
for c in &d.contacts {
let line = match &c.detail {
Some(det) => format!("Ansprechpartner: {} ({det})", c.name),
None => format!("Ansprechpartner: {}", c.name),
};
p.text(&line, 9.0, false, 0.0);
}
// 3. Positionen
p.heading("Positionen");
let cols: [(&str, f32); 6] = [
("Artikel", 78.0),
("Soll", 16.0),
("Gel.", 16.0),
("Gutschr.", 22.0),
("Einzel", 26.0),
("Lager", 22.0),
];
p.row(
&cols.iter().map(|(t, w)| (t.to_string(), *w, true)).collect::<Vec<_>>(),
8.0,
);
let mut warenwert = 0.0_f64;
for it in &d.items {
warenwert += it.unit_price * it.delivered() as f64;
let label = if it.is_component() {
format!("{} ({})", it.name, it.article_number)
} else {
format!("{} ({})", it.name, it.article_number)
};
p.row(
&[
(label, 78.0, false),
(it.required_quantity.to_string(), 16.0, false),
(it.delivered().to_string(), 16.0, false),
(it.credited_quantity.to_string(), 22.0, false),
(money(it.unit_price), 26.0, false),
(it.warehouse_code.clone().unwrap_or_default(), 22.0, false),
],
8.0,
);
}
// Lager-Legende: Nummer → Name (einmalig, alphabetisch nach Nummer).
let mut lager: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
for it in &d.items {
if let Some(code) = &it.warehouse_code {
if !code.trim().is_empty() {
lager
.entry(code.clone())
.or_insert_with(|| it.warehouse_name.clone().unwrap_or_default());
}
}
}
if !lager.is_empty() {
let legend = lager
.iter()
.map(|(code, name)| {
if name.trim().is_empty() {
code.clone()
} else {
format!("{code} = {name}")
}
})
.collect::<Vec<_>>()
.join(" ");
p.gap(1.0);
p.text(&format!("Lager: {legend}"), 7.5, false, 0.0);
}
// 4. Zahlung
p.heading("Zahlung");
let credit = d.current_credit_cents as f64 / 100.0;
let open = (warenwert - d.prepaid_amount - credit).max(0.0);
p.kv("Warenwert", &money(warenwert));
p.kv("Anzahlung", &money(d.prepaid_amount));
p.kv("Gutschrift", &cents(d.current_credit_cents));
p.kv("Offener Betrag", &money(open));
p.kv("Zahlungsmethode", opt(&d.payment_method));
// Inkasso-Bestätigung: nur wenn beim Abschluss tatsächlich kassiert
// wurde (Bar/EC bei offenem Betrag). „Auf Rechnung"/voll bezahlt → keine.
if let Some(c) = &d.completion {
if let Some(collected) = c.collected_amount_cents {
p.kv(
"Betrag erhalten",
&format!("Ja — {} (am {})", cents(collected), dt(&c.completed_at)),
);
}
}
// 5. Dienstleistungen
p.heading("Dienstleistungen");
if d.services.is_empty() {
p.text("— keine —", 9.0, false, 0.0);
} else {
// Gemeinsame Wert-Spalte = Breite des längsten Dienstleistungs-
// Namens (mind. 42mm), damit die zweite Spalte bündig ausgerichtet
// ist statt pro Zeile zu springen.
let col = d
.services
.iter()
.map(|s| kv_col(&s.name))
.fold(42.0_f32, f32::max);
for s in &d.services {
let val = if let Some(b) = s.bool_value {
if b { "Ja".to_string() } else { "Nein".to_string() }
} else if let Some(n) = s.numeric_value {
n.to_string()
} else {
"".to_string()
};
p.kv_at(&s.name, &val, col);
}
}
// 6. Notizen
p.heading("Notizen");
if d.notes.is_empty() {
p.text("— keine —", 9.0, false, 0.0);
} else {
for n in &d.notes {
let mut head = format!("{} · Fahrer {}", dt(&n.created_at), n.author_personalnummer);
if n.is_amount_credit_note {
head.push_str(" · [Gutschrift-Notiz]");
}
if n.image_attachment.is_some() {
head.push_str(" · [Bild]");
}
p.text(&head, 8.0, true, 0.0);
if let Some(t) = &n.text {
if !t.trim().is_empty() {
p.text(t, 9.0, false, 4.0);
}
}
}
}
// 7. Unterschriften
p.heading("Unterschriften");
match &d.completion {
Some(c) => {
// Es gibt nur EINEN Abschlusszeitpunkt (completed_at) — er gilt
// für beide Bestätigungen und wird hier neben den Häkchen gezeigt.
let bestätigt_am = dt(&c.completed_at);
let receipt = if c.receipt_confirmed {
format!("Ja (am {bestätigt_am})")
} else {
"Nein".to_string()
};
let notes = if c.notes_acknowledged {
format!("Ja (am {bestätigt_am})")
} else {
"Nein".to_string()
};
p.kv("Erhalt bestätigt", &receipt);
p.kv("Notizen quittiert", &notes);
if let Some(png) = &d.customer_signature_png {
p.signature_box("Unterschrift Kunde", png);
}
if let Some(png) = &d.driver_signature_png {
p.signature_box("Unterschrift Fahrer", png);
}
}
None => p.text("— Lieferung nicht abgeschlossen —", 9.0, false, 0.0),
}
// 8. Protokoll: Belade-/Scanverlauf
p.heading("Protokoll — Belade- und Scanverlauf");
if d.scan_audit.is_empty() {
p.text("— keine Einträge —", 9.0, false, 0.0);
} else {
p.row(
&[
("Zeit".to_string(), 30.0, true),
("Aktion".to_string(), 20.0, true),
("Artikel".to_string(), 58.0, true),
("Δ".to_string(), 10.0, true),
("Menge".to_string(), 16.0, true),
("Status".to_string(), 22.0, true),
("Fahrer".to_string(), 18.0, true),
],
7.5,
);
for s in &d.scan_audit {
let art = s
.article_name
.clone()
.unwrap_or_else(|| format!("Pos {}", s.belegzeilen_nr));
let mut action = scan_action_de(&s.action).to_string();
if s.manual {
action.push('*');
}
p.row(
&[
(dt(&s.server_recorded_at), 30.0, false),
(action, 20.0, false),
(art, 58.0, false),
(format!("{:+}", s.delta), 10.0, false),
(s.resulting_quantity.to_string(), 16.0, false),
(status_de(&s.resulting_status).to_string(), 22.0, false),
(s.actor_personalnummer.to_string(), 18.0, false),
],
7.5,
);
if let Some(r) = &s.reason {
if !r.trim().is_empty() {
p.text(&format!(" Grund: {r}"), 7.0, false, 0.0);
}
}
}
p.text("* = manuell bestätigt", 7.0, false, 0.0);
}
// 9. Protokoll: Gutschriftverlauf
p.heading("Protokoll — Gutschriftverlauf");
if d.credit_audit.is_empty() {
p.text("— keine Einträge —", 9.0, false, 0.0);
} else {
p.row(
&[
("Zeit".to_string(), 34.0, true),
("Aktion".to_string(), 22.0, true),
("Betrag".to_string(), 26.0, true),
("Fahrer".to_string(), 20.0, true),
("Grund".to_string(), 70.0, true),
],
7.5,
);
for c in &d.credit_audit {
p.row(
&[
(dt(&c.recorded_at), 34.0, false),
(credit_action_de(&c.action).to_string(), 22.0, false),
(cents(c.amount_cents), 26.0, false),
(c.author_personalnummer.to_string(), 20.0, false),
(c.reason.clone().unwrap_or_default(), 70.0, false),
],
7.5,
);
}
}
// 10. Anhänge
p.heading("Anhänge");
if d.attachments.is_empty() {
p.text("— keine —", 9.0, false, 0.0);
} else {
for a in &d.attachments {
let meta = format!(
"{} · {} · {} KB · hochgeladen {} von Fahrer {}",
a.filename.clone().unwrap_or_else(|| "(ohne Name)".into()),
a.mime_type,
a.size_bytes / 1024,
dt(&a.uploaded_at),
a.uploaded_by
);
p.text(&meta, 8.0, true, 0.0);
match (&a.bytes, a.mime_type.starts_with("image/")) {
(Some(bytes), true) => p.image(bytes, 90.0, 90.0),
_ => p.text(" (Vorschau nicht verfügbar)", 8.0, false, 0.0),
}
}
}
// 11. Footer
p.gap(4.0);
p.text(
&format!("Automatisch erzeugt am {} — Holzleitner Auslieferung", dt(&d.generated_at)),
7.0,
false,
0.0,
);
p.finish()
}
}

View File

@ -0,0 +1,485 @@
//! Postgres-Implementierung von `DeliveryReportRepository`.
//!
//! Sammelt mit mehreren SELECTs alle Daten einer Lieferung inkl. beider
//! Audit-Trails (`scan_audit`, `delivery_credit_audit`) zum `DeliveryReportData`.
//! Bild-Bytes werden hier NICHT geladen (macht der Use Case).
use async_trait::async_trait;
use chrono::{DateTime, NaiveDate, Utc};
use sqlx::PgPool;
use uuid::Uuid;
use holzleitner_application::dto::{
DeliveryReportData, ReportAttachment, ReportCompletion, ReportContact, ReportCreditAudit,
ReportItem, ReportNote, ReportScanAudit, ReportService,
};
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::DeliveryReportRepository;
pub struct PgDeliveryReportRepository {
pool: PgPool,
}
impl PgDeliveryReportRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
ApplicationError::Repository(e.to_string())
}
#[derive(sqlx::FromRow)]
struct HeadRow {
erp_belegart_id: i64,
erp_belegart_code: Option<String>,
erp_belegart_name: Option<String>,
erp_belegnummer: String,
state: String,
tour_date: NaiveDate,
account_id: i64,
driver_name: String,
car_plate: Option<String>,
payment_method: Option<String>,
erp_customer_id: i64,
customer_name: String,
snap_street: Option<String>,
snap_house_number: Option<String>,
snap_postal_code: Option<String>,
snap_city: Option<String>,
snap_country: Option<String>,
desired_time: Option<String>,
special_agreements: Option<String>,
prepaid_amount: f64,
}
#[derive(sqlx::FromRow)]
struct ItemRow {
belegzeilen_nr: i32,
komponenten_artikel_nr: Option<String>,
parent_artikel_nr: Option<String>,
article_number: String,
name: String,
required_quantity: i32,
credited_quantity: i32,
scanned_quantity: i32,
scan_status: String,
unit_price: f64,
warehouse_code: Option<String>,
warehouse_name: Option<String>,
}
#[derive(sqlx::FromRow)]
struct ServiceRow {
name: String,
bool_value: Option<bool>,
numeric_value: Option<i32>,
}
#[derive(sqlx::FromRow)]
struct NoteRow {
created_at: DateTime<Utc>,
author_personalnummer: i64,
text: Option<String>,
image_attachment: Option<String>,
is_amount_credit_note: bool,
}
#[derive(sqlx::FromRow)]
struct ContactRow {
name: String,
phone: Option<String>,
email: Option<String>,
}
#[derive(sqlx::FromRow)]
struct CompletionRow {
completed_at: DateTime<Utc>,
completed_by_personalnummer: i64,
receipt_confirmed: bool,
notes_acknowledged: bool,
customer_signature_path: String,
driver_signature_path: String,
payment_collected: bool,
collected_amount_cents: Option<i64>,
}
#[derive(sqlx::FromRow)]
struct ScanAuditRow {
server_recorded_at: DateTime<Utc>,
client_scanned_at: DateTime<Utc>,
action: String,
delta: i32,
resulting_quantity: i32,
resulting_status: String,
reason: Option<String>,
manual: bool,
credit_delta: Option<i32>,
actor_personalnummer: i64,
belegzeilen_nr: i32,
komponenten_artikel_nr: Option<String>,
article_name: Option<String>,
}
#[derive(sqlx::FromRow)]
struct CreditAuditRow {
recorded_at: DateTime<Utc>,
action: String,
amount_cents: i64,
reason: Option<String>,
author_personalnummer: i64,
}
#[derive(sqlx::FromRow)]
struct AttachmentRow {
filename: Option<String>,
reference: String,
mime_type: String,
size_bytes: i64,
width: Option<i32>,
height: Option<i32>,
uploaded_at: DateTime<Utc>,
uploaded_by: i64,
}
fn one_line_address(
street: Option<String>,
house: Option<String>,
plz: Option<String>,
city: Option<String>,
country: Option<String>,
) -> String {
let line1 = [street, house]
.into_iter()
.flatten()
.filter(|s| !s.trim().is_empty())
.collect::<Vec<_>>()
.join(" ");
let line2 = [plz, city]
.into_iter()
.flatten()
.filter(|s| !s.trim().is_empty())
.collect::<Vec<_>>()
.join(" ");
[line1, line2, country.unwrap_or_default()]
.into_iter()
.filter(|s| !s.trim().is_empty())
.collect::<Vec<_>>()
.join(", ")
}
#[async_trait]
impl DeliveryReportRepository for PgDeliveryReportRepository {
async fn load(
&self,
delivery_id: Uuid,
) -> Result<Option<DeliveryReportData>, ApplicationError> {
// --- Kopf ---
let head: Option<HeadRow> = sqlx::query_as(
r#"
SELECT d.erp_belegart_id, d.erp_belegart_code, d.erp_belegart_name,
d.erp_belegnummer, d.state,
t.tour_date, t.account_id,
acc.name AS driver_name,
car.plate AS car_plate,
pm.name AS payment_method,
c.erp_customer_id, c.name AS customer_name,
d.snap_street, d.snap_house_number, d.snap_postal_code,
d.snap_city, d.snap_country,
d.desired_time, d.special_agreements, d.prepaid_amount
FROM deliveries d
JOIN tours t ON t.id = d.tour_id
JOIN accounts acc ON acc.personalnummer = t.account_id
LEFT JOIN cars car ON car.id = d.assigned_car_id
LEFT JOIN payment_methods pm ON pm.id = d.payment_method_id
JOIN customers c ON c.id = d.customer_id
WHERE d.id = $1
"#,
)
.bind(delivery_id)
.fetch_optional(&self.pool)
.await
.map_err(db)?;
let Some(head) = head else {
return Ok(None);
};
// --- Positionen ---
let items: Vec<ItemRow> = sqlx::query_as(
r#"
SELECT di.belegzeilen_nr, di.komponenten_artikel_nr, di.parent_artikel_nr,
a.article_number, a.name,
di.required_quantity, di.credited_quantity, di.scanned_quantity,
di.scan_status, di.unit_price,
w.code AS warehouse_code, w.name AS warehouse_name
FROM delivery_items di
JOIN articles a ON a.id = di.article_id
LEFT JOIN warehouses w ON w.id = di.warehouse_id
WHERE di.delivery_id = $1
ORDER BY di.belegzeilen_nr,
di.komponenten_artikel_nr NULLS FIRST
"#,
)
.bind(delivery_id)
.fetch_all(&self.pool)
.await
.map_err(db)?;
// --- Dienstleistungen ---
let services: Vec<ServiceRow> = sqlx::query_as(
r#"
SELECT s.name, ds.bool_value, ds.numeric_value
FROM delivery_services ds
JOIN services s ON s.id = ds.service_id
WHERE ds.delivery_id = $1
ORDER BY s.sort_order, s.name
"#,
)
.bind(delivery_id)
.fetch_all(&self.pool)
.await
.map_err(db)?;
// --- Notizen ---
let notes: Vec<NoteRow> = sqlx::query_as(
r#"
SELECT created_at, author_personalnummer, text, image_attachment,
is_amount_credit_note
FROM delivery_notes
WHERE delivery_id = $1
ORDER BY created_at
"#,
)
.bind(delivery_id)
.fetch_all(&self.pool)
.await
.map_err(db)?;
// --- Ansprechpartner ---
let contacts: Vec<ContactRow> = sqlx::query_as(
r#"
SELECT cc.name, cc.phone, cc.email
FROM delivery_contact_persons dcp
JOIN customer_contacts cc ON cc.id = dcp.customer_contact_id
WHERE dcp.delivery_id = $1
ORDER BY cc.name
"#,
)
.bind(delivery_id)
.fetch_all(&self.pool)
.await
.map_err(db)?;
// --- Abschluss ---
let completion: Option<CompletionRow> = sqlx::query_as(
r#"
SELECT completed_at, completed_by_personalnummer, receipt_confirmed,
notes_acknowledged, customer_signature_path, driver_signature_path,
payment_collected, collected_amount_cents
FROM delivery_completions
WHERE delivery_id = $1
"#,
)
.bind(delivery_id)
.fetch_optional(&self.pool)
.await
.map_err(db)?;
// --- Audit: Scan/Belade-Verlauf ---
let scan_audit: Vec<ScanAuditRow> = sqlx::query_as(
r#"
SELECT sa.server_recorded_at, sa.client_scanned_at, sa.action, sa.delta,
sa.resulting_quantity, sa.resulting_status, sa.reason, sa.manual,
sa.credit_delta, sa.actor_personalnummer,
sa.erp_belegzeilen_nr AS belegzeilen_nr,
sa.erp_komponenten_artikel_nr AS komponenten_artikel_nr,
a.name AS article_name
FROM scan_audit sa
JOIN delivery_items di ON di.id = sa.delivery_item_id
LEFT JOIN articles a ON a.id = di.article_id
WHERE di.delivery_id = $1
ORDER BY sa.server_recorded_at
"#,
)
.bind(delivery_id)
.fetch_all(&self.pool)
.await
.map_err(db)?;
// --- Audit: Gutschrift-Verlauf ---
let credit_audit: Vec<CreditAuditRow> = sqlx::query_as(
r#"
SELECT recorded_at, action, amount_cents, reason, author_personalnummer
FROM delivery_credit_audit
WHERE delivery_id = $1
ORDER BY recorded_at
"#,
)
.bind(delivery_id)
.fetch_all(&self.pool)
.await
.map_err(db)?;
// --- Anhänge ---
let attachments: Vec<AttachmentRow> = sqlx::query_as(
r#"
SELECT filename, docuframe_object_id AS reference, mime_type, size_bytes,
width, height, uploaded_at, uploaded_by
FROM attachments
WHERE delivery_id = $1
ORDER BY uploaded_at
"#,
)
.bind(delivery_id)
.fetch_all(&self.pool)
.await
.map_err(db)?;
// Aktuelle Gutschrift = Wirkung des letzten Events (append-only).
let current_credit_cents = credit_audit
.last()
.map(|e| {
if e.action.eq_ignore_ascii_case("set") {
e.amount_cents
} else {
0
}
})
.unwrap_or(0);
let address = one_line_address(
head.snap_street,
head.snap_house_number,
head.snap_postal_code,
head.snap_city,
head.snap_country,
);
Ok(Some(DeliveryReportData {
generated_at: Utc::now(),
belegart_id: head.erp_belegart_id,
belegart_code: head.erp_belegart_code,
belegart_name: head.erp_belegart_name,
belegnummer: head.erp_belegnummer,
state: head.state,
tour_date: head.tour_date,
driver_personalnummer: head.account_id,
driver_name: head.driver_name,
car_plate: head.car_plate,
payment_method: head.payment_method,
customer_number: head.erp_customer_id,
customer_name: head.customer_name,
address,
desired_time: head.desired_time,
special_agreements: head.special_agreements,
prepaid_amount: head.prepaid_amount,
current_credit_cents,
contacts: contacts
.into_iter()
.map(|c| {
let detail = [c.phone, c.email]
.into_iter()
.flatten()
.filter(|s| !s.trim().is_empty())
.collect::<Vec<_>>()
.join(" · ");
ReportContact {
name: c.name,
detail: if detail.is_empty() { None } else { Some(detail) },
}
})
.collect(),
items: items
.into_iter()
.map(|i| ReportItem {
belegzeilen_nr: i.belegzeilen_nr,
komponenten_artikel_nr: i.komponenten_artikel_nr,
parent_artikel_nr: i.parent_artikel_nr,
article_number: i.article_number,
name: i.name,
required_quantity: i.required_quantity,
credited_quantity: i.credited_quantity,
scanned_quantity: i.scanned_quantity,
scan_status: i.scan_status,
unit_price: i.unit_price,
warehouse_code: i.warehouse_code,
warehouse_name: i.warehouse_name,
})
.collect(),
services: services
.into_iter()
.map(|s| ReportService {
name: s.name,
bool_value: s.bool_value,
numeric_value: s.numeric_value,
})
.collect(),
notes: notes
.into_iter()
.map(|n| ReportNote {
created_at: n.created_at,
author_personalnummer: n.author_personalnummer,
text: n.text,
image_attachment: n.image_attachment,
is_amount_credit_note: n.is_amount_credit_note,
})
.collect(),
completion: completion.map(|c| ReportCompletion {
completed_at: c.completed_at,
completed_by_personalnummer: c.completed_by_personalnummer,
receipt_confirmed: c.receipt_confirmed,
notes_acknowledged: c.notes_acknowledged,
customer_signature_path: c.customer_signature_path,
driver_signature_path: c.driver_signature_path,
payment_collected: c.payment_collected,
collected_amount_cents: c.collected_amount_cents,
}),
scan_audit: scan_audit
.into_iter()
.map(|s| ReportScanAudit {
server_recorded_at: s.server_recorded_at,
client_scanned_at: s.client_scanned_at,
action: s.action,
delta: s.delta,
resulting_quantity: s.resulting_quantity,
resulting_status: s.resulting_status,
reason: s.reason,
manual: s.manual,
credit_delta: s.credit_delta,
actor_personalnummer: s.actor_personalnummer,
belegzeilen_nr: s.belegzeilen_nr,
komponenten_artikel_nr: s.komponenten_artikel_nr,
article_name: s.article_name,
})
.collect(),
credit_audit: credit_audit
.into_iter()
.map(|c| ReportCreditAudit {
recorded_at: c.recorded_at,
action: c.action,
amount_cents: c.amount_cents,
reason: c.reason,
author_personalnummer: c.author_personalnummer,
})
.collect(),
attachments: attachments
.into_iter()
.map(|a| ReportAttachment {
filename: a.filename,
reference: a.reference,
mime_type: a.mime_type,
size_bytes: a.size_bytes,
width: a.width,
height: a.height,
uploaded_at: a.uploaded_at,
uploaded_by: a.uploaded_by,
bytes: None,
})
.collect(),
customer_signature_png: None,
driver_signature_png: None,
}))
}
}

View File

@ -0,0 +1,102 @@
//! Sinks für das fertige Report-PDF.
//!
//! * [`LocalReportSink`] — legt das PDF **temporär** lokal ab
//! (`<dir>/<Belegnummer>/report-<timestamp>.pdf`). Aktiver Sink.
//! * [`DocuframeReportSink`] — **Stub** für später: soll den PDF-Blob an ein
//! DOCUframe-Makro senden. Aktuell nur Logging, keine echte Übertragung.
use std::path::PathBuf;
use async_trait::async_trait;
use chrono::Utc;
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::DeliveryReportSink;
/// Dateisystem-sicheres Segment (alnum/._-, Rest → `_`).
fn sanitize(input: &str) -> String {
let s: String = input
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
c
} else {
'_'
}
})
.collect();
let t = s.trim_matches('.').trim().to_string();
if t.is_empty() { "unbenannt".to_string() } else { t }
}
// ===== Local =============================================================
pub struct LocalReportSink {
base_dir: PathBuf,
}
impl LocalReportSink {
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 })
}
}
#[async_trait]
impl DeliveryReportSink for LocalReportSink {
async fn deliver(&self, folder: &str, pdf: Vec<u8>) -> Result<String, ApplicationError> {
let folder = sanitize(folder);
let stamp = Utc::now().format("%Y%m%d-%H%M%S").to_string();
let dir = self.base_dir.join(&folder);
let name = format!("report-{stamp}.pdf");
let path = dir.join(&name);
let path_str = path.to_string_lossy().to_string();
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
std::fs::create_dir_all(&dir)?;
std::fs::write(&path, &pdf)
})
.await
.map_err(|e| ApplicationError::Repository(format!("join error: {e}")))?
.map_err(|e| ApplicationError::Repository(format!("report speichern fehlgeschlagen: {e}")))?;
tracing::info!(path = %path_str, "report.sink.local: PDF abgelegt");
Ok(path_str)
}
async fn delete(&self, folder: &str) -> Result<(), ApplicationError> {
// Löscht das gesamte Belegnummer-Verzeichnis (alle abgelegten Reports).
let dir = self.base_dir.join(sanitize(folder));
tokio::task::spawn_blocking(move || match std::fs::remove_dir_all(&dir) {
Ok(()) => Ok(()),
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!("report löschen fehlgeschlagen: {e}")))
}
}
// ===== DOCUframe (Stub) ==================================================
/// Platzhalter — später: PDF-Blob an ein DOCUframe-Makro senden. Aktuell nur
/// Logging, keine echte Übertragung.
pub struct DocuframeReportSink;
#[async_trait]
impl DeliveryReportSink for DocuframeReportSink {
async fn deliver(&self, folder: &str, pdf: Vec<u8>) -> Result<String, ApplicationError> {
tracing::warn!(
belegnummer = folder,
pdf_bytes = pdf.len(),
"report.sink.docuframe: STUB — PDF würde an DOCUframe-Makro gesendet (noch nicht implementiert)"
);
Ok(format!("docuframe-stub://{folder}"))
}
async fn delete(&self, _folder: &str) -> Result<(), ApplicationError> {
Ok(())
}
}

View 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}")))
}
}

View 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;

View 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}"))
})
}
}