diff --git a/app.py b/app.py index bad5851..6d4a8b7 100644 --- a/app.py +++ b/app.py @@ -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. diff --git a/config.py b/config.py index 6b4d98e..4d6ccd1 100644 --- a/config.py +++ b/config.py @@ -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) diff --git a/einstellungen.py b/einstellungen.py index 794098f..2d66ef2 100644 --- a/einstellungen.py +++ b/einstellungen.py @@ -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 " diff --git a/korrektur.py b/korrektur.py index 4bd8a1a..966e2ee 100644 --- a/korrektur.py +++ b/korrektur.py @@ -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 " diff --git a/ordnerwahl.py b/ordnerwahl.py index 060ff7b..6cc89b3 100644 --- a/ordnerwahl.py +++ b/ordnerwahl.py @@ -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 diff --git a/pruef_api.py b/pruef_api.py index 01a4fc2..bf62f60 100644 --- a/pruef_api.py +++ b/pruef_api.py @@ -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) diff --git a/pruef_nummernsperre.py b/pruef_nummernsperre.py index 4e99b5b..782ef7f 100644 --- a/pruef_nummernsperre.py +++ b/pruef_nummernsperre.py @@ -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): diff --git a/theme.py b/theme.py index b1b26a3..36234ba 100644 --- a/theme.py +++ b/theme.py @@ -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("", lambda _e: schliessen(vorgabe)) + else: + ttk.Button(leiste, text="OK", style="Haupt.TButton", + command=lambda: schliessen(True)).pack(side="right") + d.bind("", lambda _e: schliessen(True)) + d.bind("", 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"]