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
This commit is contained in:
TheMockTv 2026-09-03 23:27:43 +02:00
parent 7692c79269
commit 1107a9bace
3 changed files with 511 additions and 4 deletions

View file

@ -25,6 +25,7 @@ from tkinter import ttk, messagebox
import berechnung
import config
import pdf_renderer
import berechnung
from bestand import (kundendaten_ermitteln, naechste_freie_nummer,
naechste_freie_nummer_nach, rechnungen_im_ordner)
from helfer import kunde_maengel, nummer_normalisieren, parse_num, safe_filename
@ -302,9 +303,215 @@ class KorrekturMixin:
frm.columnconfigure(1, weight=1)
@staticmethod
def _passt_zusammen(alt, neu):
"""Passt die bleibende Rechnung inhaltlich zu der, die storniert wird?
Die Nummer allein sagt nichts - sie koennte auf einen ganz anderen Gast
zeigen. Verglichen werden Name, Zeitraum und Betrag, und zwar getrennt,
damit die Meldung sagt, WAS abweicht.
Gibt (schwer, leicht) zurueck: schwer = darf nicht durchgehen (anderer
Gast), leicht = Hinweise, die der Nutzer bestaetigen muss.
"""
def wort(x):
return " ".join(str(x or "").lower().split())
schwer, leicht = [], []
if wort(alt.get("nachname")) != wort(neu.get("nachname")):
schwer.append(f"anderer Gast: {alt.get('nachname') or '?'} / "
f"{neu.get('nachname') or '?'}")
for feld, name in (("anreise", "Anreise"), ("abreise", "Abreise")):
a, b = wort(alt.get(feld)), wort(neu.get(feld))
if a and b and a != b:
leicht.append(f"{name} {alt.get(feld)} / {neu.get(feld)}")
try:
ga, gb = float(alt.get("gesamt") or 0), float(neu.get("gesamt") or 0)
except (TypeError, ValueError):
ga = gb = 0.0
if abs(ga - gb) > 0.005:
leicht.append(f"Betrag {berechnung.eur(ga)} / {berechnung.eur(gb)}")
return schwer, leicht
def _storno_folge_waehlen(self, out_dir, alt_nummer, alt_meta=None):
"""Nach der Frage WELCHE Rechnung kommt die Frage, wie es weitergeht.
Zwei Schritte in einem Fenster, so wie er es beschrieben hat: erst A
oder B, und erst wenn B gedrueckt ist, kommt das Nummernfeld.
A - es folgt eine neue Rechnung (der bisherige Weg)
B - die Leistung ist schon abgerechnet -> Nummer der BLEIBENDEN
Sein Fall: mit dem alten Programm wurde bei einem Fehler einfach eine
NEUE Rechnung geschrieben. Jetzt liegt dieselbe Leistung mehrfach im
Ordner, und die Ueberzaehligen muessen auf die Rechnung verweisen, die
BLEIBT - sonst wartet der Gast auf eine Rechnung, die nie kommt.
Rueckgabe: ("neu", None) | ("vorhanden", "2026-338") | None (Abbruch).
"""
# Im Testlauf klickt niemand: dann gilt der uebliche Weg. Der Pruefstand,
# der GENAU DIESES Fenster prueft, setzt _storno_dialog_zeigen.
from app import TESTLAUF
if TESTLAUF and not getattr(self, "_storno_dialog_zeigen", False):
return getattr(self, "_folgewahl", ("neu", None))
d = tk.Toplevel(self)
d.title("Wie geht es nach dem Storno weiter?")
d.transient(self)
d.resizable(False, False)
self._style_dialog(d)
wahl = {"art": None, "nummer": ""}
gefunden = {"meta": None, "schwer": [], "leicht": []}
eingabe = tk.StringVar()
rahmen = ttk.Frame(d, padding=18)
rahmen.pack(fill="both", expand=True)
ttk.Label(rahmen, text=f"Storno zu Rechnung {alt_nummer}",
font=("Segoe UI", 13, "bold")).pack(anchor="w")
# ---- Schritt 1: A oder B ------------------------------------------
schritt1 = ttk.Frame(rahmen)
schritt1.pack(fill="x", pady=(8, 0))
ttk.Label(schritt1, wraplength=620, justify="left", text=(
"Auf dem Storno-Blatt muss stehen, wie die Leistung stattdessen abgerechnet "
"wird – sonst weiß der Gast nicht, woran er ist.")).pack(anchor="w", pady=(0, 12))
ttk.Label(schritt1, text="A – es folgt eine neue Rechnung",
font=("Segoe UI", 10, "bold")).pack(anchor="w")
ttk.Label(schritt1, wraplength=620, justify="left", text=(
"Der übliche Weg: die Rechnung war falsch und wird nach dem Storno richtig neu "
"geschrieben. Das Formular bleibt danach offen.")).pack(anchor="w", pady=(2, 10))
ttk.Label(schritt1, text="B – die Leistung ist schon abgerechnet",
font=("Segoe UI", 10, "bold")).pack(anchor="w")
ttk.Label(schritt1, wraplength=620, justify="left", text=(
"Für Doppelte: derselbe Aufenthalt steht mehrfach im Ordner. Im nächsten Schritt "
"trägst du die Nummer der Rechnung ein, die bleibt.")).pack(anchor="w", pady=(2, 0))
# ---- Schritt 2: die Nummer (erst nach B) --------------------------
schritt2 = ttk.Frame(rahmen)
ttk.Label(schritt2, wraplength=620, justify="left", text=(
"Welche Rechnung bleibt? Sie muss im Rechnungsordner liegen und denselben "
"Aufenthalt abrechnen – das Programm sieht nach.")).pack(anchor="w", pady=(8, 10))
zeile = ttk.Frame(schritt2)
zeile.pack(anchor="w")
ttk.Label(zeile, text="Bleibende Rechnung:").pack(side="left")
feld = ttk.Entry(zeile, textvariable=eingabe, width=16, justify="center")
feld.pack(side="left", padx=(8, 0))
stand = tk.Label(schritt2, text="", wraplength=620, justify="left",
font=("Segoe UI", 9))
stand.pack(anchor="w", pady=(8, 0))
def suchen(*_a):
"""Gibt es die Nummer - und passt der INHALT? Antwort aus dem Ordner."""
nr = nummer_normalisieren(eingabe.get())
gefunden["meta"], gefunden["schwer"], gefunden["leicht"] = None, [], []
if not nr:
stand.configure(text="", fg=self.col_muted)
return None
if nr == alt_nummer:
stand.configure(text="Das ist die Rechnung, die gerade storniert wird.",
fg="#a00000")
return None
treffer = [m for m in rechnungen_im_ordner(out_dir)
if str(m.get("rechnungsnummer")) == nr and m.get("art") != "storno"]
if not treffer:
stand.configure(text=f"Zu {nr} liegt keine Rechnung im Ordner.", fg="#a00000")
return None
m = treffer[0]
wer = " ".join(t for t in (str(m.get("vorname") or ""),
str(m.get("nachname") or "")) if t)
grund = (f"{nr} vom {m.get('datum','')} – {wer or 'ohne Namen'}, "
f"{berechnung.eur(m.get('gesamt') or 0)}")
schwer, leicht = self._passt_zusammen(alt_meta or {}, m)
gefunden["meta"], gefunden["schwer"], gefunden["leicht"] = m, schwer, leicht
if schwer:
stand.configure(text=grund + " — " + "; ".join(schwer)
+ ". So kann das Storno nicht darauf verweisen.",
fg="#a00000")
return None
if leicht:
stand.configure(text=grund + " — weicht ab: " + "; ".join(leicht),
fg="#a06000")
return nr
stand.configure(text=grund + " — gleicher Gast, gleicher Zeitraum, gleicher Betrag.",
fg="#1f5c3d")
return nr
eingabe.trace_add("write", suchen)
# ---- Knopfleiste, wechselt mit dem Schritt ------------------------
knoepfe = ttk.Frame(rahmen)
knoepfe.pack(fill="x", pady=(16, 0))
def nimm_neu():
wahl["art"] = "neu"
d.destroy()
def uebernehmen():
nr = suchen()
if not nr:
if not eingabe.get().strip():
stand.configure(text="Bitte die Nummer der bleibenden Rechnung eintragen.",
fg="#a00000")
return
if gefunden["leicht"] and not messagebox.askyesno(
"Weicht ab – trotzdem?",
f"Rechnung {nr} weicht ab: " + "; ".join(gefunden["leicht"]) +
". Das Storno würde trotzdem darauf verweisen. Ist das dieselbe "
"Leistung?", icon="warning", default="no", parent=d):
return
wahl["art"], wahl["nummer"] = "vorhanden", nr
d.destroy()
def zu_schritt2():
schritt1.pack_forget()
schritt2.pack(fill="x")
for w in knoepfe.winfo_children():
w.destroy()
ttk.Button(knoepfe, text="Übernehmen", width=16,
command=uebernehmen).pack(side="right")
ttk.Button(knoepfe, text="Zurück", width=12, command=zu_schritt1).pack(side="left")
ttk.Button(knoepfe, text="Abbrechen", width=14,
command=d.destroy).pack(side="right", padx=(0, 8))
d.update_idletasks()
feld.focus_set()
def zu_schritt1():
schritt2.pack_forget()
schritt1.pack(fill="x", pady=(8, 0))
for w in knoepfe.winfo_children():
w.destroy()
ttk.Button(knoepfe, text="A: neue Rechnung folgt", width=24,
command=nimm_neu).pack(side="left")
ttk.Button(knoepfe, text="B: schon abgerechnet", width=22,
command=zu_schritt2).pack(side="left", padx=8)
ttk.Button(knoepfe, text="Abbrechen", width=14,
command=d.destroy).pack(side="right")
zu_schritt1()
d.protocol("WM_DELETE_WINDOW", d.destroy)
d.bind("<Escape>", lambda _e: d.destroy())
d.update_idletasks()
x = self.winfo_rootx() + max(0, (self.winfo_width() - d.winfo_reqwidth()) // 2)
d.geometry(f"+{max(0, x)}+{self.winfo_rooty() + 110}")
d.grab_set()
self.wait_window(d)
if wahl["art"] == "vorhanden":
return "vorhanden", wahl["nummer"]
return ("neu", None) if wahl["art"] == "neu" else None
def _storno_anlegen(self, meta, out_dir):
"""Schreibt die Storno-PDF und füllt das Formular für die berichtigte Rechnung."""
alt_nummer = str(meta.get("rechnungsnummer", ""))
# Zuerst die Frage, die frueher gefehlt hat: kommt eine neue Rechnung, oder
# ist die Leistung schon unter einer anderen Nummer abgerechnet? Beim
# alten Programm wurde bei einem Fehler einfach neu geschrieben - deshalb
# liegt dieselbe Leistung mehrfach im Ordner, und die Ueberzaehligen
# muessen auf die BLEIBENDE Rechnung verweisen.
weiter = self._storno_folge_waehlen(out_dir, alt_nummer, meta)
if weiter is None:
return
art_folge, vorhandene = weiter
nummer = naechste_freie_nummer(out_dir, self.cfg, self._belegte_nummern())
if not nummer:
messagebox.showerror("Keine freie Nummer",
@ -317,7 +524,13 @@ class KorrekturMixin:
# die naechste freie NACH dem Storno. Sie kommt aufs Storno-Blatt, damit
# die Kette aus dem Beleg allein lesbar ist und kein Zustand gemerkt
# werden muss: alte Nummer -> Storno -> neue Nummer, alles auf einem Blatt.
folge_nummer = naechste_freie_nummer_nach(out_dir, self.cfg, nummer, self._belegte_nummern())
if art_folge == "vorhanden":
# Es kommt keine neue Rechnung - das Storno verweist auf die,
# die bleibt. Es wird also auch keine Nummer dafuer reserviert.
folge_nummer = vorhandene
else:
folge_nummer = naechste_freie_nummer_nach(out_dir, self.cfg, nummer,
self._belegte_nummern())
# Der Storno geht an denselben Empfaenger wie die alte Rechnung - was in
# deren Kenndaten fehlt, wird aus ihrem Adressblock geholt.
kunde, woher = kundendaten_ermitteln(meta)
@ -363,6 +576,9 @@ class KorrekturMixin:
# alte Nummer -> diese Nummer. Steht auf dem Blatt, damit die Kette
# aus dem Storno allein lesbar ist.
"korrektur_nummer": folge_nummer or "",
# Sagt dem Blatt, ob die Nummer eine BLEIBENDE Rechnung meint
# ("bereits abgerechnet mit") oder eine, die noch kommt.
"ersatz_vorhanden": art_folge == "vorhanden",
"storno_datum": str(meta.get("datum") or ""),
"kunde": kunde,
}
@ -378,7 +594,9 @@ class KorrekturMixin:
print("[storno] FEHLER:", e)
return
self.storno_stand = {
# Nur wenn eine neue Rechnung folgt, bleibt ein Vorgang offen. Bei
# "schon abgerechnet" ist mit dem Storno alles getan.
self.storno_stand = None if art_folge == "vorhanden" else {
"pfad": pfad, "nummer": nummer, "zu": alt_nummer,
"vorgang": str(meta.get("vorgang") or alt_nummer),
"zu_datum": str(meta.get("datum") or ""),
@ -394,6 +612,18 @@ class KorrekturMixin:
self._buch_anzeigen()
self._journal_benachrichtigen(f"Storno {nummer} zu Rechnung {alt_nummer} erstellt")
if art_folge == "vorhanden":
self._storno_anzeigen()
self._neue_nummer_vorschlagen()
if messagebox.askyesno(
"Storno erstellt",
f"Storno {nummer} hebt Rechnung {alt_nummer} auf.\n\n"
f"Die Leistung bleibt mit Rechnung {folge_nummer} abgerechnet – es "
"kommt keine neue Rechnung dazu.\n\n"
"Das Blatt gehört zum Gast.\n\nJetzt öffnen (zum Drucken)?"):
self._oeffne_datei(pfad)
return
fehlend = self._formular_aus_meta(meta)
self.r_nummer.set(folge_nummer or "")
self._storno_anzeigen()

View file

@ -311,8 +311,11 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
# ganze Kette auf dem Blatt: alte Nummer -> Storno -> neue Nummer.
if kopf.get("korrektur_nummer"):
# "Neue Rechnung" war missverstaendlich - man liest es als die Nummer
# DIESES Blattes. Gemeint ist die Rechnung, die den Storno ersetzt.
info_rows.append(("Berichtigte Rechnung:", kopf["korrektur_nummer"]))
# DIESES Blattes. Gemeint ist die Rechnung, die den Storno ersetzt -
# oder, bei einer doppelt geschriebenen Leistung, die BLEIBENDE.
info_rows.append(
("Schon abgerechnet:" if kopf.get("ersatz_vorhanden")
else "Berichtigte Rechnung:", kopf["korrektur_nummer"]))
elif art == "korrektur" and kopf.get("ersetzt"):
info_rows.append(("Ersetzt Rechnung:", kopf.get("ersetzt", "")))
elif art == "berichtigung":
@ -479,6 +482,18 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
if kopf.get("storno_datum"):
bezug += f" vom {kopf['storno_datum']}"
folgt = kopf.get("korrektur_nummer") or ""
if kopf.get("ersatz_vorhanden") and folgt:
# Doppelt geschriebene Leistung: es kommt nichts nach, sie ist
# bereits abgerechnet. Der Satz muss das sagen - sonst wartet der
# Gast auf eine Rechnung, die nie kommt.
block.append(Paragraph(
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
f"Die Leistung wurde versehentlich doppelt in Rechnung gestellt und "
f"ist bereits mit der Rechnung {folgt} abgerechnet – es folgt keine "
"weitere Rechnung. Bereits gezahlte Beträge werden erstattet oder "
"verrechnet.", st["dank"]))
story.append(KeepTogether(block))
return
kette = (f" Die berichtigte Rechnung trägt die Nummer {folgt} "
f"(also {kopf.get('storno_zu','')} → {kopf.get('rechnungsnummer','')} "
f"→ {folgt}).") if folgt else ""
@ -615,6 +630,7 @@ def _kenndaten(rechnung, kopf):
"vorgang": kopf.get("vorgang") or kopf.get("rechnungsnummer", ""),
"storno_zu": kopf.get("storno_zu", ""),
"korrektur_nummer": kopf.get("korrektur_nummer", ""),
"ersatz_vorhanden": bool(kopf.get("ersatz_vorhanden")),
"storno_datum": kopf.get("storno_datum", ""),
"ersetzt": kopf.get("ersetzt", ""),
"storno_nummer": kopf.get("storno_nummer", ""),

261
pruef_doppelte_leistung.py Normal file
View file

@ -0,0 +1,261 @@
# -*- 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)