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>
This commit is contained in:
commit
80967de26f
12 changed files with 1908 additions and 0 deletions
150
modell.py
Normal file
150
modell.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
# -*- 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue