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>
471 lines
20 KiB
Python
471 lines
20 KiB
Python
# -*- 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()
|