Steuerrechnungstool/pruef_doppelte.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

207 lines
8.6 KiB
Python

# -*- coding: utf-8 -*-
"""Pruefstand: zeigt das Steuerjournal doppelt vergebene Rechnungsnummern an?
Seit dem 03.09.2026 ist der Schluessel der Tabelle die PDF-Datei und nicht mehr
die Rechnungsnummer (siehe db._alte_nummern_sperre_loesen). Damit bleiben zwei
Rechnungen mit derselben Nummer beide stehen - was sie stillschweigend tun
wuerden, waere die Uebernachtung doppelt in der Meldung ans Amt. Genau deshalb
muss die Oberflaeche den Fund ZEIGEN.
Die Falle, die ein Test je Monat nicht faengt: dieselbe Nummer in ZWEI Monaten.
Kein Monat sieht fuer sich eine Dublette, das Jahr aber schon - deshalb wird
ueber das ganze Jahr gerechnet.
Geprueft wird am echten Fenster (App + MonatsTab), nicht nur am Rechenkern:
"rot faerben" ist eine Behauptung ueber die Anzeige.
"""
import os
import shutil
import sys
import tempfile
import threading
from datetime import date
HIER = r"C:\claude\beherbergungssteuer"
sys.path.insert(0, HIER)
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
os._exit(2)
_wecker = threading.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
# ---- eigener Datenordner, damit das echte Journal unberuehrt bleibt --------
tmp = tempfile.mkdtemp(prefix="bst_doppelt_")
import ablage # noqa: E402
ablage.daten_ordner = lambda: tmp
import app as app_mod # noqa: E402 (liest ablage.daten_ordner beim Import)
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 modell import Buchung, doppelte_nummern, summiere # noqa: E402
from pruefhelfer import fehler, pruefe
pruefe(app_mod.BASE_DIR == tmp, f"Pruefstand laeuft im Testordner ({app_mod.BASE_DIR})")
# ---- Buchungen: der Fall, den das Amt nicht sehen darf ---------------------
JAHR = 2026
BUCHUNGEN = [
# zwei verschiedene Gaeste unter EINER Nummer, im selben Monat
Buchung(date(JAHR, 8, 1), "020", JAHR, "Erst", 2, 100.0, quelle="manuell"),
Buchung(date(JAHR, 8, 5), "020", JAHR, "Zweit", 4, 200.0, quelle="manuell"),
# dieselbe Nummer, aber ueber die Monatsgrenze hinweg - die eigentliche Falle
Buchung(date(JAHR, 8, 20), "030", JAHR, "August", 1, 50.0, quelle="manuell"),
Buchung(date(JAHR, 9, 2), "030", JAHR, "September", 3, 150.0, quelle="manuell"),
# und eine saubere Rechnung, die nicht angefasst werden darf
Buchung(date(JAHR, 8, 9), "021", JAHR, "Sauber", 2, 80.0, quelle="manuell"),
]
fenster = app_mod.App()
fenster.withdraw() # kein Fenster ins Gesicht, gerechnet wird trotzdem
for b in BUCHUNGEN:
fenster.journal.speichern(b)
fenster.jahr.set(JAHR)
fenster.aktualisieren()
fenster.update_idletasks()
alle = fenster.journal.buchungen(jahr=JAHR)
pruefe(len(alle) == len(BUCHUNGEN), f"alle {len(BUCHUNGEN)} Buchungen stehen im Journal ({len(alle)})")
# ---- 1. Rechenkern ---------------------------------------------------------
d = doppelte_nummern(alle)
pruefe(set(d) == {"2026-020", "2026-030"}, f"genau die zwei Dubletten gefunden ({sorted(d)})")
pruefe(all(len(v) == 2 for v in d.values()), "je zwei Belege pro Nummer")
pruefe("2026-021" not in d, "die saubere Rechnung wird nicht gemeldet")
pruefe(not doppelte_nummern([b for b in alle if b.rechnungsnummer == "021"]),
"ohne Dubletten bleibt die Meldung leer")
# ---- 2. Die Betraege bleiben vollstaendig ---------------------------------
s = summiere(alle)
pruefe(abs(s.gezahlt - 580.0) < 0.005, f"Jahressumme unveraendert 580,00 ({s.gezahlt})")
pruefe(abs(s.steuer - 29.0) < 0.005, f"Steuer unveraendert 29,00 ({s.steuer})")
def zeilen(monat):
"""Was im Monatsblatt WIRKLICH steht: (Nachname, Nummerspalte, Tags).
Die Spalten kommen aus gui_monat.SPALTEN - nicht abgezaehlt, sonst
verrutscht der Pruefstand still, sobald eine Spalte dazukommt (genau das
ist beim Einbau der Spalte "Art" passiert).
"""
from gui_monat import SPALTEN
namen = [sp[0] for sp in SPALTEN]
i_name, i_nr = namen.index("nachname"), namen.index("nummer")
baum = fenster.monate[monat].baum
aus = []
for iid in baum.get_children():
w = baum.item(iid, "values")
aus.append((w[i_name], w[i_nr], tuple(baum.item(iid, "tags"))))
return aus
# ---- 3. Anzeige: rot + Warnzeichen ----------------------------------------
aug = zeilen(8)
pruefe(len(aug) == 4, f"August zeigt vier Zeilen ({len(aug)})")
rot_aug = {name for name, _nr, tags in aug if "doppelt" in tags}
pruefe(rot_aug == {"Erst", "Zweit", "August"}, f"im August sind die richtigen rot ({sorted(rot_aug)})")
pruefe(all(nr.startswith("\u26a0") for name, nr, tags in aug if "doppelt" in tags),
"die roten Zeilen tragen das Warnzeichen vor der Nummer")
sauber = [z for z in aug if z[0] == "Sauber"][0]
pruefe("doppelt" not in sauber[2] and not sauber[1].startswith("\u26a0"),
f"die saubere Rechnung bleibt schwarz und ohne Zeichen ({sauber})")
# ---- 4. Die Falle: dieselbe Nummer in zwei Monaten ------------------------
sep = zeilen(9)
pruefe(len(sep) == 1 and "doppelt" in sep[0][2],
f"September markiert seine Haelfte der Dublette ({sep})")
titel = [fenster.blaetter.tab(m - 1, "text") for m in range(1, 13)]
pruefe(titel[7].startswith("\u26a0") and titel[8].startswith("\u26a0"),
f"beide Reiter sind markiert ({titel[7]!r}, {titel[8]!r})")
pruefe(not any(t.startswith("\u26a0") for i, t in enumerate(titel) if i not in (7, 8)),
f"kein anderer Reiter ist markiert ({[t for t in titel if t.startswith(chr(0x26a0))]})")
# ---- 5. Die Warnleiste --------------------------------------------------
pruefe(bool(fenster.doppelt_leiste.winfo_manager()), "die Warnleiste ist eingeblendet")
text = fenster.warnung.cget("text")
pruefe("2026-020" in text and "2026-030" in text, f"sie nennt beide Nummern ({text[:70]}…)")
pruefe("Erst" in text and "Zweit" in text, "und die betroffenen Gaeste")
# ---- 6. Die Suche darf die Dublette nicht verstecken ---------------------
fenster.suche.set("Zweit")
fenster.update_idletasks()
gefiltert = zeilen(8)
pruefe(len(gefiltert) == 1 and "doppelt" in gefiltert[0][2],
f"auch im Suchtreffer bleibt die Zeile rot ({gefiltert})")
fenster.suche.set("")
fenster.update_idletasks()
# ---- 7. Das Fenster mit der Liste ---------------------------------------
vorher = set(fenster.winfo_children())
fenster.doppelte_zeigen()
fenster.update_idletasks()
neu = [w for w in fenster.winfo_children() if w not in vorher]
pruefe(len(neu) == 1, f"die Liste geht auf ({len(neu)} Fenster)")
if neu:
inhalt = ""
def sammeln(w):
global inhalt
import tkinter as tk
if isinstance(w, tk.Text):
inhalt += w.get("1.0", "end")
for k in w.winfo_children():
sammeln(k)
sammeln(neu[0])
pruefe("2026-020" in inhalt and "2026-030" in inhalt, "sie listet beide Nummern")
pruefe(all(n in inhalt for n in ("Erst", "Zweit", "August", "September")),
"mit allen vier Belegen")
pruefe("Sauber" not in inhalt, "und nicht die saubere Rechnung")
neu[0].destroy()
# ---- 8. Dublette aufloesen -> die Warnung muss verschwinden -------------
weg = [b for b in alle if b.nachname == "Zweit"][0]
fenster.journal.loeschen(weg.id)
weg2 = [b for b in alle if b.nachname == "September"][0]
fenster.journal.loeschen(weg2.id)
fenster.aktualisieren()
fenster.update_idletasks()
pruefe(not fenster._doppelte, f"keine Dubletten mehr ({sorted(fenster._doppelte)})")
pruefe(not fenster.doppelt_leiste.winfo_manager(), "die Warnleiste ist wieder weg")
pruefe(not any("doppelt" in t for _n, _nr, t in zeilen(8)), "keine rote Zeile mehr im August")
titel = [fenster.blaetter.tab(m - 1, "text") for m in range(1, 13)]
pruefe(not any(t.startswith("\u26a0") for t in titel), "und kein markierter Reiter mehr")
fenster.journal.schliessen()
fenster.destroy()
if not fehler:
shutil.rmtree(tmp, ignore_errors=True)
else:
print(f" (Testordner bleibt zum Nachsehen: {tmp})")
print("---")
print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")
sys.exit(1 if fehler else 0)