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
210 lines
10 KiB
Python
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)
|