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

250 lines
10 KiB
Python

# -*- coding: utf-8 -*-
"""Pruefstand ueber BEIDE Programme: was das Rechnungstool storniert und
berichtigt, muss im Steuerjournal ankommen.
Der Weg ist echt: das Rechnungstool schreibt die PDFs (Rechnung, Storno,
berichtigte Rechnung, Berichtigungsblatt), danach liest das Journal denselben
Ordner ein. Geprueft wird, was am Ende ans Amt gemeldet wuerde.
Zwei Faelle, beide am 03.09.2026 an echten Belegen gefunden:
1. Der Storno kuerzte die BETRAEGE, aber nicht die UEBERNACHTUNGEN - aus
4 - 0 + 3 wurden 7 statt 3 gemeldeter Naechte.
2. Das Berichtigungsblatt wurde nur uebersprungen. Der berichtigte NAME kam
nie im Journal an, im Amtsbericht stand weiter der falsche.
"""
import json
import os
import shutil
import sys
import tempfile
import threading
TOOL = r"C:\claude\rechnungstool"
JOURNAL = r"C:\claude\beherbergungssteuer"
sys.path.insert(0, TOOL)
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
os._exit(2)
_wecker = threading.Timer(150, _reissleine)
_wecker.daemon = True
_wecker.start()
tmp = tempfile.mkdtemp(prefix="storno_journal_")
ordner = os.path.join(tmp, "rechnungen")
os.makedirs(ordner)
import config # noqa: E402
shutil.copy(os.path.join(TOOL, "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, 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
from pruefhelfer import fehler, pruefe
# Erprobungshinweis und Freigabe vor dem Beleg werden hier bejaht - geprueft
# werden sie eigens in pruef_hinweis.py und pruef_freigabe.py des Rechnungstools.
antworten = {"Fertig": False, "endgültig erstellen": True, "Hausnummer": True,
"Storno wirklich": True, "Berichtigte Rechnung erstellen": True,
"Berichtigung": True, "Storno erstellen": True}
antworten["Erprobungsfassung"] = True
antworten["jetzt erstellen?"] = True
def askyesno(titel, text, **kw):
for schlue, wert in antworten.items():
if schlue in titel:
return wert
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
messagebox.askyesno = askyesno
messagebox.showinfo = lambda *a, **k: None
messagebox.showwarning = lambda *a, **k: print(" [warnung]", a[0] if a else "")
messagebox.showerror = lambda *a, **k: print(" [fehler]", a[0] if a else "")
f = A.RechnungsApp()
f.cfg["output_dir"] = ordner
def kunde(vorname, nachname):
f.k_vorname.set(vorname)
f.k_name.set(nachname)
f.k_strasse.set("Musterweg 1")
f.k_plzort.set("12345 Musterstadt")
# ---- Fall 1: Rechnung -> Storno -> berichtigte Rechnung -------------------
kunde("Anna", "Beispiel")
f.r_anreise.set("01.08.2026")
f.r_abreise.set("05.08.2026") # 4 Naechte
erste_pos = sorted(f.anzahl_vars)[0]
f.anzahl_vars[erste_pos].set("4")
f.update()
f.pdf_erstellen()
offen = [d for d in BE.rechnungen_im_ordner(ordner) if d.get("art") != "storno"]
pruefe(len(offen) == 1, "eine stornierbare Rechnung")
f._storno_anlegen(offen[0], ordner)
storno_pdf = f.storno_stand["pfad"] if f.storno_stand else ""
m_storno = BE.kenndaten_lesen(storno_pdf) or {}
pruefe(str(m_storno.get("naechte")) == "-4",
f"der Storno traegt -4 Naechte in den Kenndaten ({m_storno.get('naechte')!r})")
f.r_abreise.set("04.08.2026") # richtig waren 3 Naechte
f.anzahl_vars[erste_pos].set("3")
f.update()
f.pdf_erstellen()
# ---- Fall 2: Rechnung mit falschem Namen -> Berichtigungsblatt ------------
f.reset_formular()
kunde("Bernd", "Falsch")
f.r_anreise.set("10.08.2026")
f.r_abreise.set("12.08.2026") # 2 Naechte
f.anzahl_vars[erste_pos].set("2")
f.update()
f.pdf_erstellen()
zu_berichtigen = [d for d in BE.rechnungen_im_ordner(ordner)
if d.get("nachname") == "Falsch"]
pruefe(len(zu_berichtigen) == 1, "die Rechnung mit dem falschen Namen ist da")
f._berichtigung_starten(zu_berichtigen[0], ordner)
f.k_name.set("Richtig") # der Gast heisst anders
f.update()
f.pdf_erstellen()
blaetter = [d for d in os.listdir(ordner) if d.startswith("Berichtigte")]
pruefe(len(blaetter) == 1, f"das Berichtigungsblatt liegt im Ordner ({blaetter})")
f.destroy()
print("--- was im Ordner liegt ---")
for d in sorted(os.listdir(ordner)):
m = BE.kenndaten_lesen(os.path.join(ordner, d)) or {}
print(f" {d:44s} art={str(m.get('art')):12s} nr={m.get('rechnungsnummer')} "
f"naechte={str(m.get('naechte')):>4} summe={m.get('zwischensumme')}")
# ---- Das Journal liest denselben Ordner ----------------------------------
sys.path.append(JOURNAL)
import db as BST # noqa: E402
from modell import summiere # noqa: E402
journal = BST.Journal(os.path.join(tmp, "journal.sqlite3"))
journal.scanne(ordner, 5.0, voll=True)
buchungen = journal.buchungen(jahr=2026)
print("--- was im Journal steht ---")
for b in buchungen:
print(f" {b.voll_nummer} {b.nachname:10s} {b.naechte:>3} Nächte "
f"{b.gezahlt:>8.2f} € Steuer {b.steuer:>6.2f} €")
s = summiere(buchungen)
# Fall 1: 4 - 4 + 3 = 3 Naechte, 52,00 - 52,00 + 39,00 = 39,00
# Fall 2: 2 Naechte, 26,00
pruefe(s.naechte == 5,
f"gemeldet werden 5 Übernachtungen (3 + 2), nicht 9 (ist {s.naechte})")
pruefe(abs(s.gezahlt - 65.0) < 0.005, f"Summe 65,00 € (39,00 + 26,00) (ist {s.gezahlt})")
pruefe(abs(s.steuer - 3.25) < 0.005, f"Steuer 3,25 € (ist {s.steuer})")
storno_zeilen = [b for b in buchungen if b.gezahlt < 0]
pruefe(len(storno_zeilen) == 1 and storno_zeilen[0].naechte == -4,
f"der Storno steht mit -4 Nächten im Journal ({[(b.voll_nummer, b.naechte) for b in storno_zeilen]})")
namen = {b.nachname for b in buchungen}
pruefe("Richtig" in namen and "Falsch" not in namen,
f"der berichtigte Name ist übernommen ({sorted(namen)})")
berichtigte = [b for b in buchungen if b.nachname == "Richtig"]
pruefe(len(berichtigte) == 1 and abs(berichtigte[0].gezahlt - 26.0) < 0.005,
f"und die Buchung selbst ist unverändert ({[(b.voll_nummer, b.gezahlt) for b in berichtigte]})")
pruefe(len(buchungen) == 4,
f"vier Zeilen: Rechnung, Storno, berichtigte Rechnung, zweite Rechnung ({len(buchungen)})")
# ---- Fall 3: DIESELBE Nummer auf zwei Namen, aufgeloest ------------------
# Sein Fall aus dem Altbestand (2026-331, 2026-333, 2026-347 stehen dort auf je
# zwei Namen). Beide PDFs werden mit dem Renderer geschrieben - so, wie sie das
# alte Rechnungstool geschrieben hat, das die Nummer noch ein zweites Mal
# vergab. Danach wird eine davon storniert und mit freier Nummer neu
# ausgestellt: § 14 Abs. 4 Nr. 4 UStG laesst jede Nummer nur einmal zu, und das
# Storno traegt Nummer, Datum UND Empfaenger - § 31 Abs. 5 UStDV verlangt einen
# "spezifischen und eindeutigen" Bezug, und die Nummer allein ist hier keiner.
import berechnung # noqa: E402
import pdf_renderer # noqa: E402
f2 = A.RechnungsApp()
f2.cfg["output_dir"] = ordner
pos_nr = sorted(f2.anzahl_vars)[0]
def alt_rechnung(datei, nummer, nachname, datum, naechte):
r = berechnung.berechne(f2.cfg["katalog"], {pos_nr: {"anzahl": naechte, "personen": 0}},
f2.cfg)
kopf = {"rechnungsnummer": nummer, "datum": datum, "anreise": datum, "abreise": "",
"naechte": str(naechte), "nachname": nachname, "vorname": "Carla",
"kunde_zeilen": ["", f"Carla {nachname}", "", "Musterweg 9", "12345 Musterstadt"],
"kunde": {"vorname": "Carla", "nachname": nachname, "strasse": "Musterweg 9",
"plzort": "12345 Musterstadt"},
"positionen": [{"nr": z.nr, "anzahl": z.anzahl, "personen": z.personen}
for z in r.zeilen]}
pdf_renderer.render_rechnung(os.path.join(ordner, datei), f2.cfg, r, kopf)
alt_rechnung("Rechnung_2026-900_ZwillingA.pdf", "2026-900", "ZwillingA", "20.08.2026", 5)
alt_rechnung("Rechnung_2026-900_ZwillingB.pdf", "2026-900", "ZwillingB", "22.08.2026", 3)
journal2 = BST.Journal(os.path.join(tmp, "journal2.sqlite3"))
journal2.scanne(ordner, 5.0, voll=True)
from modell import aufgehobene, doppelte_nummern # noqa: E402
d = doppelte_nummern(journal2.buchungen(jahr=2026))
pruefe("2026-900" in d, f"die doppelt vergebene Nummer wird gemeldet ({sorted(d)})")
pruefe(len(d.get("2026-900", [])) == 2,
f"mit beiden Gästen ({[b.nachname for b in d.get('2026-900', [])]})")
journal2.schliessen()
# ---- Die Dublette aufloesen: stornieren und neu ausstellen ----------------
zwilling = [m for m in BE.rechnungen_im_ordner(ordner) if m.get("nachname") == "ZwillingB"]
pruefe(len(zwilling) == 1, "die zweite Rechnung ist stornierbar")
f2._storno_anlegen(zwilling[0], ordner)
m_storno2 = BE.kenndaten_lesen(f2.storno_stand["pfad"]) if f2.storno_stand else {}
pruefe(m_storno2.get("storno_zu") == "2026-900" and m_storno2.get("storno_datum") == "22.08.2026",
f"das Storno nennt Nummer UND Datum ({m_storno2.get('storno_zu')}, {m_storno2.get('storno_datum')})")
pruefe(m_storno2.get("nachname") == "ZwillingB",
f"und den Empfänger - sonst kein eindeutiger Bezug ({m_storno2.get('nachname')!r})")
neue_nummer = f2.r_nummer.get()
pruefe(neue_nummer != "2026-900", f"die Neuausstellung bekommt eine freie Nummer ({neue_nummer})")
f2.update()
f2.pdf_erstellen() # berichtigte Rechnung mit neuer Nummer
f2.destroy()
journal3 = BST.Journal(os.path.join(tmp, "journal3.sqlite3"))
journal3.scanne(ordner, 5.0, voll=True)
neu_buchungen = journal3.buchungen(jahr=2026)
d2 = doppelte_nummern(neu_buchungen)
pruefe("2026-900" not in d2,
f"nach Storno + Neuausstellung ist die Nummer nicht mehr doppelt ({sorted(d2)})")
weg = aufgehobene(neu_buchungen)
storniert = [b for b in neu_buchungen if b.id in weg and b.voll_nummer == "2026-900"]
pruefe(len(storniert) == 1 and storniert[0].nachname == "ZwillingB",
f"genau die stornierte Rechnung ist aufgehoben ({[(b.voll_nummer, b.nachname) for b in storniert]})")
erst = [b for b in neu_buchungen if b.voll_nummer == "2026-900" and b.nachname == "ZwillingA"]
pruefe(len(erst) == 1 and erst[0].id not in weg,
"die andere Rechnung mit derselben Nummer bleibt unangetastet")
journal3.schliessen()
journal.schliessen()
if not fehler:
shutil.rmtree(tmp, ignore_errors=True)
else:
print(f" (Wegwerf-Ordner bleibt: {tmp})")
print("---")
print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")
sys.exit(1 if fehler else 0)