Compare commits

...

3 commits
v1.0.0 ... main

Author SHA1 Message Date
TheMockTv
b5799a60a1 Amt-Blatt strikt vom Kundenblatt trennen
Auf dem Blatt fuers Amt taucht die Beherbergungssteuer nirgends mehr auf - auch
nicht versteckt im Gesamtbetrag (der zeigt jetzt die Summe OHNE die Steuer, also
genau die Bemessungsgrundlage). Die Zwischensumme entfaellt dort, sie waere
betragsgleich. Ebenso raus: Bankverbindung und Steuernummer im Fuss, und das Wort
"Beherbergungssteuer" in der Ueberschrift.

Die Kundenseite bleibt vollstaendig: Steuer ausgewiesen, im Gesamtbetrag enthalten,
Bankverbindung im Fuss - das ist die Rechnung, die der Gast bezahlt.

Damit Kopf und Fuss die Seiten unterscheiden koennen, wird das PDF zweimal gebaut:
der erste Lauf ermittelt nur, auf welcher Seite das Amt-Blatt beginnt. Auf "Seite 2
ist das Amt" zu wetten waere falsch, sobald die Kundenrechnung ueber eine Seite
laeuft - dann stuende die Bankverbindung auf dem Amt-Blatt und fehlte beim Kunden.
2026-07-13 21:06:08 +02:00
TheMockTv
5388203e56 Amtsblatt: Beherbergungssteuer nicht mehr ausweisen
Auf Seite 2 (Kopie fuers Amt) stehen nur noch die Umsatzsteuersaetze,
die Zwischensumme als Bemessungsgrundlage und der Gesamtbetrag. Die Zeile
"zzgl. Beherbergungssteuer" und der Satzungs-Hinweis entfallen dort - das
Amt rechnet die Steuer selbst aus der Zwischensumme.

Die Kundenseite bleibt unveraendert, und die Kenndaten in den PDF-Metadaten
(steuer_satz, steuer_betrag) bleiben erhalten - daran haengt das Steuerjournal.
2026-07-13 20:47:04 +02:00
TheMockTv
0c83f9d821 Vor- und Nachname getrennt + Kenndaten in die PDF-Metadaten
Bisher gab es nur ein Feld "Name / Firma". Stand in der Anrede etwas,
war die Anrede die erste Zeile des Adressblocks - das Steuerjournal hat
sie als Namen gelesen.

- Kundenblock hat jetzt "Vorname (optional)" und "Nachname / Firma".
  Auf der Rechnung stehen beide wie gewohnt in einer Zeile.
- Die Kenndaten der Rechnung (Nummer, Datum, Nachname, Naechte,
  Zwischensumme, Steuersatz und -betrag) stehen zusaetzlich als JSON in
  den PDF-Metadaten. Das Steuerjournal liest sie von dort und muss
  nichts mehr aus dem Fliesstext raten. Der Ausdruck aendert sich nicht.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:45:53 +02:00
2 changed files with 142 additions and 52 deletions

20
app.py
View file

