feat(sync): Anschrift je Kontaktquelle mitführen

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 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-25 16:26:00 +02:00
parent c9d0ccfdef
commit bfdd4d2307
5 changed files with 86 additions and 13 deletions

View File

@ -96,6 +96,9 @@ pub struct SyncContactSource {
pub funktion: Option<String>, pub funktion: Option<String>,
#[serde(default)] #[serde(default)]
pub channels: Vec<SyncContactChannel>, pub channels: Vec<SyncContactChannel>,
/// Anschrift dieses Adress-Datensatzes, falls gepflegt.
#[serde(default)]
pub address: Option<Address>,
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]

View File

@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::Address;
/// Rolle, mit der ein Kontakt-Datensatz an einer Lieferung hängt. Spiegelt /// 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): /// die fünf Adress-FKs von `Belegkopf` (bzw. den Umweg über den Kunden):
/// `header` = Belegadresse, `delivery` = Lieferadresse, `billing` = /// `header` = Belegadresse, `delivery` = Lieferadresse, `billing` =
@ -30,10 +32,9 @@ pub enum ContactKind {
} }
/// Snapshot eines ERP-Adress-Datensatzes, der zum Zeitpunkt des Tour-Syncs /// Snapshot eines ERP-Adress-Datensatzes, der zum Zeitpunkt des Tour-Syncs
/// an einer Lieferung hing — Namensblock ohne Anschrift, weil die Adresse /// an einer Lieferung hing — Namensblock plus (falls im ERP gepflegt) die
/// ihrerseits schon im Lieferungs-Snapshot steckt (`snap_*`-Spalten). Die /// Anschrift dieses Datensatzes. Die eigentlichen Telefonnummern, E-Mails
/// eigentlichen Telefonnummern, E-Mails etc. liegen in den /// etc. liegen in den zugehörigen [`ContactChannel`]s.
/// zugehörigen [`ContactChannel`]s.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -48,6 +49,10 @@ pub struct ContactSource {
pub name3: Option<String>, pub name3: Option<String>,
pub abteilung: Option<String>, pub abteilung: Option<String>,
pub funktion: Option<String>, pub funktion: Option<String>,
/// Anschrift dieses Adress-Datensatzes; `None`, wenn im ERP weder
/// Straße noch Ort gepflegt sind (z. B. reiner Ansprechpartner).
#[serde(default)]
pub address: Option<Address>,
} }
/// Ein einzelner Kontaktkanal (Telefonnummer / Mobil / E-Mail / Web). /// Ein einzelner Kontaktkanal (Telefonnummer / Mobil / E-Mail / Web).

View File

