Steuerrechnungstool/pruef_storno_verrechnet.py
TheMockTv 96671027c8 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_storno_verrechnet.py zaehlte in
  einer Zahl und konnte am Ende nicht sagen, WAS fehlschlug, pruef_bilder.py
  im Rechnungstool schrieb ein eigenes Format.

* 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.

* Die stummen "except OSError" in api.py, updater.py und einzelinstanz.py
  sagen jetzt, warum sie schweigen duerfen. Die 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

210 lines
10 KiB
Python

"""Pruefstand: ein Storno wird VERRECHNET, nicht weggelassen.
Entscheidung des Auftraggebers vom 04.09.2026, nachdem beide Wege
durchgerechnet und die Satzung nachgelesen waren:
"ich wuerde das wie beim alten lassen, nicht das er der Stadt zu wenig
gibt, auch wenn er das so der Steuerberaterin gibt"
Heisst: die aufgehobene Rechnung bleibt mit ihrem PLUS im Monat der Rechnung
stehen, der Storno steht mit seinem MINUS im Monat des Storno-Belegs. Uebers
Jahr hebt sich das auf. Der andere Weg (beide Zeilen gar nicht zaehlen) war
zwischenzeitlich gebaut und ist bewusst wieder raus - siehe modell.summiere.
⚠️ Dieser Pruefstand haelt genau das fest, damit es niemand (auch ich nicht)
"repariert": in einem Monat sieht eine verrechnete Doppelung nach einem Fehler
aus. Sie ist keiner, sie ist gewollt.
Gerechnet wird auf einer ECHTEN SQLite ("musst in der sqlite fake rechnungen
anlegen"), nicht auf zusammengesteckten Objekten. Das echte Journal wird nicht
angefasst - jeder Lauf legt eine eigene Datei in %TEMP% an.
Aufruf: python -u pruef_storno_verrechnet.py
"""
import os
import sys
import tempfile
import threading
from datetime import date
HIER = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HIER)
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
os._exit(2)
_wecker = threading.Timer(180, _reissleine)
_wecker.daemon = True
_wecker.start()
# ---- eigener Datenordner, damit das echte Journal unberuehrt bleibt --------
tmp = tempfile.mkdtemp(prefix="bst_storno_")
import ablage # noqa: E402
ablage.daten_ordner = lambda: tmp
from modell import Buchung, summiere, aufgehobene # noqa: E402
import bericht_pdf # noqa: E402
import firma as firma_lib # noqa: E402
from pypdf import PdfReader # noqa: E402
import app as app_mod # noqa: E402
from tkinter import messagebox # noqa: E402
# Kein echtes Fenster im Pruefstand: sonst steht der Lauf vor einem Dialog, den
# niemand anklickt. Der Erprobungshinweis wird hier bejaht - geprueft wird er
# eigens in pruef_hinweis.py des Rechnungstools. Eine UNBEKANNTE Frage bricht
# ab, statt still 'ja' zu sagen: sonst uebergeht der Lauf genau die Faelle,
# wegen denen es die Pruefung gibt.
_antworten = {"Erprobungsfassung": True, "jetzt erstellen?": True,
"Fertig": False, "Hausnummer": True}
def _askyesno(titel, text="", **kw):
for schluessel, wert in _antworten.items():
if schluessel in str(titel):
return wert
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
messagebox.askyesno = _askyesno
messagebox.showinfo = lambda *a, **k: None
messagebox.showwarning = lambda *a, **k: None
from gui_monat import SPALTEN # noqa: E402
from pruefhelfer import fehler, pruefe
pruefe(app_mod.BASE_DIR == tmp, f"Pruefstand laeuft im Testordner ({app_mod.BASE_DIR})")
# ---- Fake-Rechnungen in die SQLite ----------------------------------------
# Fall A (August): Rechnung 4 Naechte -> Storno -> Neuausstellung 3 Naechte
# Fall B (Aug/Sep): Rechnung im August, Storno erst im September
# dazu eine saubere Rechnung, die nichts angeht
JAHR = 2026
FAKE = [
Buchung(date(JAHR, 8, 22), "900", JAHR, "Muster", 4, 52.0, quelle="manuell"),
Buchung(date(JAHR, 8, 23), "901", JAHR, "Muster", -4, -52.0, quelle="manuell",
art="storno", storno_zu="2026-900", storno_datum="22.08.2026",
folge_nummer="2026-902", folge_art="neu"),
Buchung(date(JAHR, 8, 23), "902", JAHR, "Muster", 3, 39.0, quelle="manuell",
art="korrektur"),
Buchung(date(JAHR, 8, 22), "910", JAHR, "Spaet", 4, 52.0, quelle="manuell"),
Buchung(date(JAHR, 9, 2), "911", JAHR, "Spaet", -4, -52.0, quelle="manuell",
art="storno", storno_zu="2026-910", storno_datum="22.08.2026",
folge_nummer="2026-905", folge_art="vorhanden"),
Buchung(date(JAHR, 8, 5), "905", JAHR, "Sauber", 2, 26.0, quelle="manuell"),
]
fenster = app_mod.App()
fenster.withdraw() # kein Fenster ins Gesicht, gerechnet wird trotzdem
for b in FAKE:
fenster.journal.speichern(b)
fenster.jahr.set(JAHR)
fenster.aktualisieren()
fenster.update_idletasks()
alle = fenster.journal.buchungen(jahr=JAHR)
pruefe(len(alle) == 6, f"sechs Fake-Rechnungen stehen in der SQLite ({len(alle)})")
stornos = [b for b in alle if b.storno_zu]
pruefe(len(stornos) == 2 and all(b.art == "storno" for b in stornos),
"die Storno-Spalten haben den Weg durch die DB ueberstanden "
f"({[(b.voll_nummer, b.art, b.storno_zu) for b in stornos]})")
# ---------------------------------------------------------------------------
print("--- Fall 1: Storno im selben Monat wie seine Rechnung ---")
fall_a = [b for b in alle if b.nachname == "Muster"]
s = summiere(fall_a)
pruefe(s.naechte == 3, f"3 Uebernachtungen: 4 - 4 + 3 (ist {s.naechte})")
pruefe(abs(s.gezahlt - 39.0) < 0.005, f"39,00 Euro: 52 - 52 + 39 (ist {s.gezahlt})")
pruefe(abs(s.steuer - 1.95) < 0.005, f"1,95 Euro Steuer (ist {s.steuer})")
pruefe(s.buchungen == 3,
f"gezaehlt werden alle drei Belege - Rechnung, Storno, Neuausstellung "
f"(ist {s.buchungen})")
# ---------------------------------------------------------------------------
print("--- Fall 2: Rechnung im August, Storno erst im September ---")
aug = summiere([b for b in alle if b.monat == 8])
sep = summiere([b for b in alle if b.monat == 9])
pruefe(aug.naechte == 9 and abs(aug.gezahlt - 117.0) < 0.005,
f"der August meldet die Doppelung MIT: 9 Naechte, 117,00 Euro "
f"(ist {aug.naechte}, {aug.gezahlt})")
pruefe(sep.naechte == -4 and abs(sep.gezahlt + 52.0) < 0.005,
f"der September zieht sie ab: -4 Naechte, -52,00 Euro "
f"(ist {sep.naechte}, {sep.gezahlt})")
jahr = summiere(alle)
pruefe(jahr.naechte == 5 and abs(jahr.gezahlt - 65.0) < 0.005,
f"uebers JAHR hebt es sich auf: 5 Naechte, 65,00 Euro "
f"(ist {jahr.naechte}, {jahr.gezahlt})")
pruefe(abs(aug.steuer + sep.steuer - jahr.steuer) < 0.005,
"und die Steuer der Monate ergibt die Steuer des Jahres")
# Die Zuordnung wird weiter gebraucht - fuer die Spalte "Art" und dafuer, dass
# eine stornierte Rechnung nicht mehr als doppelte Nummer gemeldet wird.
weg = aufgehobene(alle)
pruefe({b.voll_nummer for b in alle if b.id in weg} == {"2026-900", "2026-910"},
"aufgehoben sind genau die beiden stornierten Rechnungen "
f"({sorted(b.voll_nummer for b in alle if b.id in weg)})")
# ---------------------------------------------------------------------------
print("--- Fall 3: Amtsbericht aus der Datenbank ---")
pfad = os.path.join(tmp, "August.pdf")
bericht_pdf.render_monat(pfad, firma_lib.lade(), JAHR, 8, alle)
# Der Text wird im PDF umbrochen ("storniert mit" / "2026-901") - Leerraum
# also vereinheitlichen, sonst prueft man den Zeilenumbruch statt den Inhalt.
text = " ".join((PdfReader(pfad).pages[0].extract_text() or "").split())
pruefe("2026-900" in text and "2026-901" in text,
"Rechnung UND Storno stehen im Bericht - die Meldung erklaert sich selbst")
pruefe("-52,00" in text, "der Storno steht mit seinem Minusbetrag drin")
pruefe("117,00" in text, "und die Monatssumme ist 117,00 Euro")
# Ohne Beschriftung steht im Bericht eine Stornonummer neben einer voellig
# anderen Rechnungsnummer - sein Wort: "musst dann rein schreiben storniert".
pruefe("storniert mit 2026-901" in text,
"die aufgehobene Rechnung nennt den Storno, der sie aufhebt")
pruefe("storniert mit 2026-911" in text,
"auch dann, wenn dieser Storno in einem ANDEREN Monat liegt")
pruefe("neue Rechnung 2026-902" in text,
"der Storno zeigt VORWAERTS auf die Rechnung, die nun gilt")
# Der zweite Storno steht im SEPTEMBER - also dort nachsehen, nicht im August.
pfad_sep = os.path.join(tmp, "September.pdf")
bericht_pdf.render_monat(pfad_sep, firma_lib.lade(), JAHR, 9, alle)
text_sep = " ".join((PdfReader(pfad_sep).pages[0].extract_text() or "").split())
pruefe("abgerechnet mit 2026-905" in text_sep,
"und beim Doppelungsfall zeigt er auf die Rechnung, die bleibt")
pruefe("-52,00" in text_sep, "der September traegt weiter seinen Minusbetrag")
# Die Buchstaben-Marke: ohne sie muss man aus der Begruendung raten, was fuer ein
# Beleg die Zeile ist ("du musst die markieren als rechnung und storno").
pruefe("R = Rechnung" in text and "S = Stornorechnung" in text,
"unter der Tabelle steht die Erklaerung der Buchstaben")
pruefe("Neuausstellung" in text, "die neue Rechnung ist als Neuausstellung beschriftet")
# ---------------------------------------------------------------------------
print("--- Fall 4: das echte Fenster ---")
namen = [sp[0] for sp in SPALTEN]
i_nr, i_art = namen.index("nummer"), namen.index("art")
baum = fenster.monate[8].baum
reihen = [(baum.item(i, "values")[i_nr], baum.item(i, "values")[i_art],
tuple(baum.item(i, "tags"))) for i in baum.get_children()]
# 2026-900 (storniert), -901 (Storno), -902 (Neuausstellung), -905, -910 (storniert)
pruefe(len(reihen) == 5, f"der August zeigt alle fuenf Zeilen ({len(reihen)})")
pruefe(not any("aufgehoben" in t for _nr, _a, t in reihen),
f"keine Zeile ist ausgegraut ({sorted({t for _n, _a, t in reihen})})")
arten = {nr: art for nr, art, _t in reihen}
pruefe(arten.get("2026-901") == "neue Rechnung 2026-902",
f"der Storno zeigt auf die neue Rechnung ({arten.get('2026-901')!r})")
pruefe(arten.get("2026-900") == "storniert mit 2026-901",
f"die aufgehobene Rechnung nennt ihren Storno ({arten.get('2026-900')!r})")
pruefe(arten.get("2026-902") == "Neuausstellung",
f"und die neue als Neuausstellung ({arten.get('2026-902')!r})")
leiste = fenster.monate[8].summen.werte
pruefe(leiste["naechte"].cget("text") == "9",
f"die Monatsleiste zeigt die verrechneten 9 Naechte "
f"({leiste['naechte'].cget('text')})")
fenster.destroy()
_wecker.cancel()
print("---")
print("alles gruen" if not fehler else f"{len(fehler)} FEHLER")
sys.exit(1 if fehler else 0)