# -*- coding: utf-8 -*- """ Steuerrechnungstool – das Steuerjournal zum Rechnungstool. 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 "Steuerrechnungstool starten.bat" oder `python app.py` """ import os import queue import sys import logging from datetime import date import tkinter as tk from tkinter import ttk, filedialog, messagebox # Nur EIN Fenster. Zwei Instanzen lesen denselben Rechnungsordner in dieselbe # journal.sqlite3: was das eine loescht, steht im anderen noch da, und der # Bericht fuers Amt kaeme aus dem veralteten Fenster. Die Sperre steht VOR # ablage.daten_ordner() - der legt beim ersten Start den Datenordner an und # zieht alte Dateien um; das darf nicht zweimal gleichzeitig laufen. # Nur beim echten Start: Pruefstaende importieren app als Modul und sollen # nicht an der Sperre des laufenden Programms scheitern. import hinweis import menueleiste import theme import wasserzeichen from einzelinstanz import (eigene_pid_merken, einzelinstanz_pruefen, fenster_nach_vorn, laufende_pid_lesen) _SPERRE = None if __name__ == "__main__": _SPERRE = einzelinstanz_pruefen() if _SPERRE is False: if not fenster_nach_vorn(laufende_pid_lesen()): _wurzel = tk.Tk() _wurzel.withdraw() _wurzel.attributes("-topmost", True) messagebox.showerror( "Steuerrechnungstool läuft bereits", "Das Steuerjournal ist schon geöffnet, das Fenster ließ sich aber " "nicht nach vorn holen – bitte über die Taskleiste zurückwechseln.") _wurzel.destroy() sys.exit(0) import api import db import gemeinsam import ordnerwahl import xlsx_io import firma as firma_lib import bericht_pdf import ablage from modell import (MONATE, STANDARD_SATZ, summiere, eur, eur_kurz, aufhebungen, doppelte_nummern) from gui_monat import MonatsTab, SummenLeiste, WARNUNG # 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") # Testlauf: kein Erststart-Dialog. Pruefstaende fahren das Fenster fern, ein # modaler Dialog wuerde sie haengen lassen - und in der EXE kommt man an den # Schalter ohnehin nicht heran (Doppelklick uebergibt keine Argumente). # python app.py --test oder CAMPINGHOF_TEST=1 TESTLAUF = ("--test" in sys.argv) or os.environ.get("CAMPINGHOF_TEST") == "1" 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(theme.ThemeMixin, tk.Tk): def __init__(self): super().__init__() self.title("Steuerrechnungstool") self._fenstersymbol() self.geometry("1000x680") self.minsize(820, 520) if TESTLAUF: # Ein Pruefstand bedient das Programm, waehrend nebenan jemand # arbeitet. Das Fenster geht deshalb auf dem ZWEITEN Monitor auf - # sichtbar, aber nicht im Weg. Seine Ansage am 07.09.2026: "teste # links auf dem 1080p monitor nicht mehr auf dem main". Bisher # ging es hier mitten auf den Hauptbildschirm. platz = theme.pruefplatz() if platz: self.geometry(platz) try: # Kein Eintrag in der Taskleiste: ein Eintrag, den man # anklickt, ohne dass etwas erscheint, ist schlimmer als # gar keiner. self.attributes("-toolwindow", True) except tk.TclError: pass self.journal = db.Journal(JOURNAL) # Die Einstellungen des Programms liegen in der Journal-Tabelle, nicht # in einer config.json wie beim Rechnungstool. theme.py erwartet aber # etwas mit .get() und []= - diese Ablage ist die Bruecke. So laeuft # dieselbe theme.py in beiden Programmen, ohne Sonderfall. self.cfg = self._einstellungen() self._apply_theme() self.buch = None # gemeinsames Nummernbuch, siehe gemeinsam.py # Der eigene kleine Server. Klopft das Rechnungstool an, wird sofort # nachgesehen - ohne auf den naechsten Durchgang der Wache zu warten. self.api = api.MiniApi("journal", self._push_empfangen) 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.protocol("WM_DELETE_WINDOW", self._beenden) self.aktualisieren() self._wasserzeichen_setzen() # NICHTS laeuft an, bevor der Erprobungshinweis bestaetigt ist - sonst # stuenden beim Ablehnen Timer in der Warteschlange, die auf ein # Fenster zugreifen, das es nicht mehr gibt. self._briefkasten_stand = "?" # "?" = noch nicht nachgesehen self._push_kasten = queue.Queue() # Zettel aus dem API-Faden self._nachricht_stand = 0 # bis hierher sind Meldungen gelesen # Der Hinweis darf erst aufgehen, wenn das Hauptfenster WIRKLICH # gezeichnet ist. after_idle reicht dafuer nicht: in der gepackten # EXE ist das Fenster dann noch nicht sichtbar, der modale Dialog # sperrt es - und beide bleiben unsichtbar. Das sah aus wie ein # haengendes Programm (06.09.2026, am Installer aufgefallen). self.after(50, self._hinweis_wenn_sichtbar) # ------------------------------------------------- Erprobungshinweis def _einstellungen(self): """Die Einstellungstabelle des Journals als Wörterbuch. hinweis.py und theme.py erwarten beide etwas mit .get() und []= - im Rechnungstool ist das die config.json, hier die Tabelle im Journal. Diese Ablage ist die Bruecke, damit beide Programme dieselben Dateien benutzen koennen. """ journal = self.journal class Ablage: def get(self, schluessel, standard=""): return journal.hole(schluessel, standard) def __setitem__(self, schluessel, wert): journal.setze(schluessel, str(wert)) return Ablage() # hinweis.py ruft die Ablage unter dem alten Namen - beides zeigt auf # dieselbe Tabelle. _hinweis_ablage = _einstellungen def _wieder(self, ms, was): """Einen sich selbst planenden Timer neu setzen - nur solange es das Fenster noch gibt. Ohne das melden die Dauertimer beim Beenden `invalid command name ..._wache`: Tk hat das Fenster samt Callback schon abgeraeumt, der eingeplante Aufruf laeuft aber noch. Das stuerzt nichts ab, landet aber als Fehler im Log - und ein Log, in dem beim normalen Beenden immer zwei Fehler stehen, liest irgendwann niemand mehr. Gleiche Loesung wie im Rechnungstool. """ try: if self.winfo_exists(): self.after(ms, was) except tk.TclError: pass # Fenster ist weg - dann gibt es nichts mehr zu tun # ------------------------------------------------------- Darstellung def _oberflaeche_neu(self): """Die Oberflaeche neu zeichnen - fuer den Wechsel hell/dunkel. Neu bauen statt umfaerben: die Farben stecken in den Widgets, und wer sie einzeln nachzieht, vergisst beim naechsten neuen Widget eine Stelle - dann leuchtet ein heller Fleck im dunklen Fenster, den niemand mehr findet. Neu bauen kann nichts vergessen. Die Werte haengen an Tk-Variablen (Jahr, Suchfeld) und am Journal - beides ueberlebt. Gesichert werden sie trotzdem; warum, steht bei variablen_sichern() im ThemeMixin. """ gesichert = self.variablen_sichern() self.kinder_loeschen() self._apply_theme() self._menue() self._kopfleiste() self._blaetter() self._statusleiste() self.variablen_zuruecksetzen(gesichert) self.aktualisieren() # Der Dialog selbst steht im ThemeMixin (theme.py). Gespeichert werden # muss hier nichts: self.cfg ist die Ablage aus _einstellungen(), die # jeden Schreibzugriff sofort in die Journaltabelle durchreicht. DARSTELLUNG_HINWEIS = ( "Gilt für das Programmfenster. Das Menü bleibt in der Farbe von " "Windows – ein natives Menü lässt sich dort nicht einfärben.") def _fenstersymbol(self): """Das Programmsymbol auf das Fenster legen.""" return wasserzeichen.symbol_setzen(self, melden=log.info) def _wasserzeichen_setzen(self, groesse=220): """Das Programmlogo blass in die untere rechte Ecke.""" bild = wasserzeichen.hole(groesse, 0.06, master=self, melden=log.info, dunkel=getattr(self, "_dark_mode", False)) if bild is None: return # Die Referenz MUSS bleiben - sonst raeumt Python das Bild weg und Tk # zeigt eine leere Flaeche. self._wz_bild = bild self._wz = tk.Label(self, image=bild, borderwidth=0) self._wz.place(relx=1.0, rely=1.0, anchor='se', x=-14, y=-30) self._wz.lower() def _hinweis_wenn_sichtbar(self, versuche=0): """Wartet, bis das Fenster gezeichnet ist, und fragt dann. winfo_viewable() sagt, ob das Fenster tatsaechlich auf dem Schirm ist. Solange nicht, wird in kurzen Abstaenden nachgesehen. Nach rund zwei Sekunden wird trotzdem gefragt - im Pruefstand ist das Fenster absichtlich nicht sichtbar, und dort soll es trotzdem laufen. """ if not self.winfo_viewable() and versuche < 40: self.after(50, lambda: self._hinweis_wenn_sichtbar(versuche + 1)) return self._hinweis_pruefen() def _hinweis_pruefen(self): """Hinweis auf die Erprobungsfassung. Ablehnen beendet das Programm.""" self.update_idletasks() # Fenster fertig zeichnen lassen # Nach vorn holen, sonst liegt der Hinweis beim Start hinter dem # Hauptfenster und wirkt wie ein Programm, das haengen geblieben ist. self.lift() self.focus_force() ablage = self._hinweis_ablage() if not hinweis.angenommen(ablage): log.info("Erprobungshinweis: Kenntnisnahme noch offen (%s)", hinweis.FASSUNG) if not messagebox.askyesno(hinweis.TITEL, hinweis.text("steuerrechnungstool"), parent=self, default="no"): log.info("Erprobungshinweis abgelehnt - Programm wird beendet") self.destroy() return zeile = hinweis.vermerken(ablage) log.info("Erprobungshinweis: %s", zeile) self._start_fortsetzen() def _start_fortsetzen(self): """Der uebrige Start - laeuft erst nach der Kenntnisnahme an.""" if self.pdf_ordner and os.path.isdir(self.pdf_ordner): self.after(200, lambda: self.einlesen(still=True)) self.after(400, self._frage_gemeinsam_falls_noetig) # Beide Programme laufen nebeneinander: schreibt das Rechnungstool eine # Rechnung, meldet es das hier herein - siehe _wache_jetzt(). self.after(250, self._push_abholen) self.after(600, self._wache) # Beim Zurueckklicken sofort nachsehen statt zwei Sekunden zu warten. self.bind("", lambda _e: self._wache_jetzt()) # ------------------------------------------------------------ Aufbau ---- def _menue(self): """Eigene Menueleiste statt tk.Menu. `tk.Menu` ist unter Windows kein Tk-Widget, sondern ein Menue des Betriebssystems: es nimmt Farben entgegen und zeichnet trotzdem im Systemgrau. Sein Befund am 07.09.2026: *"datei und daten hilfe ist immer noch hell"* - und er hatte recht, das war der letzte helle Rest im dunklen Fenster. Die Leiste kommt jetzt aus menueleiste.py, der gleichen Datei, die auch das Rechnungstool benutzt. """ self._menus = { "Datei": [ ("Rechnungs-Ordner wählen…", self.ordner_waehlen), ("-", None), ("Monatsbericht fürs Amt (PDF)…", self.bericht_monat), ("Jahresbericht fürs Amt (PDF)…", self.bericht_jahr), ("-", None), ("Excel-Tabelle einlesen…", self.altbestand_importieren), ("-", None), ("Beenden", self._beenden), ], "Daten": [ ("Rechnungen einlesen (F5)", self.einlesen), ("Alle Rechnungen neu einlesen", lambda: self.einlesen(voll=True)), ("-", None), ("Steuersatz ändern…", self.satz_aendern), ("Firmendaten des Rechnungstools wählen…", self.firma_waehlen), ("Gemeinsamer Ordner (Nummernbuch)…", self.dlg_gemeinsam), ("-", None), ("Darstellung…", self.dlg_darstellung), ], "Hilfe": [("Info", self._info)], } self.menue = menueleiste.Menueleiste(self, self._menus, farben=self) def _info(self): """Erprobungshinweis und Signatur - dauerhaft nachlesbar. Ohne diese Seite waere die Kenntnisnahme ein Klick, den spaeter niemand mehr nachlesen kann. """ d = tk.Toplevel(self) d.title("Info") d.transient(self) d.resizable(False, False) rahmen = ttk.Frame(d, padding=18) rahmen.pack(fill="both", expand=True) ttk.Label(rahmen, text="Steuerrechnungstool", font=("Segoe UI", 11, "bold") ).pack(anchor="w") ttk.Label(rahmen, justify="left", wraplength=460, text=hinweis.text("steuerrechnungstool")).pack(anchor="w", pady=(10, 0)) zeile = hinweis.signatur(self._hinweis_ablage()) if zeile: ttk.Label(rahmen, justify="left", wraplength=460, text=zeile ).pack(anchor="w", pady=(12, 0)) ttk.Button(rahmen, text="OK", width=10, command=d.destroy ).pack(anchor="e", pady=(16, 0)) d.bind("", lambda _e: d.destroy()) d.bind("", lambda _e: d.destroy()) d.grab_set() def _kopfleiste(self): leiste = ttk.Frame(self, padding=(10, 8)) leiste.pack(fill="x") # Das Programmlogo rechts in der Kopfzeile - das Erkennungszeichen. # Es steht rechts, damit es die Bedienelemente nicht nach hinten # schiebt, wenn es einmal fehlt. # 34 Pixel wie im Rechnungstool - bei 28 war es ihm zu klein # ("beim steuertool koennte es ein tick groesser sein", 07.09.2026). self._kopf_bild = wasserzeichen.kopf(34, master=self, melden=log.info, dunkel=getattr(self, "_dark_mode", False)) if self._kopf_bild is not None: ttk.Label(leiste, image=self._kopf_bild).pack(side="right") 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="PDF fürs Amt", command=self.bericht_monat).pack(side="left", padx=(6, 0)) # Kein Papierkorb mehr: im Journal wird nichts von Hand geloescht. Eine # Zeile aus einer PDF kaeme beim naechsten Einlesen ohnehin zurueck, eine # Zeile aus dem Excel-Altbestand waere fuer immer weg - samt Uebernachtung # im Amtsbericht (§ 146 Abs. 4 AO). Soll eine Buchung raus, wandert die PDF # aus dem Rechnungsordner; den Rest macht der Scan von selbst. 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) 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)) self.fuss = ttk.Frame(rahmen) self.fuss.pack(fill="x") self.status = ttk.Label(self.fuss, text="", foreground=self.col_fg) self.status.pack(side="left") self.ordner_anzeige = ttk.Label(self.fuss, text="", foreground=self.col_fg) self.ordner_anzeige.pack(side="right") # Doppelt vergebene Rechnungsnummern. Die Leiste wird erst eingehaengt, # wenn es wirklich welche gibt - im Normalfall soll hier nichts stehen # und auch kein leerer Streifen Platz wegnehmen. self._doppelte = {} self._aufgehoben = set() self.doppelt_leiste = ttk.Frame(rahmen) self.knopf_doppelte = ttk.Button(self.doppelt_leiste, text="Doppelte Rechnungen anzeigen…", command=self.doppelte_zeigen) self.knopf_doppelte.pack(side="right", padx=(10, 0)) self.warnung = tk.Label(self.doppelt_leiste, text="", fg=WARNUNG, anchor="w", justify="left", wraplength=760, font=("Segoe UI", 9)) self.warnung.pack(side="left", fill="x", expand=True) # ----------------------------------------------------------- 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 # Ueber ALLE Buchungen des Jahres, nicht ueber die gefilterten: sonst # versteckt die Suche den zweiten Beleg und die Dublette waere weg. self._doppelte = doppelte_nummern(alle) # Was ein Storno aufgehoben hat, steht weiter da - in normaler Schrift, # gekennzeichnet nur ueber die Spalte "Art", und es zaehlt nicht mehr # als offene Rechnung. # dict {id der Rechnung: Storno-Buchung} - die Anzeige will auch sagen, # WOMIT storniert wurde, nicht nur DASS. self._aufgehoben = aufhebungen(alle) if self._doppelte: # Gehoert ins Log: der Amtsbericht wird aus diesen Zeilen gebaut, und # spaeter soll nachvollziehbar sein, wann der Fall zuerst dastand. log.warning("Jahr %s: %d doppelt vergebene Rechnungsnummer(n): %s", jahr, len(self._doppelte), ", ".join(f"{nr} ({len(v)}x)" for nr, v in self._doppelte.items())) for m in range(1, 13): self.monate[m].fuellen([b for b in sichtbar if b.monat == m], self._doppelte, self._aufgehoben) # Reiter des Monats markieren, damit man die Dublette auch dann # sieht, wenn gerade ein anderer Monat offen ist. betroffen = any(b.voll_nummer in self._doppelte for b in alle if b.monat == m) self.blaetter.tab(m - 1, text=("⚠ " if betroffen else "") + MONATE[m - 1]) self._doppelte_melden() 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.ordner_anzeige.config( text=f"Rechnungen: {self.pdf_ordner}" if self.pdf_ordner else "kein Rechnungs-Ordner gewählt (Datei → Rechnungs-Ordner wählen…)") # ------------------------------------------------------------- Wache # Das Rechnungstool MELDET, wenn es eine Rechnung geschrieben hat - es legt # eine Zeile in den Briefkasten im gemeinsamen Ordner. Hier wird nur diese # eine Datei angesehen (ein os.stat), nicht der ganze Rechnungsordner. # Damit das Rechnungstool weiss, dass jemand zuhoert, traegt sich dieses # Fenster beim Start mit seiner PID ein (siehe _anmelden). WACHE_SEKUNDEN = 2 def _wache_jetzt(self): """Einmal nachsehen. True, wenn eine Meldung da war und eingelesen wurde. Billig gehalten: erst ein os.stat auf die gemeinsame Datei. Nur wenn sich dort etwas geruehrt hat, wird sie ueberhaupt geoeffnet. """ if self.buch is None: return False stand = gemeinsam.datei_stand(self.buch.ordner) if self._briefkasten_stand == "?": # Erster Blick nach dem Start: nur merken, auch wenn es die Datei # noch gar nicht gibt - sonst gilt die erste Meldung danach als # "schon gesehen" und wird verschluckt. self._briefkasten_stand = stand self._nachricht_stand = self._letzte_nachricht() return False if stand is None or stand == self._briefkasten_stand: return False self._briefkasten_stand = stand try: texte, neu = self.buch.nachrichten_ab(self._nachricht_stand) except Exception as e: # noqa: BLE001 log.warning("Briefkasten nicht lesbar: %s", e) return False if not texte: return False # das Buch hat sich geaendert, aber ohne Meldung self._nachricht_stand = neu text = texte[-1] if len(texte) == 1 else f"{len(texte)} Meldungen, zuletzt: {texte[-1]}" log.info("Rechnungstool meldet: %s", text) self.einlesen(still=True) # Mit Vorrang: das Einlesen laeuft gleich darauf an und wuerde die # Meldung sonst sofort ueberschreiben. self.melde(f"Rechnungstool meldet: {text}", vorrang=6) return True def _letzte_nachricht(self): try: return self.buch.letzte_nachricht() if self.buch else 0 except Exception: # noqa: BLE001 return 0 def _wache(self): """Der Wecker. Laeuft weiter, auch wenn ein Durchgang schiefgeht.""" try: self._wache_jetzt() except Exception as e: # noqa: BLE001 - die Wache darf nie sterben log.warning("Wache: %s", e) finally: self._wieder(self.WACHE_SEKUNDEN * 1000, self._wache) def suche_leeren(self): self.suche.set("") self.suchfeld.focus_set() def melde(self, text: str, vorrang: int = 0): """Eine Zeile in die Statusleiste schreiben. `vorrang` in Sekunden: so lange darf eine gewoehnliche Meldung diese hier NICHT ueberschreiben. Warum es das braucht (07.09.2026): Meldet das Rechnungstool eine neue Rechnung, laeuft sofort das Einlesen an - und dessen Ergebnis ("Nummernbuch: 1 Nummern eingetragen") landete in derselben Zeile. Die Meldung stand also nur Sekundenbruchteile da; der Benutzer sah nie, dass etwas hereingekommen war. Aufgefallen ist es an einem Pruefstand, der mal gruen und mal rot war - je nachdem, wann er hinsah. Das war kein Wackler im Pruefstand, sondern genau dieser Fehler. """ import time as _zeit jetzt = _zeit.monotonic() if vorrang <= 0 and jetzt < getattr(self, "_status_sperre", 0): return # eine wichtigere Meldung steht noch self.status.config(text=text) self._status_sperre = jetzt + vorrang if vorrang > 0 else 0 log.info(text) # ---- Gemeinsames Nummernbuch ------------------------------------------- def _anmelden(self): """Mit der eigenen PID im gemeinsamen Ordner eintragen. Daran sieht das Rechnungstool, dass dieses Fenster offen ist - es fragt Windows nach der Prozessnummer, nicht nach einem Fenster. """ ordner = self.journal.hole("gemeinsam_ordner", "") if not ordner: return if self.buch is None: self._buch_oeffnen() if self.buch is None: return # Die Mini-API aufmachen und mit Port und PID ins Telefonbuch: daran # sieht das Rechnungstool, dass jemand zuhoert, und kann anklopfen. self.api.starten(self.buch) if not self.api.port: try: self.buch.anmelden("journal", os.getpid(), os.path.basename(sys.executable)) except Exception as e: # noqa: BLE001 log.warning("Anmeldung fehlgeschlagen: %s", e) def _abmelden(self): self.api.stoppen(self.buch) def _push_empfangen(self): """Ruft die API aus IHREM Faden - Tkinter darf man von dort nicht anfassen. Also nur einen Zettel in die Warteschlange legen; das Fenster holt ihn gleich ab (_push_abholen). """ self._push_kasten.put(1) def _push_abholen(self): """Im Fenster-Faden: liegt ein Zettel da, sofort in der SQLite nachsehen.""" try: geklopft = False while True: try: self._push_kasten.get_nowait() geklopft = True except queue.Empty: break if geklopft: self._wache_jetzt() except Exception as e: # noqa: BLE001 - darf das Fenster nie umwerfen log.warning("Push: %s", e) finally: self._wieder(250, self._push_abholen) def _buch_oeffnen(self): ordner = self.journal.hole("gemeinsam_ordner", "") if not ordner: self.buch = None return taugt, grund = gemeinsam.ordner_taugt(ordner) if not taugt: self.buch = None log.warning("Nummernbuch nicht erreichbar: %s", grund) return self.buch = gemeinsam.Nummernbuch(ordner) def _frage_gemeinsam_falls_noetig(self): """Beim ersten Start nach dem gemeinsamen Ordner fragen. Das Buch ist der Weg, auf dem das Rechnungstool die Nummern aus dem Altbestand erfaehrt - zu denen es keine PDF gibt und die es sonst ein zweites Mal vergeben wuerde. """ self._buch_oeffnen() if self.buch is None and not TESTLAUF: # Kein Dialog mehr beim ersten Start: der Ordner steht fest und ist # in beiden Programmen derselbe (gemeinsam.standard_ordner). ordner = gemeinsam.standard_anlegen() if ordner: self.journal.setze("gemeinsam_ordner", ordner) self._buch_oeffnen() if self.buch is None: self.dlg_gemeinsam(erststart=True) self._anmelden() self._buch_fuellen() def dlg_gemeinsam(self, erststart=False): # Ist noch keiner gesetzt, wird der Rechnungsordner vorgeschlagen: # den kennen beide Programme, er wird mitgesichert, und er ueberlebt # ein Deinstallieren. Der Ordner, in dem das Programm liegt, taugt # dafuer NICHT mehr - seit dem Installer sind das zwei getrennte. ordner = ordnerwahl.ordner_abfragen( self, "Steuerjournal", self.journal.hole("gemeinsam_ordner", "") or self.pdf_ordner) if not ordner: if erststart: messagebox.showwarning( "Ohne gemeinsamen Ordner", "Es wurde kein gemeinsamer Ordner gewählt.\n\n" "Das Rechnungstool erfährt dann nicht, welche Nummern hier schon " "vergeben sind - auch nicht die aus der alten Excel-Tabelle.\n\n" "Nachholen: Daten → Gemeinsamer Ordner (Nummernbuch).", parent=self) return self.journal.setze("gemeinsam_ordner", ordner) self._buch_oeffnen() self._anmelden() self._buch_fuellen(melden=True) def _buch_fuellen(self, melden=False): """Alle Buchungen ins gemeinsame Buch schreiben. Auch die aus der Excel-Mappe und die von Hand angelegten - genau die kennt das Rechnungstool sonst nicht. Faellt das Buch aus (Netzlaufwerk weg), passiert hier nichts weiter: das Journal arbeitet ohne es. """ if self.buch is None: return anzahl = 0 try: for b in self.journal.buchungen(): self.buch.eintragen( b.voll_nummer, jahr=b.jahr, datum=b.datum.isoformat(), nachname=b.nachname, naechte=int(b.naechte or 0), zwischensumme=float(b.gezahlt or 0), steuer_satz=float(b.satz or 0), steuer_betrag=float(b.steuer or 0), gesamt=float(b.gezahlt or 0) + float(b.steuer or 0), art=b.art or "rechnung", storno_zu=b.storno_zu or "", vorgang=b.vorgang or "", quelle="journal", herkunft=b.quelle, pdf_pfad=b.pdf_pfad or "") anzahl += 1 except Exception as e: # noqa: BLE001 log.warning("Nummernbuch: Schreiben fehlgeschlagen (%s)", e) if melden: messagebox.showwarning( "Nummernbuch nicht erreichbar", f"Die Nummern konnten nicht eingetragen werden:\n{e}", parent=self) return log.info("Nummernbuch: %d Buchungen eingetragen", anzahl) if melden: messagebox.showinfo( "Nummernbuch", f"{anzahl} Rechnungsnummern stehen jetzt im gemeinsamen Buch.\n\n" "Wähle im Rechnungstool denselben Ordner - dann vergibt es keine " "dieser Nummern noch einmal.", parent=self) self.melde(f"Nummernbuch: {anzahl} Nummern eingetragen") # ---- Doppelt vergebene Rechnungsnummern -------------------------------- def _doppelte_melden(self): """Warnleiste zeigen, solange es Dubletten gibt - sonst verschwindet sie.""" if not self._doppelte: self.doppelt_leiste.pack_forget() self.warnung.config(text="") return teile = [] for nr, liste in list(self._doppelte.items())[:3]: namen = ", ".join(sorted({b.nachname or "?" for b in liste})) teile.append(f"{nr} ({len(liste)}× – {namen})") rest = len(self._doppelte) - len(teile) self.warnung.config(text=( f"⚠ {len(self._doppelte)} Rechnungsnummer(n) doppelt vergeben: " + "; ".join(teile) + (f" … und {rest} weitere" if rest else "") + ". Jede Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG) – eine " "der beiden im Rechnungstool stornieren und mit freier Nummer neu " "ausstellen. Bis dahin stehen die Beträge in allen Summen.")) self.doppelt_leiste.pack(fill="x", pady=(0, 4), before=self.fuss) def doppelte_zeigen(self): """Alle Belege auflisten, die sich eine Rechnungsnummer teilen.""" fenster = tk.Toplevel(self) fenster.title("Doppelt vergebene Rechnungsnummern") fenster.transient(self) fenster.geometry("940x520") rahmen = ttk.Frame(fenster, padding=12) rahmen.pack(fill="both", expand=True) ttk.Label(rahmen, wraplength=900, justify="left", text=( f"Jahr {self.jahr.get()}: diese Rechnungsnummern kommen mehrfach vor. " "Jede Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG).\n\n" "Alle Beträge sind in den Summen und im Bericht fürs Amt enthalten – hier wird " "nichts weggerechnet.\n\n" "SO WIRD ES GERADEGERÜCKT: eine der beiden Rechnungen im Rechnungstool " "stornieren und mit einer freien Nummer neu ausstellen. Das Storno trägt " "Nummer, Datum UND Empfänger der aufgehobenen Rechnung – bei einer doppelt " "vergebenen Nummer wäre die Nummer allein kein eindeutiger Bezug, und " "§ 31 Abs. 5 UStDV verlangt genau den. Danach steht die Zeile hier grau als " "„storniert“ und die Warnung verschwindet von allein.")).pack( anchor="w", pady=(0, 10)) innen = ttk.Frame(rahmen) innen.pack(fill="both", expand=True) innen.rowconfigure(0, weight=1) innen.columnconfigure(0, weight=1) senkrecht = ttk.Scrollbar(innen, orient="vertical") senkrecht.grid(row=0, column=1, sticky="ns") # Waagerecht, weil am Zeilenende der Dateiname steht - genau der sagt, # welche der beiden Rechnungen die zweite ist. waagerecht = ttk.Scrollbar(innen, orient="horizontal") waagerecht.grid(row=1, column=0, sticky="ew") text = tk.Text(innen, wrap="none", yscrollcommand=senkrecht.set, xscrollcommand=waagerecht.set, relief="flat", font=("Consolas", 9)) text.grid(row=0, column=0, sticky="nsew") senkrecht.configure(command=text.yview) waagerecht.configure(command=text.xview) text.tag_configure("kopf", foreground=WARNUNG, font=("Consolas", 9, "bold")) for nr, liste in self._doppelte.items(): text.insert("end", f"{nr} – {len(liste)} Belege\n", "kopf") for b in liste: datei = os.path.basename(b.pdf_pfad) if b.pdf_pfad else f"({b.quelle})" text.insert("end", f" {b.datum:%d.%m.%Y} {(b.nachname or '?'):22.22s} " f"{b.naechte:>2} {'Nacht ' if b.naechte == 1 else 'Nächte'} " f"{eur_kurz(b.gezahlt):>10s} " f"Steuer {eur_kurz(b.steuer):>8s} {datei}\n") text.insert("end", "\n") text.configure(state="disabled") ttk.Button(rahmen, text="Schließen", command=fenster.destroy).pack(side="right", pady=(10, 0)) fenster.bind("", lambda _e: fenster.destroy()) 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() # Was neu eingelesen wurde, gehoert auch ins gemeinsame Buch. self._buch_fuellen() 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 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=self.col_fg).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 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): # Austragen, damit das Rechnungstool nicht weiter meldet. self._abmelden() self.journal.setze("jahr", self.jahr.get()) self.journal.schliessen() self.destroy() if __name__ == "__main__": log.info("Steuerrechnungstool startet (Python %s)", sys.version.split()[0]) # Die Sperre selbst greift schon ganz oben, vor dem Datenordner. eigene_pid_merken() App().mainloop()