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
|
// Kunde + Adresse
|
||||||
pub customer_number: i64,
|
pub customer_number: i64,
|
||||||
pub customer_name: String,
|
pub customer_name: String,
|
||||||
|
/// Lieferadresse (Snapshot am Beleg), einzeilig.
|
||||||
pub address: String,
|
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 desired_time: Option<String>,
|
||||||
pub special_agreements: Option<String>,
|
pub special_agreements: Option<String>,
|
||||||
pub prepaid_amount: f64,
|
pub prepaid_amount: f64,
|
||||||
@ -48,6 +53,16 @@ pub struct DeliveryReportData {
|
|||||||
pub driver_signature_png: Option<Vec<u8>>,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ReportContact {
|
pub struct ReportContact {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|||||||
@ -33,7 +33,7 @@ pub use complete::CompleteDeliveryAcknowledgements;
|
|||||||
pub use credit::{CreditAction, DeliveryCreditEventRequest, DeliveryCreditResponse};
|
pub use credit::{CreditAction, DeliveryCreditEventRequest, DeliveryCreditResponse};
|
||||||
pub use delivery_report::{
|
pub use delivery_report::{
|
||||||
DeliveryReportData, ReportAttachment, ReportCompletion, ReportContact, ReportCreditAudit, ReportPayment,
|
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_action::{CancelDeliveryRequest, DeliveryResponse, HoldDeliveryRequest};
|
||||||
pub use delivery_details::DeliveryDetails;
|
pub use delivery_details::DeliveryDetails;
|
||||||
|
|||||||
@ -469,8 +469,33 @@ impl DeliveryReportRenderer for PdfDeliveryReportRenderer {
|
|||||||
|
|
||||||
// 2. Kunde & Lieferadresse
|
// 2. Kunde & Lieferadresse
|
||||||
p.heading("Kunde & Lieferadresse");
|
p.heading("Kunde & Lieferadresse");
|
||||||
|
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("Kunde", &format!("{} (Nr. {})", d.customer_name, d.customer_number));
|
||||||
p.kv("Adresse", &d.address);
|
p.kv("Adresse", &d.address);
|
||||||
|
}
|
||||||
|
}
|
||||||
p.kv("Wunschzeit", opt(&d.desired_time));
|
p.kv("Wunschzeit", opt(&d.desired_time));
|
||||||
if let Some(sa) = &d.special_agreements {
|
if let Some(sa) = &d.special_agreements {
|
||||||
if !sa.trim().is_empty() {
|
if !sa.trim().is_empty() {
|
||||||
|
|||||||
@ -11,7 +11,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use holzleitner_application::dto::{
|
use holzleitner_application::dto::{
|
||||||
DeliveryReportData, ReportAttachment, ReportCompletion, ReportContact, ReportCreditAudit, ReportPayment,
|
DeliveryReportData, ReportAttachment, ReportCompletion, ReportContact, ReportCreditAudit, ReportPayment,
|
||||||
ReportItem, ReportNote, ReportScanAudit, ReportService,
|
ReportItem, ReportNote, ReportRecipient, ReportScanAudit, ReportService,
|
||||||
};
|
};
|
||||||
use holzleitner_application::error::ApplicationError;
|
use holzleitner_application::error::ApplicationError;
|
||||||
use holzleitner_application::ports::DeliveryReportRepository;
|
use holzleitner_application::ports::DeliveryReportRepository;
|
||||||
@ -44,6 +44,11 @@ struct HeadRow {
|
|||||||
payment_method: Option<String>,
|
payment_method: Option<String>,
|
||||||
erp_customer_id: i64,
|
erp_customer_id: i64,
|
||||||
customer_name: String,
|
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_street: Option<String>,
|
||||||
snap_house_number: Option<String>,
|
snap_house_number: Option<String>,
|
||||||
snap_postal_code: Option<String>,
|
snap_postal_code: Option<String>,
|
||||||
@ -93,6 +98,30 @@ struct ContactRow {
|
|||||||
email: Option<String>,
|
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)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct CompletionRow {
|
struct CompletionRow {
|
||||||
completed_at: DateTime<Utc>,
|
completed_at: DateTime<Utc>,
|
||||||
@ -198,6 +227,9 @@ impl DeliveryReportRepository for PgDeliveryReportRepository {
|
|||||||
car.plate AS car_plate,
|
car.plate AS car_plate,
|
||||||
pm.name AS payment_method,
|
pm.name AS payment_method,
|
||||||
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.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_street, d.snap_house_number, d.snap_postal_code,
|
||||||
d.snap_city, d.snap_country,
|
d.snap_city, d.snap_country,
|
||||||
d.desired_time, d.special_agreements, d.prepaid_amount
|
d.desired_time, d.special_agreements, d.prepaid_amount
|
||||||
@ -285,6 +317,26 @@ impl DeliveryReportRepository for PgDeliveryReportRepository {
|
|||||||
.await
|
.await
|
||||||
.map_err(db)?;
|
.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 ---
|
// --- Abschluss ---
|
||||||
let completion: Option<CompletionRow> = sqlx::query_as(
|
let completion: Option<CompletionRow> = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
@ -380,13 +432,28 @@ impl DeliveryReportRepository for PgDeliveryReportRepository {
|
|||||||
})
|
})
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let address = one_line_address(
|
let snap = AddressParts {
|
||||||
head.snap_street,
|
street: head.snap_street.unwrap_or_default(),
|
||||||
head.snap_house_number,
|
house_number: head.snap_house_number.unwrap_or_default(),
|
||||||
head.snap_postal_code,
|
postal_code: head.snap_postal_code.unwrap_or_default(),
|
||||||
head.snap_city,
|
city: head.snap_city.unwrap_or_default(),
|
||||||
head.snap_country,
|
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 {
|
Ok(Some(DeliveryReportData {
|
||||||
generated_at: Utc::now(),
|
generated_at: Utc::now(),
|
||||||
@ -403,6 +470,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,
|
||||||
|
different_recipient,
|
||||||
desired_time: head.desired_time,
|
desired_time: head.desired_time,
|
||||||
special_agreements: head.special_agreements,
|
special_agreements: head.special_agreements,
|
||||||
prepaid_amount: head.prepaid_amount,
|
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