@ -86,15 +86,22 @@ const ADDRESS_CONTACT_COLUMNS: &[&str] = &[
"Telefon3", "Telefon4", "Mobiltel", "Mobiltel2", "EMail", "EMail2", "EMail3", "InternetAdresse", "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 /// Erzeugt den SELECT-Block für alle 5 Adressen — eine Zeile pro Spalte mit
/// `<alias>.<col> AS <präfix>_<col>`. Wird in beide UNION-Hälften identisch /// `<alias>.<col> AS <präfix>_<col>`, dazu das Land aus dem Länderstamm als
/// einkopiert. /// `<präfix>_LandName`. Wird in beide UNION-Hälften identisch einkopiert.
fn address_select_block() -> String { fn address_select_block() -> String {
let mut out = String::new(); let mut out = String::new();
for (alias, prefix) in ADDRESS_ALIASES { 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!(" {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 out
} }
@ -348,8 +355,9 @@ fn boolc(row: &tiberius::Row, col: &str) -> bool {
/// Liest aus einem Tiberius-Row die Kontaktdaten **einer** Adress-Rolle aus /// Liest aus einem Tiberius-Row die Kontaktdaten **einer** Adress-Rolle aus
/// (alle Spalten heißen `<präfix>_<spalte>`, siehe [`address_select_block`]). /// (alle Spalten heißen `<präfix>_<spalte>`, siehe [`address_select_block`]).
/// Gibt `None` zurück, wenn weder ein Namensfeld noch ein Kontaktkanal /// Gibt `None` zurück, wenn weder ein Namensfeld noch ein Kontaktkanal noch
/// belegt ist — leere Adressen werden so vom Sync nicht mitgeschleppt. /// eine Anschrift belegt ist — leere Adressen werden so vom Sync nicht
/// mitgeschleppt.
fn read_contact_source( fn read_contact_source(
row: &tiberius::Row, row: &tiberius::Row,
prefix: &str, prefix: &str,
@ -391,6 +399,21 @@ fn read_contact_source(
push(&mut channels, ContactKind::Email, 3, get("EMail3")); push(&mut channels, ContactKind::Email, 3, get("EMail3"));
push(&mut channels, ContactKind::Web, 1, get("InternetAdresse")); 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() let has_name = anrede.is_some()
|| titel.is_some() || titel.is_some()
|| name1.is_some() || name1.is_some()
@ -398,7 +421,7 @@ fn read_contact_source(
|| name3.is_some() || name3.is_some()
|| abteilung.is_some() || abteilung.is_some()
|| funktion.is_some(); || funktion.is_some();
if channels.is_empty() && !has_name { if channels.is_empty() && !has_name && address.is_none() {
return None; return None;
} }
@ -412,6 +435,7 @@ fn read_contact_source(
abteilung, abteilung,
funktion, funktion,
channels, channels,
address,
}) })
} }

View File

@ -161,6 +161,12 @@ struct ContactSourceRow {
name3: Option<String>, name3: Option<String>,
abteilung: Option<String>, abteilung: Option<String>,
funktion: Option<String>, funktion: Option<String>,
street: Option<String>,
house_number: Option<String>,
postal_code: Option<String>,
city: Option<String>,
country: Option<String>,
address_addition: Option<String>,
} }
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
@ -280,6 +286,19 @@ fn map_contact_source(row: ContactSourceRow) -> Result<ContactSource, Applicatio
name3: row.name3, name3: row.name3,
abteilung: row.abteilung, abteilung: row.abteilung,
funktion: row.funktion, funktion: row.funktion,
// Ohne Straße und Ort gilt die Quelle als anschriftslos.
address: if row.street.is_some() || row.city.is_some() {
Some(Address {
street: row.street.unwrap_or_default(),
house_number: row.house_number.unwrap_or_default(),
postal_code: row.postal_code.unwrap_or_default(),
city: row.city.unwrap_or_default(),
country: row.country.unwrap_or_default(),
addition: row.address_addition,
})
} else {
None
},
}) })
} }
@ -746,7 +765,8 @@ impl TourRepository for PgTourRepository {
let source_rows = sqlx::query_as::<_, ContactSourceRow>( let source_rows = sqlx::query_as::<_, ContactSourceRow>(
r#" r#"
SELECT id, delivery_id, role, 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 FROM delivery_contact_sources
WHERE delivery_id = ANY($1) WHERE delivery_id = ANY($1)
ORDER BY delivery_id, role ORDER BY delivery_id, role
@ -1319,8 +1339,9 @@ async fn replace_contact_sources(
r#" r#"
INSERT INTO delivery_contact_sources ( INSERT INTO delivery_contact_sources (
delivery_id, role, anrede, titel, name1, name2, name3, delivery_id, role, anrede, titel, name1, name2, name3,
abteilung, funktion abteilung, funktion,
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) 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 RETURNING id
"#, "#,
) )
@ -1333,6 +1354,12 @@ async fn replace_contact_sources(
.bind(src.name3.as_deref()) .bind(src.name3.as_deref())
.bind(src.abteilung.as_deref()) .bind(src.abteilung.as_deref())
.bind(src.funktion.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) .fetch_one(&mut **tx)
.await .await
.map_err(db)?; .map_err(db)?;

View File

@ -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;