diff --git a/Cargo.lock b/Cargo.lock index 82cd6a4..ca71d94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,6 +91,50 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "askama" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" +dependencies = [ + "askama_derive", + "askama_escape", + "humansize", + "num-traits", + "percent-encoding", +] + +[[package]] +name = "askama_derive" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" +dependencies = [ + "askama_parser", + "basic-toml", + "mime", + "mime_guess", + "proc-macro2", + "quote", + "serde", + "syn", +] + +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + +[[package]] +name = "askama_parser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -165,7 +209,7 @@ dependencies = [ "anyhow", "arrayvec", "log", - "nom", + "nom 8.0.0", "num-rational", "v_frame", ] @@ -250,6 +294,15 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bit_field" version = "0.10.3" @@ -962,6 +1015,7 @@ name = "holzleitner-api" version = "0.1.0" dependencies = [ "anyhow", + "askama", "axum", "chrono", "holzleitner-application", @@ -1087,6 +1141,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1594,6 +1657,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1657,6 +1726,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nom" version = "8.0.0" diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index f70b6cd..13671ef 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -31,6 +31,8 @@ anyhow.workspace = true toml.workspace = true sqlx.workspace = true tokio-cron-scheduler.workspace = true +# Compile-time HTML-Templating für die Admin-Detailansicht (nur hier genutzt). +askama = "0.12" # Windows-Dienst-Integration (SCM). Nur unter Windows kompiliert; auf # anderen Plattformen (z. B. Mac für den Kompiliertest) ausgeblendet. diff --git a/crates/api/src/routes/admin.rs b/crates/api/src/routes/admin.rs index 459d590..2804346 100644 --- a/crates/api/src/routes/admin.rs +++ b/crates/api/src/routes/admin.rs @@ -39,6 +39,10 @@ pub fn router() -> Router { get(positions_modified), ) .route("/admin/belege/{belegnummer}", get(belege_details)) + .route( + "/admin/belege/{belegnummer}/html", + get(super::belege_view::belege_details_html), + ) .route("/admin/mark-mail-sent", post(mark_mail_sent)) .route("/admin/reviews", get(list_reviews)) .route("/admin/reviews/{delivery_id}/resolve", post(resolve_review)) diff --git a/crates/api/src/routes/belege_view.rs b/crates/api/src/routes/belege_view.rs new file mode 100644 index 0000000..9078002 --- /dev/null +++ b/crates/api/src/routes/belege_view.rs @@ -0,0 +1,373 @@ +//! 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); + 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) -> 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), + 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()), + } +} + +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); + } + if !a.country.trim().is_empty() { + lines.push(a.country.clone()); + } + lines +} + +fn fmt_date(d: chrono::NaiveDate) -> String { + d.format("%d.%m.%Y").to_string() +} + +fn fmt_datetime(dt: chrono::DateTime) -> String { + dt.with_timezone(&chrono::Local) + .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", + } +} diff --git a/crates/api/src/routes/mod.rs b/crates/api/src/routes/mod.rs index a697efa..e49d906 100644 --- a/crates/api/src/routes/mod.rs +++ b/crates/api/src/routes/mod.rs @@ -4,6 +4,7 @@ pub mod accounts; pub mod admin; pub mod attachments; +pub mod belege_view; pub mod cars; pub mod deliveries; pub mod dev; diff --git a/crates/api/templates/delivery_details.html b/crates/api/templates/delivery_details.html new file mode 100644 index 0000000..f3ae745 --- /dev/null +++ b/crates/api/templates/delivery_details.html @@ -0,0 +1,220 @@ + + + + + +Beleg {{ belegnummer }} – Lieferdetails + + + +
+
+
+

Beleg {{ belegnummer }}

+
{{ customer_name }} · Tour {{ tour_date }}
+
+
+ {{ state_label }} + {% if positions_modified %}Positionen verändert{% endif %} +
+
+ +
+
+

Übersicht

+
+
Status
{{ state_label }}
+
Tourdatum
{{ tour_date }}
+
Position in Tour
{{ sort_order }}
+
Wunschzeit
{{ desired_time }}
+
Sondervereinbarung
{{ special_agreements }}
+
Vorausbezahlt
{{ prepaid }}
+
Warenwert (geliefert)
{{ total }}
+
+
+ +
+

Kunde

+
+
Name
{{ customer_name }}
+
ERP-Kundennr.
{{ erp_customer_id }}
+
+
+ {% for line in customer_address_lines %}{{ line }}
{% endfor %} +
+ {% if !customer_contacts.is_empty() %} +
+ {% for c in customer_contacts %} +
+ {{ c.name }} + {% if !c.phone.is_empty() %}
☎ {{ c.phone }}
{% endif %} + {% if !c.email.is_empty() %}
✉ {{ c.email }}
{% endif %} +
+ {% endfor %} +
+ {% endif %} +
+ +
+

Lieferadresse

+
+ {% for line in delivery_address_lines %}{{ line }}
{% endfor %} +
+
+ +
+

Geld-Gutschrift

+ {% if has_credit %} +
+
Betrag
{{ credit_amount }}
+
Grund
{{ credit_reason }}
+
+ {% else %} +
Keine Geld-Gutschrift
+ {% endif %} +
+ +
+

Positionen

+ + + + + + + + + + + {% for it in items %} + + + + + + + + + + + + + {% endfor %} + + + + +
Art.-Nr.BezeichnungLagerSollGescanntGutschr.GeliefertEinzelpreisSummeStatus
{{ it.article_number }}{{ it.article_name }}{% if !it.held_reason.is_empty() %}
{{ it.held_reason }}{% endif %}
{{ it.warehouse }}{{ it.required }}{{ it.scanned }}{{ it.credited }}{{ it.delivered }}{{ it.unit_price }}{{ it.line_total }}{{ it.status_label }}
Warenwert (geliefert){{ total }}
+
+ +
+

Dienstleistungen

+ {% if services.is_empty() %} +
Keine Dienstleistungen gesetzt
+ {% else %} +
+ {% for s in services %}
{{ s.name }}
{{ s.value }}
{% endfor %} +
+ {% endif %} +
+ +
+

Notizen

+ {% if notes.is_empty() %} +
Keine Notizen
+ {% else %} + {% for n in notes %} +
+
{{ n.text }}
+
+ #{{ n.author }} · {{ n.created_at }} + {% if n.credit_note %} · Gutschrift-Notiz{% endif %} + {% if n.image %} · 📎 Bild{% endif %} +
+
+ {% endfor %} + {% endif %} +
+ +
+

Kontakte

+ {% if contacts.is_empty() %} +
Keine Kontaktquellen
+ {% else %} + {% for c in contacts %} +
+
{{ c.role }}
+ {{ c.name }} + {% for ch in c.channels %}
{{ ch.kind }}: {{ ch.value }}
{% endfor %} +
+ {% endfor %} + {% endif %} +
+
+ + +
+ +