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