@ -265,13 +265,15 @@ class RechnungsApp(tk.Tk):
kunde = ttk.LabelFrame(top, text="Rechnung an (Kunde)", padding=8) kunde = ttk.LabelFrame(top, text="Rechnung an (Kunde)", padding=8)
kunde.grid(row=0, column=0, sticky="nsew", padx=(0, 10)) kunde.grid(row=0, column=0, sticky="nsew", padx=(0, 10))
self.k_anrede = tk.StringVar() self.k_anrede = tk.StringVar()
self.k_name = tk.StringVar() self.k_vorname = tk.StringVar()
self.k_name = tk.StringVar() # Nachname oder Firma die Zuordnung im Steuerjournal
self.k_zusatz = tk.StringVar() self.k_zusatz = tk.StringVar()
self.k_strasse = tk.StringVar() self.k_strasse = tk.StringVar()
self.k_plzort = tk.StringVar() self.k_plzort = tk.StringVar()
felder = [ felder = [
("Anrede (optional)", self.k_anrede), ("Anrede (optional)", self.k_anrede),
("Name / Firma", self.k_name), ("Vorname (optional)", self.k_vorname),
("Nachname / Firma", self.k_name),
("Zusatz (optional)", self.k_zusatz), ("Zusatz (optional)", self.k_zusatz),
("Straße / Nr.", self.k_strasse), ("Straße / Nr.", self.k_strasse),
("PLZ / Ort", self.k_plzort), ("PLZ / Ort", self.k_plzort),
@ -503,7 +505,13 @@ class RechnungsApp(tk.Tk):
"Es ist kein Kundenname eingetragen. Trotzdem PDF erstellen?"): "Es ist kein Kundenname eingetragen. Trotzdem PDF erstellen?"):
return return
kunde_zeilen = [self.k_anrede.get(), self.k_name.get(), self.k_zusatz.get(), # Auf der Rechnung stehen Vor- und Nachname wie gewohnt in einer Zeile;
# der Nachname geht zusätzlich getrennt mit, damit das Steuerjournal ihn
# nicht aus dem Adressblock raten muss.
nachname = self.k_name.get().strip()
vorname = self.k_vorname.get().strip()
namenszeile = " ".join(t for t in (vorname, nachname) if t)
kunde_zeilen = [self.k_anrede.get(), namenszeile, self.k_zusatz.get(),
self.k_strasse.get(), self.k_plzort.get()] self.k_strasse.get(), self.k_plzort.get()]
kopf = { kopf = {
"rechnungsnummer": nummer, "rechnungsnummer": nummer,
@ -512,6 +520,8 @@ class RechnungsApp(tk.Tk):
"abreise": self.r_abreise.get().strip(), "abreise": self.r_abreise.get().strip(),
"naechte": self.r_naechte.get() if self.r_naechte.get() != "" else "", "naechte": self.r_naechte.get() if self.r_naechte.get() != "" else "",
"kunde_zeilen": kunde_zeilen, "kunde_zeilen": kunde_zeilen,
"nachname": nachname,
"vorname": vorname,
} }
out_dir = config.ensure_output_dir(self.cfg) out_dir = config.ensure_output_dir(self.cfg)
fname = f"Rechnung_{safe_filename(nummer)}" fname = f"Rechnung_{safe_filename(nummer)}"
@ -547,8 +557,8 @@ class RechnungsApp(tk.Tk):
self._neue_nummer_vorschlagen() self._neue_nummer_vorschlagen()
def reset_formular(self): def reset_formular(self):
for v in (self.k_anrede, self.k_name, self.k_zusatz, self.k_strasse, self.k_plzort, for v in (self.k_anrede, self.k_vorname, self.k_name, self.k_zusatz,
self.r_anreise, self.r_abreise): self.k_strasse, self.k_plzort, self.r_anreise, self.r_abreise):
v.set("") v.set("")
self.r_datum.set(datetime.date.today().strftime("%d.%m.%Y")) self.r_datum.set(datetime.date.today().strftime("%d.%m.%Y"))
for av in self.anzahl_vars.values(): for av in self.anzahl_vars.values():

View file

