Steuerrechnungstool/modell.py
TheMockTv 894bc81c46 Doppelte Rechnungsnummern zeigen, nur ein Fenster, keine Handbuchungen
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
2026-09-03 19:47:44 +02:00

173 lines
5.4 KiB
Python
Raw Permalink 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
@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