Sein Wort: "musst dann rein schreiben storniert oder so was dran setzen". Im Bericht fuers Amt stand bisher nur die Rechnungsnummer. Bei einem Storno sieht das Amt damit eine Stornonummer neben einer voellig anderen Rechnungsnummer und im Folgemonat eine Minuszeile - ohne einen Hinweis, was wozu gehoert. Im Fenster gibt es dafuer die Spalte "Art", auf dem Blatt fehlte sie. Jetzt steht unter der Nummer in kleiner grauer Schrift dasselbe Wort wie im Fenster: "storniert", "Storno zu 2026-900", "Neuausstellung". Die Zuordnung wird ueber ALLE uebergebenen Buchungen bestimmt (das ganze Jahr), weil der Storno in einem anderen Monat liegen kann als seine Rechnung - genau der Fall, um den es geht. An den Summen aendert sich nichts: verrechnet bleibt verrechnet. pruef_storno_verrechnet prueft die drei Beschriftungen jetzt mit. Angesehen wurde es auch: Monatsbericht August und September als Bild gerendert, Zeilen und Summen stimmen (August 9 Naechte/117,00, September -4/-52,00). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bEsoFUk16DfnNA7MjY36H
341 lines
15 KiB
Python
341 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, aufgehobene, 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:
|
||
return "storniert"
|
||
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 = aufgehobene(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}")
|
||
|
||
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 + (f'<br/><font size="6.5" color="#555555">'
|
||
f'{_art_text(b, weg)}</font>'
|
||
if _art_text(b, weg) else ""), 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
|