# -*- 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("", lambda _e: self.einlesen()) self.bind("", lambda _e: self.suchfeld.focus_set()) self.bind("", 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("<>", 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("", 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("", lambda _e: fenster.destroy()) fenster.bind("", 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()