Steuerrechnungstool/bericht_pdf.py
TheMockTv 379afbd9c1 Amtsbericht: Spalte "Art" mit R/S/B und Legende unter der Tabelle
Sein Einwand: "du musst die markieren als rechnung und storno oder
berichtigung, und das kannst einfach mit buchstaben machen: r fuer
rechnung, s fuer storno, b fuer berichtigung. am footer kannst eine
erklaerung fuer die buchstaben machen."

Er hat recht - ohne die Marke muss man aus der Begruendung raten, was fuer
ein Beleg die Zeile ueberhaupt ist. Mit dem Buchstaben liest sich die
Kette von selbst:

  2026-900  R  storniert mit 2026-901
  2026-901  S  neue Rechnung 2026-902
  2026-902  B  Neuausstellung
  2026-911  S  abgerechnet mit 2026-905     (Doppelungsfall)

Unter der Tabelle steht die Legende: "R = Rechnung, S = Stornorechnung,
B = berichtigte Rechnung (Neuausstellung nach einem Storno)". Bewusst
direkt unter die Tabelle und nicht in die Fusszeile - wer eine einzelne
Zeile prueft, hat sie dann vor Augen und muss nicht blaettern.

Dazu "neu abgerechnet mit" auf "abgerechnet mit" gekuerzt: mit dem S
davor ist das "neu" ueberfluessig, und die Zeile bricht nicht mehr um.
Spaltenbreiten neu verteilt (18/18/9/42/Rest/12/18/9/18 mm).

Erklaerungs-PDF um die Art-Spalte ergaenzt. Pruefkette gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bEsoFUk16DfnNA7MjY36H
2026-09-04 02:29:48 +02:00

