Doppelte Rechnungsnummern werden ANGEZEIGT statt still mitgeschleppt: rote Zeile mit Warnzeichen, markierter Monatsreiter, rote Zeile unter der Jahressumme und eine Liste aller Belege mit Dateinamen. Gerechnet wird ueber das ganze Jahr - liegt die zweite Rechnung im naechsten Monat, sieht kein Monat fuer sich eine Dublette, das Amt bekommt sie trotzdem doppelt. Die Betraege bleiben in allen Summen und im Amtsbericht; gemeldet wird der Fund, korrigiert wird nichts von allein. Vorgeschichte: bis v1.2 war die Rechnungsnummer der Schluessel der Tabelle - die zweite Rechnung ueberschrieb die erste still und fehlte im Amtsbericht. v1.3 hat den Schluessel auf die PDF-Datei umgestellt, seitdem bleiben beide Zeilen stehen. Erst jetzt sieht man den Fall auch. Einzelinstanz aus dem Steuerrechner uebernommen (nur Mutex- und PID-Name geaendert): zwei Fenster lesen denselben Rechnungsordner in dieselbe journal.sqlite3 - was das eine loescht, steht im anderen noch da, und wer dort "PDF fuers Amt" drueckt, meldet einen Stand, den es nicht mehr gibt. Die Sperre greift vor ablage.daten_ordner() und nur beim echten Start. "Neue Buchung" ist raus: jede Zeile gehoert zu einer Rechnung aus dem Rechnungstool. Wer eine Buchung braucht, schreibt die Rechnung. Bestehende Zeilen lassen sich weiter bearbeiten. Pruefstaende: pruef_doppelte.py (27 Pruefungen am echten Fenster), pruef_einzelinstanz.py (echte Prozesse). Beide gruen, dazu Abnahme mit den Augen an Hauptfenster, Liste und Bearbeiten-Dialog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx
173 lines
5.4 KiB
Python
173 lines
5.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
|
||
|
||
@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 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.
|
||
"""
|
||
nach_nummer = {}
|
||
for b in buchungen:
|
||
if b.rechnungsnummer:
|
||
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
|