@ -10,6 +10,8 @@ im Layout der Excel-Vorlage:
""" """
import os import os
import io
import json
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm from reportlab.lib.units import mm
@ -19,11 +21,30 @@ from reportlab.lib.enums import TA_RIGHT, TA_LEFT, TA_CENTER
from reportlab.lib.styles import ParagraphStyle from reportlab.lib.styles import ParagraphStyle
from reportlab.platypus import ( from reportlab.platypus import (
SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, KeepTogether, PageBreak, SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, KeepTogether, PageBreak,
Flowable,
) )
import config import config
from berechnung import eur, eur_kurz, runde 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 --------------------------------------------------------- # ---- Farben / Masse ---------------------------------------------------------
ACCENT = colors.HexColor("#1f5c3d") # gedecktes Gruen (Camping) ACCENT = colors.HexColor("#1f5c3d") # gedecktes Gruen (Camping)
GREY_BG = colors.HexColor("#eef1ee") GREY_BG = colors.HexColor("#eef1ee")
@ -79,7 +100,8 @@ def _styles():
# ---- Kopf + Fusszeile (auf jeder Seite gezeichnet) -------------------------- # ---- Kopf + Fusszeile (auf jeder Seite gezeichnet) --------------------------
def _kopf_fuss(canvas, doc, cfg): def _kopf_fuss(canvas, doc, cfg, amt=False):
"""Kopf und Fuss einer Seite. amt=True -> Blatt fuer das Amt (siehe Fusszeile)."""
firma = cfg["firma"] firma = cfg["firma"]
canvas.saveState() canvas.saveState()
@ -133,6 +155,13 @@ def _kopf_fuss(canvas, doc, cfg):
canvas.line(MARGIN_L, line_y, PAGE_W - MARGIN_R, line_y) canvas.line(MARGIN_L, line_y, PAGE_W - MARGIN_R, line_y)
# --- Fusszeile --- # --- 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 fy = MARGIN_BOT - 4 * mm
canvas.setStrokeColor(LINE) canvas.setStrokeColor(LINE)
canvas.setLineWidth(0.6) canvas.setLineWidth(0.6)
@ -142,9 +171,12 @@ def _kopf_fuss(canvas, doc, cfg):
col_l = MARGIN_L col_l = MARGIN_L
col_m = MARGIN_L + CONTENT_W * 0.40 col_m = MARGIN_L + CONTENT_W * 0.40
col_r = PAGE_W - MARGIN_R col_r = PAGE_W - MARGIN_R
rows_l = [firma.get("bank", ""), firma.get("iban", ""), firma.get("bic", "")]
rows_m = [firma.get("inhaber", ""), firma.get("steuernummer", "")] 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", "")] rows_r = [firma.get("homepage", ""), firma.get("email", ""), firma.get("telefon", "")]
for i, t in enumerate(rows_l): for i, t in enumerate(rows_l):
canvas.drawString(col_l, fy + 9 * mm - i * 3.6 * mm, t) canvas.drawString(col_l, fy + 9 * mm - i * 3.6 * mm, t)
for i, t in enumerate(rows_m): for i, t in enumerate(rows_m):
@ -205,7 +237,9 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
story.append(Spacer(1, 7 * mm)) story.append(Spacer(1, 7 * mm))
# Ueberschrift (auf dem Amt-Blatt mit Zusatz) # Ueberschrift (auf dem Amt-Blatt mit Zusatz)
titel = "Rechnungsübersicht" + (" Beherbergungssteuer (für das Amt)" if gesamtrechnung_betont else "") # 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.
titel = "Rechnungsübersicht" + (" (für das Amt)" if gesamtrechnung_betont else "")
story.append(Paragraph(titel, st["h"])) story.append(Paragraph(titel, st["h"]))
# Positionstabelle # Positionstabelle
@ -261,14 +295,20 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
Paragraph(f"{name} {b.satz} %:", st["info_l"]), Paragraph(f"{name} {b.satz} %:", st["info_l"]),
Paragraph(eur(b.ust), st["cell_r"]), Paragraph(eur(b.ust), st["cell_r"]),
]) ])
gr_idx = None # Auf dem Amt-Blatt taucht die Beherbergungssteuer NIRGENDS auf - weder als eigene
if rechnung.aufschlaege: # Zeile, noch versteckt im Gesamtbetrag. Das Amt bekommt die Rechnung so, wie sie
gr_idx = len(summen_rows) # Zeilen-Index der Zwischensumme # ohne die Steuer aussaehe: Netto, Umsatzsteuern, Gesamt. Genau dieser Gesamtbetrag
gr_l = st["total_l"] if gesamtrechnung_betont else st["info_l"] # ist die Bemessungsgrundlage, aus der es die Steuer selbst errechnet.
gr_v = st["total_v"] if gesamtrechnung_betont else st["cell_r"] #
# 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([ summen_rows.append([
Paragraph("Zwischensumme:", gr_l), Paragraph("Zwischensumme:", st["info_l"]),
Paragraph(eur(rechnung.brutto), gr_v), Paragraph(eur(rechnung.brutto), st["cell_r"]),
]) ])
for a in rechnung.aufschlaege: for a in rechnung.aufschlaege:
satz_txt = eur_kurz(a.satz).replace(",00", "") satz_txt = eur_kurz(a.satz).replace(",00", "")
@ -276,9 +316,11 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
Paragraph(f"zzgl. {a.bezeichnung or 'Aufschlag'} ({satz_txt} %):", st["info_l"]), Paragraph(f"zzgl. {a.bezeichnung or 'Aufschlag'} ({satz_txt} %):", st["info_l"]),
Paragraph(eur(a.betrag), st["cell_r"]), Paragraph(eur(a.betrag), st["cell_r"]),
]) ])
endsumme = rechnung.brutto if gesamtrechnung_betont else rechnung.gesamt
summen_rows.append([ summen_rows.append([
Paragraph("Gesamtbetrag:", st["total_l"]), Paragraph("Gesamtbetrag:", st["total_l"]),
Paragraph(eur(rechnung.gesamt), st["total_v"]), Paragraph(eur(endsumme), st["total_v"]),
]) ])
style_cmds = [ style_cmds = [
("VALIGN", (0, 0), (-1, -1), "MIDDLE"), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
@ -288,26 +330,21 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
("LINEBELOW", (0, -1), (1, -1), 0.8, ACCENT), ("LINEBELOW", (0, -1), (1, -1), 0.8, ACCENT),
("TOPPADDING", (0, -1), (-1, -1), 4), ("TOPPADDING", (0, -1), (-1, -1), 4),
] ]
if gesamtrechnung_betont and gr_idx is not None:
# Zwischensumme wie der Gesamtbetrag hervorheben: zwei Streifen
style_cmds += [
("LINEABOVE", (0, gr_idx), (1, gr_idx), 0.8, ACCENT),
("LINEBELOW", (0, gr_idx), (1, gr_idx), 0.8, ACCENT),
("TOPPADDING", (0, gr_idx), (-1, gr_idx), 4),
]
summen = Table(summen_rows, colWidths=[CONTENT_W - 44 * mm, 44 * mm]) summen = Table(summen_rows, colWidths=[CONTENT_W - 44 * mm, 44 * mm])
summen.setStyle(TableStyle(style_cmds)) summen.setStyle(TableStyle(style_cmds))
story.append(summen) story.append(summen)
# Hinweis zur Beherbergungssteuer # Hinweis zur Beherbergungssteuer - nur auf der Kundenseite. Auf dem Amt-Blatt
for a in rechnung.aufschlaege: # wird die Steuer gar nicht mehr ausgewiesen, dann waere der Satz sinnlos.
if not a.bezeichnung: if not gesamtrechnung_betont:
continue for a in rechnung.aufschlaege:
satz_txt = eur_kurz(a.satz).replace(",00", "") if not a.bezeichnung:
story.append(Spacer(1, 1.5 * mm)) continue
story.append(Paragraph( satz_txt = eur_kurz(a.satz).replace(",00", "")
f"Die {a.bezeichnung} ({satz_txt} %) wird gemäß kommunaler Satzung auf die " story.append(Spacer(1, 1.5 * mm))
f"Zwischensumme erhoben und ist im Gesamtbetrag enthalten.", st["dank"])) 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)) story.append(Spacer(1, 8 * mm))
@ -324,6 +361,31 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
story.append(KeepTogether(block)) 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.
"""
aufschlag = rechnung.aufschlaege[0] if rechnung.aufschlaege else None
daten = {
"quelle": "rechnungstool",
"version": 1,
"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),
}
return json.dumps(daten, ensure_ascii=False)
def render_rechnung(pfad, cfg, rechnung, kopf): def render_rechnung(pfad, cfg, rechnung, kopf):
""" """
pfad : Zielpfad der PDF pfad : Zielpfad der PDF
@ -332,26 +394,44 @@ def render_rechnung(pfad, cfg, rechnung, kopf):
kopf : dict mit rechnungsnummer, datum, anreise, abreise, naechte, kunde_zeilen kopf : dict mit rechnungsnummer, datum, anreise, abreise, naechte, kunde_zeilen
EIN PDF mit zwei Blaettern (falls Beherbergungssteuer anfaellt): EIN PDF mit zwei Blaettern (falls Beherbergungssteuer anfaellt):
Seite 1 = Kunden-Rechnung. Seite 1 = Kunden-Rechnung, vollstaendig (Steuer ausgewiesen, Bankverbindung im Fuss).
Seite 2 = 1:1-Kopie fuers Amt, nur die Zwischensumme fett + zwei Streifen. Letzte Seite = Blatt fuers Amt: KEINE Beherbergungssteuer (auch nicht im Gesamt),
keine Zwischensumme, keine Bankverbindung, keine Steuernummer.
""" """
st = _styles() st = _styles()
doc = SimpleDocTemplate(
pfad, pagesize=A4,
leftMargin=MARGIN_L, rightMargin=MARGIN_R,
topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT,
title=f"Rechnung {kopf.get('rechnungsnummer','')}",
author=cfg["firma"].get("name", ""),
)
story = []
_seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=False) # Seite 1: Kunde
if rechnung.aufschlaege:
story.append(PageBreak())
_seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=True) # Seite 2: Amt
doc.build( def bauen(ziel, amt_seite):
story, doc = SimpleDocTemplate(
onFirstPage=lambda c, d: _kopf_fuss(c, d, cfg), ziel, pagesize=A4,
onLaterPages=lambda c, d: _kopf_fuss(c, d, cfg), leftMargin=MARGIN_L, rightMargin=MARGIN_R,
) topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT,
title=f"Rechnung {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 return pfad