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.

View file

@ -72,11 +72,18 @@ BASE_DIR = _daten_ordner()
CONFIG_PATH = os.path.join(BASE_DIR, "config.json")
# Laeuft gerade ein Pruefstand? Dann baut das Programm alles wie sonst, zeigt es
# aber nicht: kein Fenster auf dem Bildschirm, kein Dialog, der Maus und
# aber nicht: kein Fenster mitten auf dem Bildschirm, kein Dialog, der Maus und
# Tastatur an sich reisst, waehrend der Mensch daneben arbeitet.
# Das Flag steht HIER, damit app.py, theme.py und die Pruefstaende dasselbe
# lesen - zwei Stellen wuerden auseinanderlaufen.
TESTLAUF = ("--test" in sys.argv) or os.environ.get("CAMPINGHOF_TEST") == "1"
#
# ⚠️ Das ist eine FUNKTION, keine Konstante. Ein Pruefstand importiert config
# haeufig, bevor er CAMPINGHOF_TEST setzt - ein einmal berechneter Wert stuende
# dann fuer den ganzen Lauf auf False, und das Programm oeffnete echte Dialoge,
# auf die niemand klickt. Genau daran hing pruef_api eine Runde lang.
def testlauf():
return ("--test" in sys.argv) or os.environ.get("CAMPINGHOF_TEST") == "1"
TESTLAUF = testlauf() # Startwert; wer spaeter fragt, nimmt testlauf()
# Leistungskatalog 1:1 aus der Excel-Vorlage (Rechnung Campinghof Bartl).
# satz = Umsatzsteuersatz in Prozent (7 = Beherbergung, 19 = Zusatzleistung)

View file

