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
|
/target
|
||||||
.env
|
# Lokale Konfiguration mit Secrets — Vorlage: config.example.toml
|
||||||
|
config.toml
|
||||||
.DS_Store
|
.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"
|
async-trait = "0.1"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
axum = "0.8"
|
axum = { version = "0.8", features = ["multipart"] }
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
tower-http = { version = "0.6", features = ["trace", "cors"] }
|
tower-http = { version = "0.6", features = ["trace", "cors"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "macros"] }
|
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"
|
jsonwebtoken = "9"
|
||||||
envy = "0.4"
|
toml = "0.8"
|
||||||
dotenvy = "0.15"
|
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
sha2 = "0.10"
|
||||||
|
imagesize = "0.13"
|
||||||
utoipa = { version = "5", features = ["axum_extras", "chrono", "uuid"] }
|
utoipa = { version = "5", features = ["axum_extras", "chrono", "uuid"] }
|
||||||
utoipa-swagger-ui = { version = "9", features = ["axum"] }
|
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
|
docker compose up -d
|
||||||
# Keycloak braucht ~30s bis "Listening on http://0.0.0.0:8080" im Log steht.
|
# Keycloak braucht ~30s bis "Listening on http://0.0.0.0:8080" im Log steht.
|
||||||
|
|
||||||
# 2) Env-Datei vorbereiten
|
# 2) Konfiguration vorbereiten
|
||||||
cp .env.example .env
|
cp config.example.toml config.toml
|
||||||
|
# Werte in config.toml anpassen (DB-URL, Keycloak-Issuer, ERP-Zugang, …).
|
||||||
|
|
||||||
# 3) Backend starten
|
# 3) Backend starten
|
||||||
cargo run -p holzleitner-api
|
cargo run -p holzleitner-api
|
||||||
@ -73,14 +74,27 @@ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:3000/accounts/1001
|
|||||||
|
|
||||||
## Konfiguration
|
## Konfiguration
|
||||||
|
|
||||||
Werte werden aus Umgebungsvariablen gelesen (siehe `.env.example`),
|
Werte werden aus `config.toml` gelesen (Vorlage: `config.example.toml`),
|
||||||
gruppiert nach Prefix:
|
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 |
|
| Section | Bereich | Pflicht? |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| `SERVER_*` | Bind-Host/Port |
|
| `[server]` | Bind-Host/Port | ja |
|
||||||
| `DATABASE_*` | Postgres-URL, Pool-Größe |
|
| `[database]` | Postgres-URL, Pool-Größe | ja |
|
||||||
| `KEYCLOAK_*` | OIDC-Issuer, Audience, JWKS-Cache (greift erst in der Keycloak-Phase) |
|
| `[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
|
## Migrations
|
||||||
|
|
||||||
@ -95,5 +109,6 @@ touch migrations/0002_tour.sql
|
|||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
`tracing` + `tracing-subscriber` mit `EnvFilter`. Default:
|
`tracing` + `tracing-subscriber` mit `EnvFilter`. Der Default-Filter steht
|
||||||
`holzleitner_api=info,tower_http=info`. Override via `RUST_LOG`.
|
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
|
chrono.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
envy.workspace = true
|
toml.workspace = true
|
||||||
dotenvy.workspace = true
|
|
||||||
sqlx.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`
|
//! [`load`] liest die TOML-Datei (Pfad via `HOLZLEITNER_CONFIG`-Env
|
||||||
//! Datei und parst dann pro Bereich (Server, Database, Keycloak) mit
|
//! überschreibbar, sonst `config.toml` im Arbeitsverzeichnis) und
|
||||||
//! Prefix-Filter über `envy`. So bleiben die Strukturen klar getrennt
|
//! deserialisiert sie über `serde` in [`Config`]. Die Struktur ist nach
|
||||||
//! und Fehlermeldungen verraten den genauen Bereich.
|
//! 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;
|
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 struct Config {
|
||||||
pub server: ServerConfig,
|
pub server: ServerConfig,
|
||||||
pub database: DatabaseConfig,
|
pub database: DatabaseConfig,
|
||||||
#[allow(dead_code)] // wird in der Keycloak-Phase verdrahtet
|
#[allow(dead_code)] // wird in der Keycloak-Phase verdrahtet
|
||||||
pub keycloak: KeycloakConfig,
|
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)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct ServerConfig {
|
pub struct ServerConfig {
|
||||||
pub host: String,
|
pub host: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct DatabaseConfig {
|
pub struct DatabaseConfig {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
#[serde(default = "default_max_connections")]
|
#[serde(default = "default_max_connections")]
|
||||||
@ -36,62 +360,269 @@ fn default_max_connections() -> u32 {
|
|||||||
/// Warnings unterdrücken.
|
/// Warnings unterdrücken.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct KeycloakConfig {
|
pub struct KeycloakConfig {
|
||||||
pub issuer_url: String,
|
pub issuer_url: String,
|
||||||
pub audience: String,
|
pub audience: String,
|
||||||
#[serde(default = "default_jwks_cache_ttl")]
|
#[serde(default = "default_jwks_cache_ttl")]
|
||||||
pub jwks_cache_ttl_seconds: u64,
|
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 {
|
fn default_jwks_cache_ttl() -> u64 {
|
||||||
3600
|
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:
|
/// Reihenfolge:
|
||||||
/// 1. Optionale `.env`-Datei im Arbeitsverzeichnis (für Local-Dev).
|
/// 1. Pfad bestimmen (`HOLZLEITNER_CONFIG`-Env oder `config.toml`).
|
||||||
/// 2. Pro Bereich werden Variablen mit passendem Prefix gelesen:
|
/// 2. Datei lesen und mit `toml`/`serde` in [`Config`] deserialisieren.
|
||||||
/// * `SERVER_*` für [`ServerConfig`]
|
/// Fehlende optionale Sections/Felder fallen auf die `serde`-Defaults.
|
||||||
/// * `DATABASE_*` für [`DatabaseConfig`]
|
|
||||||
/// * `KEYCLOAK_*` für [`KeycloakConfig`]
|
|
||||||
pub fn load() -> Result<Config, ConfigError> {
|
pub fn load() -> Result<Config, ConfigError> {
|
||||||
// `.env` ist optional — in Produktion kommen die Werte aus dem
|
let path = config_path();
|
||||||
// System-Environment.
|
let raw = std::fs::read_to_string(&path).map_err(|source| ConfigError::Read {
|
||||||
let _ = dotenvy::dotenv();
|
path: path.clone(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
parse(&raw).map_err(|source| ConfigError::Parse { path, source })
|
||||||
|
}
|
||||||
|
|
||||||
let server = envy::prefixed("SERVER_")
|
/// Deserialisiert TOML-Text in [`Config`]. Ausgelagert, damit er in Tests
|
||||||
.from_env::<ServerConfig>()
|
/// ohne Dateizugriff aufgerufen werden kann.
|
||||||
.map_err(|e| ConfigError::Section {
|
fn parse(raw: &str) -> Result<Config, toml::de::Error> {
|
||||||
section: "SERVER",
|
toml::from_str::<Config>(raw)
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum ConfigError {
|
pub enum ConfigError {
|
||||||
#[error("missing or invalid env vars in section {section}: {source}")]
|
// PathBuf implementiert kein Display → Debug-Format `{path:?}`.
|
||||||
Section {
|
#[error("config-Datei {path:?} konnte nicht gelesen werden: {source}")]
|
||||||
section: &'static str,
|
Read {
|
||||||
|
path: PathBuf,
|
||||||
#[source]
|
#[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::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
||||||
ApplicationError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
|
ApplicationError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
|
||||||
ApplicationError::Validation(_) => (StatusCode::BAD_REQUEST, "validation"),
|
ApplicationError::Validation(_) => (StatusCode::BAD_REQUEST, "validation"),
|
||||||
|
ApplicationError::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
||||||
ApplicationError::Repository(_)
|
ApplicationError::Repository(_)
|
||||||
| ApplicationError::External(_)
|
| ApplicationError::External(_)
|
||||||
| ApplicationError::Unexpected(_) => {
|
| ApplicationError::Unexpected(_) => {
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
//! Holzleitner-API — HTTP-Layer und Composition Root.
|
//! Holzleitner-API — HTTP-Layer und Composition Root.
|
||||||
//!
|
//!
|
||||||
//! Bootstrap-Reihenfolge:
|
//! Bootstrap-Reihenfolge:
|
||||||
//! 1. Tracing/Logging initialisieren
|
//! 1. Konfiguration aus `config.toml` laden (liefert u. a. den Log-Filter)
|
||||||
//! 2. Konfiguration aus Env-Variablen / `.env` laden
|
//! 2. Tracing/Logging initialisieren
|
||||||
//! 3. Postgres-Pool aufbauen und Migrations ausführen
|
//! 3. Postgres-Pool aufbauen und Migrations ausführen
|
||||||
//! 4. Keycloak-AuthService instanziieren
|
//! 4. Keycloak-AuthService instanziieren
|
||||||
//! 5. Use Cases zusammenstellen und in `AppState` packen
|
//! 5. Use Cases zusammenstellen und in `AppState` packen
|
||||||
@ -24,36 +24,73 @@ use anyhow::Context;
|
|||||||
use axum::Router;
|
use axum::Router;
|
||||||
use axum::middleware::from_fn_with_state;
|
use axum::middleware::from_fn_with_state;
|
||||||
use holzleitner_application::usecases::{
|
use holzleitner_application::usecases::{
|
||||||
ApplyDeliveryActionUseCase, ApplyScansUseCase, AssignCarToDeliveryUseCase,
|
ApplyDeliveryActionUseCase, ApplyDeliveryCreditEventUseCase, ApplyScansUseCase,
|
||||||
CreateDeliveryNoteUseCase, CreateMyCarUseCase, GetAccountUseCase, GetTourUseCase,
|
AssignCarToDeliveryUseCase, CompleteDeliveryUseCase, CreateDeliveryNoteUseCase,
|
||||||
ListMyCarsUseCase, ListMyToursTodayUseCase, SetDeliveryOrderUseCase, SyncTourUseCase,
|
CreateMyCarUseCase, CreatePaymentMethodUseCase, CreateServiceUseCase,
|
||||||
UpdateMyCarUseCase,
|
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::{
|
use holzleitner_infrastructure::persistence::{
|
||||||
PgAccountRepository, PgCarRepository, PgDeliveryNoteRepository, PgDeliveryRepository,
|
PgAccountRepository, PgAttachmentRepository, PgCarRepository, PgDeliveryCompletionRepository,
|
||||||
PgScanRepository, PgTourRepository, PoolConfig, connect_and_migrate,
|
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 tower_http::trace::TraceLayer;
|
||||||
use utoipa::OpenApi;
|
use utoipa::OpenApi;
|
||||||
use utoipa_swagger_ui::SwaggerUi;
|
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::openapi::ApiDoc;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
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()
|
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(
|
.with_env_filter(
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
// `RUST_LOG`-Env hat Vorrang (Ad-hoc-Debugging ohne Datei-Edit),
|
||||||
"holzleitner_api=info,holzleitner_infrastructure=info,tower_http=info".into()
|
// sonst der Filter aus `config.toml` (`[logging] filter`).
|
||||||
}),
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| cfg.logging.filter.clone().into()),
|
||||||
)
|
)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let cfg = config::load().context("config laden fehlgeschlagen")?;
|
tracing::info!("starting up");
|
||||||
tracing::info!(host = %cfg.server.host, port = cfg.server.port, "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 ---------------------------------------------------
|
// --- Persistence ---------------------------------------------------
|
||||||
let pool = connect_and_migrate(&PoolConfig {
|
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 scan_repository = Arc::new(PgScanRepository::new(pool.clone()));
|
||||||
let delivery_repository = Arc::new(PgDeliveryRepository::new(pool.clone()));
|
let delivery_repository = Arc::new(PgDeliveryRepository::new(pool.clone()));
|
||||||
let delivery_note_repository = Arc::new(PgDeliveryNoteRepository::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 ----------------------------------------------------------
|
// --- Auth ----------------------------------------------------------
|
||||||
let auth_service = Arc::new(KeycloakAuthService::new(KeycloakAdapterConfig {
|
let auth_service = Arc::new(KeycloakAuthService::new(KeycloakAdapterConfig {
|
||||||
@ -86,8 +190,74 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// --- Use Cases -----------------------------------------------------
|
// --- Use Cases -----------------------------------------------------
|
||||||
let get_account = Arc::new(GetAccountUseCase::new(account_repository));
|
let get_account = Arc::new(GetAccountUseCase::new(account_repository));
|
||||||
let get_tour = Arc::new(GetTourUseCase::new(tour_repository.clone()));
|
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()));
|
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 set_delivery_order = Arc::new(SetDeliveryOrderUseCase::new(tour_repository));
|
||||||
let apply_scans = Arc::new(ApplyScansUseCase::new(
|
let apply_scans = Arc::new(ApplyScansUseCase::new(
|
||||||
scan_repository,
|
scan_repository,
|
||||||
@ -95,10 +265,39 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
));
|
));
|
||||||
let apply_delivery_action =
|
let apply_delivery_action =
|
||||||
Arc::new(ApplyDeliveryActionUseCase::new(delivery_repository.clone()));
|
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(
|
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,
|
delivery_note_repository,
|
||||||
car_repository.clone(),
|
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 list_my_cars = Arc::new(ListMyCarsUseCase::new(car_repository.clone()));
|
||||||
let create_my_car = Arc::new(CreateMyCarUseCase::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()));
|
let update_my_car = Arc::new(UpdateMyCarUseCase::new(car_repository.clone()));
|
||||||
@ -106,6 +305,28 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
car_repository,
|
car_repository,
|
||||||
delivery_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 {
|
let state = AppState {
|
||||||
get_account,
|
get_account,
|
||||||
@ -113,14 +334,39 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
list_my_tours_today,
|
list_my_tours_today,
|
||||||
sync_tour,
|
sync_tour,
|
||||||
set_delivery_order,
|
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_scans,
|
||||||
apply_delivery_action,
|
apply_delivery_action,
|
||||||
|
complete_delivery,
|
||||||
|
push_completion_to_erp,
|
||||||
|
list_delivered_belegnummern,
|
||||||
|
mark_mail_sent,
|
||||||
|
apply_delivery_credit_event,
|
||||||
create_delivery_note,
|
create_delivery_note,
|
||||||
|
update_delivery_note,
|
||||||
|
delete_delivery_note,
|
||||||
|
upload_delivery_note_image,
|
||||||
|
get_attachment_preview,
|
||||||
list_my_cars,
|
list_my_cars,
|
||||||
create_my_car,
|
create_my_car,
|
||||||
update_my_car,
|
update_my_car,
|
||||||
assign_car_to_delivery,
|
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,
|
auth_service,
|
||||||
|
admin_api_key: cfg.admin.api_key.clone().into(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Router --------------------------------------------------------
|
// --- Router --------------------------------------------------------
|
||||||
@ -130,29 +376,156 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// auf dem protected-Subtree. `route_layer` greift nur für die jetzt
|
// auf dem protected-Subtree. `route_layer` greift nur für die jetzt
|
||||||
// definierten Routen — neue Routen darunter müssten explizit
|
// definierten Routen — neue Routen darunter müssten explizit
|
||||||
// nochmal angehängt werden.
|
// nochmal angehängt werden.
|
||||||
let public = Router::new()
|
let mut public = Router::new()
|
||||||
.merge(routes::health::router())
|
.merge(routes::health::router())
|
||||||
.merge(SwaggerUi::new("/swagger-ui").url("/openapi.json", ApiDoc::openapi()));
|
.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()
|
let protected = Router::new()
|
||||||
.merge(routes::accounts::router())
|
.merge(routes::accounts::router())
|
||||||
.merge(routes::tours::router())
|
.merge(routes::tours::router())
|
||||||
.merge(routes::scans::router())
|
.merge(routes::scans::router())
|
||||||
.merge(routes::deliveries::router())
|
.merge(routes::deliveries::router())
|
||||||
|
.merge(routes::attachments::router())
|
||||||
.merge(routes::cars::router())
|
.merge(routes::cars::router())
|
||||||
|
.merge(routes::payment_methods::router())
|
||||||
|
.merge(routes::services::router())
|
||||||
.route_layer(from_fn_with_state(state.clone(), jwt_middleware));
|
.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()
|
let app = Router::new()
|
||||||
.merge(public)
|
.merge(public)
|
||||||
.merge(protected)
|
.merge(protected)
|
||||||
|
.merge(admin)
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state);
|
.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)
|
let addr: SocketAddr = format!("{}:{}", cfg.server.host, cfg.server.port)
|
||||||
.parse()
|
.parse()
|
||||||
.with_context(|| format!("ungültige Adresse {}:{}", cfg.server.host, cfg.server.port))?;
|
.with_context(|| format!("ungültige Adresse {}:{}", cfg.server.host, cfg.server.port))?;
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
tracing::info!("server läuft auf http://{}", addr);
|
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(())
|
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
|
//! Axum-Middleware — z. B. JWT-Validierung gegen den
|
||||||
//! `holzleitner_application::ports::AuthService`.
|
//! `holzleitner_application::ports::AuthService`.
|
||||||
|
|
||||||
|
pub mod admin_key;
|
||||||
pub mod jwt;
|
pub mod jwt;
|
||||||
|
|
||||||
|
pub use admin_key::admin_api_key_middleware;
|
||||||
pub use jwt::jwt_middleware;
|
pub use jwt::jwt_middleware;
|
||||||
|
|||||||
@ -8,7 +8,9 @@
|
|||||||
|
|
||||||
use utoipa::Modify;
|
use utoipa::Modify;
|
||||||
use utoipa::OpenApi;
|
use utoipa::OpenApi;
|
||||||
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
|
use utoipa::openapi::security::{
|
||||||
|
ApiKey, ApiKeyValue, HttpAuthScheme, HttpBuilder, SecurityScheme,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
@ -30,10 +32,29 @@ use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
|
|||||||
crate::routes::deliveries::cancel,
|
crate::routes::deliveries::cancel,
|
||||||
crate::routes::deliveries::complete,
|
crate::routes::deliveries::complete,
|
||||||
crate::routes::deliveries::create_note,
|
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::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::list_my_cars,
|
||||||
crate::routes::cars::create_my_car,
|
crate::routes::cars::create_my_car,
|
||||||
crate::routes::cars::update_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(
|
components(
|
||||||
schemas(
|
schemas(
|
||||||
@ -42,20 +63,31 @@ use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
|
|||||||
holzleitner_domain::Article,
|
holzleitner_domain::Article,
|
||||||
holzleitner_domain::AuditAction,
|
holzleitner_domain::AuditAction,
|
||||||
holzleitner_domain::Car,
|
holzleitner_domain::Car,
|
||||||
|
holzleitner_domain::ContactChannel,
|
||||||
|
holzleitner_domain::ContactKind,
|
||||||
|
holzleitner_domain::ContactRole,
|
||||||
|
holzleitner_domain::ContactSource,
|
||||||
holzleitner_domain::Customer,
|
holzleitner_domain::Customer,
|
||||||
holzleitner_domain::CustomerContact,
|
holzleitner_domain::CustomerContact,
|
||||||
holzleitner_domain::Delivery,
|
holzleitner_domain::Delivery,
|
||||||
holzleitner_domain::DeliveryItem,
|
holzleitner_domain::DeliveryItem,
|
||||||
holzleitner_domain::DeliveryNote,
|
holzleitner_domain::DeliveryNote,
|
||||||
|
holzleitner_domain::DeliveryCredit,
|
||||||
holzleitner_domain::DeliveryState,
|
holzleitner_domain::DeliveryState,
|
||||||
holzleitner_domain::ScanState,
|
holzleitner_domain::ScanState,
|
||||||
holzleitner_domain::ScanStatus,
|
holzleitner_domain::ScanStatus,
|
||||||
holzleitner_domain::Tour,
|
holzleitner_domain::Tour,
|
||||||
|
holzleitner_domain::PaymentMethod,
|
||||||
|
holzleitner_domain::Service,
|
||||||
|
holzleitner_domain::ServiceKind,
|
||||||
|
holzleitner_domain::DeliveryServiceValue,
|
||||||
holzleitner_domain::Warehouse,
|
holzleitner_domain::Warehouse,
|
||||||
holzleitner_application::dto::TourDetails,
|
holzleitner_application::dto::TourDetails,
|
||||||
holzleitner_application::dto::DeliveryWithItems,
|
holzleitner_application::dto::DeliveryWithItems,
|
||||||
holzleitner_application::dto::TourSummary,
|
holzleitner_application::dto::TourSummary,
|
||||||
holzleitner_application::dto::SyncTourRequest,
|
holzleitner_application::dto::SyncTourRequest,
|
||||||
|
holzleitner_application::dto::SyncContactChannel,
|
||||||
|
holzleitner_application::dto::SyncContactSource,
|
||||||
holzleitner_application::dto::SyncDelivery,
|
holzleitner_application::dto::SyncDelivery,
|
||||||
holzleitner_application::dto::SyncDeliveryItem,
|
holzleitner_application::dto::SyncDeliveryItem,
|
||||||
holzleitner_application::dto::SetDeliveryOrderRequest,
|
holzleitner_application::dto::SetDeliveryOrderRequest,
|
||||||
@ -68,14 +100,33 @@ use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
|
|||||||
holzleitner_application::dto::ScanResultStatus,
|
holzleitner_application::dto::ScanResultStatus,
|
||||||
holzleitner_application::dto::HoldDeliveryRequest,
|
holzleitner_application::dto::HoldDeliveryRequest,
|
||||||
holzleitner_application::dto::CancelDeliveryRequest,
|
holzleitner_application::dto::CancelDeliveryRequest,
|
||||||
|
holzleitner_application::dto::CompleteDeliveryAcknowledgements,
|
||||||
holzleitner_application::dto::DeliveryResponse,
|
holzleitner_application::dto::DeliveryResponse,
|
||||||
holzleitner_application::dto::CreateDeliveryNoteRequest,
|
holzleitner_application::dto::CreateDeliveryNoteRequest,
|
||||||
|
holzleitner_application::dto::UpdateDeliveryNoteRequest,
|
||||||
holzleitner_application::dto::DeliveryNoteResponse,
|
holzleitner_application::dto::DeliveryNoteResponse,
|
||||||
|
holzleitner_application::dto::CreditAction,
|
||||||
|
holzleitner_application::dto::DeliveryCreditEventRequest,
|
||||||
|
holzleitner_application::dto::DeliveryCreditResponse,
|
||||||
holzleitner_application::dto::CreateCarRequest,
|
holzleitner_application::dto::CreateCarRequest,
|
||||||
holzleitner_application::dto::UpdateCarRequest,
|
holzleitner_application::dto::UpdateCarRequest,
|
||||||
holzleitner_application::dto::CarResponse,
|
holzleitner_application::dto::CarResponse,
|
||||||
holzleitner_application::dto::CarsList,
|
holzleitner_application::dto::CarsList,
|
||||||
holzleitner_application::dto::AssignCarRequest,
|
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::TourSummaryList,
|
||||||
crate::routes::tours::SyncTourResponse,
|
crate::routes::tours::SyncTourResponse,
|
||||||
)
|
)
|
||||||
@ -89,6 +140,9 @@ use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
|
|||||||
(name = "scans", description = "Scan-Events (Beladung & Auslieferung)"),
|
(name = "scans", description = "Scan-Events (Beladung & Auslieferung)"),
|
||||||
(name = "deliveries", description = "Delivery-Lifecycle (hold / resume / cancel / complete)"),
|
(name = "deliveries", description = "Delivery-Lifecycle (hold / resume / cancel / complete)"),
|
||||||
(name = "cars", description = "Fahrzeug-Stammdaten pro Fahrer"),
|
(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(
|
security(
|
||||||
("bearer_auth" = [])
|
("bearer_auth" = [])
|
||||||
@ -113,6 +167,11 @@ impl Modify for SecurityAddon {
|
|||||||
.build(),
|
.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::Json;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{DefaultBodyLimit, Multipart, Path, State};
|
||||||
use axum::routing::{post, put};
|
use axum::http::StatusCode;
|
||||||
|
use axum::routing::{patch, post, put};
|
||||||
use holzleitner_application::dto::{
|
use holzleitner_application::dto::{
|
||||||
AssignCarRequest, CancelDeliveryRequest, CreateDeliveryNoteRequest, DeliveryNoteResponse,
|
AssignCarRequest, CancelDeliveryRequest, CompleteDeliveryAcknowledgements,
|
||||||
DeliveryResponse, HoldDeliveryRequest,
|
CreateDeliveryNoteRequest, DeliveryCreditEventRequest, DeliveryCreditResponse,
|
||||||
|
DeliveryNoteResponse, DeliveryResponse, DeliveryServiceResponse, HoldDeliveryRequest,
|
||||||
|
SetDeliveryServiceRequest, UpdateDeliveryNoteRequest,
|
||||||
};
|
};
|
||||||
|
use holzleitner_application::error::ApplicationError;
|
||||||
use holzleitner_application::ports::DeliveryAction;
|
use holzleitner_application::ports::DeliveryAction;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@ -13,14 +17,40 @@ use crate::error::ApiError;
|
|||||||
use crate::extractors::AuthenticatedUser;
|
use crate::extractors::AuthenticatedUser;
|
||||||
use crate::state::AppState;
|
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> {
|
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()
|
Router::new()
|
||||||
.route("/deliveries/{delivery_id}/hold", post(hold))
|
.route("/deliveries/{delivery_id}/hold", post(hold))
|
||||||
.route("/deliveries/{delivery_id}/resume", post(resume))
|
.route("/deliveries/{delivery_id}/resume", post(resume))
|
||||||
.route("/deliveries/{delivery_id}/cancel", post(cancel))
|
.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", 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))
|
.route("/deliveries/{delivery_id}/assigned-car", put(assign_car))
|
||||||
|
.merge(multipart)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Setzt die Lieferung auf `held`. Nur aus `active` zulässig.
|
/// 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`.
|
/// 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(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/deliveries/{delivery_id}/complete",
|
path = "/deliveries/{delivery_id}/complete",
|
||||||
tag = "deliveries",
|
tag = "deliveries",
|
||||||
params(("delivery_id" = Uuid, Path)),
|
params(("delivery_id" = Uuid, Path)),
|
||||||
|
request_body(
|
||||||
|
content_type = "multipart/form-data",
|
||||||
|
description = "Felder `customer_signature`, `driver_signature` (PNG) + `acknowledgements` (JSON)"
|
||||||
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "Lieferung abgeschlossen", body = DeliveryResponse),
|
(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 = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||||
(status = 404, description = "Lieferung nicht gefunden")
|
(status = 404, description = "Lieferung nicht gefunden")
|
||||||
),
|
),
|
||||||
@ -130,15 +176,107 @@ pub async fn complete(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AuthenticatedUser(claims): AuthenticatedUser,
|
AuthenticatedUser(claims): AuthenticatedUser,
|
||||||
Path(delivery_id): Path<Uuid>,
|
Path(delivery_id): Path<Uuid>,
|
||||||
|
mut multipart: Multipart,
|
||||||
) -> Result<Json<DeliveryResponse>, ApiError> {
|
) -> Result<Json<DeliveryResponse>, ApiError> {
|
||||||
tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.complete");
|
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
|
let delivery = state
|
||||||
.apply_delivery_action
|
.complete_delivery
|
||||||
.execute(delivery_id, DeliveryAction::Complete)
|
.execute(
|
||||||
|
delivery_id,
|
||||||
|
claims.personalnummer,
|
||||||
|
acknowledgements,
|
||||||
|
customer_png,
|
||||||
|
driver_png,
|
||||||
|
)
|
||||||
.await?;
|
.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 }))
|
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
|
/// Legt eine neue Notiz an einer Lieferung an. Mindestens eines von
|
||||||
/// `text` und `imageAttachment` muss inhaltlich gefüllt sein
|
/// `text` und `imageAttachment` muss inhaltlich gefüllt sein
|
||||||
/// (Leerstrings werden serverseitig getrimmt und als leer behandelt).
|
/// (Leerstrings werden serverseitig getrimmt und als leer behandelt).
|
||||||
@ -170,6 +308,238 @@ pub async fn create_note(
|
|||||||
Ok(Json(DeliveryNoteResponse { 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
|
/// Setzt das `assigned_car_id` einer Lieferung. `carId: null` löst
|
||||||
/// die Zuordnung wieder. Der Use Case stellt sicher, dass das Fahrzeug
|
/// die Zuordnung wieder. Der Use Case stellt sicher, dass das Fahrzeug
|
||||||
/// zum angemeldeten Account gehört.
|
/// 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.
|
//! zusammengesetzt.
|
||||||
|
|
||||||
pub mod accounts;
|
pub mod accounts;
|
||||||
|
pub mod admin;
|
||||||
|
pub mod attachments;
|
||||||
pub mod cars;
|
pub mod cars;
|
||||||
pub mod deliveries;
|
pub mod deliveries;
|
||||||
|
pub mod dev;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
|
pub mod payment_methods;
|
||||||
pub mod scans;
|
pub mod scans;
|
||||||
|
pub mod services;
|
||||||
pub mod tours;
|
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::ports::AuthService;
|
||||||
use holzleitner_application::usecases::{
|
use holzleitner_application::usecases::{
|
||||||
ApplyDeliveryActionUseCase, ApplyScansUseCase, AssignCarToDeliveryUseCase,
|
ApplyDeliveryActionUseCase, ApplyDeliveryCreditEventUseCase, ApplyScansUseCase,
|
||||||
CreateDeliveryNoteUseCase, CreateMyCarUseCase, GetAccountUseCase, GetTourUseCase,
|
AssignCarToDeliveryUseCase, CompleteDeliveryUseCase, CreateDeliveryNoteUseCase,
|
||||||
ListMyCarsUseCase, ListMyToursTodayUseCase, SetDeliveryOrderUseCase, SyncTourUseCase,
|
CreateMyCarUseCase, CreatePaymentMethodUseCase, CreateServiceUseCase,
|
||||||
UpdateMyCarUseCase,
|
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
|
/// Shared application state, der per Axum's `State`-Extractor in alle
|
||||||
@ -21,13 +30,49 @@ pub struct AppState {
|
|||||||
pub get_tour: Arc<GetTourUseCase>,
|
pub get_tour: Arc<GetTourUseCase>,
|
||||||
pub list_my_tours_today: Arc<ListMyToursTodayUseCase>,
|
pub list_my_tours_today: Arc<ListMyToursTodayUseCase>,
|
||||||
pub sync_tour: Arc<SyncTourUseCase>,
|
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 set_delivery_order: Arc<SetDeliveryOrderUseCase>,
|
||||||
pub apply_scans: Arc<ApplyScansUseCase>,
|
pub apply_scans: Arc<ApplyScansUseCase>,
|
||||||
pub apply_delivery_action: Arc<ApplyDeliveryActionUseCase>,
|
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 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 list_my_cars: Arc<ListMyCarsUseCase>,
|
||||||
pub create_my_car: Arc<CreateMyCarUseCase>,
|
pub create_my_car: Arc<CreateMyCarUseCase>,
|
||||||
pub update_my_car: Arc<UpdateMyCarUseCase>,
|
pub update_my_car: Arc<UpdateMyCarUseCase>,
|
||||||
pub assign_car_to_delivery: Arc<AssignCarToDeliveryUseCase>,
|
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>,
|
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
|
thiserror.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
imagesize.workspace = true
|
||||||
utoipa = { workspace = true, optional = 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.
|
//! zweite Schicht handgeschriebener API-DTOs.
|
||||||
|
|
||||||
pub mod car;
|
pub mod car;
|
||||||
|
pub mod complete;
|
||||||
|
pub mod credit;
|
||||||
pub mod delivery_action;
|
pub mod delivery_action;
|
||||||
|
pub mod delivery_report;
|
||||||
pub mod delivery_order;
|
pub mod delivery_order;
|
||||||
pub mod note;
|
pub mod note;
|
||||||
|
pub mod payment_method;
|
||||||
pub mod scan;
|
pub mod scan;
|
||||||
|
pub mod service;
|
||||||
pub mod tour_details;
|
pub mod tour_details;
|
||||||
pub mod tour_summary;
|
pub mod tour_summary;
|
||||||
pub mod tour_sync;
|
pub mod tour_sync;
|
||||||
@ -22,14 +27,32 @@ pub mod tour_sync;
|
|||||||
pub use car::{
|
pub use car::{
|
||||||
AssignCarRequest, CarResponse, CarsList, CreateCarRequest, UpdateCarRequest,
|
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_action::{CancelDeliveryRequest, DeliveryResponse, HoldDeliveryRequest};
|
||||||
pub use delivery_order::{
|
pub use delivery_order::{
|
||||||
DeliveryOrderEntry, SetDeliveryOrderRequest, SetDeliveryOrderResponse,
|
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::{
|
pub use scan::{
|
||||||
ApplyScansRequest, ApplyScansResponse, ScanEvent, ScanResult, ScanResultStatus,
|
ApplyScansRequest, ApplyScansResponse, ScanEvent, ScanResult, ScanResultStatus,
|
||||||
};
|
};
|
||||||
|
pub use service::{
|
||||||
|
CreateServiceRequest, DeliveryServiceResponse, ServiceResponse, ServicesList,
|
||||||
|
SetDeliveryServiceRequest, UpdateServiceRequest,
|
||||||
|
};
|
||||||
pub use tour_details::{DeliveryWithItems, TourDetails};
|
pub use tour_details::{DeliveryWithItems, TourDetails};
|
||||||
pub use tour_summary::TourSummary;
|
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
|
/// Fahrzeug, das die Notiz erzeugt hat. Muss zum angemeldeten
|
||||||
/// Account gehören. `None` ist erlaubt.
|
/// Account gehören. `None` ist erlaubt.
|
||||||
pub author_car_id: Option<Uuid>,
|
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)]
|
#[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,
|
pub action: AuditAction,
|
||||||
/// Pflicht bei `Hold` und `Remove`. Sonst ignoriert.
|
/// Pflicht bei `Hold` und `Remove`. Sonst ignoriert.
|
||||||
pub reason: Option<String>,
|
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>,
|
pub client_scanned_at: DateTime<Utc>,
|
||||||
/// Fahrzeug, in dem der Scan gemacht wurde. Muss zum
|
/// Fahrzeug, in dem der Scan gemacht wurde. Muss zum
|
||||||
/// angemeldeten Account gehören. `None` ist erlaubt, schwächt
|
/// 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 serde::Serialize;
|
||||||
|
|
||||||
use holzleitner_domain::{
|
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)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@ -27,6 +28,24 @@ pub struct TourDetails {
|
|||||||
/// Die App joint clientseitig per `delivery_id`. Reihenfolge:
|
/// Die App joint clientseitig per `delivery_id`. Reihenfolge:
|
||||||
/// pro Lieferung aufsteigend nach `created_at`.
|
/// pro Lieferung aufsteigend nach `created_at`.
|
||||||
pub notes: Vec<DeliveryNote>,
|
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)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use holzleitner_domain::Address;
|
use holzleitner_domain::{Address, ContactKind, ContactRole};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||||
@ -29,6 +29,12 @@ pub struct SyncTourRequest {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SyncDelivery {
|
pub struct SyncDelivery {
|
||||||
pub belegart_id: i64,
|
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 belegnummer: String,
|
||||||
|
|
||||||
pub erp_customer_id: i64,
|
pub erp_customer_id: i64,
|
||||||
@ -44,7 +50,62 @@ pub struct SyncDelivery {
|
|||||||
pub desired_time: Option<String>,
|
pub desired_time: Option<String>,
|
||||||
pub special_agreements: 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>,
|
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)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
@ -53,7 +114,13 @@ pub struct SyncDelivery {
|
|||||||
pub struct SyncDeliveryItem {
|
pub struct SyncDeliveryItem {
|
||||||
pub belegzeilen_nr: i32,
|
pub belegzeilen_nr: i32,
|
||||||
/// Komponenten-Artikelnummer bei aufgelösten Stücklisten, sonst leer.
|
/// 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>,
|
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_number: String,
|
||||||
pub article_name: String,
|
pub article_name: String,
|
||||||
@ -65,4 +132,9 @@ pub struct SyncDeliveryItem {
|
|||||||
pub warehouse_name: String,
|
pub warehouse_name: String,
|
||||||
|
|
||||||
pub required_quantity: i32,
|
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}")]
|
#[error("validation: {0}")]
|
||||||
Validation(String),
|
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}")]
|
#[error("repository: {0}")]
|
||||||
Repository(String),
|
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.
|
//! Port für Delivery-Notizen.
|
||||||
//!
|
//!
|
||||||
//! Aktuell nur das Anlegen — der Read-Pfad läuft als Teil des Tour-
|
//! Anlegen, Ändern, Löschen einzelner Notizen. Der Read-Pfad läuft als
|
||||||
//! Aggregats (`TourDetails.notes`). Sollten irgendwann Listen-Reads
|
//! Teil des Tour-Aggregats (`TourDetails.notes`).
|
||||||
//! oder Updates an einzelnen Notizen nötig werden, kommen die hier rein.
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@ -13,6 +12,7 @@ use crate::error::ApplicationError;
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait DeliveryNoteRepository: Send + Sync {
|
pub trait DeliveryNoteRepository: Send + Sync {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn create(
|
async fn create(
|
||||||
&self,
|
&self,
|
||||||
delivery_id: Uuid,
|
delivery_id: Uuid,
|
||||||
@ -20,5 +20,20 @@ pub trait DeliveryNoteRepository: Send + Sync {
|
|||||||
author_car_id: Option<Uuid>,
|
author_car_id: Option<Uuid>,
|
||||||
text: Option<String>,
|
text: Option<String>,
|
||||||
image_attachment: Option<String>,
|
image_attachment: Option<String>,
|
||||||
|
credit_delivery_item_id: Option<Uuid>,
|
||||||
|
is_amount_credit_note: bool,
|
||||||
) -> Result<DeliveryNote, ApplicationError>;
|
) -> 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`.
|
//! `holzleitner-infrastructure`.
|
||||||
|
|
||||||
pub mod account_repository;
|
pub mod account_repository;
|
||||||
|
pub mod attachment_repository;
|
||||||
|
pub mod attachment_storage;
|
||||||
pub mod auth_service;
|
pub mod auth_service;
|
||||||
pub mod car_repository;
|
pub mod car_repository;
|
||||||
|
pub mod delivery_credit_repository;
|
||||||
pub mod delivery_note_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_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 scan_repository;
|
||||||
|
pub mod service_repository;
|
||||||
|
pub mod signature_storage;
|
||||||
pub mod tour_repository;
|
pub mod tour_repository;
|
||||||
|
|
||||||
pub use account_repository::AccountRepository;
|
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 auth_service::{AuthError, AuthService, Claims};
|
||||||
pub use car_repository::CarRepository;
|
pub use car_repository::CarRepository;
|
||||||
|
pub use delivery_credit_repository::DeliveryCreditRepository;
|
||||||
pub use delivery_note_repository::DeliveryNoteRepository;
|
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_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 scan_repository::{ApplyScanOutcome, ScanRepository};
|
||||||
|
pub use service_repository::ServiceRepository;
|
||||||
|
pub use signature_storage::{SignatureRole, SignatureStorage};
|
||||||
pub use tour_repository::TourRepository;
|
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,
|
tour_id: Uuid,
|
||||||
delivery_ids: &[Uuid],
|
delivery_ids: &[Uuid],
|
||||||
) -> Result<Vec<DeliveryOrderEntry>, ApplicationError>;
|
) -> 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)`,
|
/// 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> {
|
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 {
|
match event.action {
|
||||||
AuditAction::Hold | AuditAction::Remove => {
|
AuditAction::Hold | AuditAction::Remove => {
|
||||||
let trimmed = event.reason.as_deref().map(str::trim).unwrap_or("");
|
let trimmed = event.reason.as_deref().map(str::trim).unwrap_or("");
|
||||||
@ -113,6 +126,9 @@ fn pre_validate(event: &ScanEvent) -> Option<String> {
|
|||||||
None
|
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,
|
request.author_car_id,
|
||||||
text,
|
text,
|
||||||
image,
|
image,
|
||||||
|
request.credit_delivery_item_id,
|
||||||
|
request.is_amount_credit_note,
|
||||||
)
|
)
|
||||||
.await
|
.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 std::sync::Arc;
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::{NaiveDate, Utc};
|
||||||
|
|
||||||
use crate::dto::TourSummary;
|
use crate::dto::TourSummary;
|
||||||
use crate::error::ApplicationError;
|
use crate::error::ApplicationError;
|
||||||
@ -9,17 +9,27 @@ use crate::ports::TourRepository;
|
|||||||
/// Liste der heutigen Touren des angemeldeten Fahrers. Das "heute"
|
/// Liste der heutigen Touren des angemeldeten Fahrers. Das "heute"
|
||||||
/// liegt **bewusst im Backend**: die App-Uhr ist nicht autoritativ
|
/// liegt **bewusst im Backend**: die App-Uhr ist nicht autoritativ
|
||||||
/// (Zeitzone, Falsch-Stand, Manipulation).
|
/// (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 {
|
pub struct ListMyToursTodayUseCase {
|
||||||
repository: Arc<dyn TourRepository>,
|
repository: Arc<dyn TourRepository>,
|
||||||
|
today_override: Option<NaiveDate>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ListMyToursTodayUseCase {
|
impl ListMyToursTodayUseCase {
|
||||||
pub fn new(repository: Arc<dyn TourRepository>) -> Self {
|
pub fn new(repository: Arc<dyn TourRepository>, today_override: Option<NaiveDate>) -> Self {
|
||||||
Self { repository }
|
Self {
|
||||||
|
repository,
|
||||||
|
today_override,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn execute(&self, personalnummer: i64) -> Result<Vec<TourSummary>, ApplicationError> {
|
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
|
self.repository
|
||||||
.find_today_for_driver(personalnummer, today)
|
.find_today_for_driver(personalnummer, today)
|
||||||
.await
|
.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.
|
//! entgegen und orchestrieren damit das Domänenmodell.
|
||||||
|
|
||||||
pub mod apply_delivery_action;
|
pub mod apply_delivery_action;
|
||||||
|
pub mod apply_delivery_credit_event;
|
||||||
pub mod apply_scans;
|
pub mod apply_scans;
|
||||||
pub mod cars;
|
pub mod cars;
|
||||||
|
pub mod complete_delivery;
|
||||||
pub mod create_delivery_note;
|
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_account;
|
||||||
|
pub mod get_attachment_preview;
|
||||||
pub mod get_tour;
|
pub mod get_tour;
|
||||||
|
pub mod import_erp_tours;
|
||||||
|
pub mod list_delivered_belegnummern;
|
||||||
pub mod list_my_tours_today;
|
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 set_delivery_order;
|
||||||
pub mod sync_tour;
|
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_action::ApplyDeliveryActionUseCase;
|
||||||
|
pub use apply_delivery_credit_event::ApplyDeliveryCreditEventUseCase;
|
||||||
pub use apply_scans::ApplyScansUseCase;
|
pub use apply_scans::ApplyScansUseCase;
|
||||||
pub use cars::{
|
pub use cars::{
|
||||||
AssignCarToDeliveryUseCase, CreateMyCarUseCase, ListMyCarsUseCase, UpdateMyCarUseCase,
|
AssignCarToDeliveryUseCase, CreateMyCarUseCase, ListMyCarsUseCase, UpdateMyCarUseCase,
|
||||||
};
|
};
|
||||||
|
pub use complete_delivery::CompleteDeliveryUseCase;
|
||||||
pub use create_delivery_note::CreateDeliveryNoteUseCase;
|
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_account::GetAccountUseCase;
|
||||||
|
pub use get_attachment_preview::GetAttachmentPreviewUseCase;
|
||||||
pub use get_tour::GetTourUseCase;
|
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 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 set_delivery_order::SetDeliveryOrderUseCase;
|
||||||
pub use sync_tour::SyncTourUseCase;
|
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.
|
/// * `Hold` / `Unhold` ändern nur den Status, keine Menge.
|
||||||
/// * `Remove` markiert die Position als entfernt (Status `Removed`,
|
/// * `Remove` markiert die Position als entfernt (Status `Removed`,
|
||||||
/// z. B. weil der Kunde sie nicht annimmt).
|
/// 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)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
@ -19,6 +24,7 @@ pub enum AuditAction {
|
|||||||
Hold,
|
Hold,
|
||||||
Unhold,
|
Unhold,
|
||||||
Remove,
|
Remove,
|
||||||
|
Unremove,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append-only Audit-Log-Eintrag: jedes Ereignis am Scan-Zustand einer
|
/// 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
|
/// Eine einzelne Lieferung an einen Kunden. Aggregat-Wurzel für die
|
||||||
/// Liefer-Items, Notizen und das ggf. zugeordnete Fahrzeug.
|
/// 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))]
|
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Delivery {
|
pub struct Delivery {
|
||||||
@ -58,6 +61,16 @@ pub struct Delivery {
|
|||||||
/// Begründung bei `state == Held` oder `state == Canceled`. Beim
|
/// Begründung bei `state == Held` oder `state == Canceled`. Beim
|
||||||
/// Resume / Complete wieder `None`.
|
/// Resume / Complete wieder `None`.
|
||||||
pub state_reason: Option<String>,
|
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.
|
/// Status einer einzelnen Scan-Position innerhalb eines Items.
|
||||||
@ -80,6 +93,11 @@ pub enum ScanStatus {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ScanState {
|
pub struct ScanState {
|
||||||
pub scanned_quantity: i32,
|
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,
|
pub status: ScanStatus,
|
||||||
/// Grund bei `status == Held` oder `status == Removed`.
|
/// Grund bei `status == Held` oder `status == Removed`.
|
||||||
pub held_reason: Option<String>,
|
pub held_reason: Option<String>,
|
||||||
@ -92,7 +110,8 @@ pub struct ScanState {
|
|||||||
///
|
///
|
||||||
/// Über die Felder `belegzeilen_nr` und `komponenten_artikel_nr` bleibt
|
/// Über die Felder `belegzeilen_nr` und `komponenten_artikel_nr` bleibt
|
||||||
/// die ERP-Herkunft auflösbar.
|
/// 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))]
|
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct DeliveryItem {
|
pub struct DeliveryItem {
|
||||||
@ -103,6 +122,10 @@ pub struct DeliveryItem {
|
|||||||
pub required_quantity: i32,
|
pub required_quantity: i32,
|
||||||
pub warehouse_id: Uuid,
|
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).
|
/// ERP-Belegzeilen-Nr (Position innerhalb des Belegs).
|
||||||
pub belegzeilen_nr: i32,
|
pub belegzeilen_nr: i32,
|
||||||
|
|
||||||
@ -110,9 +133,27 @@ pub struct DeliveryItem {
|
|||||||
/// Bei regulären Belegzeilen: `None`.
|
/// Bei regulären Belegzeilen: `None`.
|
||||||
pub komponenten_artikel_nr: Option<String>,
|
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,
|
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.
|
/// Notiz an einer Lieferung — frei eingegeben durch den Fahrer.
|
||||||
///
|
///
|
||||||
/// Mindestens eines von `text` oder `image_attachment` muss gesetzt
|
/// Mindestens eines von `text` oder `image_attachment` muss gesetzt
|
||||||
@ -131,5 +172,35 @@ pub struct DeliveryNote {
|
|||||||
pub author_personalnummer: i64,
|
pub author_personalnummer: i64,
|
||||||
/// Fahrzeug, falls bekannt — nullable bis das Backend Cars verwaltet.
|
/// Fahrzeug, falls bekannt — nullable bis das Backend Cars verwaltet.
|
||||||
pub author_car_id: Option<Uuid>,
|
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>,
|
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 audit;
|
||||||
mod car;
|
mod car;
|
||||||
mod common;
|
mod common;
|
||||||
|
mod contact;
|
||||||
mod customer;
|
mod customer;
|
||||||
mod delivery;
|
mod delivery;
|
||||||
|
mod payment;
|
||||||
mod process_state;
|
mod process_state;
|
||||||
|
mod service;
|
||||||
mod tour;
|
mod tour;
|
||||||
mod warehouse;
|
mod warehouse;
|
||||||
|
|
||||||
@ -31,8 +34,13 @@ pub use article::Article;
|
|||||||
pub use audit::{AuditAction, ScanAuditEntry};
|
pub use audit::{AuditAction, ScanAuditEntry};
|
||||||
pub use car::Car;
|
pub use car::Car;
|
||||||
pub use common::Address;
|
pub use common::Address;
|
||||||
|
pub use contact::{ContactChannel, ContactKind, ContactRole, ContactSource};
|
||||||
pub use customer::{Customer, CustomerContact};
|
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 process_state::{DeliveryPhase, DeliveryProcessState};
|
||||||
|
pub use service::{DeliveryServiceValue, Service, ServiceKind};
|
||||||
pub use tour::Tour;
|
pub use tour::Tour;
|
||||||
pub use warehouse::Warehouse;
|
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
|
uuid.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
tokio-util.workspace = true
|
||||||
sqlx.workspace = true
|
sqlx.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
|
tiberius.workspace = true
|
||||||
jsonwebtoken.workspace = true
|
jsonwebtoken.workspace = true
|
||||||
tracing.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).
|
//! auf das Domänenmodell (Personalnummer, Rollen).
|
||||||
|
|
||||||
pub mod keycloak;
|
pub mod keycloak;
|
||||||
|
pub mod keycloak_admin;
|
||||||
|
|
||||||
pub use keycloak::{KeycloakAdapterConfig, KeycloakAuthService};
|
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.
|
//! passenden Application-Ports.
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod erp;
|
||||||
|
pub mod gsd;
|
||||||
pub mod persistence;
|
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]
|
#[async_trait]
|
||||||
impl DeliveryNoteRepository for PgDeliveryNoteRepository {
|
impl DeliveryNoteRepository for PgDeliveryNoteRepository {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn create(
|
async fn create(
|
||||||
&self,
|
&self,
|
||||||
delivery_id: Uuid,
|
delivery_id: Uuid,
|
||||||
@ -35,6 +36,8 @@ impl DeliveryNoteRepository for PgDeliveryNoteRepository {
|
|||||||
author_car_id: Option<Uuid>,
|
author_car_id: Option<Uuid>,
|
||||||
text: Option<String>,
|
text: Option<String>,
|
||||||
image_attachment: Option<String>,
|
image_attachment: Option<String>,
|
||||||
|
credit_delivery_item_id: Option<Uuid>,
|
||||||
|
is_amount_credit_note: bool,
|
||||||
) -> Result<DeliveryNote, ApplicationError> {
|
) -> Result<DeliveryNote, ApplicationError> {
|
||||||
let exists: Option<Uuid> =
|
let exists: Option<Uuid> =
|
||||||
sqlx::query_scalar("SELECT id FROM deliveries WHERE id = $1")
|
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(
|
let (id, created_at): (Uuid, DateTime<Utc>) = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO delivery_notes (
|
INSERT INTO delivery_notes (
|
||||||
delivery_id, text, image_attachment, author_personalnummer, author_car_id
|
delivery_id, text, image_attachment, author_personalnummer,
|
||||||
) VALUES ($1, $2, $3, $4, $5)
|
author_car_id, credit_delivery_item_id, is_amount_credit_note
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
RETURNING id, created_at
|
RETURNING id, created_at
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@ -59,6 +63,8 @@ impl DeliveryNoteRepository for PgDeliveryNoteRepository {
|
|||||||
.bind(image_attachment.as_deref())
|
.bind(image_attachment.as_deref())
|
||||||
.bind(author_personalnummer)
|
.bind(author_personalnummer)
|
||||||
.bind(author_car_id)
|
.bind(author_car_id)
|
||||||
|
.bind(credit_delivery_item_id)
|
||||||
|
.bind(is_amount_credit_note)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(db)?;
|
.map_err(db)?;
|
||||||
@ -70,7 +76,86 @@ impl DeliveryNoteRepository for PgDeliveryNoteRepository {
|
|||||||
image_attachment,
|
image_attachment,
|
||||||
author_personalnummer,
|
author_personalnummer,
|
||||||
author_car_id,
|
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,
|
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>,
|
desired_time: Option<String>,
|
||||||
special_agreements: Option<String>,
|
special_agreements: Option<String>,
|
||||||
state: String,
|
state: String,
|
||||||
|
prepaid_amount: f64,
|
||||||
|
payment_method_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||||
@ -81,7 +83,15 @@ fn next_state(
|
|||||||
use DeliveryState as S;
|
use DeliveryState as S;
|
||||||
match (current, action) {
|
match (current, action) {
|
||||||
(S::Active, A::Hold { reason }) => Ok((S::Held, Some(reason.clone()))),
|
(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 | S::Held, A::Cancel { reason }) => Ok((S::Canceled, Some(reason.clone()))),
|
||||||
(S::Active, A::Complete) => Ok((S::Completed, None)),
|
(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,
|
id, tour_id, erp_belegart_id, erp_belegnummer, customer_id,
|
||||||
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
|
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
|
||||||
assigned_car_id, desired_time, special_agreements,
|
assigned_car_id, desired_time, special_agreements,
|
||||||
state
|
state, prepaid_amount, payment_method_id
|
||||||
FROM deliveries
|
FROM deliveries
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
@ -190,6 +200,8 @@ impl DeliveryRepository for PgDeliveryRepository {
|
|||||||
special_agreements: row.special_agreements,
|
special_agreements: row.special_agreements,
|
||||||
state: target,
|
state: target,
|
||||||
state_reason: new_reason,
|
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,
|
special_agreements: row.special_agreements,
|
||||||
state: current,
|
state: current,
|
||||||
state_reason,
|
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.
|
//! und Migrations werden ebenfalls hier verwaltet.
|
||||||
|
|
||||||
pub mod account_repository;
|
pub mod account_repository;
|
||||||
|
pub mod attachment_repository;
|
||||||
pub mod car_repository;
|
pub mod car_repository;
|
||||||
|
pub mod delivery_completion_repository;
|
||||||
|
pub mod delivery_credit_repository;
|
||||||
pub mod delivery_note_repository;
|
pub mod delivery_note_repository;
|
||||||
|
pub mod delivery_report_job_repository;
|
||||||
pub mod delivery_repository;
|
pub mod delivery_repository;
|
||||||
|
pub mod delivery_service_repository;
|
||||||
|
pub mod payment_method_repository;
|
||||||
pub mod pool;
|
pub mod pool;
|
||||||
pub mod scan_repository;
|
pub mod scan_repository;
|
||||||
|
pub mod service_repository;
|
||||||
pub mod tour_repository;
|
pub mod tour_repository;
|
||||||
|
|
||||||
pub use account_repository::PgAccountRepository;
|
pub use account_repository::PgAccountRepository;
|
||||||
|
pub use attachment_repository::PgAttachmentRepository;
|
||||||
pub use car_repository::PgCarRepository;
|
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_note_repository::PgDeliveryNoteRepository;
|
||||||
|
pub use delivery_report_job_repository::PgDeliveryReportJobRepository;
|
||||||
pub use delivery_repository::PgDeliveryRepository;
|
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 pool::{connect_and_migrate, PoolConfig};
|
||||||
pub use scan_repository::PgScanRepository;
|
pub use scan_repository::PgScanRepository;
|
||||||
|
pub use service_repository::PgServiceRepository;
|
||||||
pub use tour_repository::PgTourRepository;
|
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)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct ItemLockRow {
|
struct ItemLockRow {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
|
delivery_id: Uuid,
|
||||||
required_quantity: i32,
|
required_quantity: i32,
|
||||||
scanned_quantity: i32,
|
scanned_quantity: i32,
|
||||||
|
credited_quantity: i32,
|
||||||
scan_status: String,
|
scan_status: String,
|
||||||
held_reason: Option<String>,
|
held_reason: Option<String>,
|
||||||
scan_last_updated_at: DateTime<Utc>,
|
scan_last_updated_at: DateTime<Utc>,
|
||||||
@ -47,6 +49,11 @@ struct ItemLockRow {
|
|||||||
komponenten_artikel_nr: Option<String>,
|
komponenten_artikel_nr: Option<String>,
|
||||||
erp_belegart_id: i64,
|
erp_belegart_id: i64,
|
||||||
erp_belegnummer: String,
|
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 {
|
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||||
@ -81,40 +88,86 @@ fn action_str(a: AuditAction) -> &'static str {
|
|||||||
AuditAction::Hold => "hold",
|
AuditAction::Hold => "hold",
|
||||||
AuditAction::Unhold => "unhold",
|
AuditAction::Unhold => "unhold",
|
||||||
AuditAction::Remove => "remove",
|
AuditAction::Remove => "remove",
|
||||||
|
AuditAction::Unremove => "unremove",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ergebnis einer reinen Zustandsübergangs-Rechnung (ohne DB).
|
/// Ergebnis einer reinen Zustandsübergangs-Rechnung (ohne DB).
|
||||||
struct Transition {
|
struct Transition {
|
||||||
|
/// Signed Δ der SCAN-Menge (+1/-1 bei Scan/Unscan, sonst 0).
|
||||||
delta: i32,
|
delta: i32,
|
||||||
new_quantity: i32,
|
new_quantity: i32,
|
||||||
new_status: ScanStatus,
|
new_status: ScanStatus,
|
||||||
new_held_reason: Option<String>,
|
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
|
/// Berechnet den nächsten Zustand. Bei `Err` enthält der String die
|
||||||
/// fachliche Ablehnungs-Begründung, die 1:1 an die App geht.
|
/// 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(
|
fn apply_transition(
|
||||||
action: AuditAction,
|
action: AuditAction,
|
||||||
current_qty: i32,
|
item: &ItemSnapshot<'_>,
|
||||||
current_status: ScanStatus,
|
quantity: Option<i32>,
|
||||||
required_qty: i32,
|
|
||||||
reason: Option<&str>,
|
reason: Option<&str>,
|
||||||
) -> Result<Transition, String> {
|
) -> 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 {
|
match action {
|
||||||
AuditAction::Scan => match current_status {
|
AuditAction::Scan => match current_status {
|
||||||
ScanStatus::InProgress | ScanStatus::Done => {
|
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 {
|
let new_status = if new_qty >= required_qty {
|
||||||
ScanStatus::Done
|
ScanStatus::Done
|
||||||
} else {
|
} else {
|
||||||
ScanStatus::InProgress
|
ScanStatus::InProgress
|
||||||
};
|
};
|
||||||
Ok(Transition {
|
Ok(Transition {
|
||||||
delta: 1,
|
delta: n,
|
||||||
new_quantity: new_qty,
|
new_quantity: new_qty,
|
||||||
new_status,
|
new_status,
|
||||||
new_held_reason: None,
|
new_held_reason: None,
|
||||||
|
credit_delta: None,
|
||||||
|
new_credited_quantity: current_credited,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
ScanStatus::Held => Err("item is on hold; unhold before scanning".into()),
|
ScanStatus::Held => Err("item is on hold; unhold before scanning".into()),
|
||||||
@ -131,6 +184,8 @@ fn apply_transition(
|
|||||||
new_quantity: new_qty,
|
new_quantity: new_qty,
|
||||||
new_status: ScanStatus::InProgress,
|
new_status: ScanStatus::InProgress,
|
||||||
new_held_reason: None,
|
new_held_reason: None,
|
||||||
|
credit_delta: None,
|
||||||
|
new_credited_quantity: current_credited,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
ScanStatus::Held => Err("item is on hold".into()),
|
ScanStatus::Held => Err("item is on hold".into()),
|
||||||
@ -142,6 +197,8 @@ fn apply_transition(
|
|||||||
new_quantity: current_qty,
|
new_quantity: current_qty,
|
||||||
new_status: ScanStatus::Held,
|
new_status: ScanStatus::Held,
|
||||||
new_held_reason: reason.map(str::to_owned),
|
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::Held => Err("item is already held".into()),
|
||||||
ScanStatus::Removed => Err("item is removed".into()),
|
ScanStatus::Removed => Err("item is removed".into()),
|
||||||
@ -158,19 +215,101 @@ fn apply_transition(
|
|||||||
new_quantity: current_qty,
|
new_quantity: current_qty,
|
||||||
new_status,
|
new_status,
|
||||||
new_held_reason: None,
|
new_held_reason: None,
|
||||||
|
credit_delta: None,
|
||||||
|
new_credited_quantity: current_credited,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
_ => Err("item is not held".into()),
|
_ => Err("item is not held".into()),
|
||||||
},
|
},
|
||||||
AuditAction::Remove => match current_status {
|
|
||||||
ScanStatus::Removed => Err("item is already removed".into()),
|
// ── Mengen-Gutschrift ───────────────────────────────────────────
|
||||||
_ => Ok(Transition {
|
// 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,
|
delta: 0,
|
||||||
new_quantity: current_qty,
|
new_quantity: current_qty,
|
||||||
new_status: ScanStatus::Removed,
|
new_status,
|
||||||
new_held_reason: reason.map(str::to_owned),
|
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#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
di.id,
|
di.id,
|
||||||
|
di.delivery_id,
|
||||||
di.required_quantity,
|
di.required_quantity,
|
||||||
di.scanned_quantity,
|
di.scanned_quantity,
|
||||||
|
di.credited_quantity,
|
||||||
di.scan_status,
|
di.scan_status,
|
||||||
di.held_reason,
|
di.held_reason,
|
||||||
di.scan_last_updated_at,
|
di.scan_last_updated_at,
|
||||||
di.belegzeilen_nr,
|
di.belegzeilen_nr,
|
||||||
di.komponenten_artikel_nr,
|
di.komponenten_artikel_nr,
|
||||||
d.erp_belegart_id,
|
d.erp_belegart_id,
|
||||||
d.erp_belegnummer
|
d.erp_belegnummer,
|
||||||
|
a.scannable,
|
||||||
|
d.state AS delivery_state
|
||||||
FROM delivery_items di
|
FROM delivery_items di
|
||||||
JOIN deliveries d ON d.id = di.delivery_id
|
JOIN deliveries d ON d.id = di.delivery_id
|
||||||
|
JOIN articles a ON a.id = di.article_id
|
||||||
WHERE di.id = $1
|
WHERE di.id = $1
|
||||||
FOR UPDATE OF di
|
FOR UPDATE OF di
|
||||||
"#,
|
"#,
|
||||||
@ -203,6 +347,104 @@ async fn lock_item(
|
|||||||
.map_err(db)
|
.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]
|
#[async_trait]
|
||||||
impl ScanRepository for PgScanRepository {
|
impl ScanRepository for PgScanRepository {
|
||||||
async fn apply_one(
|
async fn apply_one(
|
||||||
@ -222,16 +464,46 @@ impl ScanRepository for PgScanRepository {
|
|||||||
let current_status = parse_status(&item.scan_status)?;
|
let current_status = parse_status(&item.scan_status)?;
|
||||||
let current_state = ScanState {
|
let current_state = ScanState {
|
||||||
scanned_quantity: item.scanned_quantity,
|
scanned_quantity: item.scanned_quantity,
|
||||||
|
credited_quantity: item.credited_quantity,
|
||||||
status: current_status,
|
status: current_status,
|
||||||
held_reason: item.held_reason.clone(),
|
held_reason: item.held_reason.clone(),
|
||||||
last_updated_at: item.scan_last_updated_at,
|
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(
|
let transition = match apply_transition(
|
||||||
event.action,
|
event.action,
|
||||||
item.scanned_quantity,
|
&snapshot,
|
||||||
current_status,
|
event.quantity,
|
||||||
item.required_quantity,
|
|
||||||
event.reason.as_deref(),
|
event.reason.as_deref(),
|
||||||
) {
|
) {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
@ -250,8 +522,9 @@ impl ScanRepository for PgScanRepository {
|
|||||||
delta, resulting_quantity, resulting_status,
|
delta, resulting_quantity, resulting_status,
|
||||||
reason, actor_personalnummer, actor_car_id, client_scanned_at,
|
reason, actor_personalnummer, actor_car_id, client_scanned_at,
|
||||||
erp_belegart_id, erp_belegnummer, erp_belegzeilen_nr,
|
erp_belegart_id, erp_belegnummer, erp_belegzeilen_nr,
|
||||||
erp_komponenten_artikel_nr
|
erp_komponenten_artikel_nr,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
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
|
ON CONFLICT (client_scan_id) DO NOTHING
|
||||||
RETURNING id
|
RETURNING id
|
||||||
"#,
|
"#,
|
||||||
@ -270,6 +543,11 @@ impl ScanRepository for PgScanRepository {
|
|||||||
.bind(&item.erp_belegnummer)
|
.bind(&item.erp_belegnummer)
|
||||||
.bind(item.belegzeilen_nr)
|
.bind(item.belegzeilen_nr)
|
||||||
.bind(item.komponenten_artikel_nr.as_deref())
|
.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)
|
.fetch_optional(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(db)?;
|
.map_err(db)?;
|
||||||
@ -289,14 +567,16 @@ impl ScanRepository for PgScanRepository {
|
|||||||
r#"
|
r#"
|
||||||
UPDATE delivery_items
|
UPDATE delivery_items
|
||||||
SET scanned_quantity = $1,
|
SET scanned_quantity = $1,
|
||||||
scan_status = $2,
|
credited_quantity = $2,
|
||||||
held_reason = $3,
|
scan_status = $3,
|
||||||
|
held_reason = $4,
|
||||||
scan_last_updated_at = now()
|
scan_last_updated_at = now()
|
||||||
WHERE id = $4
|
WHERE id = $5
|
||||||
RETURNING scan_last_updated_at
|
RETURNING scan_last_updated_at
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(transition.new_quantity)
|
.bind(transition.new_quantity)
|
||||||
|
.bind(transition.new_credited_quantity)
|
||||||
.bind(status_str(transition.new_status))
|
.bind(status_str(transition.new_status))
|
||||||
.bind(transition.new_held_reason.as_deref())
|
.bind(transition.new_held_reason.as_deref())
|
||||||
.bind(item.id)
|
.bind(item.id)
|
||||||
@ -304,12 +584,26 @@ impl ScanRepository for PgScanRepository {
|
|||||||
.await
|
.await
|
||||||
.map_err(db)?;
|
.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)?;
|
tx.commit().await.map_err(db)?;
|
||||||
|
|
||||||
Ok(ApplyScanOutcome::Applied {
|
Ok(ApplyScanOutcome::Applied {
|
||||||
delivery_item_id: item.id,
|
delivery_item_id: item.id,
|
||||||
new_state: ScanState {
|
new_state: ScanState {
|
||||||
scanned_quantity: transition.new_quantity,
|
scanned_quantity: transition.new_quantity,
|
||||||
|
credited_quantity: transition.new_credited_quantity,
|
||||||
status: transition.new_status,
|
status: transition.new_status,
|
||||||
held_reason: transition.new_held_reason,
|
held_reason: transition.new_held_reason,
|
||||||
last_updated_at: new_last_updated,
|
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 uuid::Uuid;
|
||||||
|
|
||||||
use holzleitner_application::dto::{
|
use holzleitner_application::dto::{
|
||||||
DeliveryOrderEntry, DeliveryWithItems, SyncDelivery, SyncDeliveryItem, SyncTourRequest,
|
DeliveryOrderEntry, DeliveryWithItems, SyncContactSource, SyncDelivery, SyncDeliveryItem,
|
||||||
TourDetails, TourSummary,
|
SyncTourRequest, TourDetails, TourSummary,
|
||||||
};
|
};
|
||||||
use holzleitner_application::error::ApplicationError;
|
use holzleitner_application::error::ApplicationError;
|
||||||
use holzleitner_application::ports::TourRepository;
|
use holzleitner_application::ports::TourRepository;
|
||||||
use holzleitner_domain::{
|
use holzleitner_domain::{
|
||||||
Address, Article, Customer, CustomerContact, Delivery, DeliveryItem, DeliveryNote,
|
Address, Article, ContactChannel, ContactKind, ContactRole, ContactSource, Customer,
|
||||||
DeliveryState, ScanState, ScanStatus, Tour, Warehouse,
|
CustomerContact, Delivery, DeliveryCredit, DeliveryItem, DeliveryNote, DeliveryServiceValue,
|
||||||
|
DeliveryState, ScanState, ScanStatus, Service, ServiceKind, Tour, Warehouse,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct PgTourRepository {
|
pub struct PgTourRepository {
|
||||||
@ -78,6 +79,8 @@ struct DeliveryRow {
|
|||||||
state: String,
|
state: String,
|
||||||
state_reason: Option<String>,
|
state_reason: Option<String>,
|
||||||
sort_order: i32,
|
sort_order: i32,
|
||||||
|
prepaid_amount: f64,
|
||||||
|
payment_method_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
@ -87,9 +90,12 @@ struct DeliveryItemRow {
|
|||||||
article_id: Uuid,
|
article_id: Uuid,
|
||||||
required_quantity: i32,
|
required_quantity: i32,
|
||||||
warehouse_id: Uuid,
|
warehouse_id: Uuid,
|
||||||
|
unit_price: f64,
|
||||||
belegzeilen_nr: i32,
|
belegzeilen_nr: i32,
|
||||||
komponenten_artikel_nr: Option<String>,
|
komponenten_artikel_nr: Option<String>,
|
||||||
|
parent_artikel_nr: Option<String>,
|
||||||
scanned_quantity: i32,
|
scanned_quantity: i32,
|
||||||
|
credited_quantity: i32,
|
||||||
scan_status: String,
|
scan_status: String,
|
||||||
held_reason: Option<String>,
|
held_reason: Option<String>,
|
||||||
scan_last_updated_at: DateTime<Utc>,
|
scan_last_updated_at: DateTime<Utc>,
|
||||||
@ -139,6 +145,29 @@ struct ContactLinkRow {
|
|||||||
customer_contact_id: Uuid,
|
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)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct DeliveryNoteRow {
|
struct DeliveryNoteRow {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
@ -147,6 +176,9 @@ struct DeliveryNoteRow {
|
|||||||
image_attachment: Option<String>,
|
image_attachment: Option<String>,
|
||||||
author_personalnummer: i64,
|
author_personalnummer: i64,
|
||||||
author_car_id: Option<Uuid>,
|
author_car_id: Option<Uuid>,
|
||||||
|
credit_delivery_item_id: Option<Uuid>,
|
||||||
|
is_amount_credit_note: bool,
|
||||||
|
image_attachment_deleted: bool,
|
||||||
created_at: DateTime<Utc>,
|
created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -192,10 +224,13 @@ fn map_item(row: DeliveryItemRow) -> Result<DeliveryItem, ApplicationError> {
|
|||||||
article_id: row.article_id,
|
article_id: row.article_id,
|
||||||
required_quantity: row.required_quantity,
|
required_quantity: row.required_quantity,
|
||||||
warehouse_id: row.warehouse_id,
|
warehouse_id: row.warehouse_id,
|
||||||
|
unit_price: row.unit_price,
|
||||||
belegzeilen_nr: row.belegzeilen_nr,
|
belegzeilen_nr: row.belegzeilen_nr,
|
||||||
komponenten_artikel_nr: row.komponenten_artikel_nr,
|
komponenten_artikel_nr: row.komponenten_artikel_nr,
|
||||||
|
parent_artikel_nr: row.parent_artikel_nr,
|
||||||
scan_state: ScanState {
|
scan_state: ScanState {
|
||||||
scanned_quantity: row.scanned_quantity,
|
scanned_quantity: row.scanned_quantity,
|
||||||
|
credited_quantity: row.credited_quantity,
|
||||||
status: parse_scan_status(&row.scan_status)?,
|
status: parse_scan_status(&row.scan_status)?,
|
||||||
held_reason: row.held_reason,
|
held_reason: row.held_reason,
|
||||||
last_updated_at: row.scan_last_updated_at,
|
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 {
|
fn map_article(row: ArticleRow) -> Article {
|
||||||
Article {
|
Article {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@ -246,10 +306,85 @@ fn map_note(row: DeliveryNoteRow) -> DeliveryNote {
|
|||||||
image_attachment: row.image_attachment,
|
image_attachment: row.image_attachment,
|
||||||
author_personalnummer: row.author_personalnummer,
|
author_personalnummer: row.author_personalnummer,
|
||||||
author_car_id: row.author_car_id,
|
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,
|
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 {
|
fn map_warehouse(row: WarehouseRow) -> Warehouse {
|
||||||
Warehouse {
|
Warehouse {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@ -283,6 +418,8 @@ fn map_delivery(
|
|||||||
special_agreements: row.special_agreements,
|
special_agreements: row.special_agreements,
|
||||||
state,
|
state,
|
||||||
state_reason: row.state_reason,
|
state_reason: row.state_reason,
|
||||||
|
prepaid_amount: row.prepaid_amount,
|
||||||
|
payment_method_id: row.payment_method_id,
|
||||||
};
|
};
|
||||||
Ok((delivery, row.sort_order))
|
Ok((delivery, row.sort_order))
|
||||||
}
|
}
|
||||||
@ -352,7 +489,8 @@ impl TourRepository for PgTourRepository {
|
|||||||
id, tour_id, erp_belegart_id, erp_belegnummer, customer_id,
|
id, tour_id, erp_belegart_id, erp_belegnummer, customer_id,
|
||||||
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
|
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
|
||||||
assigned_car_id, desired_time, special_agreements,
|
assigned_car_id, desired_time, special_agreements,
|
||||||
state, state_reason, sort_order
|
state, state_reason, sort_order,
|
||||||
|
prepaid_amount, payment_method_id
|
||||||
FROM deliveries
|
FROM deliveries
|
||||||
WHERE tour_id = $1
|
WHERE tour_id = $1
|
||||||
ORDER BY sort_order, erp_belegnummer
|
ORDER BY sort_order, erp_belegnummer
|
||||||
@ -391,8 +529,9 @@ impl TourRepository for PgTourRepository {
|
|||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
id, delivery_id, article_id, required_quantity, warehouse_id,
|
id, delivery_id, article_id, required_quantity, warehouse_id,
|
||||||
belegzeilen_nr, komponenten_artikel_nr,
|
unit_price, belegzeilen_nr, komponenten_artikel_nr, parent_artikel_nr,
|
||||||
scanned_quantity, scan_status, held_reason, scan_last_updated_at
|
scanned_quantity, credited_quantity, scan_status, held_reason,
|
||||||
|
scan_last_updated_at
|
||||||
FROM delivery_items
|
FROM delivery_items
|
||||||
WHERE delivery_id = ANY($1)
|
WHERE delivery_id = ANY($1)
|
||||||
ORDER BY delivery_id, belegzeilen_nr, komponenten_artikel_nr NULLS FIRST
|
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.
|
// 7. Notizen aller Lieferungen dieser Tour.
|
||||||
let notes = sqlx::query_as::<_, DeliveryNoteRow>(
|
let notes = sqlx::query_as::<_, DeliveryNoteRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, delivery_id, text, image_attachment,
|
SELECT dn.id, dn.delivery_id, dn.text, dn.image_attachment,
|
||||||
author_personalnummer, author_car_id, created_at
|
dn.author_personalnummer, dn.author_car_id,
|
||||||
FROM delivery_notes
|
dn.credit_delivery_item_id, dn.is_amount_credit_note,
|
||||||
WHERE delivery_id = ANY($1)
|
(att.deleted_at IS NOT NULL) AS image_attachment_deleted,
|
||||||
ORDER BY delivery_id, created_at
|
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)
|
.bind(&delivery_ids)
|
||||||
@ -519,6 +662,99 @@ impl TourRepository for PgTourRepository {
|
|||||||
.map(map_note)
|
.map(map_note)
|
||||||
.collect::<Vec<_>>();
|
.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 {
|
Ok(Some(TourDetails {
|
||||||
tour,
|
tour,
|
||||||
deliveries,
|
deliveries,
|
||||||
@ -527,6 +763,11 @@ impl TourRepository for PgTourRepository {
|
|||||||
articles,
|
articles,
|
||||||
warehouses,
|
warehouses,
|
||||||
notes,
|
notes,
|
||||||
|
credits,
|
||||||
|
services,
|
||||||
|
delivery_services,
|
||||||
|
contact_sources,
|
||||||
|
contact_channels,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -603,6 +844,23 @@ impl TourRepository for PgTourRepository {
|
|||||||
) -> Result<Uuid, ApplicationError> {
|
) -> Result<Uuid, ApplicationError> {
|
||||||
let mut tx = self.pool.begin().await.map_err(db)?;
|
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)
|
// 1. Tour upserten — Identität: (account_id, tour_date)
|
||||||
let tour_id: Uuid = sqlx::query_scalar(
|
let tour_id: Uuid = sqlx::query_scalar(
|
||||||
r#"
|
r#"
|
||||||
@ -628,6 +886,11 @@ impl TourRepository for PgTourRepository {
|
|||||||
// erhalten; nur Stammdaten + sort_order werden refresht.
|
// erhalten; nur Stammdaten + sort_order werden refresht.
|
||||||
let delivery_id = upsert_delivery(&mut tx, tour_id, customer_id, delivery).await?;
|
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 {
|
for item in &delivery.items {
|
||||||
let warehouse_id = upsert_warehouse(&mut tx, item).await?;
|
let warehouse_id = upsert_warehouse(&mut tx, item).await?;
|
||||||
let article_id = upsert_article(&mut tx, item, warehouse_id).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)?;
|
tx.commit().await.map_err(db)?;
|
||||||
Ok(tour_id)
|
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 =====================================================
|
// ===== Upsert-Helfer =====================================================
|
||||||
@ -741,15 +1015,39 @@ async fn upsert_delivery(
|
|||||||
customer_id: Uuid,
|
customer_id: Uuid,
|
||||||
delivery: &SyncDelivery,
|
delivery: &SyncDelivery,
|
||||||
) -> Result<Uuid, ApplicationError> {
|
) -> 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(
|
let id: Uuid = sqlx::query_scalar(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO deliveries (
|
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,
|
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
|
||||||
sort_order, desired_time, special_agreements
|
sort_order, desired_time, special_agreements,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
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
|
ON CONFLICT (erp_belegart_id, erp_belegnummer) DO UPDATE SET
|
||||||
tour_id = EXCLUDED.tour_id,
|
tour_id = EXCLUDED.tour_id,
|
||||||
|
erp_belegart_code = EXCLUDED.erp_belegart_code,
|
||||||
|
erp_belegart_name = EXCLUDED.erp_belegart_name,
|
||||||
customer_id = EXCLUDED.customer_id,
|
customer_id = EXCLUDED.customer_id,
|
||||||
snap_street = EXCLUDED.snap_street,
|
snap_street = EXCLUDED.snap_street,
|
||||||
snap_house_number = EXCLUDED.snap_house_number,
|
snap_house_number = EXCLUDED.snap_house_number,
|
||||||
@ -758,12 +1056,16 @@ async fn upsert_delivery(
|
|||||||
snap_country = EXCLUDED.snap_country,
|
snap_country = EXCLUDED.snap_country,
|
||||||
sort_order = EXCLUDED.sort_order,
|
sort_order = EXCLUDED.sort_order,
|
||||||
desired_time = EXCLUDED.desired_time,
|
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
|
RETURNING id
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(tour_id)
|
.bind(tour_id)
|
||||||
.bind(delivery.belegart_id)
|
.bind(delivery.belegart_id)
|
||||||
|
.bind(delivery.belegart_code.as_deref())
|
||||||
|
.bind(delivery.belegart_name.as_deref())
|
||||||
.bind(&delivery.belegnummer)
|
.bind(&delivery.belegnummer)
|
||||||
.bind(customer_id)
|
.bind(customer_id)
|
||||||
.bind(&delivery.delivery_address.street)
|
.bind(&delivery.delivery_address.street)
|
||||||
@ -774,6 +1076,8 @@ async fn upsert_delivery(
|
|||||||
.bind(delivery.sort_order)
|
.bind(delivery.sort_order)
|
||||||
.bind(delivery.desired_time.as_deref())
|
.bind(delivery.desired_time.as_deref())
|
||||||
.bind(delivery.special_agreements.as_deref())
|
.bind(delivery.special_agreements.as_deref())
|
||||||
|
.bind(delivery.prepaid_amount)
|
||||||
|
.bind(payment_method_id)
|
||||||
.fetch_one(&mut **tx)
|
.fetch_one(&mut **tx)
|
||||||
.await
|
.await
|
||||||
.map_err(db)?;
|
.map_err(db)?;
|
||||||
@ -794,22 +1098,129 @@ async fn upsert_delivery_item(
|
|||||||
r#"
|
r#"
|
||||||
INSERT INTO delivery_items (
|
INSERT INTO delivery_items (
|
||||||
delivery_id, article_id, required_quantity, warehouse_id,
|
delivery_id, article_id, required_quantity, warehouse_id,
|
||||||
belegzeilen_nr, komponenten_artikel_nr
|
unit_price, belegzeilen_nr, komponenten_artikel_nr, parent_artikel_nr
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
ON CONFLICT (delivery_id, belegzeilen_nr, komponenten_artikel_nr) DO UPDATE SET
|
ON CONFLICT (delivery_id, belegzeilen_nr, komponenten_artikel_nr) DO UPDATE SET
|
||||||
article_id = EXCLUDED.article_id,
|
article_id = EXCLUDED.article_id,
|
||||||
required_quantity = EXCLUDED.required_quantity,
|
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(delivery_id)
|
||||||
.bind(article_id)
|
.bind(article_id)
|
||||||
.bind(item.required_quantity)
|
.bind(item.required_quantity)
|
||||||
.bind(warehouse_id)
|
.bind(warehouse_id)
|
||||||
|
.bind(item.unit_price)
|
||||||
.bind(item.belegzeilen_nr)
|
.bind(item.belegzeilen_nr)
|
||||||
.bind(item.komponenten_artikel_nr.as_deref())
|
.bind(item.komponenten_artikel_nr.as_deref())
|
||||||
|
.bind(item.parent_artikel_nr.as_deref())
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await
|
.await
|
||||||
.map_err(db)?;
|
.map_err(db)?;
|
||||||
Ok(())
|
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