Steuerrechnungstool/modell.py
TheMockTv b2d933cb7c Amtsbericht: die Kette zeigt vorwaerts - Storno nennt die neue Rechnung
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
2026-09-04 02:19:49 +02:00

257 lines
9.5 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 -*-
"""
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