Sein Befund "und beim steuertool fehlt das theam in genze oder wird nicht durch die automatik gesetzt" "nur die einstellunden hilfe leiste die ist noch hell und der icon und titel leiste auch" Er hatte recht, und zwar wortwoertlich: dieses Programm lief im Tk-Standard, mit dreimal fest eingetragenem foreground="black". Es gab hier gar keine theme.py. Gebaut theme.py aus dem Rechnungstool uebernommen und dabei von config.py geloest - eine Datei, die in BEIDEN Programmen liegt, darf nichts aus einem der beiden importieren. Beim ersten Uebernehmen flog sie genau daran beim Import auseinander. Die Einstellungen liegen hier in der Journal-Tabelle, nicht in einer config.json. _einstellungen() ist die Bruecke: etwas mit .get() und []=, wie hinweis.py und theme.py es erwarten. Menueleiste aus menueleiste.py statt tk.Menu - ein natives Menue laesst sich unter Windows nicht einfaerben, das war der letzte helle Rest. Treeview-Stil in theme.py ergaenzt (ttk zeichnet Listen nicht ueber den Grundstil), Reiter schmaler, damit alle zwoelf Monatsnamen lesbar bleiben. Die Summenleisten in gui_monat.py holen ihre Farben jetzt vom Fenster statt sie fest zu tragen - sie waren ein weisser Block unten im dunklen Bild. Logo und Fenstersymbol werden fuer die dunkle Darstellung aufgehellt. ⚠️ Altfehler behoben: die Meldung des Rechnungstools stand nie pruef_wache lief mal gruen und mal rot - je nachdem, wann er hinsah. Das war kein Wackler im Pruefstand, sondern sein Symptom: meldet das Rechnungstool eine neue Rechnung, laeuft sofort das Einlesen an, und dessen Ergebnis landete in derselben Zeile. Der Benutzer sah NIE, dass etwas hereingekommen war. melde() kennt jetzt einen Vorrang in Sekunden; die Meldung des Rechnungstools bekommt sechs. Dreimal hintereinander stabil gruen. Ein wackelnder Pruefstand ist keine Nebensache, die man wegdrueckt. AUFGABE_STEUERSAETZE_DYNAMISCH.md Seine naechste Ansage notiert: die festen Saetze sollen raus, dynamisch per Tabelle wie im Rechnungstool. Mit dem, worauf dabei zu achten ist - ein Satz gilt ab einem DATUM, und die Saetze bereits erfasster Buchungen bleiben stehen (§ 147 AO). Fassung 1.9.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
1010 lines
45 KiB
Python
1010 lines
45 KiB
Python
# -*- 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)
|
||
|
||
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("<F5>", lambda _e: self.einlesen())
|
||
self.bind("<Control-f>", 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, denn die
|
||
_build-Methoden legen ihre Variablen selbst an: nach dem Neubau
|
||
stuenden sonst neue, leere da. Genau das ist im Rechnungstool beim
|
||
ersten Versuch passiert.
|
||
"""
|
||
gesichert = {}
|
||
for name in dir(self):
|
||
if name.startswith("__"):
|
||
continue
|
||
try:
|
||
wert = getattr(self, name)
|
||
except Exception: # noqa: BLE001 - Eigenschaften koennen werfen
|
||
continue
|
||
if isinstance(wert, (tk.StringVar, tk.IntVar, tk.DoubleVar,
|
||
tk.BooleanVar)):
|
||
try:
|
||
gesichert[name] = wert.get()
|
||
except tk.TclError:
|
||
pass
|
||
|
||
for kind in self.winfo_children():
|
||
kind.destroy()
|
||
|
||
self._apply_theme()
|
||
self._menue()
|
||
self._kopfleiste()
|
||
self._blaetter()
|
||
self._statusleiste()
|
||
|
||
for name, wert in gesichert.items():
|
||
neu = getattr(self, name, None)
|
||
if isinstance(neu, (tk.StringVar, tk.IntVar, tk.DoubleVar,
|
||
tk.BooleanVar)):
|
||
try:
|
||
neu.set(wert)
|
||
except tk.TclError:
|
||
pass
|
||
self.aktualisieren()
|
||
|
||
def dlg_darstellung(self):
|
||
"""Hell, dunkel oder wie Windows - und sofort sichtbar."""
|
||
d = tk.Toplevel(self)
|
||
d.title("Darstellung")
|
||
d.transient(self)
|
||
d.resizable(False, False)
|
||
self._style_dialog(d)
|
||
rahmen = ttk.Frame(d, padding=18)
|
||
rahmen.pack(fill="both", expand=True)
|
||
ttk.Label(rahmen, text="Darstellung",
|
||
font=(theme.SCHRIFT_NAME, 11, "bold"),
|
||
foreground=self.col_accent).pack(anchor="w")
|
||
ttk.Label(rahmen, justify="left", wraplength=340,
|
||
foreground=self.col_muted,
|
||
text="Gilt für das Programmfenster. Das Menü bleibt in der "
|
||
"Farbe von Windows – ein natives Menü lässt sich dort "
|
||
"nicht einfärben.").pack(anchor="w", pady=(6, 12))
|
||
|
||
gewaehlt = tk.StringVar(value=theme.modus(self.cfg))
|
||
|
||
def uebernehmen():
|
||
neu = gewaehlt.get()
|
||
if neu == theme.modus(self.cfg):
|
||
return
|
||
self.cfg["darstellung"] = neu
|
||
self._oberflaeche_neu()
|
||
self._style_dialog(d)
|
||
|
||
for wert in theme.MODI:
|
||
ttk.Radiobutton(rahmen, text=theme.MODUS_NAMEN[wert], value=wert,
|
||
variable=gewaehlt, command=uebernehmen).pack(
|
||
anchor="w", pady=3)
|
||
|
||
ttk.Separator(rahmen, orient="horizontal").pack(fill="x", pady=(12, 10))
|
||
ttk.Button(rahmen, text="Schließen", width=14,
|
||
command=d.destroy).pack(anchor="e")
|
||
d.geometry(f"+{self.winfo_rootx() + 120}+{self.winfo_rooty() + 140}")
|
||
return d
|
||
|
||
def _fenstersymbol(self):
|
||
"""Das Programmsymbol auf das Fenster legen."""
|
||
return wasserzeichen.symbol_setzen(self, melden=log.info,
|
||
dunkel=getattr(self, "_dark_mode", False))
|
||
|
||
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("<FocusIn>", 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("<Escape>", lambda _e: d.destroy())
|
||
d.bind("<Return>", 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.
|
||
self._kopf_bild = wasserzeichen.kopf(28, 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("<<ComboboxSelected>>", 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("<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)
|
||
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:
|
||
self.dlg_gemeinsam(erststart=True)
|
||
self._anmelden()
|
||
self._buch_fuellen()
|
||
|
||
def dlg_gemeinsam(self, erststart=False):
|
||
ordner = ordnerwahl.ordner_abfragen(self, "Steuerjournal",
|
||
self.journal.hole("gemeinsam_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("<Escape>", 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("<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):
|
||
# 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()
|