Sein Hinweis: "da ist noch so viel platz, die naechte und satz alleine nehmen so viel raum ein auf dem blatt, da kannst locker zeile begruendung oder so was setzen und da dann storno zu und die nummer vom storno". Stimmt: die Zahlenspalten standen mit viel Luft nebeneinander, waehrend die Begruendung klein gedruckt war. Neu verteilt (in mm): Datum 19 | Rechnung 19 | Begruendung 44 | Name Rest | Naechte 12 | Entgelt 19 | Satz 10 | Steuer 19 Damit passt "storniert mit 2026-901" in EINE Zeile, in normaler Schriftgroesse statt 7,5 pt. Spaltenkopf heisst jetzt "Begruendung" (sein Wort) und "Satz %" nur noch "Satz" - das Prozentzeichen steht ohnehin nirgends im Wert. Angesehen: Tabelle als Bild gerendert, alles einzeilig, auch mit langem Namen. pruef_storno_verrechnet und pruef_berichtigung gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bEsoFUk16DfnNA7MjY36H
353 lines
15 KiB
Python
353 lines
15 KiB
Python
# -*- 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_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:
|
||
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", "Begründung", "Name", "Nächte", "Entgelt", "Satz", "Steuer"]
|
||
stile = ["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(_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 - 142 * mm)
|
||
|
||
tabelle = Table(daten, repeatRows=1, hAlign="LEFT",
|
||
colWidths=[19 * mm, 19 * mm, 44 * mm, name_w,
|
||
12 * mm, 19 * mm, 10 * mm, 19 * 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
|