rechnungstool/pruef_storno.py
TheMockTv 2b09d15cd0 Aufraeumen nach der Pruefkette: doppelter Code an eine Stelle
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
2026-09-07 18:39:03 +02:00

437 lines
20 KiB
Python

# -*- coding: utf-8 -*-
"""Pruefstand fuer die Storno-Kette - kompletter Durchlauf in einem Wegwerf-Ordner.
Geprueft wird an ECHTEN PDFs, die das Programm selbst schreibt:
1. Rechnung 2026-001 erstellen
2. sie stornieren -> Storno 2026-002 mit negativen Betraegen
3. berichtigte Rechnung 2026-003 mit geaenderter Menge
4. der Steuerrechner liest den Ordner: 001 + 002 + 003 muss 003 ergeben
5. Rueckweg: Storno anlegen und verwerfen -> PDF weg, Zaehler zurueck, Original da
Die echte config.json wird NICHT angefasst - es wird mit einer Kopie im
Wegwerf-Ordner gearbeitet.
"""
import json
import os
import shutil
import sys
import tempfile
QUELLE = r"C:\claude\rechnungstool"
sys.path.insert(0, QUELLE)
tmp = tempfile.mkdtemp(prefix="storno_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
# Der Erststart-Zustand gehoert dem Pruefstand, nicht der config des
# Entwicklerrechners: sonst laeuft er in einen anderen Zweig, sobald hier
# jemand die Nummer einmal eingerichtet hat.
roh["nummer_eingerichtet"] = False
roh["output_dir"] = ordner
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
# Kein Erststart-Dialog im Pruefstand - er wuerde den Lauf haengen lassen.
os.environ["CAMPINGHOF_TEST"] = "1"
import app as A # noqa: E402
import bestand as BE # noqa: E402
from tkinter import messagebox # noqa: E402
# --- Reissleine ---------------------------------------------------------
# Ein Pruefstand darf NIE haengen. Bleibt er in einem Dialog stehen (etwa weil
# eine Aenderung eine neue Frage eingefuehrt hat), bricht er nach zwei Minuten
# von selbst ab, statt ein Fenster offen zu lassen, auf das niemand klickt.
import threading as _th
from pruefhelfer import fehler, pruefe
def _reissleine():
import os as _os
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog, "
"der nicht beantwortet wird")
_os._exit(2)
_wecker = _th.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
# Dialoge automatisch beantworten; jede Frage wird protokolliert.
antworten = {}
gefragt = []
def askyesno(titel, text, **kw):
gefragt.append(titel)
for schlue, wert in antworten.items():
if schlue in titel:
return wert
# Kein stilles "Nein": ein nicht angemeldeter Dialog wuerde den Ablauf
# abbrechen und der Pruefstand haelt das Ergebnis faelschlich fuer gewollt.
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
antworten["Fertig"] = False # "jetzt oeffnen?" - nie oeffnen im Pruefstand
antworten["endgültig erstellen"] = True # die Sicherheitsfrage bejahen
antworten["Hausnummer"] = True # "Musterweg 1" hat eine, greift nur im Sonderfall
# Die Freigabe vor dem Beleg (§ 14 UStG) wird bewusst bejaht - geprueft wird hier
# der Ablauf DANACH. Dass ein "Nein" keinen Beleg erzeugt, prueft pruef_freigabe.py.
antworten["jetzt erstellen?"] = True
# Der Erprobungshinweis wird hier bejaht - geprueft wird er in pruef_hinweis.py.
antworten["Erprobungsfassung"] = True
messagebox.askyesno = askyesno
messagebox.showinfo = lambda *a, **k: None
warnungen = []
messagebox.showwarning = lambda *a, **k: warnungen.append(a[0] if a else "")
messagebox.showerror = lambda *a, **k: print("[fehler]", a[0] if a else "", a[1] if len(a) > 1 else "")
f = A.RechnungsApp()
f.update()
f.cfg["output_dir"] = ordner
# ---- 1. normale Rechnung ------------------------------------------------
f.k_vorname.set("Anna")
f.k_name.set("Beispiel")
f.k_strasse.set("Musterweg 1")
f.k_plzort.set("12345 Musterstadt")
f.r_anreise.set("01.08.2026")
f.r_abreise.set("05.08.2026")
erste = sorted(f.anzahl_vars)[0]
f.anzahl_vars[erste].set("4")
f.update()
f.pdf_erstellen()
erste_pdf = os.path.join(ordner, "Rechnung_2026-001_Beispiel.pdf")
pruefe(os.path.exists(erste_pdf), "Rechnung 2026-001 geschrieben")
meta1 = BE.kenndaten_lesen(erste_pdf)
pruefe(bool(meta1), "Kenndaten der Rechnung lesbar")
pruefe(meta1.get("version") == 3, f"Metadaten Version 3 (ist {meta1.get('version')})")
pruefe(meta1.get("art") == "rechnung", "art = rechnung")
pruefe(len(meta1.get("positionen") or []) == 1, "Positionen stehen in den Kenndaten")
pruefe(bool(meta1.get("kunde", {}).get("plzort")), "Anschrift steht in den Kenndaten")
brutto1 = float(meta1["zwischensumme"])
gesamt1 = float(meta1["gesamt"])
print(f" Rechnung 001: brutto {brutto1:.2f}, gesamt {gesamt1:.2f}")
# ---- 2. stornieren ------------------------------------------------------
offen = [d for d in BE.rechnungen_im_ordner(ordner) if d.get("art") != "storno"]
pruefe(len(offen) == 1, "eine stornierbare Rechnung gefunden")
f._storno_anlegen(offen[0], ordner)
pruefe(f.storno_stand is not None, "Storno liegt im Zwischenspeicher")
storno_pdf = f.storno_stand["pfad"] if f.storno_stand else ""
pruefe(os.path.exists(storno_pdf), f"Storno-PDF geschrieben ({os.path.basename(storno_pdf)})")
meta2 = BE.kenndaten_lesen(storno_pdf)
pruefe(meta2.get("art") == "storno", "art = storno")
pruefe(meta2.get("storno_zu") == "2026-001", "storno_zu zeigt auf 2026-001")
import re as _re
pruefe(bool(_re.fullmatch(r"\d{2}\.\d{2}\.\d{4}", str(meta2.get("storno_datum") or "")))
and meta2.get("storno_datum") == meta1.get("datum"),
f"Datum der alten Rechnung steht drin ({meta2.get('storno_datum')!r})")
pruefe(float(meta2["zwischensumme"]) == -brutto1, f"Zwischensumme negativ ({meta2['zwischensumme']})")
pruefe(float(meta2["gesamt"]) == -gesamt1, f"Gesamt negativ ({meta2['gesamt']})")
pruefe(bool(meta2["ust_bloecke"])
and all(b["netto"] < 0 and b["ust"] < 0 for b in meta2["ust_bloecke"]),
f"alle USt-Bloecke echt negativ ({meta2['ust_bloecke']})")
pruefe(f.r_nummer.get() == "2026-003", f"Formular steht auf 2026-003 (ist {f.r_nummer.get()})")
pruefe(str(f.nummer_entry.cget("state")) == "readonly", "Nummernfeld gesperrt")
pruefe(f.k_plzort.get() == "12345 Musterstadt", "Anschrift ins Formular uebernommen")
pruefe(f.anzahl_vars[erste].get() == "4.0", f"Menge uebernommen (ist {f.anzahl_vars[erste].get()})")
# ---- 3. berichtigte Rechnung -------------------------------------------
antworten["Storno wirklich"] = True
f.anzahl_vars[erste].set("3") # das war der Fehler: 4 statt 3 Naechte
f.update()
f.pdf_erstellen()
dritte = os.path.join(ordner, "Rechnung_2026-003_Beispiel.pdf")
pruefe(os.path.exists(dritte), "berichtigte Rechnung 2026-003 geschrieben")
pruefe(f.storno_stand is None, "Zwischenspeicher nach dem Erstellen leer")
pruefe(str(f.nummer_entry.cget("state")) == "normal", "Nummernfeld wieder frei")
meta3 = BE.kenndaten_lesen(dritte)
pruefe(meta3.get("art") == "korrektur", "art = korrektur")
pruefe(meta3.get("ersetzt") == "2026-001", "ersetzt zeigt auf 2026-001")
pruefe(meta3.get("storno_nummer") == "2026-002", "storno_nummer zeigt auf 2026-002")
pruefe(os.path.exists(erste_pdf), "die urspruengliche Rechnung liegt unangetastet da")
gesamt3 = float(meta3["gesamt"])
print(f" Rechnung 003: gesamt {gesamt3:.2f}")
# ---- 4. was sieht der Steuerrechner? ------------------------------------
sys.path.insert(0, r"C:\claude\steuerrechner")
import einnahmen # noqa: E402
liste, kaputt = einnahmen.scanne(ordner, {"ust_saetze": [7, 19]})
pruefe(len(liste) == 3, f"Steuerrechner liest 3 Belege (liest {len(liste)})")
pruefe(not kaputt, f"kein Beleg uebersprungen ({kaputt})")
pruefe(not any(e.unsicher for e in liste),
"kein Beleg als unsicher markiert")
summe = round(sum(e.gesamt for e in liste), 2)
netto = round(sum(e.netto for e in liste), 2)
ust = round(sum(e.ust for e in liste), 2)
pruefe(abs(summe - gesamt3) < 0.005,
f"Jahressumme = nur die berichtigte Rechnung ({summe:.2f} vs {gesamt3:.2f})")
print(f" Summe {summe:.2f} | Netto {netto:.2f} | USt {ust:.2f}")
# ---- 5. Rueckweg: Storno anlegen und verwerfen --------------------------
antworten["Storno wirklich"] = False
offen = [d for d in BE.rechnungen_im_ordner(ordner)
if d.get("art") == "korrektur"]
f._storno_anlegen(offen[0], ordner)
storno2 = f.storno_stand["pfad"]
zaehler_mit_storno = f.cfg["rechnung_zaehler"]
pruefe(os.path.exists(storno2), "zweites Storno geschrieben")
f.anzahl_vars[erste].set("2")
f.update()
f.pdf_erstellen() # -> Frage wird mit Nein beantwortet
pruefe(not os.path.exists(storno2), "verworfenes Storno-PDF ist geloescht")
pruefe(f.storno_stand is None, "Zwischenspeicher geleert")
pruefe(f.cfg["rechnung_zaehler"] == zaehler_mit_storno - 1,
f"Zaehler zurueckgesetzt ({f.cfg['rechnung_zaehler']} statt {zaehler_mit_storno})")
pruefe(os.path.exists(dritte), "die Rechnung 2026-003 steht unveraendert da")
pruefe(len([n for n in os.listdir(ordner) if n.endswith('.pdf')]) == 3,
"es liegen wieder genau 3 PDFs im Ordner")
# ---- 6. Suche im Storno-Dialog (Nummer eintippen, nicht auswaehlen) -----
import tkinter as tk # noqa: E402
from tkinter import ttk # noqa: E402
f.dlg_storno()
f.update()
dialog = [w for w in f.winfo_children() if isinstance(w, tk.Toplevel)][-1]
feld = None
schild = None
for rahmen in dialog.winfo_children():
for w in rahmen.winfo_children():
if isinstance(w, ttk.Entry):
feld = w
elif isinstance(w, ttk.Label) and w.cget("textvariable"):
schild = w
pruefe(feld is not None and schild is not None, "Eingabefeld und Meldezeile im Dialog da")
def tippe(text):
feld.delete(0, "end")
feld.insert(0, text)
feld.event_generate("<KeyRelease>")
f.update()
return f.getvar(schild.cget("textvariable"))
meldung = tippe("2026-003")
pruefe("Der Storno lautet" in meldung, f"gueltige Nummer gefunden: {meldung.splitlines()[0]}")
meldung = tippe("2026-3")
pruefe("2026-003" in meldung, "kurze Schreibweise 2026-3 findet 2026-003")
meldung = tippe("2026-001")
pruefe("storniert" in meldung and "zweites Mal" in meldung,
f"schon stornierte Rechnung wird abgelehnt ({meldung})")
meldung = tippe("2026-002")
pruefe("selbst eine Stornorechnung" in meldung, "Storno selbst ist nicht stornierbar")
meldung = tippe("2026-999")
pruefe("keine Rechnung" in meldung, "unbekannte Nummer wird abgelehnt")
meldung = tippe("")
pruefe("Nummer eintragen" in meldung, "leeres Feld sagt was zu tun ist")
dialog.destroy()
# ---- 7. Pflichtfelder: ohne Anschrift wird nichts geschrieben -----------
vorher = sorted(os.listdir(ordner))
warnungen.clear()
f.k_strasse.set("")
f.k_plzort.set("")
f.anzahl_vars[erste].set("2")
f.update()
f.pdf_erstellen()
pruefe(warnungen and "Kundenangaben fehlen" in warnungen[0],
f"fehlende Anschrift wird gemeldet ({warnungen})")
pruefe(sorted(os.listdir(ordner)) == vorher, "ohne Anschrift wurde keine PDF geschrieben")
warnungen.clear()
f.k_vorname.set("")
f.k_strasse.set("Musterweg 1")
f.k_plzort.set("12345 Musterstadt")
f.update()
f.pdf_erstellen()
pruefe(sorted(os.listdir(ordner)) == vorher, "auch ohne Vornamen wird nichts geschrieben")
f.k_vorname.set("Anna")
f.update()
f.pdf_erstellen()
pruefe(len(os.listdir(ordner)) == len(vorher) + 1,
"mit allen Pflichtfeldern entsteht die PDF")
# ---- 8. Was zaehlt als "richtig ausgefuellt"? ---------------------------
def kunde_setzen(vor, nach, strasse, ort):
f.k_vorname.set(vor)
f.k_name.set(nach)
f.k_strasse.set(strasse)
f.k_plzort.set(ort)
return f.kunde_pruefen()
faelle_schlecht = [
(" ", "Meier", "Musterweg 1", "12345 Musterstadt", "Vorname nur Leerzeichen"),
("Anna", " ", "Musterweg 1", "12345 Musterstadt", "Nachname nur Leerzeichen"),
("Anna", "x", "Musterweg 1", "12345 Musterstadt", "Nachname ein Buchstabe"),
("Anna", "123", "Musterweg 1", "12345 Musterstadt", "Nachname nur Ziffern"),
("Anna", "Meier", "-", "12345 Musterstadt", "Strasse nur ein Strich"),
("Anna", "Meier", "Musterweg 1", "12345", "nur PLZ, kein Ort"),
("Anna", "Meier", "Musterweg 1", "Musterstadt", "nur Ort, keine PLZ"),
("Anna", "Meier", "Musterweg 1", " ", "Ort nur Leerzeichen"),
]
for vor, nach, strasse, ort, was in faelle_schlecht:
pruefe(bool(kunde_setzen(vor, nach, strasse, ort)), f"abgelehnt: {was}")
faelle_gut = [
("Anna", "Meier", "Musterweg 1", "12345 Musterstadt", "normaler Fall"),
("Li", "Ng", "Hauptstr. 2", "04416 Markkleeberg", "kurze Namen"),
("Jan", "de Vries", "Kerkstraat 12", "1234 AB Amsterdam", "Niederlande, 4 Ziffern"),
("Hans", "Huber", "Seeweg 3", "5020 Salzburg", "Oesterreich, 4 Ziffern"),
("Anna", "Camping Nord GmbH", "Am Deich 7a", "12345 Musterstadt", "Firma"),
]
for vor, nach, strasse, ort, was in faelle_gut:
m = kunde_setzen(vor, nach, strasse, ort)
pruefe(not m, f"angenommen: {was} {m if m else ''}")
# Saeubern: Rand- und Mehrfachleerzeichen verschwinden aus den Feldern
kunde_setzen(" Anna ", " von Meier ", " Musterweg 1 ", " 12345 Musterstadt ")
pruefe(f.k_vorname.get() == "Anna", f"Vorname getrimmt ({f.k_vorname.get()!r})")
pruefe(f.k_name.get() == "von Meier", f"Doppelleerzeichen weg ({f.k_name.get()!r})")
pruefe(f.k_plzort.get() == "12345 Musterstadt", f"Ort gesaeubert ({f.k_plzort.get()!r})")
# ---- 9. Strasse ohne Hausnummer: fragen, nicht sperren ------------------
kunde_setzen("Anna", "Beispiel", "Am Seeufer", "12345 Musterstadt")
pruefe(not f.kunde_pruefen(), "Anschrift ohne Hausnummer ist kein Mangel")
vorher = sorted(os.listdir(ordner))
gefragt.clear()
antworten["Hausnummer"] = False # Nutzer sagt: nein, doch nicht
f.anzahl_vars[erste].set("2")
f.update()
f.pdf_erstellen()
pruefe(any("Hausnummer" in t for t in gefragt), f"nach der Hausnummer wird gefragt ({gefragt})")
pruefe(sorted(os.listdir(ordner)) == vorher, "bei Nein wird nichts geschrieben")
antworten["Hausnummer"] = True # Nutzer sagt: ja, so ist es richtig
f.pdf_erstellen()
pruefe(len(os.listdir(ordner)) == len(vorher) + 1, "bei Ja entsteht die PDF trotzdem")
# ---- 10. Altrechnung im Muster "alles in der Vornamen-Zeile" -----------
import berechnung as B # noqa: E402
import pdf_renderer as P # noqa: E402
def alt_rechnung(name, dateiname, kunde_zeilen):
"""Schreibt eine Rechnung wie die alte Fassung: ohne kunde/positionen in den Kenndaten."""
rr = B.berechne(f.cfg["katalog"], {erste: {"anzahl": 3, "personen": 0}}, f.cfg)
# Nummer aus dem Dateinamen, damit Kenndaten und Datei zusammenpassen
nummer = dateiname.split("_")[1].replace(".pdf", "")
kopf = {"rechnungsnummer": nummer, "datum": "12.08.2026", "anreise": "", "abreise": "",
"naechte": "", "kunde_zeilen": kunde_zeilen, "nachname": "", "vorname": name}
ziel = os.path.join(ordner, dateiname)
P.render_rechnung(ziel, f.cfg, rr, kopf)
return ziel
# a) mit Anschrift im Text, Name komplett in der Vornamen-Zeile
alt = alt_rechnung("Andres Schäfer", "Rechnung_2026-900.pdf",
["Herr", "Andres Schäfer", "c/o Stellplatz 12",
"Bornaer Chaussee 36", "04416 Markkleeberg"])
meta_alt = BE.kenndaten_lesen(alt)
pruefe(not meta_alt.get("kunde"), "Altrechnung hat keine Kundendaten in den Kenndaten")
kunde, woher = BE.kundendaten_ermitteln(meta_alt)
pruefe(kunde["vorname"] == "Andres", f"Vorname aufgeteilt ({kunde['vorname']!r})")
pruefe(kunde["nachname"] == "Schäfer", f"Nachname aufgeteilt ({kunde['nachname']!r})")
pruefe(kunde.get("anrede") == "Herr", f"Anrede aus dem Text ({kunde.get('anrede')!r})")
pruefe(kunde.get("strasse") == "Bornaer Chaussee 36", f"Straße aus dem Text ({kunde.get('strasse')!r})")
pruefe(kunde.get("plzort") == "04416 Markkleeberg", f"PLZ/Ort aus dem Text ({kunde.get('plzort')!r})")
pruefe(kunde.get("zusatz") == "c/o Stellplatz 12", f"Zusatz aus dem Text ({kunde.get('zusatz')!r})")
# b) sein echter Fall: NUR der Name, keine Anschrift
alt2 = alt_rechnung("Andres Schäfer", "Rechnung_2026-901.pdf", ["", "Andres Schäfer", "", "", ""])
kunde2, _ = BE.kundendaten_ermitteln(BE.kenndaten_lesen(alt2))
pruefe(kunde2["nachname"] == "Schäfer", "auch ohne Anschrift wird der Name aufgeteilt")
pruefe(not kunde2.get("plzort"), "keine Anschrift erfunden")
# c) Formular fuellen: was fehlt, wird benannt
meldung = f._formular_aus_meta(BE.kenndaten_lesen(alt2))
pruefe(f.k_vorname.get() == "Andres" and f.k_name.get() == "Schäfer",
"Formular bekommt Vor- und Nachnamen getrennt")
pruefe("Straße" in meldung and "PLZ" in meldung and "Nachname vom Vornamen" in meldung, f"fehlende Anschrift wird benannt ({meldung})")
meldung = f._formular_aus_meta(BE.kenndaten_lesen(alt))
pruefe(f.k_plzort.get() == "04416 Markkleeberg", "Anschrift landet im Formular")
pruefe("aus dem Rechnungstext gelesen" in meldung, f"Herkunft wird gesagt ({meldung})")
for name in ("Rechnung_2026-900.pdf", "Rechnung_2026-901.pdf"):
os.remove(os.path.join(ordner, name))
# ---- 11. Berichtigung: gleicher Dialog, zweiter Knopf -------------------
alt3 = alt_rechnung("Andres Schäfer", "Rechnung_2026-910.pdf", ["", "Andres Schäfer", "", "", ""])
meta3 = BE.kenndaten_lesen(alt3)
vorher = sorted(os.listdir(ordner))
zaehler_vor_berichtigung = f.cfg["rechnung_zaehler"]
f._berichtigung_starten(meta3, ordner)
pruefe(f.berichtigung_stand is not None, "Flag fuer die Berichtigung gesetzt")
pruefe(f.r_nummer.get() == "2026-910", f"Rechnung behaelt ihre Nummer ({f.r_nummer.get()})")
pruefe(str(f.nummer_entry.cget("state")) == "readonly", "Nummernfeld gesperrt")
pruefe(f.k_vorname.get() == "Andres" and f.k_name.get() == "Schäfer",
"Name aufgeteilt im Formular")
# ohne Anschrift wird auch hier nichts geschrieben
warnungen.clear()
f.anzahl_vars[erste].set("3")
f.update()
f.pdf_erstellen()
pruefe(warnungen and "Kundenangaben" in warnungen[0], "Pflichtpruefung gilt auch hier")
pruefe(sorted(os.listdir(ordner)) == vorher, "nichts geschrieben")
# Anschrift ergaenzen und erstellen
f.k_strasse.set("Bornaer Chaussee 36")
f.k_plzort.set("04416 Markkleeberg")
antworten["Berichtigte Rechnung erstellen"] = True
f.update()
f.pdf_erstellen()
import datetime as _dt
ziel = os.path.join(ordner, f"Berichtigte Rechnung_{_dt.date.today():%Y-%m-%d}_2026-910.pdf")
pruefe(os.path.exists(ziel), f"Blatt geschrieben ({os.path.basename(ziel)})")
pruefe(f.berichtigung_stand is None, "Flag nach dem Erstellen wieder weg")
pruefe(str(f.nummer_entry.cget("state")) == "normal", "Nummernfeld wieder frei")
pruefe(os.path.exists(alt3), "die berichtigte Rechnung liegt unangetastet da")
pruefe(f.cfg["rechnung_zaehler"] == zaehler_vor_berichtigung,
f"Zaehler unveraendert - die Berichtigung verbraucht keine Nummer "
f"({f.cfg['rechnung_zaehler']} vs {zaehler_vor_berichtigung})")
meta_b = BE.kenndaten_lesen(ziel)
pruefe(meta_b.get("art") == "berichtigung", "art = berichtigung")
pruefe(meta_b.get("berichtigt_zu") == "2026-910", "Bezug auf die Rechnung")
pruefe(float(meta_b.get("gesamt") or 0) == 0.0,
f"Kenndaten tragen 0 - keine zweite Einnahme ({meta_b.get('gesamt')})")
from pypdf import PdfReader as _PR # noqa: E402
text = _PR(ziel).pages[0].extract_text()
pruefe("Berichtigte Rechnung" in text, "Ueberschrift steht auf dem Blatt")
pruefe("2026-910" in text, "Rechnungsnummer steht drauf")
pruefe("31 Abs. 5 UStDV" in text and "zus" in text and "tzliche Leistung" in text,
"der Schutzsatz steht drauf (keine zusaetzliche Leistung, § 31 Abs. 5)")
pruefe("04416 Markkleeberg" in text, "berichtigte Anschrift steht drauf")
liste3, kaputt3 = einnahmen.scanne(ordner, {"ust_saetze": [7, 19]})
pruefe(not any("Berichtigte" in os.path.basename(e.pdf_pfad) for e in liste3),
"Steuerrechner zaehlt das Blatt nicht als Einnahme")
pruefe(not any("Berichtigte" in os.path.basename(p) for p, _ in kaputt3),
f"und meldet es nicht als Fehler ({kaputt3})")
for name in ("Rechnung_2026-910.pdf", os.path.basename(ziel)):
os.remove(os.path.join(ordner, name))
f.destroy()
# Wegwerf-Ordner nur bei Fehlern behalten - sonst laeuft das Temp-Verzeichnis
# voll. Ein Pruefstand, der 400 Ordner hinterlaesst, ist selbst ein Problem.
if not fehler:
import shutil as _sh
_sh.rmtree(tmp, ignore_errors=True)
print("---")
print(f"Ordner: {ordner}")
print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")
sys.exit(1 if fehler else 0)