Storno: das Journal zaehlte die Uebernachtungen der aufgehobenen Rechnung weiter mit (4 - 0 + 3 = 7 statt 3 gemeldeter Naechte). Die Betraege kuerzten sich, die Naechte nicht. Der Storno traegt jetzt die negative Zahl, und das Journal kennt Art, Bezug und Datum des Belegs (vier neue Spalten, alte Journale bauen sich beim Oeffnen um). Berichtigung: das Blatt wurde nur uebersprungen. Der berichtigte NAME kommt jetzt in der Buchung an - im Amtsbericht stand sonst weiter der falsche. Angewandt wird erst am Ende des Scans, das Blatt kann vor seiner Rechnung im Ordner liegen. Doppelte Nummern: eine stornierte Rechnung ist keine offene mehr und faellt aus der Meldung. Zugeordnet wird ueber Nummer + Datum + Empfaenger, nicht ueber die Nummer allein - bei einer doppelt vergebenen Nummer waere die kein eindeutiger Bezug, und genau den verlangt § 31 Abs. 5 UStDV. Die Texte nennen jetzt den Weg: eine der beiden im Rechnungstool stornieren und mit freier Nummer neu ausstellen (§ 14 Abs. 4 Nr. 4 UStG - jede Nummer nur einmal). Neue Spalte "Art": Storno zu ..., storniert, Neuausstellung. Aufgehobene Zeilen stehen grau - geloescht wird nichts. Gemeinsames Nummernbuch (gemeinsam.py, ordnerwahl.py - in beiden Programmen dieselbe Datei): eine SQLite in einem Ordner, den beide kennen. Das Journal traegt seine Nummern ein, auch die aus der Excel-Mappe ohne PDF - genau die kannte das Rechnungstool nicht und vergab sie ein zweites Mal. Beim ersten Start wird nach dem Ordner gefragt (auswaehlen, OK, Sicherheitsfrage, bei Nein zurueck ins Feld); wer ihn nicht hat, kommt mit "Spaeter einrichten" weiter. Kein Doppelklick zum Bearbeiten mehr (gui_dialoge.py geloescht): wer hier Betrag oder Nummer verstellt, meldet dem Amt etwas anderes, als auf dem Beleg steht. Loeschen bleibt. --test / CAMPINGHOF_TEST=1 unterdrueckt den Erststart-Dialog (Pruefstaende). Pruefstaende: pruef_storno_journal.py (neu, ueber BEIDE Programme: Storno, Berichtigung, doppelte Nummer aufloesen), pruef_doppelte, pruef_berichtigung, pruef_einzelinstanz - alle gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx
215 lines
7.4 KiB
Python
215 lines
7.4 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 aufgehobene(buchungen) -> set:
|
||
"""ids der Buchungen, die durch einen Storno aufgehoben sind.
|
||
|
||
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.
|
||
"""
|
||
stornos = [b for b in buchungen if b.storno_zu]
|
||
if not stornos:
|
||
return set()
|
||
weg = set()
|
||
for s in stornos:
|
||
for b in buchungen:
|
||
if b.id in weg 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
|
||
weg.add(b.id)
|
||
break
|
||
return weg
|
||
|
||
|
||
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:
|
||
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
|