Rechnungstool Campinghof - Rechnungen als PDF statt Excel-Vorlage
Desktop-Programm (Python/Tkinter): Kundendaten + Leistungen eingeben, druckfertige PDF-Rechnung im Briefkopf des Betriebs. Optionale Beherbergungssteuer wird eigen ausgewiesen und bekommt ein zweites Blatt fuers Amt. Firmendaten, Preise und Steuersaetze stehen in der config.json neben dem Programm (nicht im Repo) und werden im Menue "Einstellungen" gepflegt. Im Code stehen nur Platzhalter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
dc170a9d9c
8 changed files with 1692 additions and 0 deletions
357
pdf_renderer.py
Normal file
357
pdf_renderer.py
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
# -*- 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"<u>{cfg['firma'].get('absender','')}</u>",
|
||||
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<br/>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("<i>(keine Positionen erfasst)</i>", 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue