diff --git a/crates/application/src/dto/delivery_report.rs b/crates/application/src/dto/delivery_report.rs index e5e4e1c..7751ba6 100644 --- a/crates/application/src/dto/delivery_report.rs +++ b/crates/application/src/dto/delivery_report.rs @@ -28,7 +28,12 @@ pub struct DeliveryReportData { // Kunde + Adresse pub customer_number: i64, pub customer_name: String, + /// Lieferadresse (Snapshot am Beleg), einzeilig. pub address: String, + /// Nur gesetzt, wenn Empfänger oder Lieferadresse vom Besteller + /// abweichen (ERP-`LieferAdressId`). Dann ist `customer_*` der Besteller + /// und `address` die abweichende Lieferadresse. + pub different_recipient: Option, pub desired_time: Option, pub special_agreements: Option, pub prepaid_amount: f64, @@ -48,6 +53,16 @@ pub struct DeliveryReportData { pub driver_signature_png: Option>, } +/// Empfänger vor Ort, der vom Besteller abweicht, samt Kontaktdaten beider. +#[derive(Debug, Clone)] +pub struct ReportRecipient { + pub name: String, + pub phones: Vec, + /// Anschrift des Bestellers (Kundenstamm), einzeilig. + pub orderer_address: String, + pub orderer_phones: Vec, +} + #[derive(Debug, Clone)] pub struct ReportContact { pub name: String, diff --git a/crates/application/src/dto/mod.rs b/crates/application/src/dto/mod.rs index 886a763..20306e0 100644 --- a/crates/application/src/dto/mod.rs +++ b/crates/application/src/dto/mod.rs @@ -33,7 +33,7 @@ pub use complete::CompleteDeliveryAcknowledgements; pub use credit::{CreditAction, DeliveryCreditEventRequest, DeliveryCreditResponse}; pub use delivery_report::{ DeliveryReportData, ReportAttachment, ReportCompletion, ReportContact, ReportCreditAudit, ReportPayment, - ReportItem, ReportNote, ReportScanAudit, ReportService, + ReportItem, ReportNote, ReportRecipient, ReportScanAudit, ReportService, }; pub use delivery_action::{CancelDeliveryRequest, DeliveryResponse, HoldDeliveryRequest}; pub use delivery_details::DeliveryDetails; diff --git a/crates/infrastructure/src/report/renderer.rs b/crates/infrastructure/src/report/renderer.rs index f05b149..62abe4d 100644 --- a/crates/infrastructure/src/report/renderer.rs +++ b/crates/infrastructure/src/report/renderer.rs @@ -469,8 +469,33 @@ impl DeliveryReportRenderer for PdfDeliveryReportRenderer { // 2. Kunde & Lieferadresse p.heading("Kunde & Lieferadresse"); - p.kv("Kunde", &format!("{} (Nr. {})", d.customer_name, d.customer_number)); - p.kv("Adresse", &d.address); + match &d.different_recipient { + // Empfänger ≠ Besteller: beide getrennt, jeweils mit Anschrift + // und Telefon, damit nachvollziehbar bleibt, wer bestellt und + // wer die Ware angenommen hat. + Some(r) => { + p.text( + "Abweichende Lieferadresse: Empfänger ist nicht der Besteller.", + 9.0, + true, + 0.0, + ); + p.kv("Besteller", &format!("{} (Nr. {})", d.customer_name, d.customer_number)); + p.kv("Adresse Besteller", &r.orderer_address); + if !r.orderer_phones.is_empty() { + p.kv("Tel. Besteller", &r.orderer_phones.join(" · ")); + } + p.kv("Empfänger", &r.name); + p.kv("Lieferadresse", &d.address); + if !r.phones.is_empty() { + p.kv("Tel. Empfänger", &r.phones.join(" · ")); + } + } + None => { + p.kv("Kunde", &format!("{} (Nr. {})", d.customer_name, d.customer_number)); + p.kv("Adresse", &d.address); + } + } p.kv("Wunschzeit", opt(&d.desired_time)); if let Some(sa) = &d.special_agreements { if !sa.trim().is_empty() { diff --git a/crates/infrastructure/src/report/repository.rs b/crates/infrastructure/src/report/repository.rs index daf8aae..650b8d2 100644 --- a/crates/infrastructure/src/report/repository.rs +++ b/crates/infrastructure/src/report/repository.rs @@ -11,7 +11,7 @@ use uuid::Uuid; use holzleitner_application::dto::{ DeliveryReportData, ReportAttachment, ReportCompletion, ReportContact, ReportCreditAudit, ReportPayment, - ReportItem, ReportNote, ReportScanAudit, ReportService, + ReportItem, ReportNote, ReportRecipient, ReportScanAudit, ReportService, }; use holzleitner_application::error::ApplicationError; use holzleitner_application::ports::DeliveryReportRepository; @@ -44,6 +44,11 @@ struct HeadRow { payment_method: Option, erp_customer_id: i64, customer_name: String, + cust_street: String, + cust_house_number: String, + cust_postal_code: String, + cust_city: String, + cust_country: String, snap_street: Option, snap_house_number: Option, snap_postal_code: Option, @@ -93,6 +98,30 @@ struct ContactRow { email: Option, } +/// Belegadresse (`header`) oder Lieferadresse (`delivery`) aus dem ERP-Sync, +/// mit ihren Telefon-/Mobilnummern. +#[derive(sqlx::FromRow)] +struct ContactSourceRow { + role: String, + name1: Option, + name2: Option, + name3: Option, + phones: Vec, +} + +impl ContactSourceRow { + /// `Name1..3` ohne Anrede/Titel, leere Teile übersprungen. + fn full_name(&self) -> Option { + let parts: Vec<&str> = [&self.name1, &self.name2, &self.name3] + .into_iter() + .flatten() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect(); + (!parts.is_empty()).then(|| parts.join(" ")) + } +} + #[derive(sqlx::FromRow)] struct CompletionRow { completed_at: DateTime, @@ -198,6 +227,9 @@ impl DeliveryReportRepository for PgDeliveryReportRepository { car.plate AS car_plate, pm.name AS payment_method, c.erp_customer_id, c.name AS customer_name, + 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.country AS cust_country, d.snap_street, d.snap_house_number, d.snap_postal_code, d.snap_city, d.snap_country, d.desired_time, d.special_agreements, d.prepaid_amount @@ -285,6 +317,26 @@ impl DeliveryReportRepository for PgDeliveryReportRepository { .await .map_err(db)?; + // --- Beleg- und Lieferadresse (für abweichenden Empfänger) --- + let sources: Vec = sqlx::query_as( + r#" + SELECT s.role, s.name1, s.name2, s.name3, + COALESCE( + array_agg(ch.value ORDER BY ch.kind DESC, ch.position) + FILTER (WHERE ch.kind IN ('phone', 'mobile')), + '{}' + ) AS phones + FROM delivery_contact_sources s + LEFT JOIN delivery_contact_channels ch ON ch.source_id = s.id + WHERE s.delivery_id = $1 AND s.role IN ('header', 'delivery') + GROUP BY s.id, s.role, s.name1, s.name2, s.name3 + "#, + ) + .bind(delivery_id) + .fetch_all(&self.pool) + .await + .map_err(db)?; + // --- Abschluss --- let completion: Option = sqlx::query_as( r#" @@ -380,13 +432,28 @@ impl DeliveryReportRepository for PgDeliveryReportRepository { }) .unwrap_or(0); - let address = one_line_address( - head.snap_street, - head.snap_house_number, - head.snap_postal_code, - head.snap_city, - head.snap_country, + let snap = AddressParts { + street: head.snap_street.unwrap_or_default(), + house_number: head.snap_house_number.unwrap_or_default(), + postal_code: head.snap_postal_code.unwrap_or_default(), + city: head.snap_city.unwrap_or_default(), + country: head.snap_country.unwrap_or_default(), + }; + let orderer = AddressParts { + street: head.cust_street, + house_number: head.cust_house_number, + postal_code: head.cust_postal_code, + city: head.cust_city, + country: head.cust_country, + }; + let different_recipient = different_recipient( + &head.customer_name, + &orderer, + &snap, + sources.iter().find(|s| s.role == "header"), + sources.iter().find(|s| s.role == "delivery"), ); + let address = snap.one_line(); Ok(Some(DeliveryReportData { generated_at: Utc::now(), @@ -403,6 +470,7 @@ impl DeliveryReportRepository for PgDeliveryReportRepository { customer_number: head.erp_customer_id, customer_name: head.customer_name, address, + different_recipient, desired_time: head.desired_time, special_agreements: head.special_agreements, prepaid_amount: head.prepaid_amount, @@ -528,3 +596,139 @@ impl DeliveryReportRepository for PgDeliveryReportRepository { })) } } + +/// Anschrift in Einzelteilen, wie sie am Kunden bzw. als Beleg-Snapshot liegt. +struct AddressParts { + street: String, + house_number: String, + postal_code: String, + city: String, + country: String, +} + +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 { + value.split_whitespace().collect::>().join(" ").to_lowercase() +} + +/// Empfänger vor Ort, falls er vom Besteller abweicht — dieselbe Regel wie +/// in der App (`TourDetails.recipientOf`): andere Anschrift ODER anderer Name +/// an der Lieferadresse (verglichen mit der Belegadresse, sonst dem +/// Kundennamen). +fn different_recipient( + customer_name: &str, + orderer: &AddressParts, + delivery_address: &AddressParts, + header: Option<&ContactSourceRow>, + delivery: Option<&ContactSourceRow>, +) -> Option { + let orderer_name = header + .and_then(ContactSourceRow::full_name) + .unwrap_or_else(|| customer_name.to_string()); + let recipient_name = delivery.and_then(ContactSourceRow::full_name); + + let address_differs = !orderer.same_place(delivery_address); + let name_differs = recipient_name + .as_deref() + .is_some_and(|n| normalized(n) != normalized(&orderer_name)); + if !address_differs && !name_differs { + return None; + } + + Some(ReportRecipient { + name: recipient_name.unwrap_or_else(|| customer_name.to_string()), + phones: delivery.map(|s| s.phones.clone()).unwrap_or_default(), + orderer_address: orderer.one_line(), + orderer_phones: header.map(|s| s.phones.clone()).unwrap_or_default(), + }) +} + +#[cfg(test)] +mod different_recipient_tests { + use super::*; + + fn addr(street: &str, city: &str) -> AddressParts { + AddressParts { + street: street.into(), + house_number: "1".into(), + postal_code: "41460".into(), + city: city.into(), + country: "DE".into(), + } + } + + fn source(role: &str, name1: &str) -> ContactSourceRow { + ContactSourceRow { + role: role.into(), + name1: Some(name1.into()), + name2: None, + name3: None, + phones: vec!["0151 1".into()], + } + } + + #[test] + fn gleicher_empfaenger_ist_keine_abweichung() { + let header = source("header", "Kraus Heike"); + let delivery = source("delivery", "kraus HEIKE"); + let r = different_recipient( + "Kraus Heike", + &addr("Hauptstr.", "Neuss"), + &addr("hauptstr.", "NEUSS"), + Some(&header), + Some(&delivery), + ); + assert!(r.is_none()); + } + + #[test] + fn andere_person_an_anderer_adresse() { + let header = source("header", "Zimmermann Ralf"); + let delivery = source("delivery", "Musterfrau Erika"); + let r = different_recipient( + "Zimmermann Ralf", + &addr("Am Sandberg", "Neuss"), + &addr("Musterstraße", "Neuss"), + Some(&header), + Some(&delivery), + ) + .expect("abweichend"); + assert_eq!(r.name, "Musterfrau Erika"); + assert_eq!(r.orderer_address, "Am Sandberg 1, 41460 Neuss, DE"); + assert_eq!(r.phones, vec!["0151 1".to_string()]); + } + + #[test] + fn andere_adresse_ohne_eigenen_namen_nutzt_besteller() { + let r = different_recipient( + "Kraus Heike", + &addr("Hauptstr.", "Neuss"), + &addr("Nebenstr.", "Kaarst"), + None, + None, + ) + .expect("abweichend"); + assert_eq!(r.name, "Kraus Heike"); + assert!(r.phones.is_empty()); + } +}