@ -24,7 +24,7 @@ class EinstellungenMixin:
if (self.cfg.get("output_dir") or "").strip():
return
vorschlag = config.default_output_dir()
messagebox.showinfo(
self.melden_info(
"Speicherort der Rechnungen",
"Bitte wähle einen Ordner, in dem die fertigen Rechnungen (PDF) gespeichert werden.\n\n"
f"Vorschlag: {vorschlag}\n\n(Lässt sich später unter Einstellungen ändern.)")
@ -41,7 +41,7 @@ class EinstellungenMixin:
self.cfg["output_dir"] = d
config.save_config(self.cfg)
config.ensure_output_dir(self.cfg)
messagebox.showinfo("Speicherort", f"Rechnungen werden gespeichert in:\n{d}")
self.melden_info("Speicherort", f"Rechnungen werden gespeichert in:\n{d}")
def _info(self):
d = tk.Toplevel(self)
@ -121,7 +121,7 @@ class EinstellungenMixin:
"bezeichnung": row["bez"].get().strip() or "USt",
"extra_blatt": bool(row["extra"].get())})
if not neu:
messagebox.showwarning("Leer", "Mindestens ein Steuersatz ist nötig.", parent=d)
self.melden_warnung("Leer", "Mindestens ein Steuersatz ist nötig.", parent=d)
return
self.cfg["steuersaetze"] = neu
config.save_config(self.cfg)
@ -177,11 +177,11 @@ class EinstellungenMixin:
name = v_leistung.get().strip()
preis = parse_num(v_preis.get())
if not name:
messagebox.showwarning("Bezeichnung fehlt",
self.melden_warnung("Bezeichnung fehlt",
"Bitte eine Bezeichnung eintragen.", parent=d)
return
if preis <= 0:
messagebox.showwarning("Preis fehlt",
self.melden_warnung("Preis fehlt",
"Bitte einen Monatspreis groesser als 0 eintragen.",
parent=d)
return
@ -289,7 +289,7 @@ class EinstellungenMixin:
"pro_person": bool(row["pro_person"].get()),
})
if not neu:
messagebox.showwarning("Leer", "Es muss mindestens eine Leistung übrig bleiben.", parent=d)
self.melden_warnung("Leer", "Es muss mindestens eine Leistung übrig bleiben.", parent=d)
return
self.cfg["katalog"] = neu
config.save_config(self.cfg)
@ -464,7 +464,7 @@ class EinstellungenMixin:
jahr = self.cfg.get("rechnung_jahr")
zaehler = int(self.cfg.get("rechnung_zaehler", 0) or 0)
hoch = self.hoechster_stand(jahr)
messagebox.showinfo(
self.melden_info(
"Rechnungsnummer",
f"Zuletzt vergeben: {jahr}-{max(zaehler, hoch):03d}\n"
f"Die nächste Rechnung bekommt {jahr}-{max(zaehler, hoch) + 1:03d}.\n\n"
@ -511,7 +511,7 @@ class EinstellungenMixin:
# (§ 14 Abs. 4 Nr. 4 UStG).
hoch = self.hoechster_stand(jahr)
if jahr == int(self.cfg.get("rechnung_jahr", jahr) or jahr) and zaehler < hoch:
messagebox.showwarning(
self.melden_warnung(
"Nummer schon vergeben",
f"Für {jahr} ist bereits bis {jahr}-{hoch:03d} vergeben.\n\n"
f"Der Zähler kann nicht auf {zaehler} zurückgesetzt werden – sonst "

View file

@ -69,7 +69,7 @@ class KorrekturMixin:
self.r_datum.set(str(meta.get("datum") or self.r_datum.get()))
self._storno_anzeigen()
hinweis = f"\n\nZum Kunden: {fehlend}." if fehlend else ""
messagebox.showinfo(
self.melden_info(
"Rechnung geladen",
f"Rechnung {nummer} vom {meta.get('datum','')} ist im Formular.\n\n"
"Jetzt die Kundenangaben richtigstellen und \"PDF erstellen\" drücken – "
@ -93,7 +93,7 @@ class KorrekturMixin:
# Abbrechen, NICHT weiterlaufen: sonst liegt das Storno noch im Ordner,
# der Zähler ist zurückgedreht und die Rechnung gilt als storniert,
# obwohl der Nutzer sie behalten wollte.
messagebox.showerror(
self.melden_fehler(
"Storno konnte nicht gelöscht werden",
f"Die Datei\n{stand['pfad']}\nließ sich nicht löschen:\n{e}\n\n"
"Ist sie noch in einem PDF-Betrachter offen? Bitte dort schließen "
@ -104,7 +104,7 @@ class KorrekturMixin:
try:
config.save_config(self.cfg)
except OSError as e: # noqa: BLE001
messagebox.showwarning(
self.melden_warnung(
"Zähler nicht gespeichert",
f"Die Einstellungen ließen sich nicht schreiben:\n{e}\n\n"
"Bitte im Menü unter „Rechnungsnummer…“ prüfen.")
@ -137,14 +137,14 @@ class KorrekturMixin:
"""
storno = modus == "storno"
if self.storno_stand:
messagebox.showinfo(
self.melden_info(
"Storno liegt schon bereit",
f"Der Storno {self.storno_stand['nummer']} zu Rechnung "
f"{self.storno_stand['zu']} ist noch offen.\n\n"
"Erst die berichtigte Rechnung erstellen oder den Storno dort verwerfen.")
return
if self.berichtigung_stand:
messagebox.showinfo(
self.melden_info(
"Berichtigung läuft schon",
f"Die Rechnung {self.berichtigung_stand['zu']} liegt gerade zum Berichtigen "
"im Formular.\n\nErst „PDF erstellen“ drücken oder das Formular zurücksetzen.")
@ -162,7 +162,7 @@ class KorrekturMixin:
if d.get("art") not in ("storno", "berichtigung")
and nummer_normalisieren(d.get("rechnungsnummer")) not in stornierte]
if not offen:
messagebox.showwarning(
self.melden_warnung(
"Nichts zu korrigieren",
f"Im Ordner\n{out_dir}\nliegt keine Rechnung, die dafür in Frage kommt.")
return
@ -472,7 +472,7 @@ class KorrekturMixin:
stand.configure(text="Bitte die Nummer der bleibenden Rechnung eintragen.",
fg="#a00000")
return
if gefunden["leicht"] and not messagebox.askyesno(
if gefunden["leicht"] and not self.frage(
"Weicht ab – trotzdem?",
f"Rechnung {nr} weicht ab: " + "; ".join(gefunden["leicht"]) +
". Das Storno würde trotzdem darauf verweisen. Ist das dieselbe "
@ -537,7 +537,7 @@ class KorrekturMixin:
nummer = naechste_freie_nummer(out_dir, self.cfg, self._belegte_nummern())
if not nummer:
messagebox.showerror("Keine freie Nummer",
self.melden_fehler("Keine freie Nummer",
"Es wurde keine freie Rechnungsnummer gefunden.")
return
@ -572,7 +572,7 @@ class KorrekturMixin:
# kann das Programm nichts erfinden; dann muss der Nutzer entscheiden.
maengel = kunde_maengel(kunde)
if maengel:
if not messagebox.askyesno(
if not self.frage(
"Angaben zum Kunden unvollständig",
f"In Rechnung {alt_nummer} steht nicht alles:\n\n- "
+ "\n- ".join(maengel) +
@ -620,7 +620,7 @@ class KorrekturMixin:
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"Storno-PDF konnte nicht erstellt werden:\n{e}")
print("[storno] FEHLER:", e)
return
@ -646,7 +646,7 @@ class KorrekturMixin:
if art_folge == "vorhanden":
self._storno_anzeigen()
self._neue_nummer_vorschlagen()
if messagebox.askyesno(
if self.frage(
"Storno erstellt",
f"Storno {nummer} hebt Rechnung {alt_nummer} auf.\n\n"
f"Die Leistung bleibt mit Rechnung {folge_nummer} abgerechnet – es "
@ -663,7 +663,7 @@ class KorrekturMixin:
if fehlend:
hinweis = (f"\n\nZum Kunden: {fehlend}.\nBitte oben prüfen und ergänzen – "
"ohne vollständige Anschrift wird die Rechnung nicht geschrieben.")
messagebox.showinfo(
self.melden_info(
"Storno erstellt",
f"Stornorechnung {nummer} zu Rechnung {alt_nummer} liegt hier:\n{pfad}\n\n"
f"Jetzt die berichtigte Rechnung {self.r_nummer.get()} anpassen und "

View file

@ -165,21 +165,33 @@ class OrdnerWahl(tk.Toplevel):
text="Hier ist noch kein Nummernbuch – es wird neu angelegt. Wähle im "
"anderen Programm denselben Ordner.", fg=self._muted)
def _melden(self, titel, text):
"""Meldung im Theme des Hauptfensters - sonst wie bisher."""
eltern = self.master
if hasattr(eltern, "melden_warnung"):
return eltern.melden_warnung(titel, text)
return messagebox.showwarning(titel, text, parent=self)
def _frage(self, titel, text):
eltern = self.master
if hasattr(eltern, "frage"):
return eltern.frage(titel, text)
return messagebox.askyesno(titel, text, parent=self)
def uebernehmen(self):
ordner = self.pfad.get().strip()
taugt, grund = gemeinsam.ordner_taugt(ordner)
if not taugt:
messagebox.showwarning("Ordner geht nicht", grund or "Bitte einen Ordner wählen.",
parent=self)
self._melden("Ordner geht nicht", grund or "Bitte einen Ordner wählen.")
return
if not messagebox.askyesno(
if not self._frage(
"Sind Sie sicher?",
f"Gemeinsamer Ordner:\n\n{ordner}\n\n"
"Steht im anderen Programm derselbe Ordner? Nur dann kennen beide "
"dieselben Rechnungsnummern.\n\n"
"Ja = Ordner übernehmen\n"
"Nein = zurück zur Auswahl",
icon="question", default="yes", parent=self):
"Nein = zurück zur Auswahl"):
# Zurueck ins Auswahlfeld - der Dialog bleibt stehen, nichts ist gesetzt.
self.feld.focus_set()
return

View file

@ -146,14 +146,16 @@ antwort = anrufen_und_drehen(name="rechnung", schluessel=schluessel)
pdfs = [n for n in os.listdir(ordner) if n.lower().endswith(".pdf")]
pruefe(len(pdfs) == 1, f"im Testlauf schreibt die API die Rechnung ({pdfs})")
# Und im ECHTEN Betrieb nicht: dieselbe Aktion, aber ohne Testlauf-Flag.
A.TESTLAUF = False
# Und im ECHTEN Betrieb nicht: dieselbe Aktion, ohne den Pruefschalter.
# Umgeschaltet wird die Umgebungsvariable selbst - das Programm fragt sie bei
# jeder Aktion neu ab, genau wie beim echten Start.
os.environ.pop("CAMPINGHOF_TEST", None)
antwort = anrufen_und_drehen(name="rechnung", schluessel=schluessel)
os.environ["CAMPINGHOF_TEST"] = "1"
pruefe(isinstance(antwort, dict) and "nur im Testlauf" in str(antwort.get("fehler", "")),
f"im echten Betrieb verweigert sie den Beleg ({antwort})")
pruefe(len([n for n in os.listdir(ordner) if n.lower().endswith(".pdf")]) == 1,
"und es kam keine zweite Rechnung dazu")
A.TESTLAUF = True
# Unbekannter Name sagt, was es gibt - statt still nichts zu tun.
antwort = anrufen_und_drehen(name="tanz", schluessel=schluessel)

View file

@ -121,6 +121,15 @@ def knoepfe_von(w, aus=None):
return aus
def _alle_widgets(w, aus=None):
"""Jedes Widget unterhalb von w - egal wie tief."""
aus = [] if aus is None else aus
for k in w.winfo_children():
aus.append(k)
_alle_widgets(k, aus)
return aus
def felder_von(w, aus=None):
aus = [] if aus is None else aus
for k in w.winfo_children():
@ -208,7 +217,9 @@ pruefe(f.cfg.get("rechnung_zaehler") == 91,
f.cfg["nummer_eingerichtet"] = False
f.cfg["rechnung_jahr"] = JAHR
config.save_config(f.cfg)
A.TESTLAUF = False # den Erststart-Dialog wirklich zeigen
# Diesen einen Dialog will der Pruefstand wirklich sehen und bedienen. Alles
# andere bleibt im Pruflauf - sonst wartet der naechste Hinweis auf einen Klick.
f._erststart_zeigen = True
def erststart(zahl, erwartet_ja=True):
@ -227,6 +238,14 @@ def erststart(zahl, erwartet_ja=True):
top = tops[-1]
felder = felder_von(top)
if felder:
# Der Dialog fragt seit 05.09.2026 zuerst nach dem WEG: bei 0
# anfangen oder die eigene Zaehlung weiterfuehren. Ohne diesen
# Klick bleibt das Zahlenfeld gesperrt - hier wird also dasselbe
# gedrueckt wie beim Benutzer, nicht daran vorbei.
for w in _alle_widgets(top):
if w.winfo_class() == "TRadiobutton" and "weiterführen" in str(w.cget("text")):
w.invoke()
break
felder[0].delete(0, "end")
felder[0].insert(0, str(zahl))
for k in knoepfe_von(top):

106
theme.py
View file

@ -8,6 +8,7 @@ baut das Programm Menueleiste und Dropdowns selbst; hier stehen nur die Farben.
import sys
import tkinter as tk
from tkinter import font as tkfont
from tkinter import messagebox
from tkinter import ttk
import config
@ -264,11 +265,15 @@ class ThemeMixin:
# Akzentfarbe, die ruhenden bleiben zurueckgenommen.
style.configure("TNotebook", background=bg, bordercolor=self.col_line,
tabmargins=(2, 4, 2, 0), borderwidth=0)
style.configure("TNotebook.Tab", background=self.col_field,
# Der OFFENE Reiter gehoert zur Kachel darunter und ist deshalb heller
# als die ruhenden. Vorher war es andersherum: die ruhenden lagen auf der
# helleren Flaeche und sahen aus, als waeren sie ausgewaehlt.
style.configure("TNotebook.Tab", background=bg,
foreground=self.col_muted, bordercolor=self.col_line,
padding=(20, 9), font=(SCHRIFT_NAME, 10), borderwidth=0)
style.map("TNotebook.Tab",
background=[("selected", self.col_card), ("active", sel)],
background=[("selected", self.col_card),
("active", mischen(accent, bg, 0.12))],
foreground=[("selected", accent), ("active", fg)],
font=[("selected", (SCHRIFT_NAME, 10, "bold"))])
# Kein expand beim ausgewaehlten Reiter: er wuchs dadurch um ein paar
@ -286,12 +291,16 @@ class ThemeMixin:
und Tastatur fest, waehrend nebenan jemand schreibt.
"""
d.configure(bg=self.col_bg)
if config.TESTLAUF:
if config.testlauf():
# Wie das Hauptfenster: neben den Bildschirm, und ohne grab_set -
# ein Dialog, der Maus und Tastatur festhaelt, blockiert den
# Menschen, der nebenher schreibt.
d.geometry(f"+{d.winfo_screenwidth() + 20}+40")
d.grab_set = lambda *a, **k: None
try:
d.attributes("-toolwindow", True) # nicht in die Taskleiste
except tk.TclError:
pass
# erst nach dem Anzeigen setzen, sonst greift DWM noch nicht
d.after(20, lambda: self._dark_titlebar(d))
@ -312,3 +321,94 @@ class ThemeMixin:
ctypes.windll.user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP)
except Exception as e: # noqa: BLE001
print("[theme] Titelleiste:", e)
class MeldungMixin:
"""Eigene Meldungs- und Ja/Nein-Fenster im Theme des Programms.
Warum nicht `messagebox`: das sind Fenster von Windows selbst. Sie sind
immer hell, egal was das Programm einstellt - mitten in einer dunklen
Oberflaeche blendet das und sieht nach Fremdkoerper aus. Dieselbe Erfahrung
steckt schon in der eigenen Menueleiste weiter oben.
⚠️ Im PRUEFLAUF geht beides weiter ueber `messagebox`. Die Pruefstaende
haengen ihre Antworten dort ein ("auf diese Frage antworte Ja") und lesen
dort mit, was das Programm gemeldet hat. Wuerde hier ein eigenes Fenster
aufgehen, warteten sie auf einen Klick, den niemand macht. Geprueft wird
damit derselbe Weg - nur die Anzeige unterscheidet sich.
"""
# Die drei Sorten Meldung. `parent=`, `icon=` und `default=` kommen aus den
# frueheren messagebox-Aufrufen mit - sie werden geschluckt, damit an den
# Aufrufstellen nichts umgeschrieben werden muss.
def melden_info(self, titel, text="", **_kw):
return self.melden(titel, text, "info")
def melden_warnung(self, titel, text="", **_kw):
return self.melden(titel, text, "warnung")
def melden_fehler(self, titel, text="", **_kw):
return self.melden(titel, text, "fehler")
def melden(self, titel, text, art="warnung"):
"""Eine Meldung mit einem OK-Knopf."""
if config.testlauf():
{"info": messagebox.showinfo,
"warnung": messagebox.showwarning,
"fehler": messagebox.showerror}.get(art, messagebox.showwarning)(titel, text)
return None
return self._meldungsfenster(titel, text, art, knoepfe=None)
def frage(self, titel, text="", ja="Ja", nein="Nein", vorgabe=True, **_kw):
"""Ja/Nein. Gibt True oder False zurueck - wie askyesno."""
if config.testlauf():
return bool(messagebox.askyesno(titel, text))
return bool(self._meldungsfenster(titel, text, "frage",
knoepfe=(ja, nein), vorgabe=vorgabe))
def _meldungsfenster(self, titel, text, art, knoepfe=None, vorgabe=True):
farbe = {"fehler": self.col_gefahr, "warnung": self.col_gefahr,
"frage": self.col_accent}.get(art, self.col_accent)
d = tk.Toplevel(self)
d.title(titel)
d.transient(self)
d.resizable(False, False)
self._style_dialog(d)
antwort = {"wert": False}
rahmen = ttk.Frame(d, padding=20)
rahmen.pack(fill="both", expand=True)
tk.Label(rahmen, text=titel, bg=self.col_bg, fg=farbe, justify="left",
font=(SCHRIFT_NAME, 12, "bold")).pack(anchor="w")
tk.Label(rahmen, text=text, bg=self.col_bg, fg=self.col_fg, justify="left",
wraplength=460, font=(SCHRIFT_NAME, 10)).pack(anchor="w", pady=(10, 0))
leiste = ttk.Frame(rahmen)
leiste.pack(fill="x", pady=(20, 0))
def schliessen(wert):
antwort["wert"] = wert
d.destroy()
if knoepfe:
ja, nein = knoepfe
ttk.Button(leiste, text=ja, style="Haupt.TButton",
command=lambda: schliessen(True)).pack(side="right")
ttk.Button(leiste, text=nein,
command=lambda: schliessen(False)).pack(side="right", padx=(0, 8))
d.bind("<Return>", lambda _e: schliessen(vorgabe))
else:
ttk.Button(leiste, text="OK", style="Haupt.TButton",
command=lambda: schliessen(True)).pack(side="right")
d.bind("<Return>", lambda _e: schliessen(True))
d.bind("<Escape>", lambda _e: schliessen(False))
d.protocol("WM_DELETE_WINDOW", lambda: schliessen(False))
d.update_idletasks()
x = self.winfo_rootx() + max(0, (self.winfo_width() - d.winfo_reqwidth()) // 2)
y = self.winfo_rooty() + 140
d.geometry(f"+{max(0, x)}+{max(0, y)}")
d.grab_set()
d.focus_set()
self.wait_window(d)
return antwort["wert"]