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

@ -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)
}
}
}
}