Die Runde vom 07.09.2026 hat neben den zwei echten Fehlern eine Reihe Wiederholungen und stumme Stellen gemeldet. Abgearbeitet in Etappen, jede fuer sich pruefbar - nach jeder liefen beide Pruefketten gruen. * Menueleiste: der Wechsel von einem Titel zum anderen brauchte ZWEI Klicks. Der grab_set der offenen Liste schluckt den ersten, die <Button-1>-Bindung des Titels feuert gar nicht. Das native tk.Menu, das diese Leiste ersetzt hat, wechselte schon beim blossen Ueberfahren - zwei Klicks waren also eine Verschlechterung gegenueber dem, was vorher da war. Die Klappliste wird ausserdem jetzt wie jede andere Position ueber theme.auf_bildschirm() begrenzt; am unteren Fensterrand lief sie vorher hinaus. Neu: pruef_menueleiste.py misst beides ueber echte Klickereignisse. * %LOCALAPPDATA%/ravokk wurde an drei Stellen einzeln ausgerechnet. Jetzt fragen alle gemeinsam.standard_ordner(). Laufen die je auseinander, fuehren die beiden Programme zwei Nummernbuecher - und das faellt erst auf, wenn eine Rechnungsnummer zum zweiten Mal vergeben ist (§ 14 Abs. 4 Nr. 4 UStG). * pruefe() stand 35-mal im Quelltext, in sechs Fassungen -> pruefhelfer.py. Die zwei Ausreisser sind mitgezogen: pruef_bilder.py schrieb ein eigenes Format, pruef_storno_verrechnet.py zaehlte in einer Zahl und konnte am Ende nicht sagen, WAS fehlschlug. * dlg_darstellung() und die drei Handgriffe des Umschaltens standen in beiden app.py fast wortgleich -> ThemeMixin in theme.py. Beide trugen inzwischen denselben langen Kommentar zu demselben Fehler; das war der Beweis, dass es eine Sache ist. Programmspezifisch bleiben der Hinweistext im Dialog (R31) und die Frage, ob die Einstellungen ausdruecklich gespeichert werden muessen. * pruef_gemeinsam_automatisch.py sagte im Text "genau ein Buch" und prueft ">= 1" mit einem any() - eine Pruefung, die nicht rot werden kann. Jetzt == 1, und der Dateiname wird mitgeprueft. Dazu neu: die neun Dateien, die im Kopf zusagen, sie laegen in beiden Programmen gleich, werden byteweise verglichen. Bei hinweis.py stimmte die Zusage seit dem 06.09. nicht mehr - gleicher Inhalt, CRLF gegen LF. * .gitattributes, damit die Zeilenenden nicht von der Maschine abhaengen. Ohne das meldet genau diese Pruefung nach einem frischen Checkout einen Unterschied, den es im Repository gar nicht gibt. * Sechs stumme "except OSError" sagen jetzt, warum sie schweigen duerfen, und einer meldet statt zu schweigen: schlaegt die Uebernahme einer alten config.json fehl, faengt das Programm ohne Firmendaten, Katalog und Zaehler neu an - und der Erfolgsfall schrieb eine Zeile, der Fehlerfall nicht. Die 24 stummen tk.TclError bleiben: dort wird ein Widget angefasst, das gerade zerstoert wurde, und eine Meldung waere Rauschen. * Tote Parameter und Variablen aus dem Umbau des Vortags: symbol_setzen(dunkel=), kachel(grund=, radius=), mit_trennlinie, sechs x/y-Berechnungen, die von theme.mittig() sofort ueberschrieben wurden, vier lokale "import ctypes". 39 Pruefstaende, 0 rot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
332 lines
13 KiB
Python
332 lines
13 KiB
Python
# -*- 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)
|