Das zweite Blatt selbst ist seit dem 05.09.2026 draussen. Stehen geblieben waren
Saetze, die es weiter behaupten - einer davon sichtbar fuer den Nutzer:
app.py:1629 "(Seite 1 = Kunde, Seite 2 = Beherbergungssteuer fuers Amt)"
stand nach JEDER Rechnung mit Beherbergungssteuer im Fertig-Dialog
app.py:1612 falscher Kommentar zum PDF-Aufruf
pdf_renderer.py zwei Kommentarbloecke, die erklaeren, was "auf dem Amt-Blatt" steht
WARUM DAS BLATT DRAUSSEN BLEIBT
Der Auftraggeber wollte diese Version. Ich habe sie geprueft und festgestellt,
dass ich sie als Auftragnehmer nicht umsetzen werde - auch nicht in der
Experimental-Fassung.
Das zweite Blatt "fuers Amt" trug dieselbe Rechnungsnummer wie die
Kundenrechnung, wies aber einen anderen Gesamtbetrag aus.
- § 14 Abs. 4 UStG: zu einer fortlaufenden Rechnungsnummer gehoert genau ein
Beleg mit einem Betrag
- § 14c UStG: sobald die ausgewiesene Steuer auf beiden Blaettern auseinander
laeuft, wird der Mehrbetrag geschuldet
- § 146 AO: Aufzeichnungen muessen richtig und eindeutig sein
- § 379 Abs. 1 Nr. 1 AO: Ausstellen eines in tatsaechlicher Hinsicht
unrichtigen Belegs = Steuergefaehrdung, Bussgeld bis 25.000 EUR
- § 370 AO: fuehrt es zu verkuerzter Steuer, ist es Steuerhinterziehung
Ich werde keine Steuerstraftaten unterstuetzen. Was die Stadt braucht, liefert
das Steuerjournal als Summenmeldung - nicht als zweite Rechnung mit Gastnamen.
Vorgabe des Auftragnehmers: § sicher, mehr nicht.
Alle 15 Pruefstaende gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
721 lines
34 KiB
Python
721 lines
34 KiB
Python
# -*- 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 json
|
||
|
||
from reportlab.lib.pagesizes import A4
|
||
from reportlab.lib.units import mm
|
||
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,
|
||
)
|
||
|
||
import bilder
|
||
import config
|
||
from berechnung import eur, eur_kurz, runde
|
||
|
||
|
||
# ---- Farben / Masse ---------------------------------------------------------
|
||
# Die Akzentfarbe gehoert der Firma, nicht dem Programm: Ueberschriften, die
|
||
# Linie unter dem Kopf und der Tabellenkopf tragen sie. Ein Handwerker will kein
|
||
# Camping-Gruen auf seiner Rechnung. Eingestellt wird EINE Farbe - die helle
|
||
# Flaeche im Tabellenkopf wird daraus gemischt, damit niemand zwei Farben
|
||
# aufeinander abstimmen muss.
|
||
AKZENT_VORGABE = "#1f5c3d" # gedecktes Gruen (Camping)
|
||
|
||
ACCENT = colors.HexColor(AKZENT_VORGABE)
|
||
GREY_BG = colors.HexColor("#eef1ee")
|
||
LINE = colors.HexColor("#9aa39c")
|
||
DARK = colors.HexColor("#222222")
|
||
|
||
|
||
def _farbe(wert, vorgabe):
|
||
"""'#1f5c3d' -> Farbe. Alles Unlesbare faellt auf die Vorgabe zurueck.
|
||
|
||
Eine kaputte Farbe in der config.json darf niemals das Schreiben der
|
||
Rechnung verhindern - dann steht der Gast an der Schranke.
|
||
"""
|
||
try:
|
||
return colors.HexColor(str(wert).strip())
|
||
except (ValueError, AttributeError):
|
||
if str(wert).strip():
|
||
print(f"[pdf] Farbe '{wert}' nicht lesbar - nehme {vorgabe}")
|
||
return colors.HexColor(vorgabe)
|
||
|
||
|
||
def _design_anwenden(cfg):
|
||
"""Firmenfarbe uebernehmen, bevor die Stile gebaut werden.
|
||
|
||
Die Farben stehen als Modulwerte da, weil 13 Stellen im Layout sie lesen.
|
||
Sie werden hier EINMAL je Rechnung gesetzt - das Programm rendert nie zwei
|
||
Rechnungen gleichzeitig.
|
||
"""
|
||
global ACCENT, GREY_BG
|
||
design = (cfg or {}).get("design") or {}
|
||
ACCENT = _farbe(design.get("akzent", AKZENT_VORGABE), AKZENT_VORGABE)
|
||
# Helle Flaeche = 8 % Akzent auf Weiss. So passt der Tabellenkopf zu jeder
|
||
# gewaehlten Farbe, ohne dass jemand einen zweiten Wert pflegen muss.
|
||
GREY_BG = colors.Color(1 - 0.08 * (1 - ACCENT.red),
|
||
1 - 0.08 * (1 - ACCENT.green),
|
||
1 - 0.08 * (1 - ACCENT.blue))
|
||
|
||
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 _vorschau_marke(canvas):
|
||
"""Schraeg ueber das Blatt: das hier ist KEINE Rechnung.
|
||
|
||
Die Vorschau soll zeigen, wie das Blatt aussehen wird - ausgedruckt oder
|
||
weitergereicht darf sie nie mit der Rechnung verwechselt werden. Sonst haette
|
||
der Gast ein Blatt in der Hand, das keine Nummer verbraucht hat und in keiner
|
||
Buchhaltung steht.
|
||
"""
|
||
canvas.saveState()
|
||
canvas.setFont("Helvetica-Bold", 62)
|
||
canvas.setFillColor(colors.Color(0.55, 0.55, 0.55, alpha=0.22))
|
||
canvas.translate(PAGE_W / 2, PAGE_H / 2)
|
||
canvas.rotate(38)
|
||
canvas.drawCentredString(0, 0, "VORSCHAU")
|
||
canvas.setFont("Helvetica-Bold", 15)
|
||
canvas.drawCentredString(0, -22 * mm, "keine Rechnung")
|
||
canvas.restoreState()
|
||
|
||
|
||
def _kopf_fuss(canvas, doc, cfg, vorschau=False):
|
||
"""Kopf und Fuss einer Seite."""
|
||
firma = cfg["firma"]
|
||
canvas.saveState()
|
||
if vorschau:
|
||
_vorschau_marke(canvas)
|
||
|
||
# --- Kopf ---
|
||
# Das Kopfbild liegt oben links, Firmenname und Untertitel stehen rechts
|
||
# daneben - genau wie bisher. Austauschbar ist nur das BILD; die Texte
|
||
# kommen aus den Einstellungen und bleiben gezeichnet.
|
||
img, iw, ih = bilder.laden(firma.get("logo"))
|
||
line_y = PAGE_H - 25.5 * mm # Hoehe der Trennlinie ohne Bild (wie bisher)
|
||
logo_ok = False
|
||
if img:
|
||
dw, dh = bilder.einpassen(iw, ih, 60 * mm, 20 * mm) # Kasten oben links
|
||
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
|
||
|
||
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 ---
|
||
# Bankverbindung und Steuernummer MUESSEN drauf: das ist die Rechnung, die der
|
||
# Gast bezahlt, und die Steuernummer ist Pflichtangabe (§ 14 Abs. 4 Nr. 2 UStG).
|
||
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
|
||
|
||
fuss_img, fiw, fih = bilder.laden(firma.get("fussbild"))
|
||
if fuss_img:
|
||
# Schmaler Streifen UNTER den Fusstexten. Die Texte bleiben, wo sie sind -
|
||
# Firma, Anschrift, Bank und Steuernummer stehen auf jeder Rechnung, egal
|
||
# welches Bild jemand einstellt.
|
||
dw, dh = bilder.einpassen(fiw, fih, CONTENT_W, 17 * mm)
|
||
canvas.drawImage(fuss_img, MARGIN_L + (CONTENT_W - dw) / 2, 7 * mm,
|
||
width=dw, height=dh, preserveAspectRatio=True, mask="auto")
|
||
|
||
rows_l = [firma.get("bank", ""), firma.get("iban", ""), firma.get("bic", "")]
|
||
rows_m = [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 %", 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):
|
||
"""Baut den kompletten Seiteninhalt (Absender -> Positionen -> Summen -> Danktexte)."""
|
||
# 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("Angebot an:" if kopf.get("art") == "angebot"
|
||
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(" ", 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"
|
||
angebot = art == "angebot"
|
||
info_rows = [
|
||
("Stornonummer:" if art == "storno" else
|
||
"Angebotsnummer:" if angebot else "Rechnungsnummer:",
|
||
kopf.get("rechnungsnummer", "")),
|
||
# Auf dem Angebot faellt das Wort "Rechnung" ueberall weg: es soll gar
|
||
# nicht erst der Eindruck entstehen, der Gast haette schon eine.
|
||
("Datum:" if angebot else "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 -
|
||
# oder, bei einer doppelt geschriebenen Leistung, die BLEIBENDE.
|
||
info_rows.append(
|
||
("Schon abgerechnet:" if kopf.get("ersatz_vorhanden")
|
||
else "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.
|
||
# Das Wort "Stornorechnung" muss auf dem Blatt stehen, damit der Beleg
|
||
# eindeutig als Aufhebung erkennbar ist.
|
||
titel = {"storno": "Stornorechnung",
|
||
"berichtigung": "Berichtigte Rechnung",
|
||
"angebot": "Unverbindliches Angebot"}.get(art, "Rechnungsübersicht")
|
||
story.append(Paragraph(titel, st["h"]))
|
||
if angebot:
|
||
# 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 unverbindliches Angebot</b> und <b>keine Rechnung im "
|
||
"Sinne des § 14 UStG</b>: es berechtigt nicht zum Vorsteuerabzug und ist "
|
||
"nicht zu bezahlen. 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=angebot)
|
||
story.append(Spacer(1, 3 * mm))
|
||
|
||
# ---- Summenblock: Netto -> USt je Satz -> Zwischensumme (brutto)
|
||
# -> zzgl. Beherbergungssteuer -> Gesamtbetrag ----
|
||
# Auf dem Angebot 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 angebot else [[
|
||
Paragraph("Nettobetrag:", st["info_l"]),
|
||
Paragraph(eur(rechnung.netto_gesamt), st["cell_r"]),
|
||
]]
|
||
for b in ([] if angebot 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 der Rechnung MUSS die Beherbergungssteuer ausgewiesen sein und im
|
||
# Gesamtbetrag stecken - das ist der Betrag, den der Gast zahlt. Die Stadt
|
||
# bekommt keine Rechnungskopie, sondern eine Summenmeldung aus dem
|
||
# Steuerjournal: es liest die Kenndaten dieser PDF und rechnet selbst.
|
||
# Auf dem Angebot 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 angebot:
|
||
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.gesamt
|
||
summen_rows.append([
|
||
Paragraph("Voraussichtlicher Gesamtbetrag:" if angebot 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.
|
||
if angebot:
|
||
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"]))
|
||
else:
|
||
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 -------------------------------------------------------
|
||
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 ""
|
||
if kopf.get("ersatz_vorhanden") and folgt:
|
||
# Doppelt geschriebene Leistung: es kommt nichts nach, sie ist
|
||
# bereits abgerechnet. Der Satz muss das sagen - sonst wartet der
|
||
# Gast auf eine Rechnung, die nie kommt.
|
||
block.append(Paragraph(
|
||
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
|
||
f"Die Leistung wurde versehentlich doppelt in Rechnung gestellt und "
|
||
f"ist bereits mit der Rechnung {folgt} abgerechnet – es folgt keine "
|
||
"weitere Rechnung. Bereits gezahlte Beträge werden erstattet oder "
|
||
"verrechnet.", st["dank"]))
|
||
story.append(KeepTogether(block))
|
||
return
|
||
if kopf.get("nur_storno"):
|
||
# Nur Storno (04.09.2026): die Leistung wird gar nicht abgerechnet.
|
||
# Hier darf NICHT von einer berichtigten Rechnung die Rede sein -
|
||
# es kommt keine, und der Gast wartet sonst darauf.
|
||
block.append(Paragraph(
|
||
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
|
||
"Die Leistung wird nicht abgerechnet – es folgt keine weitere "
|
||
"Rechnung. Bereits gezahlte Beträge werden erstattet.", st["dank"]))
|
||
story.append(KeepTogether(block))
|
||
return
|
||
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 angebot:
|
||
# 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(
|
||
"Dieses Angebot ist freibleibend und unverbindlich (§ 145 BGB) und "
|
||
"keine Zahlungsaufforderung. Sagen Sie uns Bescheid, dann reservieren "
|
||
"wir – 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") == "angebot":
|
||
# Ein Angebot ist kein Umsatz - es 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": "angebot",
|
||
"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", ""),
|
||
"ersatz_vorhanden": bool(kopf.get("ersatz_vorhanden")),
|
||
"nur_storno": bool(kopf.get("nur_storno")),
|
||
"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, vorschau=False):
|
||
"""
|
||
pfad : Zielpfad der PDF
|
||
cfg : geladene Konfiguration
|
||
rechnung : berechnung.Rechnung
|
||
kopf : dict mit rechnungsnummer, datum, anreise, abreise, naechte, kunde_zeilen
|
||
|
||
EIN Blatt: die Rechnung fuer den Gast.
|
||
|
||
Bis zum 05.09.2026 hing hier ein zweites Blatt "fuer das Amt" mit derselben
|
||
Rechnungsnummer, aber ohne Beherbergungssteuer - und damit mit einem anderen
|
||
Gesamtbetrag. Genau das ist bei einer Pruefung ein Problem: mehrere Blaetter
|
||
zum selben Umsatz mit verschiedenen Endbetraegen sehen aus, als werde ueber
|
||
verschiedene Umsaetze abgerechnet. Es ist ersatzlos entfallen.
|
||
|
||
Was die Stadt braucht, kommt aus dem Steuerjournal: es liest die Kenndaten
|
||
(subject) dieser PDF und rechnet die Bemessungsgrundlage selbst aus. Die
|
||
Anmeldung geht als Summenmeldung ans Amt, nicht als Rechnungskopie mit
|
||
Gastnamen.
|
||
"""
|
||
_design_anwenden(cfg) # Firmenfarbe VOR den Stilen - sie lesen sie mit
|
||
st = _styles()
|
||
|
||
doc = SimpleDocTemplate(
|
||
pfad, pagesize=A4,
|
||
leftMargin=MARGIN_L, rightMargin=MARGIN_R,
|
||
topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT,
|
||
title=({"storno": "Stornorechnung",
|
||
"angebot": "Angebot"}.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)
|
||
|
||
def seite(c, d):
|
||
_kopf_fuss(c, d, cfg, vorschau=vorschau)
|
||
|
||
doc.build(story, onFirstPage=seite, onLaterPages=seite)
|
||
return pfad
|