Files
Holzleitner---Backend--aktu…/crates/api/src/routes/belege_view.rs
Dennis Nemec c9d0ccfdef fix(sync): Lieferadresse nicht mehr feldweise mischen, Adresszusatz statt Land
- Sync nimmt die Lieferadresse als Ganzes: mit eigener LieferAdressId
  alle Felder aus ihr, sonst alle aus der Belegadresse. Das bisherige
  COALESCE je Feld hat leere Felder mit Werten des Bestellers gefüllt
  (z. B. dessen Ortsteil an eine fremde Anschrift gehängt).
- ERP-Freitext `Adressen.Land` (praktisch Ortsteil/Etage/Hinweis) wird
  zum Adresszusatz `Address.addition`; `country` kommt jetzt aus dem
  Länderstamm (`LandID` → `Laender.Land`). Deutschland-Kürzel und reine
  Landnamen sind kein Zusatz.
- Migration 0034: `customers.address_addition`, `deliveries.snap_addition`;
  Bestandstext aus `country` wandert in den Zusatz.
- Lieferbericht und Beleg-Ansicht: Land nur außerhalb Deutschlands,
  Zusatz als eigene Zeile „Zusatz: …".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:13:13 +02:00

379 lines
12 KiB
Rust

//! 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<AppState>,
Path(belegnummer): Path<String>,
) -> Result<Html<String>, 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<String>,
delivery_address_lines: Vec<String>,
customer_contacts: Vec<ContactVm>,
items: Vec<ItemVm>,
has_credit: bool,
credit_amount: String,
credit_reason: String,
services: Vec<ServiceVm>,
notes: Vec<NoteVm>,
contacts: Vec<ContactSourceVm>,
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<ChannelVm>,
}
// ===== 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<ItemVm> = 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<ServiceVm> = 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<NoteVm> = 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<ContactSourceVm> = 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::<Vec<_>>()
.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<ContactVm> = 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>) -> String {
match v {
Some(s) if !s.trim().is_empty() => s,
_ => "—".to_string(),
}
}
fn address_lines(a: &Address) -> Vec<String> {
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<chrono::Utc>, 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",
}
}