From bfdd4d2307f94bf84b13b915f48e472a90ba316a Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Fri, 25 Sep 2026 16:26:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(sync):=20Anschrift=20je=20Kontaktquelle=20?= =?UTF-8?q?mitf=C3=BChren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beleg-, Liefer-, Rechnungsadresse, Ansprechpartner und Kundenstamm tragen jetzt neben Namensblock und Kanälen auch ihre Anschrift (Straße, Nr., PLZ, Ort, Land aus dem Länderstamm, Adresszusatz), sofern im ERP gepflegt. Migration 0035 ergänzt die Spalten an delivery_contact_sources; ContactSource.address in der API. Co-Authored-By: Claude Opus 5.5 --- crates/application/src/dto/tour_sync.rs | 3 ++ crates/domain/src/contact.rs | 13 ++++--- .../src/erp/mssql_delivery_source.rs | 36 +++++++++++++++---- .../src/persistence/tour_repository.rs | 33 +++++++++++++++-- migrations/0035_contact_source_address.sql | 14 ++++++++ 5 files changed, 86 insertions(+), 13 deletions(-) create mode 100644 migrations/0035_contact_source_address.sql diff --git a/crates/application/src/dto/tour_sync.rs b/crates/application/src/dto/tour_sync.rs index 20a03d2..3aecb82 100644 --- a/crates/application/src/dto/tour_sync.rs +++ b/crates/application/src/dto/tour_sync.rs @@ -96,6 +96,9 @@ pub struct SyncContactSource { pub funktion: Option, #[serde(default)] pub channels: Vec, + /// Anschrift dieses Adress-Datensatzes, falls gepflegt. + #[serde(default)] + pub address: Option
, } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/crates/domain/src/contact.rs b/crates/domain/src/contact.rs index cad5d22..09988e0 100644 --- a/crates/domain/src/contact.rs +++ b/crates/domain/src/contact.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; +use crate::Address; + /// Rolle, mit der ein Kontakt-Datensatz an einer Lieferung hängt. Spiegelt /// die fünf Adress-FKs von `Belegkopf` (bzw. den Umweg über den Kunden): /// `header` = Belegadresse, `delivery` = Lieferadresse, `billing` = @@ -30,10 +32,9 @@ pub enum ContactKind { } /// Snapshot eines ERP-Adress-Datensatzes, der zum Zeitpunkt des Tour-Syncs -/// an einer Lieferung hing — Namensblock ohne Anschrift, weil die Adresse -/// ihrerseits schon im Lieferungs-Snapshot steckt (`snap_*`-Spalten). Die -/// eigentlichen Telefonnummern, E-Mails etc. liegen in den -/// zugehörigen [`ContactChannel`]s. +/// an einer Lieferung hing — Namensblock plus (falls im ERP gepflegt) die +/// Anschrift dieses Datensatzes. Die eigentlichen Telefonnummern, E-Mails +/// etc. liegen in den zugehörigen [`ContactChannel`]s. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] @@ -48,6 +49,10 @@ pub struct ContactSource { pub name3: Option, pub abteilung: Option, pub funktion: Option, + /// Anschrift dieses Adress-Datensatzes; `None`, wenn im ERP weder + /// Straße noch Ort gepflegt sind (z. B. reiner Ansprechpartner). + #[serde(default)] + pub address: Option
, } /// Ein einzelner Kontaktkanal (Telefonnummer / Mobil / E-Mail / Web). diff --git a/crates/infrastructure/src/erp/mssql_delivery_source.rs b/crates/infrastructure/src/erp/mssql_delivery_source.rs index 24754f1..096fc82 100644 --- a/crates/infrastructure/src/erp/mssql_delivery_source.rs +++ b/crates/infrastructure/src/erp/mssql_delivery_source.rs @@ -86,15 +86,22 @@ const ADDRESS_CONTACT_COLUMNS: &[&str] = &[ "Telefon3", "Telefon4", "Mobiltel", "Mobiltel2", "EMail", "EMail2", "EMail3", "InternetAdresse", ]; +/// Anschrift-Spalten je Adresse (`Land` = ERP-Freitext, praktisch Zusatz). +const ADDRESS_POSTAL_COLUMNS: &[&str] = &["Strasse", "Hausnummer", "PLZ", "Ort", "Land"]; + /// Erzeugt den SELECT-Block für alle 5 Adressen — eine Zeile pro Spalte mit -/// `. AS _`. Wird in beide UNION-Hälften identisch -/// einkopiert. +/// `. AS _`, dazu das Land aus dem Länderstamm als +/// `_LandName`. 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 { + for col in ADDRESS_CONTACT_COLUMNS.iter().chain(ADDRESS_POSTAL_COLUMNS) { out.push_str(&format!(" {alias}.{col} AS {prefix}_{col},\n")); } + out.push_str(&format!( + " (SELECT LTRIM(RTRIM(l.Land)) FROM Laender l WHERE l.ROW_ID = {alias}.LandID) \ + AS {prefix}_LandName,\n" + )); } out } @@ -348,8 +355,9 @@ fn boolc(row: &tiberius::Row, col: &str) -> bool { /// Liest aus einem Tiberius-Row die Kontaktdaten **einer** Adress-Rolle aus /// (alle Spalten heißen `_`, 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. +/// Gibt `None` zurück, wenn weder ein Namensfeld noch ein Kontaktkanal noch +/// eine Anschrift belegt ist — leere Adressen werden so vom Sync nicht +/// mitgeschleppt. fn read_contact_source( row: &tiberius::Row, prefix: &str, @@ -391,6 +399,21 @@ fn read_contact_source( push(&mut channels, ContactKind::Email, 3, get("EMail3")); push(&mut channels, ContactKind::Web, 1, get("InternetAdresse")); + // Anschrift nur, wenn Straße oder Ort gepflegt sind. + let street = get("Strasse"); + let city = get("Ort"); + let address = (street.is_some() || city.is_some()).then(|| { + let country = get("LandName").unwrap_or_default(); + Address { + street: street.unwrap_or_default(), + house_number: get("Hausnummer").unwrap_or_default(), + postal_code: get("PLZ").unwrap_or_default(), + city: city.unwrap_or_default(), + addition: address_addition(get("Land"), &country), + country, + } + }); + let has_name = anrede.is_some() || titel.is_some() || name1.is_some() @@ -398,7 +421,7 @@ fn read_contact_source( || name3.is_some() || abteilung.is_some() || funktion.is_some(); - if channels.is_empty() && !has_name { + if channels.is_empty() && !has_name && address.is_none() { return None; } @@ -412,6 +435,7 @@ fn read_contact_source( abteilung, funktion, channels, + address, }) } diff --git a/crates/infrastructure/src/persistence/tour_repository.rs b/crates/infrastructure/src/persistence/tour_repository.rs index 2242fbf..4d036a1 100644 --- a/crates/infrastructure/src/persistence/tour_repository.rs +++ b/crates/infrastructure/src/persistence/tour_repository.rs @@ -161,6 +161,12 @@ struct ContactSourceRow { name3: Option, abteilung: Option, funktion: Option, + street: Option, + house_number: Option, + postal_code: Option, + city: Option, + country: Option, + address_addition: Option, } #[derive(sqlx::FromRow)] @@ -280,6 +286,19 @@ fn map_contact_source(row: ContactSourceRow) -> Result( r#" SELECT id, delivery_id, role, - anrede, titel, name1, name2, name3, abteilung, funktion + anrede, titel, name1, name2, name3, abteilung, funktion, + street, house_number, postal_code, city, country, address_addition FROM delivery_contact_sources WHERE delivery_id = ANY($1) ORDER BY delivery_id, role @@ -1319,8 +1339,9 @@ async fn replace_contact_sources( 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) + abteilung, funktion, + street, house_number, postal_code, city, country, address_addition + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id "#, ) @@ -1333,6 +1354,12 @@ async fn replace_contact_sources( .bind(src.name3.as_deref()) .bind(src.abteilung.as_deref()) .bind(src.funktion.as_deref()) + .bind(src.address.as_ref().map(|a| a.street.as_str())) + .bind(src.address.as_ref().map(|a| a.house_number.as_str())) + .bind(src.address.as_ref().map(|a| a.postal_code.as_str())) + .bind(src.address.as_ref().map(|a| a.city.as_str())) + .bind(src.address.as_ref().map(|a| a.country.as_str())) + .bind(src.address.as_ref().and_then(|a| a.addition.as_deref())) .fetch_one(&mut **tx) .await .map_err(db)?; diff --git a/migrations/0035_contact_source_address.sql b/migrations/0035_contact_source_address.sql new file mode 100644 index 0000000..6c4db8a --- /dev/null +++ b/migrations/0035_contact_source_address.sql @@ -0,0 +1,14 @@ +-- Anschrift je Kontaktquelle (Beleg-, Liefer-, Rechnungsadresse, +-- Ansprechpartner, Kundenstamm). Bisher trug eine Quelle nur Namensblock +-- und Kanäle; die App zeigt unter „Alle Kontaktinfos" jetzt auch die +-- Anschrift, sofern im ERP gepflegt. Alles NULL = keine Anschrift. +-- Felder wie bei `customers` / `deliveries.snap_*`: `country` aus dem +-- ERP-Länderstamm, `address_addition` = ERP-Freitext `Adressen.Land`. + +ALTER TABLE delivery_contact_sources + ADD COLUMN street TEXT, + ADD COLUMN house_number TEXT, + ADD COLUMN postal_code TEXT, + ADD COLUMN city TEXT, + ADD COLUMN country TEXT, + ADD COLUMN address_addition TEXT;