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_bilder.py schrieb ein eigenes Format, pruef_storno_verrechnet.py zaehlte in einer Zahl und konnte am Ende nicht sagen, WAS fehlschlug. * 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. * Sechs stumme "except OSError" sagen jetzt, warum sie schweigen duerfen, und einer meldet statt zu schweigen: schlaegt die Uebernahme einer alten config.json fehl, faengt das Programm ohne Firmendaten, Katalog und Zaehler neu an - und der Erfolgsfall schrieb eine Zeile, der Fehlerfall nicht. Die 24 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
257 lines
10 KiB
Python
257 lines
10 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Pruefstand: dieselbe Leistung liegt mehrfach im Ordner.
|
|
|
|
Sein Fall, woertlich: *"der typ hat mit dem alten programm einfach eine neue
|
|
rechnung gemacht, nun hat der die gleiche rechnung 3-4 mal drin, aber mit
|
|
anderen nummern. nun muss man die stornieren, heisst aber, dass wir manuell auf
|
|
die neue rechnungsnummer verweisen muessen - das musst du in der storno
|
|
abfragen, ob er schon eine hat oder eine neue rechnung erstellen will."*
|
|
|
|
Also fragt das Storno jetzt zuerst:
|
|
A - es folgt eine neue Rechnung (der bisherige Weg)
|
|
B - die Leistung ist schon abgerechnet -> Nummer der BLEIBENDEN Rechnung
|
|
|
|
Geprueft wird B am echten Fenster: die Knoepfe werden gedrueckt, die Nummer
|
|
wirklich eingetippt. Und am Ende muss im Steuerjournal genau EIN Aufenthalt
|
|
stehen - nicht zwei.
|
|
"""
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
|
|
QUELLE = r"C:\claude\rechnungstool"
|
|
JOURNAL = r"C:\claude\beherbergungssteuer"
|
|
sys.path.insert(0, QUELLE)
|
|
|
|
|
|
def _reissleine():
|
|
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog")
|
|
os._exit(2)
|
|
|
|
|
|
_wecker = threading.Timer(150, _reissleine)
|
|
_wecker.daemon = True
|
|
_wecker.start()
|
|
|
|
os.environ["CAMPINGHOF_TEST"] = "1"
|
|
tmp = tempfile.mkdtemp(prefix="doppelt_leistung_")
|
|
ordner = os.path.join(tmp, "rechnungen")
|
|
os.makedirs(ordner)
|
|
|
|
import config # noqa: E402
|
|
|
|
shutil.copy(os.path.join(QUELLE, "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["nummer_eingerichtet"] = True
|
|
roh["output_dir"] = ordner
|
|
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
|
|
|
import app as A # noqa: E402
|
|
import bestand as BE # noqa: E402
|
|
from tkinter import messagebox # noqa: E402
|
|
from pruefhelfer import fehler, pruefe
|
|
|
|
antworten = {"Fertig": False, "Hausnummer": True, "Storno erstellt": False,
|
|
"Angaben zum Kunden": True, "Weicht ab": True}
|
|
warnungen = []
|
|
|
|
|
|
def askyesno(titel, text, **kw):
|
|
for schlue, wert in antworten.items():
|
|
if schlue in titel:
|
|
return wert
|
|
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
|
|
|
|
|
|
# Die Freigabe vor dem Beleg (§ 14 UStG) wird bewusst bejaht - geprueft wird hier
|
|
# der Ablauf DANACH. Dass ein "Nein" keinen Beleg erzeugt, prueft pruef_freigabe.py.
|
|
antworten["jetzt erstellen?"] = True
|
|
# Der Erprobungshinweis wird hier bejaht - geprueft wird er in pruef_hinweis.py.
|
|
antworten["Erprobungsfassung"] = True
|
|
messagebox.askyesno = askyesno
|
|
messagebox.showinfo = lambda *a, **k: None
|
|
messagebox.showwarning = lambda *a, **k: warnungen.append(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
|
|
f._storno_dialog_zeigen = True # NUR das Storno-Popup soll wirklich kommen
|
|
|
|
|
|
def widgets(w, klassen, aus=None):
|
|
aus = [] if aus is None else aus
|
|
for k in w.winfo_children():
|
|
if k.winfo_class() in klassen:
|
|
aus.append(k)
|
|
widgets(k, klassen, aus)
|
|
return aus
|
|
|
|
|
|
def rechnung(name, naechte=3):
|
|
"""Zweimal dieselbe Leistung - so, wie das alte Programm es angerichtet hat."""
|
|
f.reset_formular()
|
|
f.k_vorname.set("Anna")
|
|
f.k_name.set(name)
|
|
f.k_strasse.set("Musterweg 1")
|
|
f.k_plzort.set("12345 Musterstadt")
|
|
f.r_anreise.set("01.08.2026")
|
|
f.r_abreise.set("04.08.2026")
|
|
f.anzahl_vars[sorted(f.anzahl_vars)[0]].set(str(naechte))
|
|
f._testwahl = "rechnung"
|
|
f.update()
|
|
f.pdf_erstellen()
|
|
|
|
|
|
rechnung("Meier") # 2026-001 - die bleibt
|
|
rechnung("Meier") # 2026-002 - dieselbe Leistung, versehentlich noch einmal
|
|
pruefe(sorted(os.listdir(ordner)) == ["Rechnung_2026-001_Meier.pdf",
|
|
"Rechnung_2026-002_Meier.pdf"],
|
|
f"zwei Rechnungen ueber denselben Aufenthalt ({sorted(os.listdir(ordner))})")
|
|
|
|
|
|
def druecke(top, text):
|
|
for k in widgets(top, ("TButton", "Button")):
|
|
if text.lower() in str(k.cget("text")).lower():
|
|
k.invoke()
|
|
return True
|
|
return False
|
|
|
|
|
|
def storno_mit_popup(meta, nummer=None, schritt1="B: schon abgerechnet", danach=None):
|
|
"""Storno anstossen und das zweistufige Popup bedienen.
|
|
|
|
Erst A oder B - und ERST DANN gibt es das Nummernfeld ("step by step,
|
|
nicht alles auf einmal").
|
|
"""
|
|
zustand = {"schritt": 1}
|
|
|
|
def klick(rest=60):
|
|
tops = [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"]
|
|
if not tops:
|
|
if rest:
|
|
f.after(100, lambda: klick(rest - 1))
|
|
return
|
|
top = tops[-1]
|
|
if zustand["schritt"] == 1:
|
|
felder = widgets(top, ("TEntry", "Entry"))
|
|
zustand["feld_in_schritt1"] = bool(felder and felder[0].winfo_ismapped())
|
|
if druecke(top, schritt1):
|
|
zustand["schritt"] = 2
|
|
if nummer is None:
|
|
return
|
|
f.after(100, lambda: klick(rest - 1))
|
|
return
|
|
felder = [e for e in widgets(top, ("TEntry", "Entry")) if e.winfo_ismapped()]
|
|
if felder:
|
|
felder[0].delete(0, "end")
|
|
felder[0].insert(0, nummer)
|
|
f.update()
|
|
druecke(top, "Übernehmen")
|
|
if danach:
|
|
# Bei ungueltiger Eingabe bleibt das Fenster offen (so soll es
|
|
# sein) - der Pruefstand macht es dann selbst zu.
|
|
f.after(100, lambda: druecke(top, danach) if top.winfo_exists() else None)
|
|
return
|
|
if rest:
|
|
f.after(100, lambda: klick(rest - 1))
|
|
|
|
f.after(200, klick)
|
|
f._storno_anlegen(meta, ordner)
|
|
return zustand
|
|
|
|
|
|
zweite = [m for m in BE.rechnungen_im_ordner(ordner)
|
|
if str(m.get("rechnungsnummer")) == "2026-002"][0]
|
|
|
|
# ---- 1. Falsche Eingaben nimmt das Fenster nicht an ----------------------
|
|
zustand = storno_mit_popup(zweite, "2026-999", danach="Abbrechen") # gibt es nicht
|
|
pruefe(zustand.get("feld_in_schritt1") is False,
|
|
"im ersten Schritt gibt es noch KEIN Nummernfeld (step by step)")
|
|
pruefe(len([d for d in os.listdir(ordner) if d.startswith("Storno")]) == 0,
|
|
"eine Nummer, die es nicht gibt, schreibt kein Storno")
|
|
|
|
storno_mit_popup(zweite, "2026-002", danach="Abbrechen") # sie selbst
|
|
pruefe(len([d for d in os.listdir(ordner) if d.startswith("Storno")]) == 0,
|
|
"und auf sich selbst verweist auch keines")
|
|
|
|
# Die Inhaltspruefung selbst - was passt zusammen und was nicht
|
|
schwer, leicht = A.RechnungsApp._passt_zusammen(
|
|
{"nachname": "Meier", "anreise": "01.08.2026", "gesamt": 39.0},
|
|
{"nachname": "Schulz", "anreise": "01.08.2026", "gesamt": 39.0})
|
|
pruefe(bool(schwer) and "anderer Gast" in schwer[0],
|
|
f"ein anderer Gast ist ein harter Fehler ({schwer})")
|
|
schwer, leicht = A.RechnungsApp._passt_zusammen(
|
|
{"nachname": "Meier", "anreise": "01.08.2026", "gesamt": 39.0},
|
|
{"nachname": "Meier", "anreise": "05.08.2026", "gesamt": 52.0})
|
|
pruefe(not schwer and len(leicht) == 2,
|
|
f"anderer Zeitraum und Betrag sind Hinweise, kein Verbot ({schwer}, {leicht})")
|
|
schwer, leicht = A.RechnungsApp._passt_zusammen(
|
|
{"nachname": "Meier", "anreise": "01.08.2026", "gesamt": 39.0},
|
|
{"nachname": "meier", "anreise": "01.08.2026", "gesamt": 39.0})
|
|
pruefe(not schwer and not leicht, f"gleiche Leistung passt glatt ({schwer}, {leicht})")
|
|
|
|
# ---- 2. Der richtige Weg: auf die bleibende Rechnung verweisen ----------
|
|
storno_mit_popup(zweite, "2026-001")
|
|
f.update()
|
|
stornos = [d for d in os.listdir(ordner) if d.startswith("Storno")]
|
|
pruefe(len(stornos) == 1, f"das Storno ist geschrieben ({stornos})")
|
|
pfad = os.path.join(ordner, stornos[0])
|
|
meta = BE.kenndaten_lesen(pfad) or {}
|
|
pruefe(meta.get("storno_zu") == "2026-002", f"es hebt 2026-002 auf ({meta.get('storno_zu')})")
|
|
pruefe(meta.get("korrektur_nummer") == "2026-001",
|
|
f"und verweist auf die bleibende 2026-001 ({meta.get('korrektur_nummer')})")
|
|
pruefe(meta.get("ersatz_vorhanden") is True,
|
|
f"als BEREITS abgerechnet, nicht als kommende Rechnung ({meta.get('ersatz_vorhanden')})")
|
|
|
|
from pypdf import PdfReader # noqa: E402
|
|
|
|
text = (PdfReader(pfad).pages[0].extract_text() or "").replace("\n", " ")
|
|
pruefe("Schon abgerechnet" in text and "2026-001" in text,
|
|
"auf dem Blatt steht 'Schon abgerechnet: 2026-001'")
|
|
pruefe("doppelt in Rechnung gestellt" in text,
|
|
"und der Satz, warum es die Rechnung zweimal gab")
|
|
pruefe("es folgt keine weitere Rechnung" in text,
|
|
"sowie dass keine weitere Rechnung kommt")
|
|
pruefe("Berichtigte Rechnung" not in text,
|
|
"und NICHT 'Berichtigte Rechnung' - es kommt ja keine")
|
|
|
|
# ---- 3. Kein offener Vorgang, keine reservierte Nummer ------------------
|
|
pruefe(f.storno_stand is None, "es bleibt kein Storno-Vorgang offen")
|
|
pruefe(str(f.nummer_entry.cget("state")) == "normal", "das Nummernfeld ist nicht gesperrt")
|
|
pruefe(f.r_nummer.get() == "2026-004",
|
|
f"die naechste Rechnung waere 2026-004 (nach dem Storno 003) ({f.r_nummer.get()})")
|
|
|
|
# ---- 4. Was am Ende im Steuerjournal steht -----------------------------
|
|
sys.path.append(JOURNAL)
|
|
import db as BST # noqa: E402
|
|
from modell import summiere, aufgehobene # noqa: E402
|
|
|
|
journal = BST.Journal(os.path.join(tmp, "journal.sqlite3"))
|
|
journal.scanne(ordner, 5.0, voll=True)
|
|
buchungen = journal.buchungen(jahr=2026)
|
|
s = summiere(buchungen)
|
|
print("--- was im Journal steht ---")
|
|
for b in buchungen:
|
|
print(f" {b.voll_nummer} {b.nachname:8s} {b.naechte:>3} Nächte {b.gezahlt:>8.2f} €")
|
|
pruefe(len(buchungen) == 3, f"drei Zeilen: zwei Rechnungen und das Storno ({len(buchungen)})")
|
|
pruefe(s.naechte == 3, f"gemeldet wird EIN Aufenthalt mit 3 Nächten ({s.naechte})")
|
|
pruefe(abs(s.gezahlt - 39.0) < 0.005, f"und 39,00 € statt 78,00 € ({s.gezahlt})")
|
|
weg = aufgehobene(buchungen)
|
|
storniert = [b for b in buchungen if b.id in weg]
|
|
pruefe(len(storniert) == 1 and storniert[0].voll_nummer == "2026-002",
|
|
f"aufgehoben ist genau die zweite ({[b.voll_nummer for b in storniert]})")
|
|
journal.schliessen()
|
|
|
|
f.destroy()
|
|
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)
|