REGRESSION aus der Modulaufteilung (Code-Review-Agent): - einstellungen.py fehlten "berechnung" und "datetime". Menue > Leistungskatalog und > Rechnungsnummer stuerzten mit NameError ab und liessen ein leeres, modales Fenster stehen. Preise und Nummernkreis waren nicht mehr einstellbar. KEIN Pruefstand hat das gemerkt - keiner oeffnete je einen Dialog. Neu: pruef_dialoge.py oeffnet ALLE Dialoge und schliesst sie wieder. HAENGENDE ZWISCHENSPEICHER (derselbe Agent, gemessen): - "Zuruecksetzen" liess storno_stand/berichtigung_stand stehen. Danach gehoerten die Daten eines FREMDEN Gastes zum Storno der alten Rechnung, und die Berichtigung schrieb ein Blatt mit Nummer A im Kenndatensatz und Nummer B auf dem Blatt. reset_formular fragt jetzt nach und verwirft sauber. - _neue_nummer_vorschlagen fasst die Nummer nicht mehr an, solange ein Vorgang laeuft (traf auch dlg_nummer). - Fenster schliessen mit offenem Storno fragt jetzt nach (WM_DELETE_WINDOW). - _storno_verwerfen bricht ab, wenn das PDF nicht geloescht werden kann, statt den Zaehler trotzdem zurueckzudrehen. - Der Storno prueft die Kundenangaben und fragt, wenn die alte Rechnung sie nicht hergibt. - Zaehler nicht speicherbar -> sichtbare Meldung statt Traceback ins Nichts. - storno.py: v1-Rueckfall nimmt den ersten Satz OHNE extra_blatt. - EINE Quelle fuer die naechste Nummer: _neue_nummer_vorschlagen benutzt jetzt naechste_freie_nummer, der Jahreswechsel steht nur noch dort. Vorher schlug das Formular 2026-001 vor, waehrend der Storno-Weg 2025-088 nahm. PRUEFSTAENDE (Audit-Agent: 16 von 27 eingebauten Fehlern blieben unbemerkt): - Neu pruef_kernregeln.py (27 Pruefungen) fuer die zwei Regeln, um die es geht: Nummer von HAND auf eine vergebene setzen, Zaehler hinter dem Ordner, Jahreswechsel, Zaehler nur vorwaerts, Storno ueber den DIALOGKNOPF statt der internen Methode, zweite Berichtigung am selben Tag. Der Bestand wird ueber SHA256 verglichen - "Datei ist noch da" heisst nicht "unveraendert". - Der Text des Storno-PDFs wird gelesen: Positionszeile negativ, nicht nur der Summenblock. - Tautologien raus: Selbstvergleich beim storno_datum, all() ueber eine leere Liste, "nicht" in einem deutschen Text. - Unangemeldeter Dialog laesst den Lauf scheitern, statt still "Nein" zu sagen. - pruef_nummern.py: .PDF gross geschrieben, Rechnung_*.txt, echte Praefix-Falle. - Gegenprobe: sechs Mutationen eingebaut, die vorher gruen blieben - alle sechs werden jetzt rot. RECHT (Gegenpruefungs-Agent): Zitate praezisiert. § 146 Abs. 4 AO verbietet nur Aenderungen, bei denen der urspruengliche Inhalt nicht mehr feststellbar ist - ein protokollierter Storno-Vermerk waere erlaubt, wir verzichten trotzdem bewusst darauf. Der § 14c-Hinweis bleibt, gilt aber gegenueber Endverbrauchern nicht (EuGH C-378/21, BMF v. 27.02.2024) - er zielt auf Firmengaeste. 142 Pruefungen gruen (100 + 27 + 8 + 7). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
291 lines
11 KiB
Python
291 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Pruefstand fuer die ZWEI Regeln, um die es dem Programm eigentlich geht:
|
|
|
|
1. Eine Rechnungsnummer wird nie zweimal vergeben.
|
|
2. Eine ausgestellte Rechnung wird nie veraendert und nie geloescht.
|
|
|
|
Warum es diesen Pruefstand gibt: pruef_storno.py lief 100x gruen und pruefte
|
|
trotzdem keine der beiden Regeln - er ging immer nur den gluecklichen Weg mit
|
|
sauber hochgezaehltem Zaehler. Ein Mutationstest hat gezeigt, dass man BEIDE
|
|
Sperren gegen die doppelte Nummer ausbauen kann, ohne dass er rot wird.
|
|
|
|
Darum hier:
|
|
- Der Bestand wird ueber PRUEFSUMMEN verglichen, nicht ueber os.path.exists.
|
|
Eine Datei, die noch da ist, kann trotzdem veraendert worden sein.
|
|
- Die Nummer wird von HAND ins Feld getippt, so wie es der Nutzer tut.
|
|
- Unbekannte Dialoge lassen den Lauf scheitern, statt still "Nein" zu sagen.
|
|
"""
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
|
|
QUELLE = r"C:\claude\rechnungstool"
|
|
sys.path.insert(0, QUELLE)
|
|
|
|
tmp = tempfile.mkdtemp(prefix="kern_pruef_")
|
|
ordner = os.path.join(tmp, "rechnungen")
|
|
os.makedirs(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
|
|
roh["output_dir"] = ordner
|
|
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False)
|
|
|
|
import app as A # noqa: E402
|
|
import bestand as BE # noqa: E402
|
|
from tkinter import messagebox # noqa: E402
|
|
|
|
fehler = []
|
|
|
|
|
|
def pruefe(bedingung, text):
|
|
print((" OK | " if bedingung else "FEHLER | ") + text)
|
|
if not bedingung:
|
|
fehler.append(text)
|
|
|
|
|
|
# ---- Dialoge: jede Frage muss angemeldet sein --------------------------------
|
|
antworten = {}
|
|
gefragt = []
|
|
meldungen = []
|
|
|
|
|
|
def askyesno(titel, text="", **kw):
|
|
gefragt.append(titel)
|
|
for schluessel, wert in antworten.items():
|
|
if schluessel in titel:
|
|
return wert
|
|
# Kein stilles "Nein": sonst haelt der Pruefstand einen abgebrochenen
|
|
# Ablauf faelschlich fuer ein Ergebnis.
|
|
raise AssertionError(f"unangemeldeter Dialog: {titel!r} — {text[:80]!r}")
|
|
|
|
|
|
messagebox.askyesno = askyesno
|
|
messagebox.showinfo = lambda *a, **k: meldungen.append(("info", a[0] if a else ""))
|
|
messagebox.showwarning = lambda *a, **k: meldungen.append(("warnung", a[0] if a else ""))
|
|
messagebox.showerror = lambda *a, **k: meldungen.append(("fehler", a[0] if a else ""))
|
|
|
|
|
|
def bestand_lesen():
|
|
"""Pruefsumme je PDF im Ordner - erkennt auch stille Aenderungen am Inhalt."""
|
|
stand = {}
|
|
for name in sorted(os.listdir(ordner)):
|
|
if name.lower().endswith(".pdf"):
|
|
with open(os.path.join(ordner, name), "rb") as f:
|
|
stand[name] = hashlib.sha256(f.read()).hexdigest()
|
|
return stand
|
|
|
|
|
|
def unveraendert(vorher, ausser=()):
|
|
"""Alle Dateien aus <vorher> sind byteweise unveraendert (bis auf <ausser>)."""
|
|
jetzt = bestand_lesen()
|
|
for name, summe in vorher.items():
|
|
if name in ausser:
|
|
continue
|
|
if jetzt.get(name) != summe:
|
|
return False, (f"{name} " + ("ist weg" if name not in jetzt else "wurde verändert"))
|
|
return True, ""
|
|
|
|
|
|
def kunde_setzen(vor, nach, tage="2"):
|
|
f.k_vorname.set(vor)
|
|
f.k_name.set(nach)
|
|
f.k_strasse.set("Musterweg 1")
|
|
f.k_plzort.set("12345 Musterstadt")
|
|
f.anzahl_vars[erste].set(tage)
|
|
f.update()
|
|
|
|
|
|
f = A.RechnungsApp()
|
|
f.update()
|
|
f.cfg["output_dir"] = ordner
|
|
erste = sorted(f.anzahl_vars)[0]
|
|
antworten["Fertig"] = False # "jetzt oeffnen?" immer nein
|
|
|
|
# ---- Ausgangslage: zwei echte Rechnungen ------------------------------------
|
|
kunde_setzen("Anna", "Beispiel", "4")
|
|
f.pdf_erstellen()
|
|
kunde_setzen("Bernd", "Zweit", "3")
|
|
f.pdf_erstellen()
|
|
pruefe(sorted(bestand_lesen()) == ["Rechnung_2026-001_Beispiel.pdf",
|
|
"Rechnung_2026-002_Zweit.pdf"],
|
|
f"zwei Rechnungen angelegt ({sorted(bestand_lesen())})")
|
|
|
|
# =============================================================================
|
|
# REGEL 1: eine Nummer nie zweimal - auch nicht, wenn sie von Hand kommt
|
|
# =============================================================================
|
|
print("--- Regel 1: Nummer von Hand auf eine vergebene setzen ---")
|
|
vorher = bestand_lesen()
|
|
gefragt.clear()
|
|
antworten["Rechnungsnummer schon vergeben"] = True # "naechste freie nehmen?"
|
|
kunde_setzen("Carla", "Dritt", "1")
|
|
f.r_nummer.set("2026-001") # genau das tut der Nutzer von Hand
|
|
f.update()
|
|
f.pdf_erstellen()
|
|
|
|
pruefe(any("schon vergeben" in t for t in gefragt),
|
|
f"Programm meldet die vergebene Nummer ({gefragt})")
|
|
ok, warum = unveraendert(vorher)
|
|
pruefe(ok, f"die beiden alten Rechnungen sind byteweise unverändert ({warum})")
|
|
pruefe(os.path.exists(os.path.join(ordner, "Rechnung_2026-003_Dritt.pdf")),
|
|
"die neue Rechnung bekam die nächste FREIE Nummer 2026-003")
|
|
pruefe(f.r_nummer.get() != "2026-001", f"Feld zeigt nicht mehr die alte Nummer ({f.r_nummer.get()})")
|
|
|
|
print("--- Regel 1: Nutzer lehnt die Ausweichnummer ab ---")
|
|
vorher = bestand_lesen()
|
|
antworten["Rechnungsnummer schon vergeben"] = False
|
|
kunde_setzen("Doris", "Vier", "1")
|
|
f.r_nummer.set("2026-002")
|
|
f.update()
|
|
f.pdf_erstellen()
|
|
ok, warum = unveraendert(vorher)
|
|
pruefe(ok and bestand_lesen() == vorher,
|
|
f"bei Nein wird gar nichts geschrieben und nichts verändert ({warum})")
|
|
|
|
print("--- Regel 1: Zähler steht HINTER dem Ordner (config aus dem Backup) ---")
|
|
f.cfg["rechnung_zaehler"] = 0 # so sieht eine zurückgespielte config aus
|
|
f.berichtigung_stand = None
|
|
f.storno_stand = None
|
|
f._neue_nummer_vorschlagen()
|
|
pruefe(f.r_nummer.get() == "2026-004",
|
|
f"Vorschlag überspringt die belegten Nummern ({f.r_nummer.get()})")
|
|
|
|
print("--- Regel 1: der Zähler darf nur vorwärts ---")
|
|
# Zweite Verteidigungslinie hinter dem Ordner-Abgleich: wer von Hand eine
|
|
# niedrigere freie Nummer nimmt, darf den Zähler nicht zurückdrehen - sonst
|
|
# schlägt das Programm die dazwischenliegenden Nummern ein zweites Mal vor.
|
|
kunde_setzen("Emil", "Hoch", "1")
|
|
f.r_nummer.set("2026-009")
|
|
f.update()
|
|
f.pdf_erstellen()
|
|
pruefe(f.cfg["rechnung_zaehler"] == 9, f"Zähler steht auf 9 ({f.cfg['rechnung_zaehler']})")
|
|
kunde_setzen("Frida", "Tief", "1")
|
|
f.r_nummer.set("2026-007")
|
|
f.update()
|
|
f.pdf_erstellen()
|
|
pruefe(os.path.exists(os.path.join(ordner, "Rechnung_2026-007_Tief.pdf")),
|
|
"die niedrigere freie Nummer 2026-007 wurde geschrieben")
|
|
pruefe(f.cfg["rechnung_zaehler"] == 9,
|
|
f"Zähler bleibt auf 9, fällt NICHT auf 7 zurück ({f.cfg['rechnung_zaehler']})")
|
|
f._neue_nummer_vorschlagen()
|
|
pruefe(f.r_nummer.get() == "2026-010",
|
|
f"nächster Vorschlag ist 2026-010, keine schon vergebene ({f.r_nummer.get()})")
|
|
|
|
print("--- Regel 1: Jahreswechsel - beide Quellen müssen dasselbe sagen ---")
|
|
f.cfg["rechnung_jahr"] = 2025
|
|
f.cfg["rechnung_zaehler"] = 87
|
|
f._neue_nummer_vorschlagen()
|
|
vom_formular = f.r_nummer.get()
|
|
vom_storno = BE.naechste_freie_nummer(ordner, f.cfg)
|
|
pruefe(vom_formular == vom_storno,
|
|
f"Formular und Storno-Weg schlagen dieselbe Nummer vor ({vom_formular} / {vom_storno})")
|
|
pruefe(vom_formular.startswith("2026-"),
|
|
f"neues Jahr fängt im aktuellen Jahr an ({vom_formular})")
|
|
f.cfg["rechnung_jahr"], f.cfg["rechnung_zaehler"] = 2026, 3
|
|
config.save_config(f.cfg)
|
|
|
|
# =============================================================================
|
|
# REGEL 2: ausgestellte Rechnung nie anfassen
|
|
# =============================================================================
|
|
print("--- Regel 2: Storno über den Dialogknopf, nicht über die interne Methode ---")
|
|
vorher = bestand_lesen()
|
|
gefragt.clear()
|
|
f.dlg_storno()
|
|
f.update()
|
|
import tkinter as tk # noqa: E402
|
|
from tkinter import ttk # noqa: E402
|
|
dlg = [w for w in f.winfo_children() if isinstance(w, tk.Toplevel)][-1]
|
|
feld = knopf = None
|
|
for rahmen in dlg.winfo_children():
|
|
for w in rahmen.winfo_children():
|
|
if isinstance(w, ttk.Entry):
|
|
feld = w
|
|
elif isinstance(w, ttk.Frame):
|
|
for b in w.winfo_children():
|
|
if isinstance(b, ttk.Button) and "Storno erstellen" in str(b.cget("text")):
|
|
knopf = b
|
|
pruefe(feld is not None and knopf is not None, "Storno-Dialog hat Feld und Knopf")
|
|
feld.delete(0, "end")
|
|
feld.insert(0, "2026-001")
|
|
knopf.invoke() # der ECHTE Weg, nicht _storno_anlegen()
|
|
f.update()
|
|
|
|
pruefe(f.storno_stand is not None and f.storno_stand["zu"] == "2026-001",
|
|
f"der Knopf hat wirklich ein Storno gestartet ({f.storno_stand})")
|
|
storno_datei = os.path.basename(f.storno_stand["pfad"]) if f.storno_stand else ""
|
|
ok, warum = unveraendert(vorher)
|
|
pruefe(ok, f"alle bisherigen Rechnungen unverändert ({warum})")
|
|
|
|
print("--- Regel 2: was steht auf dem Storno-Blatt? ---")
|
|
from pypdf import PdfReader # noqa: E402
|
|
text = PdfReader(f.storno_stand["pfad"]).pages[0].extract_text()
|
|
pruefe("Stornorechnung" in text, "Überschrift „Stornorechnung“ steht drauf")
|
|
pruefe("2026-001" in text, "Bezug auf die alte Nummer steht drauf")
|
|
# pypdf legt jeden Tabellenwert in eine eigene Zeile - die Betraege stehen also
|
|
# NACH ihrer Beschriftung, nicht daneben.
|
|
zeilen = text.splitlines()
|
|
|
|
|
|
def betraege_nach(label, wieviele=6):
|
|
"""Die Zahlenwerte, die auf die Zeile mit <label> folgen."""
|
|
for i, z in enumerate(zeilen):
|
|
if label in z:
|
|
return [w.strip() for w in zeilen[i + 1:i + 1 + wieviele]
|
|
if "," in w and any(c.isdigit() for c in w)]
|
|
return []
|
|
|
|
|
|
pos = betraege_nach("Storno der Rechnung")
|
|
pruefe(bool(pos), f"Positionszeile hat Beträge ({pos})")
|
|
pruefe(all(w.startswith("-") for w in pos),
|
|
f"Positionszeile ist NEGATIV, nicht nur der Summenblock ({pos})")
|
|
gesamt = betraege_nach("Gesamtbetrag", 1)
|
|
pruefe(gesamt and gesamt[0].startswith("-"), f"Gesamtbetrag negativ ({gesamt})")
|
|
netto = betraege_nach("Nettobetrag", 1)
|
|
pruefe(netto and netto[0].startswith("-"), f"Nettobetrag negativ ({netto})")
|
|
|
|
print("--- Regel 2: berichtigte Rechnung schreiben, alter Bestand bleibt ---")
|
|
vorher = bestand_lesen()
|
|
antworten["Storno wirklich"] = True
|
|
kunde_setzen("Anna", "Beispiel", "3")
|
|
f.pdf_erstellen()
|
|
ok, warum = unveraendert(vorher)
|
|
pruefe(ok, f"Storno und alle Rechnungen unverändert ({warum})")
|
|
pruefe(len(bestand_lesen()) == len(vorher) + 1, "genau eine Datei kam dazu")
|
|
|
|
# =============================================================================
|
|
# Berichtigung: zweimal am selben Tag darf nicht stillschweigend überschreiben
|
|
# =============================================================================
|
|
print("--- Berichtigung zweimal am selben Tag ---")
|
|
antworten["Berichtigte Rechnung erstellen"] = True
|
|
meta = next(x for x in BE.rechnungen_im_ordner(ordner)
|
|
if x.get("rechnungsnummer") == "2026-002")
|
|
f._berichtigung_starten(meta, ordner)
|
|
f.k_strasse.set("Neue Straße 5")
|
|
f.update()
|
|
f.pdf_erstellen()
|
|
erste_fassung = bestand_lesen()
|
|
ziel = [n for n in erste_fassung if n.startswith("Berichtigte Rechnung")]
|
|
pruefe(len(ziel) == 1, f"Berichtigungsblatt geschrieben ({ziel})")
|
|
|
|
antworten["Berichtigung von heute ersetzen"] = False # NEIN, nicht ersetzen
|
|
f._berichtigung_starten(meta, ordner)
|
|
f.k_strasse.set("Ganz andere Straße 9")
|
|
f.update()
|
|
gefragt.clear()
|
|
f.pdf_erstellen()
|
|
pruefe(any("ersetzen" in t for t in gefragt), f"vor dem Ersetzen wird gefragt ({gefragt})")
|
|
ok, warum = unveraendert(erste_fassung)
|
|
pruefe(ok, f"bei Nein bleibt die erste Fassung unangetastet ({warum})")
|
|
|
|
f.destroy()
|
|
print("---")
|
|
print(f"Ordner: {ordner}")
|
|
print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")
|
|
sys.exit(1 if fehler else 0)
|