//! Admin-HTML-Ansicht einer Lieferung (`GET /admin/belege/{belegnummer}/html`). //! //! Rendert dieselben Daten wie der JSON-Detail-Endpunkt, aber als schön //! kategorisierte HTML-Seite (askama, compile-time-Template). Der Handler //! destilliert das `DeliveryDetails`-Aggregat in ein flaches, anzeige-fertiges //! View-Modell — die Template-Datei bleibt frei von Domain-/Lookup-Logik. use askama::Template; use axum::extract::{Path, State}; use axum::response::Html; use holzleitner_application::dto::DeliveryDetails; use holzleitner_application::error::ApplicationError; use holzleitner_domain::{ Address, ContactKind, ContactRole, DeliveryState, ScanStatus, }; use crate::error::ApiError; use crate::state::AppState; /// `GET /admin/belege/{belegnummer}/html` — Detailansicht als HTML. pub async fn belege_details_html( State(state): State, Path(belegnummer): Path, ) -> Result, ApiError> { tracing::info!(%belegnummer, "admin.belege_details_html"); let details = state.get_delivery_details.execute(&belegnummer).await?; let page = build_page(details, state.timezone); let html = page.render().map_err(|e| { ApiError(ApplicationError::Unexpected(format!( "template-render fehlgeschlagen: {e}" ))) })?; Ok(Html(html)) } // ===== View-Modell ========================================================= #[derive(Template)] #[template(path = "delivery_details.html")] struct DeliveryDetailsPage { belegnummer: String, state_label: String, state_class: String, positions_modified: bool, tour_date: String, sort_order: i32, desired_time: String, special_agreements: String, prepaid: String, total: String, customer_name: String, erp_customer_id: i64, customer_address_lines: Vec, delivery_address_lines: Vec, customer_contacts: Vec, items: Vec, has_credit: bool, credit_amount: String, credit_reason: String, services: Vec, notes: Vec, contacts: Vec, generated_at: String, } struct ItemVm { article_number: String, article_name: String, warehouse: String, required: i32, scanned: i32, credited: i32, delivered: i32, unit_price: String, line_total: String, status_label: String, status_class: String, held_reason: String, modified: bool, } struct ContactVm { name: String, phone: String, email: String, } struct ServiceVm { name: String, value: String, } struct NoteVm { text: String, author: i64, created_at: String, credit_note: bool, image: bool, } struct ChannelVm { kind: String, value: String, } struct ContactSourceVm { role: String, name: String, channels: Vec, } // ===== Assembly ============================================================ fn build_page(d: DeliveryDetails, tz: chrono_tz::Tz) -> DeliveryDetailsPage { let delivery = &d.delivery.delivery; // Positionen: Artikel-/Lagernamen auflösen, Mengen/Preise formatieren. let mut total = 0.0_f64; let items: Vec = d .delivery .items .iter() .map(|it| { let delivered = (it.required_quantity - it.scan_state.credited_quantity).max(0); let line_total = it.unit_price * delivered as f64; total += line_total; let article = d.articles.iter().find(|a| a.id == it.article_id); let warehouse = d.warehouses.iter().find(|w| w.id == it.warehouse_id); ItemVm { article_number: article .map(|a| a.article_number.clone()) .unwrap_or_default(), article_name: article.map(|a| a.name.clone()).unwrap_or_default(), warehouse: warehouse.map(|w| w.name.clone()).unwrap_or_default(), required: it.required_quantity, scanned: it.scan_state.scanned_quantity, credited: it.scan_state.credited_quantity, delivered, unit_price: fmt_euro(it.unit_price), line_total: fmt_euro(line_total), status_label: scan_status_label(it.scan_state.status).to_string(), status_class: scan_status_class(it.scan_state.status).to_string(), held_reason: it.scan_state.held_reason.clone().unwrap_or_default(), modified: it.scan_state.credited_quantity > 0, } }) .collect(); // Dienstleistungen: gesetzte Werte mit ihrem Stammdaten-Namen anzeigen. let services: Vec = d .delivery_services .iter() .map(|v| { let def = d.services.iter().find(|s| s.id == v.service_id); let name = def.map(|s| s.name.clone()).unwrap_or_default(); let value = if let Some(b) = v.bool_value { if b { "Ja".to_string() } else { "Nein".to_string() } } else if let Some(n) = v.numeric_value { n.to_string() } else { "—".to_string() }; ServiceVm { name, value } }) .collect(); let notes: Vec = d .notes .iter() .map(|n| NoteVm { text: n.text.clone().unwrap_or_default(), author: n.author_personalnummer, created_at: fmt_datetime(n.created_at, tz), credit_note: n.is_amount_credit_note || n.credit_delivery_item_id.is_some(), image: n.image_attachment.is_some(), }) .collect(); // Kontaktquellen mit ihren Kanälen gruppieren. let contacts: Vec = d .contact_sources .iter() .map(|s| { let name = [ s.anrede.as_deref(), s.titel.as_deref(), s.name1.as_deref(), s.name2.as_deref(), s.name3.as_deref(), ] .into_iter() .flatten() .filter(|p| !p.trim().is_empty()) .collect::>() .join(" "); let channels = d .contact_channels .iter() .filter(|c| c.source_id == s.id) .map(|c| ChannelVm { kind: contact_kind_label(c.kind).to_string(), value: c.value.clone(), }) .collect(); ContactSourceVm { role: contact_role_label(s.role).to_string(), name, channels, } }) .collect(); let customer_contacts: Vec = d .customer_contacts .iter() .map(|c| ContactVm { name: c.name.clone(), phone: c.phone.clone().unwrap_or_default(), email: c.email.clone().unwrap_or_default(), }) .collect(); let (has_credit, credit_amount, credit_reason) = match &d.credit { Some(c) => (true, fmt_euro(c.amount_cents as f64 / 100.0), c.reason.clone()), None => (false, String::new(), String::new()), }; DeliveryDetailsPage { belegnummer: delivery.erp_belegnummer.clone(), state_label: state_label(delivery.state).to_string(), state_class: state_class(delivery.state).to_string(), positions_modified: items_modified(&items) || has_credit, tour_date: fmt_date(d.tour.date), sort_order: d.delivery.sort_order, desired_time: dash(delivery.desired_time.clone()), special_agreements: dash(delivery.special_agreements.clone()), prepaid: fmt_euro(delivery.prepaid_amount), total: fmt_euro(total), customer_name: d.customer.as_ref().map(|c| c.name.clone()).unwrap_or_default(), erp_customer_id: d.customer.as_ref().map(|c| c.erp_customer_id).unwrap_or(0), customer_address_lines: d .customer .as_ref() .map(|c| address_lines(&c.address)) .unwrap_or_default(), delivery_address_lines: address_lines(&delivery.delivery_address_snapshot), customer_contacts, items, has_credit, credit_amount, credit_reason, services, notes, contacts, generated_at: fmt_datetime(chrono::Utc::now(), tz), } } fn items_modified(items: &[ItemVm]) -> bool { items.iter().any(|i| i.modified) } // ===== Formatier-/Label-Helfer ============================================= /// Deutsche Euro-Formatierung: `1099.0` → `1.099,00 €`. fn fmt_euro(value: f64) -> String { let cents = (value * 100.0).round() as i64; let neg = cents < 0; let cents = cents.abs(); let euros = cents / 100; let rest = cents % 100; // Tausenderpunkte in die Euro-Zahl. let s = euros.to_string(); let mut grouped = String::new(); for (i, ch) in s.chars().enumerate() { if i > 0 && (s.len() - i) % 3 == 0 { grouped.push('.'); } grouped.push(ch); } format!("{}{},{:02} €", if neg { "-" } else { "" }, grouped, rest) } fn dash(v: Option) -> String { match v { Some(s) if !s.trim().is_empty() => s, _ => "—".to_string(), } } fn address_lines(a: &Address) -> Vec { let mut lines = Vec::new(); let street = format!("{} {}", a.street, a.house_number); let street = street.trim().to_string(); if !street.is_empty() { lines.push(street); } let city = format!("{} {}", a.postal_code, a.city); let city = city.trim().to_string(); if !city.is_empty() { lines.push(city); } // Land nur außerhalb Deutschlands; der Adresszusatz (Ortsteil, Etage, // Hinweis) als eigene, gekennzeichnete Zeile. if let Some(country) = a.foreign_country() { lines.push(country.to_string()); } if let Some(addition) = a.addition.as_deref().filter(|z| !z.trim().is_empty()) { lines.push(format!("Zusatz: {addition}")); } lines } fn fmt_date(d: chrono::NaiveDate) -> String { d.format("%d.%m.%Y").to_string() } /// UTC-Zeitstempel in der konfigurierten Zone (`server.timezone`) als /// `TT.MM.JJJJ HH:MM` — bewusst nicht `chrono::Local` (OS-Zone des Hosts). fn fmt_datetime(dt: chrono::DateTime, tz: chrono_tz::Tz) -> String { dt.with_timezone(&tz).format("%d.%m.%Y %H:%M").to_string() } fn state_label(s: DeliveryState) -> &'static str { match s { DeliveryState::Active => "Aktiv", DeliveryState::Held => "Gehalten", DeliveryState::Canceled => "Storniert", DeliveryState::Completed => "Abgeschlossen", } } fn state_class(s: DeliveryState) -> &'static str { match s { DeliveryState::Active => "active", DeliveryState::Held => "held", DeliveryState::Canceled => "canceled", DeliveryState::Completed => "completed", } } fn scan_status_label(s: ScanStatus) -> &'static str { match s { ScanStatus::InProgress => "In Arbeit", ScanStatus::Done => "Erledigt", ScanStatus::Held => "Zurückgestellt", ScanStatus::Removed => "Entfernt", } } fn scan_status_class(s: ScanStatus) -> &'static str { match s { ScanStatus::InProgress => "in_progress", ScanStatus::Done => "done", ScanStatus::Held => "held", ScanStatus::Removed => "removed", } } fn contact_role_label(r: ContactRole) -> &'static str { match r { ContactRole::Header => "Belegadresse", ContactRole::Delivery => "Lieferadresse", ContactRole::Billing => "Rechnungsadresse", ContactRole::ContactPerson => "Ansprechpartner", ContactRole::CustomerMaster => "Kundenstamm", } } fn contact_kind_label(k: ContactKind) -> &'static str { match k { ContactKind::Phone => "Telefon", ContactKind::Mobile => "Mobil", ContactKind::Email => "E-Mail", ContactKind::Web => "Web", } }