# -*- coding: utf-8 -*- """Pruefstand: der Rechnungszaehler kann nicht mehr zurueck. Sein Einwand: *"er kann jetzt bescheissen, indem er das in den einstellungen aendert"* - und der Vorschlag dazu: *"dann koennte man noch die pdfs nutzen als referenz, die haben auch die nummern"*. Geprueft wird beides als Quelle: * die PDFs im Rechnungsordner (Dateiname traegt die Nummer) * das gemeinsame Nummernbuch (kennt auch Nummern OHNE PDF - Altbestand) Vorwaerts darf der Zaehler: eine lueckenlose Folge verlangt das Gesetz nicht. Zurueck nicht: jede Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG). """ import json import os import shutil import sys import tempfile import threading QUELLE = r"C:\claude\rechnungstool" sys.path.insert(0, QUELLE) def _reissleine(): print("ABBRUCH | Pruefstand haengt (Zeitgrenze)") os._exit(2) _wecker = threading.Timer(150, _reissleine) _wecker.daemon = True _wecker.start() os.environ["CAMPINGHOF_TEST"] = "1" tmp = tempfile.mkdtemp(prefix="sperre_pruef_") ordner = os.path.join(tmp, "rechnungen") gemeinsam_ordner = os.path.join(tmp, "install") os.makedirs(ordner) os.makedirs(gemeinsam_ordner) import config # noqa: E402 shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json")) config.CONFIG_PATH = os.path.join(tmp, "config.json") roh = json.load(open(config.CONFIG_PATH, encoding="utf-8")) roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0 # Der Erststart-Zustand gehoert dem Pruefstand, nicht der config des # Entwicklerrechners: steht dort "eingerichtet", zeigt dlg_nummer nur eine # Meldung statt des Fensters, das hier bedient werden soll. roh["nummer_eingerichtet"] = False roh["output_dir"] = ordner json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2) import app as A # noqa: E402 import gemeinsam # noqa: E402 from tkinter import messagebox # noqa: E402 from pruefhelfer import fehler, pruefe warnungen = [] # Der Erprobungshinweis wird von der pauschalen Antwort unten mit erfasst. # Geprueft wird er eigens in pruef_hinweis.py. messagebox.askyesno = lambda t, x, **k: ("Fertig" not in t) messagebox.showinfo = lambda *a, **k: None messagebox.showwarning = lambda *a, **k: warnungen.append(a[0] if a else "") messagebox.showerror = lambda *a, **k: print("[fehler]", a[0] if a else "") f = A.RechnungsApp() f.cfg["output_dir"] = ordner JAHR = 2026 if int(f.cfg.get("rechnung_jahr", 0)) != JAHR: print("HINWEIS | Der Pruefstand rechnet mit 2026 - heute ist ein anderes Jahr.") def rechnung(name): f.reset_formular() f.k_vorname.set("Anna") f.k_name.set(name) f.k_strasse.set("Musterweg 1") f.k_plzort.set("12345 Musterstadt") f.r_anreise.set("01.08.2026") f.r_abreise.set("03.08.2026") f.anzahl_vars[sorted(f.anzahl_vars)[0]].set("2") f._testwahl = "rechnung" f.update() f.pdf_erstellen() # ---- 1. Drei Rechnungen schreiben ---------------------------------------- for name in ("Eins", "Zwei", "Drei"): rechnung(name) dateien = sorted(d for d in os.listdir(ordner) if d.endswith(".pdf")) pruefe(len(dateien) == 3, f"drei Rechnungen geschrieben ({dateien})") pruefe(f.cfg.get("rechnung_zaehler") == 3, f"Zaehler steht auf 3 ({f.cfg.get('rechnung_zaehler')})") pruefe(f.hoechster_stand(JAHR) == 3, f"hoechster Stand aus den PDFs: 3 ({f.hoechster_stand(JAHR)})") # ---- 2. Zaehler von Hand zurueckdrehen (der Betrugsfall) ----------------- f.cfg["rechnung_zaehler"] = 0 config.save_config(f.cfg) f._neue_nummer_vorschlagen() pruefe(f.r_nummer.get() == "2026-004", f"trotz zurueckgedrehtem Zaehler wird 2026-004 vorgeschlagen ({f.r_nummer.get()})") pruefe(f.cfg.get("rechnung_zaehler") == 3, f"und der Zaehler ist wieder angehoben ({f.cfg.get('rechnung_zaehler')})") # ---- 3. Auch die Einstellungen lassen ihn nicht zurueck ------------------ def knoepfe_von(w, aus=None): aus = [] if aus is None else aus for k in w.winfo_children(): if k.winfo_class() in ("TButton", "Button"): aus.append(k) knoepfe_von(k, 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): aus = [] if aus is None else aus for k in w.winfo_children(): if k.winfo_class() in ("TEntry", "Entry"): aus.append(k) felder_von(k, aus) return aus def nummerndialog(neuer_zaehler): """Dialog oeffnen, Zaehler eintragen, Speichern druecken.""" f.dlg_nummer() f.update() top = [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"][-1] felder = felder_von(top) felder[1].delete(0, "end") felder[1].insert(0, str(neuer_zaehler)) f.update() for k in knoepfe_von(top): if "Speichern" in str(k.cget("text")): k.invoke() break f.update() noch_offen = [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"] for w in noch_offen: w.destroy() return bool(noch_offen) warnungen.clear() blieb_offen = nummerndialog(1) pruefe(f.cfg.get("rechnung_zaehler") == 3, f"die Einstellungen setzen den Zaehler NICHT auf 1 ({f.cfg.get('rechnung_zaehler')})") pruefe(any("vergeben" in w for w in warnungen), f"mit Meldung an den Benutzer ({warnungen})") pruefe(blieb_offen, "und der Dialog bleibt offen, statt still zu schlucken") warnungen.clear() nummerndialog(50) pruefe(f.cfg.get("rechnung_zaehler") == 50, f"nach VORN darf er (Luecken sind erlaubt) ({f.cfg.get('rechnung_zaehler')})") pruefe(not warnungen, f"und dabei wird nicht gewarnt ({warnungen})") # ---- 4. Das Nummernbuch kennt Nummern OHNE PDF -------------------------- f.cfg["gemeinsam_ordner"] = gemeinsam_ordner f._buch_oeffnen() pruefe(f.buch is not None, "das gemeinsame Nummernbuch ist da") f.buch.eintragen("2026-090", jahr=2026, nachname="AltbestandOhnePDF", quelle="journal", herkunft="xlsx") pruefe(f.hoechster_stand(JAHR) == 90, f"der hoechste Stand kommt jetzt aus dem Buch: 90 ({f.hoechster_stand(JAHR)})") f.cfg["rechnung_zaehler"] = 5 config.save_config(f.cfg) f._neue_nummer_vorschlagen() pruefe(f.r_nummer.get() == "2026-091", f"vorgeschlagen wird 2026-091 - hinter dem Altbestand ({f.r_nummer.get()})") warnungen.clear() nummerndialog(10) pruefe(f.cfg.get("rechnung_zaehler") == 90, f"und die Einstellungen kommen auch nicht hinter das Buch zurueck " f"({f.cfg.get('rechnung_zaehler')})") # ---- 5. Ohne PDFs im Ordner: das Buch allein haelt die Sperre ----------- for d in os.listdir(ordner): if d.endswith(".pdf"): os.remove(os.path.join(ordner, d)) pruefe(f.hoechster_stand(JAHR) == 90, f"auch ohne PDFs bleibt der Stand bei 90 ({f.hoechster_stand(JAHR)})") # ---- 6. Und ohne Buch: die PDFs allein halten sie ---------------------- rechnung("Nachher") # schreibt 2026-091 f.buch = None f.cfg["gemeinsam_ordner"] = "" pruefe(f.hoechster_stand(JAHR) == 91, f"ohne Buch zaehlen die PDFs: 91 ({f.hoechster_stand(JAHR)})") warnungen.clear() nummerndialog(2) pruefe(f.cfg.get("rechnung_zaehler") == 91, f"und auch dann geht es nicht zurueck ({f.cfg.get('rechnung_zaehler')})") # ---- 7. Der Erststart: EINMAL setzen duerfen --------------------------- # Er kommt aus einer Excel-Tabelle mit laufender Nummer - die muss er einmal # nennen koennen. Danach ist der Zaehler zu. f.cfg["nummer_eingerichtet"] = False f.cfg["rechnung_jahr"] = JAHR config.save_config(f.cfg) # 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): """Popup oeffnen, Zahl eintragen, Uebernehmen druecken.""" antwort = {"gefragt": False} def sicher(titel, text, **kw): antwort["gefragt"] = True return erwartet_ja messagebox.askyesno = sicher def klick(rest=40): tops = [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"] if tops: 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): if "bernehmen" in str(k.cget("text")): k.invoke() # Lehnt der Dialog die Zahl ab, BLEIBT er offen - das ist # gewollt und wird weiter unten geprueft. Dann muss hier # aber, wie ein Benutzer, "Spaeter" gedrueckt werden: # sonst wartet das Programm in wait_window() ewig auf # einen Klick, den im Pruefstand niemand macht. top.update() if top.winfo_exists(): for spaeter in knoepfe_von(top): if "ter" in str(spaeter.cget("text")): spaeter.invoke() break return if rest: f.after(100, lambda: klick(rest - 1)) f.after(200, klick) f._frage_nummer_falls_noetig() for w in [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"]: w.destroy() return antwort["gefragt"] hoch_jetzt = f.hoechster_stand(JAHR) erststart(hoch_jetzt - 5, True) pruefe(not f.cfg.get("nummer_eingerichtet"), "eine Zahl UNTER dem Vergebenen wird beim Erststart nicht angenommen") gefragt = erststart(500, True) pruefe(gefragt, "vor dem Uebernehmen wird noch einmal gefragt") pruefe(f.cfg.get("nummer_eingerichtet") is True, "danach ist der Zaehler eingerichtet") pruefe(f.cfg.get("rechnung_zaehler") == 500, f"und steht auf 500 ({f.cfg.get('rechnung_zaehler')})") # ---- 8. Ab jetzt ist er zu ------------------------------------------- meldungen = [] messagebox.showinfo = lambda *a, **k: meldungen.append(a[1] if len(a) > 1 else "") vorher = f.cfg.get("rechnung_zaehler") f.dlg_nummer() f.update() offen = [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"] pruefe(not offen, "die Einstellungen oeffnen kein Eingabefenster mehr") pruefe(f.cfg.get("rechnung_zaehler") == vorher, f"der Zaehler bleibt, wie er ist ({f.cfg.get('rechnung_zaehler')})") pruefe(any("Storno" in m for m in meldungen), f"und es wird auf Storno/Berichtigen verwiesen ({meldungen})") # Und der Erststart fragt nicht noch einmal f.after(200, lambda: None) f._frage_nummer_falls_noetig() pruefe(not [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"], "beim naechsten Start wird nicht wieder gefragt") # ---- 9. Jahreswechsel: nach dem Bindestrich faengt es wieder bei 001 an -- # "beim jahreswechsel sollte die nach dem - zeichen auf 000 gehen, weil neues # jahr". Der Zaehler des VORjahres darf dabei nichts anheben. import datetime as _dt heute = _dt.date.today().year f.cfg["rechnung_jahr"] = heute - 1 f.cfg["rechnung_zaehler"] = 442 f.cfg["nummer_eingerichtet"] = True config.save_config(f.cfg) f._neue_nummer_vorschlagen() pruefe(f.r_nummer.get() == f"{heute}-001", f"im neuen Jahr wird {heute}-001 vorgeschlagen ({f.r_nummer.get()})") # Auch dann, wenn im Ordner noch die hohen Nummern des Vorjahres liegen. alt_pdf = os.path.join(ordner, f"Rechnung_{heute - 1}-442_Vorjahr.pdf") open(alt_pdf, "wb").write(b"%PDF-1.4 kein echtes PDF, nur der Name zaehlt") vor_dem_anlegen = 91 # so weit ist DIESES Jahr aus den Schritten davor pruefe(f.hoechster_stand(heute) == vor_dem_anlegen, f"die Rechnung aus dem Vorjahr hebt dieses Jahr NICHT an " f"({f.hoechster_stand(heute)}, erwartet {vor_dem_anlegen})") pruefe(f.hoechster_stand(heute - 1) == 442, f"fuer ihr eigenes Jahr aber schon ({f.hoechster_stand(heute - 1)})") f._neue_nummer_vorschlagen() pruefe(f.r_nummer.get() == f"{heute}-001", f"und es bleibt bei {heute}-001 ({f.r_nummer.get()})") f.destroy() if not fehler: shutil.rmtree(tmp, ignore_errors=True) else: print(f" (Wegwerf-Ordner bleibt: {tmp})") print("---") print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}") sys.exit(1 if fehler else 0)