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

View file

@ -24,7 +24,7 @@ class EinstellungenMixin:
if (self.cfg.get("output_dir") or "").strip(): if (self.cfg.get("output_dir") or "").strip():
return return
vorschlag = config.default_output_dir() vorschlag = config.default_output_dir()
messagebox.showinfo( self.melden_info(
"Speicherort der Rechnungen", "Speicherort der Rechnungen",
"Bitte wähle einen Ordner, in dem die fertigen Rechnungen (PDF) gespeichert werden.\n\n" "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.)") f"Vorschlag: {vorschlag}\n\n(Lässt sich später unter Einstellungen ändern.)")
@ -41,7 +41,7 @@ class EinstellungenMixin:
self.cfg["output_dir"] = d self.cfg["output_dir"] = d
config.save_config(self.cfg) config.save_config(self.cfg)
config.ensure_output_dir(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): def _info(self):
d = tk.Toplevel(self) d = tk.Toplevel(self)
@ -121,7 +121,7 @@ class EinstellungenMixin:
"bezeichnung": row["bez"].get().strip() or "USt", "bezeichnung": row["bez"].get().strip() or "USt",
"extra_blatt": bool(row["extra"].get())}) "extra_blatt": bool(row["extra"].get())})
if not neu: 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 return
self.cfg["steuersaetze"] = neu self.cfg["steuersaetze"] = neu
config.save_config(self.cfg) config.save_config(self.cfg)
@ -177,11 +177,11 @@ class EinstellungenMixin:
name = v_leistung.get().strip() name = v_leistung.get().strip()
preis = parse_num(v_preis.get()) preis = parse_num(v_preis.get())
if not name: if not name:
messagebox.showwarning("Bezeichnung fehlt", self.melden_warnung("Bezeichnung fehlt",
"Bitte eine Bezeichnung eintragen.", parent=d) "Bitte eine Bezeichnung eintragen.", parent=d)
return return
if preis <= 0: if preis <= 0:
messagebox.showwarning("Preis fehlt", self.melden_warnung("Preis fehlt",
"Bitte einen Monatspreis groesser als 0 eintragen.", "Bitte einen Monatspreis groesser als 0 eintragen.",
parent=d) parent=d)
return return
@ -289,7 +289,7 @@ class EinstellungenMixin:
"pro_person": bool(row["pro_person"].get()), "pro_person": bool(row["pro_person"].get()),
}) })
if not neu: 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 return
self.cfg["katalog"] = neu self.cfg["katalog"] = neu
config.save_config(self.cfg) config.save_config(self.cfg)
@ -464,7 +464,7 @@ class EinstellungenMixin:
jahr = self.cfg.get("rechnung_jahr") jahr = self.cfg.get("rechnung_jahr")
zaehler = int(self.cfg.get("rechnung_zaehler", 0) or 0) zaehler = int(self.cfg.get("rechnung_zaehler", 0) or 0)
hoch = self.hoechster_stand(jahr) hoch = self.hoechster_stand(jahr)
messagebox.showinfo( self.melden_info(
"Rechnungsnummer", "Rechnungsnummer",
f"Zuletzt vergeben: {jahr}-{max(zaehler, hoch):03d}\n" f"Zuletzt vergeben: {jahr}-{max(zaehler, hoch):03d}\n"
f"Die nächste Rechnung bekommt {jahr}-{max(zaehler, hoch) + 1:03d}.\n\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). # (§ 14 Abs. 4 Nr. 4 UStG).
hoch = self.hoechster_stand(jahr) hoch = self.hoechster_stand(jahr)
if jahr == int(self.cfg.get("rechnung_jahr", jahr) or jahr) and zaehler < hoch: if jahr == int(self.cfg.get("rechnung_jahr", jahr) or jahr) and zaehler < hoch:
messagebox.showwarning( self.melden_warnung(
"Nummer schon vergeben", "Nummer schon vergeben",
f"Für {jahr} ist bereits bis {jahr}-{hoch:03d} vergeben.\n\n" 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 " 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.r_datum.set(str(meta.get("datum") or self.r_datum.get()))
self._storno_anzeigen() self._storno_anzeigen()
hinweis = f"\n\nZum Kunden: {fehlend}." if fehlend else "" hinweis = f"\n\nZum Kunden: {fehlend}." if fehlend else ""
messagebox.showinfo( self.melden_info(
"Rechnung geladen", "Rechnung geladen",
f"Rechnung {nummer} vom {meta.get('datum','')} ist im Formular.\n\n" f"Rechnung {nummer} vom {meta.get('datum','')} ist im Formular.\n\n"
"Jetzt die Kundenangaben richtigstellen und \"PDF erstellen\" drücken – " "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, # Abbrechen, NICHT weiterlaufen: sonst liegt das Storno noch im Ordner,
# der Zähler ist zurückgedreht und die Rechnung gilt als storniert, # der Zähler ist zurückgedreht und die Rechnung gilt als storniert,
# obwohl der Nutzer sie behalten wollte. # obwohl der Nutzer sie behalten wollte.
messagebox.showerror( self.melden_fehler(
"Storno konnte nicht gelöscht werden", "Storno konnte nicht gelöscht werden",
f"Die Datei\n{stand['pfad']}\nließ sich nicht löschen:\n{e}\n\n" 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 " "Ist sie noch in einem PDF-Betrachter offen? Bitte dort schließen "
@ -104,7 +104,7 @@ class KorrekturMixin:
try: try:
config.save_config(self.cfg) config.save_config(self.cfg)
except OSError as e: # noqa: BLE001 except OSError as e: # noqa: BLE001
messagebox.showwarning( self.melden_warnung(
"Zähler nicht gespeichert", "Zähler nicht gespeichert",
f"Die Einstellungen ließen sich nicht schreiben:\n{e}\n\n" f"Die Einstellungen ließen sich nicht schreiben:\n{e}\n\n"
"Bitte im Menü unter „Rechnungsnummer…“ prüfen.") "Bitte im Menü unter „Rechnungsnummer…“ prüfen.")
@ -137,14 +137,14 @@ class KorrekturMixin:
""" """
storno = modus == "storno" storno = modus == "storno"
if self.storno_stand: if self.storno_stand:
messagebox.showinfo( self.melden_info(
"Storno liegt schon bereit", "Storno liegt schon bereit",
f"Der Storno {self.storno_stand['nummer']} zu Rechnung " f"Der Storno {self.storno_stand['nummer']} zu Rechnung "
f"{self.storno_stand['zu']} ist noch offen.\n\n" f"{self.storno_stand['zu']} ist noch offen.\n\n"
"Erst die berichtigte Rechnung erstellen oder den Storno dort verwerfen.") "Erst die berichtigte Rechnung erstellen oder den Storno dort verwerfen.")
return return
if self.berichtigung_stand: if self.berichtigung_stand:
messagebox.showinfo( self.melden_info(
"Berichtigung läuft schon", "Berichtigung läuft schon",
f"Die Rechnung {self.berichtigung_stand['zu']} liegt gerade zum Berichtigen " f"Die Rechnung {self.berichtigung_stand['zu']} liegt gerade zum Berichtigen "
"im Formular.\n\nErst „PDF erstellen“ drücken oder das Formular zurücksetzen.") "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") if d.get("art") not in ("storno", "berichtigung")
and nummer_normalisieren(d.get("rechnungsnummer")) not in stornierte] and nummer_normalisieren(d.get("rechnungsnummer")) not in stornierte]
if not offen: if not offen:
messagebox.showwarning( self.melden_warnung(
"Nichts zu korrigieren", "Nichts zu korrigieren",
f"Im Ordner\n{out_dir}\nliegt keine Rechnung, die dafür in Frage kommt.") f"Im Ordner\n{out_dir}\nliegt keine Rechnung, die dafür in Frage kommt.")
return return
@ -472,7 +472,7 @@ class KorrekturMixin:
stand.configure(text="Bitte die Nummer der bleibenden Rechnung eintragen.", stand.configure(text="Bitte die Nummer der bleibenden Rechnung eintragen.",
fg="#a00000") fg="#a00000")
return return
if gefunden["leicht"] and not messagebox.askyesno( if gefunden["leicht"] and not self.frage(
"Weicht ab – trotzdem?", "Weicht ab – trotzdem?",
f"Rechnung {nr} weicht ab: " + "; ".join(gefunden["leicht"]) + f"Rechnung {nr} weicht ab: " + "; ".join(gefunden["leicht"]) +
". Das Storno würde trotzdem darauf verweisen. Ist das dieselbe " ". 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()) nummer = naechste_freie_nummer(out_dir, self.cfg, self._belegte_nummern())
if not nummer: if not nummer:
messagebox.showerror("Keine freie Nummer", self.melden_fehler("Keine freie Nummer",
"Es wurde keine freie Rechnungsnummer gefunden.") "Es wurde keine freie Rechnungsnummer gefunden.")
return return
@ -572,7 +572,7 @@ class KorrekturMixin:
# kann das Programm nichts erfinden; dann muss der Nutzer entscheiden. # kann das Programm nichts erfinden; dann muss der Nutzer entscheiden.
maengel = kunde_maengel(kunde) maengel = kunde_maengel(kunde)
if maengel: if maengel:
if not messagebox.askyesno( if not self.frage(
"Angaben zum Kunden unvollständig", "Angaben zum Kunden unvollständig",
f"In Rechnung {alt_nummer} steht nicht alles:\n\n- " f"In Rechnung {alt_nummer} steht nicht alles:\n\n- "
+ "\n- ".join(maengel) + + "\n- ".join(maengel) +
@ -620,7 +620,7 @@ class KorrekturMixin:
try: try:
pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf) pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf)
except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen 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}") f"Storno-PDF konnte nicht erstellt werden:\n{e}")
print("[storno] FEHLER:", e) print("[storno] FEHLER:", e)
return return
@ -646,7 +646,7 @@ class KorrekturMixin:
if art_folge == "vorhanden": if art_folge == "vorhanden":
self._storno_anzeigen() self._storno_anzeigen()
self._neue_nummer_vorschlagen() self._neue_nummer_vorschlagen()
if messagebox.askyesno( if self.frage(
"Storno erstellt", "Storno erstellt",
f"Storno {nummer} hebt Rechnung {alt_nummer} auf.\n\n" f"Storno {nummer} hebt Rechnung {alt_nummer} auf.\n\n"
f"Die Leistung bleibt mit Rechnung {folge_nummer} abgerechnet – es " f"Die Leistung bleibt mit Rechnung {folge_nummer} abgerechnet – es "
@ -663,7 +663,7 @@ class KorrekturMixin:
if fehlend: if fehlend:
hinweis = (f"\n\nZum Kunden: {fehlend}.\nBitte oben prüfen und ergänzen – " hinweis = (f"\n\nZum Kunden: {fehlend}.\nBitte oben prüfen und ergänzen – "
"ohne vollständige Anschrift wird die Rechnung nicht geschrieben.") "ohne vollständige Anschrift wird die Rechnung nicht geschrieben.")
messagebox.showinfo( self.melden_info(
"Storno erstellt", "Storno erstellt",
f"Stornorechnung {nummer} zu Rechnung {alt_nummer} liegt hier:\n{pfad}\n\n" f"Stornorechnung {nummer} zu Rechnung {alt_nummer} liegt hier:\n{pfad}\n\n"
f"Jetzt die berichtigte Rechnung {self.r_nummer.get()} anpassen und " 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 " text="Hier ist noch kein Nummernbuch – es wird neu angelegt. Wähle im "
"anderen Programm denselben Ordner.", fg=self._muted) "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): def uebernehmen(self):
ordner = self.pfad.get().strip() ordner = self.pfad.get().strip()
taugt, grund = gemeinsam.ordner_taugt(ordner) taugt, grund = gemeinsam.ordner_taugt(ordner)
if not taugt: if not taugt:
messagebox.showwarning("Ordner geht nicht", grund or "Bitte einen Ordner wählen.", self._melden("Ordner geht nicht", grund or "Bitte einen Ordner wählen.")
parent=self)
return return
if not messagebox.askyesno( if not self._frage(
"Sind Sie sicher?", "Sind Sie sicher?",
f"Gemeinsamer Ordner:\n\n{ordner}\n\n" f"Gemeinsamer Ordner:\n\n{ordner}\n\n"
"Steht im anderen Programm derselbe Ordner? Nur dann kennen beide " "Steht im anderen Programm derselbe Ordner? Nur dann kennen beide "
"dieselben Rechnungsnummern.\n\n" "dieselben Rechnungsnummern.\n\n"
"Ja = Ordner übernehmen\n" "Ja = Ordner übernehmen\n"
"Nein = zurück zur Auswahl", "Nein = zurück zur Auswahl"):
icon="question", default="yes", parent=self):
# Zurueck ins Auswahlfeld - der Dialog bleibt stehen, nichts ist gesetzt. # Zurueck ins Auswahlfeld - der Dialog bleibt stehen, nichts ist gesetzt.
self.feld.focus_set() self.feld.focus_set()
return 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")] 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})") pruefe(len(pdfs) == 1, f"im Testlauf schreibt die API die Rechnung ({pdfs})")
# Und im ECHTEN Betrieb nicht: dieselbe Aktion, aber ohne Testlauf-Flag. # Und im ECHTEN Betrieb nicht: dieselbe Aktion, ohne den Pruefschalter.
A.TESTLAUF = False # 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) 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", "")), pruefe(isinstance(antwort, dict) and "nur im Testlauf" in str(antwort.get("fehler", "")),
f"im echten Betrieb verweigert sie den Beleg ({antwort})") f"im echten Betrieb verweigert sie den Beleg ({antwort})")
pruefe(len([n for n in os.listdir(ordner) if n.lower().endswith(".pdf")]) == 1, pruefe(len([n for n in os.listdir(ordner) if n.lower().endswith(".pdf")]) == 1,
"und es kam keine zweite Rechnung dazu") "und es kam keine zweite Rechnung dazu")
A.TESTLAUF = True
# Unbekannter Name sagt, was es gibt - statt still nichts zu tun. # Unbekannter Name sagt, was es gibt - statt still nichts zu tun.
antwort = anrufen_und_drehen(name="tanz", schluessel=schluessel) antwort = anrufen_und_drehen(name="tanz", schluessel=schluessel)

View file

@ -121,6 +121,15 @@ def knoepfe_von(w, aus=None):
return aus 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): def felder_von(w, aus=None):
aus = [] if aus is None else aus aus = [] if aus is None else aus
for k in w.winfo_children(): for k in w.winfo_children():
@ -208,7 +217,9 @@ pruefe(f.cfg.get("rechnung_zaehler") == 91,
f.cfg["nummer_eingerichtet"] = False f.cfg["nummer_eingerichtet"] = False
f.cfg["rechnung_jahr"] = JAHR f.cfg["rechnung_jahr"] = JAHR
config.save_config(f.cfg) 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): def erststart(zahl, erwartet_ja=True):
@ -227,6 +238,14 @@ def erststart(zahl, erwartet_ja=True):
top = tops[-1] top = tops[-1]
felder = felder_von(top) felder = felder_von(top)
if felder: 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].delete(0, "end")
felder[0].insert(0, str(zahl)) felder[0].insert(0, str(zahl))
for k in knoepfe_von(top): 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 sys
import tkinter as tk import tkinter as tk
from tkinter import font as tkfont from tkinter import font as tkfont
from tkinter import messagebox
from tkinter import ttk from tkinter import ttk
import config import config
@ -264,11 +265,15 @@ class ThemeMixin:
# Akzentfarbe, die ruhenden bleiben zurueckgenommen. # Akzentfarbe, die ruhenden bleiben zurueckgenommen.
style.configure("TNotebook", background=bg, bordercolor=self.col_line, style.configure("TNotebook", background=bg, bordercolor=self.col_line,
tabmargins=(2, 4, 2, 0), borderwidth=0) 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, foreground=self.col_muted, bordercolor=self.col_line,
padding=(20, 9), font=(SCHRIFT_NAME, 10), borderwidth=0) padding=(20, 9), font=(SCHRIFT_NAME, 10), borderwidth=0)
style.map("TNotebook.Tab", 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)], foreground=[("selected", accent), ("active", fg)],
font=[("selected", (SCHRIFT_NAME, 10, "bold"))]) font=[("selected", (SCHRIFT_NAME, 10, "bold"))])
# Kein expand beim ausgewaehlten Reiter: er wuchs dadurch um ein paar # Kein expand beim ausgewaehlten Reiter: er wuchs dadurch um ein paar
@ -286,12 +291,16 @@ class ThemeMixin:
und Tastatur fest, waehrend nebenan jemand schreibt. und Tastatur fest, waehrend nebenan jemand schreibt.
""" """
d.configure(bg=self.col_bg) d.configure(bg=self.col_bg)
if config.TESTLAUF: if config.testlauf():
# Wie das Hauptfenster: neben den Bildschirm, und ohne grab_set - # Wie das Hauptfenster: neben den Bildschirm, und ohne grab_set -
# ein Dialog, der Maus und Tastatur festhaelt, blockiert den # ein Dialog, der Maus und Tastatur festhaelt, blockiert den
# Menschen, der nebenher schreibt. # Menschen, der nebenher schreibt.
d.geometry(f"+{d.winfo_screenwidth() + 20}+40") d.geometry(f"+{d.winfo_screenwidth() + 20}+40")
d.grab_set = lambda *a, **k: None 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 # erst nach dem Anzeigen setzen, sonst greift DWM noch nicht
d.after(20, lambda: self._dark_titlebar(d)) 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) ctypes.windll.user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
print("[theme] Titelleiste:", e) 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"]