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:
24
.env.example
24
.env.example
@ -1,24 +0,0 @@
|
||||
# Vorlage für lokale Entwicklung — kopieren nach `.env` und nach Bedarf anpassen.
|
||||
# Die `.env` selbst gehört nicht in Git.
|
||||
|
||||
# --- HTTP-Server ----------------------------------------------------------
|
||||
SERVER_HOST=127.0.0.1
|
||||
SERVER_PORT=3000
|
||||
|
||||
# --- Postgres -------------------------------------------------------------
|
||||
# Passt zur docker-compose.yml (Service `postgres`).
|
||||
DATABASE_URL=postgres://holzleitner:holzleitner_dev@localhost:5432/holzleitner
|
||||
DATABASE_MAX_CONNECTIONS=10
|
||||
|
||||
# --- Keycloak (OIDC) ------------------------------------------------------
|
||||
# Passt zur docker-compose.yml (Service `keycloak`).
|
||||
# Admin-UI: http://localhost:8080/admin/ (admin / admin)
|
||||
# Realm: holzleitner
|
||||
# Test-User: testfahrer / test (Personalnummer 1001, Rolle "driver")
|
||||
KEYCLOAK_ISSUER_URL=http://localhost:8080/realms/holzleitner
|
||||
KEYCLOAK_AUDIENCE=holzleitner-api
|
||||
KEYCLOAK_JWKS_CACHE_TTL_SECONDS=3600
|
||||
|
||||
# --- Logging --------------------------------------------------------------
|
||||
# Standard-Filter; siehe tracing_subscriber::EnvFilter-Doku.
|
||||
RUST_LOG=holzleitner_api=info,holzleitner_infrastructure=info,tower_http=info
|
||||
17
.gitignore
vendored
17
.gitignore
vendored
@ -1,3 +1,18 @@
|
||||
/target
|
||||
.env
|
||||
# Lokale Konfiguration mit Secrets — Vorlage: config.example.toml
|
||||
config.toml
|
||||
.DS_Store
|
||||
|
||||
# Laufzeit-Daten (Unterschriften, Bild-Notizen, Reports) — enthält
|
||||
# Kundendaten, gehört NICHT ins Repo.
|
||||
/data
|
||||
|
||||
# Lokale Tool-/Agent-Artefakte
|
||||
/.claude
|
||||
|
||||
# Große Demo-/Build-Artefakte
|
||||
demo.mp4
|
||||
output.png
|
||||
|
||||
# ERP-Schema-/AI-Wissensablage (separates Konzern-Wissen, kein Backend-Source)
|
||||
/variocontrol-ai
|
||||
|
||||
1098
Cargo.lock
generated
1098
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
14
Cargo.toml
14
Cargo.toml
@ -22,17 +22,23 @@ chrono = { version = "0.4", default-features = false, features = ["serde", "cloc
|
||||
async-trait = "0.1"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
axum = "0.8"
|
||||
axum = { version = "0.8", features = ["multipart"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["trace", "cors"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "macros"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart"] }
|
||||
# MSSQL (ERPframe) — nativer async-Treiber. Kein Pool: der ERP-Pull läuft
|
||||
# einmal täglich, eine frische Verbindung pro Lauf reicht.
|
||||
tiberius = { version = "0.12", default-features = false, features = ["chrono", "rustls", "tds73"] }
|
||||
tokio-util = { version = "0.7", features = ["compat"] }
|
||||
tokio-cron-scheduler = "0.13"
|
||||
jsonwebtoken = "9"
|
||||
envy = "0.4"
|
||||
dotenvy = "0.15"
|
||||
toml = "0.8"
|
||||
anyhow = "1"
|
||||
sha2 = "0.10"
|
||||
imagesize = "0.13"
|
||||
utoipa = { version = "5", features = ["axum_extras", "chrono", "uuid"] }
|
||||
utoipa-swagger-ui = { version = "9", features = ["axum"] }
|
||||
|
||||
|
||||
37
README.md
37
README.md
@ -10,8 +10,9 @@ Architecture: `domain` → `application` → `infrastructure` → `api`.
|
||||
docker compose up -d
|
||||
# Keycloak braucht ~30s bis "Listening on http://0.0.0.0:8080" im Log steht.
|
||||
|
||||
# 2) Env-Datei vorbereiten
|
||||
cp .env.example .env
|
||||
# 2) Konfiguration vorbereiten
|
||||
cp config.example.toml config.toml
|
||||
# Werte in config.toml anpassen (DB-URL, Keycloak-Issuer, ERP-Zugang, …).
|
||||
|
||||
# 3) Backend starten
|
||||
cargo run -p holzleitner-api
|
||||
@ -73,14 +74,27 @@ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:3000/accounts/1001
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Werte werden aus Umgebungsvariablen gelesen (siehe `.env.example`),
|
||||
gruppiert nach Prefix:
|
||||
Werte werden aus `config.toml` gelesen (Vorlage: `config.example.toml`),
|
||||
gruppiert in TOML-Sections. Der Dateipfad ist über die Env-Variable
|
||||
`HOLZLEITNER_CONFIG` überschreibbar (z. B. im Deployment); Default ist
|
||||
`config.toml` im Arbeitsverzeichnis. Die echte `config.toml` enthält
|
||||
Secrets und ist `.gitignore`-t.
|
||||
|
||||
| Prefix | Bereich |
|
||||
|---|---|
|
||||
| `SERVER_*` | Bind-Host/Port |
|
||||
| `DATABASE_*` | Postgres-URL, Pool-Größe |
|
||||
| `KEYCLOAK_*` | OIDC-Issuer, Audience, JWKS-Cache (greift erst in der Keycloak-Phase) |
|
||||
| Section | Bereich | Pflicht? |
|
||||
|---|---|---|
|
||||
| `[server]` | Bind-Host/Port | ja |
|
||||
| `[database]` | Postgres-URL, Pool-Größe | ja |
|
||||
| `[keycloak]` | OIDC-Issuer, Audience, JWKS-Cache, Provisioning | ja |
|
||||
| `[gsd]` | DOCUframe-REST (Datei-Upload) | ja |
|
||||
| `[erp]` | ERPframe-MSSQL (Touren-Pull) | optional |
|
||||
| `[import]` | Import-Scheduler (Cron, Offset) | optional |
|
||||
| `[report]` / `[signature]` / `[attachment]` | Lokale Speicher-Pfade | optional |
|
||||
| `[dev]` | `today_override`, `sync_enabled` (DEV-ONLY) | optional |
|
||||
| `[admin]` | `api_key` für das `/admin`-Gate | optional |
|
||||
| `[logging]` | Log-Filter (Default; `RUST_LOG`-Env hat Vorrang) | optional |
|
||||
|
||||
Unbekannte Schlüssel werden beim Laden abgewiesen (`deny_unknown_fields`),
|
||||
sodass Tippfehler sofort als Startfehler auffallen.
|
||||
|
||||
## Migrations
|
||||
|
||||
@ -95,5 +109,6 @@ touch migrations/0002_tour.sql
|
||||
|
||||
## Logging
|
||||
|
||||
`tracing` + `tracing-subscriber` mit `EnvFilter`. Default:
|
||||
`holzleitner_api=info,tower_http=info`. Override via `RUST_LOG`.
|
||||
`tracing` + `tracing-subscriber` mit `EnvFilter`. Der Default-Filter steht
|
||||
in `config.toml` unter `[logging] filter`; die Env-Variable `RUST_LOG` hat
|
||||
Vorrang (Ad-hoc-Debugging ohne Datei-Edit, z. B. `RUST_LOG=debug cargo run`).
|
||||
|
||||
92
config.example.toml
Normal file
92
config.example.toml
Normal file
@ -0,0 +1,92 @@
|
||||
# Vorlage für die lokale Backend-Konfiguration.
|
||||
# Kopieren nach `config.toml` und Werte anpassen:
|
||||
#
|
||||
# cp config.example.toml config.toml
|
||||
#
|
||||
# Die `config.toml` selbst enthält Secrets und gehört NICHT in Git.
|
||||
# Pfad überschreibbar via Env `HOLZLEITNER_CONFIG=/pfad/zu/config.toml`.
|
||||
#
|
||||
# Pflicht-Sections: [server], [database], [keycloak], [gsd].
|
||||
# Alle anderen Sections/Felder sind optional und fallen auf Defaults zurück.
|
||||
|
||||
# --- HTTP-Server ----------------------------------------------------------
|
||||
[server]
|
||||
host = "127.0.0.1"
|
||||
port = 3000
|
||||
|
||||
# --- Postgres -------------------------------------------------------------
|
||||
# Passt zur docker-compose.yml (Service `postgres`).
|
||||
[database]
|
||||
url = "postgres://holzleitner:holzleitner_dev@localhost:5432/holzleitner"
|
||||
max_connections = 10
|
||||
|
||||
# --- Keycloak (OIDC) ------------------------------------------------------
|
||||
# issuer_url muss EXAKT dem `iss`-Claim entsprechen, das Keycloak ausstellt.
|
||||
[keycloak]
|
||||
issuer_url = "http://localhost:8080/realms/holzleitner"
|
||||
audience = "holzleitner-api"
|
||||
jwks_cache_ttl_seconds = 3600
|
||||
admin_url = "http://localhost:8080"
|
||||
realm = "holzleitner"
|
||||
provisioner_client_id = "holzleitner-provisioner"
|
||||
provisioner_client_secret = "provisioner-dev-secret"
|
||||
driver_default_password = "Holzleitner-Start1!"
|
||||
driver_role = "driver"
|
||||
# Default false → Sync legt keine Fahrer-Konten an.
|
||||
provisioning_enabled = false
|
||||
|
||||
# --- GSD / DOCUframe (Datei-Upload) ---------------------------------------
|
||||
# password_md5 ist der MD5-Hash des Service-Account-Passworts (kein Klartext).
|
||||
[gsd]
|
||||
rest_url = "http://192.168.1.9:8334"
|
||||
app_key = "GSD-RestApi"
|
||||
user = "GSDWebServiceTmp"
|
||||
password_md5 = "<md5-des-passworts>"
|
||||
app_names = ["GSD-RestApi"]
|
||||
|
||||
# --- ERPframe MSSQL (täglicher Touren-Pull) -------------------------------
|
||||
[erp]
|
||||
host = "192.168.1.7"
|
||||
port = 61279
|
||||
database = "HOLZ_SQL"
|
||||
user = "sa"
|
||||
password = "<erp-passwort>"
|
||||
trust_cert = true
|
||||
writeback_enabled = false
|
||||
|
||||
# --- ERP-Import-Scheduler -------------------------------------------------
|
||||
[import]
|
||||
enabled = false
|
||||
cron = "0 0 3 * * *"
|
||||
date_offset_days = 1
|
||||
|
||||
# --- PDF-Lieferreport → DOCUframe ----------------------------------------
|
||||
[report]
|
||||
storage_dir = "./data/reports"
|
||||
upload_enabled = false
|
||||
retry_cron = "0 */5 * * * *"
|
||||
|
||||
# --- Lokale Speicher (Signaturen / Bild-Notizen) --------------------------
|
||||
[signature]
|
||||
storage_dir = "./data/signatures"
|
||||
|
||||
[attachment]
|
||||
storage_dir = "./data/attachments"
|
||||
|
||||
# --- DEV-ONLY-Schalter ----------------------------------------------------
|
||||
[dev]
|
||||
# today_override weglassen = echte Uhr. Zum Testen mit importierten Touren
|
||||
# den quotierten String setzen, z. B.:
|
||||
# today_override = "2026-06-01"
|
||||
sync_enabled = false
|
||||
|
||||
# --- Admin-API-Key (Maschinen-Zugang zu /admin) ---------------------------
|
||||
# Leer ⇒ alle /admin-Endpunkte gesperrt (fail-closed). In Produktion einen
|
||||
# hochentropischen Zufallswert setzen (`openssl rand -hex 32`).
|
||||
[admin]
|
||||
api_key = ""
|
||||
|
||||
# --- Logging --------------------------------------------------------------
|
||||
# RUST_LOG-Env hat Vorrang. Binary-Crate heißt `holzleitner_server`.
|
||||
[logging]
|
||||
filter = "holzleitner_server=info,holzleitner_api=info,holzleitner_application=info,holzleitner_infrastructure=info,tower_http=info"
|
||||
@ -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>,
|
||||
}
|
||||
|
||||
@ -17,4 +17,6 @@ async-trait.workspace = true
|
||||
thiserror.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
sha2.workspace = true
|
||||
imagesize.workspace = true
|
||||
utoipa = { workspace = true, optional = true }
|
||||
|
||||
40
crates/application/src/dto/complete.rs
Normal file
40
crates/application/src/dto/complete.rs
Normal file
@ -0,0 +1,40 @@
|
||||
//! Eingabe für den Lieferungs-Abschluss (`POST /deliveries/{id}/complete`).
|
||||
//!
|
||||
//! Der Endpoint nimmt `multipart/form-data` entgegen — zwei Signatur-PNGs
|
||||
//! plus dieses JSON-Feld mit den Checkbox-Bestätigungen des Kunden. Die
|
||||
//! Antwort ist die frisch abgeschlossene `Delivery` (`DeliveryResponse`).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Dokumentierte Bestätigungen des Kunden zum Abschlusszeitpunkt.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompleteDeliveryAcknowledgements {
|
||||
/// „Ware im ordnungsgemäßen Zustand erhalten / Aufbau korrekt." — Pflicht.
|
||||
pub receipt_confirmed: bool,
|
||||
/// „Anmerkungen zur Lieferung zur Kenntnis genommen." — Pflicht nur, wenn
|
||||
/// Notizen existieren (das prüft der Server).
|
||||
#[serde(default)]
|
||||
pub notes_acknowledged: bool,
|
||||
/// Notiz-IDs, die zum Abschlusszeitpunkt sichtbar waren und mit-bestätigt
|
||||
/// wurden (Audit-Robustheit).
|
||||
#[serde(default)]
|
||||
pub acknowledged_note_ids: Vec<Uuid>,
|
||||
/// Inkasso-Bestätigung des Fahrers: „der offene Betrag wurde erhalten
|
||||
/// (bar) bzw. über das EC-Gerät abgerechnet." Pflicht nur, wenn beim
|
||||
/// Abschluss ein offener Betrag > 0 besteht UND die Methode ein Vor-Ort-
|
||||
/// Inkasso ist (Bar/EC) — das prüft der Server. Der kassierte Betrag wird
|
||||
/// server-seitig autoritativ berechnet (nicht vom Client übernommen).
|
||||
#[serde(default)]
|
||||
pub payment_collected: bool,
|
||||
/// Optionale Zahlungsmethode, die der Fahrer beim Abschluss gewählt hat.
|
||||
/// `None` = die am Beleg hinterlegte Methode bleibt. Falls gesetzt, muss
|
||||
/// sie existieren **und** aktiv sein (vom Server geprüft).
|
||||
#[serde(default)]
|
||||
pub payment_method_id: Option<Uuid>,
|
||||
/// Fahrzeug des Akteurs (Audit-Spur). Muss zum Account gehören.
|
||||
#[serde(default)]
|
||||
pub author_car_id: Option<Uuid>,
|
||||
}
|
||||
45
crates/application/src/dto/credit.rs
Normal file
45
crates/application/src/dto/credit.rs
Normal file
@ -0,0 +1,45 @@
|
||||
//! Request/Response für `POST /deliveries/{id}/credit` — die
|
||||
//! Betrags-Gutschrift (append-only, idempotent über `client_event_id`).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_domain::DeliveryCredit;
|
||||
|
||||
/// Art des Gutschrift-Ereignisses.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CreditAction {
|
||||
/// Gutschrift setzen/ändern — `amount_cents` und `reason` Pflicht.
|
||||
Set,
|
||||
/// Gutschrift entfernen — `amount_cents`/`reason` werden ignoriert.
|
||||
Remove,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeliveryCreditEventRequest {
|
||||
/// Idempotenz-Schlüssel — pro erzeugtem Ereignis genau einmal vergeben.
|
||||
/// Ein Retry mit derselben Id wendet nichts erneut an.
|
||||
pub client_event_id: Uuid,
|
||||
pub action: CreditAction,
|
||||
/// Bei `Set` Pflicht: Betrag in Cent (> 0, ≤ 15000, Vielfaches von 1000).
|
||||
#[serde(default)]
|
||||
pub amount_cents: Option<i64>,
|
||||
/// Bei `Set` Pflicht: Begründung.
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
/// Fahrzeug des Akteurs (Audit-Spur). Muss zum Account gehören.
|
||||
#[serde(default)]
|
||||
pub author_car_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeliveryCreditResponse {
|
||||
/// Aktueller Stand nach dem Ereignis — `None`, wenn (zuletzt) entfernt.
|
||||
pub credit: Option<DeliveryCredit>,
|
||||
}
|
||||
149
crates/application/src/dto/delivery_report.rs
Normal file
149
crates/application/src/dto/delivery_report.rs
Normal file
@ -0,0 +1,149 @@
|
||||
//! DTO-Aggregat für den PDF-Lieferreport.
|
||||
//!
|
||||
//! Bündelt **alle** Informationen zu einer Lieferung inkl. der beiden
|
||||
//! Audit-Trails (`scan_audit`, `delivery_credit_audit`). Wird vom
|
||||
//! `DeliveryReportRepository` (DB) befüllt; die Bild-Bytes (Unterschriften,
|
||||
//! Anhänge) hängt der Use Case nachträglich aus dem lokalen Speicher an,
|
||||
//! damit der Renderer rein (ohne IO) bleibt.
|
||||
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeliveryReportData {
|
||||
pub generated_at: DateTime<Utc>,
|
||||
// Kopf
|
||||
pub belegart_id: i64,
|
||||
/// Belegart-Kurzcode (z. B. „VL5"), falls vom Sync befüllt.
|
||||
pub belegart_code: Option<String>,
|
||||
/// Belegart-Klartext (z. B. „Lieferschein EH").
|
||||
pub belegart_name: Option<String>,
|
||||
pub belegnummer: String,
|
||||
pub state: String,
|
||||
pub tour_date: NaiveDate,
|
||||
pub driver_personalnummer: i64,
|
||||
pub driver_name: String,
|
||||
pub car_plate: Option<String>,
|
||||
pub payment_method: Option<String>,
|
||||
// Kunde + Adresse
|
||||
pub customer_number: i64,
|
||||
pub customer_name: String,
|
||||
pub address: String,
|
||||
pub desired_time: Option<String>,
|
||||
pub special_agreements: Option<String>,
|
||||
pub prepaid_amount: f64,
|
||||
pub current_credit_cents: i64,
|
||||
pub contacts: Vec<ReportContact>,
|
||||
pub items: Vec<ReportItem>,
|
||||
pub services: Vec<ReportService>,
|
||||
pub notes: Vec<ReportNote>,
|
||||
pub completion: Option<ReportCompletion>,
|
||||
pub scan_audit: Vec<ReportScanAudit>,
|
||||
pub credit_audit: Vec<ReportCreditAudit>,
|
||||
pub attachments: Vec<ReportAttachment>,
|
||||
// Bild-Bytes (vom Use Case aus dem lokalen Speicher nachgeladen):
|
||||
pub customer_signature_png: Option<Vec<u8>>,
|
||||
pub driver_signature_png: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReportContact {
|
||||
pub name: String,
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReportItem {
|
||||
pub belegzeilen_nr: i32,
|
||||
pub komponenten_artikel_nr: Option<String>,
|
||||
pub parent_artikel_nr: Option<String>,
|
||||
pub article_number: String,
|
||||
pub name: String,
|
||||
pub required_quantity: i32,
|
||||
pub credited_quantity: i32,
|
||||
pub scanned_quantity: i32,
|
||||
pub scan_status: String,
|
||||
pub unit_price: f64,
|
||||
pub warehouse_code: Option<String>,
|
||||
pub warehouse_name: Option<String>,
|
||||
}
|
||||
|
||||
impl ReportItem {
|
||||
pub fn is_component(&self) -> bool {
|
||||
self.komponenten_artikel_nr.is_some()
|
||||
}
|
||||
/// Tatsächlich ausgeliefert = Soll − Gutschrift.
|
||||
pub fn delivered(&self) -> i32 {
|
||||
(self.required_quantity - self.credited_quantity).max(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReportService {
|
||||
pub name: String,
|
||||
pub bool_value: Option<bool>,
|
||||
pub numeric_value: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReportNote {
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub author_personalnummer: i64,
|
||||
pub text: Option<String>,
|
||||
pub image_attachment: Option<String>,
|
||||
pub is_amount_credit_note: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReportCompletion {
|
||||
pub completed_at: DateTime<Utc>,
|
||||
pub completed_by_personalnummer: i64,
|
||||
pub receipt_confirmed: bool,
|
||||
pub notes_acknowledged: bool,
|
||||
pub customer_signature_path: String,
|
||||
pub driver_signature_path: String,
|
||||
/// Fahrer hat das Inkasso (Bar/EC) bestätigt.
|
||||
pub payment_collected: bool,
|
||||
/// Snapshot des kassierten offenen Betrags in Cent (None = kein Inkasso).
|
||||
pub collected_amount_cents: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReportScanAudit {
|
||||
pub server_recorded_at: DateTime<Utc>,
|
||||
pub client_scanned_at: DateTime<Utc>,
|
||||
pub action: String,
|
||||
pub delta: i32,
|
||||
pub resulting_quantity: i32,
|
||||
pub resulting_status: String,
|
||||
pub reason: Option<String>,
|
||||
pub manual: bool,
|
||||
pub credit_delta: Option<i32>,
|
||||
pub actor_personalnummer: i64,
|
||||
pub belegzeilen_nr: i32,
|
||||
pub komponenten_artikel_nr: Option<String>,
|
||||
pub article_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReportCreditAudit {
|
||||
pub recorded_at: DateTime<Utc>,
|
||||
pub action: String,
|
||||
pub amount_cents: i64,
|
||||
pub reason: Option<String>,
|
||||
pub author_personalnummer: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReportAttachment {
|
||||
pub filename: Option<String>,
|
||||
/// Speicher-Referenz (lokaler relativer Pfad) — zum Nachladen der Bytes.
|
||||
pub reference: String,
|
||||
pub mime_type: String,
|
||||
pub size_bytes: i64,
|
||||
pub width: Option<i32>,
|
||||
pub height: Option<i32>,
|
||||
pub uploaded_at: DateTime<Utc>,
|
||||
pub uploaded_by: i64,
|
||||
/// Vom Use Case aus dem lokalen Speicher nachgeladen (fürs Einbetten).
|
||||
pub bytes: Option<Vec<u8>>,
|
||||
}
|
||||
@ -11,10 +11,15 @@
|
||||
//! zweite Schicht handgeschriebener API-DTOs.
|
||||
|
||||
pub mod car;
|
||||
pub mod complete;
|
||||
pub mod credit;
|
||||
pub mod delivery_action;
|
||||
pub mod delivery_report;
|
||||
pub mod delivery_order;
|
||||
pub mod note;
|
||||
pub mod payment_method;
|
||||
pub mod scan;
|
||||
pub mod service;
|
||||
pub mod tour_details;
|
||||
pub mod tour_summary;
|
||||
pub mod tour_sync;
|
||||
@ -22,14 +27,32 @@ pub mod tour_sync;
|
||||
pub use car::{
|
||||
AssignCarRequest, CarResponse, CarsList, CreateCarRequest, UpdateCarRequest,
|
||||
};
|
||||
pub use complete::CompleteDeliveryAcknowledgements;
|
||||
pub use credit::{CreditAction, DeliveryCreditEventRequest, DeliveryCreditResponse};
|
||||
pub use delivery_report::{
|
||||
DeliveryReportData, ReportAttachment, ReportCompletion, ReportContact, ReportCreditAudit,
|
||||
ReportItem, ReportNote, ReportScanAudit, ReportService,
|
||||
};
|
||||
pub use delivery_action::{CancelDeliveryRequest, DeliveryResponse, HoldDeliveryRequest};
|
||||
pub use delivery_order::{
|
||||
DeliveryOrderEntry, SetDeliveryOrderRequest, SetDeliveryOrderResponse,
|
||||
};
|
||||
pub use note::{CreateDeliveryNoteRequest, DeliveryNoteResponse};
|
||||
pub use note::{
|
||||
CreateDeliveryNoteRequest, DeliveryNoteResponse, UpdateDeliveryNoteRequest,
|
||||
};
|
||||
pub use payment_method::{
|
||||
CreatePaymentMethodRequest, PaymentMethodResponse, PaymentMethodsList,
|
||||
UpdatePaymentMethodRequest,
|
||||
};
|
||||
pub use scan::{
|
||||
ApplyScansRequest, ApplyScansResponse, ScanEvent, ScanResult, ScanResultStatus,
|
||||
};
|
||||
pub use service::{
|
||||
CreateServiceRequest, DeliveryServiceResponse, ServiceResponse, ServicesList,
|
||||
SetDeliveryServiceRequest, UpdateServiceRequest,
|
||||
};
|
||||
pub use tour_details::{DeliveryWithItems, TourDetails};
|
||||
pub use tour_summary::TourSummary;
|
||||
pub use tour_sync::{SyncDelivery, SyncDeliveryItem, SyncTourRequest};
|
||||
pub use tour_sync::{
|
||||
SyncContactChannel, SyncContactSource, SyncDelivery, SyncDeliveryItem, SyncTourRequest,
|
||||
};
|
||||
|
||||
@ -19,6 +19,27 @@ pub struct CreateDeliveryNoteRequest {
|
||||
/// Fahrzeug, das die Notiz erzeugt hat. Muss zum angemeldeten
|
||||
/// Account gehören. `None` ist erlaubt.
|
||||
pub author_car_id: Option<Uuid>,
|
||||
/// Optionaler Gutschrift-Bezug: die Belegzeile, für die diese Notiz als
|
||||
/// Gutschrift-Grund angelegt wird. Ermöglicht das gezielte Löschen beim
|
||||
/// Unremove. `None` für normale Notizen.
|
||||
#[serde(default)]
|
||||
pub credit_delivery_item_id: Option<Uuid>,
|
||||
/// `true` markiert die Notiz als Grund einer Betrags-Gutschrift
|
||||
/// (Lieferungs-Ebene). Default `false`.
|
||||
#[serde(default)]
|
||||
pub is_amount_credit_note: bool,
|
||||
}
|
||||
|
||||
/// Request für `PATCH /deliveries/{id}/notes/{note_id}`. Wie beim Create
|
||||
/// muss mindestens eines von `text` / `image_attachment` inhaltlich gefüllt
|
||||
/// sein — geprüft im Use Case.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateDeliveryNoteRequest {
|
||||
pub text: Option<String>,
|
||||
/// Object-Storage-Key oder URL eines vorab hochgeladenen Bildes.
|
||||
pub image_attachment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
41
crates/application/src/dto/payment_method.rs
Normal file
41
crates/application/src/dto/payment_method.rs
Normal file
@ -0,0 +1,41 @@
|
||||
//! Request- und Antwort-Typen für die Payment-Methods-Endpoints.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use holzleitner_domain::PaymentMethod;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreatePaymentMethodRequest {
|
||||
/// Eindeutiger Programm-Identifier (z. B. `"paypal"`, `"klarna"`).
|
||||
pub code: String,
|
||||
/// Anzeige-Name in der UI.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdatePaymentMethodRequest {
|
||||
/// Wenn gesetzt: neuer Anzeige-Name.
|
||||
pub name: Option<String>,
|
||||
/// Wenn gesetzt: aktiv/inaktiv. Inaktive Methoden bleiben für
|
||||
/// historische Lieferungen referenzierbar, tauchen aber im
|
||||
/// Default-Listing nicht auf.
|
||||
pub active: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PaymentMethodResponse {
|
||||
pub method: PaymentMethod,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PaymentMethodsList {
|
||||
pub methods: Vec<PaymentMethod>,
|
||||
}
|
||||
@ -27,6 +27,19 @@ pub struct ScanEvent {
|
||||
pub action: AuditAction,
|
||||
/// Pflicht bei `Hold` und `Remove`. Sonst ignoriert.
|
||||
pub reason: Option<String>,
|
||||
/// Menge für `Remove` / `Unremove` (Mengen-Gutschrift): wie viele Stück
|
||||
/// der Belegzeile gutgeschrieben bzw. wieder hergestellt werden.
|
||||
/// `None` = ganze Restmenge (abwärtskompatibel zum bisherigen
|
||||
/// „ganze Zeile entfernen"). Bei `Scan`/`Unscan`/`Hold`/`Unhold`
|
||||
/// ignoriert. Muss, wenn gesetzt, `> 0` sein.
|
||||
#[serde(default)]
|
||||
pub quantity: Option<i32>,
|
||||
/// `true`, wenn der Fahrer die Position **manuell** als geladen bestätigt
|
||||
/// hat (Fallback ohne Barcode-Scan). Wird nur im Audit (`scan_audit.manual`)
|
||||
/// festgehalten; an der Mengen-/Status-Logik ändert es nichts. Default
|
||||
/// `false` (regulärer Barcode-Scan).
|
||||
#[serde(default)]
|
||||
pub manual: bool,
|
||||
pub client_scanned_at: DateTime<Utc>,
|
||||
/// Fahrzeug, in dem der Scan gemacht wurde. Muss zum
|
||||
/// angemeldeten Account gehören. `None` ist erlaubt, schwächt
|
||||
|
||||
73
crates/application/src/dto/service.rs
Normal file
73
crates/application/src/dto/service.rs
Normal file
@ -0,0 +1,73 @@
|
||||
//! Request-/Antwort-Typen für die Services-Endpoints (Stammdaten-CRUD +
|
||||
//! Pro-Lieferung-Wert).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_domain::{DeliveryServiceValue, Service, ServiceKind};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateServiceRequest {
|
||||
/// Eindeutiger Programm-Identifier (z. B. `"podium_setup"`).
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub kind: ServiceKind,
|
||||
/// Nur bei `Numeric` sinnvoll.
|
||||
#[serde(default)]
|
||||
pub min_value: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub max_value: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub sort_order: Option<i32>,
|
||||
}
|
||||
|
||||
/// Teil-Update eines Service. `kind` ist bewusst **nicht** änderbar — ein
|
||||
/// Wechsel boolean↔numeric würde bestehende Pro-Lieferung-Werte ungültig
|
||||
/// machen (dann lieber deaktivieren + neu anlegen).
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateServiceRequest {
|
||||
pub name: Option<String>,
|
||||
pub min_value: Option<i32>,
|
||||
pub max_value: Option<i32>,
|
||||
pub active: Option<bool>,
|
||||
pub sort_order: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServiceResponse {
|
||||
pub service: Service,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServicesList {
|
||||
pub services: Vec<Service>,
|
||||
}
|
||||
|
||||
/// Setzt den Wert eines Service für eine Lieferung (Upsert). Es muss genau
|
||||
/// das zum `ServiceKind` passende Feld gesetzt sein (Use Case prüft das).
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetDeliveryServiceRequest {
|
||||
#[serde(default)]
|
||||
pub bool_value: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub numeric_value: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub author_car_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeliveryServiceResponse {
|
||||
pub value: DeliveryServiceValue,
|
||||
}
|
||||
@ -10,7 +10,8 @@
|
||||
use serde::Serialize;
|
||||
|
||||
use holzleitner_domain::{
|
||||
Article, Customer, CustomerContact, Delivery, DeliveryItem, DeliveryNote, Tour, Warehouse,
|
||||
Article, ContactChannel, ContactSource, Customer, CustomerContact, Delivery, DeliveryCredit,
|
||||
DeliveryItem, DeliveryNote, DeliveryServiceValue, Service, Tour, Warehouse,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@ -27,6 +28,24 @@ pub struct TourDetails {
|
||||
/// Die App joint clientseitig per `delivery_id`. Reihenfolge:
|
||||
/// pro Lieferung aufsteigend nach `created_at`.
|
||||
pub notes: Vec<DeliveryNote>,
|
||||
/// Aktuelle Betrags-Gutschriften (jüngster Stand pro Lieferung), nur für
|
||||
/// Lieferungen, deren letztes Ereignis `set` war. Join per `delivery_id`.
|
||||
pub credits: Vec<DeliveryCredit>,
|
||||
/// Aktive Service-Definitionen (Stammdaten) — die App rendert daraus
|
||||
/// Phase 4. Bewusst hier mitgeliefert, damit die Detailseite alles aus
|
||||
/// dem Tour-Aggregat hat.
|
||||
pub services: Vec<Service>,
|
||||
/// Pro-Lieferung gesetzte Service-Werte. Join per `delivery_id` +
|
||||
/// `service_id`.
|
||||
pub delivery_services: Vec<DeliveryServiceValue>,
|
||||
/// Alle vom ERP gespiegelten Kontaktquellen aller Lieferungen dieser
|
||||
/// Tour. Die App joint clientseitig per `delivery_id` und gruppiert
|
||||
/// nach `role` (Lieferadresse / Rechnungsadresse / Ansprechpartner /
|
||||
/// Kundenstamm / Belegadresse).
|
||||
pub contact_sources: Vec<ContactSource>,
|
||||
/// Die zu `contact_sources` gehörenden Einzel-Kanäle (Telefon, Mobil,
|
||||
/// E-Mail, Web). Join per `source_id`.
|
||||
pub contact_channels: Vec<ContactChannel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use holzleitner_domain::Address;
|
||||
use holzleitner_domain::{Address, ContactKind, ContactRole};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
@ -29,6 +29,12 @@ pub struct SyncTourRequest {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncDelivery {
|
||||
pub belegart_id: i64,
|
||||
/// Belegart-Kurzcode (z. B. „VL5"), aus `Belegarten.Belegart` (getrimmt).
|
||||
#[serde(default)]
|
||||
pub belegart_code: Option<String>,
|
||||
/// Belegart-Klartext (z. B. „Lieferschein EH"), aus `Belegarten.Bezeichnung`.
|
||||
#[serde(default)]
|
||||
pub belegart_name: Option<String>,
|
||||
pub belegnummer: String,
|
||||
|
||||
pub erp_customer_id: i64,
|
||||
@ -44,7 +50,62 @@ pub struct SyncDelivery {
|
||||
pub desired_time: Option<String>,
|
||||
pub special_agreements: Option<String>,
|
||||
|
||||
/// Bei Bestellung schon bezahlter Betrag in EUR. Default `0.0`,
|
||||
/// wenn der Kunde alles bei Lieferung zahlt. Der ERP-Sync liefert
|
||||
/// den Wert mit.
|
||||
#[serde(default)]
|
||||
pub prepaid_amount: f64,
|
||||
|
||||
/// Für den Restbetrag gewählte Zahlungsart — Referenz per
|
||||
/// `code` (z. B. `"cash"`, `"invoice"`). Das ERP kennt seine
|
||||
/// Standard-Codes, der Sync-Code resolvet sie zur UUID. Wenn
|
||||
/// `None`, fällt der Backend-Code auf `"cash"` zurück.
|
||||
#[serde(default)]
|
||||
pub payment_method_code: Option<String>,
|
||||
|
||||
pub items: Vec<SyncDeliveryItem>,
|
||||
|
||||
/// Alle vom ERP an diesem Beleg hängenden Kontakt-Adressen (Beleg-/
|
||||
/// Liefer-/Rechnungsadresse, Ansprechpartner, Kundenstamm). Leere
|
||||
/// Quellen (kein einziger ausgefüllter Kanal *und* kein Name) lässt
|
||||
/// der Sync weg.
|
||||
#[serde(default)]
|
||||
pub contact_sources: Vec<SyncContactSource>,
|
||||
}
|
||||
|
||||
/// Eine Adress-Rolle eines Belegs mit Namensblock und allen ausgefüllten
|
||||
/// Telefon-/Mobil-/E-Mail-/Web-Einträgen.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncContactSource {
|
||||
pub role: ContactRole,
|
||||
#[serde(default)]
|
||||
pub anrede: Option<String>,
|
||||
#[serde(default)]
|
||||
pub titel: Option<String>,
|
||||
#[serde(default)]
|
||||
pub name1: Option<String>,
|
||||
#[serde(default)]
|
||||
pub name2: Option<String>,
|
||||
#[serde(default)]
|
||||
pub name3: Option<String>,
|
||||
#[serde(default)]
|
||||
pub abteilung: Option<String>,
|
||||
#[serde(default)]
|
||||
pub funktion: Option<String>,
|
||||
#[serde(default)]
|
||||
pub channels: Vec<SyncContactChannel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncContactChannel {
|
||||
pub kind: ContactKind,
|
||||
/// 1-basiert: spiegelt ERP-Spaltenposition (Telefon → 1, Telefon2 → 2, …).
|
||||
pub position: i16,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@ -53,7 +114,13 @@ pub struct SyncDelivery {
|
||||
pub struct SyncDeliveryItem {
|
||||
pub belegzeilen_nr: i32,
|
||||
/// Komponenten-Artikelnummer bei aufgelösten Stücklisten, sonst leer.
|
||||
/// Trägt die **eigene** Nummer der Komponente (eindeutig je Belegzeile).
|
||||
pub komponenten_artikel_nr: Option<String>,
|
||||
/// Artikelnummer des **Oberartikels**, zu dem diese Komponente gehört.
|
||||
/// `None` bei Oberartikeln/regulären Zeilen. Erlaubt der App, Komponenten
|
||||
/// unter ihrem Oberartikel einzurücken.
|
||||
#[serde(default)]
|
||||
pub parent_artikel_nr: Option<String>,
|
||||
|
||||
pub article_number: String,
|
||||
pub article_name: String,
|
||||
@ -65,4 +132,9 @@ pub struct SyncDeliveryItem {
|
||||
pub warehouse_name: String,
|
||||
|
||||
pub required_quantity: i32,
|
||||
|
||||
/// Stückpreis (brutto, EUR). Default `0.0`. Liefert der ERP-Sync mit;
|
||||
/// die App rechnet daraus den Warenwert.
|
||||
#[serde(default)]
|
||||
pub unit_price: f64,
|
||||
}
|
||||
|
||||
@ -20,6 +20,11 @@ pub enum ApplicationError {
|
||||
#[error("validation: {0}")]
|
||||
Validation(String),
|
||||
|
||||
/// Operation würde einen Daten-Konflikt erzeugen (z. B. FK-RESTRICT
|
||||
/// beim Löschen, UNIQUE-Verletzung). Mappt auf HTTP `409`.
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
#[error("repository: {0}")]
|
||||
Repository(String),
|
||||
|
||||
|
||||
71
crates/application/src/ports/attachment_repository.rs
Normal file
71
crates/application/src/ports/attachment_repository.rs
Normal file
@ -0,0 +1,71 @@
|
||||
//! Port für die Attachment-Metadaten-Registry (Postgres).
|
||||
//!
|
||||
//! Hält Metadaten zu hochgeladenen Dateien + die DOCUframe-Referenz. Die
|
||||
//! Bytes selbst liegen extern (DOCUframe, siehe [`super::AttachmentStorage`]).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
|
||||
/// Eingabe zum Anlegen eines Attachment-Metadatensatzes. `id` und
|
||||
/// `uploaded_at` werden von der DB vergeben.
|
||||
pub struct NewAttachment {
|
||||
/// DOCUframe `~ObjectID` — Referenz zum Abruf der Bytes.
|
||||
pub docuframe_object_id: String,
|
||||
pub mime_type: String,
|
||||
pub size_bytes: i64,
|
||||
pub filename: Option<String>,
|
||||
/// SHA-256 der Bytes als Hex.
|
||||
pub checksum_sha256: String,
|
||||
pub width: Option<i32>,
|
||||
pub height: Option<i32>,
|
||||
pub uploaded_by: i64,
|
||||
pub delivery_id: Uuid,
|
||||
}
|
||||
|
||||
/// Für den Download relevante Felder eines Attachments.
|
||||
pub struct AttachmentRef {
|
||||
/// DOCUframe `~ObjectID` zum Laden der Bytes.
|
||||
pub docuframe_object_id: String,
|
||||
/// Ursprünglicher Upload-MIME-Typ (nur informativ — das Vorschau-
|
||||
/// Rendering bestimmt das tatsächliche Ausgabeformat).
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
/// Lokale Datei-Referenz eines (noch nicht gelöschten) Attachments — fürs
|
||||
/// Aufräumen nach erfolgreichem Report-Upload.
|
||||
pub struct AttachmentLocalRef {
|
||||
pub id: Uuid,
|
||||
/// Speicher-Referenz (lokal = relativer Pfad in `docuframe_object_id`).
|
||||
pub reference: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait AttachmentRepository: Send + Sync {
|
||||
/// Legt einen Metadatensatz an und liefert dessen neue Id zurück.
|
||||
async fn create(&self, attachment: NewAttachment) -> Result<Uuid, ApplicationError>;
|
||||
|
||||
/// Lädt die Download-relevanten Felder eines Attachments. `None`, wenn
|
||||
/// kein Attachment mit dieser Id existiert.
|
||||
async fn get(&self, id: Uuid) -> Result<Option<AttachmentRef>, ApplicationError>;
|
||||
|
||||
/// Liefert die Belegnummer (`deliveries.erp_belegnummer`) zur Lieferung —
|
||||
/// der lokale Speicher nutzt sie als Ordnernamen. `None`, wenn die
|
||||
/// Lieferung nicht (mehr) existiert.
|
||||
async fn delivery_belegnummer(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
) -> Result<Option<String>, ApplicationError>;
|
||||
|
||||
/// Listet alle noch nicht gelöschten Attachments einer Lieferung
|
||||
/// (`deleted_at IS NULL`) mit ihrer lokalen Referenz — fürs Aufräumen.
|
||||
async fn list_active_for_delivery(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
) -> Result<Vec<AttachmentLocalRef>, ApplicationError>;
|
||||
|
||||
/// Markiert ein Attachment als gelöscht (`deleted_at = now()`). Die
|
||||
/// Metadaten-Zeile bleibt — so ist ersichtlich, dass es ein Bild gab.
|
||||
async fn mark_deleted(&self, id: Uuid) -> Result<(), ApplicationError>;
|
||||
}
|
||||
54
crates/application/src/ports/attachment_storage.rs
Normal file
54
crates/application/src/ports/attachment_storage.rs
Normal file
@ -0,0 +1,54 @@
|
||||
//! Port für den externen Dokumenten-/Datei-Speicher.
|
||||
//!
|
||||
//! Konkrete Impl ist der DOCUframe-Adapter (GSD-REST-API). Der Use Case
|
||||
//! lädt eine Datei hoch und erhält eine persistente Referenz zurück, die
|
||||
//! als `image_attachment` an einer Notiz gespeichert wird.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
|
||||
/// Heruntergeladenes Vorschaubild: rohe Bytes + der vom Speicher gemeldete
|
||||
/// Content-Type (das Vorschau-Rendering bestimmt das Ausgabeformat, nicht
|
||||
/// der ursprüngliche Upload-MIME-Typ).
|
||||
pub struct PreviewImage {
|
||||
pub bytes: Vec<u8>,
|
||||
pub content_type: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait AttachmentStorage: Send + Sync {
|
||||
/// Lädt eine Datei hoch und liefert die persistente Referenz.
|
||||
///
|
||||
/// `folder` ist ein logischer Ablage-Schlüssel (bei uns die Belegnummer):
|
||||
/// der lokale Adapter legt die Datei in einem gleichnamigen Unterordner ab.
|
||||
/// Der DOCUframe-Adapter ignoriert ihn (könnte ihn später als Kategorie
|
||||
/// nutzen). Rückgabe: die Referenz für den späteren Abruf, die in
|
||||
/// `attachments.docuframe_object_id` gespeichert wird (lokal = rel. Pfad,
|
||||
/// DOCUframe = `~ObjectID`).
|
||||
async fn upload(
|
||||
&self,
|
||||
folder: &str,
|
||||
filename: &str,
|
||||
mime: &str,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<String, ApplicationError>;
|
||||
|
||||
/// Lädt ein gerendertes Vorschaubild zur Referenz `object_id`.
|
||||
///
|
||||
/// `parameters` folgt dem DOCUframe-Schema
|
||||
/// `width_height_quality_extension` (z. B. `1024_1024_85_jpeg`),
|
||||
/// `page` ist die Seitennummer (für Bilder i. d. R. `1`).
|
||||
async fn download_preview(
|
||||
&self,
|
||||
object_id: &str,
|
||||
parameters: &str,
|
||||
page: &str,
|
||||
) -> Result<PreviewImage, ApplicationError>;
|
||||
|
||||
/// Löscht die Datei hinter `reference` (lokaler Adapter: die lokale Datei).
|
||||
/// Wird beim Aufräumen nach erfolgreichem Report-Upload genutzt. Idempotent:
|
||||
/// eine bereits fehlende Datei ist kein Fehler. Der DOCUframe-Adapter
|
||||
/// implementiert das als No-Op (wir löschen dort nichts).
|
||||
async fn delete(&self, reference: &str) -> Result<(), ApplicationError>;
|
||||
}
|
||||
131
crates/application/src/ports/delivery_completion_repository.rs
Normal file
131
crates/application/src/ports/delivery_completion_repository.rs
Normal file
@ -0,0 +1,131 @@
|
||||
//! Port für den Lieferungs-Abschluss (Unterschriften + Bestätigungen).
|
||||
//!
|
||||
//! Der Abschluss ist eine **atomare** Operation: Gate-Prüfungen (Lieferung
|
||||
//! aktiv, alle scanbaren Positionen fertig, ggf. Notizen bestätigt),
|
||||
//! Persistenz der Abschluss-Zeile und der Statuswechsel auf `completed`
|
||||
//! laufen in genau einer Transaktion. Schlägt etwas fehl, bleibt nichts
|
||||
//! halb-fertig zurück.
|
||||
//!
|
||||
//! Die Signatur-Dateien werden VOR dem Repository-Aufruf vom Use Case über
|
||||
//! `SignatureStorage` geschrieben; hier kommen nur noch deren Referenzen an.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{NaiveDate, NaiveDateTime};
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_domain::Delivery;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
|
||||
/// Eine Belegzeile für das ERP-Rückschreiben: Position + ausgelieferte Menge.
|
||||
pub struct ErpWritebackLine {
|
||||
pub belegzeilen_nr: i32,
|
||||
/// `required_quantity − credited_quantity` (Postgres-Stand).
|
||||
pub delivered_quantity: i32,
|
||||
}
|
||||
|
||||
/// Alles, was der ERP-Rückschreib-Use-Case aus Postgres braucht, um den
|
||||
/// Abschluss ins ERP zu spiegeln. Liest den **aktuellen** lokalen Stand
|
||||
/// (Mengen, Geld-Gutschrift, Abschluss-Zeitpunkt) — daher idempotent
|
||||
/// wiederholbar.
|
||||
pub struct ErpWritebackData {
|
||||
pub belegart_id: i64,
|
||||
pub belegnummer: String,
|
||||
/// Abschluss-Zeitpunkt (lokale Zeit) aus `delivery_completions.completed_at`.
|
||||
pub delivered_at: NaiveDateTime,
|
||||
pub lines: Vec<ErpWritebackLine>,
|
||||
/// Aktuelle Geld-Gutschrift in Cent (0 = keine).
|
||||
pub credit_amount_cents: i64,
|
||||
/// Code der beim Abschluss gewählten Zahlungsmethode
|
||||
/// (`payment_methods.code`, z. B. `cash`/`ec_card`/`invoice`). `None`,
|
||||
/// wenn keine zugeordnet ist. Der Adapter mappt das auf die ERP-
|
||||
/// Zahlungsbedingung und setzt `Belegkopf.ZahlungsbedingungId`.
|
||||
pub payment_method_code: Option<String>,
|
||||
}
|
||||
|
||||
/// Vollständige Eingabe für den Abschluss — alles, was die Abschluss-Zeile
|
||||
/// braucht plus die fachlichen Bestätigungs-Flags fürs Gate.
|
||||
pub struct CompleteDeliveryInput {
|
||||
pub delivery_id: Uuid,
|
||||
pub customer_signature_path: String,
|
||||
pub driver_signature_path: String,
|
||||
/// Empfangsbestätigung des Kunden (immer Pflicht == true).
|
||||
pub receipt_confirmed: bool,
|
||||
/// Kenntnisnahme der Anmerkungen — Pflicht nur, wenn Notizen existieren
|
||||
/// (das prüft das Repository gegen den DB-Stand).
|
||||
pub notes_acknowledged: bool,
|
||||
/// Notiz-IDs, die zum Abschlusszeitpunkt sichtbar/mit-bestätigt waren.
|
||||
pub acknowledged_note_ids: Vec<Uuid>,
|
||||
/// Inkasso-Bestätigung des Fahrers. Das Repository prüft gegen den
|
||||
/// DB-Stand, ob sie nötig war (offener Betrag > 0 UND Methode Bar/EC), und
|
||||
/// friert den kassierten Betrag als Snapshot ein.
|
||||
pub payment_collected: bool,
|
||||
/// Optionale Zahlungsmethode-Override. `None` = Methode am Beleg bleibt.
|
||||
/// Falls gesetzt, prüft das Repository Existenz + Aktiv-Status.
|
||||
pub payment_method_id: Option<Uuid>,
|
||||
pub completed_by_personalnummer: i64,
|
||||
pub completed_by_car_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait DeliveryCompletionRepository: Send + Sync {
|
||||
/// Schließt eine Lieferung ab und liefert die frische `Delivery`
|
||||
/// (`state == completed`) zurück.
|
||||
///
|
||||
/// Gates (alle in der Transaktion unter Lock):
|
||||
/// * Lieferung existiert (`NotFound`).
|
||||
/// * Bereits `completed` **mit** Abschluss-Zeile → idempotenter Erfolg.
|
||||
/// * Sonst muss `state == active` sein (`Validation`).
|
||||
/// * Alle scanbaren, nicht entfernten Positionen müssen fertig sein.
|
||||
/// * Existieren Notizen, muss `notes_acknowledged == true` sein.
|
||||
async fn complete(
|
||||
&self,
|
||||
input: CompleteDeliveryInput,
|
||||
) -> Result<Delivery, ApplicationError>;
|
||||
|
||||
/// Lädt die für das ERP-Rückschreiben nötigen Daten einer **bereits
|
||||
/// abgeschlossenen** Lieferung (Beleg-Key, ausgelieferte Mengen,
|
||||
/// Geld-Gutschrift, Abschluss-Zeitpunkt).
|
||||
///
|
||||
/// `NotFound`, wenn die Lieferung oder ihre Abschluss-Zeile fehlt.
|
||||
async fn load_erp_writeback(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
) -> Result<ErpWritebackData, ApplicationError>;
|
||||
|
||||
/// Liefert die Belegnummern aller **ausgelieferten** (abgeschlossenen)
|
||||
/// Lieferungen, **deren Liefermail noch NICHT versendet wurde**
|
||||
/// (`mail_sent_at IS NULL`).
|
||||
///
|
||||
/// * `day = Some(d)` → nur Belege, deren Abschluss-Zeitpunkt
|
||||
/// (`completed_at`) auf den Kalendertag `d` fällt. `completed_at` ist ein
|
||||
/// UTC-Zeitstempel; der Kalendertag wird in der Zeitzone **Europe/Berlin**
|
||||
/// bestimmt (Geschäftszeit), nicht in UTC.
|
||||
/// * `day = None` → **alle** offenen (noch nicht versendeten) Belege über
|
||||
/// alle Tage. Das ist der Modus des Mailclients, damit Belege, die über
|
||||
/// Mitternacht nicht versendet wurden, nicht hängen bleiben.
|
||||
///
|
||||
/// Sortierung: aufsteigend nach Abschluss-Zeitpunkt.
|
||||
async fn list_delivered_belegnummern(
|
||||
&self,
|
||||
day: Option<NaiveDate>,
|
||||
) -> Result<Vec<String>, ApplicationError>;
|
||||
|
||||
/// Markiert die Liefermail der angegebenen Belegnummern als **versendet**
|
||||
/// (`mail_sent_at = now()`), aber nur dort, wo sie noch offen ist
|
||||
/// (`mail_sent_at IS NULL`) — bereits markierte bleiben unverändert
|
||||
/// (idempotent, erster Versand-Zeitpunkt bleibt erhalten). Liefert die
|
||||
/// Anzahl tatsächlich frisch markierter Belege zurück.
|
||||
async fn mark_mail_sent(
|
||||
&self,
|
||||
belegnummern: &[String],
|
||||
) -> Result<u64, ApplicationError>;
|
||||
|
||||
/// **DEV-ONLY**: Hebt die Mail-Versendet-Markierung der angegebenen
|
||||
/// Belegnummern wieder auf (`mail_sent_at = NULL`), sodass sie erneut als
|
||||
/// offen erscheinen. Liefert die Anzahl tatsächlich zurückgesetzter Belege.
|
||||
async fn unmark_mail_sent(
|
||||
&self,
|
||||
belegnummern: &[String],
|
||||
) -> Result<u64, ApplicationError>;
|
||||
}
|
||||
36
crates/application/src/ports/delivery_credit_repository.rs
Normal file
36
crates/application/src/ports/delivery_credit_repository.rs
Normal file
@ -0,0 +1,36 @@
|
||||
//! Port für die Betrags-Gutschrift (append-only).
|
||||
//!
|
||||
//! Schreibseite: jedes `set`/`remove` hängt eine Zeile ans Audit-Log. Die
|
||||
//! Leseseite (aktueller Stand pro Lieferung) läuft als Teil des
|
||||
//! Tour-Aggregats (`TourDetails.credits`), nicht über diesen Port.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_domain::DeliveryCredit;
|
||||
|
||||
use crate::dto::CreditAction;
|
||||
use crate::error::ApplicationError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait DeliveryCreditRepository: Send + Sync {
|
||||
/// Hängt ein Gutschrift-Ereignis ans Log und liefert den **aktuellen
|
||||
/// Stand** der Lieferung danach zurück (`None`, wenn zuletzt entfernt).
|
||||
///
|
||||
/// Idempotent über `client_event_id`: ist die Id bereits bekannt, wird
|
||||
/// nichts erneut angehängt und der aktuelle Stand unverändert geliefert.
|
||||
///
|
||||
/// `NotFound`, wenn die Lieferung nicht existiert; `Validation`, wenn die
|
||||
/// Lieferung nicht `active` ist (nur bei frischem Ereignis geprüft).
|
||||
/// `amount_cents` ist bei `Set` der zu setzende Betrag, bei `Remove` `0`.
|
||||
async fn apply_event(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
client_event_id: Uuid,
|
||||
action: CreditAction,
|
||||
amount_cents: i64,
|
||||
reason: Option<String>,
|
||||
author_personalnummer: i64,
|
||||
author_car_id: Option<Uuid>,
|
||||
) -> Result<Option<DeliveryCredit>, ApplicationError>;
|
||||
}
|
||||
@ -1,8 +1,7 @@
|
||||
//! Port für Delivery-Notizen.
|
||||
//!
|
||||
//! Aktuell nur das Anlegen — der Read-Pfad läuft als Teil des Tour-
|
||||
//! Aggregats (`TourDetails.notes`). Sollten irgendwann Listen-Reads
|
||||
//! oder Updates an einzelnen Notizen nötig werden, kommen die hier rein.
|
||||
//! Anlegen, Ändern, Löschen einzelner Notizen. Der Read-Pfad läuft als
|
||||
//! Teil des Tour-Aggregats (`TourDetails.notes`).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
@ -13,6 +12,7 @@ use crate::error::ApplicationError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait DeliveryNoteRepository: Send + Sync {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn create(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
@ -20,5 +20,20 @@ pub trait DeliveryNoteRepository: Send + Sync {
|
||||
author_car_id: Option<Uuid>,
|
||||
text: Option<String>,
|
||||
image_attachment: Option<String>,
|
||||
credit_delivery_item_id: Option<Uuid>,
|
||||
is_amount_credit_note: bool,
|
||||
) -> Result<DeliveryNote, ApplicationError>;
|
||||
|
||||
/// Aktualisiert `text` / `image_attachment` einer bestehenden Notiz.
|
||||
/// Autor und `created_at` bleiben unverändert (historische Metadaten).
|
||||
/// `NotFound`, wenn die Notiz nicht existiert.
|
||||
async fn update(
|
||||
&self,
|
||||
note_id: Uuid,
|
||||
text: Option<String>,
|
||||
image_attachment: Option<String>,
|
||||
) -> Result<DeliveryNote, ApplicationError>;
|
||||
|
||||
/// Löscht eine Notiz. `NotFound`, wenn keine Zeile betroffen war.
|
||||
async fn delete(&self, note_id: Uuid) -> Result<(), ApplicationError>;
|
||||
}
|
||||
|
||||
@ -0,0 +1,89 @@
|
||||
//! Port: persistenter Zustand der Report-Übertragung an DOCUframe.
|
||||
//!
|
||||
//! Spiegelt die Tabelle `delivery_report_jobs`. Hält den Fortschritt der
|
||||
//! mehrstufigen Übertragung (Upload → ~ObjectID → Makro) hart in Postgres,
|
||||
//! damit fehlgeschlagene Schritte per Cron wiederholt werden können.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
|
||||
/// Status der Übertragung — Resume-Marke für Retries.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReportJobStatus {
|
||||
/// Angelegt, noch nichts übertragen.
|
||||
Pending,
|
||||
/// PDF liegt in DOCUframe (`docuframe_object_id` gesetzt), Makro offen.
|
||||
Uploaded,
|
||||
/// Makro erfolgreich, lokale Dateien aufgeräumt — terminal.
|
||||
Done,
|
||||
}
|
||||
|
||||
impl ReportJobStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
ReportJobStatus::Pending => "pending",
|
||||
ReportJobStatus::Uploaded => "uploaded",
|
||||
ReportJobStatus::Done => "done",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Self {
|
||||
match s {
|
||||
"uploaded" => ReportJobStatus::Uploaded,
|
||||
"done" => ReportJobStatus::Done,
|
||||
_ => ReportJobStatus::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Report-Übertragungs-Job (eine Zeile aus `delivery_report_jobs`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReportJob {
|
||||
pub delivery_id: Uuid,
|
||||
pub belegnummer: String,
|
||||
pub status: ReportJobStatus,
|
||||
pub docuframe_object_id: Option<String>,
|
||||
pub report_uploaded_at: Option<DateTime<Utc>>,
|
||||
pub attempts: i32,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait DeliveryReportJobRepository: Send + Sync {
|
||||
/// Legt einen Job (`pending`) an, falls noch keiner existiert, und liefert
|
||||
/// den aktuellen Stand. Idempotent — ein erneuter Abschluss-Versuch
|
||||
/// überschreibt einen vorhandenen Job nicht.
|
||||
async fn ensure(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
belegnummer: &str,
|
||||
) -> Result<ReportJob, ApplicationError>;
|
||||
|
||||
/// Lädt einen Job. `None`, wenn keiner existiert.
|
||||
async fn get(&self, delivery_id: Uuid) -> Result<Option<ReportJob>, ApplicationError>;
|
||||
|
||||
/// Alle offenen Jobs (`status <> 'done'`) — für den Retry-Cron.
|
||||
async fn list_open(&self) -> Result<Vec<ReportJob>, ApplicationError>;
|
||||
|
||||
/// Setzt die ~ObjectID nach erfolgreichem Upload und `status = 'uploaded'`.
|
||||
async fn set_uploaded(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
object_id: &str,
|
||||
) -> Result<(), ApplicationError>;
|
||||
|
||||
/// Markiert den Job als `done` und setzt `report_uploaded_at` (Zeitpunkt
|
||||
/// der erfolgreichen Makro-Zuordnung).
|
||||
async fn mark_done(&self, delivery_id: Uuid) -> Result<(), ApplicationError>;
|
||||
|
||||
/// Vermerkt einen fehlgeschlagenen Versuch (`attempts++`, `last_error`,
|
||||
/// `last_attempt_at = now()`). Lässt `status` unverändert (Resume-Marke).
|
||||
async fn record_error(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
error: &str,
|
||||
) -> Result<(), ApplicationError>;
|
||||
}
|
||||
10
crates/application/src/ports/delivery_report_renderer.rs
Normal file
10
crates/application/src/ports/delivery_report_renderer.rs
Normal file
@ -0,0 +1,10 @@
|
||||
//! Port: rendert die Reportdaten zu einem PDF.
|
||||
|
||||
use crate::dto::DeliveryReportData;
|
||||
use crate::error::ApplicationError;
|
||||
|
||||
pub trait DeliveryReportRenderer: Send + Sync {
|
||||
/// Rendert den vollständigen Lieferreport als PDF-Bytes. Rein (kein IO):
|
||||
/// alle Bild-Bytes liegen bereits im [`DeliveryReportData`].
|
||||
fn render(&self, data: &DeliveryReportData) -> Result<Vec<u8>, ApplicationError>;
|
||||
}
|
||||
19
crates/application/src/ports/delivery_report_repository.rs
Normal file
19
crates/application/src/ports/delivery_report_repository.rs
Normal file
@ -0,0 +1,19 @@
|
||||
//! Port: lädt alle DB-Daten für den PDF-Lieferreport.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::DeliveryReportData;
|
||||
use crate::error::ApplicationError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait DeliveryReportRepository: Send + Sync {
|
||||
/// Sammelt sämtliche Lieferungs-Daten inkl. beider Audit-Trails
|
||||
/// (`scan_audit`, `delivery_credit_audit`) — **ohne** Bild-Bytes (die
|
||||
/// hängt der Use Case aus dem lokalen Speicher an). `None`, wenn die
|
||||
/// Lieferung nicht existiert.
|
||||
async fn load(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
) -> Result<Option<DeliveryReportData>, ApplicationError>;
|
||||
}
|
||||
19
crates/application/src/ports/delivery_report_sink.rs
Normal file
19
crates/application/src/ports/delivery_report_sink.rs
Normal file
@ -0,0 +1,19 @@
|
||||
//! Port: nimmt das fertige Report-PDF entgegen.
|
||||
//!
|
||||
//! Heute: lokaler Datei-Sink (temporäre Ablage). Später: DOCUframe-Sink, der
|
||||
//! den Blob an ein Makro sendet (Stub vorhanden).
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait DeliveryReportSink: Send + Sync {
|
||||
/// Übernimmt das fertige PDF. `folder` = Belegnummer (für die Ablage).
|
||||
/// Gibt eine Referenz zurück (lokal: Dateipfad; DOCUframe: später).
|
||||
async fn deliver(&self, folder: &str, pdf: Vec<u8>) -> Result<String, ApplicationError>;
|
||||
|
||||
/// Räumt alle lokal abgelegten Report-Dateien zu `folder` (Belegnummer)
|
||||
/// auf — aufgerufen nach erfolgreichem DOCUframe-Upload. Idempotent.
|
||||
async fn delete(&self, folder: &str) -> Result<(), ApplicationError>;
|
||||
}
|
||||
36
crates/application/src/ports/delivery_service_repository.rs
Normal file
36
crates/application/src/ports/delivery_service_repository.rs
Normal file
@ -0,0 +1,36 @@
|
||||
//! Port für die Pro-Lieferung-Service-Werte (Upsert).
|
||||
//!
|
||||
//! Die Leseseite (aktuelle Werte pro Lieferung) läuft als Teil des
|
||||
//! Tour-Aggregats (`TourDetails.delivery_services`), nicht über diesen Port.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_domain::DeliveryServiceValue;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait DeliveryServiceRepository: Send + Sync {
|
||||
/// Setzt (Upsert) den Wert eines Service für eine Lieferung. Prüft, dass
|
||||
/// die Lieferung existiert (`NotFound`) und `active` ist (`Validation`).
|
||||
/// Genau eines von `bool_value`/`numeric_value` ist gesetzt (Use Case
|
||||
/// stellt das passend zum Service-Typ sicher).
|
||||
async fn set(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
service_id: Uuid,
|
||||
bool_value: Option<bool>,
|
||||
numeric_value: Option<i32>,
|
||||
author_personalnummer: i64,
|
||||
author_car_id: Option<Uuid>,
|
||||
) -> Result<DeliveryServiceValue, ApplicationError>;
|
||||
|
||||
/// Entfernt den Wert (Service für diese Lieferung „nicht gesetzt").
|
||||
/// Nur bei aktiver Lieferung.
|
||||
async fn delete(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
service_id: Uuid,
|
||||
) -> Result<(), ApplicationError>;
|
||||
}
|
||||
33
crates/application/src/ports/docuframe_report_gateway.rs
Normal file
33
crates/application/src/ports/docuframe_report_gateway.rs
Normal file
@ -0,0 +1,33 @@
|
||||
//! Port: Übertragung des Report-PDFs nach DOCUframe.
|
||||
//!
|
||||
//! Zwei Schritte, getrennt, damit die Pipeline ihren Fortschritt nach jedem
|
||||
//! Schritt persistieren kann (und ein Retry den schon erledigten Upload
|
||||
//! überspringt):
|
||||
//! 1. [`upload_report_pdf`] — PDF hochladen → `~ObjectID`.
|
||||
//! 2. [`assign_report`] — DOCUframe-Makro `_SV_assignDeliveryReport`
|
||||
//! aufrufen, das den Report dem Beleg/Vorgang zuordnet.
|
||||
//!
|
||||
//! Konkrete Impl: der GSD-/DOCUframe-Adapter (`GsdService`).
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait DocuframeReportGateway: Send + Sync {
|
||||
/// Lädt das Report-PDF nach DOCUframe und liefert die `~ObjectID`.
|
||||
async fn upload_report_pdf(
|
||||
&self,
|
||||
belegnummer: &str,
|
||||
pdf: Vec<u8>,
|
||||
) -> Result<String, ApplicationError>;
|
||||
|
||||
/// Ruft das Makro `_SV_assignDeliveryReport` mit `{objectId, belegnummer}`
|
||||
/// auf und ordnet den hochgeladenen Report dem Beleg zu. Fehler, wenn das
|
||||
/// Makro `succeeded != true` liefert oder DOCUframe nicht erreichbar ist.
|
||||
async fn assign_report(
|
||||
&self,
|
||||
object_id: &str,
|
||||
belegnummer: &str,
|
||||
) -> Result<(), ApplicationError>;
|
||||
}
|
||||
36
crates/application/src/ports/driver_identity_provisioner.rs
Normal file
36
crates/application/src/ports/driver_identity_provisioner.rs
Normal file
@ -0,0 +1,36 @@
|
||||
//! Port für das **Provisionieren** von Fahrer-Konten im Identity-Provider
|
||||
//! (Keycloak) beim ERP-Sync.
|
||||
//!
|
||||
//! Wenn der tägliche Touren-Import einen Fahrer (ERP-`Vertreter`, fachlich die
|
||||
//! Account-/Vertragspartner-Nummer) sieht, soll im Realm ein Login-Konto
|
||||
//! existieren: Benutzername = Fahrer-/Account-Nummer, ein **temporäres**
|
||||
//! Passwort, das beim ersten Login zwingend geändert werden muss
|
||||
//! (Keycloak-Required-Action `UPDATE_PASSWORD`), und die Rolle `driver`.
|
||||
//!
|
||||
//! Die konkrete Impl (Keycloak Admin-REST via reqwest) lebt in
|
||||
//! `holzleitner-infrastructure` und MUSS **idempotent** sein: existiert der
|
||||
//! User bereits, passiert nichts (kein Passwort-Reset, keine Doppelanlage).
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
|
||||
/// Ergebnis einer Provisionierung — ob ein Konto **neu** angelegt wurde.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProvisionOutcome {
|
||||
/// `true` ⇒ Konto wurde in diesem Aufruf erstellt; `false` ⇒ existierte
|
||||
/// bereits (idempotenter No-Op).
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait DriverIdentityProvisioner: Send + Sync {
|
||||
/// Stellt sicher, dass für `personalnummer` ein Login-Konto existiert.
|
||||
/// Idempotent. `display_name` ist ein optionaler Anzeigename (z. B.
|
||||
/// „Fahrer 423") — nur kosmetisch im IdP.
|
||||
async fn ensure_driver(
|
||||
&self,
|
||||
personalnummer: i64,
|
||||
display_name: Option<&str>,
|
||||
) -> Result<ProvisionOutcome, ApplicationError>;
|
||||
}
|
||||
24
crates/application/src/ports/erp_delivery_source.rs
Normal file
24
crates/application/src/ports/erp_delivery_source.rs
Normal file
@ -0,0 +1,24 @@
|
||||
//! Port für die Lese-Anbindung an das ERP (ERPframe).
|
||||
//!
|
||||
//! Liefert die Tagestouren eines Datums als `SyncTourRequest`-DTOs — also
|
||||
//! genau die Repräsentation, die auch der HTTP-Sync (`POST /sync/tour`)
|
||||
//! nutzt. Eine `SyncTourRequest` = eine Fahrer-Tour. Die konkrete Impl
|
||||
//! (MSSQL via tiberius) lebt in `holzleitner-infrastructure`; die
|
||||
//! Application bleibt frei von DB-Treiber-Details.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::NaiveDate;
|
||||
|
||||
use crate::dto::SyncTourRequest;
|
||||
use crate::error::ApplicationError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait ErpDeliverySource: Send + Sync {
|
||||
/// Liest alle Lieferungen des gegebenen Tages aus dem ERP und gruppiert
|
||||
/// sie zu **einer `SyncTourRequest` pro Fahrer** (driverPersonalnummer).
|
||||
/// Reine Lese-Operation; schreibt nichts ins ERP zurück.
|
||||
async fn fetch_tours_for_date(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
) -> Result<Vec<SyncTourRequest>, ApplicationError>;
|
||||
}
|
||||
64
crates/application/src/ports/erp_delivery_writeback.rs
Normal file
64
crates/application/src/ports/erp_delivery_writeback.rs
Normal file
@ -0,0 +1,64 @@
|
||||
//! Port für das **Zurückschreiben** eines Lieferabschlusses ins ERP (ERPframe).
|
||||
//!
|
||||
//! Gegenstück zum lesenden [`ErpDeliverySource`](super::ErpDeliverySource):
|
||||
//! wenn eine Lieferung lokal (Postgres) abgeschlossen wurde, spiegelt dieser
|
||||
//! Port das Ergebnis direkt in die ERPframe-MSSQL-DB. Drei fachliche Effekte
|
||||
//! (vgl. die Alt-Makros `_web_finishDelivery` / `_removeArticles` /
|
||||
//! `_addDiscount`):
|
||||
//!
|
||||
//! 1. **Entfernte Artikel** — je Belegzeile die Menge auf die tatsächlich
|
||||
//! ausgelieferte Menge setzen (`required − credited`).
|
||||
//! 2. **Gutschrift** — eine Belegzeile für den Gutschrift-Artikel
|
||||
//! (`GUTSCHRIFT10`) hinzufügen/aktualisieren.
|
||||
//! 3. **Liefer-Zeitpunkt** — `_SV_DELIVERY_DELIVERED_AT` + `_SV_DELIVERY_STATE`
|
||||
//! setzen.
|
||||
//!
|
||||
//! Die konkrete Impl (MSSQL via tiberius) lebt in `holzleitner-infrastructure`
|
||||
//! und MUSS **idempotent** sein: alle Mengen werden absolut gesetzt, die
|
||||
//! Gutschrift als Upsert geführt — ein erneuter Aufruf (Admin-Retry) verändert
|
||||
//! das Ergebnis nicht.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
|
||||
/// Eine Belegzeile mit ihrer **neuen** (absoluten) Menge.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ErpLineQuantity {
|
||||
/// ERP-Position innerhalb des Belegs (`Belegzeilen.BelegzeilenNr`).
|
||||
pub belegzeilen_nr: i32,
|
||||
/// Tatsächlich ausgelieferte Menge = `required_quantity − credited_quantity`.
|
||||
/// Wird absolut gesetzt (nicht subtrahiert) → idempotent.
|
||||
pub delivered_quantity: i32,
|
||||
}
|
||||
|
||||
/// Vollständige Eingabe für das ERP-Rückschreiben eines Abschlusses.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ErpFinishDeliveryCommand {
|
||||
/// Beleg-Natural-Key (aus `deliveries.erp_belegart_id`/`erp_belegnummer`).
|
||||
/// Der Adapter resolved daraus die `Belegkopf.row_id`.
|
||||
pub belegart_id: i64,
|
||||
pub belegnummer: String,
|
||||
/// Liefer-Zeitpunkt (lokale Zeit), wird als ISO-8601 mit `T` geschrieben.
|
||||
pub delivered_at: NaiveDateTime,
|
||||
/// Belegzeilen mit ausgelieferten Mengen.
|
||||
pub lines: Vec<ErpLineQuantity>,
|
||||
/// Geld-Gutschrift in **Cent** (0 = keine). Der Adapter rechnet daraus die
|
||||
/// Menge der 10-€-Gutschrift-Einheiten.
|
||||
pub credit_amount_cents: i64,
|
||||
/// Code der gewählten Zahlungsmethode (`cash`/`ec_card`/`invoice`). Der
|
||||
/// Adapter mappt das auf die ERP-Zahlungsbedingung (D16/D53/D10) und setzt
|
||||
/// `Belegkopf.ZahlungsbedingungId`. `None` ⇒ Zahlungsbedingung bleibt.
|
||||
pub payment_method_code: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ErpDeliveryWriteback: Send + Sync {
|
||||
/// Schreibt den Lieferabschluss ins ERP zurück (eine MSSQL-Transaktion).
|
||||
/// Idempotent: erneuter Aufruf mit gleichem Command ⇒ gleicher Endzustand.
|
||||
async fn finish_delivery(
|
||||
&self,
|
||||
cmd: ErpFinishDeliveryCommand,
|
||||
) -> Result<(), ApplicationError>;
|
||||
}
|
||||
@ -6,17 +6,57 @@
|
||||
//! `holzleitner-infrastructure`.
|
||||
|
||||
pub mod account_repository;
|
||||
pub mod attachment_repository;
|
||||
pub mod attachment_storage;
|
||||
pub mod auth_service;
|
||||
pub mod car_repository;
|
||||
pub mod delivery_credit_repository;
|
||||
pub mod delivery_note_repository;
|
||||
pub mod delivery_report_job_repository;
|
||||
pub mod delivery_report_renderer;
|
||||
pub mod delivery_report_repository;
|
||||
pub mod delivery_report_sink;
|
||||
pub mod delivery_repository;
|
||||
pub mod delivery_service_repository;
|
||||
pub mod docuframe_report_gateway;
|
||||
pub mod driver_identity_provisioner;
|
||||
pub mod payment_method_repository;
|
||||
pub mod delivery_completion_repository;
|
||||
pub mod erp_delivery_source;
|
||||
pub mod erp_delivery_writeback;
|
||||
pub mod scan_repository;
|
||||
pub mod service_repository;
|
||||
pub mod signature_storage;
|
||||
pub mod tour_repository;
|
||||
|
||||
pub use account_repository::AccountRepository;
|
||||
pub use attachment_repository::{
|
||||
AttachmentLocalRef, AttachmentRef, AttachmentRepository, NewAttachment,
|
||||
};
|
||||
pub use attachment_storage::{AttachmentStorage, PreviewImage};
|
||||
pub use auth_service::{AuthError, AuthService, Claims};
|
||||
pub use car_repository::CarRepository;
|
||||
pub use delivery_credit_repository::DeliveryCreditRepository;
|
||||
pub use delivery_note_repository::DeliveryNoteRepository;
|
||||
pub use delivery_report_job_repository::{
|
||||
DeliveryReportJobRepository, ReportJob, ReportJobStatus,
|
||||
};
|
||||
pub use delivery_report_renderer::DeliveryReportRenderer;
|
||||
pub use delivery_report_repository::DeliveryReportRepository;
|
||||
pub use delivery_report_sink::DeliveryReportSink;
|
||||
pub use delivery_repository::{DeliveryAction, DeliveryRepository};
|
||||
pub use delivery_service_repository::DeliveryServiceRepository;
|
||||
pub use docuframe_report_gateway::DocuframeReportGateway;
|
||||
pub use driver_identity_provisioner::{DriverIdentityProvisioner, ProvisionOutcome};
|
||||
pub use delivery_completion_repository::{
|
||||
CompleteDeliveryInput, DeliveryCompletionRepository, ErpWritebackData, ErpWritebackLine,
|
||||
};
|
||||
pub use erp_delivery_source::ErpDeliverySource;
|
||||
pub use erp_delivery_writeback::{
|
||||
ErpDeliveryWriteback, ErpFinishDeliveryCommand, ErpLineQuantity,
|
||||
};
|
||||
pub use payment_method_repository::PaymentMethodRepository;
|
||||
pub use scan_repository::{ApplyScanOutcome, ScanRepository};
|
||||
pub use service_repository::ServiceRepository;
|
||||
pub use signature_storage::{SignatureRole, SignatureStorage};
|
||||
pub use tour_repository::TourRepository;
|
||||
|
||||
54
crates/application/src/ports/payment_method_repository.rs
Normal file
54
crates/application/src/ports/payment_method_repository.rs
Normal file
@ -0,0 +1,54 @@
|
||||
//! Port für Zahlungs-Stammdaten.
|
||||
//!
|
||||
//! Im Gegensatz zu `cars` sind Zahlungsmethoden **global** — sie hängen
|
||||
//! nicht an einem Account, sondern gelten für die ganze App. Daher
|
||||
//! keine `account_id`-Parameter.
|
||||
//!
|
||||
//! Lösch-Verhalten: `delete` wirft `ApplicationError::Validation`, wenn
|
||||
//! eine Lieferung die Methode noch referenziert (entsprechend dem
|
||||
//! Datenbank-`ON DELETE RESTRICT`). Für „weiches Entfernen" gibt es
|
||||
//! das `active`-Flag — wird per `update(active: Some(false))` gesetzt.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_domain::PaymentMethod;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait PaymentMethodRepository: Send + Sync {
|
||||
/// Listet alle Methoden. `include_inactive = false` filtert
|
||||
/// deaktivierte raus (Default für die App-UI).
|
||||
async fn list(
|
||||
&self,
|
||||
include_inactive: bool,
|
||||
) -> Result<Vec<PaymentMethod>, ApplicationError>;
|
||||
|
||||
async fn find_by_id(
|
||||
&self,
|
||||
id: Uuid,
|
||||
) -> Result<Option<PaymentMethod>, ApplicationError>;
|
||||
|
||||
/// Legt eine neue Methode an. `code` muss eindeutig sein —
|
||||
/// Duplikat → `Conflict("…already exists")` (HTTP 409).
|
||||
async fn create(
|
||||
&self,
|
||||
code: &str,
|
||||
name: &str,
|
||||
) -> Result<PaymentMethod, ApplicationError>;
|
||||
|
||||
/// Optional-Patch. Beide `None`s = no-op.
|
||||
async fn update(
|
||||
&self,
|
||||
id: Uuid,
|
||||
name: Option<&str>,
|
||||
active: Option<bool>,
|
||||
) -> Result<PaymentMethod, ApplicationError>;
|
||||
|
||||
/// Hart löschen. Wirft `Conflict("payment method is in use")`
|
||||
/// (→ HTTP 409), wenn noch Lieferungen darauf zeigen — der
|
||||
/// FK-RESTRICT regelt das auf DB-Ebene, der Adapter übersetzt den
|
||||
/// Pg-Fehler.
|
||||
async fn delete(&self, id: Uuid) -> Result<(), ApplicationError>;
|
||||
}
|
||||
44
crates/application/src/ports/service_repository.rs
Normal file
44
crates/application/src/ports/service_repository.rs
Normal file
@ -0,0 +1,44 @@
|
||||
//! Port für Service-Stammdaten (admin-konfigurierbar, global — keine
|
||||
//! Account-Isolation, Muster wie `PaymentMethodRepository`).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_domain::{Service, ServiceKind};
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait ServiceRepository: Send + Sync {
|
||||
/// Listet Services, sortiert nach `sort_order`. `include_inactive = false`
|
||||
/// filtert deaktivierte raus (Default für die App).
|
||||
async fn list(&self, include_inactive: bool) -> Result<Vec<Service>, ApplicationError>;
|
||||
|
||||
async fn find_by_id(&self, id: Uuid) -> Result<Option<Service>, ApplicationError>;
|
||||
|
||||
/// Legt einen Service an. `key`-Duplikat → `Conflict`.
|
||||
async fn create(
|
||||
&self,
|
||||
key: &str,
|
||||
name: &str,
|
||||
kind: ServiceKind,
|
||||
min_value: Option<i32>,
|
||||
max_value: Option<i32>,
|
||||
sort_order: i32,
|
||||
) -> Result<Service, ApplicationError>;
|
||||
|
||||
/// Teil-Update. `kind` ist nicht änderbar.
|
||||
async fn update(
|
||||
&self,
|
||||
id: Uuid,
|
||||
name: Option<&str>,
|
||||
min_value: Option<i32>,
|
||||
max_value: Option<i32>,
|
||||
active: Option<bool>,
|
||||
sort_order: Option<i32>,
|
||||
) -> Result<Service, ApplicationError>;
|
||||
|
||||
/// Hart löschen. `Conflict`, wenn noch eine Lieferung den Service
|
||||
/// referenziert (FK `ON DELETE RESTRICT`) — dann lieber deaktivieren.
|
||||
async fn delete(&self, id: Uuid) -> Result<(), ApplicationError>;
|
||||
}
|
||||
54
crates/application/src/ports/signature_storage.rs
Normal file
54
crates/application/src/ports/signature_storage.rs
Normal file
@ -0,0 +1,54 @@
|
||||
//! Port für den Unterschriften-Speicher.
|
||||
//!
|
||||
//! Im Gegensatz zu Notiz-Bildern (die nach DOCUframe gehen) liegen
|
||||
//! Unterschriften bewusst **lokal im Backend-Server** — ein einfacher
|
||||
//! Datei-Speicher reicht, und die Daten verlassen die Maschine nicht.
|
||||
//!
|
||||
//! Die konkrete Impl (lokales Dateisystem) lebt in
|
||||
//! `holzleitner-infrastructure`. Der Use Case erhält eine relative
|
||||
//! Referenz zurück, die in `delivery_completions` persistiert wird.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
|
||||
/// Wer hat unterschrieben — bestimmt den Dateinamen.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SignatureRole {
|
||||
Customer,
|
||||
Driver,
|
||||
}
|
||||
|
||||
impl SignatureRole {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
SignatureRole::Customer => "customer",
|
||||
SignatureRole::Driver => "driver",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait SignatureStorage: Send + Sync {
|
||||
/// Speichert eine Unterschrift (PNG-Bytes) für eine Lieferung+Rolle und
|
||||
/// liefert die persistente, relative Referenz (Dateiname) zurück.
|
||||
/// Deterministisch über `delivery_id`+`role` — ein Retry überschreibt
|
||||
/// dieselbe Datei statt Müll anzuhäufen.
|
||||
async fn save(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
role: SignatureRole,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<String, ApplicationError>;
|
||||
|
||||
/// Lädt die gespeicherten PNG-Bytes einer Unterschrift über ihre relative
|
||||
/// Referenz (Dateiname, wie von [`save`](Self::save) geliefert) — fürs
|
||||
/// Einbetten in den PDF-Report. `None`, wenn die Datei fehlt.
|
||||
async fn load(&self, reference: &str) -> Result<Option<Vec<u8>>, ApplicationError>;
|
||||
|
||||
/// Löscht beide Unterschriften (Kunde + Fahrer) einer Lieferung — Aufräumen
|
||||
/// nach erfolgreichem Report-Upload (die Unterschriften stecken dann
|
||||
/// eingebettet im PDF in DOCUframe). Idempotent (fehlende Datei = ok).
|
||||
async fn delete_for_delivery(&self, delivery_id: Uuid) -> Result<(), ApplicationError>;
|
||||
}
|
||||
@ -49,4 +49,10 @@ pub trait TourRepository: Send + Sync {
|
||||
tour_id: Uuid,
|
||||
delivery_ids: &[Uuid],
|
||||
) -> Result<Vec<DeliveryOrderEntry>, ApplicationError>;
|
||||
|
||||
/// **DEV-ONLY**: Löscht alle Touren (und per FK-Cascade alle Lieferungen,
|
||||
/// Positionen, Scans, Abschlüsse, Gutschriften, Notizen). Dient dem
|
||||
/// Dev-Resync, der die Postgres-Daten vor einem frischen Import platt
|
||||
/// macht. Gibt die Anzahl gelöschter Touren zurück.
|
||||
async fn delete_all_tours(&self) -> Result<u64, ApplicationError>;
|
||||
}
|
||||
|
||||
@ -0,0 +1,85 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_domain::DeliveryCredit;
|
||||
|
||||
use crate::dto::{CreditAction, DeliveryCreditEventRequest};
|
||||
use crate::error::ApplicationError;
|
||||
use crate::ports::{CarRepository, DeliveryCreditRepository};
|
||||
|
||||
/// Obergrenze der Betrags-Gutschrift in Cent (150 €).
|
||||
const MAX_CREDIT_CENTS: i64 = 15_000;
|
||||
|
||||
/// Wendet ein Gutschrift-Ereignis (`set`/`remove`) auf eine Lieferung an.
|
||||
///
|
||||
/// Validierung (fachlich, ohne DB):
|
||||
/// * `Set`: Betrag Pflicht, `0 < amount ≤ 150 €` (beliebiger Betrag, keine
|
||||
/// Schrittweite); Begründung Pflicht (nicht leer).
|
||||
/// * `author_car_id` muss — falls gesetzt — zum Account gehören.
|
||||
///
|
||||
/// Den `active`-Check der Lieferung und die Idempotenz (`client_event_id`)
|
||||
/// übernimmt das Repository mit der gelockten Zeile.
|
||||
pub struct ApplyDeliveryCreditEventUseCase {
|
||||
repository: Arc<dyn DeliveryCreditRepository>,
|
||||
cars: Arc<dyn CarRepository>,
|
||||
}
|
||||
|
||||
impl ApplyDeliveryCreditEventUseCase {
|
||||
pub fn new(
|
||||
repository: Arc<dyn DeliveryCreditRepository>,
|
||||
cars: Arc<dyn CarRepository>,
|
||||
) -> Self {
|
||||
Self { repository, cars }
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
author_personalnummer: i64,
|
||||
request: DeliveryCreditEventRequest,
|
||||
) -> Result<Option<DeliveryCredit>, ApplicationError> {
|
||||
if let Some(car_id) = request.author_car_id {
|
||||
self.cars
|
||||
.assert_owned_by_account(&[car_id], author_personalnummer)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let (amount_cents, reason) = match request.action {
|
||||
CreditAction::Set => {
|
||||
let amount = request.amount_cents.ok_or_else(|| {
|
||||
ApplicationError::Validation("amount_cents required for set".into())
|
||||
})?;
|
||||
if amount <= 0 || amount > MAX_CREDIT_CENTS {
|
||||
return Err(ApplicationError::Validation(format!(
|
||||
"amount_cents must be in (0, {MAX_CREDIT_CENTS}]"
|
||||
)));
|
||||
}
|
||||
let reason = request
|
||||
.reason
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
ApplicationError::Validation("reason required for set".into())
|
||||
})?
|
||||
.to_owned();
|
||||
(amount, Some(reason))
|
||||
}
|
||||
// Remove: Betrag/Grund irrelevant.
|
||||
CreditAction::Remove => (0, None),
|
||||
};
|
||||
|
||||
self.repository
|
||||
.apply_event(
|
||||
delivery_id,
|
||||
request.client_event_id,
|
||||
request.action,
|
||||
amount_cents,
|
||||
reason,
|
||||
author_personalnummer,
|
||||
request.author_car_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@ -99,8 +99,21 @@ impl ApplyScansUseCase {
|
||||
}
|
||||
|
||||
/// Validiert Pflichtfelder ohne DB-Aufruf. Liefert `Some(reason)`,
|
||||
/// wenn das Event verworfen werden soll.
|
||||
/// wenn das Event verworfen werden soll. Mengen- und Status-abhängige
|
||||
/// Bounds (z. B. `credited + quantity <= required`, scannbar ⇒ done,
|
||||
/// Lieferung aktiv) prüft erst das Repository mit dem gelockten Item.
|
||||
fn pre_validate(event: &ScanEvent) -> Option<String> {
|
||||
// Eine gesetzte Menge muss positiv sein — und ist nur für die
|
||||
// Mengen-Gutschrift (Remove/Unremove) überhaupt sinnvoll.
|
||||
if let Some(q) = event.quantity {
|
||||
match event.action {
|
||||
AuditAction::Remove | AuditAction::Unremove if q <= 0 => {
|
||||
return Some("quantity must be > 0".into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
match event.action {
|
||||
AuditAction::Hold | AuditAction::Remove => {
|
||||
let trimmed = event.reason.as_deref().map(str::trim).unwrap_or("");
|
||||
@ -113,6 +126,9 @@ fn pre_validate(event: &ScanEvent) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
AuditAction::Scan | AuditAction::Unscan | AuditAction::Unhold => None,
|
||||
AuditAction::Scan
|
||||
| AuditAction::Unscan
|
||||
| AuditAction::Unhold
|
||||
| AuditAction::Unremove => None,
|
||||
}
|
||||
}
|
||||
|
||||
115
crates/application/src/usecases/complete_delivery.rs
Normal file
115
crates/application/src/usecases/complete_delivery.rs
Normal file
@ -0,0 +1,115 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_domain::Delivery;
|
||||
|
||||
use crate::dto::CompleteDeliveryAcknowledgements;
|
||||
use crate::error::ApplicationError;
|
||||
use crate::ports::{
|
||||
CarRepository, CompleteDeliveryInput, DeliveryCompletionRepository, SignatureRole,
|
||||
SignatureStorage,
|
||||
};
|
||||
use crate::usecases::PushCompletionToErpUseCase;
|
||||
|
||||
/// Schließt eine Lieferung ab: speichert beide Unterschriften lokal und
|
||||
/// schreibt — atomar im Repository — die Abschluss-Zeile + den Statuswechsel
|
||||
/// auf `completed`.
|
||||
///
|
||||
/// Reihenfolge bewusst: erst die fachlichen Vor-Prüfungen ohne DB, dann die
|
||||
/// Dateien schreiben, dann das Repository (das die DB-abhängigen Gates unter
|
||||
/// Lock prüft). Schlägt das Repo-Gate fehl, bleiben höchstens die beiden
|
||||
/// deterministisch benannten PNG-Dateien liegen — ein erneuter Versuch
|
||||
/// überschreibt sie, es entsteht kein Müll.
|
||||
pub struct CompleteDeliveryUseCase {
|
||||
repository: Arc<dyn DeliveryCompletionRepository>,
|
||||
signatures: Arc<dyn SignatureStorage>,
|
||||
cars: Arc<dyn CarRepository>,
|
||||
/// Optionales ERP-Rückschreiben. `None` ⇒ rein lokaler Abschluss
|
||||
/// (ERP_WRITEBACK_ENABLED=false / Dev / Seed-Daten ohne ERP-Beleg).
|
||||
erp_push: Option<Arc<PushCompletionToErpUseCase>>,
|
||||
}
|
||||
|
||||
impl CompleteDeliveryUseCase {
|
||||
pub fn new(
|
||||
repository: Arc<dyn DeliveryCompletionRepository>,
|
||||
signatures: Arc<dyn SignatureStorage>,
|
||||
cars: Arc<dyn CarRepository>,
|
||||
erp_push: Option<Arc<PushCompletionToErpUseCase>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repository,
|
||||
signatures,
|
||||
cars,
|
||||
erp_push,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
author_personalnummer: i64,
|
||||
acknowledgements: CompleteDeliveryAcknowledgements,
|
||||
customer_signature_png: Vec<u8>,
|
||||
driver_signature_png: Vec<u8>,
|
||||
) -> Result<Delivery, ApplicationError> {
|
||||
// --- Vor-Prüfungen ohne DB ----------------------------------------
|
||||
if !acknowledgements.receipt_confirmed {
|
||||
return Err(ApplicationError::Validation(
|
||||
"receipt must be confirmed before completion".into(),
|
||||
));
|
||||
}
|
||||
if customer_signature_png.is_empty() {
|
||||
return Err(ApplicationError::Validation(
|
||||
"customer signature is required".into(),
|
||||
));
|
||||
}
|
||||
if driver_signature_png.is_empty() {
|
||||
return Err(ApplicationError::Validation(
|
||||
"driver signature is required".into(),
|
||||
));
|
||||
}
|
||||
if let Some(car_id) = acknowledgements.author_car_id {
|
||||
self.cars
|
||||
.assert_owned_by_account(&[car_id], author_personalnummer)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// --- Signaturen lokal speichern -----------------------------------
|
||||
let customer_signature_path = self
|
||||
.signatures
|
||||
.save(delivery_id, SignatureRole::Customer, customer_signature_png)
|
||||
.await?;
|
||||
let driver_signature_path = self
|
||||
.signatures
|
||||
.save(delivery_id, SignatureRole::Driver, driver_signature_png)
|
||||
.await?;
|
||||
|
||||
// --- Atomarer Abschluss im Repository -----------------------------
|
||||
let delivery = self
|
||||
.repository
|
||||
.complete(CompleteDeliveryInput {
|
||||
delivery_id,
|
||||
customer_signature_path,
|
||||
driver_signature_path,
|
||||
receipt_confirmed: acknowledgements.receipt_confirmed,
|
||||
notes_acknowledged: acknowledgements.notes_acknowledged,
|
||||
acknowledged_note_ids: acknowledgements.acknowledged_note_ids,
|
||||
payment_collected: acknowledgements.payment_collected,
|
||||
payment_method_id: acknowledgements.payment_method_id,
|
||||
completed_by_personalnummer: author_personalnummer,
|
||||
completed_by_car_id: acknowledgements.author_car_id,
|
||||
})
|
||||
.await?;
|
||||
|
||||
// --- ERP-Rückschreiben (optional, nach lokalem Commit) ------------
|
||||
// Idempotent → ein Fehler hier lässt den lokalen Abschluss bestehen;
|
||||
// der Aufrufer bekommt den Fehler (502) und kann via Admin-Endpunkt
|
||||
// `POST /admin/push-completion` erneut pushen.
|
||||
if let Some(push) = &self.erp_push {
|
||||
push.execute(delivery_id).await?;
|
||||
}
|
||||
|
||||
Ok(delivery)
|
||||
}
|
||||
}
|
||||
@ -55,6 +55,8 @@ impl CreateDeliveryNoteUseCase {
|
||||
request.author_car_id,
|
||||
text,
|
||||
image,
|
||||
request.credit_delivery_item_id,
|
||||
request.is_amount_credit_note,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
24
crates/application/src/usecases/delete_delivery_note.rs
Normal file
24
crates/application/src/usecases/delete_delivery_note.rs
Normal file
@ -0,0 +1,24 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
use crate::ports::DeliveryNoteRepository;
|
||||
|
||||
/// Löscht eine Notiz. `NotFound`, wenn keine Zeile betroffen war.
|
||||
///
|
||||
/// Berechtigung: keine Autor-Prüfung (geteilter Account) — analog zu
|
||||
/// [`super::update_delivery_note::UpdateDeliveryNoteUseCase`].
|
||||
pub struct DeleteDeliveryNoteUseCase {
|
||||
repository: Arc<dyn DeliveryNoteRepository>,
|
||||
}
|
||||
|
||||
impl DeleteDeliveryNoteUseCase {
|
||||
pub fn new(repository: Arc<dyn DeliveryNoteRepository>) -> Self {
|
||||
Self { repository }
|
||||
}
|
||||
|
||||
pub async fn execute(&self, note_id: Uuid) -> Result<(), ApplicationError> {
|
||||
self.repository.delete(note_id).await
|
||||
}
|
||||
}
|
||||
37
crates/application/src/usecases/dev_resync_tours.rs
Normal file
37
crates/application/src/usecases/dev_resync_tours.rs
Normal file
@ -0,0 +1,37 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
use crate::ports::TourRepository;
|
||||
use crate::usecases::{ImportErpToursUseCase, ImportSummary};
|
||||
|
||||
/// **DEV-ONLY**: „Überschreibender" Sync für die lokale Entwicklung.
|
||||
///
|
||||
/// Anders als der produktive Import (idempotenter Upsert, der den Scan-/
|
||||
/// Abschluss-Status bewusst erhält) macht dieser Use Case die Postgres-
|
||||
/// Tourdaten zuerst **platt** (`delete_all_tours` → FK-Cascade) und importiert
|
||||
/// dann frisch aus dem ERP. So liefert ein wiederholter Sync desselben Tages in
|
||||
/// Dev garantiert einen sauberen Stand — ohne Reste aus vorherigen
|
||||
/// Abschluss-Tests (Status `completed`, Gutschrift-Zeilen, Scans …).
|
||||
///
|
||||
/// In Produktion wird das **nicht** verwendet: dort läuft der Sync einmal
|
||||
/// täglich für den Folgetag (zentral geplante, frische Belege).
|
||||
pub struct DevResyncToursUseCase {
|
||||
tours: Arc<dyn TourRepository>,
|
||||
import: Arc<ImportErpToursUseCase>,
|
||||
}
|
||||
|
||||
impl DevResyncToursUseCase {
|
||||
pub fn new(tours: Arc<dyn TourRepository>, import: Arc<ImportErpToursUseCase>) -> Self {
|
||||
Self { tours, import }
|
||||
}
|
||||
|
||||
/// Wischt alle Tourdaten und importiert das Datum neu. Gibt die
|
||||
/// Import-Zusammenfassung zurück. (Logging übernimmt die API-Schicht.)
|
||||
pub async fn execute(&self, date: NaiveDate) -> Result<ImportSummary, ApplicationError> {
|
||||
let _deleted = self.tours.delete_all_tours().await?;
|
||||
let summary = self.import.execute(date).await?;
|
||||
Ok(summary)
|
||||
}
|
||||
}
|
||||
92
crates/application/src/usecases/generate_delivery_report.rs
Normal file
92
crates/application/src/usecases/generate_delivery_report.rs
Normal file
@ -0,0 +1,92 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
use crate::ports::{
|
||||
AttachmentStorage, DeliveryReportRenderer, DeliveryReportRepository, DeliveryReportSink,
|
||||
SignatureStorage,
|
||||
};
|
||||
|
||||
/// Erzeugt den PDF-Lieferreport: lädt alle Daten + Audit-Trails, hängt die
|
||||
/// Bild-Bytes (Unterschriften, Foto-Notizen) aus dem lokalen Speicher an,
|
||||
/// rendert das PDF und übergibt es dem Sink (lokal ablegen / später DOCUframe).
|
||||
///
|
||||
/// Wird sowohl beim Lieferabschluss (best-effort) als auch vom Dev-Endpoint
|
||||
/// genutzt. Gibt die Sink-Referenz (z. B. den Dateipfad) zurück.
|
||||
pub struct GenerateDeliveryReportUseCase {
|
||||
repo: Arc<dyn DeliveryReportRepository>,
|
||||
renderer: Arc<dyn DeliveryReportRenderer>,
|
||||
sink: Arc<dyn DeliveryReportSink>,
|
||||
signatures: Arc<dyn SignatureStorage>,
|
||||
attachments: Arc<dyn AttachmentStorage>,
|
||||
}
|
||||
|
||||
impl GenerateDeliveryReportUseCase {
|
||||
pub fn new(
|
||||
repo: Arc<dyn DeliveryReportRepository>,
|
||||
renderer: Arc<dyn DeliveryReportRenderer>,
|
||||
sink: Arc<dyn DeliveryReportSink>,
|
||||
signatures: Arc<dyn SignatureStorage>,
|
||||
attachments: Arc<dyn AttachmentStorage>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
renderer,
|
||||
sink,
|
||||
signatures,
|
||||
attachments,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lädt die Daten, bettet die lokalen Bild-/Signatur-Bytes ein und rendert
|
||||
/// das PDF **in-memory**. Liefert `(Belegnummer, PDF)`. Wird vom Dev-Sink
|
||||
/// und von der DOCUframe-Upload-Pipeline genutzt.
|
||||
pub async fn render_pdf(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
) -> Result<(String, Vec<u8>), ApplicationError> {
|
||||
let mut data = self
|
||||
.repo
|
||||
.load(delivery_id)
|
||||
.await?
|
||||
.ok_or(ApplicationError::NotFound)?;
|
||||
|
||||
// Unterschriften-Bytes anhängen (best-effort — fehlt eine Datei,
|
||||
// bleibt das Bild im Report einfach weg).
|
||||
if let Some(completion) = &data.completion {
|
||||
data.customer_signature_png = self
|
||||
.signatures
|
||||
.load(&completion.customer_signature_path)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
data.driver_signature_png = self
|
||||
.signatures
|
||||
.load(&completion.driver_signature_path)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
}
|
||||
|
||||
// Anhang-Bytes anhängen (best-effort).
|
||||
for att in data.attachments.iter_mut() {
|
||||
if let Ok(img) = self
|
||||
.attachments
|
||||
.download_preview(&att.reference, "", "1")
|
||||
.await
|
||||
{
|
||||
att.bytes = Some(img.bytes);
|
||||
}
|
||||
}
|
||||
|
||||
let pdf = self.renderer.render(&data)?;
|
||||
Ok((data.belegnummer, pdf))
|
||||
}
|
||||
|
||||
pub async fn execute(&self, delivery_id: Uuid) -> Result<String, ApplicationError> {
|
||||
let (belegnummer, pdf) = self.render_pdf(delivery_id).await?;
|
||||
let reference = self.sink.deliver(&belegnummer, pdf).await?;
|
||||
Ok(reference)
|
||||
}
|
||||
}
|
||||
43
crates/application/src/usecases/get_attachment_preview.rs
Normal file
43
crates/application/src/usecases/get_attachment_preview.rs
Normal file
@ -0,0 +1,43 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
use crate::ports::{AttachmentRepository, AttachmentStorage, PreviewImage};
|
||||
|
||||
/// Lädt ein gerendertes Vorschaubild zu einem Attachment.
|
||||
///
|
||||
/// Löst unsere Attachment-Id zur DOCUframe-`~ObjectID` auf und holt darüber
|
||||
/// die Bytes aus dem Speicher. `NotFound`, wenn die Id unbekannt ist.
|
||||
pub struct GetAttachmentPreviewUseCase {
|
||||
attachments: Arc<dyn AttachmentRepository>,
|
||||
storage: Arc<dyn AttachmentStorage>,
|
||||
}
|
||||
|
||||
impl GetAttachmentPreviewUseCase {
|
||||
pub fn new(
|
||||
attachments: Arc<dyn AttachmentRepository>,
|
||||
storage: Arc<dyn AttachmentStorage>,
|
||||
) -> Self {
|
||||
Self {
|
||||
attachments,
|
||||
storage,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
id: Uuid,
|
||||
parameters: String,
|
||||
page: String,
|
||||
) -> Result<PreviewImage, ApplicationError> {
|
||||
let attachment = self
|
||||
.attachments
|
||||
.get(id)
|
||||
.await?
|
||||
.ok_or(ApplicationError::NotFound)?;
|
||||
self.storage
|
||||
.download_preview(&attachment.docuframe_object_id, ¶meters, &page)
|
||||
.await
|
||||
}
|
||||
}
|
||||
110
crates/application/src/usecases/import_erp_tours.rs
Normal file
110
crates/application/src/usecases/import_erp_tours.rs
Normal file
@ -0,0 +1,110 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
use crate::ports::{DriverIdentityProvisioner, ErpDeliverySource};
|
||||
use crate::usecases::SyncTourUseCase;
|
||||
|
||||
/// Ergebnis eines Import-Laufs — pro Fahrer-Tour Erfolg/Fehler getrennt,
|
||||
/// damit ein einzelner kaputter Beleg nicht den ganzen Tag blockiert.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
pub struct ImportSummary {
|
||||
pub date: NaiveDate,
|
||||
pub tours_total: usize,
|
||||
pub tours_ok: usize,
|
||||
pub tours_failed: usize,
|
||||
/// Fehlertexte je fehlgeschlagener Fahrer-Tour (z. B. unbekannter Fahrer
|
||||
/// → FK auf `accounts`, oder Validierungsfehler).
|
||||
pub errors: Vec<String>,
|
||||
/// Anzahl der **neu** im Identity-Provider (Keycloak) angelegten
|
||||
/// Fahrer-Konten in diesem Lauf (0, wenn Provisionierung deaktiviert ist
|
||||
/// oder alle Konten bereits existierten).
|
||||
#[serde(default)]
|
||||
pub drivers_provisioned: usize,
|
||||
/// Fehlertexte der Konto-Provisionierung (Keycloak). Best-effort: ein
|
||||
/// Fehler hier blockiert den Touren-Import **nicht**.
|
||||
#[serde(default)]
|
||||
pub provisioning_errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Zieht die Tagestouren eines Datums aus dem ERP und schreibt sie über den
|
||||
/// **bestehenden** Sync-Pfad (`SyncTourUseCase` → `upsert_from_sync`) in unser
|
||||
/// Postgres. Damit teilt der Import dieselbe Validierung + Upsert-Logik wie der
|
||||
/// HTTP-Endpoint `POST /sync/tour` — eine Wahrheit, kein zweiter Schreibweg.
|
||||
///
|
||||
/// Fehlertoleranz: jede Fahrer-Tour wird einzeln verarbeitet. Schlägt eine fehl
|
||||
/// (häufigster Fall: `Vertreter` ist kein angelegter Account → FK-Fehler), wird
|
||||
/// sie geloggt + übersprungen, der Rest läuft weiter.
|
||||
pub struct ImportErpToursUseCase {
|
||||
source: Arc<dyn ErpDeliverySource>,
|
||||
sync_tour: Arc<SyncTourUseCase>,
|
||||
/// Optionaler Identity-Provisioner (Keycloak). `None` ⇒ Konto-Anlage
|
||||
/// deaktiviert (`KEYCLOAK_PROVISIONING_ENABLED=false`).
|
||||
provisioner: Option<Arc<dyn DriverIdentityProvisioner>>,
|
||||
}
|
||||
|
||||
impl ImportErpToursUseCase {
|
||||
pub fn new(
|
||||
source: Arc<dyn ErpDeliverySource>,
|
||||
sync_tour: Arc<SyncTourUseCase>,
|
||||
provisioner: Option<Arc<dyn DriverIdentityProvisioner>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
source,
|
||||
sync_tour,
|
||||
provisioner,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute(&self, date: NaiveDate) -> Result<ImportSummary, ApplicationError> {
|
||||
let tours = self.source.fetch_tours_for_date(date).await?;
|
||||
let tours_total = tours.len();
|
||||
let mut tours_ok = 0usize;
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
let mut drivers_provisioned = 0usize;
|
||||
let mut provisioning_errors: Vec<String> = Vec::new();
|
||||
|
||||
for request in tours {
|
||||
let driver = request.driver_personalnummer;
|
||||
let deliveries = request.deliveries.len();
|
||||
match self.sync_tour.execute(request).await {
|
||||
Ok(_) => {
|
||||
tours_ok += 1;
|
||||
// Fahrer-Konto im IdP sicherstellen (best-effort): ein
|
||||
// Fehler hier wird protokolliert, blockiert aber den Import
|
||||
// nicht — Logistik geht vor.
|
||||
if let Some(provisioner) = &self.provisioner {
|
||||
let name = format!("Fahrer {driver}");
|
||||
match provisioner.ensure_driver(driver, Some(&name)).await {
|
||||
Ok(outcome) => {
|
||||
if outcome.created {
|
||||
drivers_provisioned += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
provisioning_errors.push(format!("driver {driver}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
errors.push(format!("driver {driver} ({deliveries} Lieferungen): {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ImportSummary {
|
||||
date,
|
||||
tours_total,
|
||||
tours_ok,
|
||||
tours_failed: errors.len(),
|
||||
errors,
|
||||
drivers_provisioned,
|
||||
provisioning_errors,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
//! Use Case: Belegnummern ausgelieferter (abgeschlossener) Lieferungen
|
||||
//! auflisten, **deren Liefermail noch nicht versendet wurde**.
|
||||
//!
|
||||
//! Reine Lese-Operation für den Admin-/Betriebs-Endpunkt + den externen
|
||||
//! Mailclient. „Ausgeliefert" = es existiert eine Abschluss-Zeile
|
||||
//! (`delivery_completions`); „offen" = `mail_sent_at IS NULL`. Optionaler
|
||||
//! Tagesfilter über den Abschluss-Zeitpunkt (`completed_at`, Zeitzone
|
||||
//! Europe/Berlin); `None` ⇒ alle offenen Belege. TZ-/Filter-Logik im Repository.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
use crate::ports::DeliveryCompletionRepository;
|
||||
|
||||
pub struct ListDeliveredBelegnummernUseCase {
|
||||
completions: Arc<dyn DeliveryCompletionRepository>,
|
||||
}
|
||||
|
||||
impl ListDeliveredBelegnummernUseCase {
|
||||
pub fn new(completions: Arc<dyn DeliveryCompletionRepository>) -> Self {
|
||||
Self { completions }
|
||||
}
|
||||
|
||||
/// Liefert die Belegnummern offener (noch nicht versendeter) Lieferungen.
|
||||
/// `Some(day)` ⇒ nur Abschlüsse dieses Tages, `None` ⇒ alle offenen.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
day: Option<NaiveDate>,
|
||||
) -> Result<Vec<String>, ApplicationError> {
|
||||
self.completions.list_delivered_belegnummern(day).await
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use chrono::{NaiveDate, Utc};
|
||||
|
||||
use crate::dto::TourSummary;
|
||||
use crate::error::ApplicationError;
|
||||
@ -9,17 +9,27 @@ use crate::ports::TourRepository;
|
||||
/// Liste der heutigen Touren des angemeldeten Fahrers. Das "heute"
|
||||
/// liegt **bewusst im Backend**: die App-Uhr ist nicht autoritativ
|
||||
/// (Zeitzone, Falsch-Stand, Manipulation).
|
||||
///
|
||||
/// `today_override` ist eine **DEV-ONLY**-Hintertür zum Testen mit
|
||||
/// historischen/importierten Touren: ist sie gesetzt, wird statt der echten
|
||||
/// Uhr dieses Datum verwendet. In Produktion `None`.
|
||||
pub struct ListMyToursTodayUseCase {
|
||||
repository: Arc<dyn TourRepository>,
|
||||
today_override: Option<NaiveDate>,
|
||||
}
|
||||
|
||||
impl ListMyToursTodayUseCase {
|
||||
pub fn new(repository: Arc<dyn TourRepository>) -> Self {
|
||||
Self { repository }
|
||||
pub fn new(repository: Arc<dyn TourRepository>, today_override: Option<NaiveDate>) -> Self {
|
||||
Self {
|
||||
repository,
|
||||
today_override,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute(&self, personalnummer: i64) -> Result<Vec<TourSummary>, ApplicationError> {
|
||||
let today = Utc::now().date_naive();
|
||||
let today = self
|
||||
.today_override
|
||||
.unwrap_or_else(|| Utc::now().date_naive());
|
||||
self.repository
|
||||
.find_today_for_driver(personalnummer, today)
|
||||
.await
|
||||
|
||||
41
crates/application/src/usecases/mark_mail_sent.rs
Normal file
41
crates/application/src/usecases/mark_mail_sent.rs
Normal file
@ -0,0 +1,41 @@
|
||||
//! Use Case: Liefermails von Belegnummern als **versendet** markieren.
|
||||
//!
|
||||
//! Wird vom externen Mailclient aufgerufen, NACHDEM ERPframe die Mails für die
|
||||
//! Belege erfolgreich verschickt hat. Setzt `delivery_completions.mail_sent_at`
|
||||
//! (nur wo noch NULL → idempotent) und sorgt damit dafür, dass dieselben Belege
|
||||
//! beim nächsten Poll nicht erneut zurückgegeben werden (server-seitiges Dedup).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
use crate::ports::DeliveryCompletionRepository;
|
||||
|
||||
pub struct MarkMailSentUseCase {
|
||||
completions: Arc<dyn DeliveryCompletionRepository>,
|
||||
}
|
||||
|
||||
impl MarkMailSentUseCase {
|
||||
pub fn new(completions: Arc<dyn DeliveryCompletionRepository>) -> Self {
|
||||
Self { completions }
|
||||
}
|
||||
|
||||
/// Markiert die angegebenen Belegnummern als mail-versendet und liefert die
|
||||
/// Anzahl frisch markierter (vorher offener) Belege zurück. Leere Eingabe
|
||||
/// ⇒ 0, ohne DB-Zugriff.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
belegnummern: Vec<String>,
|
||||
) -> Result<u64, ApplicationError> {
|
||||
self.completions.mark_mail_sent(&belegnummern).await
|
||||
}
|
||||
|
||||
/// **DEV-ONLY**: hebt die Markierung wieder auf (`mail_sent_at = NULL`),
|
||||
/// sodass die Belege erneut als offen erscheinen. Anzahl zurückgesetzter
|
||||
/// Belege als Rückgabe.
|
||||
pub async fn unmark(
|
||||
&self,
|
||||
belegnummern: Vec<String>,
|
||||
) -> Result<u64, ApplicationError> {
|
||||
self.completions.unmark_mail_sent(&belegnummern).await
|
||||
}
|
||||
}
|
||||
@ -6,23 +6,59 @@
|
||||
//! entgegen und orchestrieren damit das Domänenmodell.
|
||||
|
||||
pub mod apply_delivery_action;
|
||||
pub mod apply_delivery_credit_event;
|
||||
pub mod apply_scans;
|
||||
pub mod cars;
|
||||
pub mod complete_delivery;
|
||||
pub mod create_delivery_note;
|
||||
pub mod delete_delivery_note;
|
||||
pub mod dev_resync_tours;
|
||||
pub mod generate_delivery_report;
|
||||
pub mod get_account;
|
||||
pub mod get_attachment_preview;
|
||||
pub mod get_tour;
|
||||
pub mod import_erp_tours;
|
||||
pub mod list_delivered_belegnummern;
|
||||
pub mod list_my_tours_today;
|
||||
pub mod mark_mail_sent;
|
||||
pub mod payment_methods;
|
||||
pub mod process_delivery_report;
|
||||
pub mod push_completion_to_erp;
|
||||
pub mod services;
|
||||
pub mod set_delivery_order;
|
||||
pub mod sync_tour;
|
||||
pub mod update_delivery_note;
|
||||
pub mod upload_delivery_note_image;
|
||||
|
||||
pub use apply_delivery_action::ApplyDeliveryActionUseCase;
|
||||
pub use apply_delivery_credit_event::ApplyDeliveryCreditEventUseCase;
|
||||
pub use apply_scans::ApplyScansUseCase;
|
||||
pub use cars::{
|
||||
AssignCarToDeliveryUseCase, CreateMyCarUseCase, ListMyCarsUseCase, UpdateMyCarUseCase,
|
||||
};
|
||||
pub use complete_delivery::CompleteDeliveryUseCase;
|
||||
pub use create_delivery_note::CreateDeliveryNoteUseCase;
|
||||
pub use dev_resync_tours::DevResyncToursUseCase;
|
||||
pub use generate_delivery_report::GenerateDeliveryReportUseCase;
|
||||
pub use delete_delivery_note::DeleteDeliveryNoteUseCase;
|
||||
pub use get_account::GetAccountUseCase;
|
||||
pub use get_attachment_preview::GetAttachmentPreviewUseCase;
|
||||
pub use get_tour::GetTourUseCase;
|
||||
pub use import_erp_tours::{ImportErpToursUseCase, ImportSummary};
|
||||
pub use list_delivered_belegnummern::ListDeliveredBelegnummernUseCase;
|
||||
pub use list_my_tours_today::ListMyToursTodayUseCase;
|
||||
pub use mark_mail_sent::MarkMailSentUseCase;
|
||||
pub use payment_methods::{
|
||||
CreatePaymentMethodUseCase, DeletePaymentMethodUseCase, ListPaymentMethodsUseCase,
|
||||
UpdatePaymentMethodUseCase,
|
||||
};
|
||||
pub use process_delivery_report::ProcessDeliveryReportUseCase;
|
||||
pub use push_completion_to_erp::PushCompletionToErpUseCase;
|
||||
pub use services::{
|
||||
CreateServiceUseCase, DeleteDeliveryServiceUseCase, DeleteServiceUseCase,
|
||||
ListServicesUseCase, SetDeliveryServiceUseCase, UpdateServiceUseCase,
|
||||
};
|
||||
pub use set_delivery_order::SetDeliveryOrderUseCase;
|
||||
pub use sync_tour::SyncTourUseCase;
|
||||
pub use update_delivery_note::UpdateDeliveryNoteUseCase;
|
||||
pub use upload_delivery_note_image::UploadDeliveryNoteImageUseCase;
|
||||
|
||||
106
crates/application/src/usecases/payment_methods.rs
Normal file
106
crates/application/src/usecases/payment_methods.rs
Normal file
@ -0,0 +1,106 @@
|
||||
//! Use Cases rund um Zahlungs-Stammdaten.
|
||||
//!
|
||||
//! Global — keine Account-Isolation, weil Methoden firmenweit gelten.
|
||||
//! Validierung beschränkt sich auf nicht-leere Strings; Eindeutigkeit
|
||||
//! des `code` ist DB-Constraint, nicht hier dupliziert.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_domain::PaymentMethod;
|
||||
|
||||
use crate::dto::{CreatePaymentMethodRequest, UpdatePaymentMethodRequest};
|
||||
use crate::error::ApplicationError;
|
||||
use crate::ports::PaymentMethodRepository;
|
||||
|
||||
pub struct ListPaymentMethodsUseCase {
|
||||
repository: Arc<dyn PaymentMethodRepository>,
|
||||
}
|
||||
|
||||
impl ListPaymentMethodsUseCase {
|
||||
pub fn new(repository: Arc<dyn PaymentMethodRepository>) -> Self {
|
||||
Self { repository }
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
include_inactive: bool,
|
||||
) -> Result<Vec<PaymentMethod>, ApplicationError> {
|
||||
self.repository.list(include_inactive).await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CreatePaymentMethodUseCase {
|
||||
repository: Arc<dyn PaymentMethodRepository>,
|
||||
}
|
||||
|
||||
impl CreatePaymentMethodUseCase {
|
||||
pub fn new(repository: Arc<dyn PaymentMethodRepository>) -> Self {
|
||||
Self { repository }
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
request: CreatePaymentMethodRequest,
|
||||
) -> Result<PaymentMethod, ApplicationError> {
|
||||
let code = request.code.trim();
|
||||
let name = request.name.trim();
|
||||
if code.is_empty() {
|
||||
return Err(ApplicationError::Validation(
|
||||
"code darf nicht leer sein".into(),
|
||||
));
|
||||
}
|
||||
if name.is_empty() {
|
||||
return Err(ApplicationError::Validation(
|
||||
"name darf nicht leer sein".into(),
|
||||
));
|
||||
}
|
||||
self.repository.create(code, name).await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct UpdatePaymentMethodUseCase {
|
||||
repository: Arc<dyn PaymentMethodRepository>,
|
||||
}
|
||||
|
||||
impl UpdatePaymentMethodUseCase {
|
||||
pub fn new(repository: Arc<dyn PaymentMethodRepository>) -> Self {
|
||||
Self { repository }
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
id: Uuid,
|
||||
request: UpdatePaymentMethodRequest,
|
||||
) -> Result<PaymentMethod, ApplicationError> {
|
||||
if let Some(name) = request.name.as_deref() {
|
||||
if name.trim().is_empty() {
|
||||
return Err(ApplicationError::Validation(
|
||||
"name darf nicht leer sein".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
self.repository
|
||||
.update(
|
||||
id,
|
||||
request.name.as_deref().map(str::trim),
|
||||
request.active,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DeletePaymentMethodUseCase {
|
||||
repository: Arc<dyn PaymentMethodRepository>,
|
||||
}
|
||||
|
||||
impl DeletePaymentMethodUseCase {
|
||||
pub fn new(repository: Arc<dyn PaymentMethodRepository>) -> Self {
|
||||
Self { repository }
|
||||
}
|
||||
|
||||
pub async fn execute(&self, id: Uuid) -> Result<(), ApplicationError> {
|
||||
self.repository.delete(id).await
|
||||
}
|
||||
}
|
||||
122
crates/application/src/usecases/process_delivery_report.rs
Normal file
122
crates/application/src/usecases/process_delivery_report.rs
Normal file
@ -0,0 +1,122 @@
|
||||
//! Überträgt den PDF-Lieferreport an DOCUframe — idempotent & resume-fähig.
|
||||
//!
|
||||
//! Schritte (Fortschritt nach jedem Schritt hart in `delivery_report_jobs`):
|
||||
//! 1+2. PDF in-memory rendern → nach DOCUframe hochladen → `~ObjectID` hart
|
||||
//! speichern (`status = 'uploaded'`). Bei Retry übersprungen, wenn die
|
||||
//! ObjectId schon vorliegt (kein Doppel-Upload).
|
||||
//! 3. Makro `_SV_assignDeliveryReport` aufrufen (ordnet Report dem Beleg zu).
|
||||
//! 4. Erfolg → lokale Dateien aufräumen (Report-PDF, Unterschriften,
|
||||
//! Bild-Notizen + `deleted_at`), dann `status = 'done'`.
|
||||
//!
|
||||
//! Reihenfolge bei Schritt 4: erst aufräumen, dann `done`. Ein Crash dazwischen
|
||||
//! lässt den Job auf `uploaded` → der Cron ruft das (idempotente) Makro erneut
|
||||
//! und räumt erneut auf. So bleiben keine verwaisten lokalen Dateien zurück.
|
||||
//!
|
||||
//! Fehler in 1–3 werden im Job vermerkt (`attempts`/`last_error`) und der
|
||||
//! Status bleibt auf der erreichten Stufe — der Retry-Cron nimmt offene Jobs
|
||||
//! erneut auf.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
use crate::ports::{
|
||||
AttachmentRepository, AttachmentStorage, DeliveryReportJobRepository, DeliveryReportSink,
|
||||
DocuframeReportGateway, ReportJobStatus, SignatureStorage,
|
||||
};
|
||||
use crate::usecases::GenerateDeliveryReportUseCase;
|
||||
|
||||
pub struct ProcessDeliveryReportUseCase {
|
||||
generate: Arc<GenerateDeliveryReportUseCase>,
|
||||
jobs: Arc<dyn DeliveryReportJobRepository>,
|
||||
gateway: Arc<dyn DocuframeReportGateway>,
|
||||
attachment_repo: Arc<dyn AttachmentRepository>,
|
||||
attachment_storage: Arc<dyn AttachmentStorage>,
|
||||
signatures: Arc<dyn SignatureStorage>,
|
||||
report_sink: Arc<dyn DeliveryReportSink>,
|
||||
}
|
||||
|
||||
impl ProcessDeliveryReportUseCase {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
generate: Arc<GenerateDeliveryReportUseCase>,
|
||||
jobs: Arc<dyn DeliveryReportJobRepository>,
|
||||
gateway: Arc<dyn DocuframeReportGateway>,
|
||||
attachment_repo: Arc<dyn AttachmentRepository>,
|
||||
attachment_storage: Arc<dyn AttachmentStorage>,
|
||||
signatures: Arc<dyn SignatureStorage>,
|
||||
report_sink: Arc<dyn DeliveryReportSink>,
|
||||
) -> Self {
|
||||
Self {
|
||||
generate,
|
||||
jobs,
|
||||
gateway,
|
||||
attachment_repo,
|
||||
attachment_storage,
|
||||
signatures,
|
||||
report_sink,
|
||||
}
|
||||
}
|
||||
|
||||
/// Verarbeitet einen Job (anlegen, falls nötig). Fehler werden im Job
|
||||
/// vermerkt und zusätzlich propagiert (der Aufrufer loggt).
|
||||
pub async fn execute(&self, delivery_id: Uuid) -> Result<(), ApplicationError> {
|
||||
match self.run(delivery_id).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
// Best-effort: Fehler im Job festhalten (für Cron-Retry/Sicht).
|
||||
let _ = self.jobs.record_error(delivery_id, &e.to_string()).await;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(&self, delivery_id: Uuid) -> Result<(), ApplicationError> {
|
||||
let belegnummer = self
|
||||
.attachment_repo
|
||||
.delivery_belegnummer(delivery_id)
|
||||
.await?
|
||||
.ok_or(ApplicationError::NotFound)?;
|
||||
|
||||
let job = self.jobs.ensure(delivery_id, &belegnummer).await?;
|
||||
if matches!(job.status, ReportJobStatus::Done) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Schritt 1+2: rendern + hochladen (überspringen, wenn schon erledigt).
|
||||
let object_id = match job.docuframe_object_id {
|
||||
Some(oid) => oid,
|
||||
None => {
|
||||
let (_beleg, pdf) = self.generate.render_pdf(delivery_id).await?;
|
||||
let oid = self.gateway.upload_report_pdf(&belegnummer, pdf).await?;
|
||||
self.jobs.set_uploaded(delivery_id, &oid).await?;
|
||||
oid
|
||||
}
|
||||
};
|
||||
|
||||
// Schritt 3: Makro-Zuordnung (muss succeeded == true liefern).
|
||||
self.gateway.assign_report(&object_id, &belegnummer).await?;
|
||||
|
||||
// Schritt 4: erst aufräumen, dann als erledigt markieren.
|
||||
self.cleanup_local(delivery_id, &belegnummer).await;
|
||||
self.jobs.mark_done(delivery_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Aufräumen nach erfolgreichem Upload — best-effort (Fehler werden
|
||||
/// geschluckt; der Report liegt bereits sicher in DOCUframe):
|
||||
/// * lokale Report-PDFs
|
||||
/// * Unterschriften (Kunde + Fahrer)
|
||||
/// * Bild-Notizen (Datei löschen + `deleted_at` setzen, Metadaten bleiben)
|
||||
async fn cleanup_local(&self, delivery_id: Uuid, belegnummer: &str) {
|
||||
let _ = self.report_sink.delete(belegnummer).await;
|
||||
let _ = self.signatures.delete_for_delivery(delivery_id).await;
|
||||
if let Ok(refs) = self.attachment_repo.list_active_for_delivery(delivery_id).await {
|
||||
for r in refs {
|
||||
let _ = self.attachment_storage.delete(&r.reference).await;
|
||||
let _ = self.attachment_repo.mark_deleted(r.id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
59
crates/application/src/usecases/push_completion_to_erp.rs
Normal file
59
crates/application/src/usecases/push_completion_to_erp.rs
Normal file
@ -0,0 +1,59 @@
|
||||
//! Use Case: einen **bereits lokal abgeschlossenen** Lieferabschluss ins ERP
|
||||
//! zurückschreiben.
|
||||
//!
|
||||
//! Liest den aktuellen Postgres-Stand (ausgelieferte Mengen, Geld-Gutschrift,
|
||||
//! Abschluss-Zeitpunkt) und spiegelt ihn über den `ErpDeliveryWriteback`-Port
|
||||
//! in die ERPframe-MSSQL-DB. Bewusst **getrennt** vom lokalen Abschluss:
|
||||
//!
|
||||
//! * Der normale Pfad ruft diesen Use Case direkt nach erfolgreichem
|
||||
//! `complete()` auf (Fehler ⇒ 502, lokal bleibt `completed`).
|
||||
//! * Der Admin-Retry-Endpunkt ruft denselben Use Case erneut — da das
|
||||
//! Rückschreiben idempotent ist, ist das gefahrlos.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
use crate::ports::{
|
||||
DeliveryCompletionRepository, ErpDeliveryWriteback, ErpFinishDeliveryCommand, ErpLineQuantity,
|
||||
};
|
||||
|
||||
pub struct PushCompletionToErpUseCase {
|
||||
completions: Arc<dyn DeliveryCompletionRepository>,
|
||||
erp: Arc<dyn ErpDeliveryWriteback>,
|
||||
}
|
||||
|
||||
impl PushCompletionToErpUseCase {
|
||||
pub fn new(
|
||||
completions: Arc<dyn DeliveryCompletionRepository>,
|
||||
erp: Arc<dyn ErpDeliveryWriteback>,
|
||||
) -> Self {
|
||||
Self { completions, erp }
|
||||
}
|
||||
|
||||
/// Schreibt den Abschluss der Lieferung ins ERP zurück. `NotFound`, wenn
|
||||
/// die Lieferung nicht abgeschlossen ist; sonstige Fehler reichen den
|
||||
/// MSSQL-/Repository-Fehler durch.
|
||||
pub async fn execute(&self, delivery_id: Uuid) -> Result<(), ApplicationError> {
|
||||
let data = self.completions.load_erp_writeback(delivery_id).await?;
|
||||
|
||||
let cmd = ErpFinishDeliveryCommand {
|
||||
belegart_id: data.belegart_id,
|
||||
belegnummer: data.belegnummer,
|
||||
delivered_at: data.delivered_at,
|
||||
lines: data
|
||||
.lines
|
||||
.into_iter()
|
||||
.map(|l| ErpLineQuantity {
|
||||
belegzeilen_nr: l.belegzeilen_nr,
|
||||
delivered_quantity: l.delivered_quantity,
|
||||
})
|
||||
.collect(),
|
||||
credit_amount_cents: data.credit_amount_cents,
|
||||
payment_method_code: data.payment_method_code,
|
||||
};
|
||||
|
||||
self.erp.finish_delivery(cmd).await
|
||||
}
|
||||
}
|
||||
249
crates/application/src/usecases/services.rs
Normal file
249
crates/application/src/usecases/services.rs
Normal file
@ -0,0 +1,249 @@
|
||||
//! Use Cases rund um Services (Stammdaten-CRUD + Pro-Lieferung-Wert).
|
||||
//!
|
||||
//! Global — keine Account-Isolation. Eindeutigkeit des `key` ist
|
||||
//! DB-Constraint; hier nur fachliche Validierung (nicht-leer, kind↔min/max,
|
||||
//! Wert passend zum Typ + in Grenzen).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_domain::{DeliveryServiceValue, Service, ServiceKind};
|
||||
|
||||
use crate::dto::{CreateServiceRequest, SetDeliveryServiceRequest, UpdateServiceRequest};
|
||||
use crate::error::ApplicationError;
|
||||
use crate::ports::{DeliveryServiceRepository, ServiceRepository};
|
||||
|
||||
// ─── Stammdaten-CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
pub struct ListServicesUseCase {
|
||||
repository: Arc<dyn ServiceRepository>,
|
||||
}
|
||||
|
||||
impl ListServicesUseCase {
|
||||
pub fn new(repository: Arc<dyn ServiceRepository>) -> Self {
|
||||
Self { repository }
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
include_inactive: bool,
|
||||
) -> Result<Vec<Service>, ApplicationError> {
|
||||
self.repository.list(include_inactive).await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CreateServiceUseCase {
|
||||
repository: Arc<dyn ServiceRepository>,
|
||||
}
|
||||
|
||||
impl CreateServiceUseCase {
|
||||
pub fn new(repository: Arc<dyn ServiceRepository>) -> Self {
|
||||
Self { repository }
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
request: CreateServiceRequest,
|
||||
) -> Result<Service, ApplicationError> {
|
||||
let key = request.key.trim();
|
||||
let name = request.name.trim();
|
||||
if key.is_empty() {
|
||||
return Err(ApplicationError::Validation("key darf nicht leer sein".into()));
|
||||
}
|
||||
if name.is_empty() {
|
||||
return Err(ApplicationError::Validation("name darf nicht leer sein".into()));
|
||||
}
|
||||
// boolean trägt keine Grenzen.
|
||||
let (min_value, max_value) = match request.kind {
|
||||
ServiceKind::Boolean => {
|
||||
if request.min_value.is_some() || request.max_value.is_some() {
|
||||
return Err(ApplicationError::Validation(
|
||||
"boolean-Service darf keine min/max-Werte haben".into(),
|
||||
));
|
||||
}
|
||||
(None, None)
|
||||
}
|
||||
ServiceKind::Numeric => {
|
||||
if let (Some(min), Some(max)) = (request.min_value, request.max_value) {
|
||||
if min > max {
|
||||
return Err(ApplicationError::Validation(
|
||||
"min_value darf nicht größer als max_value sein".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
(request.min_value, request.max_value)
|
||||
}
|
||||
};
|
||||
self.repository
|
||||
.create(
|
||||
key,
|
||||
name,
|
||||
request.kind,
|
||||
min_value,
|
||||
max_value,
|
||||
request.sort_order.unwrap_or(0),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct UpdateServiceUseCase {
|
||||
repository: Arc<dyn ServiceRepository>,
|
||||
}
|
||||
|
||||
impl UpdateServiceUseCase {
|
||||
pub fn new(repository: Arc<dyn ServiceRepository>) -> Self {
|
||||
Self { repository }
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
id: Uuid,
|
||||
request: UpdateServiceRequest,
|
||||
) -> Result<Service, ApplicationError> {
|
||||
if let Some(name) = request.name.as_deref() {
|
||||
if name.trim().is_empty() {
|
||||
return Err(ApplicationError::Validation("name darf nicht leer sein".into()));
|
||||
}
|
||||
}
|
||||
if let (Some(min), Some(max)) = (request.min_value, request.max_value) {
|
||||
if min > max {
|
||||
return Err(ApplicationError::Validation(
|
||||
"min_value darf nicht größer als max_value sein".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
self.repository
|
||||
.update(
|
||||
id,
|
||||
request.name.as_deref().map(str::trim),
|
||||
request.min_value,
|
||||
request.max_value,
|
||||
request.active,
|
||||
request.sort_order,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DeleteServiceUseCase {
|
||||
repository: Arc<dyn ServiceRepository>,
|
||||
}
|
||||
|
||||
impl DeleteServiceUseCase {
|
||||
pub fn new(repository: Arc<dyn ServiceRepository>) -> Self {
|
||||
Self { repository }
|
||||
}
|
||||
|
||||
pub async fn execute(&self, id: Uuid) -> Result<(), ApplicationError> {
|
||||
self.repository.delete(id).await
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pro-Lieferung-Wert ───────────────────────────────────────────────────
|
||||
|
||||
pub struct SetDeliveryServiceUseCase {
|
||||
services: Arc<dyn ServiceRepository>,
|
||||
delivery_services: Arc<dyn DeliveryServiceRepository>,
|
||||
}
|
||||
|
||||
impl SetDeliveryServiceUseCase {
|
||||
pub fn new(
|
||||
services: Arc<dyn ServiceRepository>,
|
||||
delivery_services: Arc<dyn DeliveryServiceRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
services,
|
||||
delivery_services,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
service_id: Uuid,
|
||||
author_personalnummer: i64,
|
||||
request: SetDeliveryServiceRequest,
|
||||
) -> Result<DeliveryServiceValue, ApplicationError> {
|
||||
let service = self
|
||||
.services
|
||||
.find_by_id(service_id)
|
||||
.await?
|
||||
.ok_or(ApplicationError::NotFound)?;
|
||||
if !service.active {
|
||||
return Err(ApplicationError::Validation(
|
||||
"service is inactive".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Wert muss zum Typ passen.
|
||||
let (bool_value, numeric_value) = match service.kind {
|
||||
ServiceKind::Boolean => {
|
||||
let b = request.bool_value.ok_or_else(|| {
|
||||
ApplicationError::Validation("boolValue required for boolean service".into())
|
||||
})?;
|
||||
if request.numeric_value.is_some() {
|
||||
return Err(ApplicationError::Validation(
|
||||
"numericValue not allowed for boolean service".into(),
|
||||
));
|
||||
}
|
||||
(Some(b), None)
|
||||
}
|
||||
ServiceKind::Numeric => {
|
||||
let n = request.numeric_value.ok_or_else(|| {
|
||||
ApplicationError::Validation("numericValue required for numeric service".into())
|
||||
})?;
|
||||
if request.bool_value.is_some() {
|
||||
return Err(ApplicationError::Validation(
|
||||
"boolValue not allowed for numeric service".into(),
|
||||
));
|
||||
}
|
||||
if let Some(min) = service.min_value {
|
||||
if n < min {
|
||||
return Err(ApplicationError::Validation(format!(
|
||||
"numericValue {n} below min {min}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if let Some(max) = service.max_value {
|
||||
if n > max {
|
||||
return Err(ApplicationError::Validation(format!(
|
||||
"numericValue {n} above max {max}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
(None, Some(n))
|
||||
}
|
||||
};
|
||||
|
||||
self.delivery_services
|
||||
.set(
|
||||
delivery_id,
|
||||
service_id,
|
||||
bool_value,
|
||||
numeric_value,
|
||||
author_personalnummer,
|
||||
request.author_car_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DeleteDeliveryServiceUseCase {
|
||||
delivery_services: Arc<dyn DeliveryServiceRepository>,
|
||||
}
|
||||
|
||||
impl DeleteDeliveryServiceUseCase {
|
||||
pub fn new(delivery_services: Arc<dyn DeliveryServiceRepository>) -> Self {
|
||||
Self { delivery_services }
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
service_id: Uuid,
|
||||
) -> Result<(), ApplicationError> {
|
||||
self.delivery_services.delete(delivery_id, service_id).await
|
||||
}
|
||||
}
|
||||
58
crates/application/src/usecases/update_delivery_note.rs
Normal file
58
crates/application/src/usecases/update_delivery_note.rs
Normal file
@ -0,0 +1,58 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_domain::DeliveryNote;
|
||||
|
||||
use crate::dto::UpdateDeliveryNoteRequest;
|
||||
use crate::error::ApplicationError;
|
||||
use crate::ports::DeliveryNoteRepository;
|
||||
|
||||
/// Ändert `text` / `image_attachment` einer bestehenden Notiz.
|
||||
///
|
||||
/// Validierung wie beim Anlegen: mindestens eines von `text` (nicht-leer
|
||||
/// nach trim) und `image_attachment` muss gesetzt sein. Autor und
|
||||
/// `created_at` bleiben unverändert.
|
||||
///
|
||||
/// Berechtigung: keine Autor-Prüfung — innerhalb eines (geteilten) Accounts
|
||||
/// darf jeder Fahrer Notizen pflegen. Das entspricht dem Modell der übrigen
|
||||
/// Delivery-Aktionen (hold/cancel/complete), die ebenfalls keinen
|
||||
/// Autor-Bezug erzwingen.
|
||||
pub struct UpdateDeliveryNoteUseCase {
|
||||
repository: Arc<dyn DeliveryNoteRepository>,
|
||||
}
|
||||
|
||||
impl UpdateDeliveryNoteUseCase {
|
||||
pub fn new(repository: Arc<dyn DeliveryNoteRepository>) -> Self {
|
||||
Self { repository }
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
note_id: Uuid,
|
||||
request: UpdateDeliveryNoteRequest,
|
||||
) -> Result<DeliveryNote, ApplicationError> {
|
||||
let text = clean(request.text);
|
||||
let image = clean(request.image_attachment);
|
||||
|
||||
if text.is_none() && image.is_none() {
|
||||
return Err(ApplicationError::Validation(
|
||||
"notiz braucht text oder image_attachment".into(),
|
||||
));
|
||||
}
|
||||
|
||||
self.repository.update(note_id, text, image).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim + leerer-String → None.
|
||||
fn clean(input: Option<String>) -> Option<String> {
|
||||
input.and_then(|s| {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_owned())
|
||||
}
|
||||
})
|
||||
}
|
||||
129
crates/application/src/usecases/upload_delivery_note_image.rs
Normal file
129
crates/application/src/usecases/upload_delivery_note_image.rs
Normal file
@ -0,0 +1,129 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_domain::DeliveryNote;
|
||||
|
||||
use crate::error::ApplicationError;
|
||||
use crate::ports::{
|
||||
AttachmentRepository, AttachmentStorage, CarRepository, DeliveryNoteRepository, NewAttachment,
|
||||
};
|
||||
|
||||
/// Lädt ein Bild zu einer Lieferung hoch, registriert dessen Metadaten und
|
||||
/// legt dafür eine Bild-Notiz an.
|
||||
///
|
||||
/// Ablauf:
|
||||
/// 1. Bytes analysieren (Größe, SHA-256, Bildabmessungen).
|
||||
/// 2. Belegnummer der Lieferung auflösen (= Ordnername im Speicher).
|
||||
/// 3. Datei lokal ablegen (`<dir>/<Belegnummer>/<datei>`) → Speicher-Referenz.
|
||||
/// 4. Metadatensatz in `attachments` anlegen → unsere Attachment-Id.
|
||||
/// 5. Notiz mit `image_attachment = <attachment_id>` anlegen (kein Text).
|
||||
///
|
||||
/// Die App referenziert nur die Attachment-Id; der Download-Endpoint löst sie
|
||||
/// zur Speicher-Referenz auf. (Der DOCUframe-Upload bleibt im `GsdService`
|
||||
/// erhalten, ist hier aber nicht mehr verdrahtet — Bilder gehen lokal.)
|
||||
pub struct UploadDeliveryNoteImageUseCase {
|
||||
storage: Arc<dyn AttachmentStorage>,
|
||||
attachments: Arc<dyn AttachmentRepository>,
|
||||
notes: Arc<dyn DeliveryNoteRepository>,
|
||||
cars: Arc<dyn CarRepository>,
|
||||
}
|
||||
|
||||
impl UploadDeliveryNoteImageUseCase {
|
||||
pub fn new(
|
||||
storage: Arc<dyn AttachmentStorage>,
|
||||
attachments: Arc<dyn AttachmentRepository>,
|
||||
notes: Arc<dyn DeliveryNoteRepository>,
|
||||
cars: Arc<dyn CarRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
storage,
|
||||
attachments,
|
||||
notes,
|
||||
cars,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
author_personalnummer: i64,
|
||||
author_car_id: Option<Uuid>,
|
||||
filename: String,
|
||||
mime: String,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<DeliveryNote, ApplicationError> {
|
||||
if bytes.is_empty() {
|
||||
return Err(ApplicationError::Validation("leere datei".into()));
|
||||
}
|
||||
|
||||
if let Some(car_id) = author_car_id {
|
||||
self.cars
|
||||
.assert_owned_by_account(&[car_id], author_personalnummer)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// 1. Metadaten aus den Bytes ableiten.
|
||||
let size_bytes = bytes.len() as i64;
|
||||
let checksum_sha256 = sha256_hex(&bytes);
|
||||
let (width, height) = match imagesize::blob_size(&bytes) {
|
||||
Ok(dim) => (Some(dim.width as i32), Some(dim.height as i32)),
|
||||
Err(_) => (None, None),
|
||||
};
|
||||
|
||||
// 2. Belegnummer der Lieferung auflösen (= Ordnername im Speicher).
|
||||
let belegnummer = self
|
||||
.attachments
|
||||
.delivery_belegnummer(delivery_id)
|
||||
.await?
|
||||
.ok_or(ApplicationError::NotFound)?;
|
||||
|
||||
// 3. Bytes lokal ablegen (Ordner = Belegnummer) → Speicher-Referenz.
|
||||
let storage_reference = self
|
||||
.storage
|
||||
.upload(&belegnummer, &filename, &mime, bytes)
|
||||
.await?;
|
||||
|
||||
// 4. Metadatensatz anlegen. `docuframe_object_id` trägt jetzt die
|
||||
// lokale relative Speicher-Referenz (Spaltenname bleibt vorerst).
|
||||
let attachment_id = self
|
||||
.attachments
|
||||
.create(NewAttachment {
|
||||
docuframe_object_id: storage_reference,
|
||||
mime_type: mime,
|
||||
size_bytes,
|
||||
filename: Some(filename),
|
||||
checksum_sha256,
|
||||
width,
|
||||
height,
|
||||
uploaded_by: author_personalnummer,
|
||||
delivery_id,
|
||||
})
|
||||
.await?;
|
||||
|
||||
// 5. Bild-Notiz mit Verweis auf den Metadatensatz.
|
||||
self.notes
|
||||
.create(
|
||||
delivery_id,
|
||||
author_personalnummer,
|
||||
author_car_id,
|
||||
None,
|
||||
Some(attachment_id.to_string()),
|
||||
None, // Bild-Notiz hat keinen Mengen-Gutschrift-Bezug
|
||||
false, // und ist keine Betrags-Gutschrift-Notiz
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// SHA-256 der Bytes als Hex-String.
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
@ -10,6 +10,11 @@ use super::delivery::ScanStatus;
|
||||
/// * `Hold` / `Unhold` ändern nur den Status, keine Menge.
|
||||
/// * `Remove` markiert die Position als entfernt (Status `Removed`,
|
||||
/// z. B. weil der Kunde sie nicht annimmt).
|
||||
/// * `Unremove` hebt ein `Remove` wieder auf — die Position landet
|
||||
/// zurück in `InProgress` (oder `Done`, falls die `scanned_quantity`
|
||||
/// schon `required_quantity` erreicht hatte). Der ursprüngliche
|
||||
/// `Remove`-Audit-Eintrag bleibt unangetastet; das `Unremove` erzeugt
|
||||
/// einen eigenen Audit-Eintrag — die Historie bleibt vollständig.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@ -19,6 +24,7 @@ pub enum AuditAction {
|
||||
Hold,
|
||||
Unhold,
|
||||
Remove,
|
||||
Unremove,
|
||||
}
|
||||
|
||||
/// Append-only Audit-Log-Eintrag: jedes Ereignis am Scan-Zustand einer
|
||||
|
||||
66
crates/domain/src/contact.rs
Normal file
66
crates/domain/src/contact.rs
Normal file
@ -0,0 +1,66 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Rolle, mit der ein Kontakt-Datensatz an einer Lieferung hängt. Spiegelt
|
||||
/// die fünf Adress-FKs von `Belegkopf` (bzw. den Umweg über den Kunden):
|
||||
/// `header` = Belegadresse, `delivery` = Lieferadresse, `billing` =
|
||||
/// Rechnungsadresse, `contact_person` = Ansprechpartner, `customer_master`
|
||||
/// = Stammadresse des Kunden über `Kunden.AdressId`.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContactRole {
|
||||
Header,
|
||||
Delivery,
|
||||
Billing,
|
||||
ContactPerson,
|
||||
CustomerMaster,
|
||||
}
|
||||
|
||||
/// Art eines Kommunikationskanals. `fax` bewusst nicht mitgeführt — in der
|
||||
/// App nicht verwendet.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContactKind {
|
||||
Phone,
|
||||
Mobile,
|
||||
Email,
|
||||
Web,
|
||||
}
|
||||
|
||||
/// Snapshot eines ERP-Adress-Datensatzes, der zum Zeitpunkt des Tour-Syncs
|
||||
/// an einer Lieferung hing — Namensblock ohne Anschrift, weil die Adresse
|
||||
/// ihrerseits schon im Lieferungs-Snapshot steckt (`snap_*`-Spalten). Die
|
||||
/// eigentlichen Telefonnummern, E-Mails etc. liegen in den
|
||||
/// zugehörigen [`ContactChannel`]s.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContactSource {
|
||||
pub id: Uuid,
|
||||
pub delivery_id: Uuid,
|
||||
pub role: ContactRole,
|
||||
pub anrede: Option<String>,
|
||||
pub titel: Option<String>,
|
||||
pub name1: Option<String>,
|
||||
pub name2: Option<String>,
|
||||
pub name3: Option<String>,
|
||||
pub abteilung: Option<String>,
|
||||
pub funktion: Option<String>,
|
||||
}
|
||||
|
||||
/// Ein einzelner Kontaktkanal (Telefonnummer / Mobil / E-Mail / Web).
|
||||
/// Mehrere pro [`ContactSource`] möglich, die `position` hält die
|
||||
/// 1-basierte ERP-Reihenfolge (`Telefon` → 1, `Telefon2` → 2 usw.) fest,
|
||||
/// damit der „primäre" Kanal je Art stabil identifizierbar bleibt.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContactChannel {
|
||||
pub id: Uuid,
|
||||
pub source_id: Uuid,
|
||||
pub kind: ContactKind,
|
||||
pub position: i16,
|
||||
pub value: String,
|
||||
}
|
||||
@ -21,7 +21,10 @@ pub enum DeliveryState {
|
||||
|
||||
/// Eine einzelne Lieferung an einen Kunden. Aggregat-Wurzel für die
|
||||
/// Liefer-Items, Notizen und das ggf. zugeordnete Fahrzeug.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
//
|
||||
// Kein `Eq`-Derive, weil `prepaid_amount: f64` (Float kennt kein Eq —
|
||||
// NaN-Verhalten). `PartialEq` reicht für unsere Vergleiche.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Delivery {
|
||||
@ -58,6 +61,16 @@ pub struct Delivery {
|
||||
/// Begründung bei `state == Held` oder `state == Canceled`. Beim
|
||||
/// Resume / Complete wieder `None`.
|
||||
pub state_reason: Option<String>,
|
||||
|
||||
/// Bei Bestellung schon bezahlter Betrag in EUR. `0.0` wenn der
|
||||
/// Kunde alles bei Lieferung zahlt. Wird vom ERP-Sync gefüllt.
|
||||
pub prepaid_amount: f64,
|
||||
|
||||
/// Für den Restbetrag gewählte Zahlungsart — FK auf `payment_methods`.
|
||||
/// Vom Kunden bei Bestellung festgelegt, der Fahrer übernimmt nur
|
||||
/// die Abwicklung. Aktiv-Flag und Anzeige-Name werden über die
|
||||
/// Stammdaten-Tabelle aufgelöst, nicht hier embeddet.
|
||||
pub payment_method_id: Uuid,
|
||||
}
|
||||
|
||||
/// Status einer einzelnen Scan-Position innerhalb eines Items.
|
||||
@ -80,6 +93,11 @@ pub enum ScanStatus {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScanState {
|
||||
pub scanned_quantity: i32,
|
||||
/// Als Gutschrift entfernte Menge (0..=required_quantity). Eigene
|
||||
/// Dimension neben `scanned_quantity`: „wie viele Stück dieser Zeile hat
|
||||
/// der Kunde nicht angenommen". `status == Removed` entspricht
|
||||
/// `credited_quantity == required_quantity` (ganze Zeile gutgeschrieben).
|
||||
pub credited_quantity: i32,
|
||||
pub status: ScanStatus,
|
||||
/// Grund bei `status == Held` oder `status == Removed`.
|
||||
pub held_reason: Option<String>,
|
||||
@ -92,7 +110,8 @@ pub struct ScanState {
|
||||
///
|
||||
/// Über die Felder `belegzeilen_nr` und `komponenten_artikel_nr` bleibt
|
||||
/// die ERP-Herkunft auflösbar.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
// Kein `Eq`-Derive: `unit_price: f64` kennt kein `Eq`. `PartialEq` reicht.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeliveryItem {
|
||||
@ -103,6 +122,10 @@ pub struct DeliveryItem {
|
||||
pub required_quantity: i32,
|
||||
pub warehouse_id: Uuid,
|
||||
|
||||
/// Stückpreis (brutto, EUR) aus dem ERP-Sync. Der Warenwert einer
|
||||
/// Lieferung = Σ `unit_price` × ausgelieferte Menge.
|
||||
pub unit_price: f64,
|
||||
|
||||
/// ERP-Belegzeilen-Nr (Position innerhalb des Belegs).
|
||||
pub belegzeilen_nr: i32,
|
||||
|
||||
@ -110,9 +133,27 @@ pub struct DeliveryItem {
|
||||
/// Bei regulären Belegzeilen: `None`.
|
||||
pub komponenten_artikel_nr: Option<String>,
|
||||
|
||||
/// Artikelnummer des Oberartikels, zu dem diese Komponente gehört.
|
||||
/// `None` bei Oberartikeln/regulären Zeilen — die App rückt Komponenten
|
||||
/// darüber unter ihrem Oberartikel ein.
|
||||
pub parent_artikel_nr: Option<String>,
|
||||
|
||||
pub scan_state: ScanState,
|
||||
}
|
||||
|
||||
impl DeliveryItem {
|
||||
/// Tatsächlich auszuliefernde Menge = Soll minus Gutschrift. Nie negativ
|
||||
/// (die Gutschrift ist per Constraint auf `required_quantity` gedeckelt).
|
||||
pub fn delivered_quantity(&self) -> i32 {
|
||||
(self.required_quantity - self.scan_state.credited_quantity).max(0)
|
||||
}
|
||||
|
||||
/// Wert der ausgelieferten Menge dieser Position (brutto, EUR).
|
||||
pub fn line_total(&self) -> f64 {
|
||||
self.unit_price * self.delivered_quantity() as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// Notiz an einer Lieferung — frei eingegeben durch den Fahrer.
|
||||
///
|
||||
/// Mindestens eines von `text` oder `image_attachment` muss gesetzt
|
||||
@ -131,5 +172,35 @@ pub struct DeliveryNote {
|
||||
pub author_personalnummer: i64,
|
||||
/// Fahrzeug, falls bekannt — nullable bis das Backend Cars verwaltet.
|
||||
pub author_car_id: Option<Uuid>,
|
||||
/// Wenn die Notiz als Gutschrift-Grund zu einer Belegzeile angelegt
|
||||
/// wurde: deren `DeliveryItem`-Id. Erlaubt dem Client, die Notiz beim
|
||||
/// Zurücknehmen der Gutschrift (Unremove) gezielt wieder zu löschen.
|
||||
/// `None` bei normalen Text-/Foto-Notizen.
|
||||
pub credit_delivery_item_id: Option<Uuid>,
|
||||
/// `true`, wenn die Notiz den Grund einer **Betrags-Gutschrift**
|
||||
/// (Geld-Nachlass, Lieferungs-Ebene) dokumentiert. Erlaubt dem Client,
|
||||
/// sie beim Entfernen der Gutschrift gezielt zu löschen.
|
||||
pub is_amount_credit_note: bool,
|
||||
/// `true`, wenn die lokale Bilddatei nach erfolgreichem Report-Upload
|
||||
/// gelöscht wurde (das Bild steckt nun im Lieferbericht in DOCUframe).
|
||||
/// Read-only; die App zeigt dann statt der Vorschau einen Hinweis.
|
||||
/// Bei Text-Notizen / vorhandenem Bild: `false`.
|
||||
#[serde(default)]
|
||||
pub image_attachment_deleted: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Aktuelle Betrags-Gutschrift einer Lieferung (Geld-Nachlass, unabhängig von
|
||||
/// Stückzahl). Abgeleitet aus dem jüngsten Ereignis im append-only
|
||||
/// `delivery_credit_audit`; existiert nur, solange der letzte Stand `set`
|
||||
/// (und nicht `remove`) ist. `delivery_id` macht den Eintrag — wie eine
|
||||
/// Notiz — clientseitig per Lieferung join-bar.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeliveryCredit {
|
||||
pub delivery_id: Uuid,
|
||||
/// Gutschrift-Betrag in Cent (> 0, ≤ 15000).
|
||||
pub amount_cents: i64,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
@ -20,9 +20,12 @@ mod article;
|
||||
mod audit;
|
||||
mod car;
|
||||
mod common;
|
||||
mod contact;
|
||||
mod customer;
|
||||
mod delivery;
|
||||
mod payment;
|
||||
mod process_state;
|
||||
mod service;
|
||||
mod tour;
|
||||
mod warehouse;
|
||||
|
||||
@ -31,8 +34,13 @@ pub use article::Article;
|
||||
pub use audit::{AuditAction, ScanAuditEntry};
|
||||
pub use car::Car;
|
||||
pub use common::Address;
|
||||
pub use contact::{ContactChannel, ContactKind, ContactRole, ContactSource};
|
||||
pub use customer::{Customer, CustomerContact};
|
||||
pub use delivery::{Delivery, DeliveryItem, DeliveryNote, DeliveryState, ScanState, ScanStatus};
|
||||
pub use delivery::{
|
||||
Delivery, DeliveryCredit, DeliveryItem, DeliveryNote, DeliveryState, ScanState, ScanStatus,
|
||||
};
|
||||
pub use payment::PaymentMethod;
|
||||
pub use process_state::{DeliveryPhase, DeliveryProcessState};
|
||||
pub use service::{DeliveryServiceValue, Service, ServiceKind};
|
||||
pub use tour::Tour;
|
||||
pub use warehouse::Warehouse;
|
||||
|
||||
30
crates/domain/src/payment.rs
Normal file
30
crates/domain/src/payment.rs
Normal file
@ -0,0 +1,30 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Zahlungs-Stammdatensatz.
|
||||
///
|
||||
/// Bewusst eine Tabelle und kein Enum: neue Anbieter (PayPal, Klarna, …)
|
||||
/// kommen über den `POST /payment-methods`-Endpoint hinzu. Domain-Code
|
||||
/// kann trotzdem fachliche Sonderfälle über den stabilen `code` (z. B.
|
||||
/// `"invoice"` braucht Bonitätsprüfung) referenzieren — die UUID dient
|
||||
/// nur als FK in `deliveries`.
|
||||
///
|
||||
/// `active = false` ist Soft-Delete: die Methode bleibt referenzierbar
|
||||
/// für historische Lieferungen, taucht aber in der UI-Auswahl nicht
|
||||
/// mehr auf. Echtes Löschen ist nur möglich, wenn keine Lieferung sie
|
||||
/// referenziert — Datenbank-Constraint regelt das via
|
||||
/// `ON DELETE RESTRICT`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PaymentMethod {
|
||||
pub id: Uuid,
|
||||
/// Stabiler Programm-Identifier — z. B. `"cash"`, `"ec_card"`.
|
||||
/// Eindeutig pro Eintrag. Wird vom Aufrufer beim Anlegen gesetzt.
|
||||
pub code: String,
|
||||
/// Display-Name in der UI — frei via PATCH änderbar.
|
||||
pub name: String,
|
||||
pub active: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
45
crates/domain/src/service.rs
Normal file
45
crates/domain/src/service.rs
Normal file
@ -0,0 +1,45 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Eingabetyp eines Service (früher „Lieferoption"). `Boolean` rendert als
|
||||
/// Checkbox, `Numeric` als Zahlenfeld mit optionalen Grenzen.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ServiceKind {
|
||||
Boolean,
|
||||
Numeric,
|
||||
}
|
||||
|
||||
/// Service-Stammdatensatz — admin-konfigurierbar (Muster wie `PaymentMethod`).
|
||||
///
|
||||
/// `key` ist der stabile Programm-Identifier (eindeutig), `name` der
|
||||
/// Anzeige-Name. `min_value`/`max_value` sind nur für `Numeric` relevant.
|
||||
/// `active = false` ist Soft-Delete (bleibt für historische Lieferungen
|
||||
/// referenzierbar, fällt aus dem Default-Listing).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Service {
|
||||
pub id: Uuid,
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub kind: ServiceKind,
|
||||
pub min_value: Option<i32>,
|
||||
pub max_value: Option<i32>,
|
||||
pub active: bool,
|
||||
pub sort_order: i32,
|
||||
}
|
||||
|
||||
/// Pro-Lieferung gewählter Wert eines Service. Genau einer der beiden
|
||||
/// Wert-Slots ist je nach `ServiceKind` gesetzt; per `service_id`/`delivery_id`
|
||||
/// clientseitig join-bar (wie Notizen/Gutschriften).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeliveryServiceValue {
|
||||
pub delivery_id: Uuid,
|
||||
pub service_id: Uuid,
|
||||
pub bool_value: Option<bool>,
|
||||
pub numeric_value: Option<i32>,
|
||||
}
|
||||
@ -15,7 +15,14 @@ serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-util.workspace = true
|
||||
sqlx.workspace = true
|
||||
reqwest.workspace = true
|
||||
tiberius.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
tracing.workspace = true
|
||||
# PDF-Report-Generierung (Lieferabschluss). printpdf nutzt eingebaute
|
||||
# Standard-Fonts (Helvetica, WinAnsi → deutsche Umlaute) → kein Font-Asset.
|
||||
# `image` dekodiert Unterschriften/Foto-Notizen zu Roh-RGB fürs Einbetten.
|
||||
printpdf = "0.7"
|
||||
image = "0.25"
|
||||
|
||||
298
crates/infrastructure/src/auth/keycloak_admin.rs
Normal file
298
crates/infrastructure/src/auth/keycloak_admin.rs
Normal file
@ -0,0 +1,298 @@
|
||||
//! Keycloak-Admin-Adapter — Implementierung von [`DriverIdentityProvisioner`].
|
||||
//!
|
||||
//! Legt beim ERP-Sync Fahrer-Konten im Realm an. Authentifiziert sich als
|
||||
//! **Service-Account** (confidential Client `holzleitner-provisioner`,
|
||||
//! `client_credentials`) mit der `realm-management`-Rolle `manage-users`.
|
||||
//!
|
||||
//! Ablauf je Fahrer (idempotent):
|
||||
//! 1. Admin-Token holen (`client_credentials`).
|
||||
//! 2. User per `?username=<nr>&exact=true` suchen → existiert ⇒ No-Op.
|
||||
//! 3. `POST users` mit `username=<nr>`, Attribut `personalnummer=[<nr>]`,
|
||||
//! temporärem Passwort (`temporary:true`) und Required-Action
|
||||
//! `UPDATE_PASSWORD` (Zwangsänderung beim ersten Login).
|
||||
//! 4. Realm-Rolle `driver` zuweisen (`role-mappings/realm`).
|
||||
//!
|
||||
//! Bewusst **kein** Passwort-Reset für bestehende User — wer sein Passwort
|
||||
//! schon gesetzt hat, behält es.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::{DriverIdentityProvisioner, ProvisionOutcome};
|
||||
|
||||
/// Konfiguration des Keycloak-Admin-Adapters (aus `KEYCLOAK_*`-Env).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KeycloakAdminConfig {
|
||||
/// Basis-URL der Keycloak-Instanz **ohne** `/realms/...`, z. B.
|
||||
/// `http://localhost:8080`.
|
||||
pub base_url: String,
|
||||
/// Realm-Name, z. B. `holzleitner`.
|
||||
pub realm: String,
|
||||
/// Service-Account-Client (confidential) für die Admin-API.
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
/// Default-Passwort, das neuen Konten als **temporär** gesetzt wird
|
||||
/// (muss beim ersten Login geändert werden).
|
||||
pub default_password: String,
|
||||
/// Realm-Rolle, die jedem Fahrer zugewiesen wird (z. B. `driver`).
|
||||
pub driver_role: String,
|
||||
}
|
||||
|
||||
pub struct KeycloakAdminClient {
|
||||
config: KeycloakAdminConfig,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl KeycloakAdminClient {
|
||||
pub fn new(config: KeycloakAdminConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
http: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn base(&self) -> &str {
|
||||
self.config.base_url.trim_end_matches('/')
|
||||
}
|
||||
|
||||
/// Holt ein Admin-Access-Token via `client_credentials`.
|
||||
async fn admin_token(&self) -> Result<String, ApplicationError> {
|
||||
let url = format!(
|
||||
"{}/realms/{}/protocol/openid-connect/token",
|
||||
self.base(),
|
||||
self.config.realm
|
||||
);
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.form(&[
|
||||
("grant_type", "client_credentials"),
|
||||
("client_id", self.config.client_id.as_str()),
|
||||
("client_secret", self.config.client_secret.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(ext)?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ApplicationError::External(format!(
|
||||
"keycloak token ({status}): {body}"
|
||||
)));
|
||||
}
|
||||
let token: TokenResponse = resp.json().await.map_err(ext)?;
|
||||
Ok(token.access_token)
|
||||
}
|
||||
|
||||
/// Sucht einen User per exaktem Benutzernamen. `Some(id)` ⇒ existiert.
|
||||
async fn find_user_id(
|
||||
&self,
|
||||
token: &str,
|
||||
username: &str,
|
||||
) -> Result<Option<String>, ApplicationError> {
|
||||
let url = format!("{}/admin/realms/{}/users", self.base(), self.config.realm);
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.bearer_auth(token)
|
||||
.query(&[("username", username), ("exact", "true")])
|
||||
.send()
|
||||
.await
|
||||
.map_err(ext)?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ApplicationError::External(format!(
|
||||
"keycloak user lookup ({status}): {body}"
|
||||
)));
|
||||
}
|
||||
let users: Vec<UserRep> = resp.json().await.map_err(ext)?;
|
||||
Ok(users.into_iter().find_map(|u| u.id))
|
||||
}
|
||||
|
||||
/// Legt einen neuen User an und gibt dessen ID zurück.
|
||||
async fn create_user(
|
||||
&self,
|
||||
token: &str,
|
||||
username: &str,
|
||||
display_name: Option<&str>,
|
||||
) -> Result<String, ApplicationError> {
|
||||
let url = format!("{}/admin/realms/{}/users", self.base(), self.config.realm);
|
||||
let body = json!({
|
||||
"username": username,
|
||||
"enabled": true,
|
||||
"firstName": display_name.unwrap_or(username),
|
||||
"attributes": { "personalnummer": [username] },
|
||||
"requiredActions": ["UPDATE_PASSWORD"],
|
||||
"credentials": [{
|
||||
"type": "password",
|
||||
"value": self.config.default_password,
|
||||
"temporary": true
|
||||
}]
|
||||
});
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.bearer_auth(token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(ext)?;
|
||||
|
||||
let status = resp.status();
|
||||
if status.as_u16() == 409 {
|
||||
// Race: zwischen Lookup und Create angelegt — als „existiert"
|
||||
// behandeln und ID nachschlagen.
|
||||
return self
|
||||
.find_user_id(token, username)
|
||||
.await?
|
||||
.ok_or_else(|| ApplicationError::External("keycloak 409 ohne User".into()));
|
||||
}
|
||||
if !status.is_success() {
|
||||
let txt = resp.text().await.unwrap_or_default();
|
||||
return Err(ApplicationError::External(format!(
|
||||
"keycloak create user ({status}): {txt}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Keycloak liefert die ID im `Location`-Header (.../users/{id}).
|
||||
if let Some(loc) = resp.headers().get(reqwest::header::LOCATION) {
|
||||
if let Ok(s) = loc.to_str() {
|
||||
if let Some(id) = s.rsplit('/').next() {
|
||||
if !id.is_empty() {
|
||||
return Ok(id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: erneut suchen.
|
||||
self.find_user_id(token, username)
|
||||
.await?
|
||||
.ok_or_else(|| ApplicationError::External("keycloak: User nach Create nicht gefunden".into()))
|
||||
}
|
||||
|
||||
/// Weist dem User die Realm-Rolle `driver_role` zu (idempotent).
|
||||
///
|
||||
/// Bewusst über den **user-scoped** Endpoint `role-mappings/realm/available`
|
||||
/// statt `GET /roles/{name}` — letzterer bräuchte `view-realm`; ersterer
|
||||
/// kommt mit `manage-users` aus (least privilege). Ist die Rolle nicht mehr
|
||||
/// „available", ist sie bereits zugewiesen (oder existiert nicht → wir
|
||||
/// prüfen die effektiven Zuweisungen und liefern sonst einen Fehler).
|
||||
async fn assign_driver_role(
|
||||
&self,
|
||||
token: &str,
|
||||
user_id: &str,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let role_name = self.config.driver_role.as_str();
|
||||
let base_map = format!(
|
||||
"{}/admin/realms/{}/users/{}/role-mappings/realm",
|
||||
self.base(),
|
||||
self.config.realm,
|
||||
user_id
|
||||
);
|
||||
|
||||
// 1. Zuweisbare Realm-Rollen des Users holen, `driver` suchen.
|
||||
let available: Vec<Value> = self
|
||||
.http
|
||||
.get(format!("{base_map}/available"))
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(ext)?
|
||||
.error_for_status()
|
||||
.map_err(ext)?
|
||||
.json()
|
||||
.await
|
||||
.map_err(ext)?;
|
||||
|
||||
let role = available
|
||||
.into_iter()
|
||||
.find(|r| r.get("name").and_then(Value::as_str) == Some(role_name));
|
||||
|
||||
let Some(role) = role else {
|
||||
// Nicht „available" → entweder schon zugewiesen (idempotenter
|
||||
// No-Op) oder Rolle existiert nicht. Effektive Zuweisungen prüfen.
|
||||
let assigned: Vec<Value> = self
|
||||
.http
|
||||
.get(&base_map)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(ext)?
|
||||
.error_for_status()
|
||||
.map_err(ext)?
|
||||
.json()
|
||||
.await
|
||||
.map_err(ext)?;
|
||||
let has = assigned
|
||||
.iter()
|
||||
.any(|r| r.get("name").and_then(Value::as_str) == Some(role_name));
|
||||
return if has {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApplicationError::External(format!(
|
||||
"keycloak: Realm-Rolle '{role_name}' existiert nicht"
|
||||
)))
|
||||
};
|
||||
};
|
||||
|
||||
// 2. Rolle zuweisen.
|
||||
let resp = self
|
||||
.http
|
||||
.post(&base_map)
|
||||
.bearer_auth(token)
|
||||
.json(&json!([role]))
|
||||
.send()
|
||||
.await
|
||||
.map_err(ext)?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ApplicationError::External(format!(
|
||||
"keycloak assign role ({status}): {body}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DriverIdentityProvisioner for KeycloakAdminClient {
|
||||
async fn ensure_driver(
|
||||
&self,
|
||||
personalnummer: i64,
|
||||
display_name: Option<&str>,
|
||||
) -> Result<ProvisionOutcome, ApplicationError> {
|
||||
let username = personalnummer.to_string();
|
||||
let token = self.admin_token().await?;
|
||||
|
||||
if self.find_user_id(&token, &username).await?.is_some() {
|
||||
tracing::debug!(personalnummer, "keycloak_provision: user existiert bereits");
|
||||
return Ok(ProvisionOutcome { created: false });
|
||||
}
|
||||
|
||||
let user_id = self.create_user(&token, &username, display_name).await?;
|
||||
self.assign_driver_role(&token, &user_id).await?;
|
||||
tracing::info!(personalnummer, user_id, "keycloak_provision: Fahrer-Konto angelegt");
|
||||
Ok(ProvisionOutcome { created: true })
|
||||
}
|
||||
}
|
||||
|
||||
fn ext<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::External(e.to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UserRep {
|
||||
id: Option<String>,
|
||||
}
|
||||
@ -6,5 +6,7 @@
|
||||
//! auf das Domänenmodell (Personalnummer, Rollen).
|
||||
|
||||
pub mod keycloak;
|
||||
pub mod keycloak_admin;
|
||||
|
||||
pub use keycloak::{KeycloakAdapterConfig, KeycloakAuthService};
|
||||
pub use keycloak_admin::{KeycloakAdminClient, KeycloakAdminConfig};
|
||||
|
||||
11
crates/infrastructure/src/erp/mod.rs
Normal file
11
crates/infrastructure/src/erp/mod.rs
Normal file
@ -0,0 +1,11 @@
|
||||
//! ERP-Lese-Adapter (ERPframe / MS SQL Server).
|
||||
//!
|
||||
//! Implementiert den `ErpDeliverySource`-Port gegen die ERPframe-Datenbank
|
||||
//! via `tiberius` (nativer async MSSQL-Treiber). Reine Lese-Operation; das
|
||||
//! ERP wird nicht zurückgeschrieben.
|
||||
|
||||
pub mod mssql_delivery_source;
|
||||
pub mod mssql_delivery_writeback;
|
||||
|
||||
pub use mssql_delivery_source::{MssqlErpConfig, MssqlErpDeliverySource};
|
||||
pub use mssql_delivery_writeback::MssqlErpDeliveryWriteback;
|
||||
586
crates/infrastructure/src/erp/mssql_delivery_source.rs
Normal file
586
crates/infrastructure/src/erp/mssql_delivery_source.rs
Normal file
@ -0,0 +1,586 @@
|
||||
//! MSSQL-Adapter für den `ErpDeliverySource`-Port.
|
||||
//!
|
||||
//! Liest die Lieferungen eines Tages direkt aus den ERPframe-Basistabellen
|
||||
//! (Belegart `VL5` / Lieferschein) und gruppiert sie zu `SyncTourRequest`-
|
||||
//! DTOs (eine pro Fahrer). Kein Connection-Pool: der Pull läuft einmal
|
||||
//! täglich, eine frische Verbindung pro Lauf genügt.
|
||||
//!
|
||||
//! Die SELECT-Query löst **Stücklisten** auf (`UNION ALL`, analog zur Alt-View
|
||||
//! `_SV_APP_DELIVERIES_TODAY`): Oberartikel werden non-scannable als Preis-/
|
||||
//! Gruppenträger geführt, jede Komponente als eigener (scanbarer) Artikel. Das
|
||||
//! Lager kommt pro Zeile aus `bz.Lagerverteilung` (XML), Fallback `bk.Lager`.
|
||||
//! Numerische Spalten werden serverseitig auf feste Typen gecastet
|
||||
//! (BIGINT/INT/FLOAT/BIT), damit die tiberius-Reads deterministisch sind.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::NaiveDate;
|
||||
use tiberius::{AuthMethod, Client, Config};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_util::compat::TokioAsyncWriteCompatExt;
|
||||
|
||||
use holzleitner_application::dto::{
|
||||
SyncContactChannel, SyncContactSource, SyncDelivery, SyncDeliveryItem, SyncTourRequest,
|
||||
};
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::ErpDeliverySource;
|
||||
use holzleitner_domain::{Address, ContactKind, ContactRole};
|
||||
|
||||
/// Verbindungsparameter zur ERPframe-MSSQL.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MssqlErpConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub database: String,
|
||||
pub user: String,
|
||||
pub password: String,
|
||||
/// Selbstsigniertes Server-Zertifikat akzeptieren (lokale/Intranet-DB).
|
||||
pub trust_cert: bool,
|
||||
}
|
||||
|
||||
pub struct MssqlErpDeliverySource {
|
||||
config: MssqlErpConfig,
|
||||
}
|
||||
|
||||
impl MssqlErpDeliverySource {
|
||||
pub fn new(config: MssqlErpConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
fn tiberius_config(&self) -> Config {
|
||||
let mut cfg = Config::new();
|
||||
cfg.host(&self.config.host);
|
||||
cfg.port(self.config.port);
|
||||
cfg.database(&self.config.database);
|
||||
cfg.authentication(AuthMethod::sql_server(
|
||||
&self.config.user,
|
||||
&self.config.password,
|
||||
));
|
||||
if self.config.trust_cert {
|
||||
cfg.trust_cert();
|
||||
}
|
||||
cfg
|
||||
}
|
||||
}
|
||||
|
||||
fn repo<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
/// Adress-Aliases, ihre FK-Quelle am Belegkopf und der Präfix, mit dem alle
|
||||
/// Kontaktspalten der jeweiligen Adresse in der SELECT-Liste verbiegt werden.
|
||||
/// `adr` ist die Belegadresse (auch heute schon Pflicht-JOIN für die
|
||||
/// Anschrift); die übrigen vier sind LEFT-JOINs. Reihenfolge entspricht der
|
||||
/// `ContactRole`-Enum.
|
||||
const ADDRESS_ALIASES: &[(&str, &str)] = &[
|
||||
("adr", "hdr"), // header – bk.AdressId
|
||||
("dadr", "dlv"), // delivery – bk.LieferAdressId
|
||||
("radr", "bll"), // billing – bk.RechnungsAdressId
|
||||
("kadr", "ctp"), // contact_person – bk.AnsprechpartnerId
|
||||
("sadr", "cms"), // customer_master – Kunden.AdressId (über bk.KundenId)
|
||||
];
|
||||
|
||||
/// Spalten, die wir pro Adresse selektieren. Reihenfolge irrelevant; Aliase
|
||||
/// werden im Mapper über `<präfix>_<spalte>` aufgelöst.
|
||||
const ADDRESS_CONTACT_COLUMNS: &[&str] = &[
|
||||
"Anrede", "Titel", "Name1", "Name2", "Name3", "Abteilung", "Funktion", "Telefon", "Telefon2",
|
||||
"Telefon3", "Telefon4", "Mobiltel", "Mobiltel2", "EMail", "EMail2", "EMail3", "InternetAdresse",
|
||||
];
|
||||
|
||||
/// Erzeugt den SELECT-Block für alle 5 Adressen — eine Zeile pro Spalte mit
|
||||
/// `<alias>.<col> AS <präfix>_<col>`. Wird in beide UNION-Hälften identisch
|
||||
/// einkopiert.
|
||||
fn address_select_block() -> String {
|
||||
let mut out = String::new();
|
||||
for (alias, prefix) in ADDRESS_ALIASES {
|
||||
for col in ADDRESS_CONTACT_COLUMNS {
|
||||
out.push_str(&format!(" {alias}.{col} AS {prefix}_{col},\n"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// SELECT gegen die Basistabellen, **mit Stücklisten-Auflösung** (analog zur
|
||||
/// Alt-View `_SV_APP_DELIVERIES_TODAY`). Datum als positionaler Parameter
|
||||
/// `@P1`. Zwei `UNION ALL`-Teile:
|
||||
///
|
||||
/// * **Teil 1** — alle Belegzeilen als Items. Ein Oberartikel (= Artikel mit
|
||||
/// Stücklisten-Kopf `Stuecklisten.StueckListenId=0`) wird **non-scannable**
|
||||
/// (`articleScannable=0`) ausgegeben: er ist nur Preis-/Gruppen-Träger, die
|
||||
/// physisch zu scannenden Einheiten sind seine Komponenten. `komponenten-
|
||||
/// ArtikelNr = NULL`.
|
||||
/// * **Teil 2** — die Stücklisten-Komponenten je Oberartikel, **jede als
|
||||
/// eigener Artikel** (eigene Artikelnummer/-bezeichnung/-scanbarkeit), Menge
|
||||
/// = Zeilenmenge × Komponentenmenge, `unitPrice=0` (der Preis liegt im ERP
|
||||
/// nur auf der Oberartikel-Zeile). `komponentenArtikelNr` = eigene Nummer der
|
||||
/// Komponente (eindeutig je `belegzeilenNr`; markiert die Zeile als
|
||||
/// Komponente — der Parent ist die `NULL`-Zeile gleicher `belegzeilenNr`).
|
||||
///
|
||||
/// **Lager pro Zeile** aus `bz.Lagerverteilung` (XML `(/Root/Row/Lager)[1]`),
|
||||
/// Fallback `bk.Lager`; Name aus `Lagerstammdaten`. Komponenten erben das
|
||||
/// Lager ihrer Oberartikel-Zeile. Das Lager wird je Zeile einmal per
|
||||
/// `CROSS APPLY lv` berechnet.
|
||||
///
|
||||
/// **Adressen für Kontaktdaten:** Wir joinen zusätzlich zur Belegadresse
|
||||
/// (`adr`) und der Lieferadresse (`dadr`) auch Rechnungsadresse (`radr`),
|
||||
/// Ansprechpartner (`kadr`) und die Stamm-Adresse des Kunden (`sadr` über
|
||||
/// `Kunden.AdressId`). Pro Adresse werden Name/Anrede/Titel und alle
|
||||
/// Telefon-/Mobil-/E-Mail-/Web-Spalten selektiert (siehe
|
||||
/// [`address_select_block`]); der Mapper baut daraus die `contact_sources`
|
||||
/// der Lieferung.
|
||||
const SQL_TEMPLATE: &str = r#"
|
||||
SELECT
|
||||
TRY_CAST(LTRIM(RTRIM(bk.Vertreter)) AS BIGINT) AS driverPersonalnummer,
|
||||
CAST(bk.BelegartId AS BIGINT) AS belegartId,
|
||||
LTRIM(RTRIM(ba.Belegart)) AS belegartCode,
|
||||
LTRIM(RTRIM(ba.Bezeichnung)) AS belegartName,
|
||||
LTRIM(RTRIM(bk.Belegnummer)) AS belegnummer,
|
||||
COALESCE(TRY_CAST(LTRIM(RTRIM(k.Kundennummer)) AS BIGINT),
|
||||
CAST(bk.KundenId AS BIGINT)) AS erpCustomerId,
|
||||
adr.Name1 AS customerName,
|
||||
adr.Strasse AS custStreet,
|
||||
adr.Hausnummer AS custHouseNumber,
|
||||
LTRIM(RTRIM(adr.PLZ)) AS custPostalCode,
|
||||
adr.Ort AS custCity,
|
||||
adr.Land AS custCountry,
|
||||
COALESCE(dadr.Strasse, adr.Strasse) AS delivStreet,
|
||||
COALESCE(dadr.Hausnummer, adr.Hausnummer) AS delivHouseNumber,
|
||||
LTRIM(RTRIM(COALESCE(dadr.PLZ, adr.PLZ))) AS delivPostalCode,
|
||||
COALESCE(dadr.Ort, adr.Ort) AS delivCity,
|
||||
COALESCE(dadr.Land, adr.Land) AS delivCountry,
|
||||
LTRIM(RTRIM(CAST(bk._Uhrzeit_Txt AS varchar(200)))) AS desiredTime,
|
||||
LTRIM(RTRIM(CAST(bk.Kopftext AS varchar(MAX)))) AS specialAgreements,
|
||||
CAST(ISNULL(bk._Anz_Bestellung, 0) AS FLOAT) AS prepaidAmount,
|
||||
LTRIM(RTRIM(z.Zahlungsbedingung)) AS paymentZahlbed,
|
||||
z.Bezeichnung AS paymentZahlbedText,
|
||||
CAST(bz.BelegzeilenNr AS INT) AS belegzeilenNr,
|
||||
CAST(NULL AS varchar(50)) AS komponentenArtikelNr,
|
||||
CAST(NULL AS varchar(50)) AS parentArtikelNr,
|
||||
a.Artikelnummer AS articleNumber,
|
||||
COALESCE(NULLIF(LTRIM(RTRIM(bz.ArtikelBezeichnung)), ''), a.Artikelbezeichnung)
|
||||
AS articleName,
|
||||
CASE WHEN EXISTS (SELECT 1 FROM Stuecklisten sk
|
||||
WHERE sk.ArtikelID = a.row_id AND sk.StueckListenId = 0)
|
||||
THEN CAST(0 AS BIT)
|
||||
WHEN UPPER(LTRIM(RTRIM(ag.[Bestandsführung]))) IN ('1','J','Y','T','X')
|
||||
THEN CAST(1 AS BIT)
|
||||
ELSE CAST(0 AS BIT) END AS articleScannable,
|
||||
lv.code AS warehouseCode,
|
||||
COALESCE(ls.lagerbezeichnung, lv.code) AS warehouseName,
|
||||
CAST(bz.Menge AS INT) AS requiredQuantity,
|
||||
CAST(bz.EinzelPreisBrutto AS FLOAT) AS unitPrice,
|
||||
{ADDR}
|
||||
-- Trailing-Anker: damit der letzte `,` im Adressblock syntaktisch ok ist.
|
||||
CAST(1 AS BIT) AS _addrBlockMarker
|
||||
FROM Belegkopf bk
|
||||
JOIN Adressen adr ON bk.AdressId = adr.ROW_ID
|
||||
LEFT JOIN Adressen dadr ON bk.LieferAdressId = dadr.ROW_ID
|
||||
LEFT JOIN Adressen radr ON bk.RechnungsAdressId = radr.ROW_ID
|
||||
LEFT JOIN Adressen kadr ON bk.AnsprechpartnerId = kadr.ROW_ID
|
||||
JOIN Belegzeilen bz ON bk.row_id = bz.ParentID
|
||||
JOIN Personalstamm ps ON ps.Personalnummer = bk.Vertreter
|
||||
JOIN Artikel a ON a.row_id = bz.ArtikelId
|
||||
JOIN Artikelgruppen ag ON a.ArtikelGruppenId = ag.ROW_ID
|
||||
JOIN Zahlungsbedingungen z ON z.ROW_ID = bk.ZahlungsbedingungId
|
||||
JOIN Belegarten ba ON ba.row_id = bk.BelegartId
|
||||
LEFT JOIN Kunden k ON k.row_id = bk.KundenId
|
||||
LEFT JOIN Adressen sadr ON k.AdressId = sadr.ROW_ID
|
||||
CROSS APPLY (SELECT COALESCE(
|
||||
NULLIF(LTRIM(RTRIM(
|
||||
CAST(CAST(bz.Lagerverteilung AS varchar(max)) AS XML)
|
||||
.value('(/Root/Row/Lager)[1]', 'varchar(20)'))), ''),
|
||||
LTRIM(RTRIM(bk.Lager))) AS code) lv
|
||||
LEFT JOIN Lagerstammdaten ls ON ls.lagernummer = lv.code
|
||||
WHERE ba.Belegart = 'VL5'
|
||||
AND bz.ArtikelId IS NOT NULL
|
||||
AND CAST(bk.Termin AS DATE) = @P1
|
||||
-- Abschluss-Artefakte ausschließen, damit ein erneuter Sync auch nach
|
||||
-- Rückschreiben eines Abschlusses robust bleibt: Gutschrift-/Storno-
|
||||
-- Zeilen (negativer Bruttopreis) und vollständig entfernte Positionen
|
||||
-- (Menge 0). Sonst Crash gegen unit_price>=0 / required_quantity>0.
|
||||
AND CAST(ISNULL(bz.EinzelPreisBrutto, 0) AS FLOAT) >= 0
|
||||
AND ISNULL(bz.Menge, 0) > 0
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
TRY_CAST(LTRIM(RTRIM(bk.Vertreter)) AS BIGINT) AS driverPersonalnummer,
|
||||
CAST(bk.BelegartId AS BIGINT) AS belegartId,
|
||||
LTRIM(RTRIM(ba.Belegart)) AS belegartCode,
|
||||
LTRIM(RTRIM(ba.Bezeichnung)) AS belegartName,
|
||||
LTRIM(RTRIM(bk.Belegnummer)) AS belegnummer,
|
||||
COALESCE(TRY_CAST(LTRIM(RTRIM(k.Kundennummer)) AS BIGINT),
|
||||
CAST(bk.KundenId AS BIGINT)) AS erpCustomerId,
|
||||
adr.Name1 AS customerName,
|
||||
adr.Strasse AS custStreet,
|
||||
adr.Hausnummer AS custHouseNumber,
|
||||
LTRIM(RTRIM(adr.PLZ)) AS custPostalCode,
|
||||
adr.Ort AS custCity,
|
||||
adr.Land AS custCountry,
|
||||
COALESCE(dadr.Strasse, adr.Strasse) AS delivStreet,
|
||||
COALESCE(dadr.Hausnummer, adr.Hausnummer) AS delivHouseNumber,
|
||||
LTRIM(RTRIM(COALESCE(dadr.PLZ, adr.PLZ))) AS delivPostalCode,
|
||||
COALESCE(dadr.Ort, adr.Ort) AS delivCity,
|
||||
COALESCE(dadr.Land, adr.Land) AS delivCountry,
|
||||
LTRIM(RTRIM(CAST(bk._Uhrzeit_Txt AS varchar(200)))) AS desiredTime,
|
||||
LTRIM(RTRIM(CAST(bk.Kopftext AS varchar(MAX)))) AS specialAgreements,
|
||||
CAST(ISNULL(bk._Anz_Bestellung, 0) AS FLOAT) AS prepaidAmount,
|
||||
LTRIM(RTRIM(z.Zahlungsbedingung)) AS paymentZahlbed,
|
||||
z.Bezeichnung AS paymentZahlbedText,
|
||||
CAST(bz.BelegzeilenNr AS INT) AS belegzeilenNr,
|
||||
LTRIM(RTRIM(ka.Artikelnummer)) AS komponentenArtikelNr,
|
||||
LTRIM(RTRIM(a.Artikelnummer)) AS parentArtikelNr,
|
||||
ka.Artikelnummer AS articleNumber,
|
||||
COALESCE(NULLIF(LTRIM(RTRIM(ka.Artikelbezeichnung)), ''), ka.Artikelnummer)
|
||||
AS articleName,
|
||||
CASE WHEN UPPER(LTRIM(RTRIM(kag.[Bestandsführung]))) IN ('1','J','Y','T','X')
|
||||
THEN CAST(1 AS BIT) ELSE CAST(0 AS BIT) END AS articleScannable,
|
||||
lv.code AS warehouseCode,
|
||||
COALESCE(ls.lagerbezeichnung, lv.code) AS warehouseName,
|
||||
CAST(bz.Menge * stl_pos.Menge AS INT) AS requiredQuantity,
|
||||
CAST(0 AS FLOAT) AS unitPrice,
|
||||
{ADDR}
|
||||
CAST(1 AS BIT) AS _addrBlockMarker
|
||||
FROM Belegkopf bk
|
||||
JOIN Adressen adr ON bk.AdressId = adr.ROW_ID
|
||||
LEFT JOIN Adressen dadr ON bk.LieferAdressId = dadr.ROW_ID
|
||||
LEFT JOIN Adressen radr ON bk.RechnungsAdressId = radr.ROW_ID
|
||||
LEFT JOIN Adressen kadr ON bk.AnsprechpartnerId = kadr.ROW_ID
|
||||
JOIN Belegzeilen bz ON bk.row_id = bz.ParentID
|
||||
JOIN Personalstamm ps ON ps.Personalnummer = bk.Vertreter
|
||||
JOIN Artikel a ON a.row_id = bz.ArtikelId
|
||||
JOIN Zahlungsbedingungen z ON z.ROW_ID = bk.ZahlungsbedingungId
|
||||
JOIN Belegarten ba ON ba.row_id = bk.BelegartId
|
||||
LEFT JOIN Kunden k ON k.row_id = bk.KundenId
|
||||
LEFT JOIN Adressen sadr ON k.AdressId = sadr.ROW_ID
|
||||
JOIN Stuecklisten stl_kopf ON stl_kopf.ArtikelID = a.row_id
|
||||
AND stl_kopf.StueckListenId = 0
|
||||
JOIN Stuecklisten stl_pos ON stl_pos.StueckListenId = stl_kopf.ROW_ID
|
||||
JOIN Artikel ka ON ka.row_id = stl_pos.ArtikelID
|
||||
LEFT JOIN Artikelgruppen kag ON ka.ArtikelGruppenId = kag.ROW_ID
|
||||
CROSS APPLY (SELECT COALESCE(
|
||||
NULLIF(LTRIM(RTRIM(
|
||||
CAST(CAST(bz.Lagerverteilung AS varchar(max)) AS XML)
|
||||
.value('(/Root/Row/Lager)[1]', 'varchar(20)'))), ''),
|
||||
LTRIM(RTRIM(bk.Lager))) AS code) lv
|
||||
LEFT JOIN Lagerstammdaten ls ON ls.lagernummer = lv.code
|
||||
WHERE ba.Belegart = 'VL5'
|
||||
AND bz.ArtikelId IS NOT NULL
|
||||
AND CAST(bk.Termin AS DATE) = @P1
|
||||
-- Abschluss-Artefakte ausschließen, damit ein erneuter Sync auch nach
|
||||
-- Rückschreiben eines Abschlusses robust bleibt: Gutschrift-/Storno-
|
||||
-- Zeilen (negativer Bruttopreis) und vollständig entfernte Positionen
|
||||
-- (Menge 0). Sonst Crash gegen unit_price>=0 / required_quantity>0.
|
||||
AND CAST(ISNULL(bz.EinzelPreisBrutto, 0) AS FLOAT) >= 0
|
||||
AND ISNULL(bz.Menge, 0) > 0
|
||||
|
||||
ORDER BY driverPersonalnummer, belegartId, belegnummer, belegzeilenNr, komponentenArtikelNr
|
||||
"#;
|
||||
|
||||
/// Eine flache Belegzeile, wie sie aus der Query kommt.
|
||||
struct ErpRow {
|
||||
driver_personalnummer: i64,
|
||||
belegart_id: i64,
|
||||
belegart_code: Option<String>,
|
||||
belegart_name: Option<String>,
|
||||
belegnummer: String,
|
||||
erp_customer_id: i64,
|
||||
customer_name: String,
|
||||
cust: Address,
|
||||
deliv: Address,
|
||||
desired_time: Option<String>,
|
||||
special_agreements: Option<String>,
|
||||
prepaid_amount: f64,
|
||||
payment_zahlbed: String,
|
||||
payment_zahlbed_text: String,
|
||||
belegzeilen_nr: i32,
|
||||
komponenten_artikel_nr: Option<String>,
|
||||
parent_artikel_nr: Option<String>,
|
||||
article_number: String,
|
||||
article_name: String,
|
||||
article_scannable: bool,
|
||||
warehouse_code: String,
|
||||
warehouse_name: String,
|
||||
required_quantity: i32,
|
||||
unit_price: f64,
|
||||
/// Pro-Beleg Kontaktquellen (aus allen 5 Adress-FKs). Auf jeder Item-Zeile
|
||||
/// derselben Lieferung identisch; der Grouper übernimmt sie aus der
|
||||
/// ersten Zeile und ignoriert die der Folgezeilen.
|
||||
contact_sources: Vec<SyncContactSource>,
|
||||
}
|
||||
|
||||
fn s(row: &tiberius::Row, col: &str) -> String {
|
||||
row.get::<&str, _>(col).unwrap_or("").trim().to_string()
|
||||
}
|
||||
fn opt_s(row: &tiberius::Row, col: &str) -> Option<String> {
|
||||
match row.get::<&str, _>(col) {
|
||||
Some(v) if !v.trim().is_empty() => Some(v.trim().to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn i64c(row: &tiberius::Row, col: &str) -> i64 {
|
||||
row.get::<i64, _>(col).unwrap_or(0)
|
||||
}
|
||||
fn i32c(row: &tiberius::Row, col: &str) -> i32 {
|
||||
row.get::<i32, _>(col).unwrap_or(0)
|
||||
}
|
||||
fn f64c(row: &tiberius::Row, col: &str) -> f64 {
|
||||
row.get::<f64, _>(col).unwrap_or(0.0)
|
||||
}
|
||||
fn boolc(row: &tiberius::Row, col: &str) -> bool {
|
||||
row.get::<bool, _>(col).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Liest aus einem Tiberius-Row die Kontaktdaten **einer** Adress-Rolle aus
|
||||
/// (alle Spalten heißen `<präfix>_<spalte>`, siehe [`address_select_block`]).
|
||||
/// Gibt `None` zurück, wenn weder ein Namensfeld noch ein Kontaktkanal
|
||||
/// belegt ist — leere Adressen werden so vom Sync nicht mitgeschleppt.
|
||||
fn read_contact_source(
|
||||
row: &tiberius::Row,
|
||||
prefix: &str,
|
||||
role: ContactRole,
|
||||
) -> Option<SyncContactSource> {
|
||||
let get = |col: &str| opt_s(row, &format!("{prefix}_{col}"));
|
||||
let anrede = get("Anrede");
|
||||
let titel = get("Titel");
|
||||
let name1 = get("Name1");
|
||||
let name2 = get("Name2");
|
||||
let name3 = get("Name3");
|
||||
let abteilung = get("Abteilung");
|
||||
let funktion = get("Funktion");
|
||||
|
||||
let mut channels: Vec<SyncContactChannel> = Vec::new();
|
||||
// 1-basierte Position spiegelt die ERP-Spaltennummerierung
|
||||
// (`Telefon` → 1, `Telefon2` → 2, …). Spalten-Reihenfolge identisch zu
|
||||
// [`ADDRESS_CONTACT_COLUMNS`].
|
||||
let push = |chans: &mut Vec<SyncContactChannel>,
|
||||
kind: ContactKind,
|
||||
pos: i16,
|
||||
value: Option<String>| {
|
||||
if let Some(v) = value {
|
||||
chans.push(SyncContactChannel {
|
||||
kind,
|
||||
position: pos,
|
||||
value: v,
|
||||
});
|
||||
}
|
||||
};
|
||||
push(&mut channels, ContactKind::Phone, 1, get("Telefon"));
|
||||
push(&mut channels, ContactKind::Phone, 2, get("Telefon2"));
|
||||
push(&mut channels, ContactKind::Phone, 3, get("Telefon3"));
|
||||
push(&mut channels, ContactKind::Phone, 4, get("Telefon4"));
|
||||
push(&mut channels, ContactKind::Mobile, 1, get("Mobiltel"));
|
||||
push(&mut channels, ContactKind::Mobile, 2, get("Mobiltel2"));
|
||||
push(&mut channels, ContactKind::Email, 1, get("EMail"));
|
||||
push(&mut channels, ContactKind::Email, 2, get("EMail2"));
|
||||
push(&mut channels, ContactKind::Email, 3, get("EMail3"));
|
||||
push(&mut channels, ContactKind::Web, 1, get("InternetAdresse"));
|
||||
|
||||
let has_name = anrede.is_some()
|
||||
|| titel.is_some()
|
||||
|| name1.is_some()
|
||||
|| name2.is_some()
|
||||
|| name3.is_some()
|
||||
|| abteilung.is_some()
|
||||
|| funktion.is_some();
|
||||
if channels.is_empty() && !has_name {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(SyncContactSource {
|
||||
role,
|
||||
anrede,
|
||||
titel,
|
||||
name1,
|
||||
name2,
|
||||
name3,
|
||||
abteilung,
|
||||
funktion,
|
||||
channels,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_all_contact_sources(row: &tiberius::Row) -> Vec<SyncContactSource> {
|
||||
[
|
||||
("hdr", ContactRole::Header),
|
||||
("dlv", ContactRole::Delivery),
|
||||
("bll", ContactRole::Billing),
|
||||
("ctp", ContactRole::ContactPerson),
|
||||
("cms", ContactRole::CustomerMaster),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(prefix, role)| read_contact_source(row, prefix, role))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn map_row(row: &tiberius::Row) -> ErpRow {
|
||||
ErpRow {
|
||||
driver_personalnummer: i64c(row, "driverPersonalnummer"),
|
||||
belegart_id: i64c(row, "belegartId"),
|
||||
belegart_code: opt_s(row, "belegartCode"),
|
||||
belegart_name: opt_s(row, "belegartName"),
|
||||
belegnummer: s(row, "belegnummer"),
|
||||
erp_customer_id: i64c(row, "erpCustomerId"),
|
||||
customer_name: s(row, "customerName"),
|
||||
cust: Address {
|
||||
street: s(row, "custStreet"),
|
||||
house_number: s(row, "custHouseNumber"),
|
||||
postal_code: s(row, "custPostalCode"),
|
||||
city: s(row, "custCity"),
|
||||
country: s(row, "custCountry"),
|
||||
},
|
||||
deliv: Address {
|
||||
street: s(row, "delivStreet"),
|
||||
house_number: s(row, "delivHouseNumber"),
|
||||
postal_code: s(row, "delivPostalCode"),
|
||||
city: s(row, "delivCity"),
|
||||
country: s(row, "delivCountry"),
|
||||
},
|
||||
desired_time: opt_s(row, "desiredTime"),
|
||||
special_agreements: opt_s(row, "specialAgreements"),
|
||||
prepaid_amount: f64c(row, "prepaidAmount"),
|
||||
payment_zahlbed: s(row, "paymentZahlbed"),
|
||||
payment_zahlbed_text: s(row, "paymentZahlbedText"),
|
||||
belegzeilen_nr: i32c(row, "belegzeilenNr"),
|
||||
komponenten_artikel_nr: opt_s(row, "komponentenArtikelNr"),
|
||||
parent_artikel_nr: opt_s(row, "parentArtikelNr"),
|
||||
article_number: s(row, "articleNumber"),
|
||||
article_name: s(row, "articleName"),
|
||||
article_scannable: boolc(row, "articleScannable"),
|
||||
warehouse_code: s(row, "warehouseCode"),
|
||||
warehouse_name: s(row, "warehouseName"),
|
||||
required_quantity: i32c(row, "requiredQuantity"),
|
||||
unit_price: f64c(row, "unitPrice"),
|
||||
contact_sources: read_all_contact_sources(row),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mapping ERP-Zahlungsbedingung-Code → Backend-Code.
|
||||
///
|
||||
/// Holzleitner bietet im Liefer-Flow exakt drei Zahlungsbedingungen an
|
||||
/// (Allowlist in DOCUframe-Makro `_web_getPaymentMethods.dfm`); ihre
|
||||
/// Bedeutung stammt aus der ERP-Tabelle `Zahlungsbedingungen` (live verifiziert):
|
||||
/// * `D16` = „Zahlung bei Lieferung (Barzahlung)" → cash
|
||||
/// * `d53` = „Zahlung bei Lieferung mit EC-Karte" → ec_card
|
||||
/// * `D10` = „14 Tage netto" → invoice
|
||||
/// `credit_card` kommt im Liefer-Flow nicht vor. Unbekannte Codes → `None`
|
||||
/// (Backend defaultet dann auf `cash`).
|
||||
fn map_payment_code(code: &str, _text: &str) -> Option<String> {
|
||||
match code.trim().to_uppercase().as_str() {
|
||||
"D16" => Some("cash".to_string()),
|
||||
"D53" => Some("ec_card".to_string()),
|
||||
"D10" => Some("invoice".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn item_from(row: &ErpRow) -> SyncDeliveryItem {
|
||||
SyncDeliveryItem {
|
||||
belegzeilen_nr: row.belegzeilen_nr,
|
||||
komponenten_artikel_nr: row.komponenten_artikel_nr.clone(),
|
||||
parent_artikel_nr: row.parent_artikel_nr.clone(),
|
||||
article_number: row.article_number.clone(),
|
||||
article_name: row.article_name.clone(),
|
||||
article_default_warehouse_code: None,
|
||||
article_scannable: row.article_scannable,
|
||||
warehouse_code: row.warehouse_code.clone(),
|
||||
warehouse_name: row.warehouse_name.clone(),
|
||||
required_quantity: row.required_quantity,
|
||||
unit_price: row.unit_price,
|
||||
}
|
||||
}
|
||||
|
||||
fn delivery_from(row: &ErpRow, sort_order: i32) -> SyncDelivery {
|
||||
SyncDelivery {
|
||||
belegart_id: row.belegart_id,
|
||||
belegart_code: row.belegart_code.clone(),
|
||||
belegart_name: row.belegart_name.clone(),
|
||||
belegnummer: row.belegnummer.clone(),
|
||||
erp_customer_id: row.erp_customer_id,
|
||||
customer_name: row.customer_name.clone(),
|
||||
customer_address: row.cust.clone(),
|
||||
delivery_address: row.deliv.clone(),
|
||||
sort_order,
|
||||
desired_time: row.desired_time.clone(),
|
||||
special_agreements: row.special_agreements.clone(),
|
||||
prepaid_amount: row.prepaid_amount,
|
||||
payment_method_code: map_payment_code(&row.payment_zahlbed, &row.payment_zahlbed_text),
|
||||
items: vec![item_from(row)],
|
||||
contact_sources: row.contact_sources.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gruppiert die (nach Fahrer/Beleg/Zeile sortierten) Zeilen zu
|
||||
/// `SyncTourRequest` pro Fahrer. Nutzt die Sortierung aus dem ORDER BY:
|
||||
/// gleiche Fahrer/Belege liegen kontiguös.
|
||||
fn group(rows: Vec<ErpRow>, date: NaiveDate) -> Vec<SyncTourRequest> {
|
||||
let mut tours: Vec<SyncTourRequest> = Vec::new();
|
||||
|
||||
for row in &rows {
|
||||
// Tour (Fahrer) finden/anlegen.
|
||||
let need_new_tour = tours
|
||||
.last()
|
||||
.map(|t| t.driver_personalnummer != row.driver_personalnummer)
|
||||
.unwrap_or(true);
|
||||
if need_new_tour {
|
||||
tours.push(SyncTourRequest {
|
||||
driver_personalnummer: row.driver_personalnummer,
|
||||
tour_date: date,
|
||||
deliveries: Vec::new(),
|
||||
});
|
||||
}
|
||||
let tour = tours.last_mut().unwrap();
|
||||
|
||||
// Lieferung (Beleg) finden/anlegen.
|
||||
let need_new_delivery = tour
|
||||
.deliveries
|
||||
.last()
|
||||
.map(|d| d.belegart_id != row.belegart_id || d.belegnummer != row.belegnummer)
|
||||
.unwrap_or(true);
|
||||
if need_new_delivery {
|
||||
let sort_order = tour.deliveries.len() as i32 + 1;
|
||||
tour.deliveries.push(delivery_from(row, sort_order));
|
||||
} else {
|
||||
tour.deliveries.last_mut().unwrap().items.push(item_from(row));
|
||||
}
|
||||
}
|
||||
|
||||
tours
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ErpDeliverySource for MssqlErpDeliverySource {
|
||||
async fn fetch_tours_for_date(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
) -> Result<Vec<SyncTourRequest>, ApplicationError> {
|
||||
let cfg = self.tiberius_config();
|
||||
let addr = format!("{}:{}", self.config.host, self.config.port);
|
||||
|
||||
let tcp = TcpStream::connect(&addr).await.map_err(repo)?;
|
||||
tcp.set_nodelay(true).ok();
|
||||
|
||||
let mut client = Client::connect(cfg, tcp.compat_write())
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
|
||||
let sql = SQL_TEMPLATE.replace("{ADDR}", &address_select_block());
|
||||
let rows = client
|
||||
.query(&sql, &[&date])
|
||||
.await
|
||||
.map_err(repo)?
|
||||
.into_first_result()
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
|
||||
let flat: Vec<ErpRow> = rows.iter().map(map_row).collect();
|
||||
let tours = group(flat, date);
|
||||
|
||||
tracing::info!(
|
||||
%date,
|
||||
rows = rows.len(),
|
||||
tours = tours.len(),
|
||||
"erp_source.fetched"
|
||||
);
|
||||
Ok(tours)
|
||||
}
|
||||
}
|
||||
419
crates/infrastructure/src/erp/mssql_delivery_writeback.rs
Normal file
419
crates/infrastructure/src/erp/mssql_delivery_writeback.rs
Normal file
@ -0,0 +1,419 @@
|
||||
//! MSSQL-Adapter für den `ErpDeliveryWriteback`-Port.
|
||||
//!
|
||||
//! Schreibt einen Lieferabschluss **direkt** in die ERPframe-Basistabellen
|
||||
//! zurück — das Rust-Pendant zu den Alt-Makros `_web_finishDelivery`,
|
||||
//! `_removeArticles` und `_addDiscount`. Alles läuft in **einer** MSSQL-
|
||||
//! Transaktion und ist **idempotent** (Mengen werden absolut gesetzt, die
|
||||
//! Gutschrift als Upsert geführt).
|
||||
//!
|
||||
//! Reihenfolge:
|
||||
//! 1. `Belegkopf.row_id` aus (BelegartId, Belegnummer) auflösen.
|
||||
//! 2. Je Belegzeile die `Menge` auf die ausgelieferte Menge setzen.
|
||||
//! 3. Gutschrift-Zeile (`GUTSCHRIFT10`) anlegen/aktualisieren.
|
||||
//! 4. Belegsummen neu berechnen (`Σ Einzelpreis × Menge`, `Σ Brutto × Menge`).
|
||||
//! 5. `_SV_DELIVERY_DELIVERED_AT` + `_SV_DELIVERY_STATE='geliefert'`.
|
||||
//!
|
||||
//! TODO (bewusst hardcoded, später konfigurierbar/aus Stammdaten):
|
||||
//! Gutschrift-Artikel `GUTSCHRIFT10`, Konto `8726`, Steuerschlüssel `M19`,
|
||||
//! Steuersatz `19`. Die 10-€-Einheit steckt im Artikelpreis (`Preise`).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tiberius::{Client, Config};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt};
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::{ErpDeliveryWriteback, ErpFinishDeliveryCommand};
|
||||
|
||||
use super::mssql_delivery_source::MssqlErpConfig;
|
||||
|
||||
/// Hardcodierte Gutschrift-Stammdaten (TODO: später konfigurierbar).
|
||||
const GUTSCHRIFT_ARTICLE_NUMBER: &str = "GUTSCHRIFT10";
|
||||
const GUTSCHRIFT_KONTO: &str = "8726";
|
||||
const GUTSCHRIFT_STEUERSCHLUESSEL: &str = "M19";
|
||||
const GUTSCHRIFT_STEUERSATZ: i32 = 19;
|
||||
/// Brutto-Faktor (1 + Steuersatz/100) — zum Ableiten des Netto-Einzelpreises
|
||||
/// aus dem Brutto-Gutschriftbetrag.
|
||||
const GUTSCHRIFT_GROSS_FACTOR: f64 = 1.0 + (GUTSCHRIFT_STEUERSATZ as f64) / 100.0;
|
||||
/// Belegzeilentyp (additive Bitmaske) der Gutschrift-Position:
|
||||
/// Artikel (1) + Andruck (32768) + keine Provision (262144) = 294913.
|
||||
/// Ermittelt aus einer manuell in ERPframe angelegten Gutschrift (goldene
|
||||
/// Referenz) — exakt der Typ, den native Gutschrift-Zeilen tragen.
|
||||
const GUTSCHRIFT_BELEGZEILENTYP: i32 = 294_913;
|
||||
|
||||
/// PG-Zahlungsmethode-Code → ERP-`Zahlungsbedingung`-Code. Spiegelt
|
||||
/// `map_payment_code` im Lese-Adapter (Rückrichtung). TODO: später aus
|
||||
/// Stammdaten/Config statt hardcoded.
|
||||
fn erp_zahlbed_code(pg_code: &str) -> Option<&'static str> {
|
||||
match pg_code.trim().to_lowercase().as_str() {
|
||||
"cash" => Some("D16"),
|
||||
"ec_card" => Some("D53"),
|
||||
"invoice" => Some("D10"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MssqlErpDeliveryWriteback {
|
||||
config: MssqlErpConfig,
|
||||
}
|
||||
|
||||
impl MssqlErpDeliveryWriteback {
|
||||
pub fn new(config: MssqlErpConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
fn tiberius_config(&self) -> Config {
|
||||
let mut cfg = Config::new();
|
||||
cfg.host(&self.config.host);
|
||||
cfg.port(self.config.port);
|
||||
cfg.database(&self.config.database);
|
||||
cfg.authentication(tiberius::AuthMethod::sql_server(
|
||||
&self.config.user,
|
||||
&self.config.password,
|
||||
));
|
||||
if self.config.trust_cert {
|
||||
cfg.trust_cert();
|
||||
}
|
||||
cfg
|
||||
}
|
||||
}
|
||||
|
||||
fn repo<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
type TiberiusClient = Client<Compat<TcpStream>>;
|
||||
|
||||
impl MssqlErpDeliveryWriteback {
|
||||
/// Resolved die `Belegkopf.row_id` über den Beleg-Natural-Key.
|
||||
async fn resolve_belegkopf(
|
||||
client: &mut TiberiusClient,
|
||||
belegart_id: i64,
|
||||
belegnummer: &str,
|
||||
) -> Result<i64, ApplicationError> {
|
||||
let rows = client
|
||||
.query(
|
||||
r#"SELECT TOP 1 CAST(row_id AS BIGINT) AS rowId
|
||||
FROM Belegkopf
|
||||
WHERE BelegartId = @P1 AND LTRIM(RTRIM(Belegnummer)) = @P2"#,
|
||||
&[&belegart_id, &belegnummer],
|
||||
)
|
||||
.await
|
||||
.map_err(repo)?
|
||||
.into_first_result()
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
|
||||
let row = rows.first().ok_or_else(|| {
|
||||
ApplicationError::Repository(format!(
|
||||
"Belegkopf nicht gefunden (BelegartId={belegart_id}, Belegnummer='{belegnummer}')"
|
||||
))
|
||||
})?;
|
||||
row.get::<i64, _>("rowId").ok_or_else(|| {
|
||||
ApplicationError::Repository("Belegkopf.row_id ist NULL".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Setzt die Menge einer Belegzeile (absolut, idempotent).
|
||||
async fn set_line_menge(
|
||||
client: &mut TiberiusClient,
|
||||
bk_row_id: i64,
|
||||
belegzeilen_nr: i32,
|
||||
menge: i32,
|
||||
) -> Result<(), ApplicationError> {
|
||||
client
|
||||
.execute(
|
||||
r#"UPDATE Belegzeilen SET Menge = @P1
|
||||
WHERE ParentID = @P2 AND BelegzeilenNr = @P3"#,
|
||||
&[&menge, &bk_row_id, &belegzeilen_nr],
|
||||
)
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gutschrift-Upsert als EINE Belegzeile mit dem **tatsächlichen Betrag**
|
||||
/// (keine 10-€-Einheiten mehr → beliebige Beträge ≤ 150 € exakt abbildbar).
|
||||
/// `amount_cents` = Geld-Gutschrift in Cent (Brutto; 0 = keine).
|
||||
///
|
||||
/// Die Zeile bekommt `Menge = 1` und `Einzelpreis` = -(Brutto / 1,19)
|
||||
/// (negativ → senkt den Belegwert). `EinzelPreisBrutto` ist eine berechnete
|
||||
/// Spalte (Netto × (1+Steuersatz/100)) → ergibt automatisch -(Brutto).
|
||||
async fn upsert_gutschrift(
|
||||
client: &mut TiberiusClient,
|
||||
bk_row_id: i64,
|
||||
amount_cents: i64,
|
||||
) -> Result<(), ApplicationError> {
|
||||
// Netto-Einzelpreis aus dem Brutto-Betrag (negativ). 0 € → leere Zeile.
|
||||
let menge: i32 = if amount_cents > 0 { 1 } else { 0 };
|
||||
let net: f64 = if amount_cents > 0 {
|
||||
-((amount_cents as f64 / 100.0) / GUTSCHRIFT_GROSS_FACTOR)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Existiert bereits eine Gutschrift-Zeile (GUTSCHRIFT10) am Beleg?
|
||||
let existing = client
|
||||
.query(
|
||||
r#"SELECT TOP 1 CAST(bz.row_id AS BIGINT) AS rowId
|
||||
FROM Belegzeilen bz
|
||||
JOIN Artikel a ON a.row_id = bz.ArtikelId
|
||||
WHERE bz.ParentID = @P1 AND a.Artikelnummer = @P2"#,
|
||||
&[&bk_row_id, &GUTSCHRIFT_ARTICLE_NUMBER],
|
||||
)
|
||||
.await
|
||||
.map_err(repo)?
|
||||
.into_first_result()
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
|
||||
if let Some(row) = existing.first() {
|
||||
// Vorhandene Zeile: Menge UND Einzelpreis aktualisieren (Betrag
|
||||
// variabel). Menge 0 ⇒ Zeile zählt im Recalc nicht mehr.
|
||||
let line_row_id = row.get::<i64, _>("rowId").ok_or_else(|| {
|
||||
ApplicationError::Repository("Gutschrift-Belegzeile.row_id ist NULL".to_string())
|
||||
})?;
|
||||
client
|
||||
.execute(
|
||||
"UPDATE Belegzeilen SET Menge = @P1, Einzelpreis = @P2, \
|
||||
Belegzeilentyp = @P3 WHERE row_id = @P4",
|
||||
&[&menge, &net, &GUTSCHRIFT_BELEGZEILENTYP, &line_row_id],
|
||||
)
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Keine Gutschrift nötig und keine vorhandene Zeile → nichts tun.
|
||||
if amount_cents <= 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Neue Gutschrift-Zeile: Artikel-row_id + Name aus dem Stamm holen.
|
||||
let art = client
|
||||
.query(
|
||||
r#"SELECT TOP 1 CAST(row_id AS BIGINT) AS rowId,
|
||||
CAST(Artikelbezeichnung AS NVARCHAR(400)) AS name
|
||||
FROM Artikel WHERE Artikelnummer = @P1"#,
|
||||
&[&GUTSCHRIFT_ARTICLE_NUMBER],
|
||||
)
|
||||
.await
|
||||
.map_err(repo)?
|
||||
.into_first_result()
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
let art = art.first().ok_or_else(|| {
|
||||
ApplicationError::Repository(format!(
|
||||
"Gutschrift-Artikel '{GUTSCHRIFT_ARTICLE_NUMBER}' nicht im ERP gefunden"
|
||||
))
|
||||
})?;
|
||||
let article_row_id = art
|
||||
.get::<i64, _>("rowId")
|
||||
.ok_or_else(|| ApplicationError::Repository("Artikel.row_id ist NULL".into()))?;
|
||||
let article_name = art.get::<&str, _>("name").unwrap_or("Gutschrift").to_string();
|
||||
|
||||
client
|
||||
.execute(
|
||||
r#"INSERT INTO Belegzeilen
|
||||
(ArtikelId, Artikelbezeichnung, Menge, ParentID,
|
||||
Einzelpreis, Steuersatz, Steuerschluessel,
|
||||
Konto, ParentBelegzeilenId, PositionNr, Belegzeilentext,
|
||||
Belegzeilentyp)
|
||||
VALUES (@P1, @P2, @P3, @P4, @P5, @P6, @P7, @P8, 0, 0, @P9, @P10)"#,
|
||||
&[
|
||||
&article_row_id,
|
||||
&article_name,
|
||||
&menge,
|
||||
&bk_row_id,
|
||||
&net,
|
||||
&GUTSCHRIFT_STEUERSATZ,
|
||||
&GUTSCHRIFT_STEUERSCHLUESSEL,
|
||||
&GUTSCHRIFT_KONTO,
|
||||
&"Gutschrift",
|
||||
&GUTSCHRIFT_BELEGZEILENTYP,
|
||||
],
|
||||
)
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Summiert alle Belegzeilen neu und schreibt die Kopf-Summen.
|
||||
async fn recalc_head(
|
||||
client: &mut TiberiusClient,
|
||||
bk_row_id: i64,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let rows = client
|
||||
.query(
|
||||
r#"SELECT CAST(ISNULL(Einzelpreis,0) AS FLOAT) AS net,
|
||||
CAST(ISNULL(EinzelPreisBrutto,0) AS FLOAT) AS gross,
|
||||
CAST(ISNULL(Menge,0) AS FLOAT) AS menge
|
||||
FROM Belegzeilen WHERE ParentID = @P1"#,
|
||||
&[&bk_row_id],
|
||||
)
|
||||
.await
|
||||
.map_err(repo)?
|
||||
.into_first_result()
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
|
||||
let mut net_sum = 0.0_f64;
|
||||
let mut gross_sum = 0.0_f64;
|
||||
for r in &rows {
|
||||
let net = r.get::<f64, _>("net").unwrap_or(0.0);
|
||||
let gross = r.get::<f64, _>("gross").unwrap_or(0.0);
|
||||
let menge = r.get::<f64, _>("menge").unwrap_or(0.0);
|
||||
net_sum += net * menge;
|
||||
gross_sum += gross * menge;
|
||||
}
|
||||
|
||||
client
|
||||
.execute(
|
||||
r#"UPDATE Belegkopf
|
||||
SET WarenwertNetto = @P1, WarenwertBrutto = @P2, PosSummeNetto = @P3
|
||||
WHERE row_id = @P4"#,
|
||||
&[&net_sum, &gross_sum, &net_sum, &bk_row_id],
|
||||
)
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Setzt Liefer-Zeitpunkt + Status. ISO-8601 mit `T` als String
|
||||
/// (impliziter SQL-Cast), exakt wie das Alt-Makro.
|
||||
async fn mark_delivered(
|
||||
client: &mut TiberiusClient,
|
||||
bk_row_id: i64,
|
||||
delivered_at_iso: &str,
|
||||
) -> Result<(), ApplicationError> {
|
||||
client
|
||||
.execute(
|
||||
r#"UPDATE Belegkopf
|
||||
SET _SV_DELIVERY_DELIVERED_AT = @P1, _SV_DELIVERY_STATE = 'geliefert'
|
||||
WHERE row_id = @P2"#,
|
||||
&[&delivered_at_iso, &bk_row_id],
|
||||
)
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Setzt `Belegkopf.ZahlungsbedingungId` anhand der gewählten Zahlungs-
|
||||
/// methode (Code → ERP-Zahlungsbedingung → deren `row_id`). Idempotent
|
||||
/// (absolutes Setzen). Kein Mapping bzw. keine passende Zahlungsbedingung
|
||||
/// im ERP → überspringen (kein Fehler), der Beleg behält seine bisherige.
|
||||
async fn set_payment_condition(
|
||||
client: &mut TiberiusClient,
|
||||
bk_row_id: i64,
|
||||
pg_code: &str,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let Some(erp_code) = erp_zahlbed_code(pg_code) else {
|
||||
tracing::warn!(
|
||||
pg_code,
|
||||
"erp_writeback: kein Zahlungsbedingung-Mapping — übersprungen"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let rows = client
|
||||
.query(
|
||||
r#"SELECT TOP 1 CAST(row_id AS BIGINT) AS rid
|
||||
FROM Zahlungsbedingungen
|
||||
WHERE LTRIM(RTRIM(Zahlungsbedingung)) = @P1"#,
|
||||
&[&erp_code],
|
||||
)
|
||||
.await
|
||||
.map_err(repo)?
|
||||
.into_first_result()
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
|
||||
let Some(rid) = rows.first().and_then(|r| r.get::<i64, _>("rid")) else {
|
||||
tracing::warn!(
|
||||
erp_code,
|
||||
"erp_writeback: Zahlungsbedingung nicht im ERP gefunden — übersprungen"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
client
|
||||
.execute(
|
||||
"UPDATE Belegkopf SET ZahlungsbedingungId = @P1 WHERE row_id = @P2",
|
||||
&[&rid, &bk_row_id],
|
||||
)
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Führt alle Schritte aus (innerhalb der bereits geöffneten Transaktion).
|
||||
async fn run(
|
||||
client: &mut TiberiusClient,
|
||||
cmd: &ErpFinishDeliveryCommand,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let bk = Self::resolve_belegkopf(client, cmd.belegart_id, &cmd.belegnummer).await?;
|
||||
|
||||
for line in &cmd.lines {
|
||||
Self::set_line_menge(client, bk, line.belegzeilen_nr, line.delivered_quantity).await?;
|
||||
}
|
||||
|
||||
Self::upsert_gutschrift(client, bk, cmd.credit_amount_cents).await?;
|
||||
|
||||
Self::recalc_head(client, bk).await?;
|
||||
|
||||
// Gewählte Zahlungsmethode → Zahlungsbedingung im Belegkopf.
|
||||
if let Some(code) = &cmd.payment_method_code {
|
||||
Self::set_payment_condition(client, bk, code).await?;
|
||||
}
|
||||
|
||||
let iso = cmd.delivered_at.format("%Y-%m-%dT%H:%M:%S").to_string();
|
||||
Self::mark_delivered(client, bk, &iso).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ErpDeliveryWriteback for MssqlErpDeliveryWriteback {
|
||||
async fn finish_delivery(
|
||||
&self,
|
||||
cmd: ErpFinishDeliveryCommand,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let cfg = self.tiberius_config();
|
||||
let addr = format!("{}:{}", self.config.host, self.config.port);
|
||||
|
||||
let tcp = TcpStream::connect(&addr).await.map_err(repo)?;
|
||||
tcp.set_nodelay(true).ok();
|
||||
let mut client = Client::connect(cfg, tcp.compat_write()).await.map_err(repo)?;
|
||||
|
||||
client
|
||||
.simple_query("BEGIN TRANSACTION")
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
|
||||
match Self::run(&mut client, &cmd).await {
|
||||
Ok(()) => {
|
||||
client.simple_query("COMMIT").await.map_err(repo)?;
|
||||
tracing::info!(
|
||||
belegart = cmd.belegart_id,
|
||||
belegnummer = %cmd.belegnummer,
|
||||
lines = cmd.lines.len(),
|
||||
credit_cents = cmd.credit_amount_cents,
|
||||
"erp_writeback.committed"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
// Best-effort Rollback; der ursprüngliche Fehler bleibt maßgeblich.
|
||||
let _ = client.simple_query("ROLLBACK").await;
|
||||
tracing::error!(
|
||||
belegart = cmd.belegart_id,
|
||||
belegnummer = %cmd.belegnummer,
|
||||
error = %e,
|
||||
"erp_writeback.rolled_back"
|
||||
);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
100
crates/infrastructure/src/gsd/dto.rs
Normal file
100
crates/infrastructure/src/gsd/dto.rs
Normal file
@ -0,0 +1,100 @@
|
||||
//! Wire-DTOs der GSD/DOCUframe-REST-API.
|
||||
//!
|
||||
//! Alle Antworten sind in einen Umschlag `{ status, data }` verpackt.
|
||||
//! `status.internalStatus` ist der fachliche Status: `"0"` = ok,
|
||||
//! `"201"` = Session ungültig/abgelaufen (→ Re-Login).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const STATUS_OK: &str = "0";
|
||||
pub const STATUS_INVALID_SESSION: &str = "201";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GsdStatus {
|
||||
#[serde(rename = "internalStatus")]
|
||||
pub internal_status: String,
|
||||
#[serde(rename = "statusMessage", default)]
|
||||
pub status_message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GsdEnvelope<T> {
|
||||
pub status: Option<GsdStatus>,
|
||||
pub data: Option<T>,
|
||||
}
|
||||
|
||||
impl<T> GsdEnvelope<T> {
|
||||
/// `true`, wenn der Server eine ungültige Session signalisiert.
|
||||
pub fn is_invalid_session(&self) -> bool {
|
||||
self.status
|
||||
.as_ref()
|
||||
.map(|s| s.internal_status == STATUS_INVALID_SESSION)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// `true`, wenn der fachliche Status ok ist (oder gar kein Status kam).
|
||||
pub fn is_ok(&self) -> bool {
|
||||
match &self.status {
|
||||
Some(s) => s.internal_status == STATUS_OK,
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status_message(&self) -> String {
|
||||
self.status
|
||||
.as_ref()
|
||||
.map(|s| s.status_message.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Login ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginRequest<'a> {
|
||||
pub user: &'a str,
|
||||
/// MD5-Hash des Passworts (GSD-Vorgabe).
|
||||
pub pass: &'a str,
|
||||
#[serde(rename = "appNames")]
|
||||
pub app_names: &'a [String],
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginData {
|
||||
#[serde(rename = "sessionId")]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
// ─── License release ────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReleaseRequest<'a> {
|
||||
#[serde(rename = "appNames")]
|
||||
pub app_names: &'a [String],
|
||||
}
|
||||
|
||||
// ─── Upload (3-stufig) ────────────────────────────────────────────────────
|
||||
|
||||
/// Antwort von `GET /v1/uploadFile` — reservierter Upload-Slot.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UploadIdData {
|
||||
#[serde(rename = "uploadId")]
|
||||
pub upload_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Antwort von `PATCH /v1/uploadFile/{uploadId}` — committetes Attachment.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CommitData {
|
||||
#[serde(rename = "~ObjectID")]
|
||||
pub object_id: Option<String>,
|
||||
}
|
||||
|
||||
// ─── Makro-Aufruf (`/v1/execute/<name>`) ────────────────────────────────────
|
||||
|
||||
/// Request-Body für das Makro `_SV_assignDeliveryReport`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AssignReportRequest<'a> {
|
||||
#[serde(rename = "objectId")]
|
||||
pub object_id: &'a str,
|
||||
pub belegnummer: &'a str,
|
||||
}
|
||||
6
crates/infrastructure/src/gsd/mod.rs
Normal file
6
crates/infrastructure/src/gsd/mod.rs
Normal file
@ -0,0 +1,6 @@
|
||||
//! DOCUframe/GSD-Adapter — Datei-Upload gegen die GSD-REST-API.
|
||||
|
||||
mod dto;
|
||||
pub mod service;
|
||||
|
||||
pub use service::{GsdConfig, GsdService};
|
||||
539
crates/infrastructure/src/gsd/service.rs
Normal file
539
crates/infrastructure/src/gsd/service.rs
Normal file
@ -0,0 +1,539 @@
|
||||
//! DOCUframe-Anbindung (GSD-REST-API) — Datei-Upload + Session-Verwaltung.
|
||||
//!
|
||||
//! Recycelt aus dem alten Proxy-Backend, aber als **typisierter Adapter**
|
||||
//! statt transparentem Proxy. Eine einzige technische Service-Account-Session
|
||||
//! wird wiederverwendet und **durabel in Postgres** (`app_state`) gehalten —
|
||||
//! der GSD-Server blockt pro Session einen Lizenz-Seat bis Ablauf/Release,
|
||||
//! darum darf die Id einen Backend-Neustart nicht verlieren (sonst verwaiste
|
||||
//! Blocks → Lizenz-Lockout).
|
||||
//!
|
||||
//! Session-Lebenszyklus:
|
||||
//! * Erstzugriff: Id aus `app_state` lesen, sonst einloggen.
|
||||
//! * `internalStatus == "201"` (Session tot): Single-Flight-Re-Login,
|
||||
//! neue Id überschreiben.
|
||||
//! * Graceful Shutdown: [`GsdService::release_license`] gibt den Seat frei.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::{AttachmentStorage, DocuframeReportGateway, PreviewImage};
|
||||
|
||||
use super::dto::*;
|
||||
|
||||
const SESSION_KEY: &str = "gsd_session_id";
|
||||
|
||||
/// Interner Fehlertyp, der „Session ungültig" von echten Fehlern trennt,
|
||||
/// damit der Aufrufer gezielt einen Re-Login auslösen kann.
|
||||
enum GsdError {
|
||||
InvalidSession,
|
||||
Other(ApplicationError),
|
||||
}
|
||||
|
||||
impl From<ApplicationError> for GsdError {
|
||||
fn from(e: ApplicationError) -> Self {
|
||||
GsdError::Other(e)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GsdConfig {
|
||||
pub rest_url: String,
|
||||
pub app_key: String,
|
||||
pub user: String,
|
||||
pub password_md5: String,
|
||||
pub app_names: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct GsdService {
|
||||
pool: PgPool,
|
||||
http: reqwest::Client,
|
||||
config: GsdConfig,
|
||||
/// Heißer Cache der Session-Id; Quelle der Wahrheit ist `app_state`.
|
||||
cache: RwLock<Option<String>>,
|
||||
/// Single-Flight-Guard: nie zwei parallele Logins (= zwei Lizenz-Seats).
|
||||
login_lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl GsdService {
|
||||
pub fn new(pool: PgPool, config: GsdConfig) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
http: reqwest::Client::new(),
|
||||
config,
|
||||
cache: RwLock::new(None),
|
||||
login_lock: Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn ext<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::External(e.to_string())
|
||||
}
|
||||
|
||||
fn repo<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
// ─── Session-Store (Postgres-KV) ─────────────────────────────────────
|
||||
|
||||
async fn load_session_from_db(&self) -> Result<Option<String>, ApplicationError> {
|
||||
sqlx::query_scalar("SELECT value FROM app_state WHERE key = $1")
|
||||
.bind(SESSION_KEY)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::repo)
|
||||
}
|
||||
|
||||
async fn store_session_in_db(&self, session: &str) -> Result<(), ApplicationError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO app_state (key, value, updated_at)
|
||||
VALUES ($1, $2, now())
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET value = EXCLUDED.value, updated_at = now()
|
||||
"#,
|
||||
)
|
||||
.bind(SESSION_KEY)
|
||||
.bind(session)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::repo)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn clear_session_in_db(&self) -> Result<(), ApplicationError> {
|
||||
sqlx::query("DELETE FROM app_state WHERE key = $1")
|
||||
.bind(SESSION_KEY)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::repo)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Session-Beschaffung ─────────────────────────────────────────────
|
||||
|
||||
/// Liefert die aktuelle Session-Id — aus dem Cache, sonst aus der DB,
|
||||
/// sonst per Login (Single-Flight).
|
||||
async fn current_session(&self) -> Result<String, ApplicationError> {
|
||||
if let Some(s) = self.cache.read().await.clone() {
|
||||
return Ok(s);
|
||||
}
|
||||
if let Some(s) = self.load_session_from_db().await? {
|
||||
*self.cache.write().await = Some(s.clone());
|
||||
return Ok(s);
|
||||
}
|
||||
// Keine Session bekannt → einloggen (unter Lock, mit Double-Check).
|
||||
let _guard = self.login_lock.lock().await;
|
||||
if let Some(s) = self.cache.read().await.clone() {
|
||||
return Ok(s);
|
||||
}
|
||||
self.do_login().await
|
||||
}
|
||||
|
||||
/// Re-Login nach einer als ungültig erkannten Session. Prüft unter Lock,
|
||||
/// ob inzwischen schon ein anderer Task eine neue Session geholt hat
|
||||
/// (dann wird die genutzt — kein zweiter Seat).
|
||||
async fn relogin(&self, stale: &str) -> Result<String, ApplicationError> {
|
||||
let _guard = self.login_lock.lock().await;
|
||||
if let Some(current) = self.cache.read().await.clone() {
|
||||
if current != stale {
|
||||
return Ok(current);
|
||||
}
|
||||
}
|
||||
self.do_login().await
|
||||
}
|
||||
|
||||
/// Führt den eigentlichen Login aus (Aufrufer hält `login_lock`).
|
||||
async fn do_login(&self) -> Result<String, ApplicationError> {
|
||||
info!("GSD: Login gegen {}", self.config.rest_url);
|
||||
let body = LoginRequest {
|
||||
user: &self.config.user,
|
||||
pass: &self.config.password_md5,
|
||||
app_names: &self.config.app_names,
|
||||
};
|
||||
let resp = self
|
||||
.http
|
||||
.post(format!("{}/v1/login", self.config.rest_url))
|
||||
.header("appKey", &self.config.app_key)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(Self::ext)?;
|
||||
|
||||
let env: GsdEnvelope<LoginData> = resp.json().await.map_err(Self::ext)?;
|
||||
if !env.is_ok() {
|
||||
error!("GSD: Login fehlgeschlagen: {}", env.status_message());
|
||||
return Err(ApplicationError::External(format!(
|
||||
"GSD-Login fehlgeschlagen: {}",
|
||||
env.status_message()
|
||||
)));
|
||||
}
|
||||
let session = env
|
||||
.data
|
||||
.and_then(|d| d.session_id)
|
||||
.ok_or_else(|| ApplicationError::External("GSD-Login ohne sessionId".into()))?;
|
||||
|
||||
*self.cache.write().await = Some(session.clone());
|
||||
self.store_session_in_db(&session).await?;
|
||||
info!("GSD: Neue Session etabliert");
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
// ─── Lizenz freigeben (Graceful Shutdown) ────────────────────────────
|
||||
|
||||
/// Gibt den Lizenz-Seat der aktuellen Session via `/v1/license/release`
|
||||
/// frei und vergisst die Session (Cache + DB). Best-Effort: Fehler
|
||||
/// werden geloggt, aber nicht propagiert — beim Shutdown soll nichts
|
||||
/// hängenbleiben.
|
||||
pub async fn release_license(&self) {
|
||||
let session = match self.cache.read().await.clone() {
|
||||
Some(s) => Some(s),
|
||||
None => self.load_session_from_db().await.ok().flatten(),
|
||||
};
|
||||
let Some(session) = session else {
|
||||
return;
|
||||
};
|
||||
|
||||
let body = ReleaseRequest {
|
||||
app_names: &self.config.app_names,
|
||||
};
|
||||
let result = self
|
||||
.http
|
||||
.post(format!("{}/v1/license/release", self.config.rest_url))
|
||||
.header("appKey", &self.config.app_key)
|
||||
.header("sessionId", &session)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => info!("GSD: Lizenz freigegeben"),
|
||||
Err(e) => warn!("GSD: Lizenz-Freigabe fehlgeschlagen (ignoriert): {}", e),
|
||||
}
|
||||
|
||||
*self.cache.write().await = None;
|
||||
if let Err(e) = self.clear_session_in_db().await {
|
||||
warn!("GSD: Session konnte nicht aus DB gelöscht werden: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Datei-Upload (3-stufig) ─────────────────────────────────────────
|
||||
|
||||
/// Ein vollständiger Upload-Versuch mit einer festen Session. Bei
|
||||
/// `201` in irgendeinem Schritt → `GsdError::InvalidSession`, damit der
|
||||
/// Aufrufer den ganzen Vorgang nach Re-Login wiederholen kann (die
|
||||
/// `uploadId` ist an die Session gebunden).
|
||||
async fn upload_once(
|
||||
&self,
|
||||
session: &str,
|
||||
filename: &str,
|
||||
mime: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<String, GsdError> {
|
||||
// Schritt 1: Slot anfordern.
|
||||
let url = format!("{}/v1/uploadFile", self.config.rest_url);
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.header("appkey", &self.config.app_key)
|
||||
.header("sessionId", session)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| GsdError::Other(Self::ext(e)))?;
|
||||
let env: GsdEnvelope<UploadIdData> =
|
||||
resp.json().await.map_err(|e| GsdError::Other(Self::ext(e)))?;
|
||||
if env.is_invalid_session() {
|
||||
return Err(GsdError::InvalidSession);
|
||||
}
|
||||
let upload_id = env
|
||||
.data
|
||||
.and_then(|d| d.upload_id)
|
||||
.ok_or_else(|| GsdError::Other(ApplicationError::External("GSD: keine uploadId".into())))?;
|
||||
|
||||
// Schritt 2: Bytes als multipart/form-data senden (Feld `file`).
|
||||
let part = reqwest::multipart::Part::bytes(bytes.to_vec())
|
||||
.file_name(filename.to_owned())
|
||||
.mime_str(mime)
|
||||
.map_err(|e| GsdError::Other(Self::ext(e)))?;
|
||||
let form = reqwest::multipart::Form::new().part("file", part);
|
||||
let upload_url = format!("{}/v1/uploadFile/{}", self.config.rest_url, upload_id);
|
||||
let resp = self
|
||||
.http
|
||||
.post(&upload_url)
|
||||
.header("appkey", &self.config.app_key)
|
||||
.header("sessionId", session)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| GsdError::Other(Self::ext(e)))?;
|
||||
let env: GsdEnvelope<serde_json::Value> =
|
||||
resp.json().await.map_err(|e| GsdError::Other(Self::ext(e)))?;
|
||||
if env.is_invalid_session() {
|
||||
return Err(GsdError::InvalidSession);
|
||||
}
|
||||
if !env.is_ok() {
|
||||
return Err(GsdError::Other(ApplicationError::External(format!(
|
||||
"GSD-Upload fehlgeschlagen: {}",
|
||||
env.status_message()
|
||||
))));
|
||||
}
|
||||
|
||||
// Schritt 3: Commit → liefert ~ObjectID.
|
||||
let resp = self
|
||||
.http
|
||||
.patch(&upload_url)
|
||||
.header("appkey", &self.config.app_key)
|
||||
.header("sessionId", session)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| GsdError::Other(Self::ext(e)))?;
|
||||
let env: GsdEnvelope<CommitData> =
|
||||
resp.json().await.map_err(|e| GsdError::Other(Self::ext(e)))?;
|
||||
if env.is_invalid_session() {
|
||||
return Err(GsdError::InvalidSession);
|
||||
}
|
||||
env.data
|
||||
.and_then(|d| d.object_id)
|
||||
.ok_or_else(|| GsdError::Other(ApplicationError::External("GSD: kein ~ObjectID".into())))
|
||||
}
|
||||
|
||||
/// Ein Vorschau-Download-Versuch mit fester Session. Liefert bei einem
|
||||
/// Bild-Content-Type die Bytes; bei JSON-Antwort (typisch für eine
|
||||
/// ungültige Session) → `GsdError::InvalidSession` bzw. ein Fehler.
|
||||
async fn download_preview_once(
|
||||
&self,
|
||||
session: &str,
|
||||
object_id: &str,
|
||||
parameters: &str,
|
||||
page: &str,
|
||||
) -> Result<PreviewImage, GsdError> {
|
||||
let url = format!(
|
||||
"{}/v1/preview/{}/{}/{}",
|
||||
self.config.rest_url, parameters, object_id, page
|
||||
);
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.header("appkey", &self.config.app_key)
|
||||
.header("sessionId", session)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| GsdError::Other(Self::ext(e)))?;
|
||||
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
|
||||
if content_type.starts_with("image/") {
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| GsdError::Other(Self::ext(e)))?
|
||||
.to_vec();
|
||||
return Ok(PreviewImage {
|
||||
bytes,
|
||||
content_type,
|
||||
});
|
||||
}
|
||||
|
||||
// Kein Bild → vermutlich JSON-Fehlerumschlag (z. B. Session ungültig).
|
||||
let env: GsdEnvelope<serde_json::Value> =
|
||||
resp.json().await.map_err(|e| GsdError::Other(Self::ext(e)))?;
|
||||
if env.is_invalid_session() {
|
||||
return Err(GsdError::InvalidSession);
|
||||
}
|
||||
Err(GsdError::Other(ApplicationError::External(format!(
|
||||
"GSD-Preview fehlgeschlagen: {}",
|
||||
env.status_message()
|
||||
))))
|
||||
}
|
||||
|
||||
// ─── Makro-Aufruf (`POST /v1/execute/<name>`) ────────────────────────
|
||||
|
||||
/// Ein Makro-Aufruf mit fester Session. Liefert die rohe JSON-Antwort
|
||||
/// (Envelope ODER flach — der Aufrufer extrahiert die Felder). Bei
|
||||
/// `internalStatus == "201"` → `GsdError::InvalidSession` (Re-Login).
|
||||
async fn execute_macro_once(
|
||||
&self,
|
||||
session: &str,
|
||||
macro_name: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, GsdError> {
|
||||
let url = format!("{}/v1/execute/{}", self.config.rest_url, macro_name);
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("appkey", &self.config.app_key)
|
||||
.header("sessionId", session)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| GsdError::Other(Self::ext(e)))?;
|
||||
let val: serde_json::Value =
|
||||
resp.json().await.map_err(|e| GsdError::Other(Self::ext(e)))?;
|
||||
let invalid = val
|
||||
.get("status")
|
||||
.and_then(|s| s.get("internalStatus"))
|
||||
.and_then(|v| v.as_str())
|
||||
== Some(super::dto::STATUS_INVALID_SESSION);
|
||||
if invalid {
|
||||
return Err(GsdError::InvalidSession);
|
||||
}
|
||||
Ok(val)
|
||||
}
|
||||
|
||||
/// Ruft ein DOCUframe-Makro auf (mit Session + Re-Login-Retry). Liefert die
|
||||
/// rohe JSON-Antwort.
|
||||
pub async fn execute_macro(
|
||||
&self,
|
||||
macro_name: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, ApplicationError> {
|
||||
let session = self.current_session().await?;
|
||||
match self.execute_macro_once(&session, macro_name, body).await {
|
||||
Ok(v) => Ok(v),
|
||||
Err(GsdError::InvalidSession) => {
|
||||
info!("GSD: Session ungültig, Re-Login und erneuter Makro-Aufruf");
|
||||
let fresh = self.relogin(&session).await?;
|
||||
self.execute_macro_once(&fresh, macro_name, body)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
GsdError::InvalidSession => ApplicationError::External(
|
||||
"GSD: Session nach Re-Login weiterhin ungültig".into(),
|
||||
),
|
||||
GsdError::Other(inner) => inner,
|
||||
})
|
||||
}
|
||||
Err(GsdError::Other(inner)) => Err(inner),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AttachmentStorage for GsdService {
|
||||
async fn upload(
|
||||
&self,
|
||||
// DOCUframe legt keine Ordner nach Belegnummer an — der `folder`-
|
||||
// Schlüssel wird hier (vorerst) ignoriert; später ggf. als Kategorie.
|
||||
_folder: &str,
|
||||
filename: &str,
|
||||
mime: &str,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<String, ApplicationError> {
|
||||
let session = self.current_session().await?;
|
||||
match self.upload_once(&session, filename, mime, &bytes).await {
|
||||
Ok(oid) => Ok(oid),
|
||||
Err(GsdError::InvalidSession) => {
|
||||
info!("GSD: Session ungültig, Re-Login und erneuter Upload");
|
||||
let fresh = self.relogin(&session).await?;
|
||||
self.upload_once(&fresh, filename, mime, &bytes)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
GsdError::InvalidSession => ApplicationError::External(
|
||||
"GSD: Session nach Re-Login weiterhin ungültig".into(),
|
||||
),
|
||||
GsdError::Other(inner) => inner,
|
||||
})
|
||||
}
|
||||
Err(GsdError::Other(inner)) => Err(inner),
|
||||
}
|
||||
}
|
||||
|
||||
async fn download_preview(
|
||||
&self,
|
||||
object_id: &str,
|
||||
parameters: &str,
|
||||
page: &str,
|
||||
) -> Result<PreviewImage, ApplicationError> {
|
||||
let session = self.current_session().await?;
|
||||
match self
|
||||
.download_preview_once(&session, object_id, parameters, page)
|
||||
.await
|
||||
{
|
||||
Ok(img) => Ok(img),
|
||||
Err(GsdError::InvalidSession) => {
|
||||
info!("GSD: Session ungültig, Re-Login und erneuter Preview-Download");
|
||||
let fresh = self.relogin(&session).await?;
|
||||
self.download_preview_once(&fresh, object_id, parameters, page)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
GsdError::InvalidSession => ApplicationError::External(
|
||||
"GSD: Session nach Re-Login weiterhin ungültig".into(),
|
||||
),
|
||||
GsdError::Other(inner) => inner,
|
||||
})
|
||||
}
|
||||
Err(GsdError::Other(inner)) => Err(inner),
|
||||
}
|
||||
}
|
||||
|
||||
/// No-Op: In DOCUframe löschen wir nichts (der Report bleibt dort liegen).
|
||||
async fn delete(&self, _reference: &str) -> Result<(), ApplicationError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DocuframeReportGateway for GsdService {
|
||||
async fn upload_report_pdf(
|
||||
&self,
|
||||
belegnummer: &str,
|
||||
pdf: Vec<u8>,
|
||||
) -> Result<String, ApplicationError> {
|
||||
// Report wie ein Bild hochladen — der 3-stufige Upload liefert die
|
||||
// ~ObjectID. `folder` ist für DOCUframe irrelevant.
|
||||
let filename = format!("Lieferbericht-{belegnummer}.pdf");
|
||||
AttachmentStorage::upload(self, belegnummer, &filename, "application/pdf", pdf).await
|
||||
}
|
||||
|
||||
async fn assign_report(
|
||||
&self,
|
||||
object_id: &str,
|
||||
belegnummer: &str,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let body = serde_json::to_value(super::dto::AssignReportRequest {
|
||||
object_id,
|
||||
belegnummer,
|
||||
})
|
||||
.map_err(Self::ext)?;
|
||||
let val = self
|
||||
.execute_macro("_SV_assignDeliveryReport", &body)
|
||||
.await?;
|
||||
// Das Makro liefert `{succeeded, message}` per RETURN(STRING). Wie
|
||||
// `/v1/execute` das verpackt, ist nicht garantiert — daher robust gegen
|
||||
// alle drei Formen: flach, Envelope mit `data`-Objekt, oder `data` als
|
||||
// (escaptem) JSON-String. Wir prüfen die Kandidaten der Reihe nach.
|
||||
let mut candidates: Vec<serde_json::Value> = vec![val.clone()];
|
||||
if let Some(d) = val.get("data") {
|
||||
if d.is_object() {
|
||||
candidates.push(d.clone());
|
||||
} else if let Some(s) = d.as_str() {
|
||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(s) {
|
||||
candidates.push(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
let succeeded = candidates
|
||||
.iter()
|
||||
.find_map(|c| c.get("succeeded").and_then(|v| v.as_bool()))
|
||||
.unwrap_or(false);
|
||||
if succeeded {
|
||||
Ok(())
|
||||
} else {
|
||||
let msg = candidates
|
||||
.iter()
|
||||
.find_map(|c| c.get("message").and_then(|v| v.as_str()))
|
||||
.unwrap_or("unbekannte/keine Antwort (Makro evtl. nicht vorhanden)");
|
||||
Err(ApplicationError::External(format!(
|
||||
"DOCUframe-Makro _SV_assignDeliveryReport succeeded=false: {msg}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -10,4 +10,8 @@
|
||||
//! passenden Application-Ports.
|
||||
|
||||
pub mod auth;
|
||||
pub mod erp;
|
||||
pub mod gsd;
|
||||
pub mod persistence;
|
||||
pub mod report;
|
||||
pub mod storage;
|
||||
|
||||
107
crates/infrastructure/src/persistence/attachment_repository.rs
Normal file
107
crates/infrastructure/src/persistence/attachment_repository.rs
Normal file
@ -0,0 +1,107 @@
|
||||
//! Postgres-Implementierung des `AttachmentRepository`-Ports.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::{
|
||||
AttachmentLocalRef, AttachmentRef, AttachmentRepository, NewAttachment,
|
||||
};
|
||||
|
||||
pub struct PgAttachmentRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgAttachmentRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AttachmentRepository for PgAttachmentRepository {
|
||||
async fn create(&self, attachment: NewAttachment) -> Result<Uuid, ApplicationError> {
|
||||
let id: Uuid = sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO attachments (
|
||||
docuframe_object_id, mime_type, size_bytes, filename,
|
||||
checksum_sha256, width, height, uploaded_by, delivery_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(attachment.docuframe_object_id)
|
||||
.bind(attachment.mime_type)
|
||||
.bind(attachment.size_bytes)
|
||||
.bind(attachment.filename)
|
||||
.bind(attachment.checksum_sha256)
|
||||
.bind(attachment.width)
|
||||
.bind(attachment.height)
|
||||
.bind(attachment.uploaded_by)
|
||||
.bind(attachment.delivery_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn get(&self, id: Uuid) -> Result<Option<AttachmentRef>, ApplicationError> {
|
||||
let row: Option<(String, String)> = sqlx::query_as(
|
||||
"SELECT docuframe_object_id, mime_type FROM attachments WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(row.map(|(docuframe_object_id, mime_type)| AttachmentRef {
|
||||
docuframe_object_id,
|
||||
mime_type,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn delivery_belegnummer(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
) -> Result<Option<String>, ApplicationError> {
|
||||
let belegnummer: Option<String> = sqlx::query_scalar(
|
||||
"SELECT erp_belegnummer FROM deliveries WHERE id = $1",
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(belegnummer)
|
||||
}
|
||||
|
||||
async fn list_active_for_delivery(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
) -> Result<Vec<AttachmentLocalRef>, ApplicationError> {
|
||||
let rows: Vec<(Uuid, String)> = sqlx::query_as(
|
||||
"SELECT id, docuframe_object_id FROM attachments \
|
||||
WHERE delivery_id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, reference)| AttachmentLocalRef { id, reference })
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn mark_deleted(&self, id: Uuid) -> Result<(), ApplicationError> {
|
||||
sqlx::query("UPDATE attachments SET deleted_at = now() WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,515 @@
|
||||
//! Postgres-Implementierung des `DeliveryCompletionRepository`-Ports.
|
||||
//!
|
||||
//! Eine Transaktion, ein Abschluss. Ablauf:
|
||||
//! 1. `SELECT … FOR UPDATE` auf die Lieferung (Lock + aktueller State).
|
||||
//! 2. Idempotenz: schon `completed` mit Abschluss-Zeile → Erfolg zurück.
|
||||
//! 3. Gates: `active`, alle scanbaren Positionen fertig, Notizen bestätigt.
|
||||
//! 4. `INSERT INTO delivery_completions` + `UPDATE deliveries SET state`.
|
||||
//! 5. Frische `Delivery` bauen.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::NaiveDate;
|
||||
use sqlx::{PgPool, Postgres, Transaction};
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::{
|
||||
CompleteDeliveryInput, DeliveryCompletionRepository, ErpWritebackData, ErpWritebackLine,
|
||||
};
|
||||
use holzleitner_domain::{Address, Delivery, DeliveryState};
|
||||
|
||||
pub struct PgDeliveryCompletionRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgDeliveryCompletionRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DeliveryRow {
|
||||
id: Uuid,
|
||||
tour_id: Uuid,
|
||||
erp_belegart_id: i64,
|
||||
erp_belegnummer: String,
|
||||
customer_id: Uuid,
|
||||
snap_street: String,
|
||||
snap_house_number: String,
|
||||
snap_postal_code: String,
|
||||
snap_city: String,
|
||||
snap_country: String,
|
||||
assigned_car_id: Option<Uuid>,
|
||||
desired_time: Option<String>,
|
||||
special_agreements: Option<String>,
|
||||
state: String,
|
||||
prepaid_amount: f64,
|
||||
payment_method_id: Uuid,
|
||||
}
|
||||
|
||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
async fn lock_delivery(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
delivery_id: Uuid,
|
||||
) -> Result<Option<DeliveryRow>, ApplicationError> {
|
||||
sqlx::query_as::<_, DeliveryRow>(
|
||||
r#"
|
||||
SELECT
|
||||
id, tour_id, erp_belegart_id, erp_belegnummer, customer_id,
|
||||
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
|
||||
assigned_car_id, desired_time, special_agreements,
|
||||
state, prepaid_amount, payment_method_id
|
||||
FROM deliveries
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_err(db)
|
||||
}
|
||||
|
||||
async fn load_contacts(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
delivery_id: Uuid,
|
||||
) -> Result<Vec<Uuid>, ApplicationError> {
|
||||
let rows: Vec<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT customer_contact_id FROM delivery_contact_persons WHERE delivery_id = $1",
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_all(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(rows.into_iter().map(|(id,)| id).collect())
|
||||
}
|
||||
|
||||
fn build_delivery(row: DeliveryRow, state: DeliveryState, state_reason: Option<String>, contacts: Vec<Uuid>) -> Delivery {
|
||||
Delivery {
|
||||
id: row.id,
|
||||
tour_id: row.tour_id,
|
||||
erp_belegart_id: row.erp_belegart_id,
|
||||
erp_belegnummer: row.erp_belegnummer,
|
||||
customer_id: row.customer_id,
|
||||
delivery_address_snapshot: Address {
|
||||
street: row.snap_street,
|
||||
house_number: row.snap_house_number,
|
||||
postal_code: row.snap_postal_code,
|
||||
city: row.snap_city,
|
||||
country: row.snap_country,
|
||||
},
|
||||
assigned_car_id: row.assigned_car_id,
|
||||
contact_person_ids: contacts,
|
||||
desired_time: row.desired_time,
|
||||
special_agreements: row.special_agreements,
|
||||
state,
|
||||
state_reason,
|
||||
prepaid_amount: row.prepaid_amount,
|
||||
payment_method_id: row.payment_method_id,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeliveryCompletionRepository for PgDeliveryCompletionRepository {
|
||||
async fn complete(
|
||||
&self,
|
||||
input: CompleteDeliveryInput,
|
||||
) -> Result<Delivery, ApplicationError> {
|
||||
let delivery_id = input.delivery_id;
|
||||
let mut tx = self.pool.begin().await.map_err(db)?;
|
||||
|
||||
let Some(mut row) = lock_delivery(&mut tx, delivery_id).await? else {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::NotFound);
|
||||
};
|
||||
|
||||
// Idempotenz: bereits abgeschlossen + Abschluss-Zeile vorhanden →
|
||||
// unveränderten Erfolg liefern (Netz-Retry nach erfolgreichem Commit).
|
||||
if row.state == "completed" {
|
||||
let exists: Option<Uuid> = sqlx::query_scalar(
|
||||
"SELECT delivery_id FROM delivery_completions WHERE delivery_id = $1",
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
if exists.is_some() {
|
||||
let contacts = load_contacts(&mut tx, delivery_id).await?;
|
||||
tx.commit().await.map_err(db)?;
|
||||
return Ok(build_delivery(row, DeliveryState::Completed, None, contacts));
|
||||
}
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::Validation(
|
||||
"delivery already completed".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if row.state != "active" {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::Validation(
|
||||
"delivery is not active; cannot complete".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Empfangsbestätigung ist immer Pflicht (Doppel-Guard zum Use Case).
|
||||
if !input.receipt_confirmed {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::Validation(
|
||||
"receipt must be confirmed before completion".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Gate 1: alle scanbaren, nicht entfernten Positionen müssen fertig
|
||||
// sein (`done`). Entfernte (`removed`) zählen nicht.
|
||||
let open_scannables: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COUNT(*)
|
||||
FROM delivery_items di
|
||||
JOIN articles a ON a.id = di.article_id
|
||||
WHERE di.delivery_id = $1
|
||||
AND a.scannable = true
|
||||
AND di.scan_status NOT IN ('done', 'removed')
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
if open_scannables > 0 {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::Validation(format!(
|
||||
"{open_scannables} scannable item(s) not yet done; cannot complete"
|
||||
)));
|
||||
}
|
||||
|
||||
// Gate 2: existieren Notizen, muss der Kunde sie bestätigt haben.
|
||||
let note_count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM delivery_notes WHERE delivery_id = $1")
|
||||
.bind(delivery_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
if note_count > 0 && !input.notes_acknowledged {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::Validation(
|
||||
"notes must be acknowledged before completion".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Gate 3: Zahlungsmethode-Override (falls gesetzt) muss existieren UND
|
||||
// aktiv sein. `None` lässt die am Beleg hinterlegte Methode unangetastet.
|
||||
let effective_payment_method_id = match input.payment_method_id {
|
||||
Some(pm_id) => {
|
||||
let active: Option<bool> =
|
||||
sqlx::query_scalar("SELECT active FROM payment_methods WHERE id = $1")
|
||||
.bind(pm_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
match active {
|
||||
None => {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::Validation(
|
||||
"unknown payment method".into(),
|
||||
));
|
||||
}
|
||||
Some(false) => {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::Validation(
|
||||
"payment method is not active".into(),
|
||||
));
|
||||
}
|
||||
Some(true) => pm_id,
|
||||
}
|
||||
}
|
||||
None => row.payment_method_id,
|
||||
};
|
||||
|
||||
// Gate 4: Inkasso-Bestätigung. Besteht beim Abschluss ein offener
|
||||
// Betrag (> 0) UND ist die Methode ein Vor-Ort-Inkasso (Bar/EC), muss
|
||||
// der Fahrer bestätigt haben, dass kassiert wurde. „Auf Rechnung"
|
||||
// (oder offen == 0) ⇒ kein Inkasso, keine Pflicht.
|
||||
//
|
||||
// Offener Betrag = Σ unit_price·(required − credited) − Anzahlung −
|
||||
// Gutschrift — exakt dieselbe Formel wie App-Übersicht & PDF-Report.
|
||||
let warenwert: f64 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COALESCE(
|
||||
SUM(unit_price * GREATEST(required_quantity - credited_quantity, 0)),
|
||||
0
|
||||
)::float8
|
||||
FROM delivery_items
|
||||
WHERE delivery_id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
// Aktuelle Geld-Gutschrift: jüngstes Audit-Event ('set' → Betrag, sonst 0).
|
||||
let credit_cents: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COALESCE((
|
||||
SELECT CASE WHEN action = 'set' THEN amount_cents ELSE 0 END
|
||||
FROM delivery_credit_audit
|
||||
WHERE delivery_id = $1
|
||||
ORDER BY recorded_at DESC
|
||||
LIMIT 1
|
||||
), 0)
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let method_code: String =
|
||||
sqlx::query_scalar("SELECT code FROM payment_methods WHERE id = $1")
|
||||
.bind(effective_payment_method_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let open_euros =
|
||||
(warenwert - row.prepaid_amount - (credit_cents as f64) / 100.0).max(0.0);
|
||||
let open_cents = (open_euros * 100.0).round() as i64;
|
||||
let requires_collection =
|
||||
open_cents > 0 && (method_code == "cash" || method_code == "ec_card");
|
||||
if requires_collection && !input.payment_collected {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::Validation(
|
||||
"offener Betrag nicht als kassiert bestätigt; Abschluss nicht möglich".into(),
|
||||
));
|
||||
}
|
||||
// Snapshot des kassierten Betrags nur, wenn tatsächlich Inkasso anfiel.
|
||||
let collected_amount_cents: Option<i64> =
|
||||
if requires_collection { Some(open_cents) } else { None };
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO delivery_completions (
|
||||
delivery_id, customer_signature_path, driver_signature_path,
|
||||
receipt_confirmed, notes_acknowledged, acknowledged_note_ids,
|
||||
completed_by_personalnummer, completed_by_car_id,
|
||||
payment_collected, collected_amount_cents
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.bind(&input.customer_signature_path)
|
||||
.bind(&input.driver_signature_path)
|
||||
.bind(input.receipt_confirmed)
|
||||
.bind(input.notes_acknowledged)
|
||||
.bind(&input.acknowledged_note_ids)
|
||||
.bind(input.completed_by_personalnummer)
|
||||
.bind(input.completed_by_car_id)
|
||||
.bind(requires_collection && input.payment_collected)
|
||||
.bind(collected_amount_cents)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE deliveries SET state = 'completed', state_reason = NULL, payment_method_id = $2 WHERE id = $1",
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.bind(effective_payment_method_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let contacts = load_contacts(&mut tx, delivery_id).await?;
|
||||
tx.commit().await.map_err(db)?;
|
||||
|
||||
// Rückgabe spiegelt den (ggf. überschriebenen) Stand.
|
||||
row.payment_method_id = effective_payment_method_id;
|
||||
Ok(build_delivery(row, DeliveryState::Completed, None, contacts))
|
||||
}
|
||||
|
||||
async fn load_erp_writeback(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
) -> Result<ErpWritebackData, ApplicationError> {
|
||||
// Beleg-Key + Abschluss-Zeitpunkt in einem JOIN. INNER JOIN auf
|
||||
// delivery_completions ⇒ ohne Abschluss-Zeile keine Zeile (NotFound).
|
||||
let head: Option<(i64, String, chrono::DateTime<chrono::Utc>)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT d.erp_belegart_id, d.erp_belegnummer, c.completed_at
|
||||
FROM deliveries d
|
||||
JOIN delivery_completions c ON c.delivery_id = d.id
|
||||
WHERE d.id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let Some((belegart_id, belegnummer, completed_at)) = head else {
|
||||
return Err(ApplicationError::NotFound);
|
||||
};
|
||||
|
||||
// Ausgelieferte Menge je Belegzeile = required − credited.
|
||||
// NUR Oberartikel/Normalzeilen (komponenten_artikel_nr IS NULL) — sie
|
||||
// entsprechen 1:1 einer ERP-Belegzeile. Stücklisten-Komponenten teilen
|
||||
// sich die belegzeilen_nr des Oberartikels und haben KEINE eigene
|
||||
// ERP-Belegzeile; sie würden sonst mehrfache, widersprüchliche
|
||||
// Mengen-Updates auf dieselbe Zeile auslösen.
|
||||
let line_rows: Vec<(i32, i32)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT belegzeilen_nr, (required_quantity - credited_quantity)::int
|
||||
FROM delivery_items
|
||||
WHERE delivery_id = $1
|
||||
AND komponenten_artikel_nr IS NULL
|
||||
ORDER BY belegzeilen_nr
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let lines = line_rows
|
||||
.into_iter()
|
||||
.map(|(belegzeilen_nr, delivered_quantity)| ErpWritebackLine {
|
||||
belegzeilen_nr,
|
||||
delivered_quantity,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Aktuelle Geld-Gutschrift: jüngstes Audit-Event. 'set' → Betrag,
|
||||
// 'remove' (oder keine Zeile) → 0.
|
||||
let credit: Option<(String, i64)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT action, amount_cents
|
||||
FROM delivery_credit_audit
|
||||
WHERE delivery_id = $1
|
||||
ORDER BY recorded_at DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let credit_amount_cents = match credit {
|
||||
Some((action, cents)) if action == "set" => cents,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
// Beim Abschluss gewählte Zahlungsmethode → Code (cash/ec_card/invoice).
|
||||
let payment_method_code: Option<String> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT pm.code
|
||||
FROM deliveries d
|
||||
JOIN payment_methods pm ON pm.id = d.payment_method_id
|
||||
WHERE d.id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
Ok(ErpWritebackData {
|
||||
belegart_id,
|
||||
belegnummer,
|
||||
// ERP erwartet lokale Zeit; completed_at ist UTC → in lokale
|
||||
// Wanduhrzeit umrechnen und die TZ-Info fallenlassen.
|
||||
delivered_at: completed_at.with_timezone(&chrono::Local).naive_local(),
|
||||
lines,
|
||||
credit_amount_cents,
|
||||
payment_method_code,
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_delivered_belegnummern(
|
||||
&self,
|
||||
day: Option<NaiveDate>,
|
||||
) -> Result<Vec<String>, ApplicationError> {
|
||||
// INNER JOIN auf delivery_completions ⇒ nur ausgelieferte (abgeschlossene)
|
||||
// Lieferungen. `mail_sent_at IS NULL` ⇒ nur noch nicht versendete
|
||||
// (server-seitiges Dedup für den Mailclient). Der optionale Tagesfilter:
|
||||
// bei NULL ($1) ⇒ ALLE offenen über alle Tage; sonst der Berliner
|
||||
// Kalendertag von completed_at (TIMESTAMPTZ = UTC-Instant → AT TIME ZONE
|
||||
// 'Europe/Berlin' → ::date), damit ein Abschluss um 23:30 Ortszeit nicht
|
||||
// fälschlich dem UTC-Folgetag zugeordnet wird.
|
||||
let belegnummern: Vec<String> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT d.erp_belegnummer
|
||||
FROM deliveries d
|
||||
JOIN delivery_completions c ON c.delivery_id = d.id
|
||||
WHERE c.mail_sent_at IS NULL
|
||||
AND ( $1::date IS NULL
|
||||
OR (c.completed_at AT TIME ZONE 'Europe/Berlin')::date = $1 )
|
||||
ORDER BY c.completed_at
|
||||
"#,
|
||||
)
|
||||
.bind(day)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
Ok(belegnummern)
|
||||
}
|
||||
|
||||
async fn mark_mail_sent(
|
||||
&self,
|
||||
belegnummern: &[String],
|
||||
) -> Result<u64, ApplicationError> {
|
||||
if belegnummern.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
// Setzt mail_sent_at nur dort, wo noch NULL (idempotent — erster
|
||||
// Versand-Zeitpunkt bleibt erhalten, mehrfaches Markieren ist harmlos).
|
||||
// Match über die Belegnummer (was der GET-Endpoint zurückgibt).
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE delivery_completions c
|
||||
SET mail_sent_at = now()
|
||||
FROM deliveries d
|
||||
WHERE c.delivery_id = d.id
|
||||
AND d.erp_belegnummer = ANY($1)
|
||||
AND c.mail_sent_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(belegnummern)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
async fn unmark_mail_sent(
|
||||
&self,
|
||||
belegnummern: &[String],
|
||||
) -> Result<u64, ApplicationError> {
|
||||
if belegnummern.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
// DEV: mail_sent_at zurück auf NULL, nur wo aktuell gesetzt.
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE delivery_completions c
|
||||
SET mail_sent_at = NULL
|
||||
FROM deliveries d
|
||||
WHERE c.delivery_id = d.id
|
||||
AND d.erp_belegnummer = ANY($1)
|
||||
AND c.mail_sent_at IS NOT NULL
|
||||
"#,
|
||||
)
|
||||
.bind(belegnummern)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,139 @@
|
||||
//! Postgres-Implementierung des `DeliveryCreditRepository`-Ports.
|
||||
//!
|
||||
//! Append-only: `apply_event` hängt eine Zeile ans `delivery_credit_audit`
|
||||
//! und liest danach den aktuellen Stand (jüngstes Ereignis). Idempotent über
|
||||
//! `client_event_id`; `set`/`remove` nur bei `active`er Lieferung.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{PgPool, Postgres, Transaction};
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::dto::CreditAction;
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::DeliveryCreditRepository;
|
||||
use holzleitner_domain::DeliveryCredit;
|
||||
|
||||
pub struct PgDeliveryCreditRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgDeliveryCreditRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
fn action_str(a: CreditAction) -> &'static str {
|
||||
match a {
|
||||
CreditAction::Set => "set",
|
||||
CreditAction::Remove => "remove",
|
||||
}
|
||||
}
|
||||
|
||||
/// Liest den aktuellen Gutschrift-Stand einer Lieferung = jüngstes Ereignis.
|
||||
/// `set` → `Some(..)`, `remove` (oder kein Ereignis) → `None`.
|
||||
async fn current_credit(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
delivery_id: Uuid,
|
||||
) -> Result<Option<DeliveryCredit>, ApplicationError> {
|
||||
let row: Option<(String, i64, Option<String>)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT action, amount_cents, reason
|
||||
FROM delivery_credit_audit
|
||||
WHERE delivery_id = $1
|
||||
ORDER BY recorded_at DESC, id DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
Ok(match row {
|
||||
Some((action, amount_cents, reason)) if action == "set" => Some(DeliveryCredit {
|
||||
delivery_id,
|
||||
amount_cents,
|
||||
reason: reason.unwrap_or_default(),
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeliveryCreditRepository for PgDeliveryCreditRepository {
|
||||
async fn apply_event(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
client_event_id: Uuid,
|
||||
action: CreditAction,
|
||||
amount_cents: i64,
|
||||
reason: Option<String>,
|
||||
author_personalnummer: i64,
|
||||
author_car_id: Option<Uuid>,
|
||||
) -> Result<Option<DeliveryCredit>, ApplicationError> {
|
||||
let mut tx = self.pool.begin().await.map_err(db)?;
|
||||
|
||||
// Idempotenz: ist die client_event_id schon bekannt, nichts erneut
|
||||
// anwenden — nur den aktuellen Stand liefern (ohne active-Check, das
|
||||
// Ereignis wurde ja bereits akzeptiert).
|
||||
let already: Option<Uuid> = sqlx::query_scalar(
|
||||
"SELECT id FROM delivery_credit_audit WHERE client_event_id = $1",
|
||||
)
|
||||
.bind(client_event_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
if already.is_some() {
|
||||
let current = current_credit(&mut tx, delivery_id).await?;
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Ok(current);
|
||||
}
|
||||
|
||||
// Frisches Ereignis: Lieferung muss existieren und aktiv sein.
|
||||
let state: Option<String> =
|
||||
sqlx::query_scalar("SELECT state FROM deliveries WHERE id = $1 FOR UPDATE")
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
let Some(state) = state else {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::NotFound);
|
||||
};
|
||||
if state != "active" {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::Validation(
|
||||
"delivery is not active; cannot change credit".into(),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO delivery_credit_audit (
|
||||
client_event_id, delivery_id, action, amount_cents, reason,
|
||||
author_personalnummer, author_car_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
"#,
|
||||
)
|
||||
.bind(client_event_id)
|
||||
.bind(delivery_id)
|
||||
.bind(action_str(action))
|
||||
.bind(amount_cents)
|
||||
.bind(reason.as_deref())
|
||||
.bind(author_personalnummer)
|
||||
.bind(author_car_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let current = current_credit(&mut tx, delivery_id).await?;
|
||||
tx.commit().await.map_err(db)?;
|
||||
Ok(current)
|
||||
}
|
||||
}
|
||||
@ -28,6 +28,7 @@ fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
|
||||
#[async_trait]
|
||||
impl DeliveryNoteRepository for PgDeliveryNoteRepository {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn create(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
@ -35,6 +36,8 @@ impl DeliveryNoteRepository for PgDeliveryNoteRepository {
|
||||
author_car_id: Option<Uuid>,
|
||||
text: Option<String>,
|
||||
image_attachment: Option<String>,
|
||||
credit_delivery_item_id: Option<Uuid>,
|
||||
is_amount_credit_note: bool,
|
||||
) -> Result<DeliveryNote, ApplicationError> {
|
||||
let exists: Option<Uuid> =
|
||||
sqlx::query_scalar("SELECT id FROM deliveries WHERE id = $1")
|
||||
@ -49,8 +52,9 @@ impl DeliveryNoteRepository for PgDeliveryNoteRepository {
|
||||
let (id, created_at): (Uuid, DateTime<Utc>) = sqlx::query_as(
|
||||
r#"
|
||||
INSERT INTO delivery_notes (
|
||||
delivery_id, text, image_attachment, author_personalnummer, author_car_id
|
||||
) VALUES ($1, $2, $3, $4, $5)
|
||||
delivery_id, text, image_attachment, author_personalnummer,
|
||||
author_car_id, credit_delivery_item_id, is_amount_credit_note
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, created_at
|
||||
"#,
|
||||
)
|
||||
@ -59,6 +63,8 @@ impl DeliveryNoteRepository for PgDeliveryNoteRepository {
|
||||
.bind(image_attachment.as_deref())
|
||||
.bind(author_personalnummer)
|
||||
.bind(author_car_id)
|
||||
.bind(credit_delivery_item_id)
|
||||
.bind(is_amount_credit_note)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
@ -70,7 +76,86 @@ impl DeliveryNoteRepository for PgDeliveryNoteRepository {
|
||||
image_attachment,
|
||||
author_personalnummer,
|
||||
author_car_id,
|
||||
credit_delivery_item_id,
|
||||
is_amount_credit_note,
|
||||
// Frisch angelegt → Bild (falls vorhanden) liegt lokal vor.
|
||||
image_attachment_deleted: false,
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
note_id: Uuid,
|
||||
text: Option<String>,
|
||||
image_attachment: Option<String>,
|
||||
) -> Result<DeliveryNote, ApplicationError> {
|
||||
// RETURNING liefert die vollständige Zeile zurück — kein zweiter
|
||||
// Read nötig. `fetch_optional` unterscheidet „nicht gefunden" sauber
|
||||
// von DB-Fehlern.
|
||||
let row: Option<(
|
||||
Uuid,
|
||||
Uuid,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
i64,
|
||||
Option<Uuid>,
|
||||
Option<Uuid>,
|
||||
bool,
|
||||
DateTime<Utc>,
|
||||
)> = sqlx::query_as(
|
||||
r#"
|
||||
UPDATE delivery_notes
|
||||
SET text = $2, image_attachment = $3
|
||||
WHERE id = $1
|
||||
RETURNING id, delivery_id, text, image_attachment,
|
||||
author_personalnummer, author_car_id,
|
||||
credit_delivery_item_id, is_amount_credit_note, created_at
|
||||
"#,
|
||||
)
|
||||
.bind(note_id)
|
||||
.bind(text.as_deref())
|
||||
.bind(image_attachment.as_deref())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
match row {
|
||||
None => Err(ApplicationError::NotFound),
|
||||
Some((
|
||||
id,
|
||||
delivery_id,
|
||||
text,
|
||||
image_attachment,
|
||||
author_personalnummer,
|
||||
author_car_id,
|
||||
credit_delivery_item_id,
|
||||
is_amount_credit_note,
|
||||
created_at,
|
||||
)) => Ok(DeliveryNote {
|
||||
id,
|
||||
delivery_id,
|
||||
text,
|
||||
image_attachment,
|
||||
author_personalnummer,
|
||||
author_car_id,
|
||||
credit_delivery_item_id,
|
||||
is_amount_credit_note,
|
||||
image_attachment_deleted: false,
|
||||
created_at,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, note_id: Uuid) -> Result<(), ApplicationError> {
|
||||
let result = sqlx::query("DELETE FROM delivery_notes WHERE id = $1")
|
||||
.bind(note_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApplicationError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,150 @@
|
||||
//! Postgres-Implementierung von `DeliveryReportJobRepository`.
|
||||
//!
|
||||
//! Spiegelt `delivery_report_jobs` — der harte Zustandsanker der
|
||||
//! Report-Übertragung an DOCUframe (für Resume + Cron-Retry).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::{DeliveryReportJobRepository, ReportJob, ReportJobStatus};
|
||||
|
||||
pub struct PgDeliveryReportJobRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgDeliveryReportJobRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct JobRow {
|
||||
delivery_id: Uuid,
|
||||
belegnummer: String,
|
||||
status: String,
|
||||
docuframe_object_id: Option<String>,
|
||||
report_uploaded_at: Option<DateTime<Utc>>,
|
||||
attempts: i32,
|
||||
last_error: Option<String>,
|
||||
}
|
||||
|
||||
impl From<JobRow> for ReportJob {
|
||||
fn from(r: JobRow) -> Self {
|
||||
ReportJob {
|
||||
delivery_id: r.delivery_id,
|
||||
belegnummer: r.belegnummer,
|
||||
status: ReportJobStatus::parse(&r.status),
|
||||
docuframe_object_id: r.docuframe_object_id,
|
||||
report_uploaded_at: r.report_uploaded_at,
|
||||
attempts: r.attempts,
|
||||
last_error: r.last_error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SELECT: &str = "SELECT delivery_id, belegnummer, status, docuframe_object_id, \
|
||||
report_uploaded_at, attempts, last_error FROM delivery_report_jobs";
|
||||
|
||||
#[async_trait]
|
||||
impl DeliveryReportJobRepository for PgDeliveryReportJobRepository {
|
||||
async fn ensure(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
belegnummer: &str,
|
||||
) -> Result<ReportJob, ApplicationError> {
|
||||
// Idempotent: vorhandenen Job NICHT zurücksetzen. DO UPDATE (no-op auf
|
||||
// belegnummer) nur, damit RETURNING auch bei Konflikt die Zeile liefert.
|
||||
let row: JobRow = sqlx::query_as(
|
||||
r#"
|
||||
INSERT INTO delivery_report_jobs (delivery_id, belegnummer)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (delivery_id)
|
||||
DO UPDATE SET belegnummer = EXCLUDED.belegnummer
|
||||
RETURNING delivery_id, belegnummer, status, docuframe_object_id,
|
||||
report_uploaded_at, attempts, last_error
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.bind(belegnummer)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(row.into())
|
||||
}
|
||||
|
||||
async fn get(&self, delivery_id: Uuid) -> Result<Option<ReportJob>, ApplicationError> {
|
||||
let row: Option<JobRow> = sqlx::query_as(&format!("{SELECT} WHERE delivery_id = $1"))
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn list_open(&self) -> Result<Vec<ReportJob>, ApplicationError> {
|
||||
let rows: Vec<JobRow> =
|
||||
sqlx::query_as(&format!("{SELECT} WHERE status <> 'done' ORDER BY created_at"))
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn set_uploaded(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
object_id: &str,
|
||||
) -> Result<(), ApplicationError> {
|
||||
sqlx::query(
|
||||
"UPDATE delivery_report_jobs \
|
||||
SET status = 'uploaded', docuframe_object_id = $2, updated_at = now() \
|
||||
WHERE delivery_id = $1",
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.bind(object_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mark_done(&self, delivery_id: Uuid) -> Result<(), ApplicationError> {
|
||||
sqlx::query(
|
||||
"UPDATE delivery_report_jobs \
|
||||
SET status = 'done', report_uploaded_at = now(), updated_at = now() \
|
||||
WHERE delivery_id = $1",
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn record_error(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
error: &str,
|
||||
) -> Result<(), ApplicationError> {
|
||||
sqlx::query(
|
||||
"UPDATE delivery_report_jobs \
|
||||
SET attempts = attempts + 1, last_error = $2, last_attempt_at = now(), \
|
||||
updated_at = now() \
|
||||
WHERE delivery_id = $1",
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.bind(error)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -43,6 +43,8 @@ struct DeliveryRow {
|
||||
desired_time: Option<String>,
|
||||
special_agreements: Option<String>,
|
||||
state: String,
|
||||
prepaid_amount: f64,
|
||||
payment_method_id: Uuid,
|
||||
}
|
||||
|
||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
@ -81,7 +83,15 @@ fn next_state(
|
||||
use DeliveryState as S;
|
||||
match (current, action) {
|
||||
(S::Active, A::Hold { reason }) => Ok((S::Held, Some(reason.clone()))),
|
||||
(S::Held, A::Resume) => Ok((S::Active, None)),
|
||||
// `Resume` führt sowohl aus `Held` als auch aus `Canceled`
|
||||
// zurück auf `Active`. Die App erzwingt vor Cancel-Recovery
|
||||
// einen extra Bestätigungsdialog; technisch sind beide Pfade
|
||||
// identisch. Der `state_reason` wird in beiden Fällen
|
||||
// gelöscht — der Audit-Trail dazu lebt aktuell nur am Reason
|
||||
// selbst und geht damit verloren. Schließt sich in Phase G
|
||||
// (siehe `docs/BACKEND_MIGRATION.md`): eigenes
|
||||
// `delivery_audit`-Log analog zu `scan_audit`.
|
||||
(S::Held | S::Canceled, A::Resume) => Ok((S::Active, None)),
|
||||
(S::Active | S::Held, A::Cancel { reason }) => Ok((S::Canceled, Some(reason.clone()))),
|
||||
(S::Active, A::Complete) => Ok((S::Completed, None)),
|
||||
|
||||
@ -104,7 +114,7 @@ async fn lock_delivery(
|
||||
id, tour_id, erp_belegart_id, erp_belegnummer, customer_id,
|
||||
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
|
||||
assigned_car_id, desired_time, special_agreements,
|
||||
state
|
||||
state, prepaid_amount, payment_method_id
|
||||
FROM deliveries
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
@ -190,6 +200,8 @@ impl DeliveryRepository for PgDeliveryRepository {
|
||||
special_agreements: row.special_agreements,
|
||||
state: target,
|
||||
state_reason: new_reason,
|
||||
prepaid_amount: row.prepaid_amount,
|
||||
payment_method_id: row.payment_method_id,
|
||||
})
|
||||
}
|
||||
|
||||
@ -245,6 +257,8 @@ impl DeliveryRepository for PgDeliveryRepository {
|
||||
special_agreements: row.special_agreements,
|
||||
state: current,
|
||||
state_reason,
|
||||
prepaid_amount: row.prepaid_amount,
|
||||
payment_method_id: row.payment_method_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,111 @@
|
||||
//! Postgres-Implementierung des `DeliveryServiceRepository`-Ports (Upsert).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{PgPool, Postgres, Transaction};
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::DeliveryServiceRepository;
|
||||
use holzleitner_domain::DeliveryServiceValue;
|
||||
|
||||
pub struct PgDeliveryServiceRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgDeliveryServiceRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
/// Lieferung muss existieren und `active` sein, sonst Reject. Lock auf der
|
||||
/// deliveries-Zeile innerhalb der Transaktion.
|
||||
async fn assert_delivery_active(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
delivery_id: Uuid,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let state: Option<String> =
|
||||
sqlx::query_scalar("SELECT state FROM deliveries WHERE id = $1 FOR UPDATE")
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
match state {
|
||||
None => Err(ApplicationError::NotFound),
|
||||
Some(s) if s != "active" => Err(ApplicationError::Validation(
|
||||
"delivery is not active; cannot change services".into(),
|
||||
)),
|
||||
Some(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeliveryServiceRepository for PgDeliveryServiceRepository {
|
||||
async fn set(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
service_id: Uuid,
|
||||
bool_value: Option<bool>,
|
||||
numeric_value: Option<i32>,
|
||||
author_personalnummer: i64,
|
||||
author_car_id: Option<Uuid>,
|
||||
) -> Result<DeliveryServiceValue, ApplicationError> {
|
||||
let mut tx = self.pool.begin().await.map_err(db)?;
|
||||
assert_delivery_active(&mut tx, delivery_id).await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO delivery_services (
|
||||
delivery_id, service_id, bool_value, numeric_value,
|
||||
author_personalnummer, author_car_id, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, now())
|
||||
ON CONFLICT (delivery_id, service_id) DO UPDATE SET
|
||||
bool_value = EXCLUDED.bool_value,
|
||||
numeric_value = EXCLUDED.numeric_value,
|
||||
author_personalnummer = EXCLUDED.author_personalnummer,
|
||||
author_car_id = EXCLUDED.author_car_id,
|
||||
updated_at = now()
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.bind(service_id)
|
||||
.bind(bool_value)
|
||||
.bind(numeric_value)
|
||||
.bind(author_personalnummer)
|
||||
.bind(author_car_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
tx.commit().await.map_err(db)?;
|
||||
Ok(DeliveryServiceValue {
|
||||
delivery_id,
|
||||
service_id,
|
||||
bool_value,
|
||||
numeric_value,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
service_id: Uuid,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let mut tx = self.pool.begin().await.map_err(db)?;
|
||||
assert_delivery_active(&mut tx, delivery_id).await?;
|
||||
sqlx::query(
|
||||
"DELETE FROM delivery_services WHERE delivery_id = $1 AND service_id = $2",
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.bind(service_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
tx.commit().await.map_err(db)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -5,17 +5,31 @@
|
||||
//! und Migrations werden ebenfalls hier verwaltet.
|
||||
|
||||
pub mod account_repository;
|
||||
pub mod attachment_repository;
|
||||
pub mod car_repository;
|
||||
pub mod delivery_completion_repository;
|
||||
pub mod delivery_credit_repository;
|
||||
pub mod delivery_note_repository;
|
||||
pub mod delivery_report_job_repository;
|
||||
pub mod delivery_repository;
|
||||
pub mod delivery_service_repository;
|
||||
pub mod payment_method_repository;
|
||||
pub mod pool;
|
||||
pub mod scan_repository;
|
||||
pub mod service_repository;
|
||||
pub mod tour_repository;
|
||||
|
||||
pub use account_repository::PgAccountRepository;
|
||||
pub use attachment_repository::PgAttachmentRepository;
|
||||
pub use car_repository::PgCarRepository;
|
||||
pub use delivery_completion_repository::PgDeliveryCompletionRepository;
|
||||
pub use delivery_credit_repository::PgDeliveryCreditRepository;
|
||||
pub use delivery_note_repository::PgDeliveryNoteRepository;
|
||||
pub use delivery_report_job_repository::PgDeliveryReportJobRepository;
|
||||
pub use delivery_repository::PgDeliveryRepository;
|
||||
pub use delivery_service_repository::PgDeliveryServiceRepository;
|
||||
pub use payment_method_repository::PgPaymentMethodRepository;
|
||||
pub use pool::{connect_and_migrate, PoolConfig};
|
||||
pub use scan_repository::PgScanRepository;
|
||||
pub use service_repository::PgServiceRepository;
|
||||
pub use tour_repository::PgTourRepository;
|
||||
|
||||
@ -0,0 +1,174 @@
|
||||
//! Postgres-Implementierung des `PaymentMethodRepository`-Ports.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::PaymentMethodRepository;
|
||||
use holzleitner_domain::PaymentMethod;
|
||||
|
||||
pub struct PgPaymentMethodRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgPaymentMethodRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct PaymentMethodRow {
|
||||
id: Uuid,
|
||||
code: String,
|
||||
name: String,
|
||||
active: bool,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<PaymentMethodRow> for PaymentMethod {
|
||||
fn from(r: PaymentMethodRow) -> Self {
|
||||
PaymentMethod {
|
||||
id: r.id,
|
||||
code: r.code,
|
||||
name: r.name,
|
||||
active: r.active,
|
||||
created_at: r.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
/// Postgres-SQLSTATE-Codes — wir interessieren uns für zwei:
|
||||
///
|
||||
/// * `23505 unique_violation` — `code`-Duplikat beim INSERT
|
||||
/// * `23503 foreign_key_violation` — Lieferungen zeigen noch auf die
|
||||
/// Methode, die gerade gelöscht werden soll (RESTRICT)
|
||||
fn pg_sqlstate(err: &sqlx::Error) -> Option<String> {
|
||||
if let sqlx::Error::Database(db_err) = err {
|
||||
return db_err.code().map(|c| c.into_owned());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PaymentMethodRepository for PgPaymentMethodRepository {
|
||||
async fn list(
|
||||
&self,
|
||||
include_inactive: bool,
|
||||
) -> Result<Vec<PaymentMethod>, ApplicationError> {
|
||||
let rows = sqlx::query_as::<_, PaymentMethodRow>(
|
||||
r#"
|
||||
SELECT id, code, name, active, created_at
|
||||
FROM payment_methods
|
||||
WHERE (active = TRUE OR $1)
|
||||
ORDER BY name
|
||||
"#,
|
||||
)
|
||||
.bind(include_inactive)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(rows.into_iter().map(PaymentMethod::from).collect())
|
||||
}
|
||||
|
||||
async fn find_by_id(
|
||||
&self,
|
||||
id: Uuid,
|
||||
) -> Result<Option<PaymentMethod>, ApplicationError> {
|
||||
let row = sqlx::query_as::<_, PaymentMethodRow>(
|
||||
r#"
|
||||
SELECT id, code, name, active, created_at
|
||||
FROM payment_methods
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(row.map(PaymentMethod::from))
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
code: &str,
|
||||
name: &str,
|
||||
) -> Result<PaymentMethod, ApplicationError> {
|
||||
match sqlx::query_as::<_, PaymentMethodRow>(
|
||||
r#"
|
||||
INSERT INTO payment_methods (code, name)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, code, name, active, created_at
|
||||
"#,
|
||||
)
|
||||
.bind(code)
|
||||
.bind(name)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
{
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(e) if pg_sqlstate(&e).as_deref() == Some("23505") => Err(
|
||||
ApplicationError::Conflict(format!(
|
||||
"payment method with code '{code}' already exists"
|
||||
)),
|
||||
),
|
||||
Err(e) => Err(db(e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
id: Uuid,
|
||||
name: Option<&str>,
|
||||
active: Option<bool>,
|
||||
) -> Result<PaymentMethod, ApplicationError> {
|
||||
// COALESCE-Pattern lässt unveränderte Felder durch die DB-Spalte
|
||||
// fließen — wir senden für „nicht ändern" einfach NULL.
|
||||
let row = sqlx::query_as::<_, PaymentMethodRow>(
|
||||
r#"
|
||||
UPDATE payment_methods
|
||||
SET name = COALESCE($2, name),
|
||||
active = COALESCE($3, active)
|
||||
WHERE id = $1
|
||||
RETURNING id, code, name, active, created_at
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(name)
|
||||
.bind(active)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
row.map(PaymentMethod::from)
|
||||
.ok_or(ApplicationError::NotFound)
|
||||
}
|
||||
|
||||
async fn delete(&self, id: Uuid) -> Result<(), ApplicationError> {
|
||||
match sqlx::query("DELETE FROM payment_methods WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
if result.rows_affected() == 0 {
|
||||
Err(ApplicationError::NotFound)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Err(e) if pg_sqlstate(&e).as_deref() == Some("23503") => Err(
|
||||
ApplicationError::Conflict(
|
||||
"payment method is in use by at least one delivery"
|
||||
.to_string(),
|
||||
),
|
||||
),
|
||||
Err(e) => Err(db(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -38,8 +38,10 @@ impl PgScanRepository {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ItemLockRow {
|
||||
id: Uuid,
|
||||
delivery_id: Uuid,
|
||||
required_quantity: i32,
|
||||
scanned_quantity: i32,
|
||||
credited_quantity: i32,
|
||||
scan_status: String,
|
||||
held_reason: Option<String>,
|
||||
scan_last_updated_at: DateTime<Utc>,
|
||||
@ -47,6 +49,11 @@ struct ItemLockRow {
|
||||
komponenten_artikel_nr: Option<String>,
|
||||
erp_belegart_id: i64,
|
||||
erp_belegnummer: String,
|
||||
/// `articles.scannable` der Position — entscheidet, ob für eine
|
||||
/// Gutschrift erst gescannt (`Done`) sein muss.
|
||||
scannable: bool,
|
||||
/// `deliveries.state` — Gutschriften nur bei `active`.
|
||||
delivery_state: String,
|
||||
}
|
||||
|
||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
@ -81,40 +88,86 @@ fn action_str(a: AuditAction) -> &'static str {
|
||||
AuditAction::Hold => "hold",
|
||||
AuditAction::Unhold => "unhold",
|
||||
AuditAction::Remove => "remove",
|
||||
AuditAction::Unremove => "unremove",
|
||||
}
|
||||
}
|
||||
|
||||
/// Ergebnis einer reinen Zustandsübergangs-Rechnung (ohne DB).
|
||||
struct Transition {
|
||||
/// Signed Δ der SCAN-Menge (+1/-1 bei Scan/Unscan, sonst 0).
|
||||
delta: i32,
|
||||
new_quantity: i32,
|
||||
new_status: ScanStatus,
|
||||
new_held_reason: Option<String>,
|
||||
/// Signed Δ der GUTSCHRIFT-Menge: `Some(+n)` bei Remove, `Some(-n)` bei
|
||||
/// Unremove, sonst `None` (Audit-Spalte bleibt dann NULL).
|
||||
credit_delta: Option<i32>,
|
||||
/// Neuer Stand `credited_quantity` nach der Aktion (für das Item-Update).
|
||||
new_credited_quantity: i32,
|
||||
}
|
||||
|
||||
/// Schnappschuss des relevanten Item-Zustands für die reine
|
||||
/// Übergangs-Rechnung — bündelt die vielen Parameter, die Remove/Unremove
|
||||
/// jetzt brauchen.
|
||||
struct ItemSnapshot<'a> {
|
||||
current_qty: i32,
|
||||
current_credited: i32,
|
||||
current_status: ScanStatus,
|
||||
required_qty: i32,
|
||||
scannable: bool,
|
||||
/// `deliveries.state` als String ('active' / 'held' / …).
|
||||
delivery_state: &'a str,
|
||||
}
|
||||
|
||||
/// Berechnet den nächsten Zustand. Bei `Err` enthält der String die
|
||||
/// fachliche Ablehnungs-Begründung, die 1:1 an die App geht.
|
||||
///
|
||||
/// `quantity` ist nur für Remove/Unremove relevant (Mengen-Gutschrift);
|
||||
/// `None` heißt dort „ganze Restmenge".
|
||||
fn apply_transition(
|
||||
action: AuditAction,
|
||||
current_qty: i32,
|
||||
current_status: ScanStatus,
|
||||
required_qty: i32,
|
||||
item: &ItemSnapshot<'_>,
|
||||
quantity: Option<i32>,
|
||||
reason: Option<&str>,
|
||||
) -> Result<Transition, String> {
|
||||
let current_qty = item.current_qty;
|
||||
let current_status = item.current_status;
|
||||
let required_qty = item.required_qty;
|
||||
let current_credited = item.current_credited;
|
||||
|
||||
match action {
|
||||
AuditAction::Scan => match current_status {
|
||||
ScanStatus::InProgress | ScanStatus::Done => {
|
||||
let new_qty = current_qty + 1;
|
||||
// `quantity = None` → +1 (regulärer Einzel-Barcode-Scan).
|
||||
// `quantity = Some(n)` → n Stück auf einmal (manuelle
|
||||
// Zeilen-Bestätigung der Restmenge). n muss in
|
||||
// [1 .. required − scanned] liegen, damit nicht über das Soll
|
||||
// hinaus „gescannt" wird.
|
||||
let n = match quantity {
|
||||
None => 1,
|
||||
Some(n) => {
|
||||
let remaining = required_qty - current_qty;
|
||||
if n <= 0 || n > remaining {
|
||||
return Err(format!(
|
||||
"invalid scan quantity {n}; remaining scannable {remaining}"
|
||||
));
|
||||
}
|
||||
n
|
||||
}
|
||||
};
|
||||
let new_qty = current_qty + n;
|
||||
let new_status = if new_qty >= required_qty {
|
||||
ScanStatus::Done
|
||||
} else {
|
||||
ScanStatus::InProgress
|
||||
};
|
||||
Ok(Transition {
|
||||
delta: 1,
|
||||
delta: n,
|
||||
new_quantity: new_qty,
|
||||
new_status,
|
||||
new_held_reason: None,
|
||||
credit_delta: None,
|
||||
new_credited_quantity: current_credited,
|
||||
})
|
||||
}
|
||||
ScanStatus::Held => Err("item is on hold; unhold before scanning".into()),
|
||||
@ -131,6 +184,8 @@ fn apply_transition(
|
||||
new_quantity: new_qty,
|
||||
new_status: ScanStatus::InProgress,
|
||||
new_held_reason: None,
|
||||
credit_delta: None,
|
||||
new_credited_quantity: current_credited,
|
||||
})
|
||||
}
|
||||
ScanStatus::Held => Err("item is on hold".into()),
|
||||
@ -142,6 +197,8 @@ fn apply_transition(
|
||||
new_quantity: current_qty,
|
||||
new_status: ScanStatus::Held,
|
||||
new_held_reason: reason.map(str::to_owned),
|
||||
credit_delta: None,
|
||||
new_credited_quantity: current_credited,
|
||||
}),
|
||||
ScanStatus::Held => Err("item is already held".into()),
|
||||
ScanStatus::Removed => Err("item is removed".into()),
|
||||
@ -158,19 +215,101 @@ fn apply_transition(
|
||||
new_quantity: current_qty,
|
||||
new_status,
|
||||
new_held_reason: None,
|
||||
credit_delta: None,
|
||||
new_credited_quantity: current_credited,
|
||||
})
|
||||
}
|
||||
_ => Err("item is not held".into()),
|
||||
},
|
||||
AuditAction::Remove => match current_status {
|
||||
ScanStatus::Removed => Err("item is already removed".into()),
|
||||
_ => Ok(Transition {
|
||||
|
||||
// ── Mengen-Gutschrift ───────────────────────────────────────────
|
||||
// Entscheidungstabelle (siehe Design):
|
||||
// * Lieferung muss `active` sein
|
||||
// * scannbare Position muss `Done` sein (erst verladen)
|
||||
// * Menge muss in [1 .. Restmenge] liegen
|
||||
// * Status → `Removed` erst wenn voll gutgeschrieben
|
||||
AuditAction::Remove => {
|
||||
if item.delivery_state != "active" {
|
||||
return Err("delivery is not active; cannot credit".into());
|
||||
}
|
||||
if item.scannable && current_status != ScanStatus::Done {
|
||||
return Err(
|
||||
"scannable item must be scanned (done) before it can be credited".into(),
|
||||
);
|
||||
}
|
||||
if current_status == ScanStatus::Held {
|
||||
return Err("item is on hold; resume before crediting".into());
|
||||
}
|
||||
let remaining = required_qty - current_credited;
|
||||
let n = quantity.unwrap_or(remaining);
|
||||
if n <= 0 || n > remaining {
|
||||
return Err(format!(
|
||||
"invalid credit quantity {n}; remaining creditable {remaining}"
|
||||
));
|
||||
}
|
||||
let new_credited = current_credited + n;
|
||||
// Teil-Gutschrift lässt den Status unangetastet (Zeile wird
|
||||
// weiter teilweise ausgeliefert); erst die volle Menge macht
|
||||
// die Zeile zu `Removed`. `held_reason` trägt den Grund nur im
|
||||
// Removed-Fall (Embed); die volle Historie steht ohnehin im Audit.
|
||||
let fully = new_credited >= required_qty;
|
||||
let new_status = if fully {
|
||||
ScanStatus::Removed
|
||||
} else {
|
||||
current_status
|
||||
};
|
||||
Ok(Transition {
|
||||
delta: 0,
|
||||
new_quantity: current_qty,
|
||||
new_status: ScanStatus::Removed,
|
||||
new_held_reason: reason.map(str::to_owned),
|
||||
}),
|
||||
new_status,
|
||||
new_held_reason: if fully {
|
||||
reason.map(str::to_owned)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
credit_delta: Some(n),
|
||||
new_credited_quantity: new_credited,
|
||||
})
|
||||
}
|
||||
|
||||
// Gutschrift (teilweise) zurücknehmen. Greift jetzt mengenbasiert:
|
||||
// solange `credited_quantity > 0`, lässt sich etwas wiederherstellen
|
||||
// — unabhängig davon, ob die Zeile schon ganz auf `Removed` stand.
|
||||
AuditAction::Unremove => {
|
||||
if item.delivery_state != "active" {
|
||||
return Err("delivery is not active; cannot restore".into());
|
||||
}
|
||||
if current_credited <= 0 {
|
||||
return Err("nothing credited; nothing to restore".into());
|
||||
}
|
||||
let n = quantity.unwrap_or(current_credited);
|
||||
if n <= 0 || n > current_credited {
|
||||
return Err(format!(
|
||||
"invalid restore quantity {n}; credited {current_credited}"
|
||||
));
|
||||
}
|
||||
let new_credited = current_credited - n;
|
||||
// Aus `Removed` zurück: Status nach Scan-Menge bestimmen.
|
||||
// War die Zeile nur teil-gutgeschrieben (Status z. B. `Done`),
|
||||
// bleibt er, was er war.
|
||||
let new_status = if current_status == ScanStatus::Removed {
|
||||
if current_qty >= required_qty {
|
||||
ScanStatus::Done
|
||||
} else {
|
||||
ScanStatus::InProgress
|
||||
}
|
||||
} else {
|
||||
current_status
|
||||
};
|
||||
Ok(Transition {
|
||||
delta: 0,
|
||||
new_quantity: current_qty,
|
||||
new_status,
|
||||
new_held_reason: None,
|
||||
credit_delta: Some(-n),
|
||||
new_credited_quantity: new_credited,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -182,17 +321,22 @@ async fn lock_item(
|
||||
r#"
|
||||
SELECT
|
||||
di.id,
|
||||
di.delivery_id,
|
||||
di.required_quantity,
|
||||
di.scanned_quantity,
|
||||
di.credited_quantity,
|
||||
di.scan_status,
|
||||
di.held_reason,
|
||||
di.scan_last_updated_at,
|
||||
di.belegzeilen_nr,
|
||||
di.komponenten_artikel_nr,
|
||||
d.erp_belegart_id,
|
||||
d.erp_belegnummer
|
||||
d.erp_belegnummer,
|
||||
a.scannable,
|
||||
d.state AS delivery_state
|
||||
FROM delivery_items di
|
||||
JOIN deliveries d ON d.id = di.delivery_id
|
||||
JOIN articles a ON a.id = di.article_id
|
||||
WHERE di.id = $1
|
||||
FOR UPDATE OF di
|
||||
"#,
|
||||
@ -203,6 +347,104 @@ async fn lock_item(
|
||||
.map_err(db)
|
||||
}
|
||||
|
||||
/// Markiert alle (noch nicht entfernten) Komponenten eines Oberartikels als
|
||||
/// entfernt — gleiche Belegzeile, `komponenten_artikel_nr IS NOT NULL`. Setzt
|
||||
/// volle Gutschrift (credited = required, Status `removed`) und schreibt je
|
||||
/// Komponente einen Audit-Eintrag. Läuft in der Transaktion des auslösenden
|
||||
/// Oberartikel-Removes (durch dessen Idempotenz genau einmal). Komponenten
|
||||
/// haben keine eigene ERP-Belegzeile → kein Einfluss aufs ERP-Rückschreiben.
|
||||
async fn cascade_remove_components(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
parent: &ItemLockRow,
|
||||
event: &ScanEvent,
|
||||
actor_personalnummer: i64,
|
||||
) -> Result<(), ApplicationError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CompRow {
|
||||
id: Uuid,
|
||||
required_quantity: i32,
|
||||
credited_quantity: i32,
|
||||
scanned_quantity: i32,
|
||||
komponenten_artikel_nr: Option<String>,
|
||||
}
|
||||
|
||||
let components = sqlx::query_as::<_, CompRow>(
|
||||
r#"
|
||||
SELECT id, required_quantity, credited_quantity, scanned_quantity,
|
||||
komponenten_artikel_nr
|
||||
FROM delivery_items
|
||||
WHERE delivery_id = $1
|
||||
AND belegzeilen_nr = $2
|
||||
AND komponenten_artikel_nr IS NOT NULL
|
||||
AND scan_status <> 'removed'
|
||||
FOR UPDATE
|
||||
"#,
|
||||
)
|
||||
.bind(parent.delivery_id)
|
||||
.bind(parent.belegzeilen_nr)
|
||||
.fetch_all(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let reason = event
|
||||
.reason
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| "Oberartikel entfernt".to_string());
|
||||
|
||||
for comp in components {
|
||||
let credit_delta = comp.required_quantity - comp.credited_quantity;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE delivery_items
|
||||
SET credited_quantity = required_quantity,
|
||||
scan_status = 'removed',
|
||||
scan_last_updated_at = now()
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(comp.id)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO scan_audit (
|
||||
client_scan_id, delivery_item_id, action,
|
||||
delta, resulting_quantity, resulting_status,
|
||||
reason, actor_personalnummer, actor_car_id, client_scanned_at,
|
||||
erp_belegart_id, erp_belegnummer, erp_belegzeilen_nr,
|
||||
erp_komponenten_artikel_nr,
|
||||
credit_delta, resulting_credited_quantity, manual
|
||||
) VALUES ($1, $2, 'remove', 0, $3, 'removed', $4, $5, $6, $7,
|
||||
$8, $9, $10, $11, $12, $13, false)
|
||||
"#,
|
||||
)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(comp.id)
|
||||
.bind(comp.scanned_quantity)
|
||||
.bind(&reason)
|
||||
.bind(actor_personalnummer)
|
||||
.bind(event.actor_car_id)
|
||||
.bind(event.client_scanned_at)
|
||||
.bind(parent.erp_belegart_id)
|
||||
.bind(&parent.erp_belegnummer)
|
||||
.bind(parent.belegzeilen_nr)
|
||||
.bind(comp.komponenten_artikel_nr.as_deref())
|
||||
.bind(credit_delta)
|
||||
.bind(comp.required_quantity)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ScanRepository for PgScanRepository {
|
||||
async fn apply_one(
|
||||
@ -222,16 +464,46 @@ impl ScanRepository for PgScanRepository {
|
||||
let current_status = parse_status(&item.scan_status)?;
|
||||
let current_state = ScanState {
|
||||
scanned_quantity: item.scanned_quantity,
|
||||
credited_quantity: item.credited_quantity,
|
||||
status: current_status,
|
||||
held_reason: item.held_reason.clone(),
|
||||
last_updated_at: item.scan_last_updated_at,
|
||||
};
|
||||
|
||||
// Idempotenz ZUERST — vor der Transition. Ein Netz-Retry desselben
|
||||
// `client_scan_id` muss „Duplicate" liefern, auch wenn die
|
||||
// (mengenabhängige) Transition inzwischen ablehnen würde (z. B. Item
|
||||
// bereits `done`, Restmenge 0 bei manueller Bestätigung oder
|
||||
// Remove/Unremove). Sonst würde die App ihr optimistisches Update
|
||||
// fälschlich zurückrollen.
|
||||
let already_applied: Option<Uuid> = sqlx::query_scalar(
|
||||
"SELECT id FROM scan_audit WHERE client_scan_id = $1",
|
||||
)
|
||||
.bind(event.client_scan_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
if already_applied.is_some() {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Ok(ApplyScanOutcome::Duplicate {
|
||||
delivery_item_id: item.id,
|
||||
current_state,
|
||||
});
|
||||
}
|
||||
|
||||
let snapshot = ItemSnapshot {
|
||||
current_qty: item.scanned_quantity,
|
||||
current_credited: item.credited_quantity,
|
||||
current_status,
|
||||
required_qty: item.required_quantity,
|
||||
scannable: item.scannable,
|
||||
delivery_state: &item.delivery_state,
|
||||
};
|
||||
|
||||
let transition = match apply_transition(
|
||||
event.action,
|
||||
item.scanned_quantity,
|
||||
current_status,
|
||||
item.required_quantity,
|
||||
&snapshot,
|
||||
event.quantity,
|
||||
event.reason.as_deref(),
|
||||
) {
|
||||
Ok(t) => t,
|
||||
@ -250,8 +522,9 @@ impl ScanRepository for PgScanRepository {
|
||||
delta, resulting_quantity, resulting_status,
|
||||
reason, actor_personalnummer, actor_car_id, client_scanned_at,
|
||||
erp_belegart_id, erp_belegnummer, erp_belegzeilen_nr,
|
||||
erp_komponenten_artikel_nr
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
erp_komponenten_artikel_nr,
|
||||
credit_delta, resulting_credited_quantity, manual
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
ON CONFLICT (client_scan_id) DO NOTHING
|
||||
RETURNING id
|
||||
"#,
|
||||
@ -270,6 +543,11 @@ impl ScanRepository for PgScanRepository {
|
||||
.bind(&item.erp_belegnummer)
|
||||
.bind(item.belegzeilen_nr)
|
||||
.bind(item.komponenten_artikel_nr.as_deref())
|
||||
// credit_delta / resulting_credited_quantity: nur bei Remove/Unremove
|
||||
// gesetzt, sonst NULL (credit_delta == None).
|
||||
.bind(transition.credit_delta)
|
||||
.bind(transition.credit_delta.map(|_| transition.new_credited_quantity))
|
||||
.bind(event.manual)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
@ -289,14 +567,16 @@ impl ScanRepository for PgScanRepository {
|
||||
r#"
|
||||
UPDATE delivery_items
|
||||
SET scanned_quantity = $1,
|
||||
scan_status = $2,
|
||||
held_reason = $3,
|
||||
credited_quantity = $2,
|
||||
scan_status = $3,
|
||||
held_reason = $4,
|
||||
scan_last_updated_at = now()
|
||||
WHERE id = $4
|
||||
WHERE id = $5
|
||||
RETURNING scan_last_updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(transition.new_quantity)
|
||||
.bind(transition.new_credited_quantity)
|
||||
.bind(status_str(transition.new_status))
|
||||
.bind(transition.new_held_reason.as_deref())
|
||||
.bind(item.id)
|
||||
@ -304,12 +584,26 @@ impl ScanRepository for PgScanRepository {
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
// Cascade: wird ein **Oberartikel** entfernt (Position ohne eigene
|
||||
// Komponenten-Nr, die selbst Komponenten unter derselben Belegzeile
|
||||
// hat) und ist dadurch voll `removed`, werden ALLE seine Komponenten
|
||||
// ebenfalls als entfernt markiert — auch wenn sie noch nicht gescannt
|
||||
// sind. Die Set-Entscheidung überschreibt das „scannbar muss done"-Gate.
|
||||
if event.action == AuditAction::Remove
|
||||
&& transition.new_status == ScanStatus::Removed
|
||||
&& item.komponenten_artikel_nr.is_none()
|
||||
{
|
||||
cascade_remove_components(&mut tx, &item, event, actor_personalnummer)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await.map_err(db)?;
|
||||
|
||||
Ok(ApplyScanOutcome::Applied {
|
||||
delivery_item_id: item.id,
|
||||
new_state: ScanState {
|
||||
scanned_quantity: transition.new_quantity,
|
||||
credited_quantity: transition.new_credited_quantity,
|
||||
status: transition.new_status,
|
||||
held_reason: transition.new_held_reason,
|
||||
last_updated_at: new_last_updated,
|
||||
|
||||
196
crates/infrastructure/src/persistence/service_repository.rs
Normal file
196
crates/infrastructure/src/persistence/service_repository.rs
Normal file
@ -0,0 +1,196 @@
|
||||
//! Postgres-Implementierung des `ServiceRepository`-Ports.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::ServiceRepository;
|
||||
use holzleitner_domain::{Service, ServiceKind};
|
||||
|
||||
pub struct PgServiceRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgServiceRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ServiceRow {
|
||||
id: Uuid,
|
||||
key: String,
|
||||
name: String,
|
||||
kind: String,
|
||||
min_value: Option<i32>,
|
||||
max_value: Option<i32>,
|
||||
active: bool,
|
||||
sort_order: i32,
|
||||
}
|
||||
|
||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
fn pg_sqlstate(err: &sqlx::Error) -> Option<String> {
|
||||
if let sqlx::Error::Database(db_err) = err {
|
||||
return db_err.code().map(|c| c.into_owned());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn kind_str(k: ServiceKind) -> &'static str {
|
||||
match k {
|
||||
ServiceKind::Boolean => "boolean",
|
||||
ServiceKind::Numeric => "numeric",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_kind(s: &str) -> Result<ServiceKind, ApplicationError> {
|
||||
match s {
|
||||
"boolean" => Ok(ServiceKind::Boolean),
|
||||
"numeric" => Ok(ServiceKind::Numeric),
|
||||
other => Err(ApplicationError::Repository(format!(
|
||||
"unknown service kind '{other}'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_service(r: ServiceRow) -> Result<Service, ApplicationError> {
|
||||
Ok(Service {
|
||||
id: r.id,
|
||||
key: r.key,
|
||||
name: r.name,
|
||||
kind: parse_kind(&r.kind)?,
|
||||
min_value: r.min_value,
|
||||
max_value: r.max_value,
|
||||
active: r.active,
|
||||
sort_order: r.sort_order,
|
||||
})
|
||||
}
|
||||
|
||||
const COLS: &str =
|
||||
"id, key, name, kind, min_value, max_value, active, sort_order";
|
||||
|
||||
#[async_trait]
|
||||
impl ServiceRepository for PgServiceRepository {
|
||||
async fn list(&self, include_inactive: bool) -> Result<Vec<Service>, ApplicationError> {
|
||||
let rows = sqlx::query_as::<_, ServiceRow>(
|
||||
r#"
|
||||
SELECT id, key, name, kind, min_value, max_value, active, sort_order
|
||||
FROM services
|
||||
WHERE (active = TRUE OR $1)
|
||||
ORDER BY sort_order, name
|
||||
"#,
|
||||
)
|
||||
.bind(include_inactive)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
rows.into_iter().map(map_service).collect()
|
||||
}
|
||||
|
||||
async fn find_by_id(&self, id: Uuid) -> Result<Option<Service>, ApplicationError> {
|
||||
let row = sqlx::query_as::<_, ServiceRow>(&format!(
|
||||
"SELECT {COLS} FROM services WHERE id = $1"
|
||||
))
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
row.map(map_service).transpose()
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
key: &str,
|
||||
name: &str,
|
||||
kind: ServiceKind,
|
||||
min_value: Option<i32>,
|
||||
max_value: Option<i32>,
|
||||
sort_order: i32,
|
||||
) -> Result<Service, ApplicationError> {
|
||||
match sqlx::query_as::<_, ServiceRow>(
|
||||
r#"
|
||||
INSERT INTO services (key, name, kind, min_value, max_value, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, key, name, kind, min_value, max_value, active, sort_order
|
||||
"#,
|
||||
)
|
||||
.bind(key)
|
||||
.bind(name)
|
||||
.bind(kind_str(kind))
|
||||
.bind(min_value)
|
||||
.bind(max_value)
|
||||
.bind(sort_order)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
{
|
||||
Ok(row) => map_service(row),
|
||||
Err(e) if pg_sqlstate(&e).as_deref() == Some("23505") => Err(
|
||||
ApplicationError::Conflict(format!("service with key '{key}' already exists")),
|
||||
),
|
||||
Err(e) => Err(db(e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
id: Uuid,
|
||||
name: Option<&str>,
|
||||
min_value: Option<i32>,
|
||||
max_value: Option<i32>,
|
||||
active: Option<bool>,
|
||||
sort_order: Option<i32>,
|
||||
) -> Result<Service, ApplicationError> {
|
||||
let row = sqlx::query_as::<_, ServiceRow>(
|
||||
r#"
|
||||
UPDATE services
|
||||
SET name = COALESCE($2, name),
|
||||
min_value = COALESCE($3, min_value),
|
||||
max_value = COALESCE($4, max_value),
|
||||
active = COALESCE($5, active),
|
||||
sort_order = COALESCE($6, sort_order)
|
||||
WHERE id = $1
|
||||
RETURNING id, key, name, kind, min_value, max_value, active, sort_order
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(name)
|
||||
.bind(min_value)
|
||||
.bind(max_value)
|
||||
.bind(active)
|
||||
.bind(sort_order)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
match row {
|
||||
Some(r) => map_service(r),
|
||||
None => Err(ApplicationError::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, id: Uuid) -> Result<(), ApplicationError> {
|
||||
match sqlx::query("DELETE FROM services WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
if result.rows_affected() == 0 {
|
||||
Err(ApplicationError::NotFound)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Err(e) if pg_sqlstate(&e).as_deref() == Some("23503") => Err(
|
||||
ApplicationError::Conflict(
|
||||
"service is in use by at least one delivery — deactivate instead".to_string(),
|
||||
),
|
||||
),
|
||||
Err(e) => Err(db(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -20,14 +20,15 @@ use sqlx::{PgPool, Postgres, Transaction};
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::dto::{
|
||||
DeliveryOrderEntry, DeliveryWithItems, SyncDelivery, SyncDeliveryItem, SyncTourRequest,
|
||||
TourDetails, TourSummary,
|
||||
DeliveryOrderEntry, DeliveryWithItems, SyncContactSource, SyncDelivery, SyncDeliveryItem,
|
||||
SyncTourRequest, TourDetails, TourSummary,
|
||||
};
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::TourRepository;
|
||||
use holzleitner_domain::{
|
||||
Address, Article, Customer, CustomerContact, Delivery, DeliveryItem, DeliveryNote,
|
||||
DeliveryState, ScanState, ScanStatus, Tour, Warehouse,
|
||||
Address, Article, ContactChannel, ContactKind, ContactRole, ContactSource, Customer,
|
||||
CustomerContact, Delivery, DeliveryCredit, DeliveryItem, DeliveryNote, DeliveryServiceValue,
|
||||
DeliveryState, ScanState, ScanStatus, Service, ServiceKind, Tour, Warehouse,
|
||||
};
|
||||
|
||||
pub struct PgTourRepository {
|
||||
@ -78,6 +79,8 @@ struct DeliveryRow {
|
||||
state: String,
|
||||
state_reason: Option<String>,
|
||||
sort_order: i32,
|
||||
prepaid_amount: f64,
|
||||
payment_method_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
@ -87,9 +90,12 @@ struct DeliveryItemRow {
|
||||
article_id: Uuid,
|
||||
required_quantity: i32,
|
||||
warehouse_id: Uuid,
|
||||
unit_price: f64,
|
||||
belegzeilen_nr: i32,
|
||||
komponenten_artikel_nr: Option<String>,
|
||||
parent_artikel_nr: Option<String>,
|
||||
scanned_quantity: i32,
|
||||
credited_quantity: i32,
|
||||
scan_status: String,
|
||||
held_reason: Option<String>,
|
||||
scan_last_updated_at: DateTime<Utc>,
|
||||
@ -139,6 +145,29 @@ struct ContactLinkRow {
|
||||
customer_contact_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ContactSourceRow {
|
||||
id: Uuid,
|
||||
delivery_id: Uuid,
|
||||
role: String,
|
||||
anrede: Option<String>,
|
||||
titel: Option<String>,
|
||||
name1: Option<String>,
|
||||
name2: Option<String>,
|
||||
name3: Option<String>,
|
||||
abteilung: Option<String>,
|
||||
funktion: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ContactChannelRow {
|
||||
id: Uuid,
|
||||
source_id: Uuid,
|
||||
kind: String,
|
||||
position: i16,
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DeliveryNoteRow {
|
||||
id: Uuid,
|
||||
@ -147,6 +176,9 @@ struct DeliveryNoteRow {
|
||||
image_attachment: Option<String>,
|
||||
author_personalnummer: i64,
|
||||
author_car_id: Option<Uuid>,
|
||||
credit_delivery_item_id: Option<Uuid>,
|
||||
is_amount_credit_note: bool,
|
||||
image_attachment_deleted: bool,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@ -192,10 +224,13 @@ fn map_item(row: DeliveryItemRow) -> Result<DeliveryItem, ApplicationError> {
|
||||
article_id: row.article_id,
|
||||
required_quantity: row.required_quantity,
|
||||
warehouse_id: row.warehouse_id,
|
||||
unit_price: row.unit_price,
|
||||
belegzeilen_nr: row.belegzeilen_nr,
|
||||
komponenten_artikel_nr: row.komponenten_artikel_nr,
|
||||
parent_artikel_nr: row.parent_artikel_nr,
|
||||
scan_state: ScanState {
|
||||
scanned_quantity: row.scanned_quantity,
|
||||
credited_quantity: row.credited_quantity,
|
||||
status: parse_scan_status(&row.scan_status)?,
|
||||
held_reason: row.held_reason,
|
||||
last_updated_at: row.scan_last_updated_at,
|
||||
@ -228,6 +263,31 @@ fn map_contact(row: CustomerContactRow) -> CustomerContact {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_contact_source(row: ContactSourceRow) -> Result<ContactSource, ApplicationError> {
|
||||
Ok(ContactSource {
|
||||
id: row.id,
|
||||
delivery_id: row.delivery_id,
|
||||
role: role_from_db(&row.role)?,
|
||||
anrede: row.anrede,
|
||||
titel: row.titel,
|
||||
name1: row.name1,
|
||||
name2: row.name2,
|
||||
name3: row.name3,
|
||||
abteilung: row.abteilung,
|
||||
funktion: row.funktion,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_contact_channel(row: ContactChannelRow) -> Result<ContactChannel, ApplicationError> {
|
||||
Ok(ContactChannel {
|
||||
id: row.id,
|
||||
source_id: row.source_id,
|
||||
kind: kind_from_db(&row.kind)?,
|
||||
position: row.position,
|
||||
value: row.value,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_article(row: ArticleRow) -> Article {
|
||||
Article {
|
||||
id: row.id,
|
||||
@ -246,10 +306,85 @@ fn map_note(row: DeliveryNoteRow) -> DeliveryNote {
|
||||
image_attachment: row.image_attachment,
|
||||
author_personalnummer: row.author_personalnummer,
|
||||
author_car_id: row.author_car_id,
|
||||
credit_delivery_item_id: row.credit_delivery_item_id,
|
||||
is_amount_credit_note: row.is_amount_credit_note,
|
||||
image_attachment_deleted: row.image_attachment_deleted,
|
||||
created_at: row.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CreditRow {
|
||||
delivery_id: Uuid,
|
||||
action: String,
|
||||
amount_cents: i64,
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Aktuelles Gutschrift-Ereignis → Domänenobjekt. `remove` (oder unbekannte
|
||||
/// Action) liefert `None`, sodass entfernte Gutschriften nicht erscheinen.
|
||||
fn map_credit(row: CreditRow) -> Option<DeliveryCredit> {
|
||||
if row.action != "set" {
|
||||
return None;
|
||||
}
|
||||
Some(DeliveryCredit {
|
||||
delivery_id: row.delivery_id,
|
||||
amount_cents: row.amount_cents,
|
||||
reason: row.reason.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ServiceRow {
|
||||
id: Uuid,
|
||||
key: String,
|
||||
name: String,
|
||||
kind: String,
|
||||
min_value: Option<i32>,
|
||||
max_value: Option<i32>,
|
||||
active: bool,
|
||||
sort_order: i32,
|
||||
}
|
||||
|
||||
fn map_service(row: ServiceRow) -> Result<Service, ApplicationError> {
|
||||
let kind = match row.kind.as_str() {
|
||||
"boolean" => ServiceKind::Boolean,
|
||||
"numeric" => ServiceKind::Numeric,
|
||||
other => {
|
||||
return Err(ApplicationError::Repository(format!(
|
||||
"unknown service kind '{other}'"
|
||||
)));
|
||||
}
|
||||
};
|
||||
Ok(Service {
|
||||
id: row.id,
|
||||
key: row.key,
|
||||
name: row.name,
|
||||
kind,
|
||||
min_value: row.min_value,
|
||||
max_value: row.max_value,
|
||||
active: row.active,
|
||||
sort_order: row.sort_order,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DeliveryServiceRow {
|
||||
delivery_id: Uuid,
|
||||
service_id: Uuid,
|
||||
bool_value: Option<bool>,
|
||||
numeric_value: Option<i32>,
|
||||
}
|
||||
|
||||
fn map_delivery_service(row: DeliveryServiceRow) -> DeliveryServiceValue {
|
||||
DeliveryServiceValue {
|
||||
delivery_id: row.delivery_id,
|
||||
service_id: row.service_id,
|
||||
bool_value: row.bool_value,
|
||||
numeric_value: row.numeric_value,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_warehouse(row: WarehouseRow) -> Warehouse {
|
||||
Warehouse {
|
||||
id: row.id,
|
||||
@ -283,6 +418,8 @@ fn map_delivery(
|
||||
special_agreements: row.special_agreements,
|
||||
state,
|
||||
state_reason: row.state_reason,
|
||||
prepaid_amount: row.prepaid_amount,
|
||||
payment_method_id: row.payment_method_id,
|
||||
};
|
||||
Ok((delivery, row.sort_order))
|
||||
}
|
||||
@ -352,7 +489,8 @@ impl TourRepository for PgTourRepository {
|
||||
id, tour_id, erp_belegart_id, erp_belegnummer, customer_id,
|
||||
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
|
||||
assigned_car_id, desired_time, special_agreements,
|
||||
state, state_reason, sort_order
|
||||
state, state_reason, sort_order,
|
||||
prepaid_amount, payment_method_id
|
||||
FROM deliveries
|
||||
WHERE tour_id = $1
|
||||
ORDER BY sort_order, erp_belegnummer
|
||||
@ -391,8 +529,9 @@ impl TourRepository for PgTourRepository {
|
||||
r#"
|
||||
SELECT
|
||||
id, delivery_id, article_id, required_quantity, warehouse_id,
|
||||
belegzeilen_nr, komponenten_artikel_nr,
|
||||
scanned_quantity, scan_status, held_reason, scan_last_updated_at
|
||||
unit_price, belegzeilen_nr, komponenten_artikel_nr, parent_artikel_nr,
|
||||
scanned_quantity, credited_quantity, scan_status, held_reason,
|
||||
scan_last_updated_at
|
||||
FROM delivery_items
|
||||
WHERE delivery_id = ANY($1)
|
||||
ORDER BY delivery_id, belegzeilen_nr, komponenten_artikel_nr NULLS FIRST
|
||||
@ -504,11 +643,15 @@ impl TourRepository for PgTourRepository {
|
||||
// 7. Notizen aller Lieferungen dieser Tour.
|
||||
let notes = sqlx::query_as::<_, DeliveryNoteRow>(
|
||||
r#"
|
||||
SELECT id, delivery_id, text, image_attachment,
|
||||
author_personalnummer, author_car_id, created_at
|
||||
FROM delivery_notes
|
||||
WHERE delivery_id = ANY($1)
|
||||
ORDER BY delivery_id, created_at
|
||||
SELECT dn.id, dn.delivery_id, dn.text, dn.image_attachment,
|
||||
dn.author_personalnummer, dn.author_car_id,
|
||||
dn.credit_delivery_item_id, dn.is_amount_credit_note,
|
||||
(att.deleted_at IS NOT NULL) AS image_attachment_deleted,
|
||||
dn.created_at
|
||||
FROM delivery_notes dn
|
||||
LEFT JOIN attachments att ON att.id = dn.image_attachment::uuid
|
||||
WHERE dn.delivery_id = ANY($1)
|
||||
ORDER BY dn.delivery_id, dn.created_at
|
||||
"#,
|
||||
)
|
||||
.bind(&delivery_ids)
|
||||
@ -519,6 +662,99 @@ impl TourRepository for PgTourRepository {
|
||||
.map(map_note)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// 8. Aktuelle Betrags-Gutschriften: jüngstes Ereignis pro Lieferung,
|
||||
// nur solange der letzte Stand `set` ist.
|
||||
let credits = sqlx::query_as::<_, CreditRow>(
|
||||
r#"
|
||||
SELECT DISTINCT ON (delivery_id)
|
||||
delivery_id, action, amount_cents, reason
|
||||
FROM delivery_credit_audit
|
||||
WHERE delivery_id = ANY($1)
|
||||
ORDER BY delivery_id, recorded_at DESC, id DESC
|
||||
"#,
|
||||
)
|
||||
.bind(&delivery_ids)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?
|
||||
.into_iter()
|
||||
.filter_map(map_credit)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// 9. Aktive Service-Definitionen (Stammdaten) — die App rendert daraus
|
||||
// Phase 4.
|
||||
let services = sqlx::query_as::<_, ServiceRow>(
|
||||
r#"
|
||||
SELECT id, key, name, kind, min_value, max_value, active, sort_order
|
||||
FROM services
|
||||
WHERE active = TRUE
|
||||
ORDER BY sort_order, name
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?
|
||||
.into_iter()
|
||||
.map(map_service)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
// 10. Pro-Lieferung gesetzte Service-Werte.
|
||||
let delivery_services = sqlx::query_as::<_, DeliveryServiceRow>(
|
||||
r#"
|
||||
SELECT delivery_id, service_id, bool_value, numeric_value
|
||||
FROM delivery_services
|
||||
WHERE delivery_id = ANY($1)
|
||||
"#,
|
||||
)
|
||||
.bind(&delivery_ids)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?
|
||||
.into_iter()
|
||||
.map(map_delivery_service)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// 11. Kontaktdaten-Snapshots aller Lieferungen + ihre Kanäle.
|
||||
// Reihenfolge: Quellen pro Lieferung nach Rolle, Kanäle pro
|
||||
// Quelle nach Art und ERP-Position — so kommt „Telefon"
|
||||
// vor „Telefon2", die App muss nicht extra sortieren.
|
||||
let source_rows = sqlx::query_as::<_, ContactSourceRow>(
|
||||
r#"
|
||||
SELECT id, delivery_id, role,
|
||||
anrede, titel, name1, name2, name3, abteilung, funktion
|
||||
FROM delivery_contact_sources
|
||||
WHERE delivery_id = ANY($1)
|
||||
ORDER BY delivery_id, role
|
||||
"#,
|
||||
)
|
||||
.bind(&delivery_ids)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let source_ids: Vec<Uuid> = source_rows.iter().map(|r| r.id).collect();
|
||||
let contact_sources = source_rows
|
||||
.into_iter()
|
||||
.map(map_contact_source)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let channel_rows = sqlx::query_as::<_, ContactChannelRow>(
|
||||
r#"
|
||||
SELECT id, source_id, kind, position, value
|
||||
FROM delivery_contact_channels
|
||||
WHERE source_id = ANY($1)
|
||||
ORDER BY source_id, kind, position
|
||||
"#,
|
||||
)
|
||||
.bind(&source_ids)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
let contact_channels = channel_rows
|
||||
.into_iter()
|
||||
.map(map_contact_channel)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(Some(TourDetails {
|
||||
tour,
|
||||
deliveries,
|
||||
@ -527,6 +763,11 @@ impl TourRepository for PgTourRepository {
|
||||
articles,
|
||||
warehouses,
|
||||
notes,
|
||||
credits,
|
||||
services,
|
||||
delivery_services,
|
||||
contact_sources,
|
||||
contact_channels,
|
||||
}))
|
||||
}
|
||||
|
||||
@ -603,6 +844,23 @@ impl TourRepository for PgTourRepository {
|
||||
) -> Result<Uuid, ApplicationError> {
|
||||
let mut tx = self.pool.begin().await.map_err(db)?;
|
||||
|
||||
// 0. Fahrer-/Account-Konto sicherstellen — der ERP-`Vertreter` muss als
|
||||
// `accounts`-Zeile existieren (FK von `tours`). Auto-Provisionierung:
|
||||
// fehlende Konten werden mit Default-Namen angelegt; bestehende
|
||||
// bleiben unangetastet (DO NOTHING überschreibt keinen Namen).
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO accounts (personalnummer, name)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (personalnummer) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(request.driver_personalnummer)
|
||||
.bind(format!("Fahrer {}", request.driver_personalnummer))
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
// 1. Tour upserten — Identität: (account_id, tour_date)
|
||||
let tour_id: Uuid = sqlx::query_scalar(
|
||||
r#"
|
||||
@ -628,6 +886,11 @@ impl TourRepository for PgTourRepository {
|
||||
// erhalten; nur Stammdaten + sort_order werden refresht.
|
||||
let delivery_id = upsert_delivery(&mut tx, tour_id, customer_id, delivery).await?;
|
||||
|
||||
// 3a. Kontaktdaten-Snapshot neu schreiben. Snapshot-Semantik:
|
||||
// beim Sync wird der Stand vom ERP übernommen, ältere Stände
|
||||
// verworfen. Der CASCADE-DELETE räumt auch die Channels mit.
|
||||
replace_contact_sources(&mut tx, delivery_id, &delivery.contact_sources).await?;
|
||||
|
||||
for item in &delivery.items {
|
||||
let warehouse_id = upsert_warehouse(&mut tx, item).await?;
|
||||
let article_id = upsert_article(&mut tx, item, warehouse_id).await?;
|
||||
@ -638,6 +901,17 @@ impl TourRepository for PgTourRepository {
|
||||
tx.commit().await.map_err(db)?;
|
||||
Ok(tour_id)
|
||||
}
|
||||
|
||||
async fn delete_all_tours(&self) -> Result<u64, ApplicationError> {
|
||||
// DELETE FROM tours cascadet per FK auf deliveries → delivery_items →
|
||||
// scan_audit, delivery_notes, delivery_credit_audit, delivery_services,
|
||||
// delivery_completions, attachments, delivery_contact_persons.
|
||||
let res = sqlx::query("DELETE FROM tours")
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Upsert-Helfer =====================================================
|
||||
@ -741,15 +1015,39 @@ async fn upsert_delivery(
|
||||
customer_id: Uuid,
|
||||
delivery: &SyncDelivery,
|
||||
) -> Result<Uuid, ApplicationError> {
|
||||
// Payment-Method-Code → UUID auflösen. Fallback `"cash"` falls vom
|
||||
// ERP nichts gekommen ist — `"cash"` ist Default-Stamm aus
|
||||
// Migration 0008 und damit garantiert vorhanden.
|
||||
let payment_code = delivery
|
||||
.payment_method_code
|
||||
.as_deref()
|
||||
.unwrap_or("cash");
|
||||
let payment_method_id: Uuid = sqlx::query_scalar(
|
||||
"SELECT id FROM payment_methods WHERE code = $1",
|
||||
)
|
||||
.bind(payment_code)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?
|
||||
.ok_or_else(|| {
|
||||
ApplicationError::Validation(format!(
|
||||
"unknown payment method code '{payment_code}'"
|
||||
))
|
||||
})?;
|
||||
|
||||
let id: Uuid = sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO deliveries (
|
||||
tour_id, erp_belegart_id, erp_belegnummer, customer_id,
|
||||
tour_id, erp_belegart_id, erp_belegart_code, erp_belegart_name,
|
||||
erp_belegnummer, customer_id,
|
||||
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
|
||||
sort_order, desired_time, special_agreements
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
sort_order, desired_time, special_agreements,
|
||||
prepaid_amount, payment_method_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
ON CONFLICT (erp_belegart_id, erp_belegnummer) DO UPDATE SET
|
||||
tour_id = EXCLUDED.tour_id,
|
||||
erp_belegart_code = EXCLUDED.erp_belegart_code,
|
||||
erp_belegart_name = EXCLUDED.erp_belegart_name,
|
||||
customer_id = EXCLUDED.customer_id,
|
||||
snap_street = EXCLUDED.snap_street,
|
||||
snap_house_number = EXCLUDED.snap_house_number,
|
||||
@ -758,12 +1056,16 @@ async fn upsert_delivery(
|
||||
snap_country = EXCLUDED.snap_country,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
desired_time = EXCLUDED.desired_time,
|
||||
special_agreements = EXCLUDED.special_agreements
|
||||
special_agreements = EXCLUDED.special_agreements,
|
||||
prepaid_amount = EXCLUDED.prepaid_amount,
|
||||
payment_method_id = EXCLUDED.payment_method_id
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(tour_id)
|
||||
.bind(delivery.belegart_id)
|
||||
.bind(delivery.belegart_code.as_deref())
|
||||
.bind(delivery.belegart_name.as_deref())
|
||||
.bind(&delivery.belegnummer)
|
||||
.bind(customer_id)
|
||||
.bind(&delivery.delivery_address.street)
|
||||
@ -774,6 +1076,8 @@ async fn upsert_delivery(
|
||||
.bind(delivery.sort_order)
|
||||
.bind(delivery.desired_time.as_deref())
|
||||
.bind(delivery.special_agreements.as_deref())
|
||||
.bind(delivery.prepaid_amount)
|
||||
.bind(payment_method_id)
|
||||
.fetch_one(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
@ -794,22 +1098,129 @@ async fn upsert_delivery_item(
|
||||
r#"
|
||||
INSERT INTO delivery_items (
|
||||
delivery_id, article_id, required_quantity, warehouse_id,
|
||||
belegzeilen_nr, komponenten_artikel_nr
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
unit_price, belegzeilen_nr, komponenten_artikel_nr, parent_artikel_nr
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (delivery_id, belegzeilen_nr, komponenten_artikel_nr) DO UPDATE SET
|
||||
article_id = EXCLUDED.article_id,
|
||||
required_quantity = EXCLUDED.required_quantity,
|
||||
warehouse_id = EXCLUDED.warehouse_id
|
||||
warehouse_id = EXCLUDED.warehouse_id,
|
||||
unit_price = EXCLUDED.unit_price,
|
||||
parent_artikel_nr = EXCLUDED.parent_artikel_nr
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.bind(article_id)
|
||||
.bind(item.required_quantity)
|
||||
.bind(warehouse_id)
|
||||
.bind(item.unit_price)
|
||||
.bind(item.belegzeilen_nr)
|
||||
.bind(item.komponenten_artikel_nr.as_deref())
|
||||
.bind(item.parent_artikel_nr.as_deref())
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ===== Kontaktdaten ======================================================
|
||||
|
||||
fn role_to_db(role: ContactRole) -> &'static str {
|
||||
match role {
|
||||
ContactRole::Header => "header",
|
||||
ContactRole::Delivery => "delivery",
|
||||
ContactRole::Billing => "billing",
|
||||
ContactRole::ContactPerson => "contact_person",
|
||||
ContactRole::CustomerMaster => "customer_master",
|
||||
}
|
||||
}
|
||||
|
||||
fn role_from_db(value: &str) -> Result<ContactRole, ApplicationError> {
|
||||
match value {
|
||||
"header" => Ok(ContactRole::Header),
|
||||
"delivery" => Ok(ContactRole::Delivery),
|
||||
"billing" => Ok(ContactRole::Billing),
|
||||
"contact_person" => Ok(ContactRole::ContactPerson),
|
||||
"customer_master" => Ok(ContactRole::CustomerMaster),
|
||||
other => Err(ApplicationError::Repository(format!(
|
||||
"unknown contact role in DB: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_to_db(kind: ContactKind) -> &'static str {
|
||||
match kind {
|
||||
ContactKind::Phone => "phone",
|
||||
ContactKind::Mobile => "mobile",
|
||||
ContactKind::Email => "email",
|
||||
ContactKind::Web => "web",
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_from_db(value: &str) -> Result<ContactKind, ApplicationError> {
|
||||
match value {
|
||||
"phone" => Ok(ContactKind::Phone),
|
||||
"mobile" => Ok(ContactKind::Mobile),
|
||||
"email" => Ok(ContactKind::Email),
|
||||
"web" => Ok(ContactKind::Web),
|
||||
other => Err(ApplicationError::Repository(format!(
|
||||
"unknown contact kind in DB: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot-Refresh: vorhandene Sources der Lieferung löschen (Channels
|
||||
/// fliegen per ON DELETE CASCADE mit), neue einfügen. Idempotent: leerer
|
||||
/// Input ⇒ Lieferung hat nach dem Aufruf 0 Sources.
|
||||
async fn replace_contact_sources(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
delivery_id: Uuid,
|
||||
sources: &[SyncContactSource],
|
||||
) -> Result<(), ApplicationError> {
|
||||
sqlx::query("DELETE FROM delivery_contact_sources WHERE delivery_id = $1")
|
||||
.bind(delivery_id)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
for src in sources {
|
||||
let source_id: Uuid = sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO delivery_contact_sources (
|
||||
delivery_id, role, anrede, titel, name1, name2, name3,
|
||||
abteilung, funktion
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.bind(role_to_db(src.role))
|
||||
.bind(src.anrede.as_deref())
|
||||
.bind(src.titel.as_deref())
|
||||
.bind(src.name1.as_deref())
|
||||
.bind(src.name2.as_deref())
|
||||
.bind(src.name3.as_deref())
|
||||
.bind(src.abteilung.as_deref())
|
||||
.bind(src.funktion.as_deref())
|
||||
.fetch_one(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
for ch in &src.channels {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO delivery_contact_channels (
|
||||
source_id, kind, position, value
|
||||
) VALUES ($1, $2, $3, $4)
|
||||
"#,
|
||||
)
|
||||
.bind(source_id)
|
||||
.bind(kind_to_db(ch.kind))
|
||||
.bind(ch.position)
|
||||
.bind(&ch.value)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
10
crates/infrastructure/src/report/mod.rs
Normal file
10
crates/infrastructure/src/report/mod.rs
Normal file
@ -0,0 +1,10 @@
|
||||
//! PDF-Lieferreport: Daten sammeln (PG) → rendern (printpdf) → ablegen/senden
|
||||
//! (Sink). Adapter zu den `DeliveryReport*`-Ports der Application-Schicht.
|
||||
|
||||
pub mod renderer;
|
||||
pub mod repository;
|
||||
pub mod sink;
|
||||
|
||||
pub use renderer::PdfDeliveryReportRenderer;
|
||||
pub use repository::PgDeliveryReportRepository;
|
||||
pub use sink::{DocuframeReportSink, LocalReportSink};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user