Ohne offenen Betrag gab es im Abschnitt "Zahlung" keine Aussage zur Abwicklung, nur die Zahlungsmethode vom Beleg (z. B. "Bar"). Jetzt steht dort "Zahlungsabwicklung: Nicht erforderlich, ..." mit Begruendung: bereits bei Bestellung bezahlt, durch Anzahlung und Gutschrift oder nur durch Gutschrift ausgeglichen, bzw. kein Warenwert. Unit-Tests dazu. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
884 lines
32 KiB
Rust
884 lines
32 KiB
Rust
//! 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<Self, ApplicationError> {
|
|
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<f32> {
|
|
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<u8> = 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<Vec<u8>, ApplicationError> {
|
|
self.doc.save_to_bytes().map_err(ext)
|
|
}
|
|
}
|
|
|
|
fn ext<E: std::fmt::Display>(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<chrono::Utc>) -> String {
|
|
d.with_timezone(&tz).format("%d.%m.%Y %H:%M").to_string()
|
|
}
|
|
fn opt(s: &Option<String>) -> &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<Vec<u8>, 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::<Vec<_>>(),
|
|
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<String, String> = 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::<Vec<_>>()
|
|
.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"
|
|
);
|
|
}
|
|
}
|