//! 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: E) -> ApplicationError { ApplicationError::Repository(e.to_string()) } type TiberiusClient = Client>; 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 { 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::("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::("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::("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::("net").unwrap_or(0.0); let gross = r.get::("gross").unwrap_or(0.0); let menge = r.get::("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::("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) } } } }