Files
Holzleitner---Backend--aktu…/crates/infrastructure/src/report/repository.rs
Dennis Nemec f4f534463d refactor(report): Lieferbericht-Sektion "Dienstleistungen" → "Checkliste"
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>
2026-07-10 23:32:50 +02:00

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,
}))
}
}