Eigene Meldungsfenster, aktiver Reiter hervorgehoben, Pruefschalter als Funktion

Sein Fund: "der ohne gemeinsamen ordner popup ist noch hell" und "er hebt den
aktiven tab nicht hervor, der inaktive ist der hervorgehobene".

- Meldungen und Ja/Nein-Fragen kommen jetzt aus eigenen Fenstern im Theme
  (MeldungMixin). messagebox sind Fenster von WINDOWS - sie bleiben hell, egal
  was das Programm einstellt. Dieselbe Erfahrung steckt schon in der eigenen
  Menueleiste. 49 Aufrufe umgestellt; das Ordner-Fenster meldet ueber das
  Hauptfenster mit.
  ⚠️ Im Pruflauf laeuft beides weiter ueber messagebox - dort haengen die
  Pruefstaende ihre Antworten ein. Sonst warten sie auf einen Klick.
- Reiter: der OFFENE ist jetzt der hellere (Kachelfarbe), die ruhenden liegen
  auf dem dunklen Grund. Vorher war es andersherum.
- config.TESTLAUF ist eine FUNKTION geworden: Pruefstaende importieren config
  oft, bevor sie CAMPINGHOF_TEST setzen - der einmal berechnete Wert stand dann
  den ganzen Lauf auf False, und das Programm oeffnete echte Dialoge, auf die
  niemand klickt. Genau daran hing pruef_api.
- Der Erststart-Dialog hat einen eigenen Schalter (_erststart_zeigen), statt am
  ganzen Pruefschalter zu haengen: ein Pruefstand kann IHN zeigen, ohne dass
  alle anderen Meldungen wieder echte Fenster aufmachen.
- Pruefstand-Fenster stehen nicht mehr in der Taskleiste (-toolwindow): sie
  liegen neben dem Bildschirm, und ein Eintrag, den man anklickt, ohne dass
  etwas erscheint, ist schlimmer als gar keiner.
- pruef_nummernsperre drueckt jetzt den Radioknopf "Meine Zaehlung
  weiterfuehren", statt nur ins Zahlenfeld zu schreiben - das Feld ist ohne
  diesen Klick gesperrt, wie beim Benutzer auch.

Alle 14 Pruefstaende gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
This commit is contained in:
TheMockTv 2026-09-05 19:17:13 +02:00
parent 5ebb02b267
commit 2e3a191f19
8 changed files with 229 additions and 74 deletions

91
app.py
View file

