Konsistent zur App-Umbenennung (Step "Services" → "Checkliste"): die Report-Sektion für delivery_services heißt im PDF jetzt "Checkliste". Nur Anzeige/Wording (+ Kommentare), Datenmodell unverändert. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
486 lines
16 KiB
Rust
486 lines
16 KiB
Rust
//! Postgres-Implementierung von `DeliveryReportRepository`.
|
|
//!
|
|
//! Sammelt mit mehreren SELECTs alle Daten einer Lieferung inkl. beider
|
|
//! Audit-Trails (`scan_audit`, `delivery_credit_audit`) zum `DeliveryReportData`.
|
|
//! Bild-Bytes werden hier NICHT geladen (macht der Use Case).
|
|
|
|
use async_trait::async_trait;
|
|
use chrono::{DateTime, NaiveDate, Utc};
|
|
use sqlx::PgPool;
|
|
use uuid::Uuid;
|
|
|
|
use holzleitner_application::dto::{
|
|
DeliveryReportData, ReportAttachment, ReportCompletion, ReportContact, ReportCreditAudit,
|
|
ReportItem, ReportNote, ReportScanAudit, ReportService,
|
|
};
|
|
use holzleitner_application::error::ApplicationError;
|
|
use holzleitner_application::ports::DeliveryReportRepository;
|
|
|
|
pub struct PgDeliveryReportRepository {
|
|
pool: PgPool,
|
|
}
|
|
|
|
impl PgDeliveryReportRepository {
|
|
pub fn new(pool: PgPool) -> Self {
|
|
Self { pool }
|
|
}
|
|
}
|
|
|
|
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
|
ApplicationError::Repository(e.to_string())
|
|
}
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct HeadRow {
|
|
erp_belegart_id: i64,
|
|
erp_belegart_code: Option<String>,
|
|
erp_belegart_name: Option<String>,
|
|
erp_belegnummer: String,
|
|
state: String,
|
|
tour_date: NaiveDate,
|
|
account_id: i64,
|
|
driver_name: String,
|
|
car_plate: Option<String>,
|
|
payment_method: Option<String>,
|
|
erp_customer_id: i64,
|
|
customer_name: String,
|
|
snap_street: Option<String>,
|
|
snap_house_number: Option<String>,
|
|
snap_postal_code: Option<String>,
|
|
snap_city: Option<String>,
|
|
snap_country: Option<String>,
|
|
desired_time: Option<String>,
|
|
special_agreements: Option<String>,
|
|
prepaid_amount: f64,
|
|
}
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct ItemRow {
|
|
belegzeilen_nr: i32,
|
|
komponenten_artikel_nr: Option<String>,
|
|
parent_artikel_nr: Option<String>,
|
|
article_number: String,
|
|
name: String,
|
|
required_quantity: i32,
|
|
credited_quantity: i32,
|
|
scanned_quantity: i32,
|
|
scan_status: String,
|
|
unit_price: f64,
|
|
warehouse_code: Option<String>,
|
|
warehouse_name: Option<String>,
|
|
}
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct ServiceRow {
|
|
name: String,
|
|
bool_value: Option<bool>,
|
|
numeric_value: Option<i32>,
|
|
}
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct NoteRow {
|
|
created_at: DateTime<Utc>,
|
|
author_personalnummer: i64,
|
|
text: Option<String>,
|
|
image_attachment: Option<String>,
|
|
is_amount_credit_note: bool,
|
|
}
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct ContactRow {
|
|
name: String,
|
|
phone: Option<String>,
|
|
email: Option<String>,
|
|
}
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct CompletionRow {
|
|
completed_at: DateTime<Utc>,
|
|
completed_by_personalnummer: i64,
|
|
receipt_confirmed: bool,
|
|
notes_acknowledged: bool,
|
|
customer_signature_path: String,
|
|
driver_signature_path: String,
|
|
payment_collected: bool,
|
|
collected_amount_cents: Option<i64>,
|
|
}
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct ScanAuditRow {
|
|
server_recorded_at: DateTime<Utc>,
|
|
client_scanned_at: DateTime<Utc>,
|
|
action: String,
|
|
delta: i32,
|
|
resulting_quantity: i32,
|
|
resulting_status: String,
|
|
reason: Option<String>,
|
|
manual: bool,
|
|
credit_delta: Option<i32>,
|
|
actor_personalnummer: i64,
|
|
belegzeilen_nr: i32,
|
|
komponenten_artikel_nr: Option<String>,
|
|
article_name: Option<String>,
|
|
}
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct CreditAuditRow {
|
|
recorded_at: DateTime<Utc>,
|
|
action: String,
|
|
amount_cents: i64,
|
|
reason: Option<String>,
|
|
author_personalnummer: i64,
|
|
}
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct AttachmentRow {
|
|
filename: Option<String>,
|
|
reference: String,
|
|
mime_type: String,
|
|
size_bytes: i64,
|
|
width: Option<i32>,
|
|
height: Option<i32>,
|
|
uploaded_at: DateTime<Utc>,
|
|
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]
|
|
impl DeliveryReportRepository for PgDeliveryReportRepository {
|
|
async fn load(
|
|
&self,
|
|
delivery_id: Uuid,
|
|
) -> Result<Option<DeliveryReportData>, ApplicationError> {
|
|
// --- Kopf ---
|
|
let head: Option<HeadRow> = sqlx::query_as(
|
|
r#"
|
|
SELECT d.erp_belegart_id, d.erp_belegart_code, d.erp_belegart_name,
|
|
d.erp_belegnummer, d.state,
|
|
t.tour_date, t.account_id,
|
|
acc.name AS driver_name,
|
|
car.plate AS car_plate,
|
|
pm.name AS payment_method,
|
|
c.erp_customer_id, c.name AS customer_name,
|
|
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
|
|
FROM deliveries d
|
|
JOIN tours t ON t.id = d.tour_id
|
|
JOIN accounts acc ON acc.personalnummer = t.account_id
|
|
LEFT JOIN cars car ON car.id = d.assigned_car_id
|
|
LEFT JOIN payment_methods pm ON pm.id = d.payment_method_id
|
|
JOIN customers c ON c.id = d.customer_id
|
|
WHERE d.id = $1
|
|
"#,
|
|
)
|
|
.bind(delivery_id)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.map_err(db)?;
|
|
|
|
let Some(head) = head else {
|
|
return Ok(None);
|
|
};
|
|
|
|
// --- Positionen ---
|
|
let items: Vec<ItemRow> = sqlx::query_as(
|
|
r#"
|
|
SELECT di.belegzeilen_nr, di.komponenten_artikel_nr, di.parent_artikel_nr,
|
|
a.article_number, a.name,
|
|
di.required_quantity, di.credited_quantity, di.scanned_quantity,
|
|
di.scan_status, di.unit_price,
|
|
w.code AS warehouse_code, w.name AS warehouse_name
|
|
FROM delivery_items di
|
|
JOIN articles a ON a.id = di.article_id
|
|
LEFT JOIN warehouses w ON w.id = di.warehouse_id
|
|
WHERE di.delivery_id = $1
|
|
ORDER BY di.belegzeilen_nr,
|
|
di.komponenten_artikel_nr NULLS FIRST
|
|
"#,
|
|
)
|
|
.bind(delivery_id)
|
|
.fetch_all(&self.pool)
|
|
.await
|
|
.map_err(db)?;
|
|
|
|
// --- Checkliste (delivery_services) ---
|
|
let services: Vec<ServiceRow> = sqlx::query_as(
|
|
r#"
|
|
SELECT s.name, ds.bool_value, ds.numeric_value
|
|
FROM delivery_services ds
|
|
JOIN services s ON s.id = ds.service_id
|
|
WHERE ds.delivery_id = $1
|
|
ORDER BY s.sort_order, s.name
|
|
"#,
|
|
)
|
|
.bind(delivery_id)
|
|
.fetch_all(&self.pool)
|
|
.await
|
|
.map_err(db)?;
|
|
|
|
// --- Notizen ---
|
|
let notes: Vec<NoteRow> = sqlx::query_as(
|
|
r#"
|
|
SELECT created_at, author_personalnummer, text, image_attachment,
|
|
is_amount_credit_note
|
|
FROM delivery_notes
|
|
WHERE delivery_id = $1
|
|
ORDER BY created_at
|
|
"#,
|
|
)
|
|
.bind(delivery_id)
|
|
.fetch_all(&self.pool)
|
|
.await
|
|
.map_err(db)?;
|
|
|
|
// --- Ansprechpartner ---
|
|
let contacts: Vec<ContactRow> = sqlx::query_as(
|
|
r#"
|
|
SELECT cc.name, cc.phone, cc.email
|
|
FROM delivery_contact_persons dcp
|
|
JOIN customer_contacts cc ON cc.id = dcp.customer_contact_id
|
|
WHERE dcp.delivery_id = $1
|
|
ORDER BY cc.name
|
|
"#,
|
|
)
|
|
.bind(delivery_id)
|
|
.fetch_all(&self.pool)
|
|
.await
|
|
.map_err(db)?;
|
|
|
|
// --- Abschluss ---
|
|
let completion: Option<CompletionRow> = sqlx::query_as(
|
|
r#"
|
|
SELECT completed_at, completed_by_personalnummer, receipt_confirmed,
|
|
notes_acknowledged, customer_signature_path, driver_signature_path,
|
|
payment_collected, collected_amount_cents
|
|
FROM delivery_completions
|
|
WHERE delivery_id = $1
|
|
"#,
|
|
)
|
|
.bind(delivery_id)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.map_err(db)?;
|
|
|
|
// --- Audit: Scan/Belade-Verlauf ---
|
|
let scan_audit: Vec<ScanAuditRow> = sqlx::query_as(
|
|
r#"
|
|
SELECT sa.server_recorded_at, sa.client_scanned_at, sa.action, sa.delta,
|
|
sa.resulting_quantity, sa.resulting_status, sa.reason, sa.manual,
|
|
sa.credit_delta, sa.actor_personalnummer,
|
|
sa.erp_belegzeilen_nr AS belegzeilen_nr,
|
|
sa.erp_komponenten_artikel_nr AS komponenten_artikel_nr,
|
|
a.name AS article_name
|
|
FROM scan_audit sa
|
|
JOIN delivery_items di ON di.id = sa.delivery_item_id
|
|
LEFT JOIN articles a ON a.id = di.article_id
|
|
WHERE di.delivery_id = $1
|
|
ORDER BY sa.server_recorded_at
|
|
"#,
|
|
)
|
|
.bind(delivery_id)
|
|
.fetch_all(&self.pool)
|
|
.await
|
|
.map_err(db)?;
|
|
|
|
// --- Audit: Gutschrift-Verlauf ---
|
|
let credit_audit: Vec<CreditAuditRow> = sqlx::query_as(
|
|
r#"
|
|
SELECT recorded_at, action, amount_cents, reason, author_personalnummer
|
|
FROM delivery_credit_audit
|
|
WHERE delivery_id = $1
|
|
ORDER BY recorded_at
|
|
"#,
|
|
)
|
|
.bind(delivery_id)
|
|
.fetch_all(&self.pool)
|
|
.await
|
|
.map_err(db)?;
|
|
|
|
// --- Anhänge ---
|
|
let attachments: Vec<AttachmentRow> = sqlx::query_as(
|
|
r#"
|
|
SELECT filename, docuframe_object_id AS reference, mime_type, size_bytes,
|
|
width, height, uploaded_at, uploaded_by
|
|
FROM attachments
|
|
WHERE delivery_id = $1
|
|
ORDER BY uploaded_at
|
|
"#,
|
|
)
|
|
.bind(delivery_id)
|
|
.fetch_all(&self.pool)
|
|
.await
|
|
.map_err(db)?;
|
|
|
|
// Aktuelle Gutschrift = Wirkung des letzten Events (append-only).
|
|
let current_credit_cents = credit_audit
|
|
.last()
|
|
.map(|e| {
|
|
if e.action.eq_ignore_ascii_case("set") {
|
|
e.amount_cents
|
|
} else {
|
|
0
|
|
}
|
|
})
|
|
.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,
|
|
);
|
|
|
|
Ok(Some(DeliveryReportData {
|
|
generated_at: Utc::now(),
|
|
belegart_id: head.erp_belegart_id,
|
|
belegart_code: head.erp_belegart_code,
|
|
belegart_name: head.erp_belegart_name,
|
|
belegnummer: head.erp_belegnummer,
|
|
state: head.state,
|
|
tour_date: head.tour_date,
|
|
driver_personalnummer: head.account_id,
|
|
driver_name: head.driver_name,
|
|
car_plate: head.car_plate,
|
|
payment_method: head.payment_method,
|
|
customer_number: head.erp_customer_id,
|
|
customer_name: head.customer_name,
|
|
address,
|
|
desired_time: head.desired_time,
|
|
special_agreements: head.special_agreements,
|
|
prepaid_amount: head.prepaid_amount,
|
|
current_credit_cents,
|
|
contacts: contacts
|
|
.into_iter()
|
|
.map(|c| {
|
|
let detail = [c.phone, c.email]
|
|
.into_iter()
|
|
.flatten()
|
|
.filter(|s| !s.trim().is_empty())
|
|
.collect::<Vec<_>>()
|
|
.join(" · ");
|
|
ReportContact {
|
|
name: c.name,
|
|
detail: if detail.is_empty() { None } else { Some(detail) },
|
|
}
|
|
})
|
|
.collect(),
|
|
items: items
|
|
.into_iter()
|
|
.map(|i| ReportItem {
|
|
belegzeilen_nr: i.belegzeilen_nr,
|
|
komponenten_artikel_nr: i.komponenten_artikel_nr,
|
|
parent_artikel_nr: i.parent_artikel_nr,
|
|
article_number: i.article_number,
|
|
name: i.name,
|
|
required_quantity: i.required_quantity,
|
|
credited_quantity: i.credited_quantity,
|
|
scanned_quantity: i.scanned_quantity,
|
|
scan_status: i.scan_status,
|
|
unit_price: i.unit_price,
|
|
warehouse_code: i.warehouse_code,
|
|
warehouse_name: i.warehouse_name,
|
|
})
|
|
.collect(),
|
|
services: services
|
|
.into_iter()
|
|
.map(|s| ReportService {
|
|
name: s.name,
|
|
bool_value: s.bool_value,
|
|
numeric_value: s.numeric_value,
|
|
})
|
|
.collect(),
|
|
notes: notes
|
|
.into_iter()
|
|
.map(|n| ReportNote {
|
|
created_at: n.created_at,
|
|
author_personalnummer: n.author_personalnummer,
|
|
text: n.text,
|
|
image_attachment: n.image_attachment,
|
|
is_amount_credit_note: n.is_amount_credit_note,
|
|
})
|
|
.collect(),
|
|
completion: completion.map(|c| ReportCompletion {
|
|
completed_at: c.completed_at,
|
|
completed_by_personalnummer: c.completed_by_personalnummer,
|
|
receipt_confirmed: c.receipt_confirmed,
|
|
notes_acknowledged: c.notes_acknowledged,
|
|
customer_signature_path: c.customer_signature_path,
|
|
driver_signature_path: c.driver_signature_path,
|
|
payment_collected: c.payment_collected,
|
|
collected_amount_cents: c.collected_amount_cents,
|
|
}),
|
|
scan_audit: scan_audit
|
|
.into_iter()
|
|
.map(|s| ReportScanAudit {
|
|
server_recorded_at: s.server_recorded_at,
|
|
client_scanned_at: s.client_scanned_at,
|
|
action: s.action,
|
|
delta: s.delta,
|
|
resulting_quantity: s.resulting_quantity,
|
|
resulting_status: s.resulting_status,
|
|
reason: s.reason,
|
|
manual: s.manual,
|
|
credit_delta: s.credit_delta,
|
|
actor_personalnummer: s.actor_personalnummer,
|
|
belegzeilen_nr: s.belegzeilen_nr,
|
|
komponenten_artikel_nr: s.komponenten_artikel_nr,
|
|
article_name: s.article_name,
|
|
})
|
|
.collect(),
|
|
credit_audit: credit_audit
|
|
.into_iter()
|
|
.map(|c| ReportCreditAudit {
|
|
recorded_at: c.recorded_at,
|
|
action: c.action,
|
|
amount_cents: c.amount_cents,
|
|
reason: c.reason,
|
|
author_personalnummer: c.author_personalnummer,
|
|
})
|
|
.collect(),
|
|
attachments: attachments
|
|
.into_iter()
|
|
.map(|a| ReportAttachment {
|
|
filename: a.filename,
|
|
reference: a.reference,
|
|
mime_type: a.mime_type,
|
|
size_bytes: a.size_bytes,
|
|
width: a.width,
|
|
height: a.height,
|
|
uploaded_at: a.uploaded_at,
|
|
uploaded_by: a.uploaded_by,
|
|
bytes: None,
|
|
})
|
|
.collect(),
|
|
customer_signature_png: None,
|
|
driver_signature_png: None,
|
|
}))
|
|
}
|
|
}
|