391 lines
17 KiB
Python
Raw Permalink 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, aufhebungen, 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: nur die Seitenzahl.
#
# Hier stand vorher der komplette Briefkopf-Fuß – Bankverbindung, Steuernummer,
# Inhaber, Homepage, E-Mail, Telefon. Zwei Gründe, warum das weg ist:
#
# 1. Es ist eine Meldung, keine Rechnung. Das Amt zieht nichts ein und überweist
# nichts; Bankdaten und Steuernummer haben darin nichts verloren. Auf der
# Rechnung (Rechnungstool) bleiben sie, dort werden sie gebraucht.
# 2. Der Block lag ÜBER dem unteren Seitenrand (bottomMargin) und ragte damit in
# die Tabelle hinein – auf vollen Seiten stand er quer über der letzten Zeile.
#
# Die Seitenzahl sitzt bewusst unterhalb von MARGIN_BOT, sonst passiert dasselbe
# wieder.
canvas.setFont("Helvetica", 7.3)
canvas.setFillColor(DARK)
canvas.drawCentredString(PAGE_W / 2, MARGIN_BOT - 12 * 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 _art_kuerzel(b) -> str:
"""Ein Buchstabe je Beleg - seine Ansage vom 04.09.2026: *"du musst die
markieren als rechnung und storno oder berichtigung, und das kannst einfach
mit buchstaben machen"*. Ohne die Marke muss man aus der Begruendung raten,
was fuer ein Beleg die Zeile ueberhaupt ist.
R = Rechnung
S = Stornorechnung
B = berichtigte Rechnung (die Neuausstellung nach einem Storno)
"""
if b.storno_zu or b.art == "storno":
return "S"
if b.art == "korrektur":
return "B"
return "R"
def _art_text(b, weg) -> str:
"""Was in der Rechnungsspalte unter der Nummer steht.
Ohne das steht im Bericht fuers Amt eine Stornonummer neben einer voellig
anderen Rechnungsnummer, und niemand sieht, was zusammengehoert. Sein Wort
dazu am 04.09.2026: *"musst dann rein schreiben storniert oder so was dran
setzen"*. Dieselben Worte wie in der Spalte "Art" im Fenster.
"""
if b.storno_zu:
# Die Kette zeigt VORWAERTS: Rechnung -> Storno -> Rechnung, die nun gilt.
# Ein Storno, der nur zurueckzeigt, laesst den Leser stehen ("wer soll
# denn sonst wissen, wo er suchen muss" - 04.09.2026).
if b.folge_nummer and b.folge_art == "vorhanden":
return f"abgerechnet mit {b.folge_nummer}"
if b.folge_nummer:
return f"neue Rechnung {b.folge_nummer}"
if b.folge_art == "" and b.art == "storno" and not b.folge_nummer:
# Entweder "nur Storno" oder ein alter Beleg ohne die Angabe. Dann
# bleibt der Rueckbezug - besser als gar kein Hinweis.
return f"Storno zu {b.storno_zu}"
return f"Storno zu {b.storno_zu}"
if b.id in weg:
# Nicht nur "storniert": auch WOMIT. Sonst steht auf dem Blatt ein
# Vermerk ohne Gegenstueck und man muss die Zeile suchen - erst recht,
# wenn der Storno in einem anderen Monat liegt und gar nicht auf
# demselben Blatt steht. Sein Hinweis vom 04.09.2026.
return f"storniert mit {weg[b.id].voll_nummer}"
if b.art == "korrektur":
return "Neuausstellung"
return ""
def render_monat(pfad, firma, jahr: int, monat: int, buchungen):
st = _styles()
# Ueber ALLE uebergebenen Buchungen bestimmt (das ganze Jahr): der Storno
# kann in einem anderen Monat liegen als die Rechnung, die er aufhebt.
weg = aufhebungen(buchungen)
# Storno und aufgehobene Rechnung stehen BEIDE im Bericht, jede in ihrem
# Monat und mit ihrem Vorzeichen - so ist die Meldung aus sich heraus
# nachvollziehbar (Entscheidung des Auftraggebers, siehe modell.summiere).
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}")
# Eigene Spalte "Grund" statt einer zweiten Zeile unter der Nummer - seine
# Ansage vom 04.09.2026: "mach eine neue spalte mit grund oder so was".
kopf = ["Datum", "Rechnung", "Art", "Begründung", "Name",
"Nächte", "Entgelt", "Satz", "Steuer"]
stile = ["th", "th", "th", "th_l", "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(f'<b>{_art_kuerzel(b)}</b>', st["zelle_c"]),
Paragraph(_art_text(b, weg), st["zelle"]),
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)])
# Die Zahlenspalten brauchen wenig - "Naechte", "Satz" und die Betraege
# standen frueher mit viel Luft nebeneinander. Sein Hinweis vom 04.09.2026:
# *"da ist noch so viel platz, die naechte und satz alleine nehmen so viel
# raum ein, da kannst locker zeile begruendung setzen"*. Also enger gestellt
# und der Begruendung so viel gegeben, dass "storniert mit 2026-901" in EINE
# Zeile passt - in normaler Schriftgroesse, nicht kleingedruckt.
name_w = min(max(breiteste + 6 * mm, 22 * mm), CONTENT_W - 144 * mm)
tabelle = Table(daten, repeatRows=1, hAlign="LEFT",
colWidths=[18 * mm, 18 * mm, 9 * mm, 42 * mm, name_w,
12 * mm, 18 * mm, 9 * mm, 18 * 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)
# Legende zu den Buchstaben - direkt unter der Tabelle, damit sie beim Lesen
# da ist und nicht auf der naechsten Seite. Wer nur eine Zeile prueft, muss
# nicht raten, was fuer ein Beleg sie ist.
story.append(Spacer(1, 1.5 * mm))
story.append(Paragraph(
"<b>Art:</b> R = Rechnung · S = Stornorechnung · B = berichtigte Rechnung "
"(Neuausstellung nach einem Storno). Die Spalte Begründung nennt den Beleg, "
"bei dem es weitergeht.", st["klein"]))
_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