beherbergungssteuer/modell.py
TheMockTv 80967de26f Beherbergungssteuer: Steuerjournal aus den Rechnungs-PDFs
Ersetzt die Excel-Mappe des Campinghofs. Die Rechnungs-PDFs des
Rechnungstools sind die Quelle; SQLite ist nur Cache, Excel nur noch
Import des Altbestands. Bericht fuers Amt als PDF im Briefkopf der
Rechnungen (Firmendaten aus der config.json des Rechnungstools).

- pdf_parser: liest Datum, Nummer, Name, Naechte, Zwischensumme, Satz
- db: Journal + Scan mit Cache (Pfad/mtime/Groesse)
- xlsx_io: Import der alten Tabelle, vereinheitlicht Rechnungsnummern
- bericht_pdf: Monats- und Jahresbericht (reportlab)
- GUI: Monatsreiter, Suche, Anhaken + Papierkorb, Kennzahlen-Leiste

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:15:44 +02:00

150 lines
4.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}"
@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