fix(sync): Lieferadresse nicht mehr feldweise mischen, Adresszusatz statt Land

- Sync nimmt die Lieferadresse als Ganzes: mit eigener LieferAdressId
  alle Felder aus ihr, sonst alle aus der Belegadresse. Das bisherige
  COALESCE je Feld hat leere Felder mit Werten des Bestellers gefüllt
  (z. B. dessen Ortsteil an eine fremde Anschrift gehängt).
- ERP-Freitext `Adressen.Land` (praktisch Ortsteil/Etage/Hinweis) wird
  zum Adresszusatz `Address.addition`; `country` kommt jetzt aus dem
  Länderstamm (`LandID` → `Laender.Land`). Deutschland-Kürzel und reine
  Landnamen sind kein Zusatz.
- Migration 0034: `customers.address_addition`, `deliveries.snap_addition`;
  Bestandstext aus `country` wandert in den Zusatz.
- Lieferbericht und Beleg-Ansicht: Land nur außerhalb Deutschlands,
  Zusatz als eigene Zeile „Zusatz: …".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-25 16:13:13 +02:00
parent af6d522290
commit c9d0ccfdef
11 changed files with 231 additions and 89 deletions

View File

@ -301,8 +301,13 @@ fn address_lines(a: &Address) -> Vec<String> {
if !city.is_empty() { if !city.is_empty() {
lines.push(city); lines.push(city);
} }
if !a.country.trim().is_empty() { // Land nur außerhalb Deutschlands; der Adresszusatz (Ortsteil, Etage,
lines.push(a.country.clone()); // Hinweis) als eigene, gekennzeichnete Zeile.
if let Some(country) = a.foreign_country() {
lines.push(country.to_string());
}
if let Some(addition) = a.addition.as_deref().filter(|z| !z.trim().is_empty()) {
lines.push(format!("Zusatz: {addition}"));
} }
lines lines
} }

View File

@ -30,6 +30,8 @@ pub struct DeliveryReportData {
pub customer_name: String, pub customer_name: String,
/// Lieferadresse (Snapshot am Beleg), einzeilig. /// Lieferadresse (Snapshot am Beleg), einzeilig.
pub address: String, pub address: String,
/// Adresszusatz der Lieferadresse (Ortsteil, Etage, Hinweis).
pub address_addition: Option<String>,
/// Nur gesetzt, wenn Empfänger oder Lieferadresse vom Besteller /// Nur gesetzt, wenn Empfänger oder Lieferadresse vom Besteller
/// abweichen (ERP-`LieferAdressId`). Dann ist `customer_*` der Besteller /// abweichen (ERP-`LieferAdressId`). Dann ist `customer_*` der Besteller
/// und `address` die abweichende Lieferadresse. /// und `address` die abweichende Lieferadresse.
@ -60,6 +62,7 @@ pub struct ReportRecipient {
pub phones: Vec<String>, pub phones: Vec<String>,
/// Anschrift des Bestellers (Kundenstamm), einzeilig. /// Anschrift des Bestellers (Kundenstamm), einzeilig.
pub orderer_address: String, pub orderer_address: String,
pub orderer_address_addition: Option<String>,
pub orderer_phones: Vec<String>, pub orderer_phones: Vec<String>,
} }

View File

@ -16,5 +16,74 @@ pub struct Address {
pub house_number: String, pub house_number: String,
pub postal_code: String, pub postal_code: String,
pub city: String, pub city: String,
/// Land laut ERP-Länderstamm (`Adressen.LandID` → `Laender.Land`), z. B.
/// „Deutschland" oder „Belgien". Leer, wenn im ERP kein Land verknüpft ist.
pub country: String, pub country: String,
/// Freier Adresszusatz. Kommt aus dem ERP-Textfeld `Adressen.Land`, das
/// die Sachbearbeitung praktisch für Ortsteil, Etage oder Hinweise nutzt
/// (z. B. „Holzheim", „3. Etage", „Parterre"). `None`, wenn leer.
#[serde(default)]
pub addition: Option<String>,
}
impl Address {
/// Einzeilige Anschrift „Straße Nr, PLZ Ort" — das Land nur, wenn es
/// nicht Deutschland ist. Der Adresszusatz gehört nicht dazu (eigene
/// Zeile in der Anzeige).
pub fn one_line(&self) -> String {
let line1 = join_non_empty(&[&self.street, &self.house_number], " ");
let line2 = join_non_empty(&[&self.postal_code, &self.city], " ");
let country = self.foreign_country().unwrap_or_default();
join_non_empty(&[&line1, &line2, country], ", ")
}
/// Land für die Anzeige: `None` für Deutschland (Heimatmarkt) oder leer.
pub fn foreign_country(&self) -> Option<&str> {
let c = self.country.trim();
(!c.is_empty() && !is_germany(c)).then_some(c)
}
}
/// `true` für die im ERP vorkommenden Schreibweisen von Deutschland.
pub fn is_germany(value: &str) -> bool {
matches!(
value.trim().to_lowercase().as_str(),
"d" | "de" | "deu" | "deutschland" | "germany" | "brd"
)
}
fn join_non_empty(parts: &[&str], sep: &str) -> String {
parts
.iter()
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.collect::<Vec<_>>()
.join(sep)
}
#[cfg(test)]
mod address_tests {
use super::*;
fn address(country: &str) -> Address {
Address {
street: "Am Sandberg".into(),
house_number: "15".into(),
postal_code: "41472".into(),
city: "Neuss".into(),
country: country.into(),
addition: Some("Holzheim".into()),
}
}
#[test]
fn deutschland_und_zusatz_stehen_nicht_in_der_zeile() {
assert_eq!(address("Deutschland").one_line(), "Am Sandberg 15, 41472 Neuss");
assert_eq!(address("").one_line(), "Am Sandberg 15, 41472 Neuss");
}
#[test]
fn ausland_wird_angehaengt() {
assert_eq!(address("Belgien").one_line(), "Am Sandberg 15, 41472 Neuss, Belgien");
}
} }

