Files
aufmass-web/_aufmass_web/app/services/export_pdf_service.py
T

301 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
from collections import defaultdict
from datetime import datetime
from flask import current_app, render_template
from fpdf import FPDF
# ── helpers ─────────────────────────────────────────────────────────────────
def _val(v):
return None if v is None or v == 0 or v == '' else str(v)
def _fmt_date(d):
if d is None:
return None
if isinstance(d, str):
d = d[:10]
for fmt in ('%Y-%m-%d', '%d.%m.%Y'):
try:
return datetime.strptime(d, fmt).strftime('%d.%m.%Y')
except ValueError:
continue
return d
return d.strftime('%d.%m.%Y')
def _ist_trenner(pos):
return (not pos.pos_nr or pos.pos_nr == '') and (pos.faktor == 0 or pos.faktor is None) and (pos.laenge == 0 or pos.laenge is None) and (pos.breite == 0 or pos.breite is None) and (pos.tiefe == 0 or pos.tiefe is None) and (pos.menge == 0 or pos.menge is None) and (pos.einzelpreis == 0 or pos.einzelpreis is None) and (pos.gesamtpreis == 0 or pos.gesamtpreis is None)
# ── Farben ──────────────────────────────────────────────────────────────────
BLUE = (47, 84, 150)
LIGHT_GRAY = (0.93, 0.93, 0.93)
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
# ── PDF-Klasse ──────────────────────────────────────────────────────────────
class AufmassPDF(FPDF):
def footer(self):
self.set_y(-8)
self.set_font('DJ', 'I', 7)
self.set_text_color(128, 128, 128)
self.cell(0, 6, f'Seite {self.page_no()}/{{nb}}', align='C', new_x='LMARGIN', new_y='NEXT')
# ── Hauptfunktion ───────────────────────────────────────────────────────────
def _render_pdf_html(project, aufmass, positionen, company=None):
ap_name = _name(project)
groups, seen_pos = _build_summary(positionen)
return render_template(
'aufmass/export_pdf.html',
project=project, aufmass=aufmass, positionen=positionen,
company=company, ap_name=ap_name, groups=dict(groups),
seen_pos=seen_pos, _val=_val, _fmt_date=_fmt_date,
_ist_trenner=_ist_trenner,
)
def _name(project):
return f'{_val(project.ansprechpartner_vorname) or ""} {_val(project.ansprechpartner_nachname) or ""}'.strip()
def _build_summary(positionen):
groups = defaultdict(lambda: {'kurztext': '', 'menge': 0.0, 'ep': 0.0, 'gp': 0.0})
seen = []
for p in positionen:
if _ist_trenner(p) or not p.pos_nr:
continue
key = p.pos_nr
if key not in groups:
seen.append(key)
groups[key]['kurztext'] = p.kurztext or ''
groups[key]['menge'] += p.menge_hinten if p.menge_hinten else (p.menge or 0)
groups[key]['ep'] = p.einzelpreis or 0
groups[key]['gp'] += p.gesamtpreis or 0
return groups, seen
def _val_or(v, default=''):
return str(v) if v is not None and v != 0 and v != '' else default
def _fmt(v):
return f'{v:.2f}'.replace('.',',') if v is not None else ''
# ── Spaltenbreiten (mm) Verhältnis wie Excel max_widths ───────────────────
# A4 quer = 297mm, margin 5mm → 287mm verfügbar, aber wir nutzen 282mm für die Tabelle
W_POS = [20, 17, 12, 14, 14, 14, 16, 9, 63, 52, 16, 16, 19] # sum = 282mm
# 20+17+12+14+14+14+16+9+63+52+16+16+19 = 282 ✓
W_SUM = [20, 172, 28, 28, 34] # Zusammenfassung: PNr, Kurztext, Menge, EP, GP
# 20+172+28+28+34 = 282mm (gleiche Breite wie Positionstabelle)
def _font_path(name, fallback=None):
win = f'C:/Windows/Fonts/{name}'
lin = f'/usr/share/fonts/truetype/dejavu/{name}'
if os.path.exists(win):
return win
if os.path.exists(lin):
return lin
return _font_path(fallback) if fallback else name
DJV = _font_path('DejaVuSans.ttf')
DJV_B = _font_path('DejaVuSans-Bold.ttf')
DJV_O = _font_path('DejaVuSans-Oblique.ttf', fallback='DejaVuSans.ttf')
DJV_BI = _font_path('DejaVuSans-BoldOblique.ttf', fallback='DejaVuSans-Bold.ttf')
def export_project_to_pdf(project, aufmass, positionen, output_path, company=None):
pdf = AufmassPDF(orientation='L', format='A4')
pdf.alias_nb_pages()
pdf.set_auto_page_break(auto=True, margin=10)
pdf.add_page()
pdf.set_margins(7, 5, 7) # 7mm links/rechts → 283mm verfügbar
# Aber wir nutzen 282mm passend zu den Tabellen
pdf.set_margins(7.5, 5, 7.5) # 7.5mm links/rechts → 282mm verfügbar
pdf.add_font('DJ', '', DJV, uni=True)
pdf.add_font('DJ', 'B', DJV_B, uni=True)
pdf.add_font('DJ', 'I', DJV_O, uni=True)
pdf.add_font('DJ', 'BI', DJV_BI, uni=True)
ap_name = _name(project)
groups, seen_pos = _build_summary(positionen)
total_gp = sum(g['gp'] for g in groups.values())
# ── Logo / Titel ─────────────────────────────────────────────────────────
logo_shown = False
if company and company.logo:
lp = company.logo
if not os.path.isabs(lp):
lp = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), lp)
if os.path.exists(lp):
try:
pdf.image(lp, x=5, y=5, w=30)
logo_shown = True
except Exception:
pass
if not logo_shown:
if company and company.name:
pdf.set_font('DJ', 'B', 12)
pdf.set_text_color(*BLUE)
pdf.cell(0, 8, company.name, align='L', new_x='LMARGIN', new_y='NEXT')
pdf.set_font('DJ', 'B', 16)
pdf.set_text_color(*BLUE)
pdf.cell(0, 8, 'Aufmaß', align='C', new_x='LMARGIN', new_y='NEXT')
else:
pdf.set_font('DJ', 'B', 16)
pdf.set_text_color(*BLUE)
pdf.cell(0, 8, 'Aufmaß', align='C', new_x='LMARGIN', new_y='NEXT')
pdf.set_text_color(*BLACK)
pdf.ln(3)
# ── Kopfzeilen (8 Spalten zu je 35.25mm = 282mm) ───────────────────────────
CW = 35.25 # jede Spalte gleich breit
LH = 5.5
pdf.set_font('DJ', '', 7.5)
def cell_lbl(txt, w=CW):
pdf.set_fill_color(242, 242, 242)
pdf.set_font('DJ', 'B', 7.5)
pdf.cell(w, LH, txt, border=1, fill=True, align='L')
def cell_val(txt, w=CW):
pdf.set_font('DJ', '', 7.5)
pdf.cell(w, LH, txt, border=1, align='L')
def nl():
pdf.set_x(7.5)
# Row 1: Vertrag(2) / LV-Name(4) / Aufmaß-Datum(2)
cell_lbl('Vertrag:'); cell_val(_val_or(project.vertrag), CW)
cell_lbl('LV-Name:'); cell_val(_val_or(project.lv_name), CW*3)
cell_lbl('Aufmaß-Datum:'); cell_val(_fmt_date(project.datum) or '', CW*2)
nl(); pdf.ln(LH)
# Row 2: Projekt(2) / Baustelle(6)
cell_lbl('Projekt:'); cell_val(_val_or(project.bezeichnung), CW)
cell_lbl('Baustelle:'); cell_val(_val_or(project.baustelle), CW*5)
nl(); pdf.ln(LH)
# Row 3: Typ(2) / Bauabschnitt(6)
cell_lbl('Typ:'); cell_val(_val_or(aufmass.typ if aufmass else None), CW)
cell_lbl('Bauabschnitt:'); cell_val(_val_or(project.bauabschnitt), CW*5)
nl(); pdf.ln(LH)
# Row 4: SM-Nr(2) / Startdatum(2) / Name(1) / Wert(1) / Tel(2)
cell_lbl('SM-Nr.:'); cell_val(_val_or(project.sm_nr), CW)
cell_lbl('Startdatum:'); cell_val(_fmt_date(project.datum_start) or '', CW)
cell_lbl('Name:'); cell_val(_val_or(ap_name))
cell_lbl('Tel:'); cell_val(_val_or(project.ansprechpartner_tel), CW)
nl(); pdf.ln(LH)
# Row 5: Abruf-Nr(2) / Enddatum(2) / Email(4)
cell_lbl('Abruf-Nr.:'); cell_val(_val_or(project.abruf_nr), CW)
cell_lbl('Enddatum:'); cell_val(_fmt_date(project.datum_ende) or '', CW)
cell_lbl('Email:'); cell_val(_val_or(project.ansprechpartner_email), CW*4)
nl(); pdf.ln(LH + 2)
# ── Positionstabelle ─────────────────────────────────────────────────────
headers = ['Abschn.', 'Pos-Nr', 'Fakt.', 'Länge', 'Breite', 'Tiefe',
'Menge', 'EH', 'Kurztext', 'Bemerkung', 'Menge', 'EP (€)', 'GP (€)']
# Tabellenkopf
pdf.set_fill_color(*BLUE)
pdf.set_text_color(*WHITE)
pdf.set_font('DJ', 'B', 6)
x0 = pdf.get_x()
y0 = pdf.get_y()
for i, h in enumerate(headers):
pdf.cell(W_POS[i], 5, h, border=1, fill=True, align='C')
pdf.ln()
pdf.set_text_color(*BLACK)
# Datenzeilen
pdf.set_font('DJ', '', 6)
row_h = 4.5
gesamt = 0.0
pos_counter = 0
for pos in positionen:
if _ist_trenner(pos):
pdf.set_fill_color(245, 245, 245)
pdf.cell(sum(W_POS), 3, '', border=1, fill=True)
pdf.ln()
continue
pos_counter += 1
menge = pos.menge if pos.menge else None
menge_h = pos.menge_hinten if pos.menge_hinten else None
if pos.einheit in ('ST', 'LE', 'STD', 'h', 'Psch'):
menge = pos.faktor * 1 if pos.faktor else None
gp = pos.gesamtpreis or 0
gesamt += gp
y0 = pdf.get_y()
x0 = pdf.get_x()
# Kurztext & Bemerkung: MultiCell für Wrap, dann Höhe bestimmen
kurz = _val_or(pos.kurztext)
bemerk = _val_or(pos.bemerkung)
# Einfache Zeilenhöhe (kein MultiCell zu komplex)
# Stattdessen: Text auf eine Zeile beschränken (abschneiden bei Überlauf)
MAX_KURZTEXT = 95
MAX_BEMERK = 28
if len(kurz) > MAX_KURZTEXT:
kurz = kurz[:MAX_KURZTEXT-3] + '...'
if len(bemerk) > MAX_BEMERK:
bemerk = bemerk[:MAX_BEMERK-3] + '...'
vals = [
pos.abschnitt or '', pos.pos_nr or '',
_fmt(pos.faktor), _fmt(pos.laenge), _fmt(pos.breite), _fmt(pos.tiefe),
_fmt(menge), pos.einheit or '', kurz, bemerk,
_fmt(menge_h), _fmt(pos.einzelpreis), _fmt(gp)
]
for i, v in enumerate(vals):
a = 'L' if i in (8, 9) else ('C' if i in (0, 7) else 'R')
pdf.cell(W_POS[i], row_h, v, border=1, align=a)
pdf.ln()
# Summenzeile
pdf.set_font('DJ', 'B', 6)
pdf.cell(sum(W_POS[:11]), row_h, '', border=1, align='R')
pdf.cell(W_POS[11], row_h, 'Summe:', border=1, align='R')
pdf.cell(W_POS[12], row_h, f'{gesamt:.2f}', border=1, align='R')
pdf.ln()
# ── Mengen-Zusammenfassung ─────────────────────────────────────────────
if seen_pos:
pdf.ln(4)
pdf.set_fill_color(214, 228, 240)
pdf.set_text_color(*BLUE)
pdf.set_font('DJ', 'B', 9)
pdf.cell(sum(W_SUM), 6, 'Mengen- und Positions-Zusammenfassung', border=1, fill=True, align='C')
pdf.ln()
pdf.set_text_color(*WHITE)
pdf.set_fill_color(*BLUE)
pdf.set_font('DJ', 'B', 6)
for i, h in enumerate(['Pos-Nr', 'Kurztext', 'Menge', 'EP (€)', 'GP (€)']):
pdf.cell(W_SUM[i], 5, h, border=1, fill=True, align='C')
pdf.ln()
pdf.set_text_color(*BLACK)
total = 0.0
pdf.set_font('DJ', '', 7)
MAX_KURZTEXT_SUM = 120
for key in seen_pos:
g = groups[key]
total += g['gp']
kurz_sum = g['kurztext']
if len(kurz_sum) > MAX_KURZTEXT_SUM:
kurz_sum = kurz_sum[:MAX_KURZTEXT_SUM-3] + '...'
vals = [key, kurz_sum, _fmt(g['menge']), _fmt(g['ep']), _fmt(g['gp'])]
for i, v in enumerate(vals):
a = 'L' if i == 1 else 'C' if i == 0 else 'R'
pdf.cell(W_SUM[i], 5, v, border=1, align=a)
pdf.ln()
pdf.set_font('DJ', 'B', 7)
pdf.cell(W_SUM[0] + W_SUM[1], 5, '', border=1)
pdf.cell(W_SUM[2], 5, 'Summe:', border=1, align='R')
pdf.cell(W_SUM[3], 5, '', border=1)
pdf.cell(W_SUM[4], 5, f'{total:.2f}', border=1, align='R')
pdf.ln()
pdf.output(output_path)
return output_path