Sein Fund: "an der storno verweist du auf die alte rechnung, muss aber
lauten neu zu und neue rechnungs nummer. heisst kette ist so: rechnung
storniert weist auf die storno nummer, storno verweist auf neue rechnung.
wer soll denn sonst wissen wo er suchen muss."
Er hat recht: eine Zeile, die nur zurueckzeigt, laesst den Leser stehen.
Jetzt sagt jede Zeile, wo es WEITERGEHT:
Rechnung -> "storniert mit 2026-901"
Storno -> "neue Rechnung 2026-902" (Fall A)
-> "neu abgerechnet mit 2026-343" (Fall B, Doppelung)
-> "Storno zu 2026-900" (Fall C / alte Belege ohne Angabe)
neue R. -> "Neuausstellung"
Dafuer wandert die Folgenummer aus den PDF-Kenndaten bis ins Journal:
Buchung.folge_nummer + folge_art (neu | vorhanden | ""), gelesen aus
korrektur_nummer/ersatz_vorhanden/nur_storno, zwei neue DB-Spalten, alte
Journale bauen sich wie gehabt selbst um.
An seinen ECHTEN Belegen geprueft: Storno 2026-455 -> folge 2026-369
(vorhanden), 2026-456 -> 2026-343 (vorhanden). Die Angabe steht in den
Kenndaten schon drin, sie wurde bisher nur nicht gelesen.
Fenster und Blatt zeigen dieselben Worte. Erklaerungs-PDF um eine
Kettentabelle ergaenzt. Pruefkette gruen (storno_verrechnet inkl.
September-Blatt, doppelte_leistung, nur_storno).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bEsoFUk16DfnNA7MjY36H
257 lines
9.5 KiB
Python
257 lines
9.5 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
Datenmodell + Rechenkern der Beherbergungssteuer.
|
||
|
||
Eine Buchung = eine Zeile im Steuerjournal = eine Rechnung.
|
||
Gerechnet wird ausschließlich hier, damit GUI, Excel-Export und PDF-Import
|
||
garantiert dieselben Zahlen zeigen.
|
||
|
||
Steuerlogik (wie in der Excel-Vorlage und im Rechnungstool):
|
||
Steuer = Gezahlt / 100 * Satz
|
||
wobei "Gezahlt" die Zwischensumme der Rechnung ist (Leistungen brutto,
|
||
also inkl. USt, aber ohne die Beherbergungssteuer selbst).
|
||
"""
|
||
|
||
import re
|
||
from dataclasses import dataclass, field
|
||
from datetime import date
|
||
|
||
MONATE = [
|
||
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||
]
|
||
|
||
MONATE_KURZ = ["JAN", "FEB", "MÄR", "APR", "MAI", "JUN",
|
||
"JUL", "AUG", "SEP", "OKT", "NOV", "DEZ"]
|
||
|
||
STANDARD_SATZ = 5.0 # Prozent Beherbergungssteuer
|
||
|
||
|
||
def runde(x) -> float:
|
||
"""Kaufmännisch auf 2 Nachkommastellen runden."""
|
||
return round(float(x) + 1e-9, 2)
|
||
|
||
|
||
def steuer_betrag(gezahlt, satz) -> float:
|
||
"""Beherbergungssteuer auf einen Rechnungsbetrag: Gezahlt / 100 * Satz."""
|
||
return runde(float(gezahlt or 0) / 100.0 * float(satz or 0))
|
||
|
||
|
||
@dataclass
|
||
class Buchung:
|
||
"""Eine Zeile des Steuerjournals."""
|
||
datum: date
|
||
rechnungsnummer: str # nur der Zähler, z.B. "238" (das Jahr steckt in .jahr)
|
||
jahr: int
|
||
nachname: str
|
||
naechte: int
|
||
gezahlt: float # Zwischensumme der Rechnung (Basis der Steuer)
|
||
satz: float = STANDARD_SATZ
|
||
quelle: str = "pdf" # pdf | xlsx | manuell
|
||
pdf_pfad: str = ""
|
||
id: int = 0
|
||
# Aus den Kenndaten der PDF: was fuer ein Beleg ist das, und worauf bezieht
|
||
# er sich. Ohne diese vier Felder ist eine Stornozeile im Journal nur eine
|
||
# Buchung mit Minusbetraegen - man sieht nicht, welche Rechnung sie aufhebt.
|
||
art: str = "rechnung" # rechnung | storno | korrektur
|
||
storno_zu: str = "" # Nummer der aufgehobenen Rechnung
|
||
storno_datum: str = "" # deren Datum (TT.MM.JJJJ) - Teil des Bezugs
|
||
vorgang: str = "" # Klammer um Rechnung, Storno und Neuausstellung
|
||
# Worauf der Storno VORWAERTS zeigt. Ohne das endet die Kette am Storno und
|
||
# niemand weiss, wo die Leistung nun abgerechnet ist (seine Ansage 04.09.2026:
|
||
# "wer soll denn sonst wissen, wo er suchen muss").
|
||
folge_nummer: str = "" # Nummer der Rechnung, die danach gilt
|
||
folge_art: str = "" # neu | vorhanden | "" (es folgt nichts)
|
||
|
||
@property
|
||
def steuer(self) -> float:
|
||
return steuer_betrag(self.gezahlt, self.satz)
|
||
|
||
@property
|
||
def monat(self) -> int:
|
||
"""1-12"""
|
||
return self.datum.month
|
||
|
||
@property
|
||
def voll_nummer(self) -> str:
|
||
return f"{self.jahr}-{self.rechnungsnummer}"
|
||
|
||
@property
|
||
def schluessel(self) -> str:
|
||
"""Eindeutig pro Rechnung – verhindert Doppel-Import derselben PDF."""
|
||
return f"{self.jahr}-{self.rechnungsnummer}"
|
||
|
||
|
||
def aufhebungen(buchungen) -> dict:
|
||
"""Welcher Storno hebt welche Rechnung auf: {id der Rechnung: Storno-Buchung}.
|
||
|
||
Der Bezug ist Nummer + Datum + Nachname, nicht die Nummer allein. Genau
|
||
darum geht es hier: bei einer DOPPELT vergebenen Nummer waere die Nummer
|
||
kein eindeutiger Bezug - § 31 Abs. 5 UStDV verlangt aber, dass sich ein
|
||
berichtigendes Dokument "spezifisch und eindeutig" auf die Rechnung
|
||
bezieht. Steht auf dem Storno also 2026-020 vom 05.08. fuer Zweitgast, dann
|
||
ist die Rechnung von Erstgast NICHT aufgehoben.
|
||
|
||
Zurueckgegeben wird der Storno selbst und nicht nur ein Haken, damit die
|
||
Anzeige auch sagen kann, WOMIT eine Rechnung aufgehoben wurde
|
||
("storniert mit 2026-901"). Sonst zeigt das Blatt nur "storniert" und man
|
||
muss die Gegenzeile suchen.
|
||
"""
|
||
stornos = [b for b in buchungen if b.storno_zu]
|
||
if not stornos:
|
||
return {}
|
||
zu = {}
|
||
for s in stornos:
|
||
for b in buchungen:
|
||
if b.id in zu or b is s or not b.id:
|
||
continue
|
||
if b.voll_nummer != s.storno_zu:
|
||
continue
|
||
# Datum und Name muessen passen, WENN der Storno sie nennt.
|
||
if s.storno_datum and b.datum.strftime("%d.%m.%Y") != s.storno_datum:
|
||
continue
|
||
if s.nachname and b.nachname and s.nachname != b.nachname:
|
||
continue
|
||
zu[b.id] = s
|
||
break
|
||
return zu
|
||
|
||
|
||
def aufgehobene(buchungen) -> set:
|
||
"""Nur die ids - fuer alle, die den Storno selbst nicht brauchen."""
|
||
return set(aufhebungen(buchungen))
|
||
|
||
|
||
def doppelte_nummern(buchungen) -> dict:
|
||
"""Rechnungsnummern, die mehr als einmal im Journal stehen.
|
||
|
||
{"2026-020": [Buchung, Buchung]}
|
||
|
||
Gerechnet wird ueber das ganze JAHR, nicht je Monat: dieselbe Nummer kann
|
||
im August und im September liegen - dann sieht kein Monat fuer sich eine
|
||
Dublette, das Amt bekaeme die Uebernachtung trotzdem zweimal gemeldet.
|
||
|
||
Gemeldet wird der Fund, nichts wird stillschweigend weggerechnet: die
|
||
Betraege bleiben in allen Summen und im Amtsbericht, sonst stimmte das
|
||
Journal nicht mehr mit dem Rechnungsordner ueberein. Jede Nummer darf es
|
||
nur einmal geben (Paragraph 14 Abs. 4 Nr. 4 UStG) - was zu tun ist,
|
||
entscheidet der Wirt, nicht das Programm.
|
||
"""
|
||
weg = aufgehobene(buchungen)
|
||
nach_nummer = {}
|
||
for b in buchungen:
|
||
# Stornos tragen eine eigene Nummer und sind nie eine Dublette. Und was
|
||
# storniert wurde, ist keine offene Rechnung mehr - sonst bliebe die
|
||
# Meldung stehen, obwohl der Fall richtiggestellt ist.
|
||
if not b.rechnungsnummer or b.storno_zu or b.id in weg:
|
||
continue
|
||
nach_nummer.setdefault(b.voll_nummer, []).append(b)
|
||
return {nr: sorted(liste, key=lambda b: (b.datum, b.id))
|
||
for nr, liste in sorted(nach_nummer.items()) if len(liste) > 1}
|
||
|
||
|
||
@dataclass
|
||
class Summe:
|
||
"""Aufsummierte Kennzahlen einer Menge von Buchungen (Monat oder Jahr)."""
|
||
buchungen: int = 0
|
||
naechte: int = 0
|
||
gezahlt: float = 0.0
|
||
steuer: float = 0.0
|
||
gesamt: float = field(default=0.0) # gezahlt + steuer (wie I2 in der Vorlage)
|
||
|
||
|
||
def summiere(buchungen) -> Summe:
|
||
"""Kennzahlen einer Menge von Buchungen - ALLE Zeilen, mit ihrem Vorzeichen.
|
||
|
||
⭐ Ein Storno wird VERRECHNET, nicht weggelassen: die aufgehobene Rechnung
|
||
bleibt mit ihrem Plus im Monat der Rechnung stehen, der Storno steht mit
|
||
seinem Minus im Monat des Storno-Belegs. Uebers Jahr hebt sich das auf.
|
||
|
||
Das ist eine Entscheidung des Auftraggebers vom 04.09.2026, nachdem beides
|
||
durchgerechnet und die Satzung nachgelesen war (04.09. war es zwischenzeitlich
|
||
andersherum gebaut). Sein Wort:
|
||
|
||
"ich wuerde das wie beim alten lassen, nicht das er der Stadt zu wenig
|
||
gibt"
|
||
|
||
Der Unterschied zwischen beiden Wegen ist NUR der Monat, nie das Jahr:
|
||
|
||
verrechnet (so wie hier): August meldet die Doppelung mit,
|
||
September zieht sie ab
|
||
weggelassen (verworfen): August meldet sie gar nicht erst
|
||
|
||
⚠️ Streng nach § 7 Abs. 5 der Beherbergungssteuersatzung Markkleeberg wird
|
||
die im Kalendermonat *vereinnahmte* Steuer angemeldet - fuer eine doppelt
|
||
geschriebene, nie kassierte Rechnung waere das der Monat der Rechnung.
|
||
Verrechnen meldet im ersten Monat also eher zu VIEL und im zweiten zu wenig.
|
||
Genau in dieser Richtung ist es gewollt: zu viel zuerst ist beim Amt nie ein
|
||
Problem, zu wenig schon. Wer das umbaut, aendert eine bewusste Entscheidung -
|
||
bitte vorher fragen.
|
||
"""
|
||
s = Summe()
|
||
for b in buchungen:
|
||
s.buchungen += 1
|
||
s.naechte += int(b.naechte or 0)
|
||
s.gezahlt += float(b.gezahlt or 0)
|
||
s.steuer += b.steuer
|
||
s.gezahlt = runde(s.gezahlt)
|
||
s.steuer = runde(s.steuer)
|
||
s.gesamt = runde(s.gezahlt + s.steuer)
|
||
return s
|
||
|
||
|
||
def eur(x) -> str:
|
||
"""1234.5 -> '1.234,50 €' (deutsche Schreibweise)."""
|
||
s = f"{float(x or 0):,.2f}"
|
||
s = s.replace(",", "#").replace(".", ",").replace("#", ".")
|
||
return s + " €"
|
||
|
||
|
||
def eur_kurz(x) -> str:
|
||
"""Wie eur(), aber ohne Währungszeichen (für enge Tabellenspalten)."""
|
||
s = f"{float(x or 0):,.2f}"
|
||
return s.replace(",", "#").replace(".", ",").replace("#", ".")
|
||
|
||
|
||
RE_NUMMER = re.compile(r"^\s*(?:(?P<jahr>\d{4})\s*[-/]\s*)?(?P<nr>.+?)\s*$")
|
||
|
||
|
||
def zerlege_nummer(text, standard_jahr: int):
|
||
"""
|
||
Bringt jede Schreibweise der Rechnungsnummer auf eine Form.
|
||
|
||
'2026-238' -> (2026, '238')
|
||
'013' -> (standard_jahr, '013')
|
||
'13' -> (standard_jahr, '013') dreistellig wie im Rechnungstool
|
||
'A-7' -> (standard_jahr, 'A-7') nicht-numerisch bleibt, wie es ist
|
||
|
||
Ohne diese Normalisierung wären '17' und '2026-17' zwei verschiedene
|
||
Buchungen – in der alten Excel-Tabelle kommt beides vor.
|
||
"""
|
||
s = " ".join(str(text or "").split())
|
||
if not s:
|
||
return standard_jahr, ""
|
||
m = RE_NUMMER.match(s)
|
||
jahr = int(m.group("jahr")) if m.group("jahr") else standard_jahr
|
||
nr = m.group("nr")
|
||
if nr.isdigit():
|
||
nr = f"{int(nr):03d}"
|
||
return jahr, nr
|
||
|
||
|
||
def zahl_aus_text(text) -> float:
|
||
"""'1.234,50 €' / '87,00' / '13.00' -> float. Leere/kaputte Eingabe -> 0.0"""
|
||
if text is None:
|
||
return 0.0
|
||
if isinstance(text, (int, float)):
|
||
return float(text)
|
||
s = "".join(c for c in str(text) if c.isdigit() or c in ",.-")
|
||
if not s:
|
||
return 0.0
|
||
# Deutsche Schreibweise: Punkt = Tausender, Komma = Dezimal
|
||
if "," in s:
|
||
s = s.replace(".", "").replace(",", ".")
|
||
try:
|
||
return float(s)
|
||
except ValueError:
|
||
return 0.0
|