Backend-Arbeitsstand: ERP-Sync, Lieferlebenszyklus, Reports + config.toml
Bringt das Backend vom initialen Skeleton auf den aktuellen Arbeitsstand (Clean Architecture: domain → application → infrastructure → api). Wesentliche Bereiche: - ERP-Anbindung (MSSQL-Pull der Touren, Import-Scheduler, Rückschreiben) - Lieferlebenszyklus: Scan/Hold/Cancel/Complete, Gutschriften, Notizen, Bild-Anhänge, Unterschriften, PDF-Lieferreport → DOCUframe - Stammdaten: Kunden, Artikel, Lager, Zahlungsarten, Services - Keycloak-JWT-Gate + Fahrer-Provisionierung via Admin-API - Admin-API-Key-Gate (X-Admin-Api-Key) für Maschinen-Endpunkte Jüngste Änderungen dieser Session: - Belegspezifische Kontaktdaten: alle ERP-Adressen (Beleg-/Liefer-/ Rechnungsadresse, Ansprechpartner, Kundenstamm) mit Telefon/Mobil/ E-Mail werden gesynct (Migration 0029, MSSQL-Query, TourDetails) - Konfiguration von .env (envy/dotenvy) auf config.toml (toml/serde) umgestellt; Vorlage config.example.toml, Pfad via HOLZLEITNER_CONFIG Nicht im Repo (per .gitignore): config.toml (Secrets), data/ (Laufzeit-/ Kundendaten), demo.mp4, .claude/, variocontrol-ai/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
10
crates/infrastructure/src/report/mod.rs
Normal file
10
crates/infrastructure/src/report/mod.rs
Normal file
@ -0,0 +1,10 @@
|
||||
//! PDF-Lieferreport: Daten sammeln (PG) → rendern (printpdf) → ablegen/senden
|
||||
//! (Sink). Adapter zu den `DeliveryReport*`-Ports der Application-Schicht.
|
||||
|
||||
pub mod renderer;
|
||||
pub mod repository;
|
||||
pub mod sink;
|
||||
|
||||
pub use renderer::PdfDeliveryReportRenderer;
|
||||
pub use repository::PgDeliveryReportRepository;
|
||||
pub use sink::{DocuframeReportSink, LocalReportSink};
|
||||
699
crates/infrastructure/src/report/renderer.rs
Normal file
699
crates/infrastructure/src/report/renderer.rs
Normal file
@ -0,0 +1,699 @@
|
||||
//! 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;
|
||||
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)
|
||||
}
|
||||
|
||||
pub struct PdfDeliveryReportRenderer;
|
||||
|
||||
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}"))
|
||||
}
|
||||
|
||||
fn dt(d: &chrono::DateTime<chrono::Utc>) -> String {
|
||||
d.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.
|
||||
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(&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(&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));
|
||||
// Inkasso-Bestätigung: nur wenn beim Abschluss tatsächlich kassiert
|
||||
// wurde (Bar/EC bei offenem Betrag). „Auf Rechnung"/voll bezahlt → keine.
|
||||
if let Some(c) = &d.completion {
|
||||
if let Some(collected) = c.collected_amount_cents {
|
||||
p.kv(
|
||||
"Betrag erhalten",
|
||||
&format!("Ja — {} (am {})", cents(collected), dt(&c.completed_at)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Dienstleistungen
|
||||
p.heading("Dienstleistungen");
|
||||
if d.services.is_empty() {
|
||||
p.text("— keine —", 9.0, false, 0.0);
|
||||
} else {
|
||||
// Gemeinsame Wert-Spalte = Breite des längsten Dienstleistungs-
|
||||
// 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(&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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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(&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(&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(&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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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(&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(&d.generated_at)),
|
||||
7.0,
|
||||
false,
|
||||
0.0,
|
||||
);
|
||||
|
||||
p.finish()
|
||||
}
|
||||
}
|
||||
485
crates/infrastructure/src/report/repository.rs
Normal file
485
crates/infrastructure/src/report/repository.rs
Normal file
@ -0,0 +1,485 @@
|
||||
//! Postgres-Implementierung von `DeliveryReportRepository`.
|
||||
//!
|
||||
//! Sammelt mit mehreren SELECTs alle Daten einer Lieferung inkl. beider
|
||||
//! Audit-Trails (`scan_audit`, `delivery_credit_audit`) zum `DeliveryReportData`.
|
||||
//! Bild-Bytes werden hier NICHT geladen (macht der Use Case).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use holzleitner_application::dto::{
|
||||
DeliveryReportData, ReportAttachment, ReportCompletion, ReportContact, ReportCreditAudit,
|
||||
ReportItem, ReportNote, ReportScanAudit, ReportService,
|
||||
};
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::DeliveryReportRepository;
|
||||
|
||||
pub struct PgDeliveryReportRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgDeliveryReportRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(e.to_string())
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct HeadRow {
|
||||
erp_belegart_id: i64,
|
||||
erp_belegart_code: Option<String>,
|
||||
erp_belegart_name: Option<String>,
|
||||
erp_belegnummer: String,
|
||||
state: String,
|
||||
tour_date: NaiveDate,
|
||||
account_id: i64,
|
||||
driver_name: String,
|
||||
car_plate: Option<String>,
|
||||
payment_method: Option<String>,
|
||||
erp_customer_id: i64,
|
||||
customer_name: String,
|
||||
snap_street: Option<String>,
|
||||
snap_house_number: Option<String>,
|
||||
snap_postal_code: Option<String>,
|
||||
snap_city: Option<String>,
|
||||
snap_country: Option<String>,
|
||||
desired_time: Option<String>,
|
||||
special_agreements: Option<String>,
|
||||
prepaid_amount: f64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ItemRow {
|
||||
belegzeilen_nr: i32,
|
||||
komponenten_artikel_nr: Option<String>,
|
||||
parent_artikel_nr: Option<String>,
|
||||
article_number: String,
|
||||
name: String,
|
||||
required_quantity: i32,
|
||||
credited_quantity: i32,
|
||||
scanned_quantity: i32,
|
||||
scan_status: String,
|
||||
unit_price: f64,
|
||||
warehouse_code: Option<String>,
|
||||
warehouse_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ServiceRow {
|
||||
name: String,
|
||||
bool_value: Option<bool>,
|
||||
numeric_value: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct NoteRow {
|
||||
created_at: DateTime<Utc>,
|
||||
author_personalnummer: i64,
|
||||
text: Option<String>,
|
||||
image_attachment: Option<String>,
|
||||
is_amount_credit_note: bool,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ContactRow {
|
||||
name: String,
|
||||
phone: Option<String>,
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CompletionRow {
|
||||
completed_at: DateTime<Utc>,
|
||||
completed_by_personalnummer: i64,
|
||||
receipt_confirmed: bool,
|
||||
notes_acknowledged: bool,
|
||||
customer_signature_path: String,
|
||||
driver_signature_path: String,
|
||||
payment_collected: bool,
|
||||
collected_amount_cents: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ScanAuditRow {
|
||||
server_recorded_at: DateTime<Utc>,
|
||||
client_scanned_at: DateTime<Utc>,
|
||||
action: String,
|
||||
delta: i32,
|
||||
resulting_quantity: i32,
|
||||
resulting_status: String,
|
||||
reason: Option<String>,
|
||||
manual: bool,
|
||||
credit_delta: Option<i32>,
|
||||
actor_personalnummer: i64,
|
||||
belegzeilen_nr: i32,
|
||||
komponenten_artikel_nr: Option<String>,
|
||||
article_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CreditAuditRow {
|
||||
recorded_at: DateTime<Utc>,
|
||||
action: String,
|
||||
amount_cents: i64,
|
||||
reason: Option<String>,
|
||||
author_personalnummer: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct AttachmentRow {
|
||||
filename: Option<String>,
|
||||
reference: String,
|
||||
mime_type: String,
|
||||
size_bytes: i64,
|
||||
width: Option<i32>,
|
||||
height: Option<i32>,
|
||||
uploaded_at: DateTime<Utc>,
|
||||
uploaded_by: i64,
|
||||
}
|
||||
|
||||
fn one_line_address(
|
||||
street: Option<String>,
|
||||
house: Option<String>,
|
||||
plz: Option<String>,
|
||||
city: Option<String>,
|
||||
country: Option<String>,
|
||||
) -> String {
|
||||
let line1 = [street, house]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let line2 = [plz, city]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
[line1, line2, country.unwrap_or_default()]
|
||||
.into_iter()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeliveryReportRepository for PgDeliveryReportRepository {
|
||||
async fn load(
|
||||
&self,
|
||||
delivery_id: Uuid,
|
||||
) -> Result<Option<DeliveryReportData>, ApplicationError> {
|
||||
// --- Kopf ---
|
||||
let head: Option<HeadRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT d.erp_belegart_id, d.erp_belegart_code, d.erp_belegart_name,
|
||||
d.erp_belegnummer, d.state,
|
||||
t.tour_date, t.account_id,
|
||||
acc.name AS driver_name,
|
||||
car.plate AS car_plate,
|
||||
pm.name AS payment_method,
|
||||
c.erp_customer_id, c.name AS customer_name,
|
||||
d.snap_street, d.snap_house_number, d.snap_postal_code,
|
||||
d.snap_city, d.snap_country,
|
||||
d.desired_time, d.special_agreements, d.prepaid_amount
|
||||
FROM deliveries d
|
||||
JOIN tours t ON t.id = d.tour_id
|
||||
JOIN accounts acc ON acc.personalnummer = t.account_id
|
||||
LEFT JOIN cars car ON car.id = d.assigned_car_id
|
||||
LEFT JOIN payment_methods pm ON pm.id = d.payment_method_id
|
||||
JOIN customers c ON c.id = d.customer_id
|
||||
WHERE d.id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let Some(head) = head else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// --- Positionen ---
|
||||
let items: Vec<ItemRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT di.belegzeilen_nr, di.komponenten_artikel_nr, di.parent_artikel_nr,
|
||||
a.article_number, a.name,
|
||||
di.required_quantity, di.credited_quantity, di.scanned_quantity,
|
||||
di.scan_status, di.unit_price,
|
||||
w.code AS warehouse_code, w.name AS warehouse_name
|
||||
FROM delivery_items di
|
||||
JOIN articles a ON a.id = di.article_id
|
||||
LEFT JOIN warehouses w ON w.id = di.warehouse_id
|
||||
WHERE di.delivery_id = $1
|
||||
ORDER BY di.belegzeilen_nr,
|
||||
di.komponenten_artikel_nr NULLS FIRST
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
// --- Dienstleistungen ---
|
||||
let services: Vec<ServiceRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT s.name, ds.bool_value, ds.numeric_value
|
||||
FROM delivery_services ds
|
||||
JOIN services s ON s.id = ds.service_id
|
||||
WHERE ds.delivery_id = $1
|
||||
ORDER BY s.sort_order, s.name
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
// --- Notizen ---
|
||||
let notes: Vec<NoteRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT created_at, author_personalnummer, text, image_attachment,
|
||||
is_amount_credit_note
|
||||
FROM delivery_notes
|
||||
WHERE delivery_id = $1
|
||||
ORDER BY created_at
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
// --- Ansprechpartner ---
|
||||
let contacts: Vec<ContactRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT cc.name, cc.phone, cc.email
|
||||
FROM delivery_contact_persons dcp
|
||||
JOIN customer_contacts cc ON cc.id = dcp.customer_contact_id
|
||||
WHERE dcp.delivery_id = $1
|
||||
ORDER BY cc.name
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
// --- Abschluss ---
|
||||
let completion: Option<CompletionRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT completed_at, completed_by_personalnummer, receipt_confirmed,
|
||||
notes_acknowledged, customer_signature_path, driver_signature_path,
|
||||
payment_collected, collected_amount_cents
|
||||
FROM delivery_completions
|
||||
WHERE delivery_id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
// --- Audit: Scan/Belade-Verlauf ---
|
||||
let scan_audit: Vec<ScanAuditRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT sa.server_recorded_at, sa.client_scanned_at, sa.action, sa.delta,
|
||||
sa.resulting_quantity, sa.resulting_status, sa.reason, sa.manual,
|
||||
sa.credit_delta, sa.actor_personalnummer,
|
||||
sa.erp_belegzeilen_nr AS belegzeilen_nr,
|
||||
sa.erp_komponenten_artikel_nr AS komponenten_artikel_nr,
|
||||
a.name AS article_name
|
||||
FROM scan_audit sa
|
||||
JOIN delivery_items di ON di.id = sa.delivery_item_id
|
||||
LEFT JOIN articles a ON a.id = di.article_id
|
||||
WHERE di.delivery_id = $1
|
||||
ORDER BY sa.server_recorded_at
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
// --- Audit: Gutschrift-Verlauf ---
|
||||
let credit_audit: Vec<CreditAuditRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT recorded_at, action, amount_cents, reason, author_personalnummer
|
||||
FROM delivery_credit_audit
|
||||
WHERE delivery_id = $1
|
||||
ORDER BY recorded_at
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
// --- Anhänge ---
|
||||
let attachments: Vec<AttachmentRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT filename, docuframe_object_id AS reference, mime_type, size_bytes,
|
||||
width, height, uploaded_at, uploaded_by
|
||||
FROM attachments
|
||||
WHERE delivery_id = $1
|
||||
ORDER BY uploaded_at
|
||||
"#,
|
||||
)
|
||||
.bind(delivery_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
// Aktuelle Gutschrift = Wirkung des letzten Events (append-only).
|
||||
let current_credit_cents = credit_audit
|
||||
.last()
|
||||
.map(|e| {
|
||||
if e.action.eq_ignore_ascii_case("set") {
|
||||
e.amount_cents
|
||||
} else {
|
||||
0
|
||||
}
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
let address = one_line_address(
|
||||
head.snap_street,
|
||||
head.snap_house_number,
|
||||
head.snap_postal_code,
|
||||
head.snap_city,
|
||||
head.snap_country,
|
||||
);
|
||||
|
||||
Ok(Some(DeliveryReportData {
|
||||
generated_at: Utc::now(),
|
||||
belegart_id: head.erp_belegart_id,
|
||||
belegart_code: head.erp_belegart_code,
|
||||
belegart_name: head.erp_belegart_name,
|
||||
belegnummer: head.erp_belegnummer,
|
||||
state: head.state,
|
||||
tour_date: head.tour_date,
|
||||
driver_personalnummer: head.account_id,
|
||||
driver_name: head.driver_name,
|
||||
car_plate: head.car_plate,
|
||||
payment_method: head.payment_method,
|
||||
customer_number: head.erp_customer_id,
|
||||
customer_name: head.customer_name,
|
||||
address,
|
||||
desired_time: head.desired_time,
|
||||
special_agreements: head.special_agreements,
|
||||
prepaid_amount: head.prepaid_amount,
|
||||
current_credit_cents,
|
||||
contacts: contacts
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let detail = [c.phone, c.email]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ");
|
||||
ReportContact {
|
||||
name: c.name,
|
||||
detail: if detail.is_empty() { None } else { Some(detail) },
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
items: items
|
||||
.into_iter()
|
||||
.map(|i| ReportItem {
|
||||
belegzeilen_nr: i.belegzeilen_nr,
|
||||
komponenten_artikel_nr: i.komponenten_artikel_nr,
|
||||
parent_artikel_nr: i.parent_artikel_nr,
|
||||
article_number: i.article_number,
|
||||
name: i.name,
|
||||
required_quantity: i.required_quantity,
|
||||
credited_quantity: i.credited_quantity,
|
||||
scanned_quantity: i.scanned_quantity,
|
||||
scan_status: i.scan_status,
|
||||
unit_price: i.unit_price,
|
||||
warehouse_code: i.warehouse_code,
|
||||
warehouse_name: i.warehouse_name,
|
||||
})
|
||||
.collect(),
|
||||
services: services
|
||||
.into_iter()
|
||||
.map(|s| ReportService {
|
||||
name: s.name,
|
||||
bool_value: s.bool_value,
|
||||
numeric_value: s.numeric_value,
|
||||
})
|
||||
.collect(),
|
||||
notes: notes
|
||||
.into_iter()
|
||||
.map(|n| ReportNote {
|
||||
created_at: n.created_at,
|
||||
author_personalnummer: n.author_personalnummer,
|
||||
text: n.text,
|
||||
image_attachment: n.image_attachment,
|
||||
is_amount_credit_note: n.is_amount_credit_note,
|
||||
})
|
||||
.collect(),
|
||||
completion: completion.map(|c| ReportCompletion {
|
||||
completed_at: c.completed_at,
|
||||
completed_by_personalnummer: c.completed_by_personalnummer,
|
||||
receipt_confirmed: c.receipt_confirmed,
|
||||
notes_acknowledged: c.notes_acknowledged,
|
||||
customer_signature_path: c.customer_signature_path,
|
||||
driver_signature_path: c.driver_signature_path,
|
||||
payment_collected: c.payment_collected,
|
||||
collected_amount_cents: c.collected_amount_cents,
|
||||
}),
|
||||
scan_audit: scan_audit
|
||||
.into_iter()
|
||||
.map(|s| ReportScanAudit {
|
||||
server_recorded_at: s.server_recorded_at,
|
||||
client_scanned_at: s.client_scanned_at,
|
||||
action: s.action,
|
||||
delta: s.delta,
|
||||
resulting_quantity: s.resulting_quantity,
|
||||
resulting_status: s.resulting_status,
|
||||
reason: s.reason,
|
||||
manual: s.manual,
|
||||
credit_delta: s.credit_delta,
|
||||
actor_personalnummer: s.actor_personalnummer,
|
||||
belegzeilen_nr: s.belegzeilen_nr,
|
||||
komponenten_artikel_nr: s.komponenten_artikel_nr,
|
||||
article_name: s.article_name,
|
||||
})
|
||||
.collect(),
|
||||
credit_audit: credit_audit
|
||||
.into_iter()
|
||||
.map(|c| ReportCreditAudit {
|
||||
recorded_at: c.recorded_at,
|
||||
action: c.action,
|
||||
amount_cents: c.amount_cents,
|
||||
reason: c.reason,
|
||||
author_personalnummer: c.author_personalnummer,
|
||||
})
|
||||
.collect(),
|
||||
attachments: attachments
|
||||
.into_iter()
|
||||
.map(|a| ReportAttachment {
|
||||
filename: a.filename,
|
||||
reference: a.reference,
|
||||
mime_type: a.mime_type,
|
||||
size_bytes: a.size_bytes,
|
||||
width: a.width,
|
||||
height: a.height,
|
||||
uploaded_at: a.uploaded_at,
|
||||
uploaded_by: a.uploaded_by,
|
||||
bytes: None,
|
||||
})
|
||||
.collect(),
|
||||
customer_signature_png: None,
|
||||
driver_signature_png: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
102
crates/infrastructure/src/report/sink.rs
Normal file
102
crates/infrastructure/src/report/sink.rs
Normal file
@ -0,0 +1,102 @@
|
||||
//! Sinks für das fertige Report-PDF.
|
||||
//!
|
||||
//! * [`LocalReportSink`] — legt das PDF **temporär** lokal ab
|
||||
//! (`<dir>/<Belegnummer>/report-<timestamp>.pdf`). Aktiver Sink.
|
||||
//! * [`DocuframeReportSink`] — **Stub** für später: soll den PDF-Blob an ein
|
||||
//! DOCUframe-Makro senden. Aktuell nur Logging, keine echte Übertragung.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::DeliveryReportSink;
|
||||
|
||||
/// Dateisystem-sicheres Segment (alnum/._-, Rest → `_`).
|
||||
fn sanitize(input: &str) -> String {
|
||||
let s: String = input
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let t = s.trim_matches('.').trim().to_string();
|
||||
if t.is_empty() { "unbenannt".to_string() } else { t }
|
||||
}
|
||||
|
||||
// ===== Local =============================================================
|
||||
|
||||
pub struct LocalReportSink {
|
||||
base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl LocalReportSink {
|
||||
pub fn new(base_dir: impl Into<PathBuf>) -> std::io::Result<Self> {
|
||||
let base_dir = base_dir.into();
|
||||
std::fs::create_dir_all(&base_dir)?;
|
||||
Ok(Self { base_dir })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeliveryReportSink for LocalReportSink {
|
||||
async fn deliver(&self, folder: &str, pdf: Vec<u8>) -> Result<String, ApplicationError> {
|
||||
let folder = sanitize(folder);
|
||||
let stamp = Utc::now().format("%Y%m%d-%H%M%S").to_string();
|
||||
let dir = self.base_dir.join(&folder);
|
||||
let name = format!("report-{stamp}.pdf");
|
||||
let path = dir.join(&name);
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
|
||||
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
std::fs::write(&path, &pdf)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApplicationError::Repository(format!("join error: {e}")))?
|
||||
.map_err(|e| ApplicationError::Repository(format!("report speichern fehlgeschlagen: {e}")))?;
|
||||
|
||||
tracing::info!(path = %path_str, "report.sink.local: PDF abgelegt");
|
||||
Ok(path_str)
|
||||
}
|
||||
|
||||
async fn delete(&self, folder: &str) -> Result<(), ApplicationError> {
|
||||
// Löscht das gesamte Belegnummer-Verzeichnis (alle abgelegten Reports).
|
||||
let dir = self.base_dir.join(sanitize(folder));
|
||||
tokio::task::spawn_blocking(move || match std::fs::remove_dir_all(&dir) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApplicationError::Repository(format!("join error: {e}")))?
|
||||
.map_err(|e| ApplicationError::Repository(format!("report löschen fehlgeschlagen: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
// ===== DOCUframe (Stub) ==================================================
|
||||
|
||||
/// Platzhalter — später: PDF-Blob an ein DOCUframe-Makro senden. Aktuell nur
|
||||
/// Logging, keine echte Übertragung.
|
||||
pub struct DocuframeReportSink;
|
||||
|
||||
#[async_trait]
|
||||
impl DeliveryReportSink for DocuframeReportSink {
|
||||
async fn deliver(&self, folder: &str, pdf: Vec<u8>) -> Result<String, ApplicationError> {
|
||||
tracing::warn!(
|
||||
belegnummer = folder,
|
||||
pdf_bytes = pdf.len(),
|
||||
"report.sink.docuframe: STUB — PDF würde an DOCUframe-Makro gesendet (noch nicht implementiert)"
|
||||
);
|
||||
Ok(format!("docuframe-stub://{folder}"))
|
||||
}
|
||||
|
||||
async fn delete(&self, _folder: &str) -> Result<(), ApplicationError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user