Die Runde vom 07.09.2026 hat neben den zwei echten Fehlern eine Reihe Wiederholungen und stumme Stellen gemeldet. Abgearbeitet in Etappen, jede fuer sich pruefbar - nach jeder liefen beide Pruefketten gruen. * Menueleiste: der Wechsel von einem Titel zum anderen brauchte ZWEI Klicks. Der grab_set der offenen Liste schluckt den ersten, die <Button-1>-Bindung des Titels feuert gar nicht. Das native tk.Menu, das diese Leiste ersetzt hat, wechselte schon beim blossen Ueberfahren - zwei Klicks waren also eine Verschlechterung gegenueber dem, was vorher da war. Die Klappliste wird ausserdem jetzt wie jede andere Position ueber theme.auf_bildschirm() begrenzt; am unteren Fensterrand lief sie vorher hinaus. Neu: pruef_menueleiste.py misst beides ueber echte Klickereignisse. * %LOCALAPPDATA%/ravokk wurde an drei Stellen einzeln ausgerechnet. Jetzt fragen alle gemeinsam.standard_ordner(). Laufen die je auseinander, fuehren die beiden Programme zwei Nummernbuecher - und das faellt erst auf, wenn eine Rechnungsnummer zum zweiten Mal vergeben ist (§ 14 Abs. 4 Nr. 4 UStG). * pruefe() stand 35-mal im Quelltext, in sechs Fassungen -> pruefhelfer.py. Die zwei Ausreisser sind mitgezogen: pruef_storno_verrechnet.py zaehlte in einer Zahl und konnte am Ende nicht sagen, WAS fehlschlug, pruef_bilder.py im Rechnungstool schrieb ein eigenes Format. * dlg_darstellung() und die drei Handgriffe des Umschaltens standen in beiden app.py fast wortgleich -> ThemeMixin in theme.py. Beide trugen inzwischen denselben langen Kommentar zu demselben Fehler; das war der Beweis, dass es eine Sache ist. Programmspezifisch bleiben der Hinweistext im Dialog (R31) und die Frage, ob die Einstellungen ausdruecklich gespeichert werden muessen. * pruef_gemeinsam_automatisch.py sagte im Text "genau ein Buch" und prueft ">= 1" mit einem any() - eine Pruefung, die nicht rot werden kann. Jetzt == 1, und der Dateiname wird mitgeprueft. Dazu neu: die neun Dateien, die im Kopf zusagen, sie laegen in beiden Programmen gleich, werden byteweise verglichen. Bei hinweis.py stimmte die Zusage seit dem 06.09. nicht mehr - gleicher Inhalt, CRLF gegen LF. * .gitattributes, damit die Zeilenenden nicht von der Maschine abhaengen. Ohne das meldet genau diese Pruefung nach einem frischen Checkout einen Unterschied, den es im Repository gar nicht gibt. * Die stummen "except OSError" in api.py, updater.py und einzelinstanz.py sagen jetzt, warum sie schweigen duerfen. Die stummen tk.TclError bleiben: dort wird ein Widget angefasst, das gerade zerstoert wurde, und eine Meldung waere Rauschen. * Tote Parameter und Variablen aus dem Umbau des Vortags: symbol_setzen(dunkel=), kachel(grund=, radius=), mit_trennlinie, sechs x/y-Berechnungen, die von theme.mittig() sofort ueberschrieben wurden, vier lokale "import ctypes". 39 Pruefstaende, 0 rot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
983 lines
44 KiB
Python
983 lines
44 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)
|
||
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("<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; 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("<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.
|
||
# 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("<<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:
|
||
# 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("<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()
|