REGRESSION aus der Modulaufteilung (Code-Review-Agent): - einstellungen.py fehlten "berechnung" und "datetime". Menue > Leistungskatalog und > Rechnungsnummer stuerzten mit NameError ab und liessen ein leeres, modales Fenster stehen. Preise und Nummernkreis waren nicht mehr einstellbar. KEIN Pruefstand hat das gemerkt - keiner oeffnete je einen Dialog. Neu: pruef_dialoge.py oeffnet ALLE Dialoge und schliesst sie wieder. HAENGENDE ZWISCHENSPEICHER (derselbe Agent, gemessen): - "Zuruecksetzen" liess storno_stand/berichtigung_stand stehen. Danach gehoerten die Daten eines FREMDEN Gastes zum Storno der alten Rechnung, und die Berichtigung schrieb ein Blatt mit Nummer A im Kenndatensatz und Nummer B auf dem Blatt. reset_formular fragt jetzt nach und verwirft sauber. - _neue_nummer_vorschlagen fasst die Nummer nicht mehr an, solange ein Vorgang laeuft (traf auch dlg_nummer). - Fenster schliessen mit offenem Storno fragt jetzt nach (WM_DELETE_WINDOW). - _storno_verwerfen bricht ab, wenn das PDF nicht geloescht werden kann, statt den Zaehler trotzdem zurueckzudrehen. - Der Storno prueft die Kundenangaben und fragt, wenn die alte Rechnung sie nicht hergibt. - Zaehler nicht speicherbar -> sichtbare Meldung statt Traceback ins Nichts. - storno.py: v1-Rueckfall nimmt den ersten Satz OHNE extra_blatt. - EINE Quelle fuer die naechste Nummer: _neue_nummer_vorschlagen benutzt jetzt naechste_freie_nummer, der Jahreswechsel steht nur noch dort. Vorher schlug das Formular 2026-001 vor, waehrend der Storno-Weg 2025-088 nahm. PRUEFSTAENDE (Audit-Agent: 16 von 27 eingebauten Fehlern blieben unbemerkt): - Neu pruef_kernregeln.py (27 Pruefungen) fuer die zwei Regeln, um die es geht: Nummer von HAND auf eine vergebene setzen, Zaehler hinter dem Ordner, Jahreswechsel, Zaehler nur vorwaerts, Storno ueber den DIALOGKNOPF statt der internen Methode, zweite Berichtigung am selben Tag. Der Bestand wird ueber SHA256 verglichen - "Datei ist noch da" heisst nicht "unveraendert". - Der Text des Storno-PDFs wird gelesen: Positionszeile negativ, nicht nur der Summenblock. - Tautologien raus: Selbstvergleich beim storno_datum, all() ueber eine leere Liste, "nicht" in einem deutschen Text. - Unangemeldeter Dialog laesst den Lauf scheitern, statt still "Nein" zu sagen. - pruef_nummern.py: .PDF gross geschrieben, Rechnung_*.txt, echte Praefix-Falle. - Gegenprobe: sechs Mutationen eingebaut, die vorher gruen blieben - alle sechs werden jetzt rot. RECHT (Gegenpruefungs-Agent): Zitate praezisiert. § 146 Abs. 4 AO verbietet nur Aenderungen, bei denen der urspruengliche Inhalt nicht mehr feststellbar ist - ein protokollierter Storno-Vermerk waere erlaubt, wir verzichten trotzdem bewusst darauf. Der § 14c-Hinweis bleibt, gilt aber gegenueber Endverbrauchern nicht (EuGH C-378/21, BMF v. 27.02.2024) - er zielt auf Firmengaeste. 142 Pruefungen gruen (100 + 27 + 8 + 7). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
546 lines
25 KiB
Python
546 lines
25 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 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 _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("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")
|
||
info_rows = [
|
||
("Stornonummer:" if art == "storno" 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"]))
|
||
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 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"):
|
||
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"}.get(art, "Rechnungsübersicht")
|
||
titel += " (für das Amt)" if gesamtrechnung_betont else ""
|
||
story.append(Paragraph(titel, st["h"]))
|
||
|
||
# Positionstabelle
|
||
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("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(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)
|
||
story.append(Spacer(1, 3 * mm))
|
||
|
||
# ---- Summenblock: Netto -> USt je Satz -> Zwischensumme (brutto)
|
||
# -> zzgl. Beherbergungssteuer -> Gesamtbetrag ----
|
||
summen_rows = [[
|
||
Paragraph("Nettobetrag:", st["info_l"]),
|
||
Paragraph(eur(rechnung.netto_gesamt), st["cell_r"]),
|
||
]]
|
||
for b in 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.
|
||
if rechnung.aufschlaege and not gesamtrechnung_betont:
|
||
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("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 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']}"
|
||
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.", 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"]))
|
||
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") == "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",
|
||
"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"),
|
||
"storno_zu": kopf.get("storno_zu", ""),
|
||
"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", []),
|
||
}
|
||
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=("Stornorechnung" if kopf.get("art") == "storno" else "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
|
||
if rechnung.aufschlaege:
|
||
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
|