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:
@ -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
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(_) => {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
102
crates/api/src/middleware/admin_key.rs
Normal file
102
crates/api/src/middleware/admin_key.rs
Normal 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ühzeitigen 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"));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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"))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
224
crates/api/src/routes/admin.rs
Normal file
224
crates/api/src/routes/admin.rs
Normal 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 }))
|
||||
}
|
||||
88
crates/api/src/routes/attachments.rs
Normal file
88
crates/api/src/routes/attachments.rs
Normal 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 0–100 (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())
|
||||
}
|
||||
@ -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.
|
||||
|
||||
160
crates/api/src/routes/dev.rs
Normal file
160
crates/api/src/routes/dev.rs
Normal 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 }))
|
||||
}
|
||||
@ -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;
|
||||
|
||||
142
crates/api/src/routes/payment_methods.rs
Normal file
142
crates/api/src/routes/payment_methods.rs
Normal 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)
|
||||
}
|
||||
133
crates/api/src/routes/services.rs
Normal file
133
crates/api/src/routes/services.rs
Normal 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)
|
||||
}
|
||||
@ -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>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user