beherbergungssteuer/bericht_pdf.py
TheMockTv 80967de26f Beherbergungssteuer: Steuerjournal aus den Rechnungs-PDFs
Ersetzt die Excel-Mappe des Campinghofs. Die Rechnungs-PDFs des
Rechnungstools sind die Quelle; SQLite ist nur Cache, Excel nur noch
Import des Altbestands. Bericht fuers Amt als PDF im Briefkopf der
Rechnungen (Firmendaten aus der config.json des Rechnungstools).

- pdf_parser: liest Datum, Nummer, Name, Naechte, Zwischensumme, Satz
- db: Journal + Scan mit Cache (Pfad/mtime/Groesse)
- xlsx_io: Import der alten Tabelle, vereinheitlicht Rechnungsnummern
- bericht_pdf: Monats- und Jahresbericht (reportlab)
- GUI: Monatsreiter, Suche, Anhaken + Papierkorb, Kennzahlen-Leiste

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:15:44 +02:00

318 lines
13 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.

# -*- coding: utf-8 -*-
"""
PDF-Bericht fürs Amt im gleichen Layout wie die Rechnungen des Rechnungstools.
Firmenkopf (Logo) -> Titel + Zeitraum -> Tabelle der Rechnungen
-> Summenblock (Steuer hervorgehoben)
-> Unterschriftszeile -> Bankfußzeile
render_monat(pfad, firma, jahr, monat, buchungen)
render_jahr (pfad, firma, jahr, buchungen) ein Blatt mit allen 12 Monaten
"""
import os
import logging
from datetime import date
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.pdfbase.pdfmetrics import stringWidth
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
from modell import MONATE, summiere, eur, eur_kurz
log = logging.getLogger("bst.bericht")
# Farben wie in der Rechnung
ACCENT = colors.HexColor("#1f5c3d")
GREY_BG = colors.HexColor("#eef1ee")
LINE = colors.HexColor("#9aa39c")
DARK = colors.HexColor("#222222")
MARGIN_L = MARGIN_R = 18 * mm
MARGIN_TOP = 40 * mm
MARGIN_BOT = 34 * mm
PAGE_W, PAGE_H = A4
CONTENT_W = PAGE_W - MARGIN_L - MARGIN_R
def _styles():
return {
"zelle": ParagraphStyle("zelle", fontName="Helvetica", fontSize=8.5,
leading=10.5, textColor=DARK, alignment=TA_LEFT),
"zelle_r": ParagraphStyle("zelle_r", fontName="Helvetica", fontSize=8.5,
leading=10.5, textColor=DARK, alignment=TA_RIGHT),
"zelle_c": ParagraphStyle("zelle_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),
"h2": ParagraphStyle("h2", fontName="Helvetica-Bold", fontSize=15,
leading=19, textColor=ACCENT, spaceAfter=2),
"unter": ParagraphStyle("unter", fontName="Helvetica", fontSize=9.5,
leading=13, textColor=DARK, spaceAfter=6),
"sum_l": ParagraphStyle("sum_l", fontName="Helvetica-Bold", fontSize=9,
leading=13, textColor=DARK, alignment=TA_RIGHT),
"sum_r": ParagraphStyle("sum_r", fontName="Helvetica", fontSize=9,
leading=13, textColor=DARK, alignment=TA_RIGHT),
"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),
"klein": ParagraphStyle("klein", fontName="Helvetica", fontSize=8.5,
leading=11, textColor=DARK),
}
def _kopf_fuss(canvas, doc, firma):
canvas.saveState()
line_y = PAGE_H - 25 * mm
logo = (firma.get("logo") or "").strip()
logo_ok = False
if logo and os.path.exists(logo):
try:
img = ImageReader(logo)
iw, ih = img.getSize()
scale = min(60 * mm / iw, 20 * mm / ih)
dw, dh = iw * scale, ih * scale
ly = PAGE_H - 9 * mm - dh
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
log.warning("Logo nicht ladbar (%s): %s", logo, e)
if logo_ok:
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)
# Fußzeile
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)
links = [firma.get("bank", ""), firma.get("iban", ""), firma.get("bic", "")]
mitte = [firma.get("inhaber", ""), firma.get("steuernummer", "")]
rechts = [firma.get("homepage", ""), firma.get("email", ""), firma.get("telefon", "")]
for i, t in enumerate(links):
canvas.drawString(MARGIN_L, fy + 9 * mm - i * 3.6 * mm, t)
for i, t in enumerate(mitte):
canvas.drawString(MARGIN_L + CONTENT_W * 0.40, fy + 9 * mm - i * 3.6 * mm, t)
for i, t in enumerate(rechts):
canvas.drawRightString(PAGE_W - MARGIN_R, fy + 9 * mm - i * 3.6 * mm, t)
canvas.setFont("Helvetica", 7.3)
canvas.drawCentredString(PAGE_W / 2, fy + 1 * mm, f"Seite {canvas.getPageNumber()}")
canvas.restoreState()
def _titel(story, st, firma, titel, zeitraum):
story.append(Paragraph(
f"<u>{firma.get('absender','')}</u>",
ParagraphStyle("abs", fontName="Helvetica", fontSize=7.5, textColor=DARK)))
story.append(Spacer(1, 6 * mm))
story.append(Paragraph(titel, st["h2"]))
story.append(Paragraph(zeitraum, st["unter"]))
story.append(Spacer(1, 2 * mm))
def _summenblock(story, st, s, satz_text=""):
zeilen = [
[Paragraph("Rechnungen:", st["sum_l"]), Paragraph(str(s.buchungen), st["sum_r"])],
[Paragraph("Übernachtungen:", st["sum_l"]), Paragraph(str(s.naechte), st["sum_r"])],
[Paragraph("Steuerpflichtiges Entgelt:", st["sum_l"]), Paragraph(eur(s.gezahlt), st["sum_r"])],
[Paragraph(f"Beherbergungssteuer{satz_text}:", st["total_l"]),
Paragraph(eur(s.steuer), st["total_v"])],
]
t = Table(zeilen, colWidths=[CONTENT_W - 44 * mm, 44 * mm])
t.setStyle(TableStyle([
("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),
]))
story.append(Spacer(1, 4 * mm))
story.append(t)
def _unterschrift(story, st, ort):
story.append(Spacer(1, 14 * mm))
heute = date.today().strftime("%d.%m.%Y")
t = Table([[Paragraph(f"{ort}, den {heute}", st["klein"]),
Paragraph("Unterschrift / Stempel", st["klein"])]],
colWidths=[CONTENT_W * 0.5, CONTENT_W * 0.5])
t.setStyle(TableStyle([
("LINEABOVE", (0, 0), (0, 0), 0.6, LINE),
("LINEABOVE", (1, 0), (1, 0), 0.6, LINE),
("TOPPADDING", (0, 0), (-1, -1), 2),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (1, 0), (1, 0), 0),
]))
story.append(t)
def _dokument(pfad, firma, titel):
return SimpleDocTemplate(
pfad, pagesize=A4,
leftMargin=MARGIN_L, rightMargin=MARGIN_R,
topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT,
title=titel, author=firma.get("name", ""))
def _ort(firma):
"""Ort aus der Absenderzeile ziehen ('… - 04416 Markkleeberg OT Wachau')."""
teile = [t.strip() for t in str(firma.get("absender", "")).split("-")]
if teile and teile[-1]:
worte = teile[-1].split()
if worte and worte[0].isdigit():
worte = worte[1:] # Postleitzahl weg
return " ".join(worte) or "Markkleeberg"
return "Markkleeberg"
# ------------------------------------------------------------- Monat --------
def render_monat(pfad, firma, jahr: int, monat: int, buchungen):
st = _styles()
liste = sorted([b for b in buchungen if b.jahr == jahr and b.monat == monat],
key=lambda b: (b.datum, b.rechnungsnummer))
s = summiere(liste)
saetze = {b.satz for b in liste}
satz_text = f" ({eur_kurz(list(saetze)[0]).replace(',00','')} %)" if len(saetze) == 1 else ""
story = []
_titel(story, st, firma, "Beherbergungssteuer Steueranmeldung",
f"Zeitraum: {MONATE[monat - 1]} {jahr}")
kopf = ["Datum", "Rechnung", "Name", "Nächte", "Entgelt", "Satz %", "Steuer"]
stile = ["th", "th", "th_l", "th", "th", "th", "th"]
daten = [[Paragraph(k, st[s_]) for k, s_ in zip(kopf, stile)]]
for b in liste:
daten.append([
Paragraph(b.datum.strftime("%d.%m.%Y"), st["zelle_c"]),
Paragraph(b.voll_nummer, st["zelle_c"]),
Paragraph(b.nachname, st["zelle"]),
Paragraph(str(b.naechte), st["zelle_c"]),
Paragraph(eur_kurz(b.gezahlt), st["zelle_r"]),
Paragraph(eur_kurz(b.satz).replace(",00", ""), st["zelle_c"]),
Paragraph(eur_kurz(b.steuer), st["zelle_r"]),
])
if len(daten) == 1:
daten.append([Paragraph("", st["zelle_c"]),
Paragraph("<i>(keine Buchungen in diesem Monat)</i>", st["zelle"]),
"", "", "", "", ""])
# Namensspalte nur so breit wie der breiteste Name (Rest der Tabelle ist fest)
breiteste = max([stringWidth(b.nachname, "Helvetica", 8.5) for b in liste]
+ [stringWidth("Name", "Helvetica-Bold", 8)])
name_w = min(max(breiteste + 6 * mm, 25 * mm), CONTENT_W - 120 * mm)
tabelle = Table(daten, repeatRows=1, hAlign="LEFT",
colWidths=[22 * mm, 22 * mm, name_w, 15 * mm, 22 * mm, 14 * mm, 25 * mm])
tabelle.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(tabelle)
_summenblock(story, st, s, satz_text)
story.append(Spacer(1, 3 * mm))
story.append(Paragraph(
"Die Beherbergungssteuer wird gemäß kommunaler Satzung auf das steuerpflichtige "
"Entgelt der Beherbergungsleistungen erhoben und mit den Gästen abgerechnet.",
st["klein"]))
_unterschrift(story, st, _ort(firma))
_dokument(pfad, firma, f"Beherbergungssteuer {MONATE[monat - 1]} {jahr}").build(
story,
onFirstPage=lambda c, d: _kopf_fuss(c, d, firma),
onLaterPages=lambda c, d: _kopf_fuss(c, d, firma))
log.info("Monatsbericht %s: %d Buchungen, Steuer %.2f", pfad, s.buchungen, s.steuer)
return pfad
# -------------------------------------------------------------- Jahr --------
def render_jahr(pfad, firma, jahr: int, buchungen):
st = _styles()
liste = [b for b in buchungen if b.jahr == jahr]
gesamt = summiere(liste)
story = []
_titel(story, st, firma, "Beherbergungssteuer Jahresübersicht", f"Zeitraum: Jahr {jahr}")
kopf = ["Monat", "Rechnungen", "Übernachtungen", "Entgelt", "Steuer"]
daten = [[Paragraph(k, st["th_l"] if i == 0 else st["th"]) for i, k in enumerate(kopf)]]
for m in range(1, 13):
s = summiere([b for b in liste if b.monat == m])
daten.append([
Paragraph(MONATE[m - 1], st["zelle"]),
Paragraph(str(s.buchungen), st["zelle_c"]),
Paragraph(str(s.naechte), st["zelle_c"]),
Paragraph(eur_kurz(s.gezahlt), st["zelle_r"]),
Paragraph(eur_kurz(s.steuer), st["zelle_r"]),
])
daten.append([
Paragraph("Summe", st["total_l"]),
Paragraph(str(gesamt.buchungen), st["total_v"]),
Paragraph(str(gesamt.naechte), st["total_v"]),
Paragraph(eur_kurz(gesamt.gezahlt), st["total_v"]),
Paragraph(eur_kurz(gesamt.steuer), st["total_v"]),
])
tabelle = Table(daten, repeatRows=1,
colWidths=[40 * mm, 28 * mm, 34 * mm, 34 * mm, 38 * mm])
tabelle.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), GREY_BG),
("LINEBELOW", (0, 0), (-1, 0), 0.8, ACCENT),
("LINEBELOW", (0, 1), (-1, -2), 0.35, LINE),
("LINEABOVE", (0, -1), (-1, -1), 0.8, ACCENT),
("LINEBELOW", (0, -1), (-1, -1), 0.8, ACCENT),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 3.5),
("BOTTOMPADDING", (0, 0), (-1, -1), 3.5),
("LEFTPADDING", (0, 0), (-1, -1), 3),
("RIGHTPADDING", (0, 0), (-1, -1), 3),
]))
story.append(tabelle)
_unterschrift(story, st, _ort(firma))
_dokument(pfad, firma, f"Beherbergungssteuer Jahresübersicht {jahr}").build(
story,
onFirstPage=lambda c, d: _kopf_fuss(c, d, firma),
onLaterPages=lambda c, d: _kopf_fuss(c, d, firma))
log.info("Jahresbericht %s: %d Buchungen, Steuer %.2f", pfad, gesamt.buchungen, gesamt.steuer)
return pfad