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>
420 lines
15 KiB
Rust
420 lines
15 KiB
Rust
//! 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)
|
||
}
|
||
}
|
||
}
|
||
}
|