beherbergungssteuer/app.py
TheMockTv 2b7990b1b6 Firmendaten portabel suchen + Menuepunkt zum Auswaehlen
Die config.json des Rechnungstools wurde nur unter dem festen
Entwicklungspfad gesucht - als EXE auf einem anderen Rechner haette der
Bericht keinen Briefkopf bekommen. Jetzt: neben dem Programm, im
Nachbarordner, in Dokumente, und ueber "Daten -> Firmendaten waehlen".

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

471 lines
20 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 -*-
"""
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)
daten.add_command(label="Firmendaten des Rechnungstools wählen…",
command=self.firma_waehlen)
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 firma_waehlen(self):
"""config.json des Rechnungstools zeigen sie liefert den Briefkopf des Berichts."""
pfad = filedialog.askopenfilename(
title="config.json des Rechnungstools wählen",
initialfile="config.json",
filetypes=[("Einstellungsdatei", "config.json"), ("Alle Dateien", "*.*")])
if not pfad:
return
self.journal.setze("rechnungstool_config", os.path.abspath(pfad))
self.firma = firma_lib.lade(os.path.abspath(pfad))
name = self.firma.get("name", "")
self.melde(f"Briefkopf: {name} (aus {os.path.basename(pfad)})")
messagebox.showinfo("Firmendaten",
f"Der Bericht trägt jetzt den Briefkopf von:\n\n{name}", parent=self)
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()