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:
@ -15,7 +15,14 @@ serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-util.workspace = true
|
||||
sqlx.workspace = true
|
||||
reqwest.workspace = true
|
||||
tiberius.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
tracing.workspace = true
|
||||
# PDF-Report-Generierung (Lieferabschluss). printpdf nutzt eingebaute
|
||||
# Standard-Fonts (Helvetica, WinAnsi → deutsche Umlaute) → kein Font-Asset.
|
||||
# `image` dekodiert Unterschriften/Foto-Notizen zu Roh-RGB fürs Einbetten.
|
||||
printpdf = "0.7"
|
||||
image = "0.25"
|
||||
|
||||
298
crates/infrastructure/src/auth/keycloak_admin.rs
Normal file
298
crates/infrastructure/src/auth/keycloak_admin.rs
Normal file
@ -0,0 +1,298 @@
|
||||
//! Keycloak-Admin-Adapter — Implementierung von [`DriverIdentityProvisioner`].
|
||||
//!
|
||||
//! Legt beim ERP-Sync Fahrer-Konten im Realm an. Authentifiziert sich als
|
||||
//! **Service-Account** (confidential Client `holzleitner-provisioner`,
|
||||
//! `client_credentials`) mit der `realm-management`-Rolle `manage-users`.
|
||||
//!
|
||||
//! Ablauf je Fahrer (idempotent):
|
||||
//! 1. Admin-Token holen (`client_credentials`).
|
||||
//! 2. User per `?username=<nr>&exact=true` suchen → existiert ⇒ No-Op.
|
||||
//! 3. `POST users` mit `username=<nr>`, Attribut `personalnummer=[<nr>]`,
|
||||
//! temporärem Passwort (`temporary:true`) und Required-Action
|
||||
//! `UPDATE_PASSWORD` (Zwangsänderung beim ersten Login).
|
||||
//! 4. Realm-Rolle `driver` zuweisen (`role-mappings/realm`).
|
||||
//!
|
||||
//! Bewusst **kein** Passwort-Reset für bestehende User — wer sein Passwort
|
||||
//! schon gesetzt hat, behält es.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::{DriverIdentityProvisioner, ProvisionOutcome};
|
||||
|
||||
/// Konfiguration des Keycloak-Admin-Adapters (aus `KEYCLOAK_*`-Env).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KeycloakAdminConfig {
|
||||
/// Basis-URL der Keycloak-Instanz **ohne** `/realms/...`, z. B.
|
||||
/// `http://localhost:8080`.
|
||||
pub base_url: String,
|
||||
/// Realm-Name, z. B. `holzleitner`.
|
||||
pub realm: String,
|
||||
/// Service-Account-Client (confidential) für die Admin-API.
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
/// Default-Passwort, das neuen Konten als **temporär** gesetzt wird
|
||||
/// (muss beim ersten Login geändert werden).
|
||||
pub default_password: String,
|
||||
/// Realm-Rolle, die jedem Fahrer zugewiesen wird (z. B. `driver`).
|
||||
pub driver_role: String,
|
||||
}
|
||||
|
||||
pub struct KeycloakAdminClient {
|
||||
config: KeycloakAdminConfig,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl KeycloakAdminClient {
|
||||
pub fn new(config: KeycloakAdminConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
http: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn base(&self) -> &str {
|
||||
self.config.base_url.trim_end_matches('/')
|
||||
}
|
||||
|
||||
/// Holt ein Admin-Access-Token via `client_credentials`.
|
||||
async fn admin_token(&self) -> Result<String, ApplicationError> {
|
||||
let url = format!(
|
||||
"{}/realms/{}/protocol/openid-connect/token",
|
||||
self.base(),
|
||||
self.config.realm
|
||||
);
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.form(&[
|
||||
("grant_type", "client_credentials"),
|
||||
("client_id", self.config.client_id.as_str()),
|
||||
("client_secret", self.config.client_secret.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(ext)?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ApplicationError::External(format!(
|
||||
"keycloak token ({status}): {body}"
|
||||
)));
|
||||
}
|
||||
let token: TokenResponse = resp.json().await.map_err(ext)?;
|
||||
Ok(token.access_token)
|
||||
}
|
||||
|
||||
/// Sucht einen User per exaktem Benutzernamen. `Some(id)` ⇒ existiert.
|
||||
async fn find_user_id(
|
||||
&self,
|
||||
token: &str,
|
||||
username: &str,
|
||||
) -> Result<Option<String>, ApplicationError> {
|
||||
let url = format!("{}/admin/realms/{}/users", self.base(), self.config.realm);
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.bearer_auth(token)
|
||||
.query(&[("username", username), ("exact", "true")])
|
||||
.send()
|
||||
.await
|
||||
.map_err(ext)?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ApplicationError::External(format!(
|
||||
"keycloak user lookup ({status}): {body}"
|
||||
)));
|
||||
}
|
||||
let users: Vec<UserRep> = resp.json().await.map_err(ext)?;
|
||||
Ok(users.into_iter().find_map(|u| u.id))
|
||||
}
|
||||
|
||||
/// Legt einen neuen User an und gibt dessen ID zurück.
|
||||
async fn create_user(
|
||||
&self,
|
||||
token: &str,
|
||||
username: &str,
|
||||
display_name: Option<&str>,
|
||||
) -> Result<String, ApplicationError> {
|
||||
let url = format!("{}/admin/realms/{}/users", self.base(), self.config.realm);
|
||||
let body = json!({
|
||||
"username": username,
|
||||
"enabled": true,
|
||||
"firstName": display_name.unwrap_or(username),
|
||||
"attributes": { "personalnummer": [username] },
|
||||
"requiredActions": ["UPDATE_PASSWORD"],
|
||||
"credentials": [{
|
||||
"type": "password",
|
||||
"value": self.config.default_password,
|
||||
"temporary": true
|
||||
}]
|
||||
});
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.bearer_auth(token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(ext)?;
|
||||
|
||||
let status = resp.status();
|
||||
if status.as_u16() == 409 {
|
||||
// Race: zwischen Lookup und Create angelegt — als „existiert"
|
||||
// behandeln und ID nachschlagen.
|
||||
return self
|
||||
.find_user_id(token, username)
|
||||
.await?
|
||||
.ok_or_else(|| ApplicationError::External("keycloak 409 ohne User".into()));
|
||||
}
|
||||
if !status.is_success() {
|
||||
let txt = resp.text().await.unwrap_or_default();
|
||||
return Err(ApplicationError::External(format!(
|
||||
"keycloak create user ({status}): {txt}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Keycloak liefert die ID im `Location`-Header (.../users/{id}).
|
||||
if let Some(loc) = resp.headers().get(reqwest::header::LOCATION) {
|
||||
if let Ok(s) = loc.to_str() {
|
||||
if let Some(id) = s.rsplit('/').next() {
|
||||
if !id.is_empty() {
|
||||
return Ok(id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: erneut suchen.
|
||||
self.find_user_id(token, username)
|
||||
.await?
|
||||
.ok_or_else(|| ApplicationError::External("keycloak: User nach Create nicht gefunden".into()))
|
||||
}
|
||||
|
||||
/// Weist dem User die Realm-Rolle `driver_role` zu (idempotent).
|
||||
///
|
||||
/// Bewusst über den **user-scoped** Endpoint `role-mappings/realm/available`
|
||||
/// statt `GET /roles/{name}` — letzterer bräuchte `view-realm`; ersterer
|
||||
/// kommt mit `manage-users` aus (least privilege). Ist die Rolle nicht mehr
|
||||
/// „available", ist sie bereits zugewiesen (oder existiert nicht → wir
|
||||
/// prüfen die effektiven Zuweisungen und liefern sonst einen Fehler).
|
||||
async fn assign_driver_role(
|
||||
&self,
|
||||
token: &str,
|
||||
user_id: &str,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let role_name = self.config.driver_role.as_str();
|
||||
let base_map = format!(
|
||||
"{}/admin/realms/{}/users/{}/role-mappings/realm",
|
||||
self.base(),
|
||||
self.config.realm,
|
||||
user_id
|
||||
);
|
||||
|
||||
// 1. Zuweisbare Realm-Rollen des Users holen, `driver` suchen.
|
||||
let available: Vec<Value> = self
|
||||
.http
|
||||
.get(format!("{base_map}/available"))
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(ext)?
|
||||
.error_for_status()
|
||||
.map_err(ext)?
|
||||
.json()
|
||||
.await
|
||||
.map_err(ext)?;
|
||||
|
||||
let role = available
|
||||
.into_iter()
|
||||
.find(|r| r.get("name").and_then(Value::as_str) == Some(role_name));
|
||||
|
||||
let Some(role) = role else {
|
||||
// Nicht „available" → entweder schon zugewiesen (idempotenter
|
||||
// No-Op) oder Rolle existiert nicht. Effektive Zuweisungen prüfen.
|
||||
let assigned: Vec<Value> = self
|
||||
.http
|
||||
.get(&base_map)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(ext)?
|
||||
.error_for_status()
|
||||
.map_err(ext)?
|
||||
.json()
|
||||
.await
|
||||
.map_err(ext)?;
|
||||
let has = assigned
|
||||
.iter()
|
||||
.any(|r| r.get("name").and_then(Value::as_str) == Some(role_name));
|
||||
return if has {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApplicationError::External(format!(
|
||||
"keycloak: Realm-Rolle '{role_name}' existiert nicht"
|
||||
)))
|
||||
};
|
||||
};
|
||||
|
||||
// 2. Rolle zuweisen.
|
||||
let resp = self
|
||||
.http
|
||||
.post(&base_map)
|
||||
.bearer_auth(token)
|
||||
.json(&json!([role]))
|
||||
.send()
|
||||
.await
|
||||
.map_err(ext)?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ApplicationError::External(format!(
|
||||
"keycloak assign role ({status}): {body}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DriverIdentityProvisioner for KeycloakAdminClient {
|
||||
async fn ensure_driver(
|
||||
&self,
|
||||
personalnummer: i64,
|
||||
display_name: Option<&str>,
|
||||
) -> Result<ProvisionOutcome, ApplicationError> {
|
||||
let username = personalnummer.to_string();
|
||||
let token = self.admin_token().await?;
|
||||
|
||||
if self.find_user_id(&token, &username).await?.is_some() {
|
||||
tracing::debug!(personalnummer, "keycloak_provision: user existiert bereits");
|
||||
return Ok(ProvisionOutcome { created: false });
|
||||
}
|
||||
|
||||
let user_id = self.create_user(&token, &username, display_name).await?;
|
||||
self.assign_driver_role(&token, &user_id).await?;
|
||||
tracing::info!(personalnummer, user_id, "keycloak_provision: Fahrer-Konto angelegt");
|
||||
Ok(ProvisionOutcome { created: true })
|
||||
}
|
||||
}
|
||||
|
||||
fn ext<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::External(e.to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UserRep {
|
||||
id: Option<String>,
|
||||
}
|
||||
@ -6,5 +6,7 @@
|
||||
//! auf das Domänenmodell (Personalnummer, Rollen).
|
||||
|
||||
pub mod keycloak;
|
||||
pub mod keycloak_admin;
|
||||
|
||||
pub use keycloak::{KeycloakAdapterConfig, KeycloakAuthService};
|
||||
pub use keycloak_admin::{KeycloakAdminClient, KeycloakAdminConfig};
|
||||
|
||||
11
crates/infrastructure/src/erp/mod.rs
Normal file
11
crates/infrastructure/src/erp/mod.rs
Normal file
@ -0,0 +1,11 @@
|
||||
//! ERP-Lese-Adapter (ERPframe / MS SQL Server).
|
||||
//!
|
||||
//! Implementiert den `ErpDeliverySource`-Port gegen die ERPframe-Datenbank
|
||||
//! via `tiberius` (nativer async MSSQL-Treiber). Reine Lese-Operation; das
|
||||
//! ERP wird nicht zurückgeschrieben.
|
||||
|
||||
pub mod mssql_delivery_source;
|
||||
pub mod mssql_delivery_writeback;
|
||||
|
||||
pub use mssql_delivery_source::{MssqlErpConfig, MssqlErpDeliverySource};
|
||||
pub use mssql_delivery_writeback::MssqlErpDeliveryWriteback;
|
||||
586
crates/infrastructure/src/erp/mssql_delivery_source.rs
Normal file
586
crates/infrastructure/src/erp/mssql_delivery_source.rs
Normal file
@ -0,0 +1,586 @@
|
||||
//! MSSQL-Adapter für den `ErpDeliverySource`-Port.
|
||||
//!
|
||||
//! Liest die Lieferungen eines Tages direkt aus den ERPframe-Basistabellen
|
||||
//! (Belegart `VL5` / Lieferschein) und gruppiert sie zu `SyncTourRequest`-
|
||||
//! DTOs (eine pro Fahrer). Kein Connection-Pool: der Pull läuft einmal
|
||||
//! täglich, eine frische Verbindung pro Lauf genügt.
|
||||
//!
|
||||
//! Die SELECT-Query löst **Stücklisten** auf (`UNION ALL`, analog zur Alt-View
|
||||
//! `_SV_APP_DELIVERIES_TODAY`): Oberartikel werden non-scannable als Preis-/
|
||||
//! Gruppenträger geführt, jede Komponente als eigener (scanbarer) Artikel. Das
|
||||
//! Lager kommt pro Zeile aus `bz.Lagerverteilung` (XML), Fallback `bk.Lager`.
|
||||
//! Numerische Spalten werden serverseitig auf feste Typen gecastet
|
||||
//! (BIGINT/INT/FLOAT/BIT), damit die tiberius-Reads deterministisch sind.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::NaiveDate;
|
||||
use tiberius::{AuthMethod, Client, Config};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_util::compat::TokioAsyncWriteCompatExt;
|
||||
|
||||
use holzleitner_application::dto::{
|
||||
SyncContactChannel, SyncContactSource, SyncDelivery, SyncDeliveryItem, SyncTourRequest,
|
||||
};
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::ErpDeliverySource;
|
||||
use holzleitner_domain::{Address, ContactKind, ContactRole};
|
||||
|
||||
/// Verbindungsparameter zur ERPframe-MSSQL.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MssqlErpConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub database: String,
|
||||
pub user: String,
|
||||
pub password: String,
|
||||
/// Selbstsigniertes Server-Zertifikat akzeptieren (lokale/Intranet-DB).
|
||||
pub trust_cert: bool,
|
||||
}
|
||||
|
||||
pub struct MssqlErpDeliverySource {
|
||||
config: MssqlErpConfig,
|
||||
}
|
||||
|
||||
impl MssqlErpDeliverySource {
|
||||
pub fn new(config: MssqlErpConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
fn tiberius_config(&self) -> Config {
|
||||
let mut cfg = Config::new();
|
||||
cfg.host(&self.config.host);
|
||||
cfg.port(self.config.port);
|
||||
cfg.database(&self.config.database);
|
||||
cfg.authentication(AuthMethod::sql_server(
|
||||
&self.config.user,
|
||||
&self.config.password,
|
||||
));
|
||||
if self.config.trust_cert {
|
||||
cfg.trust_cert();
|
||||
}
|
||||
cfg
|
||||
}
|
||||
}
|
||||
|
||||
fn repo<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
/// Adress-Aliases, ihre FK-Quelle am Belegkopf und der Präfix, mit dem alle
|
||||
/// Kontaktspalten der jeweiligen Adresse in der SELECT-Liste verbiegt werden.
|
||||
/// `adr` ist die Belegadresse (auch heute schon Pflicht-JOIN für die
|
||||
/// Anschrift); die übrigen vier sind LEFT-JOINs. Reihenfolge entspricht der
|
||||
/// `ContactRole`-Enum.
|
||||
const ADDRESS_ALIASES: &[(&str, &str)] = &[
|
||||
("adr", "hdr"), // header – bk.AdressId
|
||||
("dadr", "dlv"), // delivery – bk.LieferAdressId
|
||||
("radr", "bll"), // billing – bk.RechnungsAdressId
|
||||
("kadr", "ctp"), // contact_person – bk.AnsprechpartnerId
|
||||
("sadr", "cms"), // customer_master – Kunden.AdressId (über bk.KundenId)
|
||||
];
|
||||
|
||||
/// Spalten, die wir pro Adresse selektieren. Reihenfolge irrelevant; Aliase
|
||||
/// werden im Mapper über `<präfix>_<spalte>` aufgelöst.
|
||||
const ADDRESS_CONTACT_COLUMNS: &[&str] = &[
|
||||
"Anrede", "Titel", "Name1", "Name2", "Name3", "Abteilung", "Funktion", "Telefon", "Telefon2",
|
||||
"Telefon3", "Telefon4", "Mobiltel", "Mobiltel2", "EMail", "EMail2", "EMail3", "InternetAdresse",
|
||||
];
|
||||
|
||||
/// Erzeugt den SELECT-Block für alle 5 Adressen — eine Zeile pro Spalte mit
|
||||
/// `<alias>.<col> AS <präfix>_<col>`. Wird in beide UNION-Hälften identisch
|
||||
/// einkopiert.
|
||||
fn address_select_block() -> String {
|
||||
let mut out = String::new();
|
||||
for (alias, prefix) in ADDRESS_ALIASES {
|
||||
for col in ADDRESS_CONTACT_COLUMNS {
|
||||
out.push_str(&format!(" {alias}.{col} AS {prefix}_{col},\n"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// SELECT gegen die Basistabellen, **mit Stücklisten-Auflösung** (analog zur
|
||||
/// Alt-View `_SV_APP_DELIVERIES_TODAY`). Datum als positionaler Parameter
|
||||
/// `@P1`. Zwei `UNION ALL`-Teile:
|
||||
///
|
||||
/// * **Teil 1** — alle Belegzeilen als Items. Ein Oberartikel (= Artikel mit
|
||||
/// Stücklisten-Kopf `Stuecklisten.StueckListenId=0`) wird **non-scannable**
|
||||
/// (`articleScannable=0`) ausgegeben: er ist nur Preis-/Gruppen-Träger, die
|
||||
/// physisch zu scannenden Einheiten sind seine Komponenten. `komponenten-
|
||||
/// ArtikelNr = NULL`.
|
||||
/// * **Teil 2** — die Stücklisten-Komponenten je Oberartikel, **jede als
|
||||
/// eigener Artikel** (eigene Artikelnummer/-bezeichnung/-scanbarkeit), Menge
|
||||
/// = Zeilenmenge × Komponentenmenge, `unitPrice=0` (der Preis liegt im ERP
|
||||
/// nur auf der Oberartikel-Zeile). `komponentenArtikelNr` = eigene Nummer der
|
||||
/// Komponente (eindeutig je `belegzeilenNr`; markiert die Zeile als
|
||||
/// Komponente — der Parent ist die `NULL`-Zeile gleicher `belegzeilenNr`).
|
||||
///
|
||||
/// **Lager pro Zeile** aus `bz.Lagerverteilung` (XML `(/Root/Row/Lager)[1]`),
|
||||
/// Fallback `bk.Lager`; Name aus `Lagerstammdaten`. Komponenten erben das
|
||||
/// Lager ihrer Oberartikel-Zeile. Das Lager wird je Zeile einmal per
|
||||
/// `CROSS APPLY lv` berechnet.
|
||||
///
|
||||
/// **Adressen für Kontaktdaten:** Wir joinen zusätzlich zur Belegadresse
|
||||
/// (`adr`) und der Lieferadresse (`dadr`) auch Rechnungsadresse (`radr`),
|
||||
/// Ansprechpartner (`kadr`) und die Stamm-Adresse des Kunden (`sadr` über
|
||||
/// `Kunden.AdressId`). Pro Adresse werden Name/Anrede/Titel und alle
|
||||
/// Telefon-/Mobil-/E-Mail-/Web-Spalten selektiert (siehe
|
||||
/// [`address_select_block`]); der Mapper baut daraus die `contact_sources`
|
||||
/// der Lieferung.
|
||||
const SQL_TEMPLATE: &str = r#"
|
||||
SELECT
|
||||
TRY_CAST(LTRIM(RTRIM(bk.Vertreter)) AS BIGINT) AS driverPersonalnummer,
|
||||
CAST(bk.BelegartId AS BIGINT) AS belegartId,
|
||||
LTRIM(RTRIM(ba.Belegart)) AS belegartCode,
|
||||
LTRIM(RTRIM(ba.Bezeichnung)) AS belegartName,
|
||||
LTRIM(RTRIM(bk.Belegnummer)) AS belegnummer,
|
||||
COALESCE(TRY_CAST(LTRIM(RTRIM(k.Kundennummer)) AS BIGINT),
|
||||
CAST(bk.KundenId AS BIGINT)) AS erpCustomerId,
|
||||
adr.Name1 AS customerName,
|
||||
adr.Strasse AS custStreet,
|
||||
adr.Hausnummer AS custHouseNumber,
|
||||
LTRIM(RTRIM(adr.PLZ)) AS custPostalCode,
|
||||
adr.Ort AS custCity,
|
||||
adr.Land AS custCountry,
|
||||
COALESCE(dadr.Strasse, adr.Strasse) AS delivStreet,
|
||||
COALESCE(dadr.Hausnummer, adr.Hausnummer) AS delivHouseNumber,
|
||||
LTRIM(RTRIM(COALESCE(dadr.PLZ, adr.PLZ))) AS delivPostalCode,
|
||||
COALESCE(dadr.Ort, adr.Ort) AS delivCity,
|
||||
COALESCE(dadr.Land, adr.Land) AS delivCountry,
|
||||
LTRIM(RTRIM(CAST(bk._Uhrzeit_Txt AS varchar(200)))) AS desiredTime,
|
||||
LTRIM(RTRIM(CAST(bk.Kopftext AS varchar(MAX)))) AS specialAgreements,
|
||||
CAST(ISNULL(bk._Anz_Bestellung, 0) AS FLOAT) AS prepaidAmount,
|
||||
LTRIM(RTRIM(z.Zahlungsbedingung)) AS paymentZahlbed,
|
||||
z.Bezeichnung AS paymentZahlbedText,
|
||||
CAST(bz.BelegzeilenNr AS INT) AS belegzeilenNr,
|
||||
CAST(NULL AS varchar(50)) AS komponentenArtikelNr,
|
||||
CAST(NULL AS varchar(50)) AS parentArtikelNr,
|
||||
a.Artikelnummer AS articleNumber,
|
||||
COALESCE(NULLIF(LTRIM(RTRIM(bz.ArtikelBezeichnung)), ''), a.Artikelbezeichnung)
|
||||
AS articleName,
|
||||
CASE WHEN EXISTS (SELECT 1 FROM Stuecklisten sk
|
||||
WHERE sk.ArtikelID = a.row_id AND sk.StueckListenId = 0)
|
||||
THEN CAST(0 AS BIT)
|
||||
WHEN UPPER(LTRIM(RTRIM(ag.[Bestandsführung]))) IN ('1','J','Y','T','X')
|
||||
THEN CAST(1 AS BIT)
|
||||
ELSE CAST(0 AS BIT) END AS articleScannable,
|
||||
lv.code AS warehouseCode,
|
||||
COALESCE(ls.lagerbezeichnung, lv.code) AS warehouseName,
|
||||
CAST(bz.Menge AS INT) AS requiredQuantity,
|
||||
CAST(bz.EinzelPreisBrutto AS FLOAT) AS unitPrice,
|
||||
{ADDR}
|
||||
-- Trailing-Anker: damit der letzte `,` im Adressblock syntaktisch ok ist.
|
||||
CAST(1 AS BIT) AS _addrBlockMarker
|
||||
FROM Belegkopf bk
|
||||
JOIN Adressen adr ON bk.AdressId = adr.ROW_ID
|
||||
LEFT JOIN Adressen dadr ON bk.LieferAdressId = dadr.ROW_ID
|
||||
LEFT JOIN Adressen radr ON bk.RechnungsAdressId = radr.ROW_ID
|
||||
LEFT JOIN Adressen kadr ON bk.AnsprechpartnerId = kadr.ROW_ID
|
||||
JOIN Belegzeilen bz ON bk.row_id = bz.ParentID
|
||||
JOIN Personalstamm ps ON ps.Personalnummer = bk.Vertreter
|
||||
JOIN Artikel a ON a.row_id = bz.ArtikelId
|
||||
JOIN Artikelgruppen ag ON a.ArtikelGruppenId = ag.ROW_ID
|
||||
JOIN Zahlungsbedingungen z ON z.ROW_ID = bk.ZahlungsbedingungId
|
||||
JOIN Belegarten ba ON ba.row_id = bk.BelegartId
|
||||
LEFT JOIN Kunden k ON k.row_id = bk.KundenId
|
||||
LEFT JOIN Adressen sadr ON k.AdressId = sadr.ROW_ID
|
||||
CROSS APPLY (SELECT COALESCE(
|
||||
NULLIF(LTRIM(RTRIM(
|
||||
CAST(CAST(bz.Lagerverteilung AS varchar(max)) AS XML)
|
||||
.value('(/Root/Row/Lager)[1]', 'varchar(20)'))), ''),
|
||||
LTRIM(RTRIM(bk.Lager))) AS code) lv
|
||||
LEFT JOIN Lagerstammdaten ls ON ls.lagernummer = lv.code
|
||||
WHERE ba.Belegart = 'VL5'
|
||||
AND bz.ArtikelId IS NOT NULL
|
||||
AND CAST(bk.Termin AS DATE) = @P1
|
||||
-- Abschluss-Artefakte ausschließen, damit ein erneuter Sync auch nach
|
||||
-- Rückschreiben eines Abschlusses robust bleibt: Gutschrift-/Storno-
|
||||
-- Zeilen (negativer Bruttopreis) und vollständig entfernte Positionen
|
||||
-- (Menge 0). Sonst Crash gegen unit_price>=0 / required_quantity>0.
|
||||
AND CAST(ISNULL(bz.EinzelPreisBrutto, 0) AS FLOAT) >= 0
|
||||
AND ISNULL(bz.Menge, 0) > 0
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
TRY_CAST(LTRIM(RTRIM(bk.Vertreter)) AS BIGINT) AS driverPersonalnummer,
|
||||
CAST(bk.BelegartId AS BIGINT) AS belegartId,
|
||||
LTRIM(RTRIM(ba.Belegart)) AS belegartCode,
|
||||
LTRIM(RTRIM(ba.Bezeichnung)) AS belegartName,
|
||||
LTRIM(RTRIM(bk.Belegnummer)) AS belegnummer,
|
||||
COALESCE(TRY_CAST(LTRIM(RTRIM(k.Kundennummer)) AS BIGINT),
|
||||
CAST(bk.KundenId AS BIGINT)) AS erpCustomerId,
|
||||
adr.Name1 AS customerName,
|
||||
adr.Strasse AS custStreet,
|
||||
adr.Hausnummer AS custHouseNumber,
|
||||
LTRIM(RTRIM(adr.PLZ)) AS custPostalCode,
|
||||
adr.Ort AS custCity,
|
||||
adr.Land AS custCountry,
|
||||
COALESCE(dadr.Strasse, adr.Strasse) AS delivStreet,
|
||||
COALESCE(dadr.Hausnummer, adr.Hausnummer) AS delivHouseNumber,
|
||||
LTRIM(RTRIM(COALESCE(dadr.PLZ, adr.PLZ))) AS delivPostalCode,
|
||||
COALESCE(dadr.Ort, adr.Ort) AS delivCity,
|
||||
COALESCE(dadr.Land, adr.Land) AS delivCountry,
|
||||
LTRIM(RTRIM(CAST(bk._Uhrzeit_Txt AS varchar(200)))) AS desiredTime,
|
||||
LTRIM(RTRIM(CAST(bk.Kopftext AS varchar(MAX)))) AS specialAgreements,
|
||||
CAST(ISNULL(bk._Anz_Bestellung, 0) AS FLOAT) AS prepaidAmount,
|
||||
LTRIM(RTRIM(z.Zahlungsbedingung)) AS paymentZahlbed,
|
||||
z.Bezeichnung AS paymentZahlbedText,
|
||||
CAST(bz.BelegzeilenNr AS INT) AS belegzeilenNr,
|
||||
LTRIM(RTRIM(ka.Artikelnummer)) AS komponentenArtikelNr,
|
||||
LTRIM(RTRIM(a.Artikelnummer)) AS parentArtikelNr,
|
||||
ka.Artikelnummer AS articleNumber,
|
||||
COALESCE(NULLIF(LTRIM(RTRIM(ka.Artikelbezeichnung)), ''), ka.Artikelnummer)
|
||||
AS articleName,
|
||||
CASE WHEN UPPER(LTRIM(RTRIM(kag.[Bestandsführung]))) IN ('1','J','Y','T','X')
|
||||
THEN CAST(1 AS BIT) ELSE CAST(0 AS BIT) END AS articleScannable,
|
||||
lv.code AS warehouseCode,
|
||||
COALESCE(ls.lagerbezeichnung, lv.code) AS warehouseName,
|
||||
CAST(bz.Menge * stl_pos.Menge AS INT) AS requiredQuantity,
|
||||
CAST(0 AS FLOAT) AS unitPrice,
|
||||
{ADDR}
|
||||
CAST(1 AS BIT) AS _addrBlockMarker
|
||||
FROM Belegkopf bk
|
||||
JOIN Adressen adr ON bk.AdressId = adr.ROW_ID
|
||||
LEFT JOIN Adressen dadr ON bk.LieferAdressId = dadr.ROW_ID
|
||||
LEFT JOIN Adressen radr ON bk.RechnungsAdressId = radr.ROW_ID
|
||||
LEFT JOIN Adressen kadr ON bk.AnsprechpartnerId = kadr.ROW_ID
|
||||
JOIN Belegzeilen bz ON bk.row_id = bz.ParentID
|
||||
JOIN Personalstamm ps ON ps.Personalnummer = bk.Vertreter
|
||||
JOIN Artikel a ON a.row_id = bz.ArtikelId
|
||||
JOIN Zahlungsbedingungen z ON z.ROW_ID = bk.ZahlungsbedingungId
|
||||
JOIN Belegarten ba ON ba.row_id = bk.BelegartId
|
||||
LEFT JOIN Kunden k ON k.row_id = bk.KundenId
|
||||
LEFT JOIN Adressen sadr ON k.AdressId = sadr.ROW_ID
|
||||
JOIN Stuecklisten stl_kopf ON stl_kopf.ArtikelID = a.row_id
|
||||
AND stl_kopf.StueckListenId = 0
|
||||
JOIN Stuecklisten stl_pos ON stl_pos.StueckListenId = stl_kopf.ROW_ID
|
||||
JOIN Artikel ka ON ka.row_id = stl_pos.ArtikelID
|
||||
LEFT JOIN Artikelgruppen kag ON ka.ArtikelGruppenId = kag.ROW_ID
|
||||
CROSS APPLY (SELECT COALESCE(
|
||||
NULLIF(LTRIM(RTRIM(
|
||||
CAST(CAST(bz.Lagerverteilung AS varchar(max)) AS XML)
|
||||
.value('(/Root/Row/Lager)[1]', 'varchar(20)'))), ''),
|
||||
LTRIM(RTRIM(bk.Lager))) AS code) lv
|
||||
LEFT JOIN Lagerstammdaten ls ON ls.lagernummer = lv.code
|
||||
WHERE ba.Belegart = 'VL5'
|
||||
AND bz.ArtikelId IS NOT NULL
|
||||
AND CAST(bk.Termin AS DATE) = @P1
|
||||
-- Abschluss-Artefakte ausschließen, damit ein erneuter Sync auch nach
|
||||
-- Rückschreiben eines Abschlusses robust bleibt: Gutschrift-/Storno-
|
||||
-- Zeilen (negativer Bruttopreis) und vollständig entfernte Positionen
|
||||
-- (Menge 0). Sonst Crash gegen unit_price>=0 / required_quantity>0.
|
||||
AND CAST(ISNULL(bz.EinzelPreisBrutto, 0) AS FLOAT) >= 0
|
||||
AND ISNULL(bz.Menge, 0) > 0
|
||||
|
||||
ORDER BY driverPersonalnummer, belegartId, belegnummer, belegzeilenNr, komponentenArtikelNr
|
||||
"#;
|
||||
|
||||
/// Eine flache Belegzeile, wie sie aus der Query kommt.
|
||||
struct ErpRow {
|
||||
driver_personalnummer: i64,
|
||||
belegart_id: i64,
|
||||
belegart_code: Option<String>,
|
||||
belegart_name: Option<String>,
|
||||
belegnummer: String,
|
||||
erp_customer_id: i64,
|
||||
customer_name: String,
|
||||
cust: Address,
|
||||
deliv: Address,
|
||||
desired_time: Option<String>,
|
||||
special_agreements: Option<String>,
|
||||
prepaid_amount: f64,
|
||||
payment_zahlbed: String,
|
||||
payment_zahlbed_text: String,
|
||||
belegzeilen_nr: i32,
|
||||
komponenten_artikel_nr: Option<String>,
|
||||
parent_artikel_nr: Option<String>,
|
||||
article_number: String,
|
||||
article_name: String,
|
||||
article_scannable: bool,
|
||||
warehouse_code: String,
|
||||
warehouse_name: String,
|
||||
required_quantity: i32,
|
||||
unit_price: f64,
|
||||
/// Pro-Beleg Kontaktquellen (aus allen 5 Adress-FKs). Auf jeder Item-Zeile
|
||||
/// derselben Lieferung identisch; der Grouper übernimmt sie aus der
|
||||
/// ersten Zeile und ignoriert die der Folgezeilen.
|
||||
contact_sources: Vec<SyncContactSource>,
|
||||
}
|
||||
|
||||
fn s(row: &tiberius::Row, col: &str) -> String {
|
||||
row.get::<&str, _>(col).unwrap_or("").trim().to_string()
|
||||
}
|
||||
fn opt_s(row: &tiberius::Row, col: &str) -> Option<String> {
|
||||
match row.get::<&str, _>(col) {
|
||||
Some(v) if !v.trim().is_empty() => Some(v.trim().to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn i64c(row: &tiberius::Row, col: &str) -> i64 {
|
||||
row.get::<i64, _>(col).unwrap_or(0)
|
||||
}
|
||||
fn i32c(row: &tiberius::Row, col: &str) -> i32 {
|
||||
row.get::<i32, _>(col).unwrap_or(0)
|
||||
}
|
||||
fn f64c(row: &tiberius::Row, col: &str) -> f64 {
|
||||
row.get::<f64, _>(col).unwrap_or(0.0)
|
||||
}
|
||||
fn boolc(row: &tiberius::Row, col: &str) -> bool {
|
||||
row.get::<bool, _>(col).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Liest aus einem Tiberius-Row die Kontaktdaten **einer** Adress-Rolle aus
|
||||
/// (alle Spalten heißen `<präfix>_<spalte>`, siehe [`address_select_block`]).
|
||||
/// Gibt `None` zurück, wenn weder ein Namensfeld noch ein Kontaktkanal
|
||||
/// belegt ist — leere Adressen werden so vom Sync nicht mitgeschleppt.
|
||||
fn read_contact_source(
|
||||
row: &tiberius::Row,
|
||||
prefix: &str,
|
||||
role: ContactRole,
|
||||
) -> Option<SyncContactSource> {
|
||||
let get = |col: &str| opt_s(row, &format!("{prefix}_{col}"));
|
||||
let anrede = get("Anrede");
|
||||
let titel = get("Titel");
|
||||
let name1 = get("Name1");
|
||||
let name2 = get("Name2");
|
||||
let name3 = get("Name3");
|
||||
let abteilung = get("Abteilung");
|
||||
let funktion = get("Funktion");
|
||||
|
||||
let mut channels: Vec<SyncContactChannel> = Vec::new();
|
||||
// 1-basierte Position spiegelt die ERP-Spaltennummerierung
|
||||
// (`Telefon` → 1, `Telefon2` → 2, …). Spalten-Reihenfolge identisch zu
|
||||
// [`ADDRESS_CONTACT_COLUMNS`].
|
||||
let push = |chans: &mut Vec<SyncContactChannel>,
|
||||
kind: ContactKind,
|
||||
pos: i16,
|
||||
value: Option<String>| {
|
||||
if let Some(v) = value {
|
||||
chans.push(SyncContactChannel {
|
||||
kind,
|
||||
position: pos,
|
||||
value: v,
|
||||
});
|
||||
}
|
||||
};
|
||||
push(&mut channels, ContactKind::Phone, 1, get("Telefon"));
|
||||
push(&mut channels, ContactKind::Phone, 2, get("Telefon2"));
|
||||
push(&mut channels, ContactKind::Phone, 3, get("Telefon3"));
|
||||
push(&mut channels, ContactKind::Phone, 4, get("Telefon4"));
|
||||
push(&mut channels, ContactKind::Mobile, 1, get("Mobiltel"));
|
||||
push(&mut channels, ContactKind::Mobile, 2, get("Mobiltel2"));
|
||||
push(&mut channels, ContactKind::Email, 1, get("EMail"));
|
||||
push(&mut channels, ContactKind::Email, 2, get("EMail2"));
|
||||
push(&mut channels, ContactKind::Email, 3, get("EMail3"));
|
||||
push(&mut channels, ContactKind::Web, 1, get("InternetAdresse"));
|
||||
|
||||
let has_name = anrede.is_some()
|
||||
|| titel.is_some()
|
||||
|| name1.is_some()
|
||||
|| name2.is_some()
|
||||
|| name3.is_some()
|
||||
|| abteilung.is_some()
|
||||
|| funktion.is_some();
|
||||
if channels.is_empty() && !has_name {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(SyncContactSource {
|
||||
role,
|
||||
anrede,
|
||||
titel,
|
||||
name1,
|
||||
name2,
|
||||
name3,
|
||||
abteilung,
|
||||
funktion,
|
||||
channels,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_all_contact_sources(row: &tiberius::Row) -> Vec<SyncContactSource> {
|
||||
[
|
||||
("hdr", ContactRole::Header),
|
||||
("dlv", ContactRole::Delivery),
|
||||
("bll", ContactRole::Billing),
|
||||
("ctp", ContactRole::ContactPerson),
|
||||
("cms", ContactRole::CustomerMaster),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(prefix, role)| read_contact_source(row, prefix, role))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn map_row(row: &tiberius::Row) -> ErpRow {
|
||||
ErpRow {
|
||||
driver_personalnummer: i64c(row, "driverPersonalnummer"),
|
||||
belegart_id: i64c(row, "belegartId"),
|
||||
belegart_code: opt_s(row, "belegartCode"),
|
||||
belegart_name: opt_s(row, "belegartName"),
|
||||
belegnummer: s(row, "belegnummer"),
|
||||
erp_customer_id: i64c(row, "erpCustomerId"),
|
||||
customer_name: s(row, "customerName"),
|
||||
cust: Address {
|
||||
street: s(row, "custStreet"),
|
||||
house_number: s(row, "custHouseNumber"),
|
||||
postal_code: s(row, "custPostalCode"),
|
||||
city: s(row, "custCity"),
|
||||
country: s(row, "custCountry"),
|
||||
},
|
||||
deliv: Address {
|
||||
street: s(row, "delivStreet"),
|
||||
house_number: s(row, "delivHouseNumber"),
|
||||
postal_code: s(row, "delivPostalCode"),
|
||||
city: s(row, "delivCity"),
|
||||
country: s(row, "delivCountry"),
|
||||
},
|
||||
desired_time: opt_s(row, "desiredTime"),
|
||||
special_agreements: opt_s(row, "specialAgreements"),
|
||||
prepaid_amount: f64c(row, "prepaidAmount"),
|
||||
payment_zahlbed: s(row, "paymentZahlbed"),
|
||||
payment_zahlbed_text: s(row, "paymentZahlbedText"),
|
||||
belegzeilen_nr: i32c(row, "belegzeilenNr"),
|
||||
komponenten_artikel_nr: opt_s(row, "komponentenArtikelNr"),
|
||||
parent_artikel_nr: opt_s(row, "parentArtikelNr"),
|
||||
article_number: s(row, "articleNumber"),
|
||||
article_name: s(row, "articleName"),
|
||||
article_scannable: boolc(row, "articleScannable"),
|
||||
warehouse_code: s(row, "warehouseCode"),
|
||||
warehouse_name: s(row, "warehouseName"),
|
||||
required_quantity: i32c(row, "requiredQuantity"),
|
||||
unit_price: f64c(row, "unitPrice"),
|
||||
contact_sources: read_all_contact_sources(row),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mapping ERP-Zahlungsbedingung-Code → Backend-Code.
|
||||
///
|
||||
/// Holzleitner bietet im Liefer-Flow exakt drei Zahlungsbedingungen an
|
||||
/// (Allowlist in DOCUframe-Makro `_web_getPaymentMethods.dfm`); ihre
|
||||
/// Bedeutung stammt aus der ERP-Tabelle `Zahlungsbedingungen` (live verifiziert):
|
||||
/// * `D16` = „Zahlung bei Lieferung (Barzahlung)" → cash
|
||||
/// * `d53` = „Zahlung bei Lieferung mit EC-Karte" → ec_card
|
||||
/// * `D10` = „14 Tage netto" → invoice
|
||||
/// `credit_card` kommt im Liefer-Flow nicht vor. Unbekannte Codes → `None`
|
||||
/// (Backend defaultet dann auf `cash`).
|
||||
fn map_payment_code(code: &str, _text: &str) -> Option<String> {
|
||||
match code.trim().to_uppercase().as_str() {
|
||||
"D16" => Some("cash".to_string()),
|
||||
"D53" => Some("ec_card".to_string()),
|
||||
"D10" => Some("invoice".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn item_from(row: &ErpRow) -> SyncDeliveryItem {
|
||||
SyncDeliveryItem {
|
||||
belegzeilen_nr: row.belegzeilen_nr,
|
||||
komponenten_artikel_nr: row.komponenten_artikel_nr.clone(),
|
||||
parent_artikel_nr: row.parent_artikel_nr.clone(),
|
||||
article_number: row.article_number.clone(),
|
||||
article_name: row.article_name.clone(),
|
||||
article_default_warehouse_code: None,
|
||||
article_scannable: row.article_scannable,
|
||||
warehouse_code: row.warehouse_code.clone(),
|
||||
warehouse_name: row.warehouse_name.clone(),
|
||||
required_quantity: row.required_quantity,
|
||||
unit_price: row.unit_price,
|
||||
}
|
||||
}
|
||||
|
||||
fn delivery_from(row: &ErpRow, sort_order: i32) -> SyncDelivery {
|
||||
SyncDelivery {
|
||||
belegart_id: row.belegart_id,
|
||||
belegart_code: row.belegart_code.clone(),
|
||||
belegart_name: row.belegart_name.clone(),
|
||||
belegnummer: row.belegnummer.clone(),
|
||||
erp_customer_id: row.erp_customer_id,
|
||||
customer_name: row.customer_name.clone(),
|
||||
customer_address: row.cust.clone(),
|
||||
delivery_address: row.deliv.clone(),
|
||||
sort_order,
|
||||
desired_time: row.desired_time.clone(),
|
||||
special_agreements: row.special_agreements.clone(),
|
||||
prepaid_amount: row.prepaid_amount,
|
||||
payment_method_code: map_payment_code(&row.payment_zahlbed, &row.payment_zahlbed_text),
|
||||
items: vec![item_from(row)],
|
||||
contact_sources: row.contact_sources.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gruppiert die (nach Fahrer/Beleg/Zeile sortierten) Zeilen zu
|
||||
/// `SyncTourRequest` pro Fahrer. Nutzt die Sortierung aus dem ORDER BY:
|
||||
/// gleiche Fahrer/Belege liegen kontiguös.
|
||||
fn group(rows: Vec<ErpRow>, date: NaiveDate) -> Vec<SyncTourRequest> {
|
||||
let mut tours: Vec<SyncTourRequest> = Vec::new();
|
||||
|
||||
for row in &rows {
|
||||
// Tour (Fahrer) finden/anlegen.
|
||||
let need_new_tour = tours
|
||||
.last()
|
||||
.map(|t| t.driver_personalnummer != row.driver_personalnummer)
|
||||
.unwrap_or(true);
|
||||
if need_new_tour {
|
||||
tours.push(SyncTourRequest {
|
||||
driver_personalnummer: row.driver_personalnummer,
|
||||
tour_date: date,
|
||||
deliveries: Vec::new(),
|
||||
});
|
||||
}
|
||||
let tour = tours.last_mut().unwrap();
|
||||
|
||||
// Lieferung (Beleg) finden/anlegen.
|
||||
let need_new_delivery = tour
|
||||
.deliveries
|
||||
.last()
|
||||
.map(|d| d.belegart_id != row.belegart_id || d.belegnummer != row.belegnummer)
|
||||
.unwrap_or(true);
|
||||
if need_new_delivery {
|
||||
let sort_order = tour.deliveries.len() as i32 + 1;
|
||||
tour.deliveries.push(delivery_from(row, sort_order));
|
||||
} else {
|
||||
tour.deliveries.last_mut().unwrap().items.push(item_from(row));
|
||||
}
|
||||
}
|
||||
|
||||
tours
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ErpDeliverySource for MssqlErpDeliverySource {
|
||||
async fn fetch_tours_for_date(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
) -> Result<Vec<SyncTourRequest>, ApplicationError> {
|
||||
let cfg = self.tiberius_config();
|
||||
let addr = format!("{}:{}", self.config.host, self.config.port);
|
||||
|
||||
let tcp = TcpStream::connect(&addr).await.map_err(repo)?;
|
||||
tcp.set_nodelay(true).ok();
|
||||
|
||||
let mut client = Client::connect(cfg, tcp.compat_write())
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
|
||||
let sql = SQL_TEMPLATE.replace("{ADDR}", &address_select_block());
|
||||
let rows = client
|
||||
.query(&sql, &[&date])
|
||||
.await
|
||||
.map_err(repo)?
|
||||
.into_first_result()
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
|
||||
let flat: Vec<ErpRow> = rows.iter().map(map_row).collect();
|
||||
let tours = group(flat, date);
|
||||
|
||||
tracing::info!(
|
||||
%date,
|
||||
rows = rows.len(),
|
||||
tours = tours.len(),
|
||||
"erp_source.fetched"
|
||||
);
|
||||
Ok(tours)
|
||||
}
|
||||
}
|
||||
419
crates/infrastructure/src/erp/mssql_delivery_writeback.rs
Normal file
419
crates/infrastructure/src/erp/mssql_delivery_writeback.rs
Normal file
@ -0,0 +1,419 @@
|
||||
//! MSSQL-Adapter für den `ErpDeliveryWriteback`-Port.
|
||||
//!
|
||||
//! Schreibt einen Lieferabschluss **direkt** in die ERPframe-Basistabellen
|
||||
//! zurück — das Rust-Pendant zu den Alt-Makros `_web_finishDelivery`,
|
||||
//! `_removeArticles` und `_addDiscount`. Alles läuft in **einer** MSSQL-
|
||||
//! Transaktion und ist **idempotent** (Mengen werden absolut gesetzt, die
|
||||
//! Gutschrift als Upsert geführt).
|
||||
//!
|
||||
//! Reihenfolge:
|
||||
//! 1. `Belegkopf.row_id` aus (BelegartId, Belegnummer) auflösen.
|
||||
//! 2. Je Belegzeile die `Menge` auf die ausgelieferte Menge setzen.
|
||||
//! 3. Gutschrift-Zeile (`GUTSCHRIFT10`) anlegen/aktualisieren.
|
||||
//! 4. Belegsummen neu berechnen (`Σ Einzelpreis × Menge`, `Σ Brutto × Menge`).
|
||||
//! 5. `_SV_DELIVERY_DELIVERED_AT` + `_SV_DELIVERY_STATE='geliefert'`.
|
||||
//!
|
||||
//! TODO (bewusst hardcoded, später konfigurierbar/aus Stammdaten):
|
||||
//! Gutschrift-Artikel `GUTSCHRIFT10`, Konto `8726`, Steuerschlüssel `M19`,
|
||||
//! Steuersatz `19`. Die 10-€-Einheit steckt im Artikelpreis (`Preise`).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tiberius::{Client, Config};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt};
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::{ErpDeliveryWriteback, ErpFinishDeliveryCommand};
|
||||
|
||||
use super::mssql_delivery_source::MssqlErpConfig;
|
||||
|
||||
/// Hardcodierte Gutschrift-Stammdaten (TODO: später konfigurierbar).
|
||||
const GUTSCHRIFT_ARTICLE_NUMBER: &str = "GUTSCHRIFT10";
|
||||
const GUTSCHRIFT_KONTO: &str = "8726";
|
||||
const GUTSCHRIFT_STEUERSCHLUESSEL: &str = "M19";
|
||||
const GUTSCHRIFT_STEUERSATZ: i32 = 19;
|
||||
/// Brutto-Faktor (1 + Steuersatz/100) — zum Ableiten des Netto-Einzelpreises
|
||||
/// aus dem Brutto-Gutschriftbetrag.
|
||||
const GUTSCHRIFT_GROSS_FACTOR: f64 = 1.0 + (GUTSCHRIFT_STEUERSATZ as f64) / 100.0;
|
||||
/// Belegzeilentyp (additive Bitmaske) der Gutschrift-Position:
|
||||
/// Artikel (1) + Andruck (32768) + keine Provision (262144) = 294913.
|
||||
/// Ermittelt aus einer manuell in ERPframe angelegten Gutschrift (goldene
|
||||
/// Referenz) — exakt der Typ, den native Gutschrift-Zeilen tragen.
|
||||
const GUTSCHRIFT_BELEGZEILENTYP: i32 = 294_913;
|
||||
|
||||
/// PG-Zahlungsmethode-Code → ERP-`Zahlungsbedingung`-Code. Spiegelt
|
||||
/// `map_payment_code` im Lese-Adapter (Rückrichtung). TODO: später aus
|
||||
/// Stammdaten/Config statt hardcoded.
|
||||
fn erp_zahlbed_code(pg_code: &str) -> Option<&'static str> {
|
||||
match pg_code.trim().to_lowercase().as_str() {
|
||||
"cash" => Some("D16"),
|
||||
"ec_card" => Some("D53"),
|
||||
"invoice" => Some("D10"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MssqlErpDeliveryWriteback {
|
||||
config: MssqlErpConfig,
|
||||
}
|
||||
|
||||
impl MssqlErpDeliveryWriteback {
|
||||
pub fn new(config: MssqlErpConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
fn tiberius_config(&self) -> Config {
|
||||
let mut cfg = Config::new();
|
||||
cfg.host(&self.config.host);
|
||||
cfg.port(self.config.port);
|
||||
cfg.database(&self.config.database);
|
||||
cfg.authentication(tiberius::AuthMethod::sql_server(
|
||||
&self.config.user,
|
||||
&self.config.password,
|
||||
));
|
||||
if self.config.trust_cert {
|
||||
cfg.trust_cert();
|
||||
}
|
||||
cfg
|
||||
}
|
||||
}
|
||||
|
||||
fn repo<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
type TiberiusClient = Client<Compat<TcpStream>>;
|
||||
|
||||
impl MssqlErpDeliveryWriteback {
|
||||
/// Resolved die `Belegkopf.row_id` über den Beleg-Natural-Key.
|
||||
async fn resolve_belegkopf(
|
||||
client: &mut TiberiusClient,
|
||||
belegart_id: i64,
|
||||
belegnummer: &str,
|
||||
) -> Result<i64, ApplicationError> {
|
||||
let rows = client
|
||||
.query(
|
||||
r#"SELECT TOP 1 CAST(row_id AS BIGINT) AS rowId
|
||||
FROM Belegkopf
|
||||
WHERE BelegartId = @P1 AND LTRIM(RTRIM(Belegnummer)) = @P2"#,
|
||||
&[&belegart_id, &belegnummer],
|
||||
)
|
||||
.await
|
||||
.map_err(repo)?
|
||||
.into_first_result()
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
|
||||
let row = rows.first().ok_or_else(|| {
|
||||
ApplicationError::Repository(format!(
|
||||
"Belegkopf nicht gefunden (BelegartId={belegart_id}, Belegnummer='{belegnummer}')"
|
||||
))
|
||||
})?;
|
||||
row.get::<i64, _>("rowId").ok_or_else(|| {
|
||||
ApplicationError::Repository("Belegkopf.row_id ist NULL".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Setzt die Menge einer Belegzeile (absolut, idempotent).
|
||||
async fn set_line_menge(
|
||||
client: &mut TiberiusClient,
|
||||
bk_row_id: i64,
|
||||
belegzeilen_nr: i32,
|
||||
menge: i32,
|
||||
) -> Result<(), ApplicationError> {
|
||||
client
|
||||
.execute(
|
||||
r#"UPDATE Belegzeilen SET Menge = @P1
|
||||
WHERE ParentID = @P2 AND BelegzeilenNr = @P3"#,
|
||||
&[&menge, &bk_row_id, &belegzeilen_nr],
|
||||
)
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gutschrift-Upsert als EINE Belegzeile mit dem **tatsächlichen Betrag**
|
||||
/// (keine 10-€-Einheiten mehr → beliebige Beträge ≤ 150 € exakt abbildbar).
|
||||
/// `amount_cents` = Geld-Gutschrift in Cent (Brutto; 0 = keine).
|
||||
///
|
||||
/// Die Zeile bekommt `Menge = 1` und `Einzelpreis` = -(Brutto / 1,19)
|
||||
/// (negativ → senkt den Belegwert). `EinzelPreisBrutto` ist eine berechnete
|
||||
/// Spalte (Netto × (1+Steuersatz/100)) → ergibt automatisch -(Brutto).
|
||||
async fn upsert_gutschrift(
|
||||
client: &mut TiberiusClient,
|
||||
bk_row_id: i64,
|
||||
amount_cents: i64,
|
||||
) -> Result<(), ApplicationError> {
|
||||
// Netto-Einzelpreis aus dem Brutto-Betrag (negativ). 0 € → leere Zeile.
|
||||
let menge: i32 = if amount_cents > 0 { 1 } else { 0 };
|
||||
let net: f64 = if amount_cents > 0 {
|
||||
-((amount_cents as f64 / 100.0) / GUTSCHRIFT_GROSS_FACTOR)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Existiert bereits eine Gutschrift-Zeile (GUTSCHRIFT10) am Beleg?
|
||||
let existing = client
|
||||
.query(
|
||||
r#"SELECT TOP 1 CAST(bz.row_id AS BIGINT) AS rowId
|
||||
FROM Belegzeilen bz
|
||||
JOIN Artikel a ON a.row_id = bz.ArtikelId
|
||||
WHERE bz.ParentID = @P1 AND a.Artikelnummer = @P2"#,
|
||||
&[&bk_row_id, &GUTSCHRIFT_ARTICLE_NUMBER],
|
||||
)
|
||||
.await
|
||||
.map_err(repo)?
|
||||
.into_first_result()
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
|
||||
if let Some(row) = existing.first() {
|
||||
// Vorhandene Zeile: Menge UND Einzelpreis aktualisieren (Betrag
|
||||
// variabel). Menge 0 ⇒ Zeile zählt im Recalc nicht mehr.
|
||||
let line_row_id = row.get::<i64, _>("rowId").ok_or_else(|| {
|
||||
ApplicationError::Repository("Gutschrift-Belegzeile.row_id ist NULL".to_string())
|
||||
})?;
|
||||
client
|
||||
.execute(
|
||||
"UPDATE Belegzeilen SET Menge = @P1, Einzelpreis = @P2, \
|
||||
Belegzeilentyp = @P3 WHERE row_id = @P4",
|
||||
&[&menge, &net, &GUTSCHRIFT_BELEGZEILENTYP, &line_row_id],
|
||||
)
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Keine Gutschrift nötig und keine vorhandene Zeile → nichts tun.
|
||||
if amount_cents <= 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Neue Gutschrift-Zeile: Artikel-row_id + Name aus dem Stamm holen.
|
||||
let art = client
|
||||
.query(
|
||||
r#"SELECT TOP 1 CAST(row_id AS BIGINT) AS rowId,
|
||||
CAST(Artikelbezeichnung AS NVARCHAR(400)) AS name
|
||||
FROM Artikel WHERE Artikelnummer = @P1"#,
|
||||
&[&GUTSCHRIFT_ARTICLE_NUMBER],
|
||||
)
|
||||
.await
|
||||
.map_err(repo)?
|
||||
.into_first_result()
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
let art = art.first().ok_or_else(|| {
|
||||
ApplicationError::Repository(format!(
|
||||
"Gutschrift-Artikel '{GUTSCHRIFT_ARTICLE_NUMBER}' nicht im ERP gefunden"
|
||||
))
|
||||
})?;
|
||||
let article_row_id = art
|
||||
.get::<i64, _>("rowId")
|
||||
.ok_or_else(|| ApplicationError::Repository("Artikel.row_id ist NULL".into()))?;
|
||||
let article_name = art.get::<&str, _>("name").unwrap_or("Gutschrift").to_string();
|
||||
|
||||
client
|
||||
.execute(
|
||||
r#"INSERT INTO Belegzeilen
|
||||
(ArtikelId, Artikelbezeichnung, Menge, ParentID,
|
||||
Einzelpreis, Steuersatz, Steuerschluessel,
|
||||
Konto, ParentBelegzeilenId, PositionNr, Belegzeilentext,
|
||||
Belegzeilentyp)
|
||||
VALUES (@P1, @P2, @P3, @P4, @P5, @P6, @P7, @P8, 0, 0, @P9, @P10)"#,
|
||||
&[
|
||||
&article_row_id,
|
||||
&article_name,
|
||||
&menge,
|
||||
&bk_row_id,
|
||||
&net,
|
||||
&GUTSCHRIFT_STEUERSATZ,
|
||||
&GUTSCHRIFT_STEUERSCHLUESSEL,
|
||||
&GUTSCHRIFT_KONTO,
|
||||
&"Gutschrift",
|
||||
&GUTSCHRIFT_BELEGZEILENTYP,
|
||||
],
|
||||
)
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Summiert alle Belegzeilen neu und schreibt die Kopf-Summen.
|
||||
async fn recalc_head(
|
||||
client: &mut TiberiusClient,
|
||||
bk_row_id: i64,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let rows = client
|
||||
.query(
|
||||
r#"SELECT CAST(ISNULL(Einzelpreis,0) AS FLOAT) AS net,
|
||||
CAST(ISNULL(EinzelPreisBrutto,0) AS FLOAT) AS gross,
|
||||
CAST(ISNULL(Menge,0) AS FLOAT) AS menge
|
||||
FROM Belegzeilen WHERE ParentID = @P1"#,
|
||||
&[&bk_row_id],
|
||||
)
|
||||
.await
|
||||
.map_err(repo)?
|
||||
.into_first_result()
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
|
||||
let mut net_sum = 0.0_f64;
|
||||
let mut gross_sum = 0.0_f64;
|
||||
for r in &rows {
|
||||
let net = r.get::<f64, _>("net").unwrap_or(0.0);
|
||||
let gross = r.get::<f64, _>("gross").unwrap_or(0.0);
|
||||
let menge = r.get::<f64, _>("menge").unwrap_or(0.0);
|
||||
net_sum += net * menge;
|
||||
gross_sum += gross * menge;
|
||||
}
|
||||
|
||||
client
|
||||
.execute(
|
||||
r#"UPDATE Belegkopf
|
||||
SET WarenwertNetto = @P1, WarenwertBrutto = @P2, PosSummeNetto = @P3
|
||||
WHERE row_id = @P4"#,
|
||||
&[&net_sum, &gross_sum, &net_sum, &bk_row_id],
|
||||
)
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Setzt Liefer-Zeitpunkt + Status. ISO-8601 mit `T` als String
|
||||
/// (impliziter SQL-Cast), exakt wie das Alt-Makro.
|
||||
async fn mark_delivered(
|
||||
client: &mut TiberiusClient,
|
||||
bk_row_id: i64,
|
||||
delivered_at_iso: &str,
|
||||
) -> Result<(), ApplicationError> {
|
||||
client
|
||||
.execute(
|
||||
r#"UPDATE Belegkopf
|
||||
SET _SV_DELIVERY_DELIVERED_AT = @P1, _SV_DELIVERY_STATE = 'geliefert'
|
||||
WHERE row_id = @P2"#,
|
||||
&[&delivered_at_iso, &bk_row_id],
|
||||
)
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Setzt `Belegkopf.ZahlungsbedingungId` anhand der gewählten Zahlungs-
|
||||
/// methode (Code → ERP-Zahlungsbedingung → deren `row_id`). Idempotent
|
||||
/// (absolutes Setzen). Kein Mapping bzw. keine passende Zahlungsbedingung
|
||||
/// im ERP → überspringen (kein Fehler), der Beleg behält seine bisherige.
|
||||
async fn set_payment_condition(
|
||||
client: &mut TiberiusClient,
|
||||
bk_row_id: i64,
|
||||
pg_code: &str,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let Some(erp_code) = erp_zahlbed_code(pg_code) else {
|
||||
tracing::warn!(
|
||||
pg_code,
|
||||
"erp_writeback: kein Zahlungsbedingung-Mapping — übersprungen"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let rows = client
|
||||
.query(
|
||||
r#"SELECT TOP 1 CAST(row_id AS BIGINT) AS rid
|
||||
FROM Zahlungsbedingungen
|
||||
WHERE LTRIM(RTRIM(Zahlungsbedingung)) = @P1"#,
|
||||
&[&erp_code],
|
||||
)
|
||||
.await
|
||||
.map_err(repo)?
|
||||
.into_first_result()
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
|
||||
let Some(rid) = rows.first().and_then(|r| r.get::<i64, _>("rid")) else {
|
||||
tracing::warn!(
|
||||
erp_code,
|
||||
"erp_writeback: Zahlungsbedingung nicht im ERP gefunden — übersprungen"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
client
|
||||
.execute(
|
||||
"UPDATE Belegkopf SET ZahlungsbedingungId = @P1 WHERE row_id = @P2",
|
||||
&[&rid, &bk_row_id],
|
||||
)
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Führt alle Schritte aus (innerhalb der bereits geöffneten Transaktion).
|
||||
async fn run(
|
||||
client: &mut TiberiusClient,
|
||||
cmd: &ErpFinishDeliveryCommand,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let bk = Self::resolve_belegkopf(client, cmd.belegart_id, &cmd.belegnummer).await?;
|
||||
|
||||
for line in &cmd.lines {
|
||||
Self::set_line_menge(client, bk, line.belegzeilen_nr, line.delivered_quantity).await?;
|
||||
}
|
||||
|
||||
Self::upsert_gutschrift(client, bk, cmd.credit_amount_cents).await?;
|
||||
|
||||
Self::recalc_head(client, bk).await?;
|
||||
|
||||
// Gewählte Zahlungsmethode → Zahlungsbedingung im Belegkopf.
|
||||
if let Some(code) = &cmd.payment_method_code {
|
||||
Self::set_payment_condition(client, bk, code).await?;
|
||||
}
|
||||
|
||||
let iso = cmd.delivered_at.format("%Y-%m-%dT%H:%M:%S").to_string();
|
||||
Self::mark_delivered(client, bk, &iso).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ErpDeliveryWriteback for MssqlErpDeliveryWriteback {
|
||||
async fn finish_delivery(
|
||||
&self,
|
||||
cmd: ErpFinishDeliveryCommand,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let cfg = self.tiberius_config();
|
||||
let addr = format!("{}:{}", self.config.host, self.config.port);
|
||||
|
||||
let tcp = TcpStream::connect(&addr).await.map_err(repo)?;
|
||||
tcp.set_nodelay(true).ok();
|
||||
let mut client = Client::connect(cfg, tcp.compat_write()).await.map_err(repo)?;
|
||||
|
||||
client
|
||||
.simple_query("BEGIN TRANSACTION")
|
||||
.await
|
||||
.map_err(repo)?;
|
||||
|
||||
match Self::run(&mut client, &cmd).await {
|
||||
Ok(()) => {
|
||||
client.simple_query("COMMIT").await.map_err(repo)?;
|
||||
tracing::info!(
|
||||
belegart = cmd.belegart_id,
|
||||
belegnummer = %cmd.belegnummer,
|
||||
lines = cmd.lines.len(),
|
||||
credit_cents = cmd.credit_amount_cents,
|
||||
"erp_writeback.committed"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
// Best-effort Rollback; der ursprüngliche Fehler bleibt maßgeblich.
|
||||
let _ = client.simple_query("ROLLBACK").await;
|
||||
tracing::error!(
|
||||
belegart = cmd.belegart_id,
|
||||
belegnummer = %cmd.belegnummer,
|
||||
error = %e,
|
||||
"erp_writeback.rolled_back"
|
||||
);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
100
crates/infrastructure/src/gsd/dto.rs
Normal file
100
crates/infrastructure/src/gsd/dto.rs
Normal file
@ -0,0 +1,100 @@
|
||||
//! Wire-DTOs der GSD/DOCUframe-REST-API.
|
||||
//!
|
||||
//! Alle Antworten sind in einen Umschlag `{ status, data }` verpackt.
|
||||
//! `status.internalStatus` ist der fachliche Status: `"0"` = ok,
|
||||
//! `"201"` = Session ungültig/abgelaufen (→ Re-Login).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const STATUS_OK: &str = "0";
|
||||
pub const STATUS_INVALID_SESSION: &str = "201";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GsdStatus {
|
||||
#[serde(rename = "internalStatus")]
|
||||
pub internal_status: String,
|
||||
#[serde(rename = "statusMessage", default)]
|
||||
pub status_message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GsdEnvelope<T> {
|
||||
pub status: Option<GsdStatus>,
|
||||
pub data: Option<T>,
|
||||
}
|
||||
|
||||
impl<T> GsdEnvelope<T> {
|
||||
/// `true`, wenn der Server eine ungültige Session signalisiert.
|
||||
pub fn is_invalid_session(&self) -> bool {
|
||||
self.status
|
||||
.as_ref()
|
||||
.map(|s| s.internal_status == STATUS_INVALID_SESSION)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// `true`, wenn der fachliche Status ok ist (oder gar kein Status kam).
|
||||
pub fn is_ok(&self) -> bool {
|
||||
match &self.status {
|
||||
Some(s) => s.internal_status == STATUS_OK,
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status_message(&self) -> String {
|
||||
self.status
|
||||
.as_ref()
|
||||
.map(|s| s.status_message.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Login ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginRequest<'a> {
|
||||
pub user: &'a str,
|
||||
/// MD5-Hash des Passworts (GSD-Vorgabe).
|
||||
pub pass: &'a str,
|
||||
#[serde(rename = "appNames")]
|
||||
pub app_names: &'a [String],
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginData {
|
||||
#[serde(rename = "sessionId")]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
// ─── License release ────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReleaseRequest<'a> {
|
||||
#[serde(rename = "appNames")]
|
||||
pub app_names: &'a [String],
|
||||
}
|
||||
|
||||
// ─── Upload (3-stufig) ────────────────────────────────────────────────────
|
||||
|
||||
/// Antwort von `GET /v1/uploadFile` — reservierter Upload-Slot.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UploadIdData {
|
||||
#[serde(rename = "uploadId")]
|
||||
pub upload_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Antwort von `PATCH /v1/uploadFile/{uploadId}` — committetes Attachment.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CommitData {
|
||||
#[serde(rename = "~ObjectID")]
|
||||
pub object_id: Option<String>,
|
||||
}
|
||||
|
||||
// ─── Makro-Aufruf (`/v1/execute/<name>`) ────────────────────────────────────
|
||||
|
||||
/// Request-Body für das Makro `_SV_assignDeliveryReport`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AssignReportRequest<'a> {
|
||||
#[serde(rename = "objectId")]
|
||||
pub object_id: &'a str,
|
||||
pub belegnummer: &'a str,
|
||||
}
|
||||
6
crates/infrastructure/src/gsd/mod.rs
Normal file
6
crates/infrastructure/src/gsd/mod.rs
Normal file
@ -0,0 +1,6 @@
|
||||
//! DOCUframe/GSD-Adapter — Datei-Upload gegen die GSD-REST-API.
|
||||
|
||||
mod dto;
|
||||
pub mod service;
|
||||
|
||||
pub use service::{GsdConfig, GsdService};
|
||||
539
crates/infrastructure/src/gsd/service.rs
Normal file
539
crates/infrastructure/src/gsd/service.rs
Normal file
@ -0,0 +1,539 @@
|
||||
//! DOCUframe-Anbindung (GSD-REST-API) — Datei-Upload + Session-Verwaltung.
|
||||
//!
|
||||
//! Recycelt aus dem alten Proxy-Backend, aber als **typisierter Adapter**
|
||||
//! statt transparentem Proxy. Eine einzige technische Service-Account-Session
|
||||
//! wird wiederverwendet und **durabel in Postgres** (`app_state`) gehalten —
|
||||
//! der GSD-Server blockt pro Session einen Lizenz-Seat bis Ablauf/Release,
|
||||
//! darum darf die Id einen Backend-Neustart nicht verlieren (sonst verwaiste
|
||||
//! Blocks → Lizenz-Lockout).
|
||||
//!
|
||||
//! Session-Lebenszyklus:
|
||||
//! * Erstzugriff: Id aus `app_state` lesen, sonst einloggen.
|
||||
//! * `internalStatus == "201"` (Session tot): Single-Flight-Re-Login,
|
||||
//! neue Id überschreiben.
|
||||
//! * Graceful Shutdown: [`GsdService::release_license`] gibt den Seat frei.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::{AttachmentStorage, DocuframeReportGateway, PreviewImage};
|
||||
|
||||
use super::dto::*;
|
||||
|
||||
const SESSION_KEY: &str = "gsd_session_id";
|
||||
|
||||
/// Interner Fehlertyp, der „Session ungültig" von echten Fehlern trennt,
|
||||
/// damit der Aufrufer gezielt einen Re-Login auslösen kann.
|
||||
enum GsdError {
|
||||
InvalidSession,
|
||||
Other(ApplicationError),
|
||||
}
|
||||
|
||||
impl From<ApplicationError> for GsdError {
|
||||
fn from(e: ApplicationError) -> Self {
|
||||
GsdError::Other(e)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GsdConfig {
|
||||
pub rest_url: String,
|
||||
pub app_key: String,
|
||||
pub user: String,
|
||||
pub password_md5: String,
|
||||
pub app_names: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct GsdService {
|
||||
pool: PgPool,
|
||||
http: reqwest::Client,
|
||||
config: GsdConfig,
|
||||
/// Heißer Cache der Session-Id; Quelle der Wahrheit ist `app_state`.
|
||||
cache: RwLock<Option<String>>,
|
||||
/// Single-Flight-Guard: nie zwei parallele Logins (= zwei Lizenz-Seats).
|
||||
login_lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl GsdService {
|
||||
pub fn new(pool: PgPool, config: GsdConfig) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
http: reqwest::Client::new(),
|
||||
config,
|
||||
cache: RwLock::new(None),
|
||||
login_lock: Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn ext<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::External(e.to_string())
|
||||
}
|
||||
|
||||
fn repo<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
// ─── Session-Store (Postgres-KV) ─────────────────────────────────────
|
||||
|
||||
async fn load_session_from_db(&self) -> Result<Option<String>, ApplicationError> {
|
||||
sqlx::query_scalar("SELECT value FROM app_state WHERE key = $1")
|
||||
.bind(SESSION_KEY)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::repo)
|
||||
}
|
||||
|
||||
async fn store_session_in_db(&self, session: &str) -> Result<(), ApplicationError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO app_state (key, value, updated_at)
|
||||
VALUES ($1, $2, now())
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET value = EXCLUDED.value, updated_at = now()
|
||||
"#,
|
||||
)
|
||||
.bind(SESSION_KEY)
|
||||
.bind(session)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::repo)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn clear_session_in_db(&self) -> Result<(), ApplicationError> {
|
||||
sqlx::query("DELETE FROM app_state WHERE key = $1")
|
||||
.bind(SESSION_KEY)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::repo)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Session-Beschaffung ─────────────────────────────────────────────
|
||||
|
||||
/// Liefert die aktuelle Session-Id — aus dem Cache, sonst aus der DB,
|
||||
/// sonst per Login (Single-Flight).
|
||||
async fn current_session(&self) -> Result<String, ApplicationError> {
|
||||
if let Some(s) = self.cache.read().await.clone() {
|
||||
return Ok(s);
|
||||
}
|
||||
if let Some(s) = self.load_session_from_db().await? {
|
||||
*self.cache.write().await = Some(s.clone());
|
||||
return Ok(s);
|
||||
}
|
||||
// Keine Session bekannt → einloggen (unter Lock, mit Double-Check).
|
||||
let _guard = self.login_lock.lock().await;
|
||||
if let Some(s) = self.cache.read().await.clone() {
|
||||
return Ok(s);
|
||||
}
|
||||
self.do_login().await
|
||||
}
|
||||
|
||||
/// Re-Login nach einer als ungültig erkannten Session. Prüft unter Lock,
|
||||
/// ob inzwischen schon ein anderer Task eine neue Session geholt hat
|
||||
/// (dann wird die genutzt — kein zweiter Seat).
|
||||
async fn relogin(&self, stale: &str) -> Result<String, ApplicationError> {
|
||||
let _guard = self.login_lock.lock().await;
|
||||
if let Some(current) = self.cache.read().await.clone() {
|
||||
if current != stale {
|
||||
return Ok(current);
|
||||
}
|
||||
}
|
||||
self.do_login().await
|
||||
}
|
||||
|
||||
/// Führt den eigentlichen Login aus (Aufrufer hält `login_lock`).
|
||||
async fn do_login(&self) -> Result<String, ApplicationError> {
|
||||
info!("GSD: Login gegen {}", self.config.rest_url);
|
||||
let body = LoginRequest {
|
||||
user: &self.config.user,
|
||||
pass: &self.config.password_md5,
|
||||
app_names: &self.config.app_names,
|
||||
};
|
||||
let resp = self
|
||||
.http
|
||||
.post(format!("{}/v1/login", self.config.rest_url))
|
||||
.header("appKey", &self.config.app_key)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(Self::ext)?;
|
||||
|
||||
let env: GsdEnvelope<LoginData> = resp.json().await.map_err(Self::ext)?;
|
||||
if !env.is_ok() {
|
||||
error!("GSD: Login fehlgeschlagen: {}", env.status_message());
|
||||
return Err(ApplicationError::External(format!(
|
||||
"GSD-Login fehlgeschlagen: {}",
|
||||
env.status_message()
|
||||
)));
|
||||
}
|
||||
let session = env
|
||||
.data
|
||||
.and_then(|d| d.session_id)
|
||||
.ok_or_else(|| ApplicationError::External("GSD-Login ohne sessionId".into()))?;
|
||||
|
||||
*self.cache.write().await = Some(session.clone());
|
||||
self.store_session_in_db(&session).await?;
|
||||
info!("GSD: Neue Session etabliert");
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
// ─── Lizenz freigeben (Graceful Shutdown) ────────────────────────────
|
||||
|
||||
/// Gibt den Lizenz-Seat der aktuellen Session via `/v1/license/release`
|
||||
/// frei und vergisst die Session (Cache + DB). Best-Effort: Fehler
|
||||
/// werden geloggt, aber nicht propagiert — beim Shutdown soll nichts
|
||||
/// hängenbleiben.
|
||||
pub async fn release_license(&self) {
|
||||
let session = match self.cache.read().await.clone() {
|
||||
Some(s) => Some(s),
|
||||
None => self.load_session_from_db().await.ok().flatten(),
|
||||
};
|
||||
let Some(session) = session else {
|
||||
return;
|
||||
};
|
||||
|
||||
let body = ReleaseRequest {
|
||||
app_names: &self.config.app_names,
|
||||
};
|
||||
let result = self
|
||||
.http
|
||||
.post(format!("{}/v1/license/release", self.config.rest_url))
|
||||
.header("appKey", &self.config.app_key)
|
||||
.header("sessionId", &session)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => info!("GSD: Lizenz freigegeben"),
|
||||
Err(e) => warn!("GSD: Lizenz-Freigabe fehlgeschlagen (ignoriert): {}", e),
|
||||
}
|
||||
|
||||
*self.cache.write().await = None;
|
||||
if let Err(e) = self.clear_session_in_db().await {
|
||||
warn!("GSD: Session konnte nicht aus DB gelöscht werden: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Datei-Upload (3-stufig) ─────────────────────────────────────────
|
||||
|
||||
/// Ein vollständiger Upload-Versuch mit einer festen Session. Bei
|
||||
/// `201` in irgendeinem Schritt → `GsdError::InvalidSession`, damit der
|
||||
/// Aufrufer den ganzen Vorgang nach Re-Login wiederholen kann (die
|
||||
/// `uploadId` ist an die Session gebunden).
|
||||
async fn upload_once(
|
||||
&self,
|
||||
session: &str,
|
||||
filename: &str,
|
||||
mime: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<String, GsdError> {
|
||||
// Schritt 1: Slot anfordern.
|
||||
let url = format!("{}/v1/uploadFile", self.config.rest_url);
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.header("appkey", &self.config.app_key)
|
||||
.header("sessionId", session)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| GsdError::Other(Self::ext(e)))?;
|
||||
let env: GsdEnvelope<UploadIdData> =
|
||||
resp.json().await.map_err(|e| GsdError::Other(Self::ext(e)))?;
|
||||
if env.is_invalid_session() {
|
||||
return Err(GsdError::InvalidSession);
|
||||
}
|
||||
let upload_id = env
|
||||
.data
|
||||
.and_then(|d| d.upload_id)
|
||||
.ok_or_else(|| GsdError::Other(ApplicationError::External("GSD: keine uploadId".into())))?;
|
||||
|
||||
// Schritt 2: Bytes als multipart/form-data senden (Feld `file`).
|
||||
let part = reqwest::multipart::Part::bytes(bytes.to_vec())
|
||||
.file_name(filename.to_owned())
|
||||
.mime_str(mime)
|
||||
.map_err(|e| GsdError::Other(Self::ext(e)))?;
|
||||
let form = reqwest::multipart::Form::new().part("file", part);
|
||||
let upload_url = format!("{}/v1/uploadFile/{}", self.config.rest_url, upload_id);
|
||||
let resp = self
|
||||
.http
|
||||
.post(&upload_url)
|
||||
.header("appkey", &self.config.app_key)
|
||||
.header("sessionId", session)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| GsdError::Other(Self::ext(e)))?;
|
||||
let env: GsdEnvelope<serde_json::Value> =
|
||||
resp.json().await.map_err(|e| GsdError::Other(Self::ext(e)))?;
|
||||
if env.is_invalid_session() {
|
||||
return Err(GsdError::InvalidSession);
|
||||
}
|
||||
if !env.is_ok() {
|
||||
return Err(GsdError::Other(ApplicationError::External(format!(
|
||||
"GSD-Upload fehlgeschlagen: {}",
|
||||
env.status_message()
|
||||
))));
|
||||
}
|
||||
|
||||
// Schritt 3: Commit → liefert ~ObjectID.
|
||||
let resp = self
|
||||
.http
|
||||
.patch(&upload_url)
|
||||
.header("appkey", &self.config.app_key)
|
||||
.header("sessionId", session)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| GsdError::Other(Self::ext(e)))?;
|
||||
let env: GsdEnvelope<CommitData> =
|
||||
resp.json().await.map_err(|e| GsdError::Other(Self::ext(e)))?;
|
||||
if env.is_invalid_session() {
|
||||
return Err(GsdError::InvalidSession);
|
||||
}
|
||||
env.data
|
||||
.and_then(|d| d.object_id)
|
||||
.ok_or_else(|| GsdError::Other(ApplicationError::External("GSD: kein ~ObjectID".into())))
|
||||
}
|
||||
|
||||
/// Ein Vorschau-Download-Versuch mit fester Session. Liefert bei einem
|
||||
/// Bild-Content-Type die Bytes; bei JSON-Antwort (typisch für eine
|
||||
/// ungültige Session) → `GsdError::InvalidSession` bzw. ein Fehler.
|
||||
async fn download_preview_once(
|
||||
&self,
|
||||
session: &str,
|
||||
object_id: &str,
|
||||
parameters: &str,
|
||||
page: &str,
|
||||
) -> Result<PreviewImage, GsdError> {
|
||||
let url = format!(
|
||||
"{}/v1/preview/{}/{}/{}",
|
||||
self.config.rest_url, parameters, object_id, page
|
||||
);
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.header("appkey", &self.config.app_key)
|
||||
.header("sessionId", session)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| GsdError::Other(Self::ext(e)))?;
|
||||
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
|
||||
if content_type.starts_with("image/") {
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| GsdError::Other(Self::ext(e)))?
|
||||
.to_vec();
|
||||
return Ok(PreviewImage {
|
||||
bytes,
|
||||
content_type,
|
||||
});
|
||||
}
|
||||
|
||||
// Kein Bild → vermutlich JSON-Fehlerumschlag (z. B. Session ungültig).
|
||||
let env: GsdEnvelope<serde_json::Value> =
|
||||
resp.json().await.map_err(|e| GsdError::Other(Self::ext(e)))?;
|
||||
if env.is_invalid_session() {
|
||||
return Err(GsdError::InvalidSession);
|
||||
}
|
||||
Err(GsdError::Other(ApplicationError::External(format!(
|
||||
"GSD-Preview fehlgeschlagen: {}",
|
||||
env.status_message()
|
||||
))))
|
||||
}
|
||||
|
||||
// ─── Makro-Aufruf (`POST /v1/execute/<name>`) ────────────────────────
|
||||
|
||||
/// Ein Makro-Aufruf mit fester Session. Liefert die rohe JSON-Antwort
|
||||
/// (Envelope ODER flach — der Aufrufer extrahiert die Felder). Bei
|
||||
/// `internalStatus == "201"` → `GsdError::InvalidSession` (Re-Login).
|
||||
async fn execute_macro_once(
|
||||
&self,
|
||||
session: &str,
|
||||
macro_name: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, GsdError> {
|
||||
let url = format!("{}/v1/execute/{}", self.config.rest_url, macro_name);
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("appkey", &self.config.app_key)
|
||||
.header("sessionId", session)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| GsdError::Other(Self::ext(e)))?;
|
||||
let val: serde_json::Value =
|
||||
resp.json().await.map_err(|e| GsdError::Other(Self::ext(e)))?;
|
||||
let invalid = val
|
||||
.get("status")
|
||||
.and_then(|s| s.get("internalStatus"))
|
||||
.and_then(|v| v.as_str())
|
||||
== Some(super::dto::STATUS_INVALID_SESSION);
|
||||
if invalid {
|
||||
return Err(GsdError::InvalidSession);
|
||||
}
|
||||
Ok(val)
|
||||
}
|
||||
|
||||
/// Ruft ein DOCUframe-Makro auf (mit Session + Re-Login-Retry). Liefert die
|
||||
/// rohe JSON-Antwort.
|
||||
pub async fn execute_macro(
|
||||
&self,
|
||||
macro_name: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, ApplicationError> {
|
||||
let session = self.current_session().await?;
|
||||
match self.execute_macro_once(&session, macro_name, body).await {
|
||||
Ok(v) => Ok(v),
|
||||
Err(GsdError::InvalidSession) => {
|
||||
info!("GSD: Session ungültig, Re-Login und erneuter Makro-Aufruf");
|
||||
let fresh = self.relogin(&session).await?;
|
||||
self.execute_macro_once(&fresh, macro_name, body)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
GsdError::InvalidSession => ApplicationError::External(
|
||||
"GSD: Session nach Re-Login weiterhin ungültig".into(),
|
||||
),
|
||||
GsdError::Other(inner) => inner,
|
||||
})
|
||||
}
|
||||
Err(GsdError::Other(inner)) => Err(inner),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AttachmentStorage for GsdService {
|
||||
async fn upload(
|
||||
&self,
|
||||
// DOCUframe legt keine Ordner nach Belegnummer an — der `folder`-
|
||||
// Schlüssel wird hier (vorerst) ignoriert; später ggf. als Kategorie.
|
||||
_folder: &str,
|
||||
filename: &str,
|
||||
mime: &str,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<String, ApplicationError> {
|
||||
let session = self.current_session().await?;
|
||||
match self.upload_once(&session, filename, mime, &bytes).await {
|
||||
Ok(oid) => Ok(oid),
|
||||
Err(GsdError::InvalidSession) => {
|
||||
info!("GSD: Session ungültig, Re-Login und erneuter Upload");
|
||||
let fresh = self.relogin(&session).await?;
|
||||
self.upload_once(&fresh, filename, mime, &bytes)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
GsdError::InvalidSession => ApplicationError::External(
|
||||
"GSD: Session nach Re-Login weiterhin ungültig".into(),
|
||||
),
|
||||
GsdError::Other(inner) => inner,
|
||||
})
|
||||
}
|
||||
Err(GsdError::Other(inner)) => Err(inner),
|
||||
}
|
||||
}
|
||||
|
||||
async fn download_preview(
|
||||
&self,
|
||||
object_id: &str,
|
||||
parameters: &str,
|
||||
page: &str,
|
||||
) -> Result<PreviewImage, ApplicationError> {
|
||||
let session = self.current_session().await?;
|
||||
match self
|
||||
.download_preview_once(&session, object_id, parameters, page)
|
||||
.await
|
||||
{
|
||||
Ok(img) => Ok(img),
|
||||
Err(GsdError::InvalidSession) => {
|
||||
info!("GSD: Session ungültig, Re-Login und erneuter Preview-Download");
|
||||
let fresh = self.relogin(&session).await?;
|
||||
self.download_preview_once(&fresh, object_id, parameters, page)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
GsdError::InvalidSession => ApplicationError::External(
|
||||
"GSD: Session nach Re-Login weiterhin ungültig".into(),
|
||||
),
|
||||
GsdError::Other(inner) => inner,
|
||||
})
|
||||
}
|
||||
Err(GsdError::Other(inner)) => Err(inner),
|
||||
}
|
||||
}
|
||||
|
||||
/// No-Op: In DOCUframe löschen wir nichts (der Report bleibt dort liegen).
|
||||
async fn delete(&self, _reference: &str) -> Result<(), ApplicationError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DocuframeReportGateway for GsdService {
|
||||
async fn upload_report_pdf(
|
||||
&self,
|
||||
belegnummer: &str,
|
||||
pdf: Vec<u8>,
|
||||
) -> Result<String, ApplicationError> {
|
||||
// Report wie ein Bild hochladen — der 3-stufige Upload liefert die
|
||||
// ~ObjectID. `folder` ist für DOCUframe irrelevant.
|
||||
let filename = format!("Lieferbericht-{belegnummer}.pdf");
|
||||
AttachmentStorage::upload(self, belegnummer, &filename, "application/pdf", pdf).await
|
||||
}
|
||||
|
||||
async fn assign_report(
|
||||
&self,
|
||||
object_id: &str,
|
||||
belegnummer: &str,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let body = serde_json::to_value(super::dto::AssignReportRequest {
|
||||
object_id,
|
||||
belegnummer,
|
||||
})
|
||||
.map_err(Self::ext)?;
|
||||
let val = self
|
||||
.execute_macro("_SV_assignDeliveryReport", &body)
|
||||
.await?;
|
||||
// Das Makro liefert `{succeeded, message}` per RETURN(STRING). Wie
|
||||
// `/v1/execute` das verpackt, ist nicht garantiert — daher robust gegen
|
||||
// alle drei Formen: flach, Envelope mit `data`-Objekt, oder `data` als
|
||||
// (escaptem) JSON-String. Wir prüfen die Kandidaten der Reihe nach.
|
||||
let mut candidates: Vec<serde_json::Value> = vec![val.clone()];
|
||||
if let Some(d) = val.get("data") {
|
||||
if d.is_object() {
|
||||
candidates.push(d.clone());
|
||||
} else if let Some(s) = d.as_str() {
|
||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(s) {
|
||||
candidates.push(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
let succeeded = candidates
|
||||
.iter()
|
||||
.find_map(|c| c.get("succeeded").and_then(|v| v.as_bool()))
|
||||
.unwrap_or(false);
|
||||
if succeeded {
|
||||
Ok(())
|
||||
} else {
|
||||
let msg = candidates
|
||||
.iter()
|
||||
.find_map(|c| c.get("message").and_then(|v| v.as_str()))
|
||||
.unwrap_or("unbekannte/keine Antwort (Makro evtl. nicht vorhanden)");
|
||||
Err(ApplicationError::External(format!(
|
||||
"DOCUframe-Makro _SV_assignDeliveryReport succeeded=false: {msg}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -10,4 +10,8 @@
|
||||
//! passenden Application-Ports.
|
||||
|
||||
pub mod auth;
|
||||
pub mod erp;
|
||||
pub mod gsd;
|
||||
pub mod persistence;
|
||||
pub mod report;
|
||||
pub mod storage;
|
||||
|
||||
107
crates/infrastructure/src/persistence/attachment_repository.rs
Normal file
107
crates/infrastructure/src/persistence/attachment_repository.rs
Normal file
@ -0,0 +1,107 @@
|
||||
//! Postgres-Implementierung des `AttachmentRepository`-Ports.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::{
|
||||
AttachmentLocalRef, AttachmentRef, AttachmentRepository, NewAttachment,
|
||||
};
|
||||
|
||||
pub struct PgAttachmentRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgAttachmentRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AttachmentRepository for PgAttachmentRepository {
|
||||
async fn create(&self, attachment: NewAttachment) -> Result<Uuid, ApplicationError> {
|
||||
let id: Uuid = sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO attachments (
|
||||
docuframe_object_id, mime_type, size_bytes, filename,
|
||||
checksum_sha256, width, height, uploaded_by, delivery_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(attachment.docuframe_object_id)
|
||||
.bind(attachment.mime_type)
|
||||
.bind(attachment.size_bytes)
|
||||
.bind(attachment.filename)
|
||||
.bind(attachment.checksum_sha256)
|
||||
.bind(attachment.width)
|
||||
.bind(attachment.height)
|
||||
.bind(attachment.uploaded_by)
|
||||
.bind(attachment.delivery_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn get(&self, id: Uuid) -> Result<Option<AttachmentRef>, ApplicationError> {
|
||||
let row: Option<(String, String)> = sqlx::query_as(
|
||||
"SELECT docuframe_object_id, mime_type FROM attachments WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(row.map(|(docuframe_object_id, mime_type)| AttachmentRef {
|
||||
docuframe_object_id,
|
||||
mime_type,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn delivery_belegnummer(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
) -> Result<Option<String>, ApplicationError> {
|
||||
let belegnummer: Option<String> = sqlx::query_scalar(
|
||||
"SELECT erp_belegnummer FROM deliveries WHERE id = $1",
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(belegnummer)
|
||||
}
|
||||
|
||||
async fn list_active_for_delivery(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
) -> Result<Vec<AttachmentLocalRef>, ApplicationError> {
|
||||
let rows: Vec<(Uuid, String)> = sqlx::query_as(
|
||||
"SELECT id, docuframe_object_id FROM attachments \
|
||||
WHERE delivery_id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, reference)| AttachmentLocalRef { id, reference })
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn mark_deleted(&self, id: Uuid) -> Result<(), ApplicationError> {
|
||||
sqlx::query("UPDATE attachments SET deleted_at = now() WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,515 @@
|
||||
//! Postgres-Implementierung des `DeliveryCompletionRepository`-Ports.
|
||||
//!
|
||||
//! Eine Transaktion, ein Abschluss. Ablauf:
|
||||
//! 1. `SELECT … FOR UPDATE` auf die Lieferung (Lock + aktueller State).
|
||||
//! 2. Idempotenz: schon `completed` mit Abschluss-Zeile → Erfolg zurück.
|
||||
//! 3. Gates: `active`, alle scanbaren Positionen fertig, Notizen bestätigt.
|
||||
//! 4. `INSERT INTO delivery_completions` + `UPDATE deliveries SET state`.
|
||||
//! 5. Frische `Delivery` bauen.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::NaiveDate;
|
||||
use sqlx::{PgPool, Postgres, Transaction};
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::{
|
||||
CompleteDeliveryInput, DeliveryCompletionRepository, ErpWritebackData, ErpWritebackLine,
|
||||
};
|
||||
use holzleitner_domain::{Address, Delivery, DeliveryState};
|
||||
|
||||
pub struct PgDeliveryCompletionRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgDeliveryCompletionRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DeliveryRow {
|
||||
id: Uuid,
|
||||
tour_id: Uuid,
|
||||
erp_belegart_id: i64,
|
||||
erp_belegnummer: String,
|
||||
customer_id: Uuid,
|
||||
snap_street: String,
|
||||
snap_house_number: String,
|
||||
snap_postal_code: String,
|
||||
snap_city: String,
|
||||
snap_country: String,
|
||||
assigned_car_id: Option<Uuid>,
|
||||
desired_time: Option<String>,
|
||||
special_agreements: Option<String>,
|
||||
state: String,
|
||||
prepaid_amount: f64,
|
||||
payment_method_id: Uuid,
|
||||
}
|
||||
|
||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
async fn lock_delivery(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
delivery_id: Uuid,
|
||||
) -> Result<Option<DeliveryRow>, ApplicationError> {
|
||||
sqlx::query_as::<_, DeliveryRow>(
|
||||
r#"
|
||||
SELECT
|
||||
id, tour_id, erp_belegart_id, erp_belegnummer, customer_id,
|
||||
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
|
||||
assigned_car_id, desired_time, special_agreements,
|
||||
state, prepaid_amount, payment_method_id
|
||||
FROM deliveries
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_err(db)
|
||||
}
|
||||
|
||||
async fn load_contacts(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
delivery_id: Uuid,
|
||||
) -> Result<Vec<Uuid>, ApplicationError> {
|
||||
let rows: Vec<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT customer_contact_id FROM delivery_contact_persons WHERE delivery_id = $1",
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_all(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(rows.into_iter().map(|(id,)| id).collect())
|
||||
}
|
||||
|
||||
fn build_delivery(row: DeliveryRow, state: DeliveryState, state_reason: Option<String>, contacts: Vec<Uuid>) -> Delivery {
|
||||
Delivery {
|
||||
id: row.id,
|
||||
tour_id: row.tour_id,
|
||||
erp_belegart_id: row.erp_belegart_id,
|
||||
erp_belegnummer: row.erp_belegnummer,
|
||||
customer_id: row.customer_id,
|
||||
delivery_address_snapshot: Address {
|
||||
street: row.snap_street,
|
||||
house_number: row.snap_house_number,
|
||||
postal_code: row.snap_postal_code,
|
||||
city: row.snap_city,
|
||||
country: row.snap_country,
|
||||
},
|
||||
assigned_car_id: row.assigned_car_id,
|
||||
contact_person_ids: contacts,
|
||||
desired_time: row.desired_time,
|
||||
special_agreements: row.special_agreements,
|
||||
state,
|
||||
state_reason,
|
||||
prepaid_amount: row.prepaid_amount,
|
||||
payment_method_id: row.payment_method_id,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeliveryCompletionRepository for PgDeliveryCompletionRepository {
|
||||
async fn complete(
|
||||
&self,
|
||||
input: CompleteDeliveryInput,
|
||||
) -> Result<Delivery, ApplicationError> {
|
||||
let delivery_id = input.delivery_id;
|
||||
let mut tx = self.pool.begin().await.map_err(db)?;
|
||||
|
||||
let Some(mut row) = lock_delivery(&mut tx, delivery_id).await? else {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::NotFound);
|
||||
};
|
||||
|
||||
// Idempotenz: bereits abgeschlossen + Abschluss-Zeile vorhanden →
|
||||
// unveränderten Erfolg liefern (Netz-Retry nach erfolgreichem Commit).
|
||||
if row.state == "completed" {
|
||||
let exists: Option<Uuid> = sqlx::query_scalar(
|
||||
"SELECT delivery_id FROM delivery_completions WHERE delivery_id = $1",
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
if exists.is_some() {
|
||||
let contacts = load_contacts(&mut tx, delivery_id).await?;
|
||||
tx.commit().await.map_err(db)?;
|
||||
return Ok(build_delivery(row, DeliveryState::Completed, None, contacts));
|
||||
}
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::Validation(
|
||||
"delivery already completed".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if row.state != "active" {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::Validation(
|
||||
"delivery is not active; cannot complete".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Empfangsbestätigung ist immer Pflicht (Doppel-Guard zum Use Case).
|
||||
if !input.receipt_confirmed {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::Validation(
|
||||
"receipt must be confirmed before completion".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Gate 1: alle scanbaren, nicht entfernten Positionen müssen fertig
|
||||
// sein (`done`). Entfernte (`removed`) zählen nicht.
|
||||
let open_scannables: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COUNT(*)
|
||||
FROM delivery_items di
|
||||
JOIN articles a ON a.id = di.article_id
|
||||
WHERE di.delivery_id = $1
|
||||
AND a.scannable = true
|
||||
AND di.scan_status NOT IN ('done', 'removed')
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
if open_scannables > 0 {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::Validation(format!(
|
||||
"{open_scannables} scannable item(s) not yet done; cannot complete"
|
||||
)));
|
||||
}
|
||||
|
||||
// Gate 2: existieren Notizen, muss der Kunde sie bestätigt haben.
|
||||
let note_count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM delivery_notes WHERE delivery_id = $1")
|
||||
.bind(delivery_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
if note_count > 0 && !input.notes_acknowledged {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::Validation(
|
||||
"notes must be acknowledged before completion".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Gate 3: Zahlungsmethode-Override (falls gesetzt) muss existieren UND
|
||||
// aktiv sein. `None` lässt die am Beleg hinterlegte Methode unangetastet.
|
||||
let effective_payment_method_id = match input.payment_method_id {
|
||||
Some(pm_id) => {
|
||||
let active: Option<bool> =
|
||||
sqlx::query_scalar("SELECT active FROM payment_methods WHERE id = $1")
|
||||
.bind(pm_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
match active {
|
||||
None => {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::Validation(
|
||||
"unknown payment method".into(),
|
||||
));
|
||||
}
|
||||
Some(false) => {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::Validation(
|
||||
"payment method is not active".into(),
|
||||
));
|
||||
}
|
||||
Some(true) => pm_id,
|
||||
}
|
||||
}
|
||||
None => row.payment_method_id,
|
||||
};
|
||||
|
||||
// Gate 4: Inkasso-Bestätigung. Besteht beim Abschluss ein offener
|
||||
// Betrag (> 0) UND ist die Methode ein Vor-Ort-Inkasso (Bar/EC), muss
|
||||
// der Fahrer bestätigt haben, dass kassiert wurde. „Auf Rechnung"
|
||||
// (oder offen == 0) ⇒ kein Inkasso, keine Pflicht.
|
||||
//
|
||||
// Offener Betrag = Σ unit_price·(required − credited) − Anzahlung −
|
||||
// Gutschrift — exakt dieselbe Formel wie App-Übersicht & PDF-Report.
|
||||
let warenwert: f64 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COALESCE(
|
||||
SUM(unit_price * GREATEST(required_quantity - credited_quantity, 0)),
|
||||
0
|
||||
)::float8
|
||||
FROM delivery_items
|
||||
WHERE delivery_id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
// Aktuelle Geld-Gutschrift: jüngstes Audit-Event ('set' → Betrag, sonst 0).
|
||||
let credit_cents: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COALESCE((
|
||||
SELECT CASE WHEN action = 'set' THEN amount_cents ELSE 0 END
|
||||
FROM delivery_credit_audit
|
||||
WHERE delivery_id = $1
|
||||
ORDER BY recorded_at DESC
|
||||
LIMIT 1
|
||||
), 0)
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let method_code: String =
|
||||
sqlx::query_scalar("SELECT code FROM payment_methods WHERE id = $1")
|
||||
.bind(effective_payment_method_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let open_euros =
|
||||
(warenwert - row.prepaid_amount - (credit_cents as f64) / 100.0).max(0.0);
|
||||
let open_cents = (open_euros * 100.0).round() as i64;
|
||||
let requires_collection =
|
||||
open_cents > 0 && (method_code == "cash" || method_code == "ec_card");
|
||||
if requires_collection && !input.payment_collected {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::Validation(
|
||||
"offener Betrag nicht als kassiert bestätigt; Abschluss nicht möglich".into(),
|
||||
));
|
||||
}
|
||||
// Snapshot des kassierten Betrags nur, wenn tatsächlich Inkasso anfiel.
|
||||
let collected_amount_cents: Option<i64> =
|
||||
if requires_collection { Some(open_cents) } else { None };
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO delivery_completions (
|
||||
delivery_id, customer_signature_path, driver_signature_path,
|
||||
receipt_confirmed, notes_acknowledged, acknowledged_note_ids,
|
||||
completed_by_personalnummer, completed_by_car_id,
|
||||
payment_collected, collected_amount_cents
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.bind(&input.customer_signature_path)
|
||||
.bind(&input.driver_signature_path)
|
||||
.bind(input.receipt_confirmed)
|
||||
.bind(input.notes_acknowledged)
|
||||
.bind(&input.acknowledged_note_ids)
|
||||
.bind(input.completed_by_personalnummer)
|
||||
.bind(input.completed_by_car_id)
|
||||
.bind(requires_collection && input.payment_collected)
|
||||
.bind(collected_amount_cents)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE deliveries SET state = 'completed', state_reason = NULL, payment_method_id = $2 WHERE id = $1",
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.bind(effective_payment_method_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let contacts = load_contacts(&mut tx, delivery_id).await?;
|
||||
tx.commit().await.map_err(db)?;
|
||||
|
||||
// Rückgabe spiegelt den (ggf. überschriebenen) Stand.
|
||||
row.payment_method_id = effective_payment_method_id;
|
||||
Ok(build_delivery(row, DeliveryState::Completed, None, contacts))
|
||||
}
|
||||
|
||||
async fn load_erp_writeback(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
) -> Result<ErpWritebackData, ApplicationError> {
|
||||
// Beleg-Key + Abschluss-Zeitpunkt in einem JOIN. INNER JOIN auf
|
||||
// delivery_completions ⇒ ohne Abschluss-Zeile keine Zeile (NotFound).
|
||||
let head: Option<(i64, String, chrono::DateTime<chrono::Utc>)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT d.erp_belegart_id, d.erp_belegnummer, c.completed_at
|
||||
FROM deliveries d
|
||||
JOIN delivery_completions c ON c.delivery_id = d.id
|
||||
WHERE d.id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let Some((belegart_id, belegnummer, completed_at)) = head else {
|
||||
return Err(ApplicationError::NotFound);
|
||||
};
|
||||
|
||||
// Ausgelieferte Menge je Belegzeile = required − credited.
|
||||
// NUR Oberartikel/Normalzeilen (komponenten_artikel_nr IS NULL) — sie
|
||||
// entsprechen 1:1 einer ERP-Belegzeile. Stücklisten-Komponenten teilen
|
||||
// sich die belegzeilen_nr des Oberartikels und haben KEINE eigene
|
||||
// ERP-Belegzeile; sie würden sonst mehrfache, widersprüchliche
|
||||
// Mengen-Updates auf dieselbe Zeile auslösen.
|
||||
let line_rows: Vec<(i32, i32)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT belegzeilen_nr, (required_quantity - credited_quantity)::int
|
||||
FROM delivery_items
|
||||
WHERE delivery_id = $1
|
||||
AND komponenten_artikel_nr IS NULL
|
||||
ORDER BY belegzeilen_nr
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let lines = line_rows
|
||||
.into_iter()
|
||||
.map(|(belegzeilen_nr, delivered_quantity)| ErpWritebackLine {
|
||||
belegzeilen_nr,
|
||||
delivered_quantity,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Aktuelle Geld-Gutschrift: jüngstes Audit-Event. 'set' → Betrag,
|
||||
// 'remove' (oder keine Zeile) → 0.
|
||||
let credit: Option<(String, i64)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT action, amount_cents
|
||||
FROM delivery_credit_audit
|
||||
WHERE delivery_id = $1
|
||||
ORDER BY recorded_at DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let credit_amount_cents = match credit {
|
||||
Some((action, cents)) if action == "set" => cents,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
// Beim Abschluss gewählte Zahlungsmethode → Code (cash/ec_card/invoice).
|
||||
let payment_method_code: Option<String> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT pm.code
|
||||
FROM deliveries d
|
||||
JOIN payment_methods pm ON pm.id = d.payment_method_id
|
||||
WHERE d.id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
Ok(ErpWritebackData {
|
||||
belegart_id,
|
||||
belegnummer,
|
||||
// ERP erwartet lokale Zeit; completed_at ist UTC → in lokale
|
||||
// Wanduhrzeit umrechnen und die TZ-Info fallenlassen.
|
||||
delivered_at: completed_at.with_timezone(&chrono::Local).naive_local(),
|
||||
lines,
|
||||
credit_amount_cents,
|
||||
payment_method_code,
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_delivered_belegnummern(
|
||||
&self,
|
||||
day: Option<NaiveDate>,
|
||||
) -> Result<Vec<String>, ApplicationError> {
|
||||
// INNER JOIN auf delivery_completions ⇒ nur ausgelieferte (abgeschlossene)
|
||||
// Lieferungen. `mail_sent_at IS NULL` ⇒ nur noch nicht versendete
|
||||
// (server-seitiges Dedup für den Mailclient). Der optionale Tagesfilter:
|
||||
// bei NULL ($1) ⇒ ALLE offenen über alle Tage; sonst der Berliner
|
||||
// Kalendertag von completed_at (TIMESTAMPTZ = UTC-Instant → AT TIME ZONE
|
||||
// 'Europe/Berlin' → ::date), damit ein Abschluss um 23:30 Ortszeit nicht
|
||||
// fälschlich dem UTC-Folgetag zugeordnet wird.
|
||||
let belegnummern: Vec<String> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT d.erp_belegnummer
|
||||
FROM deliveries d
|
||||
JOIN delivery_completions c ON c.delivery_id = d.id
|
||||
WHERE c.mail_sent_at IS NULL
|
||||
AND ( $1::date IS NULL
|
||||
OR (c.completed_at AT TIME ZONE 'Europe/Berlin')::date = $1 )
|
||||
ORDER BY c.completed_at
|
||||
"#,
|
||||
)
|
||||
.bind(day)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
Ok(belegnummern)
|
||||
}
|
||||
|
||||
async fn mark_mail_sent(
|
||||
&self,
|
||||
belegnummern: &[String],
|
||||
) -> Result<u64, ApplicationError> {
|
||||
if belegnummern.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
// Setzt mail_sent_at nur dort, wo noch NULL (idempotent — erster
|
||||
// Versand-Zeitpunkt bleibt erhalten, mehrfaches Markieren ist harmlos).
|
||||
// Match über die Belegnummer (was der GET-Endpoint zurückgibt).
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE delivery_completions c
|
||||
SET mail_sent_at = now()
|
||||
FROM deliveries d
|
||||
WHERE c.delivery_id = d.id
|
||||
AND d.erp_belegnummer = ANY($1)
|
||||
AND c.mail_sent_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(belegnummern)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
async fn unmark_mail_sent(
|
||||
&self,
|
||||
belegnummern: &[String],
|
||||
) -> Result<u64, ApplicationError> {
|
||||
if belegnummern.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
// DEV: mail_sent_at zurück auf NULL, nur wo aktuell gesetzt.
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE delivery_completions c
|
||||
SET mail_sent_at = NULL
|
||||
FROM deliveries d
|
||||
WHERE c.delivery_id = d.id
|
||||
AND d.erp_belegnummer = ANY($1)
|
||||
AND c.mail_sent_at IS NOT NULL
|
||||
"#,
|
||||
)
|
||||
.bind(belegnummern)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,139 @@
|
||||
//! Postgres-Implementierung des `DeliveryCreditRepository`-Ports.
|
||||
//!
|
||||
//! Append-only: `apply_event` hängt eine Zeile ans `delivery_credit_audit`
|
||||
//! und liest danach den aktuellen Stand (jüngstes Ereignis). Idempotent über
|
||||
//! `client_event_id`; `set`/`remove` nur bei `active`er Lieferung.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{PgPool, Postgres, Transaction};
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::dto::CreditAction;
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::DeliveryCreditRepository;
|
||||
use holzleitner_domain::DeliveryCredit;
|
||||
|
||||
pub struct PgDeliveryCreditRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgDeliveryCreditRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
fn action_str(a: CreditAction) -> &'static str {
|
||||
match a {
|
||||
CreditAction::Set => "set",
|
||||
CreditAction::Remove => "remove",
|
||||
}
|
||||
}
|
||||
|
||||
/// Liest den aktuellen Gutschrift-Stand einer Lieferung = jüngstes Ereignis.
|
||||
/// `set` → `Some(..)`, `remove` (oder kein Ereignis) → `None`.
|
||||
async fn current_credit(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
delivery_id: Uuid,
|
||||
) -> Result<Option<DeliveryCredit>, ApplicationError> {
|
||||
let row: Option<(String, i64, Option<String>)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT action, amount_cents, reason
|
||||
FROM delivery_credit_audit
|
||||
WHERE delivery_id = $1
|
||||
ORDER BY recorded_at DESC, id DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
Ok(match row {
|
||||
Some((action, amount_cents, reason)) if action == "set" => Some(DeliveryCredit {
|
||||
delivery_id,
|
||||
amount_cents,
|
||||
reason: reason.unwrap_or_default(),
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeliveryCreditRepository for PgDeliveryCreditRepository {
|
||||
async fn apply_event(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
client_event_id: Uuid,
|
||||
action: CreditAction,
|
||||
amount_cents: i64,
|
||||
reason: Option<String>,
|
||||
author_personalnummer: i64,
|
||||
author_car_id: Option<Uuid>,
|
||||
) -> Result<Option<DeliveryCredit>, ApplicationError> {
|
||||
let mut tx = self.pool.begin().await.map_err(db)?;
|
||||
|
||||
// Idempotenz: ist die client_event_id schon bekannt, nichts erneut
|
||||
// anwenden — nur den aktuellen Stand liefern (ohne active-Check, das
|
||||
// Ereignis wurde ja bereits akzeptiert).
|
||||
let already: Option<Uuid> = sqlx::query_scalar(
|
||||
"SELECT id FROM delivery_credit_audit WHERE client_event_id = $1",
|
||||
)
|
||||
.bind(client_event_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
if already.is_some() {
|
||||
let current = current_credit(&mut tx, delivery_id).await?;
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Ok(current);
|
||||
}
|
||||
|
||||
// Frisches Ereignis: Lieferung muss existieren und aktiv sein.
|
||||
let state: Option<String> =
|
||||
sqlx::query_scalar("SELECT state FROM deliveries WHERE id = $1 FOR UPDATE")
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
let Some(state) = state else {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::NotFound);
|
||||
};
|
||||
if state != "active" {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Err(ApplicationError::Validation(
|
||||
"delivery is not active; cannot change credit".into(),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO delivery_credit_audit (
|
||||
client_event_id, delivery_id, action, amount_cents, reason,
|
||||
author_personalnummer, author_car_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
"#,
|
||||
)
|
||||
.bind(client_event_id)
|
||||
.bind(delivery_id)
|
||||
.bind(action_str(action))
|
||||
.bind(amount_cents)
|
||||
.bind(reason.as_deref())
|
||||
.bind(author_personalnummer)
|
||||
.bind(author_car_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let current = current_credit(&mut tx, delivery_id).await?;
|
||||
tx.commit().await.map_err(db)?;
|
||||
Ok(current)
|
||||
}
|
||||
}
|
||||
@ -28,6 +28,7 @@ fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
|
||||
#[async_trait]
|
||||
impl DeliveryNoteRepository for PgDeliveryNoteRepository {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn create(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
@ -35,6 +36,8 @@ impl DeliveryNoteRepository for PgDeliveryNoteRepository {
|
||||
author_car_id: Option<Uuid>,
|
||||
text: Option<String>,
|
||||
image_attachment: Option<String>,
|
||||
credit_delivery_item_id: Option<Uuid>,
|
||||
is_amount_credit_note: bool,
|
||||
) -> Result<DeliveryNote, ApplicationError> {
|
||||
let exists: Option<Uuid> =
|
||||
sqlx::query_scalar("SELECT id FROM deliveries WHERE id = $1")
|
||||
@ -49,8 +52,9 @@ impl DeliveryNoteRepository for PgDeliveryNoteRepository {
|
||||
let (id, created_at): (Uuid, DateTime<Utc>) = sqlx::query_as(
|
||||
r#"
|
||||
INSERT INTO delivery_notes (
|
||||
delivery_id, text, image_attachment, author_personalnummer, author_car_id
|
||||
) VALUES ($1, $2, $3, $4, $5)
|
||||
delivery_id, text, image_attachment, author_personalnummer,
|
||||
author_car_id, credit_delivery_item_id, is_amount_credit_note
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, created_at
|
||||
"#,
|
||||
)
|
||||
@ -59,6 +63,8 @@ impl DeliveryNoteRepository for PgDeliveryNoteRepository {
|
||||
.bind(image_attachment.as_deref())
|
||||
.bind(author_personalnummer)
|
||||
.bind(author_car_id)
|
||||
.bind(credit_delivery_item_id)
|
||||
.bind(is_amount_credit_note)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
@ -70,7 +76,86 @@ impl DeliveryNoteRepository for PgDeliveryNoteRepository {
|
||||
image_attachment,
|
||||
author_personalnummer,
|
||||
author_car_id,
|
||||
credit_delivery_item_id,
|
||||
is_amount_credit_note,
|
||||
// Frisch angelegt → Bild (falls vorhanden) liegt lokal vor.
|
||||
image_attachment_deleted: false,
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
note_id: Uuid,
|
||||
text: Option<String>,
|
||||
image_attachment: Option<String>,
|
||||
) -> Result<DeliveryNote, ApplicationError> {
|
||||
// RETURNING liefert die vollständige Zeile zurück — kein zweiter
|
||||
// Read nötig. `fetch_optional` unterscheidet „nicht gefunden" sauber
|
||||
// von DB-Fehlern.
|
||||
let row: Option<(
|
||||
Uuid,
|
||||
Uuid,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
i64,
|
||||
Option<Uuid>,
|
||||
Option<Uuid>,
|
||||
bool,
|
||||
DateTime<Utc>,
|
||||
)> = sqlx::query_as(
|
||||
r#"
|
||||
UPDATE delivery_notes
|
||||
SET text = $2, image_attachment = $3
|
||||
WHERE id = $1
|
||||
RETURNING id, delivery_id, text, image_attachment,
|
||||
author_personalnummer, author_car_id,
|
||||
credit_delivery_item_id, is_amount_credit_note, created_at
|
||||
"#,
|
||||
)
|
||||
.bind(note_id)
|
||||
.bind(text.as_deref())
|
||||
.bind(image_attachment.as_deref())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
match row {
|
||||
None => Err(ApplicationError::NotFound),
|
||||
Some((
|
||||
id,
|
||||
delivery_id,
|
||||
text,
|
||||
image_attachment,
|
||||
author_personalnummer,
|
||||
author_car_id,
|
||||
credit_delivery_item_id,
|
||||
is_amount_credit_note,
|
||||
created_at,
|
||||
)) => Ok(DeliveryNote {
|
||||
id,
|
||||
delivery_id,
|
||||
text,
|
||||
image_attachment,
|
||||
author_personalnummer,
|
||||
author_car_id,
|
||||
credit_delivery_item_id,
|
||||
is_amount_credit_note,
|
||||
image_attachment_deleted: false,
|
||||
created_at,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, note_id: Uuid) -> Result<(), ApplicationError> {
|
||||
let result = sqlx::query("DELETE FROM delivery_notes WHERE id = $1")
|
||||
.bind(note_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApplicationError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,150 @@
|
||||
//! Postgres-Implementierung von `DeliveryReportJobRepository`.
|
||||
//!
|
||||
//! Spiegelt `delivery_report_jobs` — der harte Zustandsanker der
|
||||
//! Report-Übertragung an DOCUframe (für Resume + Cron-Retry).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::{DeliveryReportJobRepository, ReportJob, ReportJobStatus};
|
||||
|
||||
pub struct PgDeliveryReportJobRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgDeliveryReportJobRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct JobRow {
|
||||
delivery_id: Uuid,
|
||||
belegnummer: String,
|
||||
status: String,
|
||||
docuframe_object_id: Option<String>,
|
||||
report_uploaded_at: Option<DateTime<Utc>>,
|
||||
attempts: i32,
|
||||
last_error: Option<String>,
|
||||
}
|
||||
|
||||
impl From<JobRow> for ReportJob {
|
||||
fn from(r: JobRow) -> Self {
|
||||
ReportJob {
|
||||
delivery_id: r.delivery_id,
|
||||
belegnummer: r.belegnummer,
|
||||
status: ReportJobStatus::parse(&r.status),
|
||||
docuframe_object_id: r.docuframe_object_id,
|
||||
report_uploaded_at: r.report_uploaded_at,
|
||||
attempts: r.attempts,
|
||||
last_error: r.last_error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SELECT: &str = "SELECT delivery_id, belegnummer, status, docuframe_object_id, \
|
||||
report_uploaded_at, attempts, last_error FROM delivery_report_jobs";
|
||||
|
||||
#[async_trait]
|
||||
impl DeliveryReportJobRepository for PgDeliveryReportJobRepository {
|
||||
async fn ensure(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
belegnummer: &str,
|
||||
) -> Result<ReportJob, ApplicationError> {
|
||||
// Idempotent: vorhandenen Job NICHT zurücksetzen. DO UPDATE (no-op auf
|
||||
// belegnummer) nur, damit RETURNING auch bei Konflikt die Zeile liefert.
|
||||
let row: JobRow = sqlx::query_as(
|
||||
r#"
|
||||
INSERT INTO delivery_report_jobs (delivery_id, belegnummer)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (delivery_id)
|
||||
DO UPDATE SET belegnummer = EXCLUDED.belegnummer
|
||||
RETURNING delivery_id, belegnummer, status, docuframe_object_id,
|
||||
report_uploaded_at, attempts, last_error
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.bind(belegnummer)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(row.into())
|
||||
}
|
||||
|
||||
async fn get(&self, delivery_id: Uuid) -> Result<Option<ReportJob>, ApplicationError> {
|
||||
let row: Option<JobRow> = sqlx::query_as(&format!("{SELECT} WHERE delivery_id = $1"))
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn list_open(&self) -> Result<Vec<ReportJob>, ApplicationError> {
|
||||
let rows: Vec<JobRow> =
|
||||
sqlx::query_as(&format!("{SELECT} WHERE status <> 'done' ORDER BY created_at"))
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn set_uploaded(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
object_id: &str,
|
||||
) -> Result<(), ApplicationError> {
|
||||
sqlx::query(
|
||||
"UPDATE delivery_report_jobs \
|
||||
SET status = 'uploaded', docuframe_object_id = $2, updated_at = now() \
|
||||
WHERE delivery_id = $1",
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.bind(object_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mark_done(&self, delivery_id: Uuid) -> Result<(), ApplicationError> {
|
||||
sqlx::query(
|
||||
"UPDATE delivery_report_jobs \
|
||||
SET status = 'done', report_uploaded_at = now(), updated_at = now() \
|
||||
WHERE delivery_id = $1",
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn record_error(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
error: &str,
|
||||
) -> Result<(), ApplicationError> {
|
||||
sqlx::query(
|
||||
"UPDATE delivery_report_jobs \
|
||||
SET attempts = attempts + 1, last_error = $2, last_attempt_at = now(), \
|
||||
updated_at = now() \
|
||||
WHERE delivery_id = $1",
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.bind(error)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -43,6 +43,8 @@ struct DeliveryRow {
|
||||
desired_time: Option<String>,
|
||||
special_agreements: Option<String>,
|
||||
state: String,
|
||||
prepaid_amount: f64,
|
||||
payment_method_id: Uuid,
|
||||
}
|
||||
|
||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
@ -81,7 +83,15 @@ fn next_state(
|
||||
use DeliveryState as S;
|
||||
match (current, action) {
|
||||
(S::Active, A::Hold { reason }) => Ok((S::Held, Some(reason.clone()))),
|
||||
(S::Held, A::Resume) => Ok((S::Active, None)),
|
||||
// `Resume` führt sowohl aus `Held` als auch aus `Canceled`
|
||||
// zurück auf `Active`. Die App erzwingt vor Cancel-Recovery
|
||||
// einen extra Bestätigungsdialog; technisch sind beide Pfade
|
||||
// identisch. Der `state_reason` wird in beiden Fällen
|
||||
// gelöscht — der Audit-Trail dazu lebt aktuell nur am Reason
|
||||
// selbst und geht damit verloren. Schließt sich in Phase G
|
||||
// (siehe `docs/BACKEND_MIGRATION.md`): eigenes
|
||||
// `delivery_audit`-Log analog zu `scan_audit`.
|
||||
(S::Held | S::Canceled, A::Resume) => Ok((S::Active, None)),
|
||||
(S::Active | S::Held, A::Cancel { reason }) => Ok((S::Canceled, Some(reason.clone()))),
|
||||
(S::Active, A::Complete) => Ok((S::Completed, None)),
|
||||
|
||||
@ -104,7 +114,7 @@ async fn lock_delivery(
|
||||
id, tour_id, erp_belegart_id, erp_belegnummer, customer_id,
|
||||
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
|
||||
assigned_car_id, desired_time, special_agreements,
|
||||
state
|
||||
state, prepaid_amount, payment_method_id
|
||||
FROM deliveries
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
@ -190,6 +200,8 @@ impl DeliveryRepository for PgDeliveryRepository {
|
||||
special_agreements: row.special_agreements,
|
||||
state: target,
|
||||
state_reason: new_reason,
|
||||
prepaid_amount: row.prepaid_amount,
|
||||
payment_method_id: row.payment_method_id,
|
||||
})
|
||||
}
|
||||
|
||||
@ -245,6 +257,8 @@ impl DeliveryRepository for PgDeliveryRepository {
|
||||
special_agreements: row.special_agreements,
|
||||
state: current,
|
||||
state_reason,
|
||||
prepaid_amount: row.prepaid_amount,
|
||||
payment_method_id: row.payment_method_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,111 @@
|
||||
//! Postgres-Implementierung des `DeliveryServiceRepository`-Ports (Upsert).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{PgPool, Postgres, Transaction};
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::DeliveryServiceRepository;
|
||||
use holzleitner_domain::DeliveryServiceValue;
|
||||
|
||||
pub struct PgDeliveryServiceRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgDeliveryServiceRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
/// Lieferung muss existieren und `active` sein, sonst Reject. Lock auf der
|
||||
/// deliveries-Zeile innerhalb der Transaktion.
|
||||
async fn assert_delivery_active(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
delivery_id: Uuid,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let state: Option<String> =
|
||||
sqlx::query_scalar("SELECT state FROM deliveries WHERE id = $1 FOR UPDATE")
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
match state {
|
||||
None => Err(ApplicationError::NotFound),
|
||||
Some(s) if s != "active" => Err(ApplicationError::Validation(
|
||||
"delivery is not active; cannot change services".into(),
|
||||
)),
|
||||
Some(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeliveryServiceRepository for PgDeliveryServiceRepository {
|
||||
async fn set(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
service_id: Uuid,
|
||||
bool_value: Option<bool>,
|
||||
numeric_value: Option<i32>,
|
||||
author_personalnummer: i64,
|
||||
author_car_id: Option<Uuid>,
|
||||
) -> Result<DeliveryServiceValue, ApplicationError> {
|
||||
let mut tx = self.pool.begin().await.map_err(db)?;
|
||||
assert_delivery_active(&mut tx, delivery_id).await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO delivery_services (
|
||||
delivery_id, service_id, bool_value, numeric_value,
|
||||
author_personalnummer, author_car_id, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, now())
|
||||
ON CONFLICT (delivery_id, service_id) DO UPDATE SET
|
||||
bool_value = EXCLUDED.bool_value,
|
||||
numeric_value = EXCLUDED.numeric_value,
|
||||
author_personalnummer = EXCLUDED.author_personalnummer,
|
||||
author_car_id = EXCLUDED.author_car_id,
|
||||
updated_at = now()
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.bind(service_id)
|
||||
.bind(bool_value)
|
||||
.bind(numeric_value)
|
||||
.bind(author_personalnummer)
|
||||
.bind(author_car_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
tx.commit().await.map_err(db)?;
|
||||
Ok(DeliveryServiceValue {
|
||||
delivery_id,
|
||||
service_id,
|
||||
bool_value,
|
||||
numeric_value,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
service_id: Uuid,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let mut tx = self.pool.begin().await.map_err(db)?;
|
||||
assert_delivery_active(&mut tx, delivery_id).await?;
|
||||
sqlx::query(
|
||||
"DELETE FROM delivery_services WHERE delivery_id = $1 AND service_id = $2",
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.bind(service_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
tx.commit().await.map_err(db)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -5,17 +5,31 @@
|
||||
//! und Migrations werden ebenfalls hier verwaltet.
|
||||
|
||||
pub mod account_repository;
|
||||
pub mod attachment_repository;
|
||||
pub mod car_repository;
|
||||
pub mod delivery_completion_repository;
|
||||
pub mod delivery_credit_repository;
|
||||
pub mod delivery_note_repository;
|
||||
pub mod delivery_report_job_repository;
|
||||
pub mod delivery_repository;
|
||||
pub mod delivery_service_repository;
|
||||
pub mod payment_method_repository;
|
||||
pub mod pool;
|
||||
pub mod scan_repository;
|
||||
pub mod service_repository;
|
||||
pub mod tour_repository;
|
||||
|
||||
pub use account_repository::PgAccountRepository;
|
||||
pub use attachment_repository::PgAttachmentRepository;
|
||||
pub use car_repository::PgCarRepository;
|
||||
pub use delivery_completion_repository::PgDeliveryCompletionRepository;
|
||||
pub use delivery_credit_repository::PgDeliveryCreditRepository;
|
||||
pub use delivery_note_repository::PgDeliveryNoteRepository;
|
||||
pub use delivery_report_job_repository::PgDeliveryReportJobRepository;
|
||||
pub use delivery_repository::PgDeliveryRepository;
|
||||
pub use delivery_service_repository::PgDeliveryServiceRepository;
|
||||
pub use payment_method_repository::PgPaymentMethodRepository;
|
||||
pub use pool::{connect_and_migrate, PoolConfig};
|
||||
pub use scan_repository::PgScanRepository;
|
||||
pub use service_repository::PgServiceRepository;
|
||||
pub use tour_repository::PgTourRepository;
|
||||
|
||||
@ -0,0 +1,174 @@
|
||||
//! Postgres-Implementierung des `PaymentMethodRepository`-Ports.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::PaymentMethodRepository;
|
||||
use holzleitner_domain::PaymentMethod;
|
||||
|
||||
pub struct PgPaymentMethodRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgPaymentMethodRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct PaymentMethodRow {
|
||||
id: Uuid,
|
||||
code: String,
|
||||
name: String,
|
||||
active: bool,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<PaymentMethodRow> for PaymentMethod {
|
||||
fn from(r: PaymentMethodRow) -> Self {
|
||||
PaymentMethod {
|
||||
id: r.id,
|
||||
code: r.code,
|
||||
name: r.name,
|
||||
active: r.active,
|
||||
created_at: r.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
/// Postgres-SQLSTATE-Codes — wir interessieren uns für zwei:
|
||||
///
|
||||
/// * `23505 unique_violation` — `code`-Duplikat beim INSERT
|
||||
/// * `23503 foreign_key_violation` — Lieferungen zeigen noch auf die
|
||||
/// Methode, die gerade gelöscht werden soll (RESTRICT)
|
||||
fn pg_sqlstate(err: &sqlx::Error) -> Option<String> {
|
||||
if let sqlx::Error::Database(db_err) = err {
|
||||
return db_err.code().map(|c| c.into_owned());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PaymentMethodRepository for PgPaymentMethodRepository {
|
||||
async fn list(
|
||||
&self,
|
||||
include_inactive: bool,
|
||||
) -> Result<Vec<PaymentMethod>, ApplicationError> {
|
||||
let rows = sqlx::query_as::<_, PaymentMethodRow>(
|
||||
r#"
|
||||
SELECT id, code, name, active, created_at
|
||||
FROM payment_methods
|
||||
WHERE (active = TRUE OR $1)
|
||||
ORDER BY name
|
||||
"#,
|
||||
)
|
||||
.bind(include_inactive)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(rows.into_iter().map(PaymentMethod::from).collect())
|
||||
}
|
||||
|
||||
async fn find_by_id(
|
||||
&self,
|
||||
id: Uuid,
|
||||
) -> Result<Option<PaymentMethod>, ApplicationError> {
|
||||
let row = sqlx::query_as::<_, PaymentMethodRow>(
|
||||
r#"
|
||||
SELECT id, code, name, active, created_at
|
||||
FROM payment_methods
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(row.map(PaymentMethod::from))
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
code: &str,
|
||||
name: &str,
|
||||
) -> Result<PaymentMethod, ApplicationError> {
|
||||
match sqlx::query_as::<_, PaymentMethodRow>(
|
||||
r#"
|
||||
INSERT INTO payment_methods (code, name)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, code, name, active, created_at
|
||||
"#,
|
||||
)
|
||||
.bind(code)
|
||||
.bind(name)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
{
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(e) if pg_sqlstate(&e).as_deref() == Some("23505") => Err(
|
||||
ApplicationError::Conflict(format!(
|
||||
"payment method with code '{code}' already exists"
|
||||
)),
|
||||
),
|
||||
Err(e) => Err(db(e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
id: Uuid,
|
||||
name: Option<&str>,
|
||||
active: Option<bool>,
|
||||
) -> Result<PaymentMethod, ApplicationError> {
|
||||
// COALESCE-Pattern lässt unveränderte Felder durch die DB-Spalte
|
||||
// fließen — wir senden für „nicht ändern" einfach NULL.
|
||||
let row = sqlx::query_as::<_, PaymentMethodRow>(
|
||||
r#"
|
||||
UPDATE payment_methods
|
||||
SET name = COALESCE($2, name),
|
||||
active = COALESCE($3, active)
|
||||
WHERE id = $1
|
||||
RETURNING id, code, name, active, created_at
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(name)
|
||||
.bind(active)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
row.map(PaymentMethod::from)
|
||||
.ok_or(ApplicationError::NotFound)
|
||||
}
|
||||
|
||||
async fn delete(&self, id: Uuid) -> Result<(), ApplicationError> {
|
||||
match sqlx::query("DELETE FROM payment_methods WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
if result.rows_affected() == 0 {
|
||||
Err(ApplicationError::NotFound)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Err(e) if pg_sqlstate(&e).as_deref() == Some("23503") => Err(
|
||||
ApplicationError::Conflict(
|
||||
"payment method is in use by at least one delivery"
|
||||
.to_string(),
|
||||
),
|
||||
),
|
||||
Err(e) => Err(db(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -38,8 +38,10 @@ impl PgScanRepository {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ItemLockRow {
|
||||
id: Uuid,
|
||||
delivery_id: Uuid,
|
||||
required_quantity: i32,
|
||||
scanned_quantity: i32,
|
||||
credited_quantity: i32,
|
||||
scan_status: String,
|
||||
held_reason: Option<String>,
|
||||
scan_last_updated_at: DateTime<Utc>,
|
||||
@ -47,6 +49,11 @@ struct ItemLockRow {
|
||||
komponenten_artikel_nr: Option<String>,
|
||||
erp_belegart_id: i64,
|
||||
erp_belegnummer: String,
|
||||
/// `articles.scannable` der Position — entscheidet, ob für eine
|
||||
/// Gutschrift erst gescannt (`Done`) sein muss.
|
||||
scannable: bool,
|
||||
/// `deliveries.state` — Gutschriften nur bei `active`.
|
||||
delivery_state: String,
|
||||
}
|
||||
|
||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
@ -81,40 +88,86 @@ fn action_str(a: AuditAction) -> &'static str {
|
||||
AuditAction::Hold => "hold",
|
||||
AuditAction::Unhold => "unhold",
|
||||
AuditAction::Remove => "remove",
|
||||
AuditAction::Unremove => "unremove",
|
||||
}
|
||||
}
|
||||
|
||||
/// Ergebnis einer reinen Zustandsübergangs-Rechnung (ohne DB).
|
||||
struct Transition {
|
||||
/// Signed Δ der SCAN-Menge (+1/-1 bei Scan/Unscan, sonst 0).
|
||||
delta: i32,
|
||||
new_quantity: i32,
|
||||
new_status: ScanStatus,
|
||||
new_held_reason: Option<String>,
|
||||
/// Signed Δ der GUTSCHRIFT-Menge: `Some(+n)` bei Remove, `Some(-n)` bei
|
||||
/// Unremove, sonst `None` (Audit-Spalte bleibt dann NULL).
|
||||
credit_delta: Option<i32>,
|
||||
/// Neuer Stand `credited_quantity` nach der Aktion (für das Item-Update).
|
||||
new_credited_quantity: i32,
|
||||
}
|
||||
|
||||
/// Schnappschuss des relevanten Item-Zustands für die reine
|
||||
/// Übergangs-Rechnung — bündelt die vielen Parameter, die Remove/Unremove
|
||||
/// jetzt brauchen.
|
||||
struct ItemSnapshot<'a> {
|
||||
current_qty: i32,
|
||||
current_credited: i32,
|
||||
current_status: ScanStatus,
|
||||
required_qty: i32,
|
||||
scannable: bool,
|
||||
/// `deliveries.state` als String ('active' / 'held' / …).
|
||||
delivery_state: &'a str,
|
||||
}
|
||||
|
||||
/// Berechnet den nächsten Zustand. Bei `Err` enthält der String die
|
||||
/// fachliche Ablehnungs-Begründung, die 1:1 an die App geht.
|
||||
///
|
||||
/// `quantity` ist nur für Remove/Unremove relevant (Mengen-Gutschrift);
|
||||
/// `None` heißt dort „ganze Restmenge".
|
||||
fn apply_transition(
|
||||
action: AuditAction,
|
||||
current_qty: i32,
|
||||
current_status: ScanStatus,
|
||||
required_qty: i32,
|
||||
item: &ItemSnapshot<'_>,
|
||||
quantity: Option<i32>,
|
||||
reason: Option<&str>,
|
||||
) -> Result<Transition, String> {
|
||||
let current_qty = item.current_qty;
|
||||
let current_status = item.current_status;
|
||||
let required_qty = item.required_qty;
|
||||
let current_credited = item.current_credited;
|
||||
|
||||
match action {
|
||||
AuditAction::Scan => match current_status {
|
||||
ScanStatus::InProgress | ScanStatus::Done => {
|
||||
let new_qty = current_qty + 1;
|
||||
// `quantity = None` → +1 (regulärer Einzel-Barcode-Scan).
|
||||
// `quantity = Some(n)` → n Stück auf einmal (manuelle
|
||||
// Zeilen-Bestätigung der Restmenge). n muss in
|
||||
// [1 .. required − scanned] liegen, damit nicht über das Soll
|
||||
// hinaus „gescannt" wird.
|
||||
let n = match quantity {
|
||||
None => 1,
|
||||
Some(n) => {
|
||||
let remaining = required_qty - current_qty;
|
||||
if n <= 0 || n > remaining {
|
||||
return Err(format!(
|
||||
"invalid scan quantity {n}; remaining scannable {remaining}"
|
||||
));
|
||||
}
|
||||
n
|
||||
}
|
||||
};
|
||||
let new_qty = current_qty + n;
|
||||
let new_status = if new_qty >= required_qty {
|
||||
ScanStatus::Done
|
||||
} else {
|
||||
ScanStatus::InProgress
|
||||
};
|
||||
Ok(Transition {
|
||||
delta: 1,
|
||||
delta: n,
|
||||
new_quantity: new_qty,
|
||||
new_status,
|
||||
new_held_reason: None,
|
||||
credit_delta: None,
|
||||
new_credited_quantity: current_credited,
|
||||
})
|
||||
}
|
||||
ScanStatus::Held => Err("item is on hold; unhold before scanning".into()),
|
||||
@ -131,6 +184,8 @@ fn apply_transition(
|
||||
new_quantity: new_qty,
|
||||
new_status: ScanStatus::InProgress,
|
||||
new_held_reason: None,
|
||||
credit_delta: None,
|
||||
new_credited_quantity: current_credited,
|
||||
})
|
||||
}
|
||||
ScanStatus::Held => Err("item is on hold".into()),
|
||||
@ -142,6 +197,8 @@ fn apply_transition(
|
||||
new_quantity: current_qty,
|
||||
new_status: ScanStatus::Held,
|
||||
new_held_reason: reason.map(str::to_owned),
|
||||
credit_delta: None,
|
||||
new_credited_quantity: current_credited,
|
||||
}),
|
||||
ScanStatus::Held => Err("item is already held".into()),
|
||||
ScanStatus::Removed => Err("item is removed".into()),
|
||||
@ -158,19 +215,101 @@ fn apply_transition(
|
||||
new_quantity: current_qty,
|
||||
new_status,
|
||||
new_held_reason: None,
|
||||
credit_delta: None,
|
||||
new_credited_quantity: current_credited,
|
||||
})
|
||||
}
|
||||
_ => Err("item is not held".into()),
|
||||
},
|
||||
AuditAction::Remove => match current_status {
|
||||
ScanStatus::Removed => Err("item is already removed".into()),
|
||||
_ => Ok(Transition {
|
||||
|
||||
// ── Mengen-Gutschrift ───────────────────────────────────────────
|
||||
// Entscheidungstabelle (siehe Design):
|
||||
// * Lieferung muss `active` sein
|
||||
// * scannbare Position muss `Done` sein (erst verladen)
|
||||
// * Menge muss in [1 .. Restmenge] liegen
|
||||
// * Status → `Removed` erst wenn voll gutgeschrieben
|
||||
AuditAction::Remove => {
|
||||
if item.delivery_state != "active" {
|
||||
return Err("delivery is not active; cannot credit".into());
|
||||
}
|
||||
if item.scannable && current_status != ScanStatus::Done {
|
||||
return Err(
|
||||
"scannable item must be scanned (done) before it can be credited".into(),
|
||||
);
|
||||
}
|
||||
if current_status == ScanStatus::Held {
|
||||
return Err("item is on hold; resume before crediting".into());
|
||||
}
|
||||
let remaining = required_qty - current_credited;
|
||||
let n = quantity.unwrap_or(remaining);
|
||||
if n <= 0 || n > remaining {
|
||||
return Err(format!(
|
||||
"invalid credit quantity {n}; remaining creditable {remaining}"
|
||||
));
|
||||
}
|
||||
let new_credited = current_credited + n;
|
||||
// Teil-Gutschrift lässt den Status unangetastet (Zeile wird
|
||||
// weiter teilweise ausgeliefert); erst die volle Menge macht
|
||||
// die Zeile zu `Removed`. `held_reason` trägt den Grund nur im
|
||||
// Removed-Fall (Embed); die volle Historie steht ohnehin im Audit.
|
||||
let fully = new_credited >= required_qty;
|
||||
let new_status = if fully {
|
||||
ScanStatus::Removed
|
||||
} else {
|
||||
current_status
|
||||
};
|
||||
Ok(Transition {
|
||||
delta: 0,
|
||||
new_quantity: current_qty,
|
||||
new_status: ScanStatus::Removed,
|
||||
new_held_reason: reason.map(str::to_owned),
|
||||
}),
|
||||
},
|
||||
new_status,
|
||||
new_held_reason: if fully {
|
||||
reason.map(str::to_owned)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
credit_delta: Some(n),
|
||||
new_credited_quantity: new_credited,
|
||||
})
|
||||
}
|
||||
|
||||
// Gutschrift (teilweise) zurücknehmen. Greift jetzt mengenbasiert:
|
||||
// solange `credited_quantity > 0`, lässt sich etwas wiederherstellen
|
||||
// — unabhängig davon, ob die Zeile schon ganz auf `Removed` stand.
|
||||
AuditAction::Unremove => {
|
||||
if item.delivery_state != "active" {
|
||||
return Err("delivery is not active; cannot restore".into());
|
||||
}
|
||||
if current_credited <= 0 {
|
||||
return Err("nothing credited; nothing to restore".into());
|
||||
}
|
||||
let n = quantity.unwrap_or(current_credited);
|
||||
if n <= 0 || n > current_credited {
|
||||
return Err(format!(
|
||||
"invalid restore quantity {n}; credited {current_credited}"
|
||||
));
|
||||
}
|
||||
let new_credited = current_credited - n;
|
||||
// Aus `Removed` zurück: Status nach Scan-Menge bestimmen.
|
||||
// War die Zeile nur teil-gutgeschrieben (Status z. B. `Done`),
|
||||
// bleibt er, was er war.
|
||||
let new_status = if current_status == ScanStatus::Removed {
|
||||
if current_qty >= required_qty {
|
||||
ScanStatus::Done
|
||||
} else {
|
||||
ScanStatus::InProgress
|
||||
}
|
||||
} else {
|
||||
current_status
|
||||
};
|
||||
Ok(Transition {
|
||||
delta: 0,
|
||||
new_quantity: current_qty,
|
||||
new_status,
|
||||
new_held_reason: None,
|
||||
credit_delta: Some(-n),
|
||||
new_credited_quantity: new_credited,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -182,17 +321,22 @@ async fn lock_item(
|
||||
r#"
|
||||
SELECT
|
||||
di.id,
|
||||
di.delivery_id,
|
||||
di.required_quantity,
|
||||
di.scanned_quantity,
|
||||
di.credited_quantity,
|
||||
di.scan_status,
|
||||
di.held_reason,
|
||||
di.scan_last_updated_at,
|
||||
di.belegzeilen_nr,
|
||||
di.komponenten_artikel_nr,
|
||||
d.erp_belegart_id,
|
||||
d.erp_belegnummer
|
||||
d.erp_belegnummer,
|
||||
a.scannable,
|
||||
d.state AS delivery_state
|
||||
FROM delivery_items di
|
||||
JOIN deliveries d ON d.id = di.delivery_id
|
||||
JOIN articles a ON a.id = di.article_id
|
||||
WHERE di.id = $1
|
||||
FOR UPDATE OF di
|
||||
"#,
|
||||
@ -203,6 +347,104 @@ async fn lock_item(
|
||||
.map_err(db)
|
||||
}
|
||||
|
||||
/// Markiert alle (noch nicht entfernten) Komponenten eines Oberartikels als
|
||||
/// entfernt — gleiche Belegzeile, `komponenten_artikel_nr IS NOT NULL`. Setzt
|
||||
/// volle Gutschrift (credited = required, Status `removed`) und schreibt je
|
||||
/// Komponente einen Audit-Eintrag. Läuft in der Transaktion des auslösenden
|
||||
/// Oberartikel-Removes (durch dessen Idempotenz genau einmal). Komponenten
|
||||
/// haben keine eigene ERP-Belegzeile → kein Einfluss aufs ERP-Rückschreiben.
|
||||
async fn cascade_remove_components(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
parent: &ItemLockRow,
|
||||
event: &ScanEvent,
|
||||
actor_personalnummer: i64,
|
||||
) -> Result<(), ApplicationError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CompRow {
|
||||
id: Uuid,
|
||||
required_quantity: i32,
|
||||
credited_quantity: i32,
|
||||
scanned_quantity: i32,
|
||||
komponenten_artikel_nr: Option<String>,
|
||||
}
|
||||
|
||||
let components = sqlx::query_as::<_, CompRow>(
|
||||
r#"
|
||||
SELECT id, required_quantity, credited_quantity, scanned_quantity,
|
||||
komponenten_artikel_nr
|
||||
FROM delivery_items
|
||||
WHERE delivery_id = $1
|
||||
AND belegzeilen_nr = $2
|
||||
AND komponenten_artikel_nr IS NOT NULL
|
||||
AND scan_status <> 'removed'
|
||||
FOR UPDATE
|
||||
"#,
|
||||
)
|
||||
.bind(parent.delivery_id)
|
||||
.bind(parent.belegzeilen_nr)
|
||||
.fetch_all(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let reason = event
|
||||
.reason
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| "Oberartikel entfernt".to_string());
|
||||
|
||||
for comp in components {
|
||||
let credit_delta = comp.required_quantity - comp.credited_quantity;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE delivery_items
|
||||
SET credited_quantity = required_quantity,
|
||||
scan_status = 'removed',
|
||||
scan_last_updated_at = now()
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(comp.id)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO scan_audit (
|
||||
client_scan_id, delivery_item_id, action,
|
||||
delta, resulting_quantity, resulting_status,
|
||||
reason, actor_personalnummer, actor_car_id, client_scanned_at,
|
||||
erp_belegart_id, erp_belegnummer, erp_belegzeilen_nr,
|
||||
erp_komponenten_artikel_nr,
|
||||
credit_delta, resulting_credited_quantity, manual
|
||||
) VALUES ($1, $2, 'remove', 0, $3, 'removed', $4, $5, $6, $7,
|
||||
$8, $9, $10, $11, $12, $13, false)
|
||||
"#,
|
||||
)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(comp.id)
|
||||
.bind(comp.scanned_quantity)
|
||||
.bind(&reason)
|
||||
.bind(actor_personalnummer)
|
||||
.bind(event.actor_car_id)
|
||||
.bind(event.client_scanned_at)
|
||||
.bind(parent.erp_belegart_id)
|
||||
.bind(&parent.erp_belegnummer)
|
||||
.bind(parent.belegzeilen_nr)
|
||||
.bind(comp.komponenten_artikel_nr.as_deref())
|
||||
.bind(credit_delta)
|
||||
.bind(comp.required_quantity)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ScanRepository for PgScanRepository {
|
||||
async fn apply_one(
|
||||
@ -222,16 +464,46 @@ impl ScanRepository for PgScanRepository {
|
||||
let current_status = parse_status(&item.scan_status)?;
|
||||
let current_state = ScanState {
|
||||
scanned_quantity: item.scanned_quantity,
|
||||
credited_quantity: item.credited_quantity,
|
||||
status: current_status,
|
||||
held_reason: item.held_reason.clone(),
|
||||
last_updated_at: item.scan_last_updated_at,
|
||||
};
|
||||
|
||||
// Idempotenz ZUERST — vor der Transition. Ein Netz-Retry desselben
|
||||
// `client_scan_id` muss „Duplicate" liefern, auch wenn die
|
||||
// (mengenabhängige) Transition inzwischen ablehnen würde (z. B. Item
|
||||
// bereits `done`, Restmenge 0 bei manueller Bestätigung oder
|
||||
// Remove/Unremove). Sonst würde die App ihr optimistisches Update
|
||||
// fälschlich zurückrollen.
|
||||
let already_applied: Option<Uuid> = sqlx::query_scalar(
|
||||
"SELECT id FROM scan_audit WHERE client_scan_id = $1",
|
||||
)
|
||||
.bind(event.client_scan_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
if already_applied.is_some() {
|
||||
tx.rollback().await.map_err(db)?;
|
||||
return Ok(ApplyScanOutcome::Duplicate {
|
||||
delivery_item_id: item.id,
|
||||
current_state,
|
||||
});
|
||||
}
|
||||
|
||||
let snapshot = ItemSnapshot {
|
||||
current_qty: item.scanned_quantity,
|
||||
current_credited: item.credited_quantity,
|
||||
current_status,
|
||||
required_qty: item.required_quantity,
|
||||
scannable: item.scannable,
|
||||
delivery_state: &item.delivery_state,
|
||||
};
|
||||
|
||||
let transition = match apply_transition(
|
||||
event.action,
|
||||
item.scanned_quantity,
|
||||
current_status,
|
||||
item.required_quantity,
|
||||
&snapshot,
|
||||
event.quantity,
|
||||
event.reason.as_deref(),
|
||||
) {
|
||||
Ok(t) => t,
|
||||
@ -250,8 +522,9 @@ impl ScanRepository for PgScanRepository {
|
||||
delta, resulting_quantity, resulting_status,
|
||||
reason, actor_personalnummer, actor_car_id, client_scanned_at,
|
||||
erp_belegart_id, erp_belegnummer, erp_belegzeilen_nr,
|
||||
erp_komponenten_artikel_nr
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
erp_komponenten_artikel_nr,
|
||||
credit_delta, resulting_credited_quantity, manual
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
ON CONFLICT (client_scan_id) DO NOTHING
|
||||
RETURNING id
|
||||
"#,
|
||||
@ -270,6 +543,11 @@ impl ScanRepository for PgScanRepository {
|
||||
.bind(&item.erp_belegnummer)
|
||||
.bind(item.belegzeilen_nr)
|
||||
.bind(item.komponenten_artikel_nr.as_deref())
|
||||
// credit_delta / resulting_credited_quantity: nur bei Remove/Unremove
|
||||
// gesetzt, sonst NULL (credit_delta == None).
|
||||
.bind(transition.credit_delta)
|
||||
.bind(transition.credit_delta.map(|_| transition.new_credited_quantity))
|
||||
.bind(event.manual)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
@ -289,14 +567,16 @@ impl ScanRepository for PgScanRepository {
|
||||
r#"
|
||||
UPDATE delivery_items
|
||||
SET scanned_quantity = $1,
|
||||
scan_status = $2,
|
||||
held_reason = $3,
|
||||
credited_quantity = $2,
|
||||
scan_status = $3,
|
||||
held_reason = $4,
|
||||
scan_last_updated_at = now()
|
||||
WHERE id = $4
|
||||
WHERE id = $5
|
||||
RETURNING scan_last_updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(transition.new_quantity)
|
||||
.bind(transition.new_credited_quantity)
|
||||
.bind(status_str(transition.new_status))
|
||||
.bind(transition.new_held_reason.as_deref())
|
||||
.bind(item.id)
|
||||
@ -304,12 +584,26 @@ impl ScanRepository for PgScanRepository {
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
// Cascade: wird ein **Oberartikel** entfernt (Position ohne eigene
|
||||
// Komponenten-Nr, die selbst Komponenten unter derselben Belegzeile
|
||||
// hat) und ist dadurch voll `removed`, werden ALLE seine Komponenten
|
||||
// ebenfalls als entfernt markiert — auch wenn sie noch nicht gescannt
|
||||
// sind. Die Set-Entscheidung überschreibt das „scannbar muss done"-Gate.
|
||||
if event.action == AuditAction::Remove
|
||||
&& transition.new_status == ScanStatus::Removed
|
||||
&& item.komponenten_artikel_nr.is_none()
|
||||
{
|
||||
cascade_remove_components(&mut tx, &item, event, actor_personalnummer)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await.map_err(db)?;
|
||||
|
||||
Ok(ApplyScanOutcome::Applied {
|
||||
delivery_item_id: item.id,
|
||||
new_state: ScanState {
|
||||
scanned_quantity: transition.new_quantity,
|
||||
credited_quantity: transition.new_credited_quantity,
|
||||
status: transition.new_status,
|
||||
held_reason: transition.new_held_reason,
|
||||
last_updated_at: new_last_updated,
|
||||
|
||||
196
crates/infrastructure/src/persistence/service_repository.rs
Normal file
196
crates/infrastructure/src/persistence/service_repository.rs
Normal file
@ -0,0 +1,196 @@
|
||||
//! Postgres-Implementierung des `ServiceRepository`-Ports.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::ServiceRepository;
|
||||
use holzleitner_domain::{Service, ServiceKind};
|
||||
|
||||
pub struct PgServiceRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgServiceRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ServiceRow {
|
||||
id: Uuid,
|
||||
key: String,
|
||||
name: String,
|
||||
kind: String,
|
||||
min_value: Option<i32>,
|
||||
max_value: Option<i32>,
|
||||
active: bool,
|
||||
sort_order: i32,
|
||||
}
|
||||
|
||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
fn pg_sqlstate(err: &sqlx::Error) -> Option<String> {
|
||||
if let sqlx::Error::Database(db_err) = err {
|
||||
return db_err.code().map(|c| c.into_owned());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn kind_str(k: ServiceKind) -> &'static str {
|
||||
match k {
|
||||
ServiceKind::Boolean => "boolean",
|
||||
ServiceKind::Numeric => "numeric",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_kind(s: &str) -> Result<ServiceKind, ApplicationError> {
|
||||
match s {
|
||||
"boolean" => Ok(ServiceKind::Boolean),
|
||||
"numeric" => Ok(ServiceKind::Numeric),
|
||||
other => Err(ApplicationError::Repository(format!(
|
||||
"unknown service kind '{other}'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_service(r: ServiceRow) -> Result<Service, ApplicationError> {
|
||||
Ok(Service {
|
||||
id: r.id,
|
||||
key: r.key,
|
||||
name: r.name,
|
||||
kind: parse_kind(&r.kind)?,
|
||||
min_value: r.min_value,
|
||||
max_value: r.max_value,
|
||||
active: r.active,
|
||||
sort_order: r.sort_order,
|
||||
})
|
||||
}
|
||||
|
||||
const COLS: &str =
|
||||
"id, key, name, kind, min_value, max_value, active, sort_order";
|
||||
|
||||
#[async_trait]
|
||||
impl ServiceRepository for PgServiceRepository {
|
||||
async fn list(&self, include_inactive: bool) -> Result<Vec<Service>, ApplicationError> {
|
||||
let rows = sqlx::query_as::<_, ServiceRow>(
|
||||
r#"
|
||||
SELECT id, key, name, kind, min_value, max_value, active, sort_order
|
||||
FROM services
|
||||
WHERE (active = TRUE OR $1)
|
||||
ORDER BY sort_order, name
|
||||
"#,
|
||||
)
|
||||
.bind(include_inactive)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
rows.into_iter().map(map_service).collect()
|
||||
}
|
||||
|
||||
async fn find_by_id(&self, id: Uuid) -> Result<Option<Service>, ApplicationError> {
|
||||
let row = sqlx::query_as::<_, ServiceRow>(&format!(
|
||||
"SELECT {COLS} FROM services WHERE id = $1"
|
||||
))
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
row.map(map_service).transpose()
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
key: &str,
|
||||
name: &str,
|
||||
kind: ServiceKind,
|
||||
min_value: Option<i32>,
|
||||
max_value: Option<i32>,
|
||||
sort_order: i32,
|
||||
) -> Result<Service, ApplicationError> {
|
||||
match sqlx::query_as::<_, ServiceRow>(
|
||||
r#"
|
||||
INSERT INTO services (key, name, kind, min_value, max_value, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, key, name, kind, min_value, max_value, active, sort_order
|
||||
"#,
|
||||
)
|
||||
.bind(key)
|
||||
.bind(name)
|
||||
.bind(kind_str(kind))
|
||||
.bind(min_value)
|
||||
.bind(max_value)
|
||||
.bind(sort_order)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
{
|
||||
Ok(row) => map_service(row),
|
||||
Err(e) if pg_sqlstate(&e).as_deref() == Some("23505") => Err(
|
||||
ApplicationError::Conflict(format!("service with key '{key}' already exists")),
|
||||
),
|
||||
Err(e) => Err(db(e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
id: Uuid,
|
||||
name: Option<&str>,
|
||||
min_value: Option<i32>,
|
||||
max_value: Option<i32>,
|
||||
active: Option<bool>,
|
||||
sort_order: Option<i32>,
|
||||
) -> Result<Service, ApplicationError> {
|
||||
let row = sqlx::query_as::<_, ServiceRow>(
|
||||
r#"
|
||||
UPDATE services
|
||||
SET name = COALESCE($2, name),
|
||||
min_value = COALESCE($3, min_value),
|
||||
max_value = COALESCE($4, max_value),
|
||||
active = COALESCE($5, active),
|
||||
sort_order = COALESCE($6, sort_order)
|
||||
WHERE id = $1
|
||||
RETURNING id, key, name, kind, min_value, max_value, active, sort_order
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(name)
|
||||
.bind(min_value)
|
||||
.bind(max_value)
|
||||
.bind(active)
|
||||
.bind(sort_order)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
match row {
|
||||
Some(r) => map_service(r),
|
||||
None => Err(ApplicationError::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, id: Uuid) -> Result<(), ApplicationError> {
|
||||
match sqlx::query("DELETE FROM services WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
if result.rows_affected() == 0 {
|
||||
Err(ApplicationError::NotFound)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Err(e) if pg_sqlstate(&e).as_deref() == Some("23503") => Err(
|
||||
ApplicationError::Conflict(
|
||||
"service is in use by at least one delivery — deactivate instead".to_string(),
|
||||
),
|
||||
),
|
||||
Err(e) => Err(db(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -20,14 +20,15 @@ use sqlx::{PgPool, Postgres, Transaction};
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::dto::{
|
||||
DeliveryOrderEntry, DeliveryWithItems, SyncDelivery, SyncDeliveryItem, SyncTourRequest,
|
||||
TourDetails, TourSummary,
|
||||
DeliveryOrderEntry, DeliveryWithItems, SyncContactSource, SyncDelivery, SyncDeliveryItem,
|
||||
SyncTourRequest, TourDetails, TourSummary,
|
||||
};
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::TourRepository;
|
||||
use holzleitner_domain::{
|
||||
Address, Article, Customer, CustomerContact, Delivery, DeliveryItem, DeliveryNote,
|
||||
DeliveryState, ScanState, ScanStatus, Tour, Warehouse,
|
||||
Address, Article, ContactChannel, ContactKind, ContactRole, ContactSource, Customer,
|
||||
CustomerContact, Delivery, DeliveryCredit, DeliveryItem, DeliveryNote, DeliveryServiceValue,
|
||||
DeliveryState, ScanState, ScanStatus, Service, ServiceKind, Tour, Warehouse,
|
||||
};
|
||||
|
||||
pub struct PgTourRepository {
|
||||
@ -78,6 +79,8 @@ struct DeliveryRow {
|
||||
state: String,
|
||||
state_reason: Option<String>,
|
||||
sort_order: i32,
|
||||
prepaid_amount: f64,
|
||||
payment_method_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
@ -87,9 +90,12 @@ struct DeliveryItemRow {
|
||||
article_id: Uuid,
|
||||
required_quantity: i32,
|
||||
warehouse_id: Uuid,
|
||||
unit_price: f64,
|
||||
belegzeilen_nr: i32,
|
||||
komponenten_artikel_nr: Option<String>,
|
||||
parent_artikel_nr: Option<String>,
|
||||
scanned_quantity: i32,
|
||||
credited_quantity: i32,
|
||||
scan_status: String,
|
||||
held_reason: Option<String>,
|
||||
scan_last_updated_at: DateTime<Utc>,
|
||||
@ -139,6 +145,29 @@ struct ContactLinkRow {
|
||||
customer_contact_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ContactSourceRow {
|
||||
id: Uuid,
|
||||
delivery_id: Uuid,
|
||||
role: String,
|
||||
anrede: Option<String>,
|
||||
titel: Option<String>,
|
||||
name1: Option<String>,
|
||||
name2: Option<String>,
|
||||
name3: Option<String>,
|
||||
abteilung: Option<String>,
|
||||
funktion: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ContactChannelRow {
|
||||
id: Uuid,
|
||||
source_id: Uuid,
|
||||
kind: String,
|
||||
position: i16,
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DeliveryNoteRow {
|
||||
id: Uuid,
|
||||
@ -147,6 +176,9 @@ struct DeliveryNoteRow {
|
||||
image_attachment: Option<String>,
|
||||
author_personalnummer: i64,
|
||||
author_car_id: Option<Uuid>,
|
||||
credit_delivery_item_id: Option<Uuid>,
|
||||
is_amount_credit_note: bool,
|
||||
image_attachment_deleted: bool,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@ -192,10 +224,13 @@ fn map_item(row: DeliveryItemRow) -> Result<DeliveryItem, ApplicationError> {
|
||||
article_id: row.article_id,
|
||||
required_quantity: row.required_quantity,
|
||||
warehouse_id: row.warehouse_id,
|
||||
unit_price: row.unit_price,
|
||||
belegzeilen_nr: row.belegzeilen_nr,
|
||||
komponenten_artikel_nr: row.komponenten_artikel_nr,
|
||||
parent_artikel_nr: row.parent_artikel_nr,
|
||||
scan_state: ScanState {
|
||||
scanned_quantity: row.scanned_quantity,
|
||||
credited_quantity: row.credited_quantity,
|
||||
status: parse_scan_status(&row.scan_status)?,
|
||||
held_reason: row.held_reason,
|
||||
last_updated_at: row.scan_last_updated_at,
|
||||
@ -228,6 +263,31 @@ fn map_contact(row: CustomerContactRow) -> CustomerContact {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_contact_source(row: ContactSourceRow) -> Result<ContactSource, ApplicationError> {
|
||||
Ok(ContactSource {
|
||||
id: row.id,
|
||||
delivery_id: row.delivery_id,
|
||||
role: role_from_db(&row.role)?,
|
||||
anrede: row.anrede,
|
||||
titel: row.titel,
|
||||
name1: row.name1,
|
||||
name2: row.name2,
|
||||
name3: row.name3,
|
||||
abteilung: row.abteilung,
|
||||
funktion: row.funktion,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_contact_channel(row: ContactChannelRow) -> Result<ContactChannel, ApplicationError> {
|
||||
Ok(ContactChannel {
|
||||
id: row.id,
|
||||
source_id: row.source_id,
|
||||
kind: kind_from_db(&row.kind)?,
|
||||
position: row.position,
|
||||
value: row.value,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_article(row: ArticleRow) -> Article {
|
||||
Article {
|
||||
id: row.id,
|
||||
@ -246,10 +306,85 @@ fn map_note(row: DeliveryNoteRow) -> DeliveryNote {
|
||||
image_attachment: row.image_attachment,
|
||||
author_personalnummer: row.author_personalnummer,
|
||||
author_car_id: row.author_car_id,
|
||||
credit_delivery_item_id: row.credit_delivery_item_id,
|
||||
is_amount_credit_note: row.is_amount_credit_note,
|
||||
image_attachment_deleted: row.image_attachment_deleted,
|
||||
created_at: row.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CreditRow {
|
||||
delivery_id: Uuid,
|
||||
action: String,
|
||||
amount_cents: i64,
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Aktuelles Gutschrift-Ereignis → Domänenobjekt. `remove` (oder unbekannte
|
||||
/// Action) liefert `None`, sodass entfernte Gutschriften nicht erscheinen.
|
||||
fn map_credit(row: CreditRow) -> Option<DeliveryCredit> {
|
||||
if row.action != "set" {
|
||||
return None;
|
||||
}
|
||||
Some(DeliveryCredit {
|
||||
delivery_id: row.delivery_id,
|
||||
amount_cents: row.amount_cents,
|
||||
reason: row.reason.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ServiceRow {
|
||||
id: Uuid,
|
||||
key: String,
|
||||
name: String,
|
||||
kind: String,
|
||||
min_value: Option<i32>,
|
||||
max_value: Option<i32>,
|
||||
active: bool,
|
||||
sort_order: i32,
|
||||
}
|
||||
|
||||
fn map_service(row: ServiceRow) -> Result<Service, ApplicationError> {
|
||||
let kind = match row.kind.as_str() {
|
||||
"boolean" => ServiceKind::Boolean,
|
||||
"numeric" => ServiceKind::Numeric,
|
||||
other => {
|
||||
return Err(ApplicationError::Repository(format!(
|
||||
"unknown service kind '{other}'"
|
||||
)));
|
||||
}
|
||||
};
|
||||
Ok(Service {
|
||||
id: row.id,
|
||||
key: row.key,
|
||||
name: row.name,
|
||||
kind,
|
||||
min_value: row.min_value,
|
||||
max_value: row.max_value,
|
||||
active: row.active,
|
||||
sort_order: row.sort_order,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DeliveryServiceRow {
|
||||
delivery_id: Uuid,
|
||||
service_id: Uuid,
|
||||
bool_value: Option<bool>,
|
||||
numeric_value: Option<i32>,
|
||||
}
|
||||
|
||||
fn map_delivery_service(row: DeliveryServiceRow) -> DeliveryServiceValue {
|
||||
DeliveryServiceValue {
|
||||
delivery_id: row.delivery_id,
|
||||
service_id: row.service_id,
|
||||
bool_value: row.bool_value,
|
||||
numeric_value: row.numeric_value,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_warehouse(row: WarehouseRow) -> Warehouse {
|
||||
Warehouse {
|
||||
id: row.id,
|
||||
@ -283,6 +418,8 @@ fn map_delivery(
|
||||
special_agreements: row.special_agreements,
|
||||
state,
|
||||
state_reason: row.state_reason,
|
||||
prepaid_amount: row.prepaid_amount,
|
||||
payment_method_id: row.payment_method_id,
|
||||
};
|
||||
Ok((delivery, row.sort_order))
|
||||
}
|
||||
@ -352,7 +489,8 @@ impl TourRepository for PgTourRepository {
|
||||
id, tour_id, erp_belegart_id, erp_belegnummer, customer_id,
|
||||
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
|
||||
assigned_car_id, desired_time, special_agreements,
|
||||
state, state_reason, sort_order
|
||||
state, state_reason, sort_order,
|
||||
prepaid_amount, payment_method_id
|
||||
FROM deliveries
|
||||
WHERE tour_id = $1
|
||||
ORDER BY sort_order, erp_belegnummer
|
||||
@ -391,8 +529,9 @@ impl TourRepository for PgTourRepository {
|
||||
r#"
|
||||
SELECT
|
||||
id, delivery_id, article_id, required_quantity, warehouse_id,
|
||||
belegzeilen_nr, komponenten_artikel_nr,
|
||||
scanned_quantity, scan_status, held_reason, scan_last_updated_at
|
||||
unit_price, belegzeilen_nr, komponenten_artikel_nr, parent_artikel_nr,
|
||||
scanned_quantity, credited_quantity, scan_status, held_reason,
|
||||
scan_last_updated_at
|
||||
FROM delivery_items
|
||||
WHERE delivery_id = ANY($1)
|
||||
ORDER BY delivery_id, belegzeilen_nr, komponenten_artikel_nr NULLS FIRST
|
||||
@ -504,11 +643,15 @@ impl TourRepository for PgTourRepository {
|
||||
// 7. Notizen aller Lieferungen dieser Tour.
|
||||
let notes = sqlx::query_as::<_, DeliveryNoteRow>(
|
||||
r#"
|
||||
SELECT id, delivery_id, text, image_attachment,
|
||||
author_personalnummer, author_car_id, created_at
|
||||
FROM delivery_notes
|
||||
WHERE delivery_id = ANY($1)
|
||||
ORDER BY delivery_id, created_at
|
||||
SELECT dn.id, dn.delivery_id, dn.text, dn.image_attachment,
|
||||
dn.author_personalnummer, dn.author_car_id,
|
||||
dn.credit_delivery_item_id, dn.is_amount_credit_note,
|
||||
(att.deleted_at IS NOT NULL) AS image_attachment_deleted,
|
||||
dn.created_at
|
||||
FROM delivery_notes dn
|
||||
LEFT JOIN attachments att ON att.id = dn.image_attachment::uuid
|
||||
WHERE dn.delivery_id = ANY($1)
|
||||
ORDER BY dn.delivery_id, dn.created_at
|
||||
"#,
|
||||
)
|
||||
.bind(&delivery_ids)
|
||||
@ -519,6 +662,99 @@ impl TourRepository for PgTourRepository {
|
||||
.map(map_note)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// 8. Aktuelle Betrags-Gutschriften: jüngstes Ereignis pro Lieferung,
|
||||
// nur solange der letzte Stand `set` ist.
|
||||
let credits = sqlx::query_as::<_, CreditRow>(
|
||||
r#"
|
||||
SELECT DISTINCT ON (delivery_id)
|
||||
delivery_id, action, amount_cents, reason
|
||||
FROM delivery_credit_audit
|
||||
WHERE delivery_id = ANY($1)
|
||||
ORDER BY delivery_id, recorded_at DESC, id DESC
|
||||
"#,
|
||||
)
|
||||
.bind(&delivery_ids)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?
|
||||
.into_iter()
|
||||
.filter_map(map_credit)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// 9. Aktive Service-Definitionen (Stammdaten) — die App rendert daraus
|
||||
// Phase 4.
|
||||
let services = sqlx::query_as::<_, ServiceRow>(
|
||||
r#"
|
||||
SELECT id, key, name, kind, min_value, max_value, active, sort_order
|
||||
FROM services
|
||||
WHERE active = TRUE
|
||||
ORDER BY sort_order, name
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?
|
||||
.into_iter()
|
||||
.map(map_service)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
// 10. Pro-Lieferung gesetzte Service-Werte.
|
||||
let delivery_services = sqlx::query_as::<_, DeliveryServiceRow>(
|
||||
r#"
|
||||
SELECT delivery_id, service_id, bool_value, numeric_value
|
||||
FROM delivery_services
|
||||
WHERE delivery_id = ANY($1)
|
||||
"#,
|
||||
)
|
||||
.bind(&delivery_ids)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?
|
||||
.into_iter()
|
||||
.map(map_delivery_service)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// 11. Kontaktdaten-Snapshots aller Lieferungen + ihre Kanäle.
|
||||
// Reihenfolge: Quellen pro Lieferung nach Rolle, Kanäle pro
|
||||
// Quelle nach Art und ERP-Position — so kommt „Telefon"
|
||||
// vor „Telefon2", die App muss nicht extra sortieren.
|
||||
let source_rows = sqlx::query_as::<_, ContactSourceRow>(
|
||||
r#"
|
||||
SELECT id, delivery_id, role,
|
||||
anrede, titel, name1, name2, name3, abteilung, funktion
|
||||
FROM delivery_contact_sources
|
||||
WHERE delivery_id = ANY($1)
|
||||
ORDER BY delivery_id, role
|
||||
"#,
|
||||
)
|
||||
.bind(&delivery_ids)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let source_ids: Vec<Uuid> = source_rows.iter().map(|r| r.id).collect();
|
||||
let contact_sources = source_rows
|
||||
.into_iter()
|
||||
.map(map_contact_source)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let channel_rows = sqlx::query_as::<_, ContactChannelRow>(
|
||||
r#"
|
||||
SELECT id, source_id, kind, position, value
|
||||
FROM delivery_contact_channels
|
||||
WHERE source_id = ANY($1)
|
||||
ORDER BY source_id, kind, position
|
||||
"#,
|
||||
)
|
||||
.bind(&source_ids)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
let contact_channels = channel_rows
|
||||
.into_iter()
|
||||
.map(map_contact_channel)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(Some(TourDetails {
|
||||
tour,
|
||||
deliveries,
|
||||
@ -527,6 +763,11 @@ impl TourRepository for PgTourRepository {
|
||||
articles,
|
||||
warehouses,
|
||||
notes,
|
||||
credits,
|
||||
services,
|
||||
delivery_services,
|
||||
contact_sources,
|
||||
contact_channels,
|
||||
}))
|
||||
}
|
||||
|
||||
@ -603,6 +844,23 @@ impl TourRepository for PgTourRepository {
|
||||
) -> Result<Uuid, ApplicationError> {
|
||||
let mut tx = self.pool.begin().await.map_err(db)?;
|
||||
|
||||
// 0. Fahrer-/Account-Konto sicherstellen — der ERP-`Vertreter` muss als
|
||||
// `accounts`-Zeile existieren (FK von `tours`). Auto-Provisionierung:
|
||||
// fehlende Konten werden mit Default-Namen angelegt; bestehende
|
||||
// bleiben unangetastet (DO NOTHING überschreibt keinen Namen).
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO accounts (personalnummer, name)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (personalnummer) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(request.driver_personalnummer)
|
||||
.bind(format!("Fahrer {}", request.driver_personalnummer))
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
// 1. Tour upserten — Identität: (account_id, tour_date)
|
||||
let tour_id: Uuid = sqlx::query_scalar(
|
||||
r#"
|
||||
@ -628,6 +886,11 @@ impl TourRepository for PgTourRepository {
|
||||
// erhalten; nur Stammdaten + sort_order werden refresht.
|
||||
let delivery_id = upsert_delivery(&mut tx, tour_id, customer_id, delivery).await?;
|
||||
|
||||
// 3a. Kontaktdaten-Snapshot neu schreiben. Snapshot-Semantik:
|
||||
// beim Sync wird der Stand vom ERP übernommen, ältere Stände
|
||||
// verworfen. Der CASCADE-DELETE räumt auch die Channels mit.
|
||||
replace_contact_sources(&mut tx, delivery_id, &delivery.contact_sources).await?;
|
||||
|
||||
for item in &delivery.items {
|
||||
let warehouse_id = upsert_warehouse(&mut tx, item).await?;
|
||||
let article_id = upsert_article(&mut tx, item, warehouse_id).await?;
|
||||
@ -638,6 +901,17 @@ impl TourRepository for PgTourRepository {
|
||||
tx.commit().await.map_err(db)?;
|
||||
Ok(tour_id)
|
||||
}
|
||||
|
||||
async fn delete_all_tours(&self) -> Result<u64, ApplicationError> {
|
||||
// DELETE FROM tours cascadet per FK auf deliveries → delivery_items →
|
||||
// scan_audit, delivery_notes, delivery_credit_audit, delivery_services,
|
||||
// delivery_completions, attachments, delivery_contact_persons.
|
||||
let res = sqlx::query("DELETE FROM tours")
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Upsert-Helfer =====================================================
|
||||
@ -741,15 +1015,39 @@ async fn upsert_delivery(
|
||||
customer_id: Uuid,
|
||||
delivery: &SyncDelivery,
|
||||
) -> Result<Uuid, ApplicationError> {
|
||||
// Payment-Method-Code → UUID auflösen. Fallback `"cash"` falls vom
|
||||
// ERP nichts gekommen ist — `"cash"` ist Default-Stamm aus
|
||||
// Migration 0008 und damit garantiert vorhanden.
|
||||
let payment_code = delivery
|
||||
.payment_method_code
|
||||
.as_deref()
|
||||
.unwrap_or("cash");
|
||||
let payment_method_id: Uuid = sqlx::query_scalar(
|
||||
"SELECT id FROM payment_methods WHERE code = $1",
|
||||
)
|
||||
.bind(payment_code)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?
|
||||
.ok_or_else(|| {
|
||||
ApplicationError::Validation(format!(
|
||||
"unknown payment method code '{payment_code}'"
|
||||
))
|
||||
})?;
|
||||
|
||||
let id: Uuid = sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO deliveries (
|
||||
tour_id, erp_belegart_id, erp_belegnummer, customer_id,
|
||||
tour_id, erp_belegart_id, erp_belegart_code, erp_belegart_name,
|
||||
erp_belegnummer, customer_id,
|
||||
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
|
||||
sort_order, desired_time, special_agreements
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
sort_order, desired_time, special_agreements,
|
||||
prepaid_amount, payment_method_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
ON CONFLICT (erp_belegart_id, erp_belegnummer) DO UPDATE SET
|
||||
tour_id = EXCLUDED.tour_id,
|
||||
erp_belegart_code = EXCLUDED.erp_belegart_code,
|
||||
erp_belegart_name = EXCLUDED.erp_belegart_name,
|
||||
customer_id = EXCLUDED.customer_id,
|
||||
snap_street = EXCLUDED.snap_street,
|
||||
snap_house_number = EXCLUDED.snap_house_number,
|
||||
@ -758,12 +1056,16 @@ async fn upsert_delivery(
|
||||
snap_country = EXCLUDED.snap_country,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
desired_time = EXCLUDED.desired_time,
|
||||
special_agreements = EXCLUDED.special_agreements
|
||||
special_agreements = EXCLUDED.special_agreements,
|
||||
prepaid_amount = EXCLUDED.prepaid_amount,
|
||||
payment_method_id = EXCLUDED.payment_method_id
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(tour_id)
|
||||
.bind(delivery.belegart_id)
|
||||
.bind(delivery.belegart_code.as_deref())
|
||||
.bind(delivery.belegart_name.as_deref())
|
||||
.bind(&delivery.belegnummer)
|
||||
.bind(customer_id)
|
||||
.bind(&delivery.delivery_address.street)
|
||||
@ -774,6 +1076,8 @@ async fn upsert_delivery(
|
||||
.bind(delivery.sort_order)
|
||||
.bind(delivery.desired_time.as_deref())
|
||||
.bind(delivery.special_agreements.as_deref())
|
||||
.bind(delivery.prepaid_amount)
|
||||
.bind(payment_method_id)
|
||||
.fetch_one(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
@ -794,22 +1098,129 @@ async fn upsert_delivery_item(
|
||||
r#"
|
||||
INSERT INTO delivery_items (
|
||||
delivery_id, article_id, required_quantity, warehouse_id,
|
||||
belegzeilen_nr, komponenten_artikel_nr
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
unit_price, belegzeilen_nr, komponenten_artikel_nr, parent_artikel_nr
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (delivery_id, belegzeilen_nr, komponenten_artikel_nr) DO UPDATE SET
|
||||
article_id = EXCLUDED.article_id,
|
||||
required_quantity = EXCLUDED.required_quantity,
|
||||
warehouse_id = EXCLUDED.warehouse_id
|
||||
warehouse_id = EXCLUDED.warehouse_id,
|
||||
unit_price = EXCLUDED.unit_price,
|
||||
parent_artikel_nr = EXCLUDED.parent_artikel_nr
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.bind(article_id)
|
||||
.bind(item.required_quantity)
|
||||
.bind(warehouse_id)
|
||||
.bind(item.unit_price)
|
||||
.bind(item.belegzeilen_nr)
|
||||
.bind(item.komponenten_artikel_nr.as_deref())
|
||||
.bind(item.parent_artikel_nr.as_deref())
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ===== Kontaktdaten ======================================================
|
||||
|
||||
fn role_to_db(role: ContactRole) -> &'static str {
|
||||
match role {
|
||||
ContactRole::Header => "header",
|
||||
ContactRole::Delivery => "delivery",
|
||||
ContactRole::Billing => "billing",
|
||||
ContactRole::ContactPerson => "contact_person",
|
||||
ContactRole::CustomerMaster => "customer_master",
|
||||
}
|
||||
}
|
||||
|
||||
fn role_from_db(value: &str) -> Result<ContactRole, ApplicationError> {
|
||||
match value {
|
||||
"header" => Ok(ContactRole::Header),
|
||||
"delivery" => Ok(ContactRole::Delivery),
|
||||
"billing" => Ok(ContactRole::Billing),
|
||||
"contact_person" => Ok(ContactRole::ContactPerson),
|
||||
"customer_master" => Ok(ContactRole::CustomerMaster),
|
||||
other => Err(ApplicationError::Repository(format!(
|
||||
"unknown contact role in DB: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_to_db(kind: ContactKind) -> &'static str {
|
||||
match kind {
|
||||
ContactKind::Phone => "phone",
|
||||
ContactKind::Mobile => "mobile",
|
||||
ContactKind::Email => "email",
|
||||
ContactKind::Web => "web",
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_from_db(value: &str) -> Result<ContactKind, ApplicationError> {
|
||||
match value {
|
||||
"phone" => Ok(ContactKind::Phone),
|
||||
"mobile" => Ok(ContactKind::Mobile),
|
||||
"email" => Ok(ContactKind::Email),
|
||||
"web" => Ok(ContactKind::Web),
|
||||
other => Err(ApplicationError::Repository(format!(
|
||||
"unknown contact kind in DB: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot-Refresh: vorhandene Sources der Lieferung löschen (Channels
|
||||
/// fliegen per ON DELETE CASCADE mit), neue einfügen. Idempotent: leerer
|
||||
/// Input ⇒ Lieferung hat nach dem Aufruf 0 Sources.
|
||||
async fn replace_contact_sources(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
delivery_id: Uuid,
|
||||
sources: &[SyncContactSource],
|
||||
) -> Result<(), ApplicationError> {
|
||||
sqlx::query("DELETE FROM delivery_contact_sources WHERE delivery_id = $1")
|
||||
.bind(delivery_id)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
for src in sources {
|
||||
let source_id: Uuid = sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO delivery_contact_sources (
|
||||
delivery_id, role, anrede, titel, name1, name2, name3,
|
||||
abteilung, funktion
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.bind(role_to_db(src.role))
|
||||
.bind(src.anrede.as_deref())
|
||||
.bind(src.titel.as_deref())
|
||||
.bind(src.name1.as_deref())
|
||||
.bind(src.name2.as_deref())
|
||||
.bind(src.name3.as_deref())
|
||||
.bind(src.abteilung.as_deref())
|
||||
.bind(src.funktion.as_deref())
|
||||
.fetch_one(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
for ch in &src.channels {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO delivery_contact_channels (
|
||||
source_id, kind, position, value
|
||||
) VALUES ($1, $2, $3, $4)
|
||||
"#,
|
||||
)
|
||||
.bind(source_id)
|
||||
.bind(kind_to_db(ch.kind))
|
||||
.bind(ch.position)
|
||||
.bind(&ch.value)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
10
crates/infrastructure/src/report/mod.rs
Normal file
10
crates/infrastructure/src/report/mod.rs
Normal file
@ -0,0 +1,10 @@
|
||||
//! PDF-Lieferreport: Daten sammeln (PG) → rendern (printpdf) → ablegen/senden
|
||||
//! (Sink). Adapter zu den `DeliveryReport*`-Ports der Application-Schicht.
|
||||
|
||||
pub mod renderer;
|
||||
pub mod repository;
|
||||
pub mod sink;
|
||||
|
||||
pub use renderer::PdfDeliveryReportRenderer;
|
||||
pub use repository::PgDeliveryReportRepository;
|
||||
pub use sink::{DocuframeReportSink, LocalReportSink};
|
||||
699
crates/infrastructure/src/report/renderer.rs
Normal file
699
crates/infrastructure/src/report/renderer.rs
Normal file
@ -0,0 +1,699 @@
|
||||
//! printpdf-Renderer für den Lieferreport.
|
||||
//!
|
||||
//! Nutzt die eingebaute **Helvetica** (WinAnsi → deutsche Umlaute) — kein
|
||||
//! Font-Asset. Einfache, eigene Layout-Engine: Cursor `y` von oben nach unten,
|
||||
//! automatischer Seitenumbruch, klippende Tabellen, eingebettete Bilder
|
||||
//! (Unterschriften + Foto-Notizen) via roher RGB-Daten.
|
||||
|
||||
use printpdf::{
|
||||
BuiltinFont, Color, ColorBits, ColorSpace, Image, ImageFilter, ImageTransform, ImageXObject,
|
||||
IndirectFontRef, Line, Mm, PdfDocument, PdfDocumentReference, PdfLayerReference, Point, Px, Rgb,
|
||||
};
|
||||
|
||||
use holzleitner_application::dto::DeliveryReportData;
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::DeliveryReportRenderer;
|
||||
|
||||
const PAGE_W: f32 = 210.0;
|
||||
const PAGE_H: f32 = 297.0;
|
||||
const MARGIN_L: f32 = 15.0;
|
||||
const MARGIN_R: f32 = 15.0;
|
||||
const MARGIN_TOP: f32 = 15.0;
|
||||
const MARGIN_BOTTOM: f32 = 16.0;
|
||||
const CONTENT_W: f32 = PAGE_W - MARGIN_L - MARGIN_R; // 180
|
||||
|
||||
fn pt2mm(pt: f32) -> f32 {
|
||||
pt * 0.352_778
|
||||
}
|
||||
/// Grobe Helvetica-Zeichenbreite (≈0.5em) — reicht für Umbruch/Klippen.
|
||||
fn char_w(size: f32) -> f32 {
|
||||
pt2mm(size) * 0.5
|
||||
}
|
||||
/// Geschätzte Breite (mm) der fetten Schlüssel-Spalte inkl. 4mm Luft.
|
||||
/// Konservative Obergrenze für Helvetica-Bold (~0.6em → hier 0.66em).
|
||||
fn kv_col(key: &str) -> f32 {
|
||||
pt2mm(9.0) * 0.66 * key.chars().count() as f32 + 4.0
|
||||
}
|
||||
fn money(v: f64) -> String {
|
||||
format!("{:.2} €", v).replace('.', ",")
|
||||
}
|
||||
fn cents(c: i64) -> String {
|
||||
money(c as f64 / 100.0)
|
||||
}
|
||||
|
||||
pub struct PdfDeliveryReportRenderer;
|
||||
|
||||
struct Pdf {
|
||||
doc: PdfDocumentReference,
|
||||
font: IndirectFontRef,
|
||||
bold: IndirectFontRef,
|
||||
layer: PdfLayerReference,
|
||||
y: f32,
|
||||
}
|
||||
|
||||
impl Pdf {
|
||||
fn new(title: &str) -> Result<Self, ApplicationError> {
|
||||
let (doc, page, layer) = PdfDocument::new(title, Mm(PAGE_W), Mm(PAGE_H), "Layer 1");
|
||||
let font = doc
|
||||
.add_builtin_font(BuiltinFont::Helvetica)
|
||||
.map_err(ext)?;
|
||||
let bold = doc
|
||||
.add_builtin_font(BuiltinFont::HelveticaBold)
|
||||
.map_err(ext)?;
|
||||
let layer = doc.get_page(page).get_layer(layer);
|
||||
Ok(Self {
|
||||
doc,
|
||||
font,
|
||||
bold,
|
||||
layer,
|
||||
y: PAGE_H - MARGIN_TOP,
|
||||
})
|
||||
}
|
||||
|
||||
fn new_page(&mut self) {
|
||||
let (page, layer) = self.doc.add_page(Mm(PAGE_W), Mm(PAGE_H), "Layer 1");
|
||||
self.layer = self.doc.get_page(page).get_layer(layer);
|
||||
self.y = PAGE_H - MARGIN_TOP;
|
||||
}
|
||||
|
||||
fn ensure(&mut self, needed: f32) {
|
||||
if self.y - needed < MARGIN_BOTTOM {
|
||||
self.new_page();
|
||||
}
|
||||
}
|
||||
|
||||
/// Schreibt eine Zeile ab x (mm von links innerhalb des Inhalts).
|
||||
fn put(&mut self, text: &str, size: f32, bold: bool, x: f32, baseline_y: f32) {
|
||||
let font = if bold { &self.bold } else { &self.font };
|
||||
self.layer
|
||||
.use_text(text, size, Mm(MARGIN_L + x), Mm(baseline_y), font);
|
||||
}
|
||||
|
||||
fn max_chars(&self, width: f32, size: f32) -> usize {
|
||||
((width / char_w(size)).floor() as usize).max(1)
|
||||
}
|
||||
|
||||
fn clip(&self, text: &str, width: f32, size: f32) -> String {
|
||||
let max = self.max_chars(width, size);
|
||||
if text.chars().count() <= max {
|
||||
text.to_string()
|
||||
} else if max <= 1 {
|
||||
"…".to_string()
|
||||
} else {
|
||||
let truncated: String = text.chars().take(max - 1).collect();
|
||||
format!("{truncated}…")
|
||||
}
|
||||
}
|
||||
|
||||
/// Mehrzeiliger, umbrechender Text. `indent` in mm.
|
||||
fn text(&mut self, text: &str, size: f32, bold: bool, indent: f32) {
|
||||
let avail = CONTENT_W - indent;
|
||||
let max = self.max_chars(avail, size);
|
||||
for raw_line in text.split('\n') {
|
||||
if raw_line.is_empty() {
|
||||
self.y -= pt2mm(size) * 1.3;
|
||||
continue;
|
||||
}
|
||||
let mut current = String::new();
|
||||
for word in raw_line.split_whitespace() {
|
||||
let candidate = if current.is_empty() {
|
||||
word.to_string()
|
||||
} else {
|
||||
format!("{current} {word}")
|
||||
};
|
||||
if candidate.chars().count() > max && !current.is_empty() {
|
||||
self.write_line(¤t, size, bold, indent);
|
||||
current = word.to_string();
|
||||
} else {
|
||||
current = candidate;
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
self.write_line(¤t, size, bold, indent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_line(&mut self, text: &str, size: f32, bold: bool, indent: f32) {
|
||||
let lh = pt2mm(size) * 1.35;
|
||||
self.ensure(lh);
|
||||
let baseline = self.y - pt2mm(size);
|
||||
self.put(text, size, bold, indent, baseline);
|
||||
self.y -= lh;
|
||||
}
|
||||
|
||||
fn gap(&mut self, mm: f32) {
|
||||
self.y -= mm;
|
||||
}
|
||||
|
||||
fn title(&mut self, text: &str) {
|
||||
self.write_line(text, 18.0, true, 0.0);
|
||||
self.gap(2.0);
|
||||
}
|
||||
|
||||
fn heading(&mut self, text: &str) {
|
||||
self.gap(4.0);
|
||||
self.ensure(8.0);
|
||||
self.write_line(text, 12.0, true, 0.0);
|
||||
self.gap(1.0);
|
||||
}
|
||||
|
||||
fn kv(&mut self, key: &str, value: &str) {
|
||||
// Wert-Spalte normal bei 42mm; ist der (fette) Schlüssel breiter,
|
||||
// wird der Wert nach rechts geschoben, damit nichts überlappt.
|
||||
let val_x = 42.0_f32.max(kv_col(key));
|
||||
self.kv_at(key, value, val_x);
|
||||
}
|
||||
|
||||
/// Wie `kv`, aber mit fest vorgegebener Wert-Spalte `val_x` (mm) — für
|
||||
/// bündig ausgerichtete Blöcke, in denen alle Werte an derselben Stelle
|
||||
/// beginnen (Spaltenbreite = breitester Schlüssel des Blocks).
|
||||
fn kv_at(&mut self, key: &str, value: &str, val_x: f32) {
|
||||
// Key in der ersten Spalte (fett), Wert daneben.
|
||||
let lh = pt2mm(9.0) * 1.35;
|
||||
self.ensure(lh);
|
||||
let baseline = self.y - pt2mm(9.0);
|
||||
self.put(key, 9.0, true, 0.0, baseline);
|
||||
let val = self.clip(value, CONTENT_W - val_x, 9.0);
|
||||
self.put(&val, 9.0, false, val_x, baseline);
|
||||
self.y -= lh;
|
||||
}
|
||||
|
||||
/// Tabellenzeile: (Text, Spaltenbreite mm, fett). Klippt je Zelle.
|
||||
fn row(&mut self, cells: &[(String, f32, bool)], size: f32) {
|
||||
let lh = pt2mm(size) * 1.5;
|
||||
self.ensure(lh);
|
||||
let baseline = self.y - pt2mm(size);
|
||||
let mut x = 0.0;
|
||||
for (text, width, bold) in cells {
|
||||
let clipped = self.clip(text, *width - 1.5, size);
|
||||
self.put(&clipped, size, *bold, x, baseline);
|
||||
x += width;
|
||||
}
|
||||
self.y -= lh;
|
||||
}
|
||||
|
||||
/// Bettet ein Bild ein (Bytes → RGB). `max_w`/`max_h` in mm. Bewegt den
|
||||
/// Cursor (für Anhänge/Foto-Notizen im Textfluss).
|
||||
fn image(&mut self, bytes: &[u8], max_w: f32, max_h: f32) {
|
||||
// `max_h` ist die obere Schranke der Zeichenhöhe → konservativ reservieren.
|
||||
self.ensure(max_h + 2.0);
|
||||
match self.draw_image_at(bytes, MARGIN_L, self.y, max_w, max_h) {
|
||||
Some(h) => self.y -= h + 2.0,
|
||||
None => self.text("[Bild konnte nicht gelesen werden]", 8.0, false, 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Zeichnet ein Bild an fester Position (x links, `top_y` Oberkante, mm),
|
||||
/// **ohne** den Cursor zu verändern. Liefert die gezeichnete Höhe in mm
|
||||
/// (oder `None`, wenn das Bild nicht dekodiert werden konnte).
|
||||
fn draw_image_at(&self, bytes: &[u8], x_mm: f32, top_y: f32, max_w: f32, max_h: f32) -> Option<f32> {
|
||||
let dynimg = image::load_from_memory(bytes).ok()?;
|
||||
// Auf eine sinnvolle Maximal-Kantenlänge runterskalieren — ein Report
|
||||
// braucht keine 12-MP-Fotos. Begrenzt die Pixelmenge VOR der Kompression.
|
||||
const MAX_EDGE: u32 = 1600;
|
||||
let dynimg = if dynimg.width().max(dynimg.height()) > MAX_EDGE {
|
||||
dynimg.resize(MAX_EDGE, MAX_EDGE, image::imageops::FilterType::Triangle)
|
||||
} else {
|
||||
dynimg
|
||||
};
|
||||
let rgb = dynimg.to_rgb8();
|
||||
let (w_px, h_px) = (rgb.width(), rgb.height());
|
||||
if w_px == 0 || h_px == 0 {
|
||||
return None;
|
||||
}
|
||||
// JPEG-komprimieren und als DCTDecode-Stream ins PDF legen (statt rohem
|
||||
// RGB) → drastisch kleinere PDFs (12-MP-Foto: 37 MB roh → ~200 KB).
|
||||
let mut jpeg: Vec<u8> = Vec::new();
|
||||
{
|
||||
let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 82);
|
||||
enc.encode(rgb.as_raw(), w_px, h_px, image::ExtendedColorType::Rgb8)
|
||||
.ok()?;
|
||||
}
|
||||
// Zielgröße unter Wahrung des Seitenverhältnisses.
|
||||
let aspect = h_px as f32 / w_px as f32;
|
||||
let mut draw_w = max_w;
|
||||
let mut draw_h = draw_w * aspect;
|
||||
if draw_h > max_h {
|
||||
draw_h = max_h;
|
||||
draw_w = draw_h / aspect;
|
||||
}
|
||||
let bottom = top_y - draw_h;
|
||||
|
||||
// Scale relativ zur Default-DPI (300): natürliche Breite in mm.
|
||||
let natural_w_mm = (w_px as f32) / 300.0 * 25.4;
|
||||
let scale = if natural_w_mm > 0.0 {
|
||||
draw_w / natural_w_mm
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
let xobject = ImageXObject {
|
||||
width: Px(w_px as usize),
|
||||
height: Px(h_px as usize),
|
||||
color_space: ColorSpace::Rgb,
|
||||
bits_per_component: ColorBits::Bit8,
|
||||
interpolate: false,
|
||||
image_data: jpeg,
|
||||
image_filter: Some(ImageFilter::DCT),
|
||||
smask: None,
|
||||
clipping_bbox: None,
|
||||
};
|
||||
Image::from(xobject).add_to_layer(
|
||||
self.layer.clone(),
|
||||
ImageTransform {
|
||||
translate_x: Some(Mm(x_mm)),
|
||||
translate_y: Some(Mm(bottom)),
|
||||
scale_x: Some(scale),
|
||||
scale_y: Some(scale),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
Some(draw_h)
|
||||
}
|
||||
|
||||
/// Zeichnet einen Rechteck-Rahmen (nur Kontur, grau). `bottom_y` = Unterkante.
|
||||
fn rect(&self, x_mm: f32, bottom_y: f32, w: f32, h: f32) {
|
||||
self.layer
|
||||
.set_outline_color(Color::Rgb(Rgb::new(0.6, 0.6, 0.6, None)));
|
||||
self.layer.set_outline_thickness(0.5); // pt
|
||||
let (x0, x1) = (x_mm, x_mm + w);
|
||||
let (y0, y1) = (bottom_y, bottom_y + h);
|
||||
let line = Line {
|
||||
points: vec![
|
||||
(Point::new(Mm(x0), Mm(y0)), false),
|
||||
(Point::new(Mm(x1), Mm(y0)), false),
|
||||
(Point::new(Mm(x1), Mm(y1)), false),
|
||||
(Point::new(Mm(x0), Mm(y1)), false),
|
||||
],
|
||||
is_closed: true,
|
||||
};
|
||||
self.layer.add_line(line);
|
||||
}
|
||||
|
||||
/// Unterschriftsfeld: Beschriftung + umrahmter Kasten mit dem (kleinen) Bild.
|
||||
fn signature_box(&mut self, label: &str, png: &[u8]) {
|
||||
const BOX_W: f32 = 75.0;
|
||||
const BOX_H: f32 = 22.0;
|
||||
const PAD: f32 = 2.5;
|
||||
let label_lh = pt2mm(9.0) * 1.35;
|
||||
// Beschriftung + Kasten zusammenhalten (kein Umbruch mittendrin).
|
||||
self.ensure(label_lh + BOX_H + 3.0);
|
||||
self.write_line(label, 9.0, true, 0.0);
|
||||
let top = self.y;
|
||||
let bottom = top - BOX_H;
|
||||
self.rect(MARGIN_L, bottom, BOX_W, BOX_H);
|
||||
self.draw_image_at(
|
||||
png,
|
||||
MARGIN_L + PAD,
|
||||
top - PAD,
|
||||
BOX_W - 2.0 * PAD,
|
||||
BOX_H - 2.0 * PAD,
|
||||
);
|
||||
self.y = bottom - 3.0;
|
||||
}
|
||||
|
||||
fn finish(self) -> Result<Vec<u8>, ApplicationError> {
|
||||
self.doc.save_to_bytes().map_err(ext)
|
||||
}
|
||||
}
|
||||
|
||||
fn ext<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(format!("pdf: {e}"))
|
||||
}
|
||||
|
||||
fn dt(d: &chrono::DateTime<chrono::Utc>) -> String {
|
||||
d.format("%d.%m.%Y %H:%M").to_string()
|
||||
}
|
||||
fn opt(s: &Option<String>) -> &str {
|
||||
s.as_deref().unwrap_or("—")
|
||||
}
|
||||
|
||||
/// Lesbare Belegart: „VL5 — Lieferschein EH", sonst nur Code/Name, sonst die
|
||||
/// nackte ERP-ID („Nr. 24") als Fallback für noch nicht nachsynchronisierte
|
||||
/// Altbestände.
|
||||
fn belegart_label(d: &DeliveryReportData) -> String {
|
||||
let code = d
|
||||
.belegart_code
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
let name = d
|
||||
.belegart_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
match (code, name) {
|
||||
(Some(c), Some(n)) => format!("{c} — {n}"),
|
||||
(Some(c), None) => c.to_string(),
|
||||
(None, Some(n)) => n.to_string(),
|
||||
(None, None) => format!("Nr. {}", d.belegart_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deutsche Beschriftung des Lieferungs-Status (DB-Werte sind technisch/englisch).
|
||||
fn state_de(s: &str) -> &str {
|
||||
match s {
|
||||
"active" => "Aktiv",
|
||||
"held" => "Zurückgestellt",
|
||||
"canceled" => "Storniert",
|
||||
"completed" => "Abgeschlossen",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Deutsche Beschriftung des Positions-/Scanstatus.
|
||||
fn status_de(s: &str) -> &str {
|
||||
match s {
|
||||
"in_progress" => "In Arbeit",
|
||||
"done" => "Erledigt",
|
||||
"held" => "Zurückgestellt",
|
||||
"removed" => "Entfernt",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Deutsche Beschriftung der Belade-/Scanaktion.
|
||||
fn scan_action_de(s: &str) -> &str {
|
||||
match s {
|
||||
"scan" => "Erfasst",
|
||||
"unscan" => "Zurückgesetzt",
|
||||
"hold" => "Zurückgestellt",
|
||||
"unhold" => "Freigegeben",
|
||||
"remove" => "Entfernt",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Deutsche Beschriftung der Gutschriftaktion.
|
||||
fn credit_action_de(s: &str) -> &str {
|
||||
match s {
|
||||
"set" => "Gesetzt",
|
||||
"remove" => "Entfernt",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
impl DeliveryReportRenderer for PdfDeliveryReportRenderer {
|
||||
fn render(&self, d: &DeliveryReportData) -> Result<Vec<u8>, ApplicationError> {
|
||||
let mut p = Pdf::new(&format!("Lieferbericht {}", d.belegnummer))?;
|
||||
|
||||
// 1. Kopf
|
||||
p.title(&format!("Lieferbericht {}", d.belegnummer));
|
||||
p.kv("Belegnummer", &d.belegnummer);
|
||||
p.kv("Belegart", &belegart_label(d));
|
||||
p.kv("Status", state_de(&d.state));
|
||||
p.kv("Tourdatum", &d.tour_date.format("%d.%m.%Y").to_string());
|
||||
match &d.completion {
|
||||
Some(c) => p.kv("Abgeschlossen", &dt(&c.completed_at)),
|
||||
None => p.kv("Abgeschlossen", "— (nicht abgeschlossen)"),
|
||||
}
|
||||
p.kv("Fahrer", &format!("{} ({})", d.driver_name, d.driver_personalnummer));
|
||||
p.kv("Fahrzeug", opt(&d.car_plate));
|
||||
p.kv("Erstellt am", &dt(&d.generated_at));
|
||||
|
||||
// 2. Kunde & Lieferadresse
|
||||
p.heading("Kunde & Lieferadresse");
|
||||
p.kv("Kunde", &format!("{} (Nr. {})", d.customer_name, d.customer_number));
|
||||
p.kv("Adresse", &d.address);
|
||||
p.kv("Wunschzeit", opt(&d.desired_time));
|
||||
if let Some(sa) = &d.special_agreements {
|
||||
if !sa.trim().is_empty() {
|
||||
p.text(&format!("Sonderwünsche: {sa}"), 9.0, false, 0.0);
|
||||
}
|
||||
}
|
||||
for c in &d.contacts {
|
||||
let line = match &c.detail {
|
||||
Some(det) => format!("Ansprechpartner: {} ({det})", c.name),
|
||||
None => format!("Ansprechpartner: {}", c.name),
|
||||
};
|
||||
p.text(&line, 9.0, false, 0.0);
|
||||
}
|
||||
|
||||
// 3. Positionen
|
||||
p.heading("Positionen");
|
||||
let cols: [(&str, f32); 6] = [
|
||||
("Artikel", 78.0),
|
||||
("Soll", 16.0),
|
||||
("Gel.", 16.0),
|
||||
("Gutschr.", 22.0),
|
||||
("Einzel", 26.0),
|
||||
("Lager", 22.0),
|
||||
];
|
||||
p.row(
|
||||
&cols.iter().map(|(t, w)| (t.to_string(), *w, true)).collect::<Vec<_>>(),
|
||||
8.0,
|
||||
);
|
||||
let mut warenwert = 0.0_f64;
|
||||
for it in &d.items {
|
||||
warenwert += it.unit_price * it.delivered() as f64;
|
||||
let label = if it.is_component() {
|
||||
format!(" ↳ {} ({})", it.name, it.article_number)
|
||||
} else {
|
||||
format!("{} ({})", it.name, it.article_number)
|
||||
};
|
||||
p.row(
|
||||
&[
|
||||
(label, 78.0, false),
|
||||
(it.required_quantity.to_string(), 16.0, false),
|
||||
(it.delivered().to_string(), 16.0, false),
|
||||
(it.credited_quantity.to_string(), 22.0, false),
|
||||
(money(it.unit_price), 26.0, false),
|
||||
(it.warehouse_code.clone().unwrap_or_default(), 22.0, false),
|
||||
],
|
||||
8.0,
|
||||
);
|
||||
}
|
||||
// Lager-Legende: Nummer → Name (einmalig, alphabetisch nach Nummer).
|
||||
let mut lager: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
|
||||
for it in &d.items {
|
||||
if let Some(code) = &it.warehouse_code {
|
||||
if !code.trim().is_empty() {
|
||||
lager
|
||||
.entry(code.clone())
|
||||
.or_insert_with(|| it.warehouse_name.clone().unwrap_or_default());
|
||||
}
|
||||
}
|
||||
}
|
||||
if !lager.is_empty() {
|
||||
let legend = lager
|
||||
.iter()
|
||||
.map(|(code, name)| {
|
||||
if name.trim().is_empty() {
|
||||
code.clone()
|
||||
} else {
|
||||
format!("{code} = {name}")
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
p.gap(1.0);
|
||||
p.text(&format!("Lager: {legend}"), 7.5, false, 0.0);
|
||||
}
|
||||
|
||||
// 4. Zahlung
|
||||
p.heading("Zahlung");
|
||||
let credit = d.current_credit_cents as f64 / 100.0;
|
||||
let open = (warenwert - d.prepaid_amount - credit).max(0.0);
|
||||
p.kv("Warenwert", &money(warenwert));
|
||||
p.kv("Anzahlung", &money(d.prepaid_amount));
|
||||
p.kv("Gutschrift", ¢s(d.current_credit_cents));
|
||||
p.kv("Offener Betrag", &money(open));
|
||||
p.kv("Zahlungsmethode", opt(&d.payment_method));
|
||||
// Inkasso-Bestätigung: nur wenn beim Abschluss tatsächlich kassiert
|
||||
// wurde (Bar/EC bei offenem Betrag). „Auf Rechnung"/voll bezahlt → keine.
|
||||
if let Some(c) = &d.completion {
|
||||
if let Some(collected) = c.collected_amount_cents {
|
||||
p.kv(
|
||||
"Betrag erhalten",
|
||||
&format!("Ja — {} (am {})", cents(collected), dt(&c.completed_at)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Dienstleistungen
|
||||
p.heading("Dienstleistungen");
|
||||
if d.services.is_empty() {
|
||||
p.text("— keine —", 9.0, false, 0.0);
|
||||
} else {
|
||||
// Gemeinsame Wert-Spalte = Breite des längsten Dienstleistungs-
|
||||
// Namens (mind. 42mm), damit die zweite Spalte bündig ausgerichtet
|
||||
// ist statt pro Zeile zu springen.
|
||||
let col = d
|
||||
.services
|
||||
.iter()
|
||||
.map(|s| kv_col(&s.name))
|
||||
.fold(42.0_f32, f32::max);
|
||||
for s in &d.services {
|
||||
let val = if let Some(b) = s.bool_value {
|
||||
if b { "Ja".to_string() } else { "Nein".to_string() }
|
||||
} else if let Some(n) = s.numeric_value {
|
||||
n.to_string()
|
||||
} else {
|
||||
"—".to_string()
|
||||
};
|
||||
p.kv_at(&s.name, &val, col);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Notizen
|
||||
p.heading("Notizen");
|
||||
if d.notes.is_empty() {
|
||||
p.text("— keine —", 9.0, false, 0.0);
|
||||
} else {
|
||||
for n in &d.notes {
|
||||
let mut head = format!("{} · Fahrer {}", dt(&n.created_at), n.author_personalnummer);
|
||||
if n.is_amount_credit_note {
|
||||
head.push_str(" · [Gutschrift-Notiz]");
|
||||
}
|
||||
if n.image_attachment.is_some() {
|
||||
head.push_str(" · [Bild]");
|
||||
}
|
||||
p.text(&head, 8.0, true, 0.0);
|
||||
if let Some(t) = &n.text {
|
||||
if !t.trim().is_empty() {
|
||||
p.text(t, 9.0, false, 4.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Unterschriften
|
||||
p.heading("Unterschriften");
|
||||
match &d.completion {
|
||||
Some(c) => {
|
||||
// Es gibt nur EINEN Abschlusszeitpunkt (completed_at) — er gilt
|
||||
// für beide Bestätigungen und wird hier neben den Häkchen gezeigt.
|
||||
let bestätigt_am = dt(&c.completed_at);
|
||||
let receipt = if c.receipt_confirmed {
|
||||
format!("Ja (am {bestätigt_am})")
|
||||
} else {
|
||||
"Nein".to_string()
|
||||
};
|
||||
let notes = if c.notes_acknowledged {
|
||||
format!("Ja (am {bestätigt_am})")
|
||||
} else {
|
||||
"Nein".to_string()
|
||||
};
|
||||
p.kv("Erhalt bestätigt", &receipt);
|
||||
p.kv("Notizen quittiert", ¬es);
|
||||
if let Some(png) = &d.customer_signature_png {
|
||||
p.signature_box("Unterschrift Kunde", png);
|
||||
}
|
||||
if let Some(png) = &d.driver_signature_png {
|
||||
p.signature_box("Unterschrift Fahrer", png);
|
||||
}
|
||||
}
|
||||
None => p.text("— Lieferung nicht abgeschlossen —", 9.0, false, 0.0),
|
||||
}
|
||||
|
||||
// 8. Protokoll: Belade-/Scanverlauf
|
||||
p.heading("Protokoll — Belade- und Scanverlauf");
|
||||
if d.scan_audit.is_empty() {
|
||||
p.text("— keine Einträge —", 9.0, false, 0.0);
|
||||
} else {
|
||||
p.row(
|
||||
&[
|
||||
("Zeit".to_string(), 30.0, true),
|
||||
("Aktion".to_string(), 20.0, true),
|
||||
("Artikel".to_string(), 58.0, true),
|
||||
("Δ".to_string(), 10.0, true),
|
||||
("Menge".to_string(), 16.0, true),
|
||||
("Status".to_string(), 22.0, true),
|
||||
("Fahrer".to_string(), 18.0, true),
|
||||
],
|
||||
7.5,
|
||||
);
|
||||
for s in &d.scan_audit {
|
||||
let art = s
|
||||
.article_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("Pos {}", s.belegzeilen_nr));
|
||||
let mut action = scan_action_de(&s.action).to_string();
|
||||
if s.manual {
|
||||
action.push('*');
|
||||
}
|
||||
p.row(
|
||||
&[
|
||||
(dt(&s.server_recorded_at), 30.0, false),
|
||||
(action, 20.0, false),
|
||||
(art, 58.0, false),
|
||||
(format!("{:+}", s.delta), 10.0, false),
|
||||
(s.resulting_quantity.to_string(), 16.0, false),
|
||||
(status_de(&s.resulting_status).to_string(), 22.0, false),
|
||||
(s.actor_personalnummer.to_string(), 18.0, false),
|
||||
],
|
||||
7.5,
|
||||
);
|
||||
if let Some(r) = &s.reason {
|
||||
if !r.trim().is_empty() {
|
||||
p.text(&format!(" Grund: {r}"), 7.0, false, 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
p.text("* = manuell bestätigt", 7.0, false, 0.0);
|
||||
}
|
||||
|
||||
// 9. Protokoll: Gutschriftverlauf
|
||||
p.heading("Protokoll — Gutschriftverlauf");
|
||||
if d.credit_audit.is_empty() {
|
||||
p.text("— keine Einträge —", 9.0, false, 0.0);
|
||||
} else {
|
||||
p.row(
|
||||
&[
|
||||
("Zeit".to_string(), 34.0, true),
|
||||
("Aktion".to_string(), 22.0, true),
|
||||
("Betrag".to_string(), 26.0, true),
|
||||
("Fahrer".to_string(), 20.0, true),
|
||||
("Grund".to_string(), 70.0, true),
|
||||
],
|
||||
7.5,
|
||||
);
|
||||
for c in &d.credit_audit {
|
||||
p.row(
|
||||
&[
|
||||
(dt(&c.recorded_at), 34.0, false),
|
||||
(credit_action_de(&c.action).to_string(), 22.0, false),
|
||||
(cents(c.amount_cents), 26.0, false),
|
||||
(c.author_personalnummer.to_string(), 20.0, false),
|
||||
(c.reason.clone().unwrap_or_default(), 70.0, false),
|
||||
],
|
||||
7.5,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Anhänge
|
||||
p.heading("Anhänge");
|
||||
if d.attachments.is_empty() {
|
||||
p.text("— keine —", 9.0, false, 0.0);
|
||||
} else {
|
||||
for a in &d.attachments {
|
||||
let meta = format!(
|
||||
"{} · {} · {} KB · hochgeladen {} von Fahrer {}",
|
||||
a.filename.clone().unwrap_or_else(|| "(ohne Name)".into()),
|
||||
a.mime_type,
|
||||
a.size_bytes / 1024,
|
||||
dt(&a.uploaded_at),
|
||||
a.uploaded_by
|
||||
);
|
||||
p.text(&meta, 8.0, true, 0.0);
|
||||
match (&a.bytes, a.mime_type.starts_with("image/")) {
|
||||
(Some(bytes), true) => p.image(bytes, 90.0, 90.0),
|
||||
_ => p.text(" (Vorschau nicht verfügbar)", 8.0, false, 0.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 11. Footer
|
||||
p.gap(4.0);
|
||||
p.text(
|
||||
&format!("Automatisch erzeugt am {} — Holzleitner Auslieferung", dt(&d.generated_at)),
|
||||
7.0,
|
||||
false,
|
||||
0.0,
|
||||
);
|
||||
|
||||
p.finish()
|
||||
}
|
||||
}
|
||||
485
crates/infrastructure/src/report/repository.rs
Normal file
485
crates/infrastructure/src/report/repository.rs
Normal file
@ -0,0 +1,485 @@
|
||||
//! Postgres-Implementierung von `DeliveryReportRepository`.
|
||||
//!
|
||||
//! Sammelt mit mehreren SELECTs alle Daten einer Lieferung inkl. beider
|
||||
//! Audit-Trails (`scan_audit`, `delivery_credit_audit`) zum `DeliveryReportData`.
|
||||
//! Bild-Bytes werden hier NICHT geladen (macht der Use Case).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::dto::{
|
||||
DeliveryReportData, ReportAttachment, ReportCompletion, ReportContact, ReportCreditAudit,
|
||||
ReportItem, ReportNote, ReportScanAudit, ReportService,
|
||||
};
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::DeliveryReportRepository;
|
||||
|
||||
pub struct PgDeliveryReportRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgDeliveryReportRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct HeadRow {
|
||||
erp_belegart_id: i64,
|
||||
erp_belegart_code: Option<String>,
|
||||
erp_belegart_name: Option<String>,
|
||||
erp_belegnummer: String,
|
||||
state: String,
|
||||
tour_date: NaiveDate,
|
||||
account_id: i64,
|
||||
driver_name: String,
|
||||
car_plate: Option<String>,
|
||||
payment_method: Option<String>,
|
||||
erp_customer_id: i64,
|
||||
customer_name: String,
|
||||
snap_street: Option<String>,
|
||||
snap_house_number: Option<String>,
|
||||
snap_postal_code: Option<String>,
|
||||
snap_city: Option<String>,
|
||||
snap_country: Option<String>,
|
||||
desired_time: Option<String>,
|
||||
special_agreements: Option<String>,
|
||||
prepaid_amount: f64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ItemRow {
|
||||
belegzeilen_nr: i32,
|
||||
komponenten_artikel_nr: Option<String>,
|
||||
parent_artikel_nr: Option<String>,
|
||||
article_number: String,
|
||||
name: String,
|
||||
required_quantity: i32,
|
||||
credited_quantity: i32,
|
||||
scanned_quantity: i32,
|
||||
scan_status: String,
|
||||
unit_price: f64,
|
||||
warehouse_code: Option<String>,
|
||||
warehouse_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ServiceRow {
|
||||
name: String,
|
||||
bool_value: Option<bool>,
|
||||
numeric_value: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct NoteRow {
|
||||
created_at: DateTime<Utc>,
|
||||
author_personalnummer: i64,
|
||||
text: Option<String>,
|
||||
image_attachment: Option<String>,
|
||||
is_amount_credit_note: bool,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ContactRow {
|
||||
name: String,
|
||||
phone: Option<String>,
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CompletionRow {
|
||||
completed_at: DateTime<Utc>,
|
||||
completed_by_personalnummer: i64,
|
||||
receipt_confirmed: bool,
|
||||
notes_acknowledged: bool,
|
||||
customer_signature_path: String,
|
||||
driver_signature_path: String,
|
||||
payment_collected: bool,
|
||||
collected_amount_cents: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ScanAuditRow {
|
||||
server_recorded_at: DateTime<Utc>,
|
||||
client_scanned_at: DateTime<Utc>,
|
||||
action: String,
|
||||
delta: i32,
|
||||
resulting_quantity: i32,
|
||||
resulting_status: String,
|
||||
reason: Option<String>,
|
||||
manual: bool,
|
||||
credit_delta: Option<i32>,
|
||||
actor_personalnummer: i64,
|
||||
belegzeilen_nr: i32,
|
||||
komponenten_artikel_nr: Option<String>,
|
||||
article_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CreditAuditRow {
|
||||
recorded_at: DateTime<Utc>,
|
||||
action: String,
|
||||
amount_cents: i64,
|
||||
reason: Option<String>,
|
||||
author_personalnummer: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct AttachmentRow {
|
||||
filename: Option<String>,
|
||||
reference: String,
|
||||
mime_type: String,
|
||||
size_bytes: i64,
|
||||
width: Option<i32>,
|
||||
height: Option<i32>,
|
||||
uploaded_at: DateTime<Utc>,
|
||||
uploaded_by: i64,
|
||||
}
|
||||
|
||||
fn one_line_address(
|
||||
street: Option<String>,
|
||||
house: Option<String>,
|
||||
plz: Option<String>,
|
||||
city: Option<String>,
|
||||
country: Option<String>,
|
||||
) -> String {
|
||||
let line1 = [street, house]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let line2 = [plz, city]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
[line1, line2, country.unwrap_or_default()]
|
||||
.into_iter()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeliveryReportRepository for PgDeliveryReportRepository {
|
||||
async fn load(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
) -> Result<Option<DeliveryReportData>, ApplicationError> {
|
||||
// --- Kopf ---
|
||||
let head: Option<HeadRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT d.erp_belegart_id, d.erp_belegart_code, d.erp_belegart_name,
|
||||
d.erp_belegnummer, d.state,
|
||||
t.tour_date, t.account_id,
|
||||
acc.name AS driver_name,
|
||||
car.plate AS car_plate,
|
||||
pm.name AS payment_method,
|
||||
c.erp_customer_id, c.name AS customer_name,
|
||||
d.snap_street, d.snap_house_number, d.snap_postal_code,
|
||||
d.snap_city, d.snap_country,
|
||||
d.desired_time, d.special_agreements, d.prepaid_amount
|
||||
FROM deliveries d
|
||||
JOIN tours t ON t.id = d.tour_id
|
||||
JOIN accounts acc ON acc.personalnummer = t.account_id
|
||||
LEFT JOIN cars car ON car.id = d.assigned_car_id
|
||||
LEFT JOIN payment_methods pm ON pm.id = d.payment_method_id
|
||||
JOIN customers c ON c.id = d.customer_id
|
||||
WHERE d.id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let Some(head) = head else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// --- Positionen ---
|
||||
let items: Vec<ItemRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT di.belegzeilen_nr, di.komponenten_artikel_nr, di.parent_artikel_nr,
|
||||
a.article_number, a.name,
|
||||
di.required_quantity, di.credited_quantity, di.scanned_quantity,
|
||||
di.scan_status, di.unit_price,
|
||||
w.code AS warehouse_code, w.name AS warehouse_name
|
||||
FROM delivery_items di
|
||||
JOIN articles a ON a.id = di.article_id
|
||||
LEFT JOIN warehouses w ON w.id = di.warehouse_id
|
||||
WHERE di.delivery_id = $1
|
||||
ORDER BY di.belegzeilen_nr,
|
||||
di.komponenten_artikel_nr NULLS FIRST
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
// --- Dienstleistungen ---
|
||||
let services: Vec<ServiceRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT s.name, ds.bool_value, ds.numeric_value
|
||||
FROM delivery_services ds
|
||||
JOIN services s ON s.id = ds.service_id
|
||||
WHERE ds.delivery_id = $1
|
||||
ORDER BY s.sort_order, s.name
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
// --- Notizen ---
|
||||
let notes: Vec<NoteRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT created_at, author_personalnummer, text, image_attachment,
|
||||
is_amount_credit_note
|
||||
FROM delivery_notes
|
||||
WHERE delivery_id = $1
|
||||
ORDER BY created_at
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
// --- Ansprechpartner ---
|
||||
let contacts: Vec<ContactRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT cc.name, cc.phone, cc.email
|
||||
FROM delivery_contact_persons dcp
|
||||
JOIN customer_contacts cc ON cc.id = dcp.customer_contact_id
|
||||
WHERE dcp.delivery_id = $1
|
||||
ORDER BY cc.name
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
// --- Abschluss ---
|
||||
let completion: Option<CompletionRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT completed_at, completed_by_personalnummer, receipt_confirmed,
|
||||
notes_acknowledged, customer_signature_path, driver_signature_path,
|
||||
payment_collected, collected_amount_cents
|
||||
FROM delivery_completions
|
||||
WHERE delivery_id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
// --- Audit: Scan/Belade-Verlauf ---
|
||||
let scan_audit: Vec<ScanAuditRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT sa.server_recorded_at, sa.client_scanned_at, sa.action, sa.delta,
|
||||
sa.resulting_quantity, sa.resulting_status, sa.reason, sa.manual,
|
||||
sa.credit_delta, sa.actor_personalnummer,
|
||||
sa.erp_belegzeilen_nr AS belegzeilen_nr,
|
||||
sa.erp_komponenten_artikel_nr AS komponenten_artikel_nr,
|
||||
a.name AS article_name
|
||||
FROM scan_audit sa
|
||||
JOIN delivery_items di ON di.id = sa.delivery_item_id
|
||||
LEFT JOIN articles a ON a.id = di.article_id
|
||||
WHERE di.delivery_id = $1
|
||||
ORDER BY sa.server_recorded_at
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
// --- Audit: Gutschrift-Verlauf ---
|
||||
let credit_audit: Vec<CreditAuditRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT recorded_at, action, amount_cents, reason, author_personalnummer
|
||||
FROM delivery_credit_audit
|
||||
WHERE delivery_id = $1
|
||||
ORDER BY recorded_at
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
// --- Anhänge ---
|
||||
let attachments: Vec<AttachmentRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT filename, docuframe_object_id AS reference, mime_type, size_bytes,
|
||||
width, height, uploaded_at, uploaded_by
|
||||
FROM attachments
|
||||
WHERE delivery_id = $1
|
||||
ORDER BY uploaded_at
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
// Aktuelle Gutschrift = Wirkung des letzten Events (append-only).
|
||||
let current_credit_cents = credit_audit
|
||||
.last()
|
||||
.map(|e| {
|
||||
if e.action.eq_ignore_ascii_case("set") {
|
||||
e.amount_cents
|
||||
} else {
|
||||
0
|
||||
}
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
let address = one_line_address(
|
||||
head.snap_street,
|
||||
head.snap_house_number,
|
||||
head.snap_postal_code,
|
||||
head.snap_city,
|
||||
head.snap_country,
|
||||
);
|
||||
|
||||
Ok(Some(DeliveryReportData {
|
||||
generated_at: Utc::now(),
|
||||
belegart_id: head.erp_belegart_id,
|
||||
belegart_code: head.erp_belegart_code,
|
||||
belegart_name: head.erp_belegart_name,
|
||||
belegnummer: head.erp_belegnummer,
|
||||
state: head.state,
|
||||
tour_date: head.tour_date,
|
||||
driver_personalnummer: head.account_id,
|
||||
driver_name: head.driver_name,
|
||||
car_plate: head.car_plate,
|
||||
payment_method: head.payment_method,
|
||||
customer_number: head.erp_customer_id,
|
||||
customer_name: head.customer_name,
|
||||
address,
|
||||
desired_time: head.desired_time,
|
||||
special_agreements: head.special_agreements,
|
||||
prepaid_amount: head.prepaid_amount,
|
||||
current_credit_cents,
|
||||
contacts: contacts
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let detail = [c.phone, c.email]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ");
|
||||
ReportContact {
|
||||
name: c.name,
|
||||
detail: if detail.is_empty() { None } else { Some(detail) },
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
items: items
|
||||
.into_iter()
|
||||
.map(|i| ReportItem {
|
||||
belegzeilen_nr: i.belegzeilen_nr,
|
||||
komponenten_artikel_nr: i.komponenten_artikel_nr,
|
||||
parent_artikel_nr: i.parent_artikel_nr,
|
||||
article_number: i.article_number,
|
||||
name: i.name,
|
||||
required_quantity: i.required_quantity,
|
||||
credited_quantity: i.credited_quantity,
|
||||
scanned_quantity: i.scanned_quantity,
|
||||
scan_status: i.scan_status,
|
||||
unit_price: i.unit_price,
|
||||
warehouse_code: i.warehouse_code,
|
||||
warehouse_name: i.warehouse_name,
|
||||
})
|
||||
.collect(),
|
||||
services: services
|
||||
.into_iter()
|
||||
.map(|s| ReportService {
|
||||
name: s.name,
|
||||
bool_value: s.bool_value,
|
||||
numeric_value: s.numeric_value,
|
||||
})
|
||||
.collect(),
|
||||
notes: notes
|
||||
.into_iter()
|
||||
.map(|n| ReportNote {
|
||||
created_at: n.created_at,
|
||||
author_personalnummer: n.author_personalnummer,
|
||||
text: n.text,
|
||||
image_attachment: n.image_attachment,
|
||||
is_amount_credit_note: n.is_amount_credit_note,
|
||||
})
|
||||
.collect(),
|
||||
completion: completion.map(|c| ReportCompletion {
|
||||
completed_at: c.completed_at,
|
||||
completed_by_personalnummer: c.completed_by_personalnummer,
|
||||
receipt_confirmed: c.receipt_confirmed,
|
||||
notes_acknowledged: c.notes_acknowledged,
|
||||
customer_signature_path: c.customer_signature_path,
|
||||
driver_signature_path: c.driver_signature_path,
|
||||
payment_collected: c.payment_collected,
|
||||
collected_amount_cents: c.collected_amount_cents,
|
||||
}),
|
||||
scan_audit: scan_audit
|
||||
.into_iter()
|
||||
.map(|s| ReportScanAudit {
|
||||
server_recorded_at: s.server_recorded_at,
|
||||
client_scanned_at: s.client_scanned_at,
|
||||
action: s.action,
|
||||
delta: s.delta,
|
||||
resulting_quantity: s.resulting_quantity,
|
||||
resulting_status: s.resulting_status,
|
||||
reason: s.reason,
|
||||
manual: s.manual,
|
||||
credit_delta: s.credit_delta,
|
||||
actor_personalnummer: s.actor_personalnummer,
|
||||
belegzeilen_nr: s.belegzeilen_nr,
|
||||
komponenten_artikel_nr: s.komponenten_artikel_nr,
|
||||
article_name: s.article_name,
|
||||
})
|
||||
.collect(),
|
||||
credit_audit: credit_audit
|
||||
.into_iter()
|
||||
.map(|c| ReportCreditAudit {
|
||||
recorded_at: c.recorded_at,
|
||||
action: c.action,
|
||||
amount_cents: c.amount_cents,
|
||||
reason: c.reason,
|
||||
author_personalnummer: c.author_personalnummer,
|
||||
})
|
||||
.collect(),
|
||||
attachments: attachments
|
||||
.into_iter()
|
||||
.map(|a| ReportAttachment {
|
||||
filename: a.filename,
|
||||
reference: a.reference,
|
||||
mime_type: a.mime_type,
|
||||
size_bytes: a.size_bytes,
|
||||
width: a.width,
|
||||
height: a.height,
|
||||
uploaded_at: a.uploaded_at,
|
||||
uploaded_by: a.uploaded_by,
|
||||
bytes: None,
|
||||
})
|
||||
.collect(),
|
||||
customer_signature_png: None,
|
||||
driver_signature_png: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
102
crates/infrastructure/src/report/sink.rs
Normal file
102
crates/infrastructure/src/report/sink.rs
Normal file
@ -0,0 +1,102 @@
|
||||
//! Sinks für das fertige Report-PDF.
|
||||
//!
|
||||
//! * [`LocalReportSink`] — legt das PDF **temporär** lokal ab
|
||||
//! (`<dir>/<Belegnummer>/report-<timestamp>.pdf`). Aktiver Sink.
|
||||
//! * [`DocuframeReportSink`] — **Stub** für später: soll den PDF-Blob an ein
|
||||
//! DOCUframe-Makro senden. Aktuell nur Logging, keine echte Übertragung.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::DeliveryReportSink;
|
||||
|
||||
/// Dateisystem-sicheres Segment (alnum/._-, Rest → `_`).
|
||||
fn sanitize(input: &str) -> String {
|
||||
let s: String = input
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let t = s.trim_matches('.').trim().to_string();
|
||||
if t.is_empty() { "unbenannt".to_string() } else { t }
|
||||
}
|
||||
|
||||
// ===== Local =============================================================
|
||||
|
||||
pub struct LocalReportSink {
|
||||
base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl LocalReportSink {
|
||||
pub fn new(base_dir: impl Into<PathBuf>) -> std::io::Result<Self> {
|
||||
let base_dir = base_dir.into();
|
||||
std::fs::create_dir_all(&base_dir)?;
|
||||
Ok(Self { base_dir })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeliveryReportSink for LocalReportSink {
|
||||
async fn deliver(&self, folder: &str, pdf: Vec<u8>) -> Result<String, ApplicationError> {
|
||||
let folder = sanitize(folder);
|
||||
let stamp = Utc::now().format("%Y%m%d-%H%M%S").to_string();
|
||||
let dir = self.base_dir.join(&folder);
|
||||
let name = format!("report-{stamp}.pdf");
|
||||
let path = dir.join(&name);
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
|
||||
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
std::fs::write(&path, &pdf)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApplicationError::Repository(format!("join error: {e}")))?
|
||||
.map_err(|e| ApplicationError::Repository(format!("report speichern fehlgeschlagen: {e}")))?;
|
||||
|
||||
tracing::info!(path = %path_str, "report.sink.local: PDF abgelegt");
|
||||
Ok(path_str)
|
||||
}
|
||||
|
||||
async fn delete(&self, folder: &str) -> Result<(), ApplicationError> {
|
||||
// Löscht das gesamte Belegnummer-Verzeichnis (alle abgelegten Reports).
|
||||
let dir = self.base_dir.join(sanitize(folder));
|
||||
tokio::task::spawn_blocking(move || match std::fs::remove_dir_all(&dir) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApplicationError::Repository(format!("join error: {e}")))?
|
||||
.map_err(|e| ApplicationError::Repository(format!("report löschen fehlgeschlagen: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
// ===== DOCUframe (Stub) ==================================================
|
||||
|
||||
/// Platzhalter — später: PDF-Blob an ein DOCUframe-Makro senden. Aktuell nur
|
||||
/// Logging, keine echte Übertragung.
|
||||
pub struct DocuframeReportSink;
|
||||
|
||||
#[async_trait]
|
||||
impl DeliveryReportSink for DocuframeReportSink {
|
||||
async fn deliver(&self, folder: &str, pdf: Vec<u8>) -> Result<String, ApplicationError> {
|
||||
tracing::warn!(
|
||||
belegnummer = folder,
|
||||
pdf_bytes = pdf.len(),
|
||||
"report.sink.docuframe: STUB — PDF würde an DOCUframe-Makro gesendet (noch nicht implementiert)"
|
||||
);
|
||||
Ok(format!("docuframe-stub://{folder}"))
|
||||
}
|
||||
|
||||
async fn delete(&self, _folder: &str) -> Result<(), ApplicationError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
169
crates/infrastructure/src/storage/local_attachment_storage.rs
Normal file
169
crates/infrastructure/src/storage/local_attachment_storage.rs
Normal file
@ -0,0 +1,169 @@
|
||||
//! Lokaler Dateisystem-Adapter für `AttachmentStorage`.
|
||||
//!
|
||||
//! Ersetzt (vorerst) den DOCUframe-Upload: hochgeladene Bild-Notizen landen
|
||||
//! als Dateien auf der Backend-Maschine — gruppiert pro **Belegnummer** in
|
||||
//! einem eigenen Unterordner:
|
||||
//!
|
||||
//! ```text
|
||||
//! <base_dir>/<Belegnummer>/<uuid>_<dateiname>
|
||||
//! ```
|
||||
//!
|
||||
//! Die in der DB (`attachments.docuframe_object_id`) gespeicherte Referenz ist
|
||||
//! der **relative** Pfad `<Belegnummer>/<datei>` — so bleibt ein Umzug des
|
||||
//! Verzeichnisses unkompliziert und der Download liest dieselbe Referenz
|
||||
//! wieder ein.
|
||||
//!
|
||||
//! Das DOCUframe-Pendant (`gsd::GsdService`) bleibt im Code erhalten und kann
|
||||
//! später wieder verdrahtet werden.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::{AttachmentStorage, PreviewImage};
|
||||
|
||||
pub struct LocalAttachmentStorage {
|
||||
base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl LocalAttachmentStorage {
|
||||
/// Legt das Basis-Verzeichnis (rekursiv) an, falls nötig. Ein
|
||||
/// nicht-anlegbares Verzeichnis ist ein Boot-Fehler.
|
||||
pub fn new(base_dir: impl Into<PathBuf>) -> std::io::Result<Self> {
|
||||
let base_dir = base_dir.into();
|
||||
std::fs::create_dir_all(&base_dir)?;
|
||||
Ok(Self { base_dir })
|
||||
}
|
||||
}
|
||||
|
||||
/// Macht aus einem beliebigen Segment einen dateisystem-sicheren Namen:
|
||||
/// erlaubt sind `[A-Za-z0-9._-]`, alles andere wird zu `_`. Verhindert damit
|
||||
/// auch Path-Traversal (`/`, `\`, `..` → `_`). Leeres Ergebnis → `unbenannt`.
|
||||
fn sanitize_segment(input: &str) -> String {
|
||||
let cleaned: String = input
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let trimmed = cleaned.trim_matches('.').trim();
|
||||
if trimmed.is_empty() {
|
||||
"unbenannt".to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Content-Type aus der Dateiendung (das lokale Storage rendert nicht, es
|
||||
/// liefert die Originalbytes — der Typ kommt aus der Endung).
|
||||
fn content_type_for(path: &Path) -> String {
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"png" => "image/png",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
"heic" | "heif" => "image/heic",
|
||||
"bmp" => "image/bmp",
|
||||
"pdf" => "application/pdf",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AttachmentStorage for LocalAttachmentStorage {
|
||||
async fn upload(
|
||||
&self,
|
||||
folder: &str,
|
||||
filename: &str,
|
||||
_mime: &str,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<String, ApplicationError> {
|
||||
let folder = sanitize_segment(folder);
|
||||
// Nur den reinen Dateinamen verwenden (etwaige Pfadanteile abschneiden).
|
||||
let raw_name = Path::new(filename)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(filename);
|
||||
let safe_name = sanitize_segment(raw_name);
|
||||
// UUID-Präfix → mehrere Bilder pro Beleg kollidieren nicht.
|
||||
let stored_name = format!("{}_{}", Uuid::new_v4(), safe_name);
|
||||
|
||||
let dir = self.base_dir.join(&folder);
|
||||
let path = dir.join(&stored_name);
|
||||
let reference = format!("{folder}/{stored_name}");
|
||||
|
||||
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
std::fs::write(&path, &bytes)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApplicationError::Repository(format!("join error: {e}")))?
|
||||
.map_err(|e| {
|
||||
ApplicationError::Repository(format!("bild speichern fehlgeschlagen: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(reference)
|
||||
}
|
||||
|
||||
async fn download_preview(
|
||||
&self,
|
||||
object_id: &str,
|
||||
_parameters: &str,
|
||||
_page: &str,
|
||||
) -> Result<PreviewImage, ApplicationError> {
|
||||
// `object_id` ist unsere relative Referenz `<Belegnummer>/<datei>`.
|
||||
// Defense-in-depth gegen Traversal: keine absoluten Pfade / `..`.
|
||||
if object_id.contains("..")
|
||||
|| object_id.starts_with('/')
|
||||
|| object_id.starts_with('\\')
|
||||
{
|
||||
return Err(ApplicationError::Validation(
|
||||
"ungültige Attachment-Referenz".into(),
|
||||
));
|
||||
}
|
||||
let path = self.base_dir.join(object_id);
|
||||
let content_type = content_type_for(&path);
|
||||
|
||||
let read_path = path.clone();
|
||||
let bytes = tokio::task::spawn_blocking(move || std::fs::read(&read_path))
|
||||
.await
|
||||
.map_err(|e| ApplicationError::Repository(format!("join error: {e}")))?
|
||||
.map_err(|_| ApplicationError::NotFound)?;
|
||||
|
||||
Ok(PreviewImage {
|
||||
bytes,
|
||||
content_type,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete(&self, reference: &str) -> Result<(), ApplicationError> {
|
||||
// `reference` = relative Referenz `<Belegnummer>/<datei>`. Traversal-Guard.
|
||||
if reference.contains("..") || reference.starts_with('/') || reference.starts_with('\\') {
|
||||
return Err(ApplicationError::Validation(
|
||||
"ungültige Attachment-Referenz".into(),
|
||||
));
|
||||
}
|
||||
let path = self.base_dir.join(reference);
|
||||
tokio::task::spawn_blocking(move || match std::fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
// Fehlende Datei ist kein Fehler (idempotent).
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApplicationError::Repository(format!("join error: {e}")))?
|
||||
.map_err(|e| ApplicationError::Repository(format!("bild löschen fehlgeschlagen: {e}")))
|
||||
}
|
||||
}
|
||||
11
crates/infrastructure/src/storage/mod.rs
Normal file
11
crates/infrastructure/src/storage/mod.rs
Normal file
@ -0,0 +1,11 @@
|
||||
//! Lokale Datei-Adapter.
|
||||
//!
|
||||
//! Im Gegensatz zu `gsd` (DOCUframe-Dokumentenspeicher) bleiben diese
|
||||
//! Daten auf der Backend-Maschine: Unterschriften-PNGs und (neu) die
|
||||
//! hochgeladenen Bild-Notizen pro Belegnummer.
|
||||
|
||||
pub mod local_attachment_storage;
|
||||
pub mod signature_storage;
|
||||
|
||||
pub use local_attachment_storage::LocalAttachmentStorage;
|
||||
pub use signature_storage::LocalSignatureStorage;
|
||||
99
crates/infrastructure/src/storage/signature_storage.rs
Normal file
99
crates/infrastructure/src/storage/signature_storage.rs
Normal file
@ -0,0 +1,99 @@
|
||||
//! Lokaler Dateisystem-Adapter für `SignatureStorage`.
|
||||
//!
|
||||
//! Schreibt jede Unterschrift als PNG unter
|
||||
//! `<base_dir>/<delivery_id>_<role>.png`. Der Pfad ist deterministisch:
|
||||
//! ein Retry überschreibt dieselbe Datei, statt Müll anzuhäufen. Persistiert
|
||||
//! wird in der DB nur die relative Referenz (Dateiname), nicht der absolute
|
||||
//! Pfad — so bleibt ein Umzug des Verzeichnisses unkompliziert.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::{SignatureRole, SignatureStorage};
|
||||
|
||||
pub struct LocalSignatureStorage {
|
||||
base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl LocalSignatureStorage {
|
||||
/// Legt das Basis-Verzeichnis (rekursiv) an, falls es noch nicht
|
||||
/// existiert. Ein nicht-anlegbares Verzeichnis ist ein Boot-Fehler.
|
||||
pub fn new(base_dir: impl Into<PathBuf>) -> std::io::Result<Self> {
|
||||
let base_dir = base_dir.into();
|
||||
std::fs::create_dir_all(&base_dir)?;
|
||||
Ok(Self { base_dir })
|
||||
}
|
||||
|
||||
fn file_name(delivery_id: Uuid, role: SignatureRole) -> String {
|
||||
format!("{delivery_id}_{}.png", role.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SignatureStorage for LocalSignatureStorage {
|
||||
async fn save(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
role: SignatureRole,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<String, ApplicationError> {
|
||||
let name = Self::file_name(delivery_id, role);
|
||||
let path = self.base_dir.join(&name);
|
||||
// Blocking-IO bewusst auf einen Blocking-Thread auslagern.
|
||||
tokio::task::spawn_blocking(move || std::fs::write(&path, &bytes))
|
||||
.await
|
||||
.map_err(|e| ApplicationError::Repository(format!("join error: {e}")))?
|
||||
.map_err(|e| {
|
||||
ApplicationError::Repository(format!("signatur speichern fehlgeschlagen: {e}"))
|
||||
})?;
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
async fn load(&self, reference: &str) -> Result<Option<Vec<u8>>, ApplicationError> {
|
||||
// Traversal-Schutz: Referenz ist ein einfacher Dateiname.
|
||||
if reference.contains('/') || reference.contains('\\') || reference.contains("..") {
|
||||
return Err(ApplicationError::Validation(
|
||||
"ungültige Signatur-Referenz".into(),
|
||||
));
|
||||
}
|
||||
let path = self.base_dir.join(reference);
|
||||
let bytes = tokio::task::spawn_blocking(move || std::fs::read(&path))
|
||||
.await
|
||||
.map_err(|e| ApplicationError::Repository(format!("join error: {e}")))?;
|
||||
match bytes {
|
||||
Ok(b) => Ok(Some(b)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(ApplicationError::Repository(format!(
|
||||
"signatur laden fehlgeschlagen: {e}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_for_delivery(&self, delivery_id: Uuid) -> Result<(), ApplicationError> {
|
||||
// Beide deterministisch benannten Dateien (Kunde + Fahrer) entfernen.
|
||||
let paths = [
|
||||
self.base_dir
|
||||
.join(Self::file_name(delivery_id, SignatureRole::Customer)),
|
||||
self.base_dir
|
||||
.join(Self::file_name(delivery_id, SignatureRole::Driver)),
|
||||
];
|
||||
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
|
||||
for path in paths {
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApplicationError::Repository(format!("join error: {e}")))?
|
||||
.map_err(|e| {
|
||||
ApplicationError::Repository(format!("signatur löschen fehlgeschlagen: {e}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user