rechnungstool/pruef_doppelte_leistung.py
TheMockTv 1107a9bace Storno fragt zweistufig: neue Rechnung oder schon abgerechnet
Sein Fall: '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'. Die Ueberzaehligen muessen storniert werden - und ihr Storno muss auf
die Rechnung verweisen, die BLEIBT, nicht auf eine neue.

Nach dem Auswaehlen der Rechnung fragt das Storno jetzt:
  A - es folgt eine neue Rechnung (der bisherige Weg)
  B - die Leistung ist schon abgerechnet

Zweistufig, weil er es so braucht: 'ich druecke b und ERST DANN kann ich die
nummer eingeben ... das ein opa, immer step by step, nicht alles auf einmal,
wie bei kindern'. Schritt 1 zeigt nur die Wahl, Schritt 2 das Nummernfeld, mit
Zurueck.

Geprueft wird nicht nur, OB es die Nummer gibt, sondern auch, ob der INHALT
passt (sein Zusatz): anderer Gast = geht nicht, anderer Zeitraum oder Betrag =
Nachfrage. Auf dem Blatt steht dann 'Schon abgerechnet: 2026-001' statt
'Berichtigte Rechnung', dazu der Satz, dass die Leistung versehentlich doppelt
in Rechnung gestellt wurde und keine weitere Rechnung folgt. Es wird keine
Nummer reserviert und kein Vorgang offen gehalten.

Pruefstand pruef_doppelte_leistung.py: faehrt das echte zweistufige Fenster,
prueft die Inhaltspruefung einzeln und am Ende das Steuerjournal - EIN
Aufenthalt (3 Naechte, 39,00) statt zwei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx
2026-09-03 23:27:43 +02:00

261 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)
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kodierung = sys.stdout.encoding or "utf-8"
print(zeile.encode(kodierung, "replace").decode(kodierung))
if not bedingung:
fehler.append(text)
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
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}")
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)