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