rechnungstool/pdf_renderer.py
TheMockTv c190887621 Proforma-Blatt heisst auch Kostenvoranschlag - das ist sein Zweck
Seine Erklaerung: 'es geht nur darum das er seinen kunden wie eine art
kostenvoranschlag/proforma rechnung das die wissen ah das kommt auf mich zu und
wenn die ja sagen er dann die richtige machen tut'. Ueberschrift jetzt
'Proforma-Rechnung (Kostenvoranschlag)', im Text 'Dies ist ein
Kostenvoranschlag' - damit steht die Natur des Blattes ganz vorn und nicht das
Wort Rechnung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx
2026-09-03 21:27:20 +02:00

687 lines
33 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-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
import io
import json
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,
Flowable,
)
import config
from berechnung import eur, eur_kurz, runde
class _AmtMarke(Flowable):
"""Unsichtbar. Haelt fest, auf welcher Seite das Amt-Blatt beginnt.
Nur so kann die Fusszeile die Kundenseite vom Amt-Blatt unterscheiden - sie wird
gezeichnet, bevor der Seiteninhalt steht, und weiss von sich aus nichts davon.
"""
def __init__(self, merker):
super().__init__()
self._merker = merker
def wrap(self, *_):
return (0, 0)
def draw(self):
self._merker["seite"] = self.canv.getPageNumber()
# ---- 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, amt=False):
"""Kopf und Fuss einer Seite. amt=True -> Blatt fuer das Amt (siehe Fusszeile)."""
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 ---
#
# STRIKTE TRENNUNG Kunde / Amt:
# Kundenseite : voller Briefkopf-Fuss - Bankverbindung und Steuernummer MUESSEN
# drauf, das ist die Rechnung, die der Gast bezahlt.
# Amt-Blatt : KEINE Bankverbindung, KEINE Steuernummer. Das Amt zieht nichts
# ein und ueberweist nichts. Es bleiben Inhaber (wer meldet) und
# die Kontaktdaten fuer Rueckfragen.
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 = [] if amt else [firma.get("bank", ""), firma.get("iban", ""), firma.get("bic", "")]
rows_m = [firma.get("inhaber", "")] if amt else [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 _positionstabelle(story, st, rechnung, ohne_ust=False):
"""Die gewohnte Positionstabelle der Campingrechnung.
ohne_ust=True laesst die Spalte mit dem Steuersatz weg - auf einer
Proforma steht bewusst kein Steuersatz und kein Steuerbetrag
(§ 14c UStG: wer Steuer ausweist, schuldet sie)."""
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("" if ohne_ust else "USt&nbsp;%", 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("" if ohne_ust else 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)
def _montage_zeile(story, st, rechnung, kopf):
"""Das Montage-Blatt: eine Textzeile, darunter Netto / USt / Brutto.
Bartl schreibt dieses Blatt seit Jahren von Hand ("Miete fuer Montagearbeiter
inkl. 7 % MwSt. fuer den Monat August 2026"), darum hat es keine Tabelle mit
Anzahl und Einzelpreis. Gerechnet wird trotzdem im selben Rechenkern - die
Zahlen kommen aus rechnung.zeilen, nicht aus dem Text.
"""
text = kopf.get("montage_text") or ""
if not text:
text = ", ".join(z.leistung for z in rechnung.zeilen)
story.append(Paragraph(text, st["leistung"]))
story.append(Spacer(1, 3 * mm))
netto = sum(z.netto for z in rechnung.zeilen)
ust = rechnung.brutto - netto
dreier = Table(
[[Paragraph(f"Netto {eur(netto)}", st["cell_c"]),
Paragraph(f"USt. {eur(ust)}", st["cell_c"]),
Paragraph(f"Brutto {eur(rechnung.brutto)}", st["cell_c"])]],
colWidths=[CONTENT_W / 3.0] * 3)
dreier.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), GREY_BG),
("LINEBELOW", (0, 0), (-1, -1), 0.8, ACCENT),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
]))
story.append(dreier)
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("Proforma an:" if kopf.get("art") == "proforma"
else "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("&nbsp;", st["addr"]))
art = kopf.get("art", "rechnung")
# Montage-Rechnung: eigenes Blatt fuer die Monatsmiete eines Monteurs.
# Keine Positionstabelle, keine Naechte im Kopf - der Zeitraum steht in der
# einen Textzeile, so wie Bartl das Blatt bisher von Hand geschrieben hat.
montage = kopf.get("vorlage") == "montage"
proforma = art == "proforma"
info_rows = [
("Stornonummer:" if art == "storno" else
"Proforma-Nr.:" if proforma else "Rechnungsnummer:",
kopf.get("rechnungsnummer", "")),
("Rechnungsdatum:", kopf.get("datum", "")),
]
# Der Bezug auf die alte Rechnung ist Pflicht (§ 31 Abs. 5 UStDV): Nummer UND Datum.
if art == "storno" and kopf.get("storno_zu"):
# Nummer und Datum in zwei Zeilen - zusammen brechen sie in der schmalen
# Spalte um und der Bezug sieht aus wie zwei halbe Angaben.
info_rows.append(("Storno zu Rechnung:", kopf.get("storno_zu", "")))
if kopf.get("storno_datum"):
info_rows.append(("Rechnung vom:", kopf["storno_datum"]))
# Die Nummer, unter der die berichtigte Rechnung kommt. Damit steht die
# ganze Kette auf dem Blatt: alte Nummer -> Storno -> neue Nummer.
if kopf.get("korrektur_nummer"):
# "Neue Rechnung" war missverstaendlich - man liest es als die Nummer
# DIESES Blattes. Gemeint ist die Rechnung, die den Storno ersetzt.
info_rows.append(("Berichtigte Rechnung:", kopf["korrektur_nummer"]))
elif art == "korrektur" and kopf.get("ersetzt"):
info_rows.append(("Ersetzt Rechnung:", kopf.get("ersetzt", "")))
elif art == "berichtigung":
# Die Rechnung behaelt Nummer und Datum - dazu kommt der Tag der
# Berichtigung, damit man die Reihenfolge sieht.
if kopf.get("berichtigt_am"):
info_rows.append(("Berichtigt am:", kopf["berichtigt_am"]))
if not montage:
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"):
# Auf dem Storno stehen die Naechte negativ in den Kenndaten (das
# Spiegelbild fuer das Steuerjournal). Auf dem BLATT liest sich "-4
# Naechte" wie ein Tippfehler - dort steht, was aufgehoben wird.
try:
anzahl = int(float(str(kopf.get("naechte")).replace(",", ".")))
except (TypeError, ValueError):
anzahl = 0
if anzahl < 0:
info_rows.append(("Zeitraum:", f"{abs(anzahl)} Nächte (aufgehoben)"))
else:
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)
# Das Wort "Beherbergungssteuer" steht auf dem Amt-Blatt nirgends mehr - auch nicht
# in der Ueberschrift. Der Zusatz sagt nur noch, fuer wen das Blatt ist.
# Das Wort "Stornorechnung" muss auf dem Blatt stehen, damit der Beleg
# eindeutig als Aufhebung erkennbar ist.
titel = {"storno": "Stornorechnung",
"berichtigung": "Berichtigte Rechnung",
"proforma": "Proforma-Rechnung (Kostenvoranschlag)"}.get(art, "Rechnungsübersicht")
titel += " (für das Amt)" if gesamtrechnung_betont else ""
story.append(Paragraph(titel, st["h"]))
if proforma:
# Ohne diesen Satz sieht das Blatt aus wie eine Rechnung. Es ist keine -
# und ohne die deutliche Kennzeichnung kann das Finanzamt es als eine
# werten. Ein Steuersatz oder Steuerbetrag steht bewusst NIRGENDS auf
# dem Blatt: wer Steuer ausweist, schuldet sie (§ 14c UStG).
story.append(Paragraph(
"<b>Dies ist ein Kostenvoranschlag</b> – eine unverbindliche Vorab-"
"Aufstellung der Kosten. Sie ist <b>keine Rechnung im Sinne des § 14 "
"UStG</b>, berechtigt nicht zum Vorsteuerabzug und ist nicht zu bezahlen. "
"Die Nummer oben ist <b>keine Rechnungsnummer</b>. Alle Beträge in Euro "
"inkl. gesetzlicher Umsatzsteuer; ausgewiesen wird sie erst in der "
"Rechnung nach dem Aufenthalt.", st["dank"]))
story.append(Spacer(1, 3 * mm))
if montage:
_montage_zeile(story, st, rechnung, kopf)
else:
_positionstabelle(story, st, rechnung, ohne_ust=proforma)
story.append(Spacer(1, 3 * mm))
# ---- Summenblock: Netto -> USt je Satz -> Zwischensumme (brutto)
# -> zzgl. Beherbergungssteuer -> Gesamtbetrag ----
# Auf der Proforma steht weder Netto noch Umsatzsteuer: ausgewiesen wird
# sie erst mit der Rechnung. Die Beherbergungssteuer ist keine Umsatzsteuer
# und darf bleiben - der Gast soll sehen, was er zahlen wird.
summen_rows = [] if proforma else [[
Paragraph("Nettobetrag:", st["info_l"]),
Paragraph(eur(rechnung.netto_gesamt), st["cell_r"]),
]]
for b in ([] if proforma else 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"]),
])
# Auf dem Amt-Blatt taucht die Beherbergungssteuer NIRGENDS auf - weder als eigene
# Zeile, noch versteckt im Gesamtbetrag. Das Amt bekommt die Rechnung so, wie sie
# ohne die Steuer aussaehe: Netto, Umsatzsteuern, Gesamt. Genau dieser Gesamtbetrag
# ist die Bemessungsgrundlage, aus der es die Steuer selbst errechnet.
#
# Deshalb entfaellt hier auch die Zwischensumme: sie waere betragsgleich mit dem
# Gesamtbetrag und wuerde nur zweimal dasselbe zeigen.
#
# Auf der Kundenseite bleibt alles wie gehabt - dort MUSS die Steuer ausgewiesen
# sein und im Gesamtbetrag stecken, das ist der Betrag, den der Gast zahlt.
# Auf der Proforma wird gar keine Steuer ausgewiesen - weder Umsatzsteuer
# noch Beherbergungssteuer. Es steht nur, was der Gast voraussichtlich
# zahlt; aufgeschluesselt wird erst in der Rechnung.
if rechnung.aufschlaege and not gesamtrechnung_betont and not proforma:
summen_rows.append([
Paragraph("Zwischensumme:", st["info_l"]),
Paragraph(eur(rechnung.brutto), st["cell_r"]),
])
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"]),
])
endsumme = rechnung.brutto if gesamtrechnung_betont else rechnung.gesamt
summen_rows.append([
Paragraph("Voraussichtlicher Gesamtbetrag:" if proforma else
"Rechnungsbetrag Insgesamt:" if montage else "Gesamtbetrag:",
st["total_l"]),
Paragraph(eur(endsumme), 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),
]
summen = Table(summen_rows, colWidths=[CONTENT_W - 44 * mm, 44 * mm])
summen.setStyle(TableStyle(style_cmds))
story.append(summen)
# Hinweis zur Beherbergungssteuer - nur auf der Kundenseite. Auf dem Amt-Blatt
# wird die Steuer gar nicht mehr ausgewiesen, dann waere der Satz sinnlos.
if proforma:
story.append(Spacer(1, 1.5 * mm))
story.append(Paragraph(
"In diesem Betrag sind alle Steuern und Abgaben enthalten. Aufgeschlüsselt "
"werden sie erst in der Rechnung nach dem Aufenthalt.", st["dank"]))
elif not gesamtrechnung_betont:
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))
if art == "storno":
# Auf dem Storno wird nichts erhoben, sondern zurueckgenommen.
story.append(Paragraph(
f"Die mit der stornierten Rechnung erhobene {a.bezeichnung} "
f"({satz_txt} %) wird mit aufgehoben.", st["dank"]))
else:
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))
# ---- Schlusstexte (nur Kundenseite; auf dem Amt-Blatt unnoetig) ----
if not gesamtrechnung_betont:
texte = cfg.get("texte", {})
block = []
if art == "storno":
# Auf einem Storno sind Dank- und Zahlungstexte falsch - hier wird
# nichts geschuldet, sondern aufgehoben.
bezug = kopf.get("storno_zu", "")
if kopf.get("storno_datum"):
bezug += f" vom {kopf['storno_datum']}"
folgt = kopf.get("korrektur_nummer") or ""
kette = (f" Die berichtigte Rechnung trägt die Nummer {folgt} "
f"(also {kopf.get('storno_zu','')} → {kopf.get('rechnungsnummer','')} "
f"→ {folgt}).") if folgt else ""
block.append(Paragraph(
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
"Bereits gezahlte Beträge werden erstattet oder mit der berichtigten "
"Rechnung verrechnet." + kette, st["dank"]))
elif art == "berichtigung":
# Der Satz ist kein Beiwerk: mehrere Rechnungen ueber dieselbe Leistung
# ohne Kennzeichnung loesen die Steuer erneut aus (Abschn. 14c.1 Abs. 4
# Satz 4 UStAE). Gegenueber Endverbrauchern entsteht sie zwar nicht
# (EuGH C-378/21, BMF v. 27.02.2024) - aber Firmengaeste und
# gewerbliche Dauercamper gibt es auch, und der Nachweis liegt beim
# Aussteller. Also bleibt der Satz drauf.
block.append(Paragraph(
f"Dieses Blatt berichtigt die Angaben zur Rechnung "
f"{kopf.get('berichtigt_zu','')} vom {kopf.get('datum','')} nach "
"§ 31 Abs. 5 UStDV. Leistungen, Beträge und Umsatzsteuer sind "
"unverändert – es handelt sich <b>nicht</b> um eine zusätzliche "
"Leistung und nicht um eine zweite Rechnung. Die Rechnung behält "
"ihre Nummer.", st["dank"]))
if texte.get("dank1"):
block.append(Paragraph(texte["dank1"], st["dank"]))
elif proforma:
# Dank- und Zahlungstexte waeren hier falsch: der Aufenthalt liegt
# noch vor uns, und bezahlt wird nichts. Der Hinweis "bitte die
# Rechnungsnummer angeben" wuerde dem Blatt sogar widersprechen.
block.append(Paragraph(
"Diese Aufstellung ist unverbindlich und dient nur zur Information. "
"Sie ist keine Zahlungsaufforderung – die Rechnung erhalten Sie nach "
"dem Aufenthalt. Für Rückfragen stehen wir gern zur Verfügung.",
st["dank"]))
else:
if art == "korrektur" and kopf.get("ersetzt"):
hinweis = f"Diese Rechnung ersetzt die Rechnung {kopf['ersetzt']}"
if kopf.get("storno_nummer"):
hinweis += f", die mit der Stornorechnung {kopf['storno_nummer']} aufgehoben wurde"
block.append(Paragraph(hinweis + ".", st["dank"]))
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 _kenndaten(rechnung, kopf):
"""
Kenndaten der Rechnung als JSON in die PDF-Metadaten (Feld "Subject").
Das Steuerjournal (Programm "Beherbergungssteuer") liest sie von dort aus,
statt Nachname und Betraege aus dem Fliesstext zu raten. Fuer den Ausdruck
aendert sich nichts - Metadaten stehen nicht auf dem Blatt.
Version 2 ergaenzt die USt-Aufteilung ("ust_bloecke") fuers dritte Programm,
den Steuerrechner: der braucht Brutto->Netto je Steuersatz, und 7 % und 19 %
stecken auf einer Rechnung gemischt drin. Alle Felder der Version 1 bleiben
unveraendert stehen - am "zwischensumme"/"steuer_satz"/"steuer_betrag" haengt
der Import des Steuerjournals, das darf nicht brechen.
Version 3 kommt mit dem Storno dazu:
"art" - "rechnung", "storno" oder "korrektur"
"storno_zu" - beim Storno die Nummer der aufgehobenen Rechnung
"ersetzt" - bei der Korrektur die Nummer der falschen Rechnung
"kunde" / "positionen" / "anreise" / "abreise" - der komplette Formularstand,
damit eine Rechnung spaeter wieder ins Formular geladen werden kann, statt
sie abzutippen. Aeltere PDFs (v1/v2) haben das nicht.
Beim Storno sind alle Betraege NEGATIV - das Steuerjournal und der
Steuerrechner summieren dadurch von allein richtig (+100 -100 +120 = 120).
"""
aufschlag = rechnung.aufschlaege[0] if rechnung.aufschlaege else None
if kopf.get("art") == "proforma":
# Eine Proforma ist kein Umsatz - sie traegt Nullen. Ein Programm, das
# "art" nicht kennt, zaehlt dann 0,00 statt einer Uebernachtung, die nie
# abgerechnet wurde.
return json.dumps({
"quelle": "rechnungstool", "version": 3, "art": "proforma",
"rechnungsnummer": kopf.get("rechnungsnummer", ""),
"datum": kopf.get("datum", ""),
"nachname": kopf.get("nachname", ""), "vorname": kopf.get("vorname", ""),
"naechte": kopf.get("naechte", ""),
"zwischensumme": 0.0, "gesamt": 0.0,
"steuer_satz": 0.0, "steuer_betrag": 0.0,
"netto_gesamt": 0.0, "ust_gesamt": 0.0, "ust_bloecke": [],
"kunde": kopf.get("kunde", {}),
"anreise": kopf.get("anreise", ""), "abreise": kopf.get("abreise", ""),
}, ensure_ascii=False)
if kopf.get("art") == "berichtigung":
# Das berichtigte Blatt zeigt dieselben Betraege wie die Rechnung, ist aber
# KEINE zweite Einnahme. Es traegt deshalb Nullen: ein Programm, das "art"
# nicht kennt, zaehlt dann 0,00 € statt den Umsatz ein zweites Mal.
return json.dumps({
"quelle": "rechnungstool", "version": 3, "art": "berichtigung",
"vorgang": kopf.get("vorgang") or kopf.get("berichtigt_zu", ""),
"rechnungsnummer": kopf.get("berichtigt_zu", ""),
"berichtigt_zu": kopf.get("berichtigt_zu", ""),
"berichtigt_am": kopf.get("berichtigt_am", ""),
"datum": kopf.get("datum", ""),
"nachname": kopf.get("nachname", ""), "vorname": kopf.get("vorname", ""),
"zwischensumme": 0.0, "gesamt": 0.0,
"steuer_satz": 0.0, "steuer_betrag": 0.0,
"netto_gesamt": 0.0, "ust_gesamt": 0.0, "ust_bloecke": [],
"kunde": kopf.get("kunde", {}),
}, ensure_ascii=False)
daten = {
"quelle": "rechnungstool",
"version": 3,
"rechnungsnummer": kopf.get("rechnungsnummer", ""),
"datum": kopf.get("datum", ""),
"nachname": kopf.get("nachname", ""),
"vorname": kopf.get("vorname", ""),
"naechte": kopf.get("naechte", ""),
"zwischensumme": runde(rechnung.brutto),
"steuer_satz": float(aufschlag.satz) if aufschlag else 0.0,
"steuer_betrag": runde(aufschlag.betrag) if aufschlag else 0.0,
"gesamt": runde(rechnung.gesamt),
# neu in Version 2: je USt-Satz ein Block (Netto, enthaltene USt, Brutto)
"netto_gesamt": runde(rechnung.netto_gesamt),
"ust_gesamt": runde(rechnung.ust_gesamt),
"ust_bloecke": [
{"satz": b.satz, "netto": runde(b.netto), "ust": runde(b.ust),
"brutto": runde(b.brutto)}
for b in rechnung.bloecke
],
# neu in Version 3
"art": kopf.get("art", "rechnung"),
# Klammer um die ganze Kette: Rechnung, ihr Storno, die berichtigte
# Rechnung und jede Berichtigung tragen DIESELBE Vorgangsnummer - die der
# allerersten Rechnung. Ein anderes Programm gruppiert danach in einem
# Schritt, statt sich die Kette ueber storno_zu/ersetzt zusammenzusuchen.
# Eine normale Rechnung ist ihr eigener Vorgang.
"vorgang": kopf.get("vorgang") or kopf.get("rechnungsnummer", ""),
"storno_zu": kopf.get("storno_zu", ""),
"korrektur_nummer": kopf.get("korrektur_nummer", ""),
"storno_datum": kopf.get("storno_datum", ""),
"ersetzt": kopf.get("ersetzt", ""),
"storno_nummer": kopf.get("storno_nummer", ""),
"anreise": kopf.get("anreise", ""),
"abreise": kopf.get("abreise", ""),
"kunde": kopf.get("kunde", {}),
"positionen": kopf.get("positionen", []),
}
# Montage-Rechnung: welches Formular sie geschrieben hat und der Text der
# einen Zeile. "art" bleibt "rechnung" - fuer das Steuerjournal ist sie eine
# ganz normale Buchung (Miete mit Beherbergungssteuer), nur eben monatsweise.
# Damit kann das Formular sie spaeter wieder laden und der Storno-Weg
# erkennt, in welchen Reiter sie gehoert.
if kopf.get("vorlage"):
daten["vorlage"] = kopf["vorlage"]
daten["montage_text"] = kopf.get("montage_text", "")
return json.dumps(daten, ensure_ascii=False)
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, vollstaendig (Steuer ausgewiesen, Bankverbindung im Fuss).
Letzte Seite = Blatt fuers Amt: KEINE Beherbergungssteuer (auch nicht im Gesamt),
keine Zwischensumme, keine Bankverbindung, keine Steuernummer.
"""
st = _styles()
def bauen(ziel, amt_seite):
doc = SimpleDocTemplate(
ziel, pagesize=A4,
leftMargin=MARGIN_L, rightMargin=MARGIN_R,
topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT,
title=({"storno": "Stornorechnung",
"proforma": "Proforma-Rechnung"}.get(kopf.get("art"), "Rechnung"))
+ f" {kopf.get('rechnungsnummer','')}",
author=cfg["firma"].get("name", ""),
subject=_kenndaten(rechnung, kopf), # maschinenlesbar fuers Steuerjournal
)
story = []
_seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=False) # Kunde
# Eine Proforma bekommt KEIN Blatt fuers Amt: gemeldet wird eine
# Uebernachtung erst, wenn sie abgerechnet ist.
if rechnung.aufschlaege and kopf.get("art") != "proforma":
story.append(PageBreak())
story.append(_AmtMarke(marke))
_seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=True) # Amt
def seite(c, d):
_kopf_fuss(c, d, cfg, amt=(amt_seite is not None and c.getPageNumber() >= amt_seite))
doc.build(story, onFirstPage=seite, onLaterPages=seite)
# Zwei Durchlaeufe, und zwar aus einem konkreten Grund:
#
# Kopf und Fuss werden VOR dem Seiteninhalt gezeichnet - beim Zeichnen weiss die
# Seite also noch nicht, ob auf ihr gleich die Kunden- oder die Amts-Seite landet.
# Auf "Seite 2 ist das Amt" zu wetten, waere falsch, sobald die Kundenrechnung
# laenger als eine Seite wird: dann stuende die Bankverbindung ploetzlich auf dem
# Amt-Blatt und fehlte beim Kunden.
#
# Darum: erster Lauf ins Nichts, nur um zu erfahren, auf welcher Seite das
# Amt-Blatt beginnt. Zweiter Lauf schreibt die echte Datei.
marke = {}
bauen(io.BytesIO(), None)
bauen(pfad, marke.get("seite"))
return pfad