feat(report): abweichenden Empfänger im Lieferbericht ausweisen
Weicht Lieferadresse oder Empfänger (ERP-LieferAdressId) vom Besteller ab, führt der Abschnitt „Kunde & Lieferadresse" Besteller und Empfänger getrennt auf — jeweils mit Anschrift und Telefonnummern — plus Hinweis „Abweichende Lieferadresse". Sonst bleibt der Abschnitt unverändert. Erkennung wie in der App: andere Anschrift ODER anderer Name an der Lieferadresse (gegen die Belegadresse), Schreibweise egal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@ -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<ReportRecipient>,
|
||||
pub desired_time: Option<String>,
|
||||
pub special_agreements: Option<String>,
|
||||
pub prepaid_amount: f64,
|
||||
@ -48,6 +53,16 @@ pub struct DeliveryReportData {
|
||||
pub driver_signature_png: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Empfänger vor Ort, der vom Besteller abweicht, samt Kontaktdaten beider.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReportRecipient {
|
||||
pub name: String,
|
||||
pub phones: Vec<String>,
|
||||
/// Anschrift des Bestellers (Kundenstamm), einzeilig.
|
||||
pub orderer_address: String,
|
||||
pub orderer_phones: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReportContact {
|
||||
pub name: String,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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<String>,
|
||||
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<String>,
|
||||
snap_house_number: Option<String>,
|
||||
snap_postal_code: Option<String>,
|
||||
@ -93,6 +98,30 @@ struct ContactRow {
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
/// Belegadresse (`header`) oder Lieferadresse (`delivery`) aus dem ERP-Sync,
|
||||
/// mit ihren Telefon-/Mobilnummern.
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ContactSourceRow {
|
||||
role: String,
|
||||
name1: Option<String>,
|
||||
name2: Option<String>,
|
||||
name3: Option<String>,
|
||||
phones: Vec<String>,
|
||||
}
|
||||
|
||||
impl ContactSourceRow {
|
||||
/// `Name1..3` ohne Anrede/Titel, leere Teile übersprungen.
|
||||
fn full_name(&self) -> Option<String> {
|
||||
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<Utc>,
|
||||
@ -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<ContactSourceRow> = 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<CompletionRow> = 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::<Vec<_>>().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<ReportRecipient> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user