Zwei Ansagen von ihm, beide am selben Blatt:
1. "weil die nummer hast nur auf der storno selber aber nicht am
stornierten nummer um die es geht" - die aufgehobene Rechnung stand
nur als "storniert" da. Jetzt steht dort "storniert mit 2026-901".
Damit ist das Paar in BEIDE Richtungen lesbar, und das zaehlt vor
allem dann, wenn der Storno in einem anderen Monat liegt und auf
diesem Blatt gar nicht auftaucht.
2. "mach eine neue spalte mit grund oder so was" - der Vermerk stand als
zweite Zeile unter der Rechnungsnummer. Jetzt ist es eine eigene
Spalte "Grund" zwischen Rechnung und Name, eine Zeile je Buchung.
Feste Breiten neu verteilt (20/20/34 mm + Rest fuer den Namen), damit
nichts umbricht.
Dafuer neu: modell.aufhebungen() gibt {id der Rechnung: Storno-Buchung}
statt nur der ids - die Anzeige will auch sagen, WOMIT storniert wurde,
nicht nur DASS. aufgehobene() bleibt als duenne Huelle darueber, damit
zeig_stornos und doppelte_nummern unveraendert weiterlaufen.
Fenster und Blatt zeigen wieder dieselben Worte.
pruef_storno_verrechnet prueft beide Richtungen mit, auch ueber die
Monatsgrenze ("storniert mit 2026-911" im August, der Storno selbst steht
im September). Angesehen: Tabelle als Bild gerendert, alles einzeilig,
auch mit langem Namen. doppelte und berichtigung gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bEsoFUk16DfnNA7MjY36H
252 lines
9.1 KiB
Python
252 lines
9.1 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
|
||
|
||
@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
|