@ -40,7 +40,7 @@ from bestand import (hoechster_zaehler_im_ordner, naechste_freie_nummer,
from einzelinstanz import (eigene_pid_merken, einzelinstanz_pruefen, fenster_nach_vorn,
laufende_pid_lesen)
import theme
from theme import ThemeMixin
from theme import MeldungMixin, ThemeMixin
from korrektur import KorrekturMixin
from einstellungen import EinstellungenMixin
@ -49,10 +49,12 @@ from einstellungen import EinstellungenMixin
# 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"
# Der Pruefschalter steht in config (dort als Funktion, siehe Kommentar). Hier
# nur noch der Startwert fuer alles, was ihn schon beim Laden braucht.
TESTLAUF = config.testlauf()
class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
def __init__(self):
super().__init__()
self.cfg = config.load_config()
@ -61,13 +63,20 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
hoehe = max(560, min(840, self.winfo_screenheight() - 90))
self.geometry(f"1060x{hoehe}")
self.minsize(900, 540)
if TESTLAUF:
if config.testlauf():
# Ein Pruefstand bedient das Programm, waehrend nebenan jemand
# arbeitet. Das Fenster geht deshalb NEBEN dem Bildschirm auf (auf
# dem zweiten Monitor, wenn einer da ist) - sichtbar, aber nicht im
# Weg. Unsichtbar machen waere falsch: dann sieht niemand mehr, was
# der Pruefstand eigentlich tut.
self.geometry(f"+{self.winfo_screenwidth() + 20}+40")
try:
# Kein Eintrag in der Taskleiste: das Fenster liegt neben
# dem Bildschirm, und ein Eintrag, den man anklickt, ohne
# dass etwas erscheint, ist schlimmer als gar keiner.
self.attributes("-toolwindow", True)
except tk.TclError:
pass
self.anzahl_vars = {} # nr -> StringVar
self.personen_vars = {} # nr -> StringVar
@ -593,7 +602,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
wird wieder gefragt.
"""
self._buch_oeffnen()
if self.buch is None and not TESTLAUF:
if self.buch is None and not config.testlauf():
self.dlg_gemeinsam(erststart=True)
self._buch_anzeigen()
@ -602,7 +611,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
self, "Rechnungstool", str(self.cfg.get("gemeinsam_ordner", "") or ""))
if not ordner:
if erststart:
messagebox.showwarning(
self.melden_warnung(
"Ohne gemeinsamen Ordner",
"Es wurde kein gemeinsamer Ordner gewählt.\n\n"
"Das Rechnungstool kennt jetzt nur die Nummern seiner eigenen "
@ -642,7 +651,13 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
einmal nennen koennen, sonst faengt das Programm bei 001 an und vergibt
Nummern ein zweites Mal. Danach ist der Zaehler zu.
"""
if TESTLAUF or self.cfg.get("nummer_eingerichtet"):
# Im Pruflauf bleibt dieser Dialog zu - er wartet auf einen Klick, den dort
# niemand macht. Ein Pruefstand, der GENAU IHN pruefen will, setzt
# `_erststart_zeigen = True` (dasselbe Muster wie `_dialog_zeigen` beim
# Angebot). So bleiben alle anderen Meldungen weiterhin automatisch
# beantwortet - vorher musste dafuer der ganze Pruefschalter fallen, und
# dann hing der Lauf in der naechsten Meldung.
if (config.testlauf() and not getattr(self, "_erststart_zeigen", False)) or self.cfg.get("nummer_eingerichtet"):
return
jahr = int(self.cfg.get("rechnung_jahr", 0) or datetime.date.today().year)
gefunden = self.hoechster_stand(jahr)
@ -720,7 +735,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
if weg.get() == "weiter" else
f"Bei 0 anfangen geht nicht mehr: {jahr}-{gefunden:03d} ist schon vergeben.")
return
if not messagebox.askyesno(
if not self.frage(
"Sind Sie sicher?",
f"Die nächste Rechnung bekommt die Nummer {jahr}-{zahl + 1:03d}.\n\n"
"Das lässt sich danach nicht mehr ändern.\n\n"
@ -733,7 +748,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
try:
config.save_config(self.cfg)
except OSError as e: # noqa: BLE001
messagebox.showwarning("Nicht gespeichert",
self.melden_warnung("Nicht gespeichert",
f"Der Stand ließ sich nicht speichern:\n{e}", parent=d)
return
fertig["ok"] = True
@ -832,7 +847,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
quelle="rechnungstool", herkunft="pdf", pdf_pfad=pdf_pfad)
except Exception as e: # noqa: BLE001 - die PDF ist schon geschrieben
print("[nummernbuch] Eintragen fehlgeschlagen:", e)
messagebox.showwarning(
self.melden_warnung(
"Nummernbuch nicht erreichbar",
"Die Rechnung ist geschrieben, konnte aber nicht ins gemeinsame "
f"Nummernbuch eingetragen werden:\n{e}\n\n"
@ -1048,7 +1063,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
# Im Testlauf gibt es niemanden, der klickt: dann gilt der uebliche Weg
# (Rechnung). Der Pruefstand, der GENAU DIESES Fenster pruefen will,
# setzt _dialog_zeigen und drueckt die Knoepfe wirklich.
if TESTLAUF and not getattr(self, "_dialog_zeigen", False):
if config.testlauf() and not getattr(self, "_dialog_zeigen", False):
# _testwahl: was der Pruefstand im Fenster geklickt haette.
# "rechnung" (Vorgabe), "angebot" oder None fuer Abbrechen.
return getattr(self, "_testwahl", "rechnung")
@ -1174,7 +1189,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
}
if name not in tabelle:
return {"fehler": "kenne ich nicht: " + str(name), "kann": sorted(tabelle)}
if name in schreibt_beleg and not TESTLAUF:
if name in schreibt_beleg and not config.testlauf():
return {"fehler": "schreibt einen Beleg - geht nur im Testlauf"}
return tabelle[name](daten or {})
@ -1270,7 +1285,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
montage = self.ist_montage()
r = self.recalc()
if not r.zeilen:
messagebox.showinfo(
self.melden_info(
"Noch nichts einzutragen",
"Bitte die Anzahl der Monate eintragen." if montage else
"Trage erst bei einer Leistung eine Anzahl ein - dann zeigt die "
@ -1288,7 +1303,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
self._oeffne_datei(pfad)
return pfad
except OSError as e:
messagebox.showerror("Vorschau nicht möglich",
self.melden_fehler("Vorschau nicht möglich",
f"Die Vorschau konnte nicht geöffnet werden:\n{e}")
return ""
@ -1296,7 +1311,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
montage = self.ist_montage()
r = self.recalc()
if not r.zeilen:
messagebox.showwarning(
self.melden_warnung(
"Keine Positionen",
"Bitte die Anzahl der Monate eintragen." if montage else
"Es wurde keine Leistung mit Anzahl > 0 eingetragen.")
@ -1304,7 +1319,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
if montage and not (self.r_anreise.get().strip() and self.r_abreise.get().strip()):
# Ohne Zeitraum steht auf dem Blatt nicht, wofuer gezahlt wird - und
# das Steuerjournal haette keine Naechte fuer die Meldung ans Amt.
messagebox.showwarning(
self.melden_warnung(
"Zeitraum fehlt",
"Bitte oben rechts den Zeitraum eintragen (von / bis).\n\n"
"Er steht auf der Rechnung und ist die Grundlage für die "
@ -1312,14 +1327,14 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
return
nummer = self.r_nummer.get().strip()
if not nummer:
messagebox.showwarning("Rechnungsnummer fehlt", "Bitte eine Rechnungsnummer eingeben.")
self.melden_warnung("Rechnungsnummer fehlt", "Bitte eine Rechnungsnummer eingeben.")
return
# Den 31.02. gibt es auch dann nicht, wenn Tag und Monat einzeln moeglich
# sind. Ein falsches Datum auf einer ausgestellten Rechnung laesst sich nur
# noch ueber eine Berichtigung geradeziehen - also hier abfangen.
kaputt = self._datumsfelder_pruefen()
if kaputt:
messagebox.showwarning(
self.melden_warnung(
"Datum nicht lesbar",
"So kann die Rechnung nicht geschrieben werden:" + chr(10) + chr(10) +
"- " + (chr(10) + "- ").join(kaputt) + chr(10) + chr(10) +
@ -1329,7 +1344,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
# (§ 14 Abs. 4 Nr. 1 UStG) - ohne sie wird nichts geschrieben.
maengel = self.kunde_pruefen()
if maengel:
messagebox.showwarning(
self.melden_warnung(
"Kundenangaben fehlen",
"So kann die Rechnung nicht geschrieben werden:\n\n- " + "\n- ".join(maengel) +
"\n\nName und vollständige Anschrift des Kunden gehören auf jede "
@ -1338,7 +1353,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
# Hausnummer nur nachfragen, nicht blockieren - es gibt Anschriften ohne
# (Postfach, Haus am Feldweg), und eine falsche Sperre waere schlimmer.
if not ziffern(self.k_strasse.get()):
if not messagebox.askyesno(
if not self.frage(
"Hausnummer fehlt",
f"In „{self.k_strasse.get()}“ steht keine Hausnummer.\n\n"
"Trotzdem so auf die Rechnung schreiben?"):
@ -1368,7 +1383,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
# geloescht und es bleibt allein die urspruengliche Rechnung stehen.
stand = self.storno_stand
if stand:
if not messagebox.askyesno(
if not self.frage(
"Storno wirklich durchführen?",
f"Storno {stand['nummer']} hebt die Rechnung {stand['zu']} auf, "
f"und {nummer} wird die berichtigte Rechnung.\n\n"
@ -1378,7 +1393,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
f"Nein = alles verwerfen, es bleibt allein Rechnung {stand['zu']}",
icon="warning", default="yes"):
self._storno_verwerfen("vom Benutzer verworfen")
messagebox.showinfo(
self.melden_info(
"Storno verworfen",
f"Der Storno wurde gelöscht. Es bleibt allein die Rechnung {stand['zu']}.")
return
@ -1406,7 +1421,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
kopf["vorgang"] = ber.get("vorgang") or ber["zu"]
kopf["berichtigt_zu"] = ber["zu"]
kopf["berichtigt_am"] = datetime.date.today().strftime("%d.%m.%Y")
if not messagebox.askyesno(
if not self.frage(
"Berichtigte Rechnung erstellen?",
f"Zu Rechnung {ber['zu']} vom {ber['datum']} wird ein berichtigtes "
"Blatt geschrieben.\n\n"
@ -1421,7 +1436,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
pfad = os.path.join(
out_dir, f"Berichtigte Rechnung_{datetime.date.today():%Y-%m-%d}"
f"_{safe_filename(ber['zu'])}.pdf")
if os.path.exists(pfad) and not messagebox.askyesno(
if os.path.exists(pfad) and not self.frage(
"Berichtigung von heute ersetzen?",
f"Zu Rechnung {ber['zu']} gibt es von heute schon eine Berichtigung:\n"
f"{os.path.basename(pfad)}\n\nDurch die neue ersetzen?"):
@ -1429,7 +1444,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
try:
pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf)
except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen
messagebox.showerror("Fehler beim Erstellen",
self.melden_fehler("Fehler beim Erstellen",
f"Berichtigung konnte nicht erstellt werden:\n{e}")
print("[berichtigung] FEHLER:", e)
return
@ -1447,7 +1462,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
print("[nummernbuch] Berichtigung nicht eingetragen:", e)
self.berichtigung_stand = None
self._storno_anzeigen()
if messagebox.askyesno("Fertig",
if self.frage("Fertig",
f"Berichtigte Rechnung zu {ber['zu']}:\n{pfad}\n\n"
"Jetzt öffnen (zum Drucken)?"):
self._oeffne_datei(pfad)
@ -1468,12 +1483,12 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
liste = "- steht im gemeinsamen Nummernbuch (Steuerjournal)"
frei = naechste_freie_nummer(out_dir, self.cfg, self._belegte_nummern())
if not frei:
messagebox.showerror(
self.melden_fehler(
"Rechnungsnummer schon vergeben",
f"Die Rechnungsnummer {nummer} ist schon vergeben:\n\n{liste}\n\n"
"Es wurde keine freie Nummer gefunden - bitte die Nummer von Hand setzen.")
return
if not messagebox.askyesno(
if not self.frage(
"Rechnungsnummer schon vergeben",
f"Die Rechnungsnummer {nummer} ist schon vergeben:\n\n{liste}\n\n"
"Die vorhandene Rechnung bleibt bestehen - sie darf weder überschrieben "
@ -1493,7 +1508,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
try:
pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf)
except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen
messagebox.showerror(
self.melden_fehler(
"Fehler beim Erstellen",
f"Angebot konnte nicht erstellt werden:\n{e}")
print("[angebot] FEHLER:", e)
@ -1509,7 +1524,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
except OSError as e: # noqa: BLE001
print("[angebot] Zaehler nicht gespeichert:", e)
print(f"[angebot] erstellt -> {pfad}")
if messagebox.askyesno(
if self.frage(
"Fertig", f"Angebot erstellt:\n{pfad}\n\n"
"Jetzt öffnen (zum Drucken)?"):
self._oeffne_datei(pfad)
@ -1529,7 +1544,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
# Ein PDF: Seite 1 = Kunden-Rechnung, Seite 2 = Beherbergungssteuer fuers Amt
pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf)
except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen
messagebox.showerror("Fehler beim Erstellen", f"PDF konnte nicht erstellt werden:\n{e}")
self.melden_fehler("Fehler beim Erstellen", f"PDF konnte nicht erstellt werden:\n{e}")
print("[pdf] FEHLER:", e)
return
@ -1553,7 +1568,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
hinweis = (f"\n\nDazu gehört die Stornorechnung {stand['nummer']} "
f"({os.path.basename(storno_pfad)}).\n"
"Beide Blätter gehören zum Gast.") + hinweis
if messagebox.askyesno("Fertig", f"Rechnung erstellt:\n{pfad}{hinweis}\n\nJetzt öffnen (zum Drucken)?"):
if self.frage("Fertig", f"Rechnung erstellt:\n{pfad}{hinweis}\n\nJetzt öffnen (zum Drucken)?"):
self._oeffne_datei(pfad)
if storno_pfad:
self._oeffne_datei(storno_pfad)
@ -1578,7 +1593,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
except OSError as e: # noqa: BLE001 - die PDF ist schon geschrieben
# Ohne diese Meldung merkt niemand, dass der Zaehler stehengeblieben
# ist: die Ausgabe geht in eine Konsole, die es in der EXE nicht gibt.
messagebox.showwarning(
self.melden_warnung(
"Rechnungsnummer nicht gespeichert",
f"Die Rechnung ist geschrieben, der Zählerstand aber nicht:\n{e}\n\n"
f"Bitte im Menü unter „Rechnungsnummer…“ den Zähler auf {z} setzen, "
@ -1588,7 +1603,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
def _beenden(self):
"""Schliessen - mit Rückfrage, wenn ein Storno noch offen ist."""
if self.storno_stand:
if not messagebox.askyesno(
if not self.frage(
"Storno ist noch offen",
f"Der Storno {self.storno_stand['nummer']} zu Rechnung "
f"{self.storno_stand['zu']} liegt im Ordner, die berichtigte "
@ -1606,7 +1621,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
# Ein laufender Vorgang darf nicht stumm liegenbleiben: sonst gehören die
# neu eingetippten Daten plötzlich zum Storno der fremden Rechnung.
if self.storno_stand:
if not messagebox.askyesno(
if not self.frage(
"Storno verwerfen?",
f"Der Storno {self.storno_stand['nummer']} zu Rechnung "
f"{self.storno_stand['zu']} liegt noch bereit.\n\n"
@ -1643,7 +1658,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
Fenster offen, die er selbst wegklicken darf. Gemeldet wird es trotzdem,
damit im Protokoll steht, was zu sehen gewesen waere.
"""
if TESTLAUF:
if config.testlauf():
print(f"[oeffnen] im Pruflauf nicht geoeffnet: {pfad}")
return False
try:
@ -1654,7 +1669,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
else:
os.system(f'xdg-open "{pfad}"')
except Exception as e: # noqa: BLE001
messagebox.showinfo("Pfad", f"{pfad}\n\n(Konnte nicht automatisch geöffnet werden: {e})")
self.melden_info("Pfad", f"{pfad}\n\n(Konnte nicht automatisch geöffnet werden: {e})")
if __name__ == "__main__":
@ -1665,7 +1680,7 @@ if __name__ == "__main__":
if not fenster_nach_vorn(laufende_pid_lesen()):
_r = tk.Tk()
_r.withdraw()
messagebox.showerror(
self.melden_fehler(
"Rechnungstool läuft bereits",
"""Das Rechnungstool ist schon geöffnet, das Fenster ließ sich
aber nicht nach vorn holen - bitte über die Taskleiste zurückwechseln.