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