Initial commit – AufmaßCreater v2.35
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
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
|
||||
Reference in New Issue
Block a user