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:
Dennis Nemec
2026-09-25 15:57:11 +02:00
parent 7cb054cf6c
commit af6d522290
4 changed files with 254 additions and 10 deletions

View File

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

View File

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

View File

@ -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() {

View File

@ -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());
}
}