Steuerrechnungstool/app.py
TheMockTv de71fce64f Eigener Datenordner (ablage.py)
Als EXE lagen Journal, Berichte und Log bisher direkt neben der EXE. Damit
laesst sich das Programm nicht in einen Ordner legen, in den Windows nicht
schreiben laesst - und beim Sichern uebersieht man leicht die Haelfte.

Jetzt: beim ersten Start ein Ordner "Daten" neben der EXE, alles darin. Geht
das nicht, weicht es nach LOCALAPPDATA aus. Was schon neben der EXE lag, zieht
mit um - sonst waere das Journal ploetzlich leer.

Ist der gemerkte Ordner nicht erreichbar (Stick ab, NAS aus), wird NICHTS
geaendert und der Merkzettel bleibt stehen: morgen steckt der Stick wieder
dran und alles ist da, wo es war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
2026-09-02 21:44:51 +02:00

473 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
import ablage
from modell import MONATE, STANDARD_SATZ, summiere, eur
from gui_monat import MonatsTab, SummenLeiste
from gui_dialoge import BuchungDialog
# Als EXE liegt alles in einem eigenen Datenordner - siehe ablage.py.
BASE_DIR = ablage.daten_ordner()
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()