Backend-Arbeitsstand: ERP-Sync, Lieferlebenszyklus, Reports + config.toml

Bringt das Backend vom initialen Skeleton auf den aktuellen Arbeitsstand
(Clean Architecture: domain → application → infrastructure → api).

Wesentliche Bereiche:
- ERP-Anbindung (MSSQL-Pull der Touren, Import-Scheduler, Rückschreiben)
- Lieferlebenszyklus: Scan/Hold/Cancel/Complete, Gutschriften, Notizen,
  Bild-Anhänge, Unterschriften, PDF-Lieferreport → DOCUframe
- Stammdaten: Kunden, Artikel, Lager, Zahlungsarten, Services
- Keycloak-JWT-Gate + Fahrer-Provisionierung via Admin-API
- Admin-API-Key-Gate (X-Admin-Api-Key) für Maschinen-Endpunkte

Jüngste Änderungen dieser Session:
- Belegspezifische Kontaktdaten: alle ERP-Adressen (Beleg-/Liefer-/
  Rechnungsadresse, Ansprechpartner, Kundenstamm) mit Telefon/Mobil/
  E-Mail werden gesynct (Migration 0029, MSSQL-Query, TourDetails)
- Konfiguration von .env (envy/dotenvy) auf config.toml (toml/serde)
  umgestellt; Vorlage config.example.toml, Pfad via HOLZLEITNER_CONFIG

Nicht im Repo (per .gitignore): config.toml (Secrets), data/ (Laufzeit-/
Kundendaten), demo.mp4, .claude/, variocontrol-ai/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-06-01 17:52:58 +02:00
parent 438040acce
commit 6a9b5872e1
137 changed files with 13700 additions and 218 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,169 @@
//! Lokaler Dateisystem-Adapter für `AttachmentStorage`.
//!
//! Ersetzt (vorerst) den DOCUframe-Upload: hochgeladene Bild-Notizen landen
//! als Dateien auf der Backend-Maschine — gruppiert pro **Belegnummer** in
//! einem eigenen Unterordner:
//!
//! ```text
//! <base_dir>/<Belegnummer>/<uuid>_<dateiname>
//! ```
//!
//! Die in der DB (`attachments.docuframe_object_id`) gespeicherte Referenz ist
//! der **relative** Pfad `<Belegnummer>/<datei>` — so bleibt ein Umzug des
//! Verzeichnisses unkompliziert und der Download liest dieselbe Referenz
//! wieder ein.
//!
//! Das DOCUframe-Pendant (`gsd::GsdService`) bleibt im Code erhalten und kann
//! später wieder verdrahtet werden.
use std::path::{Path, PathBuf};
use async_trait::async_trait;
use uuid::Uuid;
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::{AttachmentStorage, PreviewImage};
pub struct LocalAttachmentStorage {
base_dir: PathBuf,
}
impl LocalAttachmentStorage {
/// Legt das Basis-Verzeichnis (rekursiv) an, falls nötig. Ein
/// nicht-anlegbares Verzeichnis ist ein Boot-Fehler.
pub fn new(base_dir: impl Into<PathBuf>) -> std::io::Result<Self> {
let base_dir = base_dir.into();
std::fs::create_dir_all(&base_dir)?;
Ok(Self { base_dir })
}
}
/// Macht aus einem beliebigen Segment einen dateisystem-sicheren Namen:
/// erlaubt sind `[A-Za-z0-9._-]`, alles andere wird zu `_`. Verhindert damit
/// auch Path-Traversal (`/`, `\`, `..` → `_`). Leeres Ergebnis → `unbenannt`.
fn sanitize_segment(input: &str) -> String {
let cleaned: String = input
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
c
} else {
'_'
}
})
.collect();
let trimmed = cleaned.trim_matches('.').trim();
if trimmed.is_empty() {
"unbenannt".to_string()
} else {
trimmed.to_string()
}
}
/// Content-Type aus der Dateiendung (das lokale Storage rendert nicht, es
/// liefert die Originalbytes — der Typ kommt aus der Endung).
fn content_type_for(path: &Path) -> String {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
match ext.as_str() {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
"heic" | "heif" => "image/heic",
"bmp" => "image/bmp",
"pdf" => "application/pdf",
_ => "application/octet-stream",
}
.to_string()
}
#[async_trait]
impl AttachmentStorage for LocalAttachmentStorage {
async fn upload(
&self,
folder: &str,
filename: &str,
_mime: &str,
bytes: Vec<u8>,
) -> Result<String, ApplicationError> {
let folder = sanitize_segment(folder);
// Nur den reinen Dateinamen verwenden (etwaige Pfadanteile abschneiden).
let raw_name = Path::new(filename)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(filename);
let safe_name = sanitize_segment(raw_name);
// UUID-Präfix → mehrere Bilder pro Beleg kollidieren nicht.
let stored_name = format!("{}_{}", Uuid::new_v4(), safe_name);
let dir = self.base_dir.join(&folder);
let path = dir.join(&stored_name);
let reference = format!("{folder}/{stored_name}");
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
std::fs::create_dir_all(&dir)?;
std::fs::write(&path, &bytes)
})
.await
.map_err(|e| ApplicationError::Repository(format!("join error: {e}")))?
.map_err(|e| {
ApplicationError::Repository(format!("bild speichern fehlgeschlagen: {e}"))
})?;
Ok(reference)
}
async fn download_preview(
&self,
object_id: &str,
_parameters: &str,
_page: &str,
) -> Result<PreviewImage, ApplicationError> {
// `object_id` ist unsere relative Referenz `<Belegnummer>/<datei>`.
// Defense-in-depth gegen Traversal: keine absoluten Pfade / `..`.
if object_id.contains("..")
|| object_id.starts_with('/')
|| object_id.starts_with('\\')
{
return Err(ApplicationError::Validation(
"ungültige Attachment-Referenz".into(),
));
}
let path = self.base_dir.join(object_id);
let content_type = content_type_for(&path);
let read_path = path.clone();
let bytes = tokio::task::spawn_blocking(move || std::fs::read(&read_path))
.await
.map_err(|e| ApplicationError::Repository(format!("join error: {e}")))?
.map_err(|_| ApplicationError::NotFound)?;
Ok(PreviewImage {
bytes,
content_type,
})
}
async fn delete(&self, reference: &str) -> Result<(), ApplicationError> {
// `reference` = relative Referenz `<Belegnummer>/<datei>`. Traversal-Guard.
if reference.contains("..") || reference.starts_with('/') || reference.starts_with('\\') {
return Err(ApplicationError::Validation(
"ungültige Attachment-Referenz".into(),
));
}
let path = self.base_dir.join(reference);
tokio::task::spawn_blocking(move || match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
// Fehlende Datei ist kein Fehler (idempotent).
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
})
.await
.map_err(|e| ApplicationError::Repository(format!("join error: {e}")))?
.map_err(|e| ApplicationError::Repository(format!("bild löschen fehlgeschlagen: {e}")))
}
}