View File

@ -33,7 +33,7 @@ pub use account::Account;
pub use article::Article; pub use article::Article;
pub use audit::{AuditAction, ScanAuditEntry}; pub use audit::{AuditAction, ScanAuditEntry};
pub use car::Car; pub use car::Car;
pub use common::Address; pub use common::{is_germany, Address};
pub use contact::{ContactChannel, ContactKind, ContactRole, ContactSource}; pub use contact::{ContactChannel, ContactKind, ContactRole, ContactSource};
pub use customer::{Customer, CustomerContact}; pub use customer::{Customer, CustomerContact};
pub use delivery::{ pub use delivery::{

View File

@ -23,7 +23,7 @@ use holzleitner_application::dto::{
}; };
use holzleitner_application::error::ApplicationError; use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::ErpDeliverySource; use holzleitner_application::ports::ErpDeliverySource;
use holzleitner_domain::{Address, ContactKind, ContactRole}; use holzleitner_domain::{is_germany, Address, ContactKind, ContactRole};
/// Verbindungsparameter zur ERPframe-MSSQL. /// Verbindungsparameter zur ERPframe-MSSQL.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -141,12 +141,18 @@ SELECT
adr.Hausnummer AS custHouseNumber, adr.Hausnummer AS custHouseNumber,
LTRIM(RTRIM(adr.PLZ)) AS custPostalCode, LTRIM(RTRIM(adr.PLZ)) AS custPostalCode,
adr.Ort AS custCity, adr.Ort AS custCity,
adr.Land AS custCountry, LTRIM(RTRIM(hland.Land)) AS custCountry,
COALESCE(dadr.Strasse, adr.Strasse) AS delivStreet, adr.Land AS custAddition,
COALESCE(dadr.Hausnummer, adr.Hausnummer) AS delivHouseNumber, -- Lieferadresse als Ganzes: gibt es eine eigene, kommen ALLE Felder aus
LTRIM(RTRIM(COALESCE(dadr.PLZ, adr.PLZ))) AS delivPostalCode, -- ihr, sonst alle aus der Belegadresse. Kein Feld-für-Feld-COALESCE —
COALESCE(dadr.Ort, adr.Ort) AS delivCity, -- das hat leere Felder der Lieferadresse mit Werten des Bestellers
COALESCE(dadr.Land, adr.Land) AS delivCountry, -- aufgefüllt (z. B. dessen Ortsteil an eine fremde Anschrift gehängt).
CASE WHEN dadr.ROW_ID IS NOT NULL THEN dadr.Strasse ELSE adr.Strasse END AS delivStreet,
CASE WHEN dadr.ROW_ID IS NOT NULL THEN dadr.Hausnummer ELSE adr.Hausnummer END AS delivHouseNumber,
LTRIM(RTRIM(CASE WHEN dadr.ROW_ID IS NOT NULL THEN dadr.PLZ ELSE adr.PLZ END)) AS delivPostalCode,
CASE WHEN dadr.ROW_ID IS NOT NULL THEN dadr.Ort ELSE adr.Ort END AS delivCity,
LTRIM(RTRIM(CASE WHEN dadr.ROW_ID IS NOT NULL THEN dland.Land ELSE hland.Land END)) AS delivCountry,
CASE WHEN dadr.ROW_ID IS NOT NULL THEN dadr.Land ELSE adr.Land END AS delivAddition,
LTRIM(RTRIM(CAST(bk._Uhrzeit_Txt AS varchar(200)))) AS desiredTime, LTRIM(RTRIM(CAST(bk._Uhrzeit_Txt AS varchar(200)))) AS desiredTime,
LTRIM(RTRIM(CAST(bk.Kopftext AS varchar(MAX)))) AS specialAgreements, LTRIM(RTRIM(CAST(bk.Kopftext AS varchar(MAX)))) AS specialAgreements,
CAST(ISNULL(bk._Anz_Bestellung, 0) AS FLOAT) AS prepaidAmount, CAST(ISNULL(bk._Anz_Bestellung, 0) AS FLOAT) AS prepaidAmount,
@ -174,6 +180,8 @@ SELECT
FROM Belegkopf bk FROM Belegkopf bk
JOIN Adressen adr ON bk.AdressId = adr.ROW_ID JOIN Adressen adr ON bk.AdressId = adr.ROW_ID
LEFT JOIN Adressen dadr ON bk.LieferAdressId = dadr.ROW_ID LEFT JOIN Adressen dadr ON bk.LieferAdressId = dadr.ROW_ID
LEFT JOIN Laender hland ON hland.ROW_ID = adr.LandID
LEFT JOIN Laender dland ON dland.ROW_ID = dadr.LandID
LEFT JOIN Adressen radr ON bk.RechnungsAdressId = radr.ROW_ID LEFT JOIN Adressen radr ON bk.RechnungsAdressId = radr.ROW_ID
LEFT JOIN Adressen kadr ON bk.AnsprechpartnerId = kadr.ROW_ID LEFT JOIN Adressen kadr ON bk.AnsprechpartnerId = kadr.ROW_ID
JOIN Belegzeilen bz ON bk.row_id = bz.ParentID JOIN Belegzeilen bz ON bk.row_id = bz.ParentID
@ -215,12 +223,18 @@ SELECT
adr.Hausnummer AS custHouseNumber, adr.Hausnummer AS custHouseNumber,
LTRIM(RTRIM(adr.PLZ)) AS custPostalCode, LTRIM(RTRIM(adr.PLZ)) AS custPostalCode,
adr.Ort AS custCity, adr.Ort AS custCity,
adr.Land AS custCountry, LTRIM(RTRIM(hland.Land)) AS custCountry,
COALESCE(dadr.Strasse, adr.Strasse) AS delivStreet, adr.Land AS custAddition,
COALESCE(dadr.Hausnummer, adr.Hausnummer) AS delivHouseNumber, -- Lieferadresse als Ganzes: gibt es eine eigene, kommen ALLE Felder aus
LTRIM(RTRIM(COALESCE(dadr.PLZ, adr.PLZ))) AS delivPostalCode, -- ihr, sonst alle aus der Belegadresse. Kein Feld-für-Feld-COALESCE —
COALESCE(dadr.Ort, adr.Ort) AS delivCity, -- das hat leere Felder der Lieferadresse mit Werten des Bestellers
COALESCE(dadr.Land, adr.Land) AS delivCountry, -- aufgefüllt (z. B. dessen Ortsteil an eine fremde Anschrift gehängt).
CASE WHEN dadr.ROW_ID IS NOT NULL THEN dadr.Strasse ELSE adr.Strasse END AS delivStreet,
CASE WHEN dadr.ROW_ID IS NOT NULL THEN dadr.Hausnummer ELSE adr.Hausnummer END AS delivHouseNumber,
LTRIM(RTRIM(CASE WHEN dadr.ROW_ID IS NOT NULL THEN dadr.PLZ ELSE adr.PLZ END)) AS delivPostalCode,
CASE WHEN dadr.ROW_ID IS NOT NULL THEN dadr.Ort ELSE adr.Ort END AS delivCity,
LTRIM(RTRIM(CASE WHEN dadr.ROW_ID IS NOT NULL THEN dland.Land ELSE hland.Land END)) AS delivCountry,
CASE WHEN dadr.ROW_ID IS NOT NULL THEN dadr.Land ELSE adr.Land END AS delivAddition,
LTRIM(RTRIM(CAST(bk._Uhrzeit_Txt AS varchar(200)))) AS desiredTime, LTRIM(RTRIM(CAST(bk._Uhrzeit_Txt AS varchar(200)))) AS desiredTime,
LTRIM(RTRIM(CAST(bk.Kopftext AS varchar(MAX)))) AS specialAgreements, LTRIM(RTRIM(CAST(bk.Kopftext AS varchar(MAX)))) AS specialAgreements,
CAST(ISNULL(bk._Anz_Bestellung, 0) AS FLOAT) AS prepaidAmount, CAST(ISNULL(bk._Anz_Bestellung, 0) AS FLOAT) AS prepaidAmount,
@ -243,6 +257,8 @@ SELECT
FROM Belegkopf bk FROM Belegkopf bk
JOIN Adressen adr ON bk.AdressId = adr.ROW_ID JOIN Adressen adr ON bk.AdressId = adr.ROW_ID
LEFT JOIN Adressen dadr ON bk.LieferAdressId = dadr.ROW_ID LEFT JOIN Adressen dadr ON bk.LieferAdressId = dadr.ROW_ID
LEFT JOIN Laender hland ON hland.ROW_ID = adr.LandID
LEFT JOIN Laender dland ON dland.ROW_ID = dadr.LandID
LEFT JOIN Adressen radr ON bk.RechnungsAdressId = radr.ROW_ID LEFT JOIN Adressen radr ON bk.RechnungsAdressId = radr.ROW_ID
LEFT JOIN Adressen kadr ON bk.AnsprechpartnerId = kadr.ROW_ID LEFT JOIN Adressen kadr ON bk.AnsprechpartnerId = kadr.ROW_ID
JOIN Belegzeilen bz ON bk.row_id = bz.ParentID JOIN Belegzeilen bz ON bk.row_id = bz.ParentID
@ -412,6 +428,14 @@ fn read_all_contact_sources(row: &tiberius::Row) -> Vec<SyncContactSource> {
.collect() .collect()
} }
/// Adresszusatz aus dem ERP-Freitextfeld `Adressen.Land`. Das Feld wird
/// praktisch für Ortsteil/Etage/Hinweise genutzt; steht dort nur das Land
/// (Deutschland-Kürzel oder derselbe Name wie im Länderstamm), ist es kein
/// Zusatz.
fn address_addition(free_text: Option<String>, country: &str) -> Option<String> {
free_text.filter(|t| !is_germany(t) && !t.eq_ignore_ascii_case(country.trim()))
}
fn map_row(row: &tiberius::Row) -> ErpRow { fn map_row(row: &tiberius::Row) -> ErpRow {
ErpRow { ErpRow {
driver_personalnummer: i64c(row, "driverPersonalnummer"), driver_personalnummer: i64c(row, "driverPersonalnummer"),
@ -427,6 +451,7 @@ fn map_row(row: &tiberius::Row) -> ErpRow {
postal_code: s(row, "custPostalCode"), postal_code: s(row, "custPostalCode"),
city: s(row, "custCity"), city: s(row, "custCity"),
country: s(row, "custCountry"), country: s(row, "custCountry"),
addition: address_addition(opt_s(row, "custAddition"), &s(row, "custCountry")),
}, },
deliv: Address { deliv: Address {
street: s(row, "delivStreet"), street: s(row, "delivStreet"),
@ -434,6 +459,7 @@ fn map_row(row: &tiberius::Row) -> ErpRow {
postal_code: s(row, "delivPostalCode"), postal_code: s(row, "delivPostalCode"),
city: s(row, "delivCity"), city: s(row, "delivCity"),
country: s(row, "delivCountry"), country: s(row, "delivCountry"),
addition: address_addition(opt_s(row, "delivAddition"), &s(row, "delivCountry")),
}, },
desired_time: opt_s(row, "desiredTime"), desired_time: opt_s(row, "desiredTime"),
special_agreements: opt_s(row, "specialAgreements"), special_agreements: opt_s(row, "specialAgreements"),
@ -584,3 +610,23 @@ impl ErpDeliverySource for MssqlErpDeliverySource {
Ok(tours) Ok(tours)
} }
} }
#[cfg(test)]
mod address_addition_tests {
use super::address_addition;
#[test]
fn ortsteil_und_hinweise_sind_zusatz() {
assert_eq!(address_addition(Some("Holzheim".into()), "Deutschland").as_deref(), Some("Holzheim"));
assert_eq!(address_addition(Some("3 Etage".into()), "").as_deref(), Some("3 Etage"));
}
#[test]
fn reines_land_ist_kein_zusatz() {
for t in ["D", "DEU", "Deutschland"] {
assert_eq!(address_addition(Some(t.into()), ""), None, "{t}");
}
assert_eq!(address_addition(Some("belgien".into()), "Belgien"), None);
assert_eq!(address_addition(None, "Deutschland"), None);
}
}

View File

@ -48,6 +48,7 @@ struct DeliveryRow {
snap_postal_code: String, snap_postal_code: String,
snap_city: String, snap_city: String,
snap_country: String, snap_country: String,
snap_addition: Option<String>,
assigned_car_id: Option<Uuid>, assigned_car_id: Option<Uuid>,
desired_time: Option<String>, desired_time: Option<String>,
special_agreements: Option<String>, special_agreements: Option<String>,
@ -68,7 +69,7 @@ async fn lock_delivery(
r#" r#"
SELECT SELECT
id, tour_id, erp_belegart_id, erp_belegnummer, customer_id, id, tour_id, erp_belegart_id, erp_belegnummer, customer_id,
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country, snap_street, snap_house_number, snap_postal_code, snap_city, snap_country, snap_addition,
assigned_car_id, desired_time, special_agreements, assigned_car_id, desired_time, special_agreements,
state, prepaid_amount, payment_method_id state, prepaid_amount, payment_method_id
FROM deliveries FROM deliveries
@ -109,6 +110,7 @@ fn build_delivery(row: DeliveryRow, state: DeliveryState, state_reason: Option<S
postal_code: row.snap_postal_code, postal_code: row.snap_postal_code,
city: row.snap_city, city: row.snap_city,
country: row.snap_country, country: row.snap_country,
addition: row.snap_addition,
}, },
assigned_car_id: row.assigned_car_id, assigned_car_id: row.assigned_car_id,
contact_person_ids: contacts, contact_person_ids: contacts,

View File

@ -39,6 +39,7 @@ struct DeliveryRow {
snap_postal_code: String, snap_postal_code: String,
snap_city: String, snap_city: String,
snap_country: String, snap_country: String,
snap_addition: Option<String>,
assigned_car_id: Option<Uuid>, assigned_car_id: Option<Uuid>,
desired_time: Option<String>, desired_time: Option<String>,
special_agreements: Option<String>, special_agreements: Option<String>,
@ -112,7 +113,7 @@ async fn lock_delivery(
r#" r#"
SELECT SELECT
id, tour_id, erp_belegart_id, erp_belegnummer, customer_id, id, tour_id, erp_belegart_id, erp_belegnummer, customer_id,
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country, snap_street, snap_house_number, snap_postal_code, snap_city, snap_country, snap_addition,
assigned_car_id, desired_time, special_agreements, assigned_car_id, desired_time, special_agreements,
state, prepaid_amount, payment_method_id state, prepaid_amount, payment_method_id
FROM deliveries FROM deliveries
@ -193,6 +194,7 @@ impl DeliveryRepository for PgDeliveryRepository {
postal_code: row.snap_postal_code, postal_code: row.snap_postal_code,
city: row.snap_city, city: row.snap_city,
country: row.snap_country, country: row.snap_country,
addition: row.snap_addition,
}, },
assigned_car_id: row.assigned_car_id, assigned_car_id: row.assigned_car_id,
contact_person_ids, contact_person_ids,
@ -250,6 +252,7 @@ impl DeliveryRepository for PgDeliveryRepository {
postal_code: row.snap_postal_code, postal_code: row.snap_postal_code,
city: row.snap_city, city: row.snap_city,
country: row.snap_country, country: row.snap_country,
addition: row.snap_addition,
}, },
assigned_car_id: car_id, assigned_car_id: car_id,
contact_person_ids, contact_person_ids,

View File

@ -75,6 +75,7 @@ struct DeliveryRow {
snap_postal_code: String, snap_postal_code: String,
snap_city: String, snap_city: String,
snap_country: String, snap_country: String,
snap_addition: Option<String>,
assigned_car_id: Option<Uuid>, assigned_car_id: Option<Uuid>,
desired_time: Option<String>, desired_time: Option<String>,
special_agreements: Option<String>, special_agreements: Option<String>,
@ -113,6 +114,7 @@ struct CustomerRow {
postal_code: String, postal_code: String,
city: String, city: String,
country: String, country: String,
address_addition: Option<String>,
} }
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
@ -251,6 +253,7 @@ fn map_customer(row: CustomerRow) -> Customer {
postal_code: row.postal_code, postal_code: row.postal_code,
city: row.city, city: row.city,
country: row.country, country: row.country,
addition: row.address_addition,
}, },
} }
} }
@ -413,6 +416,7 @@ fn map_delivery(
postal_code: row.snap_postal_code, postal_code: row.snap_postal_code,
city: row.snap_city, city: row.snap_city,
country: row.snap_country, country: row.snap_country,
addition: row.snap_addition,
}, },
assigned_car_id: row.assigned_car_id, assigned_car_id: row.assigned_car_id,
contact_person_ids, contact_person_ids,
@ -490,6 +494,7 @@ impl TourRepository for PgTourRepository {
SELECT SELECT
id, tour_id, erp_belegart_id, erp_belegnummer, customer_id, id, tour_id, erp_belegart_id, erp_belegnummer, customer_id,
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country, snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
snap_addition,
assigned_car_id, desired_time, special_agreements, assigned_car_id, desired_time, special_agreements,
state, state_reason, sort_order, state, state_reason, sort_order,
prepaid_amount, payment_method_id prepaid_amount, payment_method_id
@ -578,7 +583,8 @@ impl TourRepository for PgTourRepository {
let customer_ids_vec: Vec<Uuid> = customer_ids.into_iter().collect(); let customer_ids_vec: Vec<Uuid> = customer_ids.into_iter().collect();
let customers = sqlx::query_as::<_, CustomerRow>( let customers = sqlx::query_as::<_, CustomerRow>(
r#" r#"
SELECT id, erp_customer_id, name, street, house_number, postal_code, city, country SELECT id, erp_customer_id, name, street, house_number, postal_code, city, country,
address_addition
FROM customers FROM customers
WHERE id = ANY($1) WHERE id = ANY($1)
ORDER BY name ORDER BY name
@ -1105,15 +1111,17 @@ async fn upsert_customer(
let id: Uuid = sqlx::query_scalar( let id: Uuid = sqlx::query_scalar(
r#" r#"
INSERT INTO customers ( INSERT INTO customers (
erp_customer_id, name, street, house_number, postal_code, city, country erp_customer_id, name, street, house_number, postal_code, city, country,
) VALUES ($1, $2, $3, $4, $5, $6, $7) address_addition
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (erp_customer_id) DO UPDATE SET ON CONFLICT (erp_customer_id) DO UPDATE SET
name = EXCLUDED.name, name = EXCLUDED.name,
street = EXCLUDED.street, street = EXCLUDED.street,
house_number = EXCLUDED.house_number, house_number = EXCLUDED.house_number,
postal_code = EXCLUDED.postal_code, postal_code = EXCLUDED.postal_code,
city = EXCLUDED.city, city = EXCLUDED.city,
country = EXCLUDED.country country = EXCLUDED.country,
address_addition = EXCLUDED.address_addition
RETURNING id RETURNING id
"#, "#,
) )
@ -1124,6 +1132,7 @@ async fn upsert_customer(
.bind(&delivery.customer_address.postal_code) .bind(&delivery.customer_address.postal_code)
.bind(&delivery.customer_address.city) .bind(&delivery.customer_address.city)
.bind(&delivery.customer_address.country) .bind(&delivery.customer_address.country)
.bind(delivery.customer_address.addition.as_deref())
.fetch_one(&mut **tx) .fetch_one(&mut **tx)
.await .await
.map_err(db)?; .map_err(db)?;
@ -1163,8 +1172,8 @@ async fn upsert_delivery(
erp_belegnummer, customer_id, erp_belegnummer, customer_id,
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country, snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
sort_order, desired_time, special_agreements, sort_order, desired_time, special_agreements,
prepaid_amount, payment_method_id prepaid_amount, payment_method_id, snap_addition
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
ON CONFLICT (erp_belegart_id, erp_belegnummer) DO UPDATE SET ON CONFLICT (erp_belegart_id, erp_belegnummer) DO UPDATE SET
tour_id = EXCLUDED.tour_id, tour_id = EXCLUDED.tour_id,
erp_belegart_code = EXCLUDED.erp_belegart_code, erp_belegart_code = EXCLUDED.erp_belegart_code,
@ -1175,6 +1184,7 @@ async fn upsert_delivery(
snap_postal_code = EXCLUDED.snap_postal_code, snap_postal_code = EXCLUDED.snap_postal_code,
snap_city = EXCLUDED.snap_city, snap_city = EXCLUDED.snap_city,
snap_country = EXCLUDED.snap_country, snap_country = EXCLUDED.snap_country,
snap_addition = EXCLUDED.snap_addition,
sort_order = EXCLUDED.sort_order, sort_order = EXCLUDED.sort_order,
desired_time = EXCLUDED.desired_time, desired_time = EXCLUDED.desired_time,
special_agreements = EXCLUDED.special_agreements, special_agreements = EXCLUDED.special_agreements,
@ -1199,6 +1209,7 @@ async fn upsert_delivery(
.bind(delivery.special_agreements.as_deref()) .bind(delivery.special_agreements.as_deref())
.bind(delivery.prepaid_amount) .bind(delivery.prepaid_amount)
.bind(payment_method_id) .bind(payment_method_id)
.bind(delivery.delivery_address.addition.as_deref())
.fetch_one(&mut **tx) .fetch_one(&mut **tx)
.await .await
.map_err(db)?; .map_err(db)?;

View File

@ -482,11 +482,17 @@ impl DeliveryReportRenderer for PdfDeliveryReportRenderer {
); );
p.kv("Besteller", &format!("{} (Nr. {})", d.customer_name, d.customer_number)); p.kv("Besteller", &format!("{} (Nr. {})", d.customer_name, d.customer_number));
p.kv("Adresse Besteller", &r.orderer_address); p.kv("Adresse Besteller", &r.orderer_address);
if let Some(z) = &r.orderer_address_addition {
p.kv("Zusatz Besteller", z);
}
if !r.orderer_phones.is_empty() { if !r.orderer_phones.is_empty() {
p.kv("Tel. Besteller", &r.orderer_phones.join(" · ")); p.kv("Tel. Besteller", &r.orderer_phones.join(" · "));
} }
p.kv("Empfänger", &r.name); p.kv("Empfänger", &r.name);
p.kv("Lieferadresse", &d.address); p.kv("Lieferadresse", &d.address);
if let Some(z) = &d.address_addition {
p.kv("Zusatz", z);
}
if !r.phones.is_empty() { if !r.phones.is_empty() {
p.kv("Tel. Empfänger", &r.phones.join(" · ")); p.kv("Tel. Empfänger", &r.phones.join(" · "));
} }
@ -494,6 +500,9 @@ impl DeliveryReportRenderer for PdfDeliveryReportRenderer {
None => { None => {
p.kv("Kunde", &format!("{} (Nr. {})", d.customer_name, d.customer_number)); p.kv("Kunde", &format!("{} (Nr. {})", d.customer_name, d.customer_number));
p.kv("Adresse", &d.address); p.kv("Adresse", &d.address);
if let Some(z) = &d.address_addition {
p.kv("Zusatz", z);
}
} }
} }
p.kv("Wunschzeit", opt(&d.desired_time)); p.kv("Wunschzeit", opt(&d.desired_time));

View File

@ -15,6 +15,7 @@ use holzleitner_application::dto::{
}; };
use holzleitner_application::error::ApplicationError; use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::DeliveryReportRepository; use holzleitner_application::ports::DeliveryReportRepository;
use holzleitner_domain::Address;
pub struct PgDeliveryReportRepository { pub struct PgDeliveryReportRepository {
pool: PgPool, pool: PgPool,
@ -49,11 +50,13 @@ struct HeadRow {
cust_postal_code: String, cust_postal_code: String,
cust_city: String, cust_city: String,
cust_country: String, cust_country: String,
cust_addition: Option<String>,
snap_street: Option<String>, snap_street: Option<String>,
snap_house_number: Option<String>, snap_house_number: Option<String>,
snap_postal_code: Option<String>, snap_postal_code: Option<String>,
snap_city: Option<String>, snap_city: Option<String>,
snap_country: Option<String>, snap_country: Option<String>,
snap_addition: Option<String>,
desired_time: Option<String>, desired_time: Option<String>,
special_agreements: Option<String>, special_agreements: Option<String>,
prepaid_amount: f64, prepaid_amount: f64,
@ -185,32 +188,6 @@ struct AttachmentRow {
uploaded_by: i64, uploaded_by: i64,
} }
fn one_line_address(
street: Option<String>,
house: Option<String>,
plz: Option<String>,
city: Option<String>,
country: Option<String>,
) -> String {
let line1 = [street, house]
.into_iter()
.flatten()
.filter(|s| !s.trim().is_empty())
.collect::<Vec<_>>()
.join(" ");
let line2 = [plz, city]
.into_iter()
.flatten()
.filter(|s| !s.trim().is_empty())
.collect::<Vec<_>>()
.join(" ");
[line1, line2, country.unwrap_or_default()]
.into_iter()
.filter(|s| !s.trim().is_empty())
.collect::<Vec<_>>()
.join(", ")
}
#[async_trait] #[async_trait]
impl DeliveryReportRepository for PgDeliveryReportRepository { impl DeliveryReportRepository for PgDeliveryReportRepository {
async fn load( async fn load(
@ -229,9 +206,9 @@ impl DeliveryReportRepository for PgDeliveryReportRepository {
c.erp_customer_id, c.name AS customer_name, c.erp_customer_id, c.name AS customer_name,
c.street AS cust_street, c.house_number AS cust_house_number, c.street AS cust_street, c.house_number AS cust_house_number,
c.postal_code AS cust_postal_code, c.city AS cust_city, c.postal_code AS cust_postal_code, c.city AS cust_city,
c.country AS cust_country, c.country AS cust_country, c.address_addition AS cust_addition,
d.snap_street, d.snap_house_number, d.snap_postal_code, d.snap_street, d.snap_house_number, d.snap_postal_code,
d.snap_city, d.snap_country, d.snap_city, d.snap_country, d.snap_addition,
d.desired_time, d.special_agreements, d.prepaid_amount d.desired_time, d.special_agreements, d.prepaid_amount
FROM deliveries d FROM deliveries d
JOIN tours t ON t.id = d.tour_id JOIN tours t ON t.id = d.tour_id
@ -432,19 +409,21 @@ impl DeliveryReportRepository for PgDeliveryReportRepository {
}) })
.unwrap_or(0); .unwrap_or(0);
let snap = AddressParts { let snap = Address {
street: head.snap_street.unwrap_or_default(), street: head.snap_street.unwrap_or_default(),
house_number: head.snap_house_number.unwrap_or_default(), house_number: head.snap_house_number.unwrap_or_default(),
postal_code: head.snap_postal_code.unwrap_or_default(), postal_code: head.snap_postal_code.unwrap_or_default(),
city: head.snap_city.unwrap_or_default(), city: head.snap_city.unwrap_or_default(),
country: head.snap_country.unwrap_or_default(), country: head.snap_country.unwrap_or_default(),
addition: head.snap_addition,
}; };
let orderer = AddressParts { let orderer = Address {
street: head.cust_street, street: head.cust_street,
house_number: head.cust_house_number, house_number: head.cust_house_number,
postal_code: head.cust_postal_code, postal_code: head.cust_postal_code,
city: head.cust_city, city: head.cust_city,
country: head.cust_country, country: head.cust_country,
addition: head.cust_addition,
}; };
let different_recipient = different_recipient( let different_recipient = different_recipient(
&head.customer_name, &head.customer_name,
@ -454,6 +433,7 @@ impl DeliveryReportRepository for PgDeliveryReportRepository {
sources.iter().find(|s| s.role == "delivery"), sources.iter().find(|s| s.role == "delivery"),
); );
let address = snap.one_line(); let address = snap.one_line();
let address_addition = snap.addition.clone();
Ok(Some(DeliveryReportData { Ok(Some(DeliveryReportData {
generated_at: Utc::now(), generated_at: Utc::now(),
@ -470,6 +450,7 @@ impl DeliveryReportRepository for PgDeliveryReportRepository {
customer_number: head.erp_customer_id, customer_number: head.erp_customer_id,
customer_name: head.customer_name, customer_name: head.customer_name,
address, address,
address_addition,
different_recipient, different_recipient,
desired_time: head.desired_time, desired_time: head.desired_time,
special_agreements: head.special_agreements, special_agreements: head.special_agreements,
@ -597,34 +578,13 @@ impl DeliveryReportRepository for PgDeliveryReportRepository {
} }
} }
/// Anschrift in Einzelteilen, wie sie am Kunden bzw. als Beleg-Snapshot liegt. /// Gleiche Anschrift, unabhängig von Groß-/Kleinschreibung und Leerzeichen.
struct AddressParts { /// Land und Zusatz zählen nicht (im ERP uneinheitlich gepflegt).
street: String, fn same_place(a: &Address, b: &Address) -> bool {
house_number: String, normalized(&a.street) == normalized(&b.street)
postal_code: String, && normalized(&a.house_number) == normalized(&b.house_number)
city: String, && normalized(&a.postal_code) == normalized(&b.postal_code)
country: String, && normalized(&a.city) == normalized(&b.city)
}
impl AddressParts {
fn one_line(&self) -> String {
one_line_address(
Some(self.street.clone()),
Some(self.house_number.clone()),
Some(self.postal_code.clone()),
Some(self.city.clone()),
Some(self.country.clone()),
)
}
/// Gleiche Anschrift, unabhängig von Groß-/Kleinschreibung und
/// Leerzeichen. Das Land zählt nicht (im ERP oft uneinheitlich gepflegt).
fn same_place(&self, other: &Self) -> bool {
normalized(&self.street) == normalized(&other.street)
&& normalized(&self.house_number) == normalized(&other.house_number)
&& normalized(&self.postal_code) == normalized(&other.postal_code)
&& normalized(&self.city) == normalized(&other.city)
}
} }
fn normalized(value: &str) -> String { fn normalized(value: &str) -> String {
@ -637,8 +597,8 @@ fn normalized(value: &str) -> String {
/// Kundennamen). /// Kundennamen).
fn different_recipient( fn different_recipient(
customer_name: &str, customer_name: &str,
orderer: &AddressParts, orderer: &Address,
delivery_address: &AddressParts, delivery_address: &Address,
header: Option<&ContactSourceRow>, header: Option<&ContactSourceRow>,
delivery: Option<&ContactSourceRow>, delivery: Option<&ContactSourceRow>,
) -> Option<ReportRecipient> { ) -> Option<ReportRecipient> {
@ -647,7 +607,7 @@ fn different_recipient(
.unwrap_or_else(|| customer_name.to_string()); .unwrap_or_else(|| customer_name.to_string());
let recipient_name = delivery.and_then(ContactSourceRow::full_name); let recipient_name = delivery.and_then(ContactSourceRow::full_name);
let address_differs = !orderer.same_place(delivery_address); let address_differs = !same_place(orderer, delivery_address);
let name_differs = recipient_name let name_differs = recipient_name
.as_deref() .as_deref()
.is_some_and(|n| normalized(n) != normalized(&orderer_name)); .is_some_and(|n| normalized(n) != normalized(&orderer_name));
@ -659,6 +619,7 @@ fn different_recipient(
name: recipient_name.unwrap_or_else(|| customer_name.to_string()), name: recipient_name.unwrap_or_else(|| customer_name.to_string()),
phones: delivery.map(|s| s.phones.clone()).unwrap_or_default(), phones: delivery.map(|s| s.phones.clone()).unwrap_or_default(),
orderer_address: orderer.one_line(), orderer_address: orderer.one_line(),
orderer_address_addition: orderer.addition.clone(),
orderer_phones: header.map(|s| s.phones.clone()).unwrap_or_default(), orderer_phones: header.map(|s| s.phones.clone()).unwrap_or_default(),
}) })
} }
@ -667,13 +628,14 @@ fn different_recipient(
mod different_recipient_tests { mod different_recipient_tests {
use super::*; use super::*;
fn addr(street: &str, city: &str) -> AddressParts { fn addr(street: &str, city: &str) -> Address {
AddressParts { Address {
street: street.into(), street: street.into(),
house_number: "1".into(), house_number: "1".into(),
postal_code: "41460".into(), postal_code: "41460".into(),
city: city.into(), city: city.into(),
country: "DE".into(), country: "Deutschland".into(),
addition: None,
} }
} }
@ -714,7 +676,7 @@ mod different_recipient_tests {
) )
.expect("abweichend"); .expect("abweichend");
assert_eq!(r.name, "Musterfrau Erika"); assert_eq!(r.name, "Musterfrau Erika");
assert_eq!(r.orderer_address, "Am Sandberg 1, 41460 Neuss, DE"); assert_eq!(r.orderer_address, "Am Sandberg 1, 41460 Neuss");
assert_eq!(r.phones, vec!["0151 1".to_string()]); assert_eq!(r.phones, vec!["0151 1".to_string()]);
} }

View File

@ -0,0 +1,32 @@
-- Adresszusatz getrennt vom Land.
--
-- Das ERP-Textfeld `Adressen.Land` wird von der Sachbearbeitung praktisch
-- nie für das Land genutzt, sondern für Ortsteil, Etage oder Hinweise
-- („Holzheim", „3 Etage", „Parterre"). Bisher landete es in `country` und
-- erschien im Lieferbericht als vermeintliches Land. Ab jetzt:
-- * `*_addition` = dieser freie Zusatz,
-- * `country` = echtes Land aus dem ERP-Länderstamm (`LandID`).
--
-- Bestandsdaten: der bisherige `country`-Text war immer dieses Freitextfeld
-- → in den Zusatz verschieben. Reine Deutschland-Kürzel („D", „DEU", …)
-- sind kein Zusatz und werden verworfen. Der nächste Sync setzt `country`
-- wieder korrekt aus dem Länderstamm.
ALTER TABLE customers ADD COLUMN address_addition TEXT;
ALTER TABLE deliveries ADD COLUMN snap_addition TEXT;
UPDATE customers
SET address_addition = CASE
WHEN lower(btrim(country)) IN ('', 'd', 'de', 'deu', 'deutschland', 'germany', 'brd')
THEN NULL
ELSE btrim(country)
END,
country = '';
UPDATE deliveries
SET snap_addition = CASE
WHEN lower(btrim(snap_country)) IN ('', 'd', 'de', 'deu', 'deutschland', 'germany', 'brd')
THEN NULL
ELSE btrim(snap_country)
END,
snap_country = '';