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:
TheMockTv 2026-07-12 19:15:44 +02:00
commit 80967de26f
12 changed files with 1908 additions and 0 deletions

454
app.py Normal file
View file

@ -0,0 +1,454 @@
# -*- coding: utf-8 -*-
"""
Beherbergungssteuer Steuerjournal für den Campinghof Bartl.
Ersetzt die Excel-Mappe: Die Rechnungs-PDFs aus dem Rechnungstool sind die
Quelle, das Programm liest sie ein, führt das Journal (SQLite) und schreibt
auf Knopfdruck die Excel-Mappe fürs Amt.
Start: Doppelklick auf "Beherbergungssteuer starten.bat" oder `python app.py`
"""
import os
import sys
import logging
from datetime import date
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import db
import xlsx_io
import firma as firma_lib
import bericht_pdf
from modell import MONATE, STANDARD_SATZ, summiere, eur
from gui_monat import MonatsTab, SummenLeiste
from gui_dialoge import BuchungDialog
BASE_DIR = os.path.dirname(os.path.abspath(sys.argv[0] if getattr(sys, "frozen", False) else __file__))
JOURNAL = os.path.join(BASE_DIR, "journal.sqlite3")
LOGDATEI = os.path.join(BASE_DIR, "beherbergungssteuer.log")
BERICHTE = os.path.join(BASE_DIR, "berichte")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)-8s %(message)s",
handlers=[logging.FileHandler(LOGDATEI, encoding="utf-8"), logging.StreamHandler()],
)
log = logging.getLogger("bst.app")
class App(tk.Tk):
def __init__(self):
super().__init__()
self.title("Beherbergungssteuer Campinghof Bartl")
self.geometry("1000x680")
self.minsize(820, 520)
self.journal = db.Journal(JOURNAL)
self.satz = float(self.journal.hole("satz", str(STANDARD_SATZ)))
self.pdf_ordner = self.journal.hole("pdf_ordner", "")
self.jahr = tk.IntVar(value=int(self.journal.hole("jahr", str(date.today().year))))
self.firma = firma_lib.lade(self.journal.hole("rechnungstool_config", ""))
self.suche = tk.StringVar()
self.suche.trace_add("write", lambda *_: self.aktualisieren())
os.makedirs(BERICHTE, exist_ok=True)
self._menue()
self._kopfleiste()
self._blaetter()
self._statusleiste()
self.bind("<F5>", lambda _e: self.einlesen())
self.bind("<Control-f>", lambda _e: self.suchfeld.focus_set())
self.bind("<Delete>", lambda _e: self.buchung_loeschen())
self.protocol("WM_DELETE_WINDOW", self._beenden)
self.aktualisieren()
if self.pdf_ordner and os.path.isdir(self.pdf_ordner):
self.after(200, lambda: self.einlesen(still=True))
# ------------------------------------------------------------ Aufbau ----
def _menue(self):
leiste = tk.Menu(self)
datei = tk.Menu(leiste, tearoff=0)
datei.add_command(label="Rechnungs-Ordner wählen…", command=self.ordner_waehlen)
datei.add_separator()
datei.add_command(label="Monatsbericht fürs Amt (PDF)…", command=self.bericht_monat)
datei.add_command(label="Jahresbericht fürs Amt (PDF)…", command=self.bericht_jahr)
datei.add_separator()
datei.add_command(label="Excel-Tabelle einlesen…", command=self.altbestand_importieren)
datei.add_separator()
datei.add_command(label="Beenden", command=self._beenden)
leiste.add_cascade(label="Datei", menu=datei)
daten = tk.Menu(leiste, tearoff=0)
daten.add_command(label="Rechnungen einlesen (F5)", command=self.einlesen)
daten.add_command(label="Alle Rechnungen neu einlesen", command=lambda: self.einlesen(voll=True))
daten.add_separator()
daten.add_command(label="Buchung von Hand anlegen…", command=self.buchung_anlegen)
daten.add_command(label="Markierte Buchung löschen (Entf)", command=self.buchung_loeschen)
daten.add_separator()
daten.add_command(label="Steuersatz ändern…", command=self.satz_aendern)
leiste.add_cascade(label="Daten", menu=daten)
self.config(menu=leiste)
def _kopfleiste(self):
leiste = ttk.Frame(self, padding=(10, 8))
leiste.pack(fill="x")
ttk.Label(leiste, text="Jahr:").pack(side="left")
self.jahr_wahl = ttk.Combobox(leiste, width=6, state="readonly",
textvariable=self.jahr, values=self._jahre())
self.jahr_wahl.pack(side="left", padx=(4, 14))
self.jahr_wahl.bind("<<ComboboxSelected>>", lambda _e: self.jahr_gewechselt())
ttk.Button(leiste, text="Rechnungen einlesen", command=self.einlesen).pack(side="left")
ttk.Button(leiste, text="Neue Buchung", command=self.buchung_anlegen).pack(side="left", padx=6)
ttk.Button(leiste, text="PDF fürs Amt", command=self.bericht_monat).pack(side="left")
self.papierkorb = ttk.Button(leiste, text="🗑 Löschen", state="disabled",
command=self.angehakte_loeschen)
self.papierkorb.pack(side="left", padx=(6, 0))
ttk.Label(leiste, text="Suche:").pack(side="left", padx=(20, 4))
self.suchfeld = ttk.Entry(leiste, textvariable=self.suche, width=22)
self.suchfeld.pack(side="left")
self.suchfeld.bind("<Escape>", lambda _e: self.suche_leeren())
ttk.Button(leiste, text="×", width=3, command=self.suche_leeren).pack(side="left", padx=(3, 0))
def _blaetter(self):
self.blaetter = ttk.Notebook(self)
self.blaetter.pack(fill="both", expand=True, padx=10, pady=(0, 6))
self.monate = {}
for m in range(1, 13):
tab = MonatsTab(self.blaetter, m, bei_doppelklick=self.buchung_bearbeiten,
bei_haken=self.papierkorb_auffrischen)
self.blaetter.add(tab, text=MONATE[m - 1])
self.monate[m] = tab
self.blaetter.select(min(date.today().month, 12) - 1)
def _statusleiste(self):
rahmen = ttk.Frame(self, padding=(10, 2, 10, 6))
rahmen.pack(fill="x")
self.jahr_titel = ttk.Label(rahmen, text="", font=("Segoe UI", 9, "bold"))
self.jahr_titel.pack(anchor="w")
self.jahr_summe = SummenLeiste(rahmen, mit_schnitt=True)
self.jahr_summe.pack(fill="x", pady=(2, 4))
fuss = ttk.Frame(rahmen)
fuss.pack(fill="x")
self.status = ttk.Label(fuss, text="", foreground="black")
self.status.pack(side="left")
self.ordner_anzeige = ttk.Label(fuss, text="", foreground="black")
self.ordner_anzeige.pack(side="right")
# ----------------------------------------------------------- Anzeige ----
def _jahre(self):
vorhanden = set(self.journal.jahre()) | {date.today().year, self.jahr.get()}
return [str(j) for j in sorted(vorhanden)]
def _passt(self, b, begriff: str) -> bool:
"""Sucht in Nachname, Rechnungsnummer und Datum."""
return begriff in " ".join((
b.nachname.lower(),
b.voll_nummer.lower(),
b.rechnungsnummer.lower(),
b.datum.strftime("%d.%m.%Y"),
))
def aktualisieren(self):
jahr = self.jahr.get()
alle = self.journal.buchungen(jahr=jahr)
begriff = self.suche.get().strip().lower()
sichtbar = [b for b in alle if self._passt(b, begriff)] if begriff else alle
for m in range(1, 13):
self.monate[m].fuellen([b for b in sichtbar if b.monat == m])
if begriff:
self.jahr_titel.config(text=f"Jahr {jahr} Treffer für „{self.suche.get().strip()}")
# zum ersten Monat mit Treffern springen
monate_mit_treffern = sorted({b.monat for b in sichtbar})
if monate_mit_treffern and self._monat not in monate_mit_treffern:
self.blaetter.select(monate_mit_treffern[0] - 1)
else:
self.jahr_titel.config(text=f"Jahr {jahr} gesamt")
self.jahr_summe.setzen(summiere(sichtbar), sichtbar)
self.jahr_wahl.config(values=self._jahre())
self.papierkorb_auffrischen()
self.ordner_anzeige.config(
text=f"Rechnungen: {self.pdf_ordner}" if self.pdf_ordner
else "kein Rechnungs-Ordner gewählt (Datei → Rechnungs-Ordner wählen…)")
def suche_leeren(self):
self.suche.set("")
self.suchfeld.focus_set()
def melde(self, text: str):
self.status.config(text=text)
log.info(text)
def jahr_gewechselt(self):
self.journal.setze("jahr", self.jahr.get())
self.aktualisieren()
# ---------------------------------------------------------- Aktionen ----
def ordner_waehlen(self):
ordner = filedialog.askdirectory(
title="Ordner mit den Rechnungs-PDFs wählen",
initialdir=self.pdf_ordner or os.path.expanduser("~"))
if not ordner:
return
self.pdf_ordner = os.path.abspath(ordner)
self.journal.setze("pdf_ordner", self.pdf_ordner)
self.aktualisieren()
self.einlesen()
def einlesen(self, voll: bool = False, still: bool = False):
if not self.pdf_ordner or not os.path.isdir(self.pdf_ordner):
if not still:
messagebox.showinfo("Rechnungs-Ordner",
"Bitte zuerst den Ordner mit den Rechnungs-PDFs wählen.",
parent=self)
return
self.config(cursor="watch")
self.melde("Rechnungen werden eingelesen…")
self.update_idletasks()
try:
neu, akt, unveraendert, fehler = self.journal.scanne(
self.pdf_ordner, self.satz, voll=voll)
finally:
self.config(cursor="")
self.aktualisieren()
text = f"{neu} neu, {akt} aktualisiert, {unveraendert} unverändert"
if fehler:
text += f", {len(fehler)} übersprungen"
self.melde(text)
if fehler and not still:
namen = "\n".join(f"{os.path.basename(p)} {g}" for p, g in fehler[:12])
if len(fehler) > 12:
namen += f"\n… und {len(fehler) - 12} weitere"
messagebox.showwarning(
"Nicht eingelesen",
f"{len(fehler)} Datei(en) sind keine lesbare Rechnung:\n\n{namen}",
parent=self)
def buchung_anlegen(self):
d = BuchungDialog(self, jahr=self.jahr.get(), satz=self.satz)
self.wait_window(d)
if d.ergebnis:
self.journal.speichern(d.ergebnis)
self.jahr.set(d.ergebnis.jahr)
self.aktualisieren()
self.blaetter.select(d.ergebnis.monat - 1)
self.melde(f"Buchung {d.ergebnis.voll_nummer} gespeichert")
def buchung_bearbeiten(self, buchung):
d = BuchungDialog(self, jahr=buchung.jahr, satz=self.satz, buchung=buchung)
self.wait_window(d)
if d.loeschen:
self.journal.loeschen(buchung.id)
self.aktualisieren()
self.melde(f"Buchung {buchung.voll_nummer} gelöscht")
return
if d.ergebnis:
# Nummer/Jahr können geändert worden sein -> alte Zeile weicht der neuen
if d.ergebnis.schluessel != buchung.schluessel and buchung.id:
self.journal.loeschen(buchung.id)
self.journal.speichern(d.ergebnis)
self.aktualisieren()
self.melde(f"Buchung {d.ergebnis.voll_nummer} geändert")
def _markierte(self):
return self.monate[self._monat].markierte()
def buchung_loeschen(self):
b = self._markierte()
if not b:
return
if not messagebox.askyesno(
"Buchung löschen",
f"Rechnung {b.voll_nummer} ({b.nachname}) aus dem Journal löschen?\n\n"
"Die PDF-Datei bleibt erhalten beim nächsten Einlesen "
"taucht die Buchung wieder auf.", parent=self):
return
self.journal.loeschen(b.id)
self.aktualisieren()
self.melde(f"Buchung {b.voll_nummer} gelöscht")
# ---- Anhaken + Papierkorb ----------------------------------------------
def _angehakte(self):
"""Alle angehakten Buchungen über alle Monate hinweg."""
return [b for m in range(1, 13) for b in self.monate[m].angehakte()]
def papierkorb_auffrischen(self):
anzahl = len(self._angehakte())
self.papierkorb.config(
text=f"🗑 Löschen ({anzahl})" if anzahl else "🗑 Löschen",
state="normal" if anzahl else "disabled")
def angehakte_loeschen(self):
angehakt = self._angehakte()
if not angehakt:
return
aus_pdf = [b for b in angehakt if b.quelle == "pdf"]
namen = "\n".join(f"{b.datum.strftime('%d.%m.%Y')} {b.voll_nummer} "
f"{b.nachname or 'ohne Namen'}" for b in angehakt[:15])
if len(angehakt) > 15:
namen += f"\n… und {len(angehakt) - 15} weitere"
hinweis = ""
if aus_pdf:
hinweis = (f"\n\n{len(aus_pdf)} davon stammen aus einer Rechnungs-PDF die Dateien "
"bleiben liegen und tauchen beim nächsten Einlesen wieder auf.")
if not messagebox.askyesno(
"Angehakte Buchungen löschen",
f"{len(angehakt)} Buchung(en) aus dem Journal löschen?\n\n{namen}{hinweis}",
parent=self):
return
for b in angehakt:
self.journal.loeschen(b.id)
for m in range(1, 13):
self.monate[m].haken_loesen()
self.aktualisieren()
self.papierkorb_auffrischen()
self.melde(f"{len(angehakt)} Buchung(en) gelöscht")
def satz_aendern(self):
fenster = tk.Toplevel(self)
fenster.title("Steuersatz")
fenster.resizable(False, False)
fenster.transient(self)
fenster.grab_set()
rahmen = ttk.Frame(fenster, padding=14)
rahmen.pack()
ttk.Label(rahmen, text="Beherbergungssteuer in Prozent:").grid(row=0, column=0, padx=(0, 8))
eingabe = ttk.Entry(rahmen, width=8)
eingabe.insert(0, f"{self.satz:g}".replace(".", ","))
eingabe.grid(row=0, column=1)
ttk.Label(rahmen, text="Gilt für neu eingelesene und neue Buchungen.\n"
"Bereits erfasste Zeilen behalten ihren Satz.",
foreground="black").grid(row=1, column=0, columnspan=2, pady=(8, 10))
def speichern():
from modell import zahl_aus_text
wert = zahl_aus_text(eingabe.get())
if wert <= 0:
messagebox.showerror("Steuersatz", "Bitte einen Satz größer 0 eintragen.",
parent=fenster)
return
self.satz = wert
self.journal.setze("satz", wert)
self.melde(f"Steuersatz auf {wert:g} % gesetzt")
fenster.destroy()
ttk.Button(rahmen, text="Speichern", command=speichern) \
.grid(row=2, column=0, columnspan=2, sticky="ew")
fenster.bind("<Escape>", lambda _e: fenster.destroy())
fenster.bind("<Return>", lambda _e: speichern())
eingabe.focus_set()
def altbestand_importieren(self):
pfad = filedialog.askopenfilename(
title="Alte Beherbergungssteuer-Tabelle wählen",
filetypes=[("Excel-Mappe", "*.xlsx"), ("Alle Dateien", "*.*")])
if not pfad:
return
buchungen, fehler = xlsx_io.importiere(pfad, self.satz)
if not buchungen:
messagebox.showwarning("Import", "In der Tabelle wurden keine Buchungen gefunden.",
parent=self)
return
s = summiere(buchungen)
if not messagebox.askyesno(
"Alte Tabelle importieren",
f"{s.buchungen} Buchungen gefunden "
f"({s.naechte} Übernachtungen, Steuer {eur(s.steuer)}).\n\n"
"Bestehende Zeilen mit gleicher Rechnungsnummer werden überschrieben.\n"
"Importieren?", parent=self):
return
for b in buchungen:
self.journal.speichern(b)
self.aktualisieren()
self.melde(f"{len(buchungen)} Buchungen aus {os.path.basename(pfad)} importiert"
+ (f", {len(fehler)} Zeilen übersprungen" if fehler else ""))
@property
def _monat(self) -> int:
"""Der Monat des gerade offenen Reiters."""
return self.blaetter.index("current") + 1
def _bericht_bauen(self, jahr: int, monat: int = None) -> str:
"""Schreibt den Bericht nach berichte/ und gibt den Pfad zurück ('' = keine Buchungen)."""
buchungen = self.journal.buchungen(jahr=jahr)
if monat:
passend = [b for b in buchungen if b.monat == monat]
if not passend:
messagebox.showinfo(
"Bericht", f"Für {MONATE[monat - 1]} {jahr} sind keine Buchungen erfasst.",
parent=self)
return ""
pfad = os.path.join(BERICHTE, f"Beherbergungssteuer_{jahr}_{monat:02d}_"
f"{MONATE[monat - 1]}.pdf")
bericht_pdf.render_monat(pfad, self.firma, jahr, monat, buchungen)
else:
if not buchungen:
messagebox.showinfo("Bericht", f"Für {jahr} sind keine Buchungen erfasst.",
parent=self)
return ""
pfad = os.path.join(BERICHTE, f"Beherbergungssteuer_{jahr}_Jahr.pdf")
bericht_pdf.render_jahr(pfad, self.firma, jahr, buchungen)
return pfad
def bericht_monat(self):
jahr, monat = self.jahr.get(), self._monat
try:
pfad = self._bericht_bauen(jahr, monat)
except PermissionError:
messagebox.showerror("Bericht", "Die PDF ist geöffnet und konnte nicht "
"überschrieben werden. Bitte schließen.", parent=self)
return
if not pfad:
return
s = summiere(self.journal.buchungen(jahr=jahr, monat=monat))
self.melde(f"Bericht geschrieben: {pfad}")
if messagebox.askyesno(
"Bericht fertig",
f"{MONATE[monat - 1]} {jahr}: {s.buchungen} Rechnungen, "
f"{s.naechte} Übernachtungen,\nSteuer {eur(s.steuer)}\n\n{pfad}\n\n"
"PDF jetzt öffnen?", parent=self):
os.startfile(pfad) # noqa: S606 - Windows-Standardprogramm
def bericht_jahr(self):
jahr = self.jahr.get()
try:
pfad = self._bericht_bauen(jahr)
except PermissionError:
messagebox.showerror("Bericht", "Die PDF ist geöffnet und konnte nicht "
"überschrieben werden. Bitte schließen.", parent=self)
return
if not pfad:
return
s = summiere(self.journal.buchungen(jahr=jahr))
self.melde(f"Jahresbericht geschrieben: {pfad}")
if messagebox.askyesno(
"Bericht fertig",
f"Jahr {jahr}: {s.buchungen} Rechnungen, {s.naechte} Übernachtungen,\n"
f"Steuer {eur(s.steuer)}\n\n{pfad}\n\nPDF jetzt öffnen?", parent=self):
os.startfile(pfad) # noqa: S606
def _beenden(self):
self.journal.setze("jahr", self.jahr.get())
self.journal.schliessen()
self.destroy()
if __name__ == "__main__":
App().mainloop()