# -*- coding: utf-8 -*-
"""
PDF-Renderer fuer die Campinghof-Bartl-Rechnung (reportlab).
render_rechnung(pfad, cfg, rechnung, kopf) erzeugt ein druckfertiges A4-PDF
im Layout der Excel-Vorlage:
Firmenkopf -> Absenderzeile -> Kundenadresse | Rechnungsinfos
-> Positionstabelle -> Summen + Steueraufstellung
-> Danktexte -> Bankfusszeile
"""
import os
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.lib import colors
from reportlab.lib.enums import TA_RIGHT, TA_LEFT, TA_CENTER
from reportlab.lib.styles import ParagraphStyle
from reportlab.platypus import (
SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, KeepTogether, PageBreak,
)
import config
from berechnung import eur, eur_kurz, runde
# ---- Farben / Masse ---------------------------------------------------------
ACCENT = colors.HexColor("#1f5c3d") # gedecktes Gruen (Camping)
GREY_BG = colors.HexColor("#eef1ee")
LINE = colors.HexColor("#9aa39c")
DARK = colors.HexColor("#222222")
MARGIN_L = 18 * mm
MARGIN_R = 18 * mm
MARGIN_TOP = 40 * mm # Platz fuer den Kopf
MARGIN_BOT = 34 * mm # Platz fuer die Fusszeile
PAGE_W, PAGE_H = A4
CONTENT_W = PAGE_W - MARGIN_L - MARGIN_R # ~174mm
# ---- Absatz-Stile -----------------------------------------------------------
def _styles():
return {
"leistung": ParagraphStyle("leistung", fontName="Helvetica", fontSize=8.5,
leading=10.5, textColor=DARK, alignment=TA_LEFT),
"cell_r": ParagraphStyle("cell_r", fontName="Helvetica", fontSize=8.5,
leading=10.5, textColor=DARK, alignment=TA_RIGHT),
"cell_c": ParagraphStyle("cell_c", fontName="Helvetica", fontSize=8.5,
leading=10.5, textColor=DARK, alignment=TA_CENTER),
"th": ParagraphStyle("th", fontName="Helvetica-Bold", fontSize=8,
leading=9.3, textColor=DARK, alignment=TA_CENTER),
"th_l": ParagraphStyle("th_l", fontName="Helvetica-Bold", fontSize=8,
leading=9.3, textColor=DARK, alignment=TA_LEFT),
"sum_r": ParagraphStyle("sum_r", fontName="Helvetica-Bold", fontSize=8.5,
leading=10.5, textColor=DARK, alignment=TA_RIGHT),
"sum_l": ParagraphStyle("sum_l", fontName="Helvetica-Bold", fontSize=8.5,
leading=10.5, textColor=DARK, alignment=TA_LEFT),
"addr": ParagraphStyle("addr", fontName="Helvetica", fontSize=10,
leading=13, textColor=DARK),
"addr_label": ParagraphStyle("addr_label", fontName="Helvetica-Bold",
fontSize=9, leading=12, textColor=ACCENT),
"info_l": ParagraphStyle("info_l", fontName="Helvetica-Bold", fontSize=9,
leading=13, textColor=DARK, alignment=TA_RIGHT),
"info_v": ParagraphStyle("info_v", fontName="Helvetica", fontSize=9,
leading=13, textColor=DARK, alignment=TA_LEFT),
"h": ParagraphStyle("h", fontName="Helvetica-Bold", fontSize=11,
leading=14, textColor=ACCENT, spaceBefore=4, spaceAfter=4),
"h2": ParagraphStyle("h2", fontName="Helvetica-Bold", fontSize=15,
leading=19, textColor=ACCENT, spaceAfter=4),
"dank": ParagraphStyle("dank", fontName="Helvetica", fontSize=8.5,
leading=11, textColor=DARK, spaceAfter=4),
"hinweis": ParagraphStyle("hinweis", fontName="Helvetica-Oblique", fontSize=8.5,
leading=11, textColor=ACCENT, alignment=TA_CENTER, spaceBefore=4),
"total_l": ParagraphStyle("total_l", fontName="Helvetica-Bold", fontSize=10.5,
leading=14, textColor=DARK, alignment=TA_RIGHT),
"total_v": ParagraphStyle("total_v", fontName="Helvetica-Bold", fontSize=10.5,
leading=14, textColor=ACCENT, alignment=TA_RIGHT),
}
# ---- Kopf + Fusszeile (auf jeder Seite gezeichnet) --------------------------
def _kopf_fuss(canvas, doc, cfg):
firma = cfg["firma"]
canvas.saveState()
# --- Kopf ---
logo = (firma.get("logo") or "").strip()
# relativen Pfad (z.B. "assets/logo.png") portabel aufloesen:
# erst neben Tool/exe, dann im gebuendelten Ressourcen-Ordner (.exe)
if logo and not os.path.isabs(logo):
for _base in (config.BASE_DIR, config.RESOURCE_DIR):
_cand = os.path.join(_base, logo)
if os.path.exists(_cand):
logo = _cand
break
logo_ok = False
if logo and os.path.exists(logo):
try:
img = ImageReader(logo)
iw, ih = img.getSize()
box_w, box_h = 60 * mm, 20 * mm # max. Logo-Box oben links
scale = min(box_w / iw, box_h / ih)
dw, dh = iw * scale, ih * scale
ly = PAGE_H - 9 * mm - dh # Oberkante ~9mm vom Blattrand
canvas.drawImage(img, MARGIN_L, ly, width=dw, height=dh,
preserveAspectRatio=True, mask="auto")
logo_ok = True
line_y = ly - 3 * mm
except Exception as e: # noqa: BLE001
print("[pdf] Logo konnte nicht geladen werden:", e)
if logo_ok:
# Firmenname + Untertitel rechtsbuendig neben dem Logo
top = PAGE_H - 15 * mm
canvas.setFillColor(ACCENT)
canvas.setFont("Helvetica-Bold", 16)
canvas.drawRightString(PAGE_W - MARGIN_R, top, firma.get("name", ""))
canvas.setFillColor(DARK)
canvas.setFont("Helvetica-Oblique", 9.5)
canvas.drawRightString(PAGE_W - MARGIN_R, top - 5.5 * mm, firma.get("untertitel", ""))
else:
top = PAGE_H - 16 * mm
canvas.setFillColor(ACCENT)
canvas.setFont("Helvetica-Bold", 19)
canvas.drawString(MARGIN_L, top, firma.get("name", ""))
canvas.setFillColor(DARK)
canvas.setFont("Helvetica-Oblique", 10.5)
canvas.drawString(MARGIN_L, top - 6.2 * mm, firma.get("untertitel", ""))
line_y = top - 9.5 * mm
canvas.setStrokeColor(ACCENT)
canvas.setLineWidth(1.1)
canvas.line(MARGIN_L, line_y, PAGE_W - MARGIN_R, line_y)
# --- Fusszeile ---
fy = MARGIN_BOT - 4 * mm
canvas.setStrokeColor(LINE)
canvas.setLineWidth(0.6)
canvas.line(MARGIN_L, fy + 13 * mm, PAGE_W - MARGIN_R, fy + 13 * mm)
canvas.setFont("Helvetica", 7.3)
canvas.setFillColor(DARK)
col_l = MARGIN_L
col_m = MARGIN_L + CONTENT_W * 0.40
col_r = PAGE_W - MARGIN_R
rows_l = [firma.get("bank", ""), firma.get("iban", ""), firma.get("bic", "")]
rows_m = [firma.get("inhaber", ""), firma.get("steuernummer", "")]
rows_r = [firma.get("homepage", ""), firma.get("email", ""), firma.get("telefon", "")]
for i, t in enumerate(rows_l):
canvas.drawString(col_l, fy + 9 * mm - i * 3.6 * mm, t)
for i, t in enumerate(rows_m):
canvas.drawString(col_m, fy + 9 * mm - i * 3.6 * mm, t)
for i, t in enumerate(rows_r):
canvas.drawRightString(col_r, fy + 9 * mm - i * 3.6 * mm, t)
canvas.restoreState()
# ---- Hauptfunktion ----------------------------------------------------------
def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
"""Baut den kompletten Seiteninhalt (Absender -> Positionen -> Summen -> Danktexte).
gesamtrechnung_betont=True hebt die Zwischensumme-Zeile wie den Gesamtbetrag hervor
(fett + zwei Streifen) -> fuer das Amt-Blatt. Sonst 1:1 die Kundenseite.
"""
# Absenderzeile (klein, unterstrichen)
story.append(Paragraph(
f"{cfg['firma'].get('absender','')}",
ParagraphStyle("abs", fontName="Helvetica", fontSize=7.5, textColor=DARK)))
story.append(Spacer(1, 5 * mm))
# Kundenadresse (links) | Rechnungsinfos (rechts)
kunde_flow = [Paragraph("Rechnung an:", st["addr_label"])]
for z in kopf.get("kunde_zeilen", []):
if str(z).strip():
kunde_flow.append(Paragraph(str(z), st["addr"]))
if len(kunde_flow) == 1:
kunde_flow.append(Paragraph(" ", st["addr"]))
info_rows = [
("Rechnungsnummer:", kopf.get("rechnungsnummer", "")),
("Rechnungsdatum:", kopf.get("datum", "")),
]
if kopf.get("anreise"):
info_rows.append(("Anreise:", kopf.get("anreise", "")))
if kopf.get("abreise"):
info_rows.append(("Abreise:", kopf.get("abreise", "")))
if kopf.get("naechte") not in (None, "", 0, "0"):
info_rows.append(("Zeitraum:", f"{kopf.get('naechte')} Nächte"))
info_tbl = Table(
[[Paragraph(k, st["info_l"]), Paragraph(str(v), st["info_v"])] for k, v in info_rows],
colWidths=[39 * mm, 25 * mm])
info_tbl.setStyle(TableStyle([
("VALIGN", (0, 0), (-1, -1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 0),
("BOTTOMPADDING", (0, 0), (-1, -1), 1),
]))
head = Table([[kunde_flow, info_tbl]], colWidths=[CONTENT_W - 64 * mm, 64 * mm])
head.setStyle(TableStyle([
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 0),
]))
story.append(head)
story.append(Spacer(1, 7 * mm))
# Ueberschrift (auf dem Amt-Blatt mit Zusatz)
titel = "Rechnungsübersicht" + (" – Beherbergungssteuer (für das Amt)" if gesamtrechnung_betont else "")
story.append(Paragraph(titel, st["h"]))
# Positionstabelle
col_w = [10 * mm, 70 * mm, 12 * mm, 21 * mm, 13 * mm, 22 * mm, 26 * mm]
data = [[
Paragraph("Pos.", st["th"]),
Paragraph("Leistung", st["th_l"]),
Paragraph("USt %", st["th"]),
Paragraph("Anzahl
Tage/Menge", st["th"]),
Paragraph("Pers.", st["th"]),
Paragraph("E.-Preis", st["th"]),
Paragraph("Gesamt", st["th"]),
]]
for z in rechnung.zeilen:
pers = eur_kurz(z.personen).replace(",00", "") if z.pro_person else ""
data.append([
Paragraph(str(z.nr), st["cell_c"]),
Paragraph(z.leistung, st["leistung"]),
Paragraph(str(z.satz), st["cell_c"]),
Paragraph(eur_kurz(z.anzahl).replace(",00", ""), st["cell_c"]),
Paragraph(pers, st["cell_c"]),
Paragraph(eur_kurz(z.preis), st["cell_r"]),
Paragraph(eur_kurz(z.brutto), st["cell_r"]),
])
if len(data) == 1: # keine Position erfasst
data.append([Paragraph("", st["cell_c"]),
Paragraph("(keine Positionen erfasst)", st["leistung"]),
"", "", "", "", ""])
items = Table(data, colWidths=col_w, repeatRows=1)
items.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), GREY_BG),
("LINEBELOW", (0, 0), (-1, 0), 0.8, ACCENT),
("LINEBELOW", (0, 1), (-1, -1), 0.35, LINE),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
("LEFTPADDING", (0, 0), (-1, -1), 3),
("RIGHTPADDING", (0, 0), (-1, -1), 3),
]))
story.append(items)
story.append(Spacer(1, 3 * mm))
# ---- Summenblock: Netto -> USt je Satz -> Zwischensumme (brutto)
# -> zzgl. Beherbergungssteuer -> Gesamtbetrag ----
summen_rows = [[
Paragraph("Nettobetrag:", st["info_l"]),
Paragraph(eur(rechnung.netto_gesamt), st["cell_r"]),
]]
for b in rechnung.bloecke:
name = b.bezeichnung or "USt"
summen_rows.append([
Paragraph(f"{name} {b.satz} %:", st["info_l"]),
Paragraph(eur(b.ust), st["cell_r"]),
])
gr_idx = None
if rechnung.aufschlaege:
gr_idx = len(summen_rows) # Zeilen-Index der Zwischensumme
gr_l = st["total_l"] if gesamtrechnung_betont else st["info_l"]
gr_v = st["total_v"] if gesamtrechnung_betont else st["cell_r"]
summen_rows.append([
Paragraph("Zwischensumme:", gr_l),
Paragraph(eur(rechnung.brutto), gr_v),
])
for a in rechnung.aufschlaege:
satz_txt = eur_kurz(a.satz).replace(",00", "")
summen_rows.append([
Paragraph(f"zzgl. {a.bezeichnung or 'Aufschlag'} ({satz_txt} %):", st["info_l"]),
Paragraph(eur(a.betrag), st["cell_r"]),
])
summen_rows.append([
Paragraph("Gesamtbetrag:", st["total_l"]),
Paragraph(eur(rechnung.gesamt), st["total_v"]),
])
style_cmds = [
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 1.5),
("BOTTOMPADDING", (0, 0), (-1, -1), 1.5),
("LINEABOVE", (0, -1), (1, -1), 0.8, ACCENT),
("LINEBELOW", (0, -1), (1, -1), 0.8, ACCENT),
("TOPPADDING", (0, -1), (-1, -1), 4),
]
if gesamtrechnung_betont and gr_idx is not None:
# Zwischensumme wie der Gesamtbetrag hervorheben: zwei Streifen
style_cmds += [
("LINEABOVE", (0, gr_idx), (1, gr_idx), 0.8, ACCENT),
("LINEBELOW", (0, gr_idx), (1, gr_idx), 0.8, ACCENT),
("TOPPADDING", (0, gr_idx), (-1, gr_idx), 4),
]
summen = Table(summen_rows, colWidths=[CONTENT_W - 44 * mm, 44 * mm])
summen.setStyle(TableStyle(style_cmds))
story.append(summen)
# Hinweis zur Beherbergungssteuer
for a in rechnung.aufschlaege:
if not a.bezeichnung:
continue
satz_txt = eur_kurz(a.satz).replace(",00", "")
story.append(Spacer(1, 1.5 * mm))
story.append(Paragraph(
f"Die {a.bezeichnung} ({satz_txt} %) wird gemäß kommunaler Satzung auf die "
f"Zwischensumme erhoben und ist im Gesamtbetrag enthalten.", st["dank"]))
story.append(Spacer(1, 8 * mm))
# ---- Danktexte + Hinweis (nur Kundenseite; auf dem Amt-Blatt unnoetig) ----
if not gesamtrechnung_betont:
texte = cfg.get("texte", {})
block = []
if texte.get("dank1"):
block.append(Paragraph(texte["dank1"], st["dank"]))
if texte.get("dank2"):
block.append(Paragraph(texte["dank2"], st["dank"]))
if texte.get("hinweis"):
block.append(Paragraph(texte["hinweis"], st["hinweis"]))
story.append(KeepTogether(block))
def render_rechnung(pfad, cfg, rechnung, kopf):
"""
pfad : Zielpfad der PDF
cfg : geladene Konfiguration
rechnung : berechnung.Rechnung
kopf : dict mit rechnungsnummer, datum, anreise, abreise, naechte, kunde_zeilen
EIN PDF mit zwei Blaettern (falls Beherbergungssteuer anfaellt):
Seite 1 = Kunden-Rechnung.
Seite 2 = 1:1-Kopie fuers Amt, nur die Zwischensumme fett + zwei Streifen.
"""
st = _styles()
doc = SimpleDocTemplate(
pfad, pagesize=A4,
leftMargin=MARGIN_L, rightMargin=MARGIN_R,
topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT,
title=f"Rechnung {kopf.get('rechnungsnummer','')}",
author=cfg["firma"].get("name", ""),
)
story = []
_seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=False) # Seite 1: Kunde
if rechnung.aufschlaege:
story.append(PageBreak())
_seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=True) # Seite 2: Amt
doc.build(
story,
onFirstPage=lambda c, d: _kopf_fuss(c, d, cfg),
onLaterPages=lambda c, d: _kopf_fuss(c, d, cfg),
)
return pfad