View File

@ -0,0 +1,11 @@
//! Lokale Datei-Adapter.
//!
//! Im Gegensatz zu `gsd` (DOCUframe-Dokumentenspeicher) bleiben diese
//! Daten auf der Backend-Maschine: Unterschriften-PNGs und (neu) die
//! hochgeladenen Bild-Notizen pro Belegnummer.
pub mod local_attachment_storage;
pub mod signature_storage;
pub use local_attachment_storage::LocalAttachmentStorage;
pub use signature_storage::LocalSignatureStorage;

View File

@ -0,0 +1,99 @@
//! Lokaler Dateisystem-Adapter für `SignatureStorage`.
//!
//! Schreibt jede Unterschrift als PNG unter
//! `<base_dir>/<delivery_id>_<role>.png`. Der Pfad ist deterministisch:
//! ein Retry überschreibt dieselbe Datei, statt Müll anzuhäufen. Persistiert
//! wird in der DB nur die relative Referenz (Dateiname), nicht der absolute
//! Pfad — so bleibt ein Umzug des Verzeichnisses unkompliziert.
use std::path::PathBuf;
use async_trait::async_trait;
use uuid::Uuid;
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::{SignatureRole, SignatureStorage};
pub struct LocalSignatureStorage {
base_dir: PathBuf,
}
impl LocalSignatureStorage {
/// Legt das Basis-Verzeichnis (rekursiv) an, falls es noch nicht
/// existiert. Ein nicht-anlegbares Verzeichnis ist ein Boot-Fehler.
pub fn new(base_dir: impl Into<PathBuf>) -> std::io::Result<Self> {
let base_dir = base_dir.into();
std::fs::create_dir_all(&base_dir)?;
Ok(Self { base_dir })
}
fn file_name(delivery_id: Uuid, role: SignatureRole) -> String {
format!("{delivery_id}_{}.png", role.as_str())
}
}
#[async_trait]
impl SignatureStorage for LocalSignatureStorage {
async fn save(
&self,
delivery_id: Uuid,
role: SignatureRole,
bytes: Vec<u8>,
) -> Result<String, ApplicationError> {
let name = Self::file_name(delivery_id, role);
let path = self.base_dir.join(&name);
// Blocking-IO bewusst auf einen Blocking-Thread auslagern.
tokio::task::spawn_blocking(move || std::fs::write(&path, &bytes))
.await
.map_err(|e| ApplicationError::Repository(format!("join error: {e}")))?
.map_err(|e| {
ApplicationError::Repository(format!("signatur speichern fehlgeschlagen: {e}"))
})?;
Ok(name)
}
async fn load(&self, reference: &str) -> Result<Option<Vec<u8>>, ApplicationError> {
// Traversal-Schutz: Referenz ist ein einfacher Dateiname.
if reference.contains('/') || reference.contains('\\') || reference.contains("..") {
return Err(ApplicationError::Validation(
"ungültige Signatur-Referenz".into(),
));
}
let path = self.base_dir.join(reference);
let bytes = tokio::task::spawn_blocking(move || std::fs::read(&path))
.await
.map_err(|e| ApplicationError::Repository(format!("join error: {e}")))?;
match bytes {
Ok(b) => Ok(Some(b)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(ApplicationError::Repository(format!(
"signatur laden fehlgeschlagen: {e}"
))),
}
}
async fn delete_for_delivery(&self, delivery_id: Uuid) -> Result<(), ApplicationError> {
// Beide deterministisch benannten Dateien (Kunde + Fahrer) entfernen.
let paths = [
self.base_dir
.join(Self::file_name(delivery_id, SignatureRole::Customer)),
self.base_dir
.join(Self::file_name(delivery_id, SignatureRole::Driver)),
];
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
for path in paths {
match std::fs::remove_file(&path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
}
Ok(())
})
.await
.map_err(|e| ApplicationError::Repository(format!("join error: {e}")))?
.map_err(|e| {
ApplicationError::Repository(format!("signatur löschen fehlgeschlagen: {e}"))
})
}
}