//! printpdf-Renderer für den Lieferreport. //! //! Nutzt die eingebaute **Helvetica** (WinAnsi → deutsche Umlaute) — kein //! Font-Asset. Einfache, eigene Layout-Engine: Cursor `y` von oben nach unten, //! automatischer Seitenumbruch, klippende Tabellen, eingebettete Bilder //! (Unterschriften + Foto-Notizen) via roher RGB-Daten. use printpdf::{ BuiltinFont, Color, ColorBits, ColorSpace, Image, ImageFilter, ImageTransform, ImageXObject, IndirectFontRef, Line, Mm, PdfDocument, PdfDocumentReference, PdfLayerReference, Point, Px, Rgb, }; use holzleitner_application::dto::{DeliveryReportData, ReportPayment}; use holzleitner_application::error::ApplicationError; use holzleitner_application::ports::DeliveryReportRenderer; const PAGE_W: f32 = 210.0; const PAGE_H: f32 = 297.0; const MARGIN_L: f32 = 15.0; const MARGIN_R: f32 = 15.0; const MARGIN_TOP: f32 = 15.0; const MARGIN_BOTTOM: f32 = 16.0; const CONTENT_W: f32 = PAGE_W - MARGIN_L - MARGIN_R; // 180 fn pt2mm(pt: f32) -> f32 { pt * 0.352_778 } /// Grobe Helvetica-Zeichenbreite (≈0.5em) — reicht für Umbruch/Klippen. fn char_w(size: f32) -> f32 { pt2mm(size) * 0.5 } /// Geschätzte Breite (mm) der fetten Schlüssel-Spalte inkl. 4mm Luft. /// Konservative Obergrenze für Helvetica-Bold (~0.6em → hier 0.66em). fn kv_col(key: &str) -> f32 { pt2mm(9.0) * 0.66 * key.chars().count() as f32 + 4.0 } fn money(v: f64) -> String { format!("{:.2} €", v).replace('.', ",") } fn cents(c: i64) -> String { money(c as f64 / 100.0) } /// Rendert den Lieferbericht als PDF. Alle Zeitstempel in den Report-Daten /// sind UTC; für die Darstellung werden sie in die konfigurierte Fach-Zeitzone /// (`server.timezone`, z. B. Europe/Berlin) umgerechnet — sonst stünde z. B. /// „08.09. 22:54" statt „09.09. 00:54" im Bericht. pub struct PdfDeliveryReportRenderer { tz: chrono_tz::Tz, } impl PdfDeliveryReportRenderer { pub fn new(tz: chrono_tz::Tz) -> Self { Self { tz } } } struct Pdf { doc: PdfDocumentReference, font: IndirectFontRef, bold: IndirectFontRef, layer: PdfLayerReference, y: f32, } impl Pdf { fn new(title: &str) -> Result { let (doc, page, layer) = PdfDocument::new(title, Mm(PAGE_W), Mm(PAGE_H), "Layer 1"); let font = doc .add_builtin_font(BuiltinFont::Helvetica) .map_err(ext)?; let bold = doc .add_builtin_font(BuiltinFont::HelveticaBold) .map_err(ext)?; let layer = doc.get_page(page).get_layer(layer); Ok(Self { doc, font, bold, layer, y: PAGE_H - MARGIN_TOP, }) } fn new_page(&mut self) { let (page, layer) = self.doc.add_page(Mm(PAGE_W), Mm(PAGE_H), "Layer 1"); self.layer = self.doc.get_page(page).get_layer(layer); self.y = PAGE_H - MARGIN_TOP; } fn ensure(&mut self, needed: f32) { if self.y - needed < MARGIN_BOTTOM { self.new_page(); } } /// Schreibt eine Zeile ab x (mm von links innerhalb des Inhalts). fn put(&mut self, text: &str, size: f32, bold: bool, x: f32, baseline_y: f32) { let font = if bold { &self.bold } else { &self.font }; self.layer .use_text(text, size, Mm(MARGIN_L + x), Mm(baseline_y), font); } fn max_chars(&self, width: f32, size: f32) -> usize { ((width / char_w(size)).floor() as usize).max(1) } fn clip(&self, text: &str, width: f32, size: f32) -> String { let max = self.max_chars(width, size); if text.chars().count() <= max { text.to_string() } else if max <= 1 { "…".to_string() } else { let truncated: String = text.chars().take(max - 1).collect(); format!("{truncated}…") } } /// Mehrzeiliger, umbrechender Text. `indent` in mm. fn text(&mut self, text: &str, size: f32, bold: bool, indent: f32) { let avail = CONTENT_W - indent; let max = self.max_chars(avail, size); for raw_line in text.split('\n') { if raw_line.is_empty() { self.y -= pt2mm(size) * 1.3; continue; } let mut current = String::new(); for word in raw_line.split_whitespace() { let candidate = if current.is_empty() { word.to_string() } else { format!("{current} {word}") }; if candidate.chars().count() > max && !current.is_empty() { self.write_line(¤t, size, bold, indent); current = word.to_string(); } else { current = candidate; } } if !current.is_empty() { self.write_line(¤t, size, bold, indent); } } } fn write_line(&mut self, text: &str, size: f32, bold: bool, indent: f32) { let lh = pt2mm(size) * 1.35; self.ensure(lh); let baseline = self.y - pt2mm(size); self.put(text, size, bold, indent, baseline); self.y -= lh; } fn gap(&mut self, mm: f32) { self.y -= mm; } fn title(&mut self, text: &str) { self.write_line(text, 18.0, true, 0.0); self.gap(2.0); } fn heading(&mut self, text: &str) { self.gap(4.0); self.ensure(8.0); self.write_line(text, 12.0, true, 0.0); self.gap(1.0); } fn kv(&mut self, key: &str, value: &str) { // Wert-Spalte normal bei 42mm; ist der (fette) Schlüssel breiter, // wird der Wert nach rechts geschoben, damit nichts überlappt. let val_x = 42.0_f32.max(kv_col(key)); self.kv_at(key, value, val_x); } /// Wie `kv`, aber mit fest vorgegebener Wert-Spalte `val_x` (mm) — für /// bündig ausgerichtete Blöcke, in denen alle Werte an derselben Stelle /// beginnen (Spaltenbreite = breitester Schlüssel des Blocks). fn kv_at(&mut self, key: &str, value: &str, val_x: f32) { // Key in der ersten Spalte (fett), Wert daneben. let lh = pt2mm(9.0) * 1.35; self.ensure(lh); let baseline = self.y - pt2mm(9.0); self.put(key, 9.0, true, 0.0, baseline); let val = self.clip(value, CONTENT_W - val_x, 9.0); self.put(&val, 9.0, false, val_x, baseline); self.y -= lh; } /// Tabellenzeile: (Text, Spaltenbreite mm, fett). Klippt je Zelle. fn row(&mut self, cells: &[(String, f32, bool)], size: f32) { let lh = pt2mm(size) * 1.5; self.ensure(lh); let baseline = self.y - pt2mm(size); let mut x = 0.0; for (text, width, bold) in cells { let clipped = self.clip(text, *width - 1.5, size); self.put(&clipped, size, *bold, x, baseline); x += width; } self.y -= lh; } /// Bettet ein Bild ein (Bytes → RGB). `max_w`/`max_h` in mm. Bewegt den /// Cursor (für Anhänge/Foto-Notizen im Textfluss). fn image(&mut self, bytes: &[u8], max_w: f32, max_h: f32) { // `max_h` ist die obere Schranke der Zeichenhöhe → konservativ reservieren. self.ensure(max_h + 2.0); match self.draw_image_at(bytes, MARGIN_L, self.y, max_w, max_h) { Some(h) => self.y -= h + 2.0, None => self.text("[Bild konnte nicht gelesen werden]", 8.0, false, 0.0), } } /// Zeichnet ein Bild an fester Position (x links, `top_y` Oberkante, mm), /// **ohne** den Cursor zu verändern. Liefert die gezeichnete Höhe in mm /// (oder `None`, wenn das Bild nicht dekodiert werden konnte). fn draw_image_at(&self, bytes: &[u8], x_mm: f32, top_y: f32, max_w: f32, max_h: f32) -> Option { let dynimg = image::load_from_memory(bytes).ok()?; // Auf eine sinnvolle Maximal-Kantenlänge runterskalieren — ein Report // braucht keine 12-MP-Fotos. Begrenzt die Pixelmenge VOR der Kompression. const MAX_EDGE: u32 = 1600; let dynimg = if dynimg.width().max(dynimg.height()) > MAX_EDGE { dynimg.resize(MAX_EDGE, MAX_EDGE, image::imageops::FilterType::Triangle) } else { dynimg }; let rgb = dynimg.to_rgb8(); let (w_px, h_px) = (rgb.width(), rgb.height()); if w_px == 0 || h_px == 0 { return None; } // JPEG-komprimieren und als DCTDecode-Stream ins PDF legen (statt rohem // RGB) → drastisch kleinere PDFs (12-MP-Foto: 37 MB roh → ~200 KB). let mut jpeg: Vec = Vec::new(); { let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 82); enc.encode(rgb.as_raw(), w_px, h_px, image::ExtendedColorType::Rgb8) .ok()?; } // Zielgröße unter Wahrung des Seitenverhältnisses. let aspect = h_px as f32 / w_px as f32; let mut draw_w = max_w; let mut draw_h = draw_w * aspect; if draw_h > max_h { draw_h = max_h; draw_w = draw_h / aspect; } let bottom = top_y - draw_h; // Scale relativ zur Default-DPI (300): natürliche Breite in mm. let natural_w_mm = (w_px as f32) / 300.0 * 25.4; let scale = if natural_w_mm > 0.0 { draw_w / natural_w_mm } else { 1.0 }; let xobject = ImageXObject { width: Px(w_px as usize), height: Px(h_px as usize), color_space: ColorSpace::Rgb, bits_per_component: ColorBits::Bit8, interpolate: false, image_data: jpeg, image_filter: Some(ImageFilter::DCT), smask: None, clipping_bbox: None, }; Image::from(xobject).add_to_layer( self.layer.clone(), ImageTransform { translate_x: Some(Mm(x_mm)), translate_y: Some(Mm(bottom)), scale_x: Some(scale), scale_y: Some(scale), ..Default::default() }, ); Some(draw_h) } /// Zeichnet einen Rechteck-Rahmen (nur Kontur, grau). `bottom_y` = Unterkante. fn rect(&self, x_mm: f32, bottom_y: f32, w: f32, h: f32) { self.layer .set_outline_color(Color::Rgb(Rgb::new(0.6, 0.6, 0.6, None))); self.layer.set_outline_thickness(0.5); // pt let (x0, x1) = (x_mm, x_mm + w); let (y0, y1) = (bottom_y, bottom_y + h); let line = Line { points: vec![ (Point::new(Mm(x0), Mm(y0)), false), (Point::new(Mm(x1), Mm(y0)), false), (Point::new(Mm(x1), Mm(y1)), false), (Point::new(Mm(x0), Mm(y1)), false), ], is_closed: true, }; self.layer.add_line(line); } /// Unterschriftsfeld: Beschriftung + umrahmter Kasten mit dem (kleinen) Bild. fn signature_box(&mut self, label: &str, png: &[u8]) { const BOX_W: f32 = 75.0; const BOX_H: f32 = 22.0; const PAD: f32 = 2.5; let label_lh = pt2mm(9.0) * 1.35; // Beschriftung + Kasten zusammenhalten (kein Umbruch mittendrin). self.ensure(label_lh + BOX_H + 3.0); self.write_line(label, 9.0, true, 0.0); let top = self.y; let bottom = top - BOX_H; self.rect(MARGIN_L, bottom, BOX_W, BOX_H); self.draw_image_at( png, MARGIN_L + PAD, top - PAD, BOX_W - 2.0 * PAD, BOX_H - 2.0 * PAD, ); self.y = bottom - 3.0; } fn finish(self) -> Result, ApplicationError> { self.doc.save_to_bytes().map_err(ext) } } fn ext(e: E) -> ApplicationError { ApplicationError::Repository(format!("pdf: {e}")) } /// UTC-Instant → Wanduhrzeit in der konfigurierten Zone, `TT.MM.JJJJ HH:MM`. fn dt(tz: chrono_tz::Tz, d: &chrono::DateTime) -> String { d.with_timezone(&tz).format("%d.%m.%Y %H:%M").to_string() } fn opt(s: &Option) -> &str { s.as_deref().unwrap_or("—") } /// Lesbare Belegart: „VL5 — Lieferschein EH", sonst nur Code/Name, sonst die /// nackte ERP-ID („Nr. 24") als Fallback für noch nicht nachsynchronisierte /// Altbestände. fn belegart_label(d: &DeliveryReportData) -> String { let code = d .belegart_code .as_deref() .map(str::trim) .filter(|s| !s.is_empty()); let name = d .belegart_name .as_deref() .map(str::trim) .filter(|s| !s.is_empty()); match (code, name) { (Some(c), Some(n)) => format!("{c} — {n}"), (Some(c), None) => c.to_string(), (None, Some(n)) => n.to_string(), (None, None) => format!("Nr. {}", d.belegart_id), } } /// Deutsche Beschriftung des Lieferungs-Status (DB-Werte sind technisch/englisch). fn state_de(s: &str) -> &str { match s { "active" => "Aktiv", "held" => "Zurückgestellt", "canceled" => "Storniert", "completed" => "Abgeschlossen", other => other, } } /// Deutsche Beschriftung des Positions-/Scanstatus. fn status_de(s: &str) -> &str { match s { "in_progress" => "In Arbeit", "done" => "Erledigt", "held" => "Zurückgestellt", "removed" => "Entfernt", other => other, } } /// Deutsche Beschriftung der Belade-/Scanaktion. fn scan_action_de(s: &str) -> &str { match s { "scan" => "Erfasst", "unscan" => "Zurückgesetzt", "hold" => "Zurückgestellt", "unhold" => "Freigegeben", "remove" => "Entfernt", other => other, } } /// Deutsche Beschriftung der Gutschriftaktion. /// Begründung, warum ohne offenen Betrag nichts abzuwickeln war. /// Anzahlung deckt den Warenwert ⇒ bei Bestellung bezahlt; sonst hat eine /// Gutschrift (ggf. zusammen mit der Anzahlung) den Rest ausgeglichen. fn settled_without_payment(warenwert: f64, prepaid: f64, credit: f64) -> &'static str { const EPS: f64 = 0.005; if warenwert < EPS { "Nicht erforderlich, kein Warenwert" } else if prepaid + EPS >= warenwert { "Nicht erforderlich, bereits bei Bestellung bezahlt" } else if prepaid >= EPS && credit >= EPS { "Nicht erforderlich, durch Anzahlung und Gutschrift ausgeglichen" } else { "Nicht erforderlich, durch Gutschrift ausgeglichen" } } /// Bar/EC = vor Ort kassiert; alles andere (Rechnung) bleibt offen. fn is_collected_on_site(code: &str) -> bool { code == "cash" || code == "ec_card" } /// Anzeigename der Zahlungsart: Stammdaten-Name, sonst aus dem Code-Snapshot. fn payment_method_label(pay: &ReportPayment) -> String { if let Some(name) = pay.method_name.as_deref().filter(|n| !n.trim().is_empty()) { return name.to_string(); } match pay.method_code.as_str() { "cash" => "Bar".into(), "ec_card" => "EC-Karte".into(), "invoice" => "Rechnung".into(), other => other.to_string(), } } /// „Fahrer 423, Fahrzeug AB-CD 12" (Fahrzeug nur, wenn bekannt). fn payment_actor(pay: &ReportPayment) -> String { match &pay.car_plate { Some(plate) => format!("Fahrer {}, Fahrzeug {}", pay.recorded_by_personalnummer, plate), None => format!("Fahrer {}", pay.recorded_by_personalnummer), } } fn credit_action_de(s: &str) -> &str { match s { "set" => "Gesetzt", "remove" => "Entfernt", other => other, } } impl DeliveryReportRenderer for PdfDeliveryReportRenderer { fn render(&self, d: &DeliveryReportData) -> Result, ApplicationError> { let mut p = Pdf::new(&format!("Lieferbericht {}", d.belegnummer))?; // 1. Kopf p.title(&format!("Lieferbericht {}", d.belegnummer)); p.kv("Belegnummer", &d.belegnummer); p.kv("Belegart", &belegart_label(d)); p.kv("Status", state_de(&d.state)); p.kv("Tourdatum", &d.tour_date.format("%d.%m.%Y").to_string()); match &d.completion { Some(c) => p.kv("Abgeschlossen", &dt(self.tz, &c.completed_at)), None => p.kv("Abgeschlossen", "— (nicht abgeschlossen)"), } p.kv("Fahrer", &format!("{} ({})", d.driver_name, d.driver_personalnummer)); p.kv("Fahrzeug", opt(&d.car_plate)); p.kv("Erstellt am", &dt(self.tz, &d.generated_at)); // 2. Kunde & Lieferadresse p.heading("Kunde & Lieferadresse"); p.kv("Kunde", &format!("{} (Nr. {})", d.customer_name, d.customer_number)); p.kv("Adresse", &d.address); p.kv("Wunschzeit", opt(&d.desired_time)); if let Some(sa) = &d.special_agreements { if !sa.trim().is_empty() { p.text(&format!("Sonderwünsche: {sa}"), 9.0, false, 0.0); } } for c in &d.contacts { let line = match &c.detail { Some(det) => format!("Ansprechpartner: {} ({det})", c.name), None => format!("Ansprechpartner: {}", c.name), }; p.text(&line, 9.0, false, 0.0); } // 3. Positionen p.heading("Positionen"); let cols: [(&str, f32); 6] = [ ("Artikel", 78.0), ("Soll", 16.0), ("Gel.", 16.0), ("Gutschr.", 22.0), ("Einzel", 26.0), ("Lager", 22.0), ]; p.row( &cols.iter().map(|(t, w)| (t.to_string(), *w, true)).collect::>(), 8.0, ); let mut warenwert = 0.0_f64; for it in &d.items { warenwert += it.unit_price * it.delivered() as f64; let label = if it.is_component() { format!(" ↳ {} ({})", it.name, it.article_number) } else { format!("{} ({})", it.name, it.article_number) }; p.row( &[ (label, 78.0, false), (it.required_quantity.to_string(), 16.0, false), (it.delivered().to_string(), 16.0, false), (it.credited_quantity.to_string(), 22.0, false), (money(it.unit_price), 26.0, false), (it.warehouse_code.clone().unwrap_or_default(), 22.0, false), ], 8.0, ); } // Lager-Legende: Nummer → Name (einmalig, alphabetisch nach Nummer). let mut lager: std::collections::BTreeMap = std::collections::BTreeMap::new(); for it in &d.items { if let Some(code) = &it.warehouse_code { if !code.trim().is_empty() { lager .entry(code.clone()) .or_insert_with(|| it.warehouse_name.clone().unwrap_or_default()); } } } if !lager.is_empty() { let legend = lager .iter() .map(|(code, name)| { if name.trim().is_empty() { code.clone() } else { format!("{code} = {name}") } }) .collect::>() .join(" "); p.gap(1.0); p.text(&format!("Lager: {legend}"), 7.5, false, 0.0); } // 4. Zahlung p.heading("Zahlung"); let credit = d.current_credit_cents as f64 / 100.0; let open = (warenwert - d.prepaid_amount - credit).max(0.0); p.kv("Warenwert", &money(warenwert)); p.kv("Anzahlung", &money(d.prepaid_amount)); p.kv("Gutschrift", ¢s(d.current_credit_cents)); p.kv("Offener Betrag", &money(open)); p.kv("Zahlungsmethode", opt(&d.payment_method)); // Zahlungsabwicklung aus dem Step „Zahlung": nach dem Abschluss die // dabei gültige Zahlung, vorher der jüngste Eintrag. let settled = match &d.completion { Some(c) => c .payment_id .and_then(|id| d.payments.iter().find(|pay| pay.id == id)), None => d.payments.last(), }; if let Some(pay) = settled { let verb = if is_collected_on_site(&pay.method_code) { "erhalten" } else { // Name der Methode sagt bereits „Rechnung" — nicht doppeln. "bestätigt" }; // Eigene Zeilen statt einer Satzzeile: Der Betrag steht so immer // am Zeilenende (die eingebaute Helvetica setzt nach „€" zu eng). p.kv( "Zahlungsabwicklung", &format!("{}, {}", payment_method_label(pay), verb), ); p.kv("Zahlbetrag", ¢s(pay.amount_cents)); p.kv("Abgewickelt am", &dt(self.tz, &pay.recorded_at)); p.kv("Erfasst von", &payment_actor(pay)); } else if open < 0.005 { // Nichts offen ⇒ nichts abzuwickeln. Grund nennen, damit die // Zahlungsmethode vom Beleg (z. B. „Bar") nicht als kassiert gilt. p.kv( "Zahlungsabwicklung", settled_without_payment(warenwert, d.prepaid_amount, credit), ); } else if let Some(c) = &d.completion { // Abschluss ohne Zahlungs-Step (ältere App): nur das Inkasso-Flag. if let Some(collected) = c.collected_amount_cents { p.kv( "Betrag erhalten", &format!("Ja, am {} — {}", dt(self.tz, &c.completed_at), cents(collected)), ); } } // 5. Checkliste p.heading("Checkliste"); if d.services.is_empty() { p.text("— keine —", 9.0, false, 0.0); } else { // Gemeinsame Wert-Spalte = Breite des längsten Checklisten- // Namens (mind. 42mm), damit die zweite Spalte bündig ausgerichtet // ist statt pro Zeile zu springen. let col = d .services .iter() .map(|s| kv_col(&s.name)) .fold(42.0_f32, f32::max); for s in &d.services { let val = if let Some(b) = s.bool_value { if b { "Ja".to_string() } else { "Nein".to_string() } } else if let Some(n) = s.numeric_value { n.to_string() } else { "—".to_string() }; p.kv_at(&s.name, &val, col); } } // 6. Notizen p.heading("Notizen"); if d.notes.is_empty() { p.text("— keine —", 9.0, false, 0.0); } else { for n in &d.notes { let mut head = format!("{} · Fahrer {}", dt(self.tz, &n.created_at), n.author_personalnummer); if n.is_amount_credit_note { head.push_str(" · [Gutschrift-Notiz]"); } if n.image_attachment.is_some() { head.push_str(" · [Bild]"); } p.text(&head, 8.0, true, 0.0); if let Some(t) = &n.text { if !t.trim().is_empty() { p.text(t, 9.0, false, 4.0); } } } } // 6b. Interne Notiz des Fahrers (nach den Unterschriften erfasst, // nicht Teil der Kunden-Bestätigung). p.heading("Interne Notiz"); match d .completion .as_ref() .and_then(|c| c.internal_note.as_deref().map(|n| (c, n))) { Some((c, note)) => { p.text( &format!( "{} · Fahrer {}", dt(self.tz, &c.completed_at), c.completed_by_personalnummer ), 8.0, true, 0.0, ); p.text(note, 9.0, false, 4.0); } None => p.text("— keine —", 9.0, false, 0.0), } // 7. Unterschriften p.heading("Unterschriften"); match &d.completion { Some(c) => { // Es gibt nur EINEN Abschlusszeitpunkt (completed_at) — er gilt // für beide Bestätigungen und wird hier neben den Häkchen gezeigt. let bestätigt_am = dt(self.tz, &c.completed_at); let receipt = if c.receipt_confirmed { format!("Ja (am {bestätigt_am})") } else { "Nein".to_string() }; let notes = if c.notes_acknowledged { format!("Ja (am {bestätigt_am})") } else { "Nein".to_string() }; p.kv("Erhalt bestätigt", &receipt); p.kv("Notizen quittiert", ¬es); if let Some(png) = &d.customer_signature_png { p.signature_box("Unterschrift Kunde", png); } if let Some(png) = &d.driver_signature_png { p.signature_box("Unterschrift Fahrer", png); } } None => p.text("— Lieferung nicht abgeschlossen —", 9.0, false, 0.0), } // 8. Protokoll: Belade-/Scanverlauf p.heading("Protokoll — Belade- und Scanverlauf"); if d.scan_audit.is_empty() { p.text("— keine Einträge —", 9.0, false, 0.0); } else { p.row( &[ ("Zeit".to_string(), 30.0, true), ("Aktion".to_string(), 20.0, true), ("Artikel".to_string(), 58.0, true), ("Δ".to_string(), 10.0, true), ("Menge".to_string(), 16.0, true), ("Status".to_string(), 22.0, true), ("Fahrer".to_string(), 18.0, true), ], 7.5, ); for s in &d.scan_audit { let art = s .article_name .clone() .unwrap_or_else(|| format!("Pos {}", s.belegzeilen_nr)); let mut action = scan_action_de(&s.action).to_string(); if s.manual { action.push('*'); } p.row( &[ (dt(self.tz, &s.server_recorded_at), 30.0, false), (action, 20.0, false), (art, 58.0, false), (format!("{:+}", s.delta), 10.0, false), (s.resulting_quantity.to_string(), 16.0, false), (status_de(&s.resulting_status).to_string(), 22.0, false), (s.actor_personalnummer.to_string(), 18.0, false), ], 7.5, ); if let Some(r) = &s.reason { if !r.trim().is_empty() { p.text(&format!(" Grund: {r}"), 7.0, false, 0.0); } } } p.text("* = manuell bestätigt", 7.0, false, 0.0); } // 9. Protokoll: Gutschriftverlauf p.heading("Protokoll — Gutschriftverlauf"); if d.credit_audit.is_empty() { p.text("— keine Einträge —", 9.0, false, 0.0); } else { p.row( &[ ("Zeit".to_string(), 34.0, true), ("Aktion".to_string(), 22.0, true), ("Betrag".to_string(), 26.0, true), ("Fahrer".to_string(), 20.0, true), ("Grund".to_string(), 70.0, true), ], 7.5, ); for c in &d.credit_audit { p.row( &[ (dt(self.tz, &c.recorded_at), 34.0, false), (credit_action_de(&c.action).to_string(), 22.0, false), (cents(c.amount_cents), 26.0, false), (c.author_personalnummer.to_string(), 20.0, false), (c.reason.clone().unwrap_or_default(), 70.0, false), ], 7.5, ); } } // 9b. Protokoll: Zahlungsabwicklung (alle Einträge inkl. Korrekturen) p.heading("Protokoll — Zahlungsabwicklung"); if d.payments.is_empty() { p.text("— keine Einträge —", 9.0, false, 0.0); } else { p.row( &[ ("Zeit".to_string(), 30.0, true), ("Zahlungsart".to_string(), 48.0, true), ("Betrag".to_string(), 22.0, true), ("Fahrer".to_string(), 16.0, true), ("Fahrzeug".to_string(), 28.0, true), ("Status".to_string(), 28.0, true), ], 7.5, ); let current = settled.map(|pay| pay.id); for pay in &d.payments { let status = match (current == Some(pay.id), d.completion.is_some()) { (true, true) => "beim Abschluss", (true, false) => "aktuell", (false, _) => "ersetzt", }; p.row( &[ (dt(self.tz, &pay.recorded_at), 30.0, false), (payment_method_label(pay), 48.0, false), (cents(pay.amount_cents), 22.0, false), (pay.recorded_by_personalnummer.to_string(), 16.0, false), (pay.car_plate.clone().unwrap_or_else(|| "—".into()), 28.0, false), (status.to_string(), 28.0, false), ], 7.5, ); } } // 10. Anhänge p.heading("Anhänge"); if d.attachments.is_empty() { p.text("— keine —", 9.0, false, 0.0); } else { for a in &d.attachments { let meta = format!( "{} · {} · {} KB · hochgeladen {} von Fahrer {}", a.filename.clone().unwrap_or_else(|| "(ohne Name)".into()), a.mime_type, a.size_bytes / 1024, dt(self.tz, &a.uploaded_at), a.uploaded_by ); p.text(&meta, 8.0, true, 0.0); match (&a.bytes, a.mime_type.starts_with("image/")) { (Some(bytes), true) => p.image(bytes, 90.0, 90.0), _ => p.text(" (Vorschau nicht verfügbar)", 8.0, false, 0.0), } } } // 11. Footer p.gap(4.0); p.text( &format!("Automatisch erzeugt am {} — Holzleitner Auslieferung", dt(self.tz, &d.generated_at)), 7.0, false, 0.0, ); p.finish() } } #[cfg(test)] mod settled_without_payment_tests { use super::settled_without_payment; #[test] fn anzahlung_deckt_warenwert() { assert_eq!( settled_without_payment(688.0, 688.0, 0.0), "Nicht erforderlich, bereits bei Bestellung bezahlt" ); // Überzahlt zählt ebenfalls als bei Bestellung bezahlt. assert_eq!( settled_without_payment(838.0, 938.0, 0.0), "Nicht erforderlich, bereits bei Bestellung bezahlt" ); } #[test] fn gutschrift_gleicht_rest_aus() { assert_eq!( settled_without_payment(100.0, 80.0, 20.0), "Nicht erforderlich, durch Anzahlung und Gutschrift ausgeglichen" ); assert_eq!( settled_without_payment(100.0, 0.0, 100.0), "Nicht erforderlich, durch Gutschrift ausgeglichen" ); } #[test] fn ohne_warenwert() { assert_eq!( settled_without_payment(0.0, 0.0, 0.0), "Nicht erforderlich, kein Warenwert" ); } }