Files
Holzleitner---Backend--aktu…/crates/infrastructure/src/erp/mssql_delivery_writeback.rs
Dennis Nemec 6a9b5872e1 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>
2026-06-01 17:52:58 +02:00

420 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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)
}
}
}
}