//! 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: E) -> ApplicationError { ApplicationError::Repository(e.to_string()) } #[derive(sqlx::FromRow)] struct HeadRow { erp_belegart_id: i64, erp_belegart_code: Option, erp_belegart_name: Option, erp_belegnummer: String, state: String, tour_date: NaiveDate, account_id: i64, driver_name: String, car_plate: Option, payment_method: Option, erp_customer_id: i64, customer_name: String, snap_street: Option, snap_house_number: Option, snap_postal_code: Option, snap_city: Option, snap_country: Option, desired_time: Option, special_agreements: Option, prepaid_amount: f64, } #[derive(sqlx::FromRow)] struct ItemRow { belegzeilen_nr: i32, komponenten_artikel_nr: Option, parent_artikel_nr: Option, article_number: String, name: String, required_quantity: i32, credited_quantity: i32, scanned_quantity: i32, scan_status: String, unit_price: f64, warehouse_code: Option, warehouse_name: Option, } #[derive(sqlx::FromRow)] struct ServiceRow { name: String, bool_value: Option, numeric_value: Option, } #[derive(sqlx::FromRow)] struct NoteRow { created_at: DateTime, author_personalnummer: i64, text: Option, image_attachment: Option, is_amount_credit_note: bool, } #[derive(sqlx::FromRow)] struct ContactRow { name: String, phone: Option, email: Option, } #[derive(sqlx::FromRow)] struct CompletionRow { completed_at: DateTime, 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, } #[derive(sqlx::FromRow)] struct ScanAuditRow { server_recorded_at: DateTime, client_scanned_at: DateTime, action: String, delta: i32, resulting_quantity: i32, resulting_status: String, reason: Option, manual: bool, credit_delta: Option, actor_personalnummer: i64, belegzeilen_nr: i32, komponenten_artikel_nr: Option, article_name: Option, } #[derive(sqlx::FromRow)] struct CreditAuditRow { recorded_at: DateTime, action: String, amount_cents: i64, reason: Option, author_personalnummer: i64, } #[derive(sqlx::FromRow)] struct AttachmentRow { filename: Option, reference: String, mime_type: String, size_bytes: i64, width: Option, height: Option, uploaded_at: DateTime, uploaded_by: i64, } fn one_line_address( street: Option, house: Option, plz: Option, city: Option, country: Option, ) -> String { let line1 = [street, house] .into_iter() .flatten() .filter(|s| !s.trim().is_empty()) .collect::>() .join(" "); let line2 = [plz, city] .into_iter() .flatten() .filter(|s| !s.trim().is_empty()) .collect::>() .join(" "); [line1, line2, country.unwrap_or_default()] .into_iter() .filter(|s| !s.trim().is_empty()) .collect::>() .join(", ") } #[async_trait] impl DeliveryReportRepository for PgDeliveryReportRepository { async fn load( &self, delivery_id: Uuid, ) -> Result, ApplicationError> { // --- Kopf --- let head: Option = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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::>() .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, })) } }