Storno: Version C - nur Storno, es folgt nichts

Seine Ansage: "des wegen sollte mann auch eine version c mit only storno
machen weil das gibt es auch".

Bisher gab es nur zwei Wege aus dem Storno:
  A - es folgt eine neue Rechnung
  B - die Leistung ist schon abgerechnet, Verweis auf die bleibende Nummer

Beide behaupten etwas, das im dritten Fall nicht stimmt: die Buchung ist
geplatzt oder die Rechnung war komplett irrtuemlich, es wird also gar
nichts abgerechnet. Mit A wartet der Gast auf eine Rechnung, die nie
kommt, und es waere unnoetig eine Nummer reserviert; mit B muesste man
auf eine Rechnung verweisen, die es nicht gibt.

C steht als dritter Knopf im selben Schritt (A, B, C, Abbrechen) - kein
zweiter Schritt, weil es nichts einzutippen gibt.

- keine Folgenummer reserviert (die fortlaufende Nummernfolge bekaeme
  sonst eine Luecke, § 14 Abs. 4 Nr. 4 UStG)
- kein offener Vorgang, das Formular wartet auf nichts
- eigener Satz auf dem Blatt: "Die Leistung wird nicht abgerechnet - es
  folgt keine weitere Rechnung. Bereits gezahlte Betraege werden
  erstattet." Kein Wort von einer berichtigten Rechnung
- Kenndaten tragen "nur_storno": true, damit das Steuerjournal den Fall
  auseinanderhalten kann

Pruefstand pruef_nur_storno.py (neu, 15 Pruefungen) drueckt den Knopf am
echten Fenster und sieht danach Blatt, Kenndaten, Nummernzaehler und das
Steuerjournal an (Rechnung + Storno stehen drin, gemeldet wird nichts).
pruef_storno (A), pruef_doppelte_leistung (B), pruef_kette und
pruef_nummernsperre laufen unveraendert gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bEsoFUk16DfnNA7MjY36H
This commit is contained in:
TheMockTv 2026-09-04 00:53:23 +02:00
parent 1107a9bace
commit 01f6a127c2
3 changed files with 247 additions and 4 deletions

View file

@ -341,13 +341,21 @@ class KorrekturMixin:
A - es folgt eine neue Rechnung (der bisherige Weg) A - es folgt eine neue Rechnung (der bisherige Weg)
B - die Leistung ist schon abgerechnet -> Nummer der BLEIBENDEN B - die Leistung ist schon abgerechnet -> Nummer der BLEIBENDEN
C - nur Storno: es folgt nichts und es gibt keine andere Rechnung
Sein Fall: mit dem alten Programm wurde bei einem Fehler einfach eine Sein Fall: mit dem alten Programm wurde bei einem Fehler einfach eine
NEUE Rechnung geschrieben. Jetzt liegt dieselbe Leistung mehrfach im NEUE Rechnung geschrieben. Jetzt liegt dieselbe Leistung mehrfach im
Ordner, und die Ueberzaehligen muessen auf die Rechnung verweisen, die Ordner, und die Ueberzaehligen muessen auf die Rechnung verweisen, die
BLEIBT - sonst wartet der Gast auf eine Rechnung, die nie kommt. BLEIBT - sonst wartet der Gast auf eine Rechnung, die nie kommt.
Rueckgabe: ("neu", None) | ("vorhanden", "2026-338") | None (Abbruch). C kam am 04.09.2026 dazu (*"des wegen sollte man auch eine version c mit
only storno machen weil das gibt es auch"*): die Leistung wird gar nicht
abgerechnet - Buchung geplatzt, Rechnung komplett irrtuemlich. Ohne C
muesste man A waehlen und die versprochene neue Rechnung bliebe ewig
offen, oder B und muesste auf eine Rechnung verweisen, die es nicht gibt.
Rueckgabe: ("neu", None) | ("vorhanden", "2026-338") | ("nur", None)
| None (Abbruch).
""" """
# Im Testlauf klickt niemand: dann gilt der uebliche Weg. Der Pruefstand, # Im Testlauf klickt niemand: dann gilt der uebliche Weg. Der Pruefstand,
# der GENAU DIESES Fenster prueft, setzt _storno_dialog_zeigen. # der GENAU DIESES Fenster prueft, setzt _storno_dialog_zeigen.
@ -384,7 +392,13 @@ class KorrekturMixin:
font=("Segoe UI", 10, "bold")).pack(anchor="w") font=("Segoe UI", 10, "bold")).pack(anchor="w")
ttk.Label(schritt1, wraplength=620, justify="left", text=( ttk.Label(schritt1, wraplength=620, justify="left", text=(
"Für Doppelte: derselbe Aufenthalt steht mehrfach im Ordner. Im nächsten Schritt " "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)) "trägst du die Nummer der Rechnung ein, die bleibt.")).pack(anchor="w", pady=(2, 10))
ttk.Label(schritt1, text="C – nur Storno",
font=("Segoe UI", 10, "bold")).pack(anchor="w")
ttk.Label(schritt1, wraplength=620, justify="left", text=(
"Die Leistung wird gar nicht abgerechnet: Buchung geplatzt oder die Rechnung "
"war komplett irrtümlich. Es folgt keine neue Rechnung und es gibt auch keine "
"andere, auf die verwiesen wird.")).pack(anchor="w", pady=(2, 0))
# ---- Schritt 2: die Nummer (erst nach B) -------------------------- # ---- Schritt 2: die Nummer (erst nach B) --------------------------
schritt2 = ttk.Frame(rahmen) schritt2 = ttk.Frame(rahmen)
@ -446,6 +460,10 @@ class KorrekturMixin:
wahl["art"] = "neu" wahl["art"] = "neu"
d.destroy() d.destroy()
def nimm_nur():
wahl["art"] = "nur"
d.destroy()
def uebernehmen(): def uebernehmen():
nr = suchen() nr = suchen()
if not nr: if not nr:
@ -484,6 +502,8 @@ class KorrekturMixin:
command=nimm_neu).pack(side="left") command=nimm_neu).pack(side="left")
ttk.Button(knoepfe, text="B: schon abgerechnet", width=22, ttk.Button(knoepfe, text="B: schon abgerechnet", width=22,
command=zu_schritt2).pack(side="left", padx=8) command=zu_schritt2).pack(side="left", padx=8)
ttk.Button(knoepfe, text="C: nur Storno", width=16,
command=nimm_nur).pack(side="left")
ttk.Button(knoepfe, text="Abbrechen", width=14, ttk.Button(knoepfe, text="Abbrechen", width=14,
command=d.destroy).pack(side="right") command=d.destroy).pack(side="right")
@ -497,7 +517,9 @@ class KorrekturMixin:
self.wait_window(d) self.wait_window(d)
if wahl["art"] == "vorhanden": if wahl["art"] == "vorhanden":
return "vorhanden", wahl["nummer"] return "vorhanden", wahl["nummer"]
return ("neu", None) if wahl["art"] == "neu" else None if wahl["art"] in ("neu", "nur"):
return wahl["art"], None
return None
def _storno_anlegen(self, meta, out_dir): def _storno_anlegen(self, meta, out_dir):
"""Schreibt die Storno-PDF und füllt das Formular für die berichtigte Rechnung.""" """Schreibt die Storno-PDF und füllt das Formular für die berichtigte Rechnung."""
@ -528,6 +550,11 @@ class KorrekturMixin:
# Es kommt keine neue Rechnung - das Storno verweist auf die, # Es kommt keine neue Rechnung - das Storno verweist auf die,
# die bleibt. Es wird also auch keine Nummer dafuer reserviert. # die bleibt. Es wird also auch keine Nummer dafuer reserviert.
folge_nummer = vorhandene folge_nummer = vorhandene
elif art_folge == "nur":
# Nur Storno: es folgt nichts, es gibt auch nichts, worauf zu
# verweisen waere. Keine Nummer reservieren - sonst reisst die
# fortlaufende Nummernfolge eine Luecke (Paragraph 14 Abs. 4 Nr. 4 UStG).
folge_nummer = ""
else: else:
folge_nummer = naechste_freie_nummer_nach(out_dir, self.cfg, nummer, folge_nummer = naechste_freie_nummer_nach(out_dir, self.cfg, nummer,
self._belegte_nummern()) self._belegte_nummern())
@ -579,6 +606,9 @@ class KorrekturMixin:
# Sagt dem Blatt, ob die Nummer eine BLEIBENDE Rechnung meint # Sagt dem Blatt, ob die Nummer eine BLEIBENDE Rechnung meint
# ("bereits abgerechnet mit") oder eine, die noch kommt. # ("bereits abgerechnet mit") oder eine, die noch kommt.
"ersatz_vorhanden": art_folge == "vorhanden", "ersatz_vorhanden": art_folge == "vorhanden",
# Sagt dem Blatt: es kommt NICHTS nach. Ohne das stuende dort der
# Satz von der "berichtigten Rechnung", die es nie geben wird.
"nur_storno": art_folge == "nur",
"storno_datum": str(meta.get("datum") or ""), "storno_datum": str(meta.get("datum") or ""),
"kunde": kunde, "kunde": kunde,
} }
@ -596,7 +626,7 @@ class KorrekturMixin:
# Nur wenn eine neue Rechnung folgt, bleibt ein Vorgang offen. Bei # Nur wenn eine neue Rechnung folgt, bleibt ein Vorgang offen. Bei
# "schon abgerechnet" ist mit dem Storno alles getan. # "schon abgerechnet" ist mit dem Storno alles getan.
self.storno_stand = None if art_folge == "vorhanden" else { self.storno_stand = None if art_folge in ("vorhanden", "nur") else {
"pfad": pfad, "nummer": nummer, "zu": alt_nummer, "pfad": pfad, "nummer": nummer, "zu": alt_nummer,
"vorgang": str(meta.get("vorgang") or alt_nummer), "vorgang": str(meta.get("vorgang") or alt_nummer),
"zu_datum": str(meta.get("datum") or ""), "zu_datum": str(meta.get("datum") or ""),

View file

@ -494,6 +494,16 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
"verrechnet.", st["dank"])) "verrechnet.", st["dank"]))
story.append(KeepTogether(block)) story.append(KeepTogether(block))
return return
if kopf.get("nur_storno"):
# Nur Storno (04.09.2026): die Leistung wird gar nicht abgerechnet.
# Hier darf NICHT von einer berichtigten Rechnung die Rede sein -
# es kommt keine, und der Gast wartet sonst darauf.
block.append(Paragraph(
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
"Die Leistung wird nicht abgerechnet – es folgt keine weitere "
"Rechnung. Bereits gezahlte Beträge werden erstattet.", st["dank"]))
story.append(KeepTogether(block))
return
kette = (f" Die berichtigte Rechnung trägt die Nummer {folgt} " kette = (f" Die berichtigte Rechnung trägt die Nummer {folgt} "
f"(also {kopf.get('storno_zu','')} → {kopf.get('rechnungsnummer','')} " f"(also {kopf.get('storno_zu','')} → {kopf.get('rechnungsnummer','')} "
f"→ {folgt}).") if folgt else "" f"→ {folgt}).") if folgt else ""
@ -631,6 +641,7 @@ def _kenndaten(rechnung, kopf):
"storno_zu": kopf.get("storno_zu", ""), "storno_zu": kopf.get("storno_zu", ""),
"korrektur_nummer": kopf.get("korrektur_nummer", ""), "korrektur_nummer": kopf.get("korrektur_nummer", ""),
"ersatz_vorhanden": bool(kopf.get("ersatz_vorhanden")), "ersatz_vorhanden": bool(kopf.get("ersatz_vorhanden")),
"nur_storno": bool(kopf.get("nur_storno")),
"storno_datum": kopf.get("storno_datum", ""), "storno_datum": kopf.get("storno_datum", ""),
"ersetzt": kopf.get("ersetzt", ""), "ersetzt": kopf.get("ersetzt", ""),
"storno_nummer": kopf.get("storno_nummer", ""), "storno_nummer": kopf.get("storno_nummer", ""),

202
pruef_nur_storno.py Normal file
View file

@ -0,0 +1,202 @@
# -*- coding: utf-8 -*-
"""Pruefstand: Version C - nur Storno, es folgt nichts.
Seine Ansage vom 04.09.2026:
"des wegen sollte mann auch eine version c mit only storno machen
weil das gibt es auch"
Es gab bisher nur zwei Wege aus dem Storno heraus:
A - es folgt eine neue Rechnung
B - die Leistung ist schon abgerechnet -> Verweis auf die bleibende Nummer
Beide behaupten etwas, das im dritten Fall nicht stimmt: die Buchung ist
geplatzt oder die Rechnung war komplett irrtuemlich, es wird also gar nichts
abgerechnet. Mit A wartet der Gast ewig auf eine Rechnung, die nie kommt (und
eine Nummer waere unnoetig reserviert), mit B muesste man auf eine Rechnung
verweisen, die es nicht gibt.
Geprueft wird am echten Fenster: der Knopf wird wirklich gedrueckt, danach
werden Blatt, Kenndaten, Nummernzaehler und das Steuerjournal angesehen.
Aufruf: python -u pruef_nur_storno.py
"""
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="nur_storno_")
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
messagebox.askyesno = lambda *a, **k: True
messagebox.showinfo = lambda *a, **k: None
messagebox.showwarning = lambda *a, **k: None
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 # 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 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
# ---- Eine Rechnung, die gleich wieder aufgehoben wird ---------------------
f.reset_formular()
f.k_vorname.set("Anna")
f.k_name.set("Platzer")
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("3")
f._testwahl = "rechnung"
f.update()
f.pdf_erstellen()
pruefe(os.listdir(ordner) == ["Rechnung_2026-001_Platzer.pdf"],
f"die Rechnung liegt im Ordner ({os.listdir(ordner)})")
zaehler_vorher = f.cfg.get("rechnung_zaehler")
# ---- Storno, im Fenster auf "C: nur Storno" ------------------------------
gedrueckt = {"c": False, "knoepfe": []}
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]
gedrueckt["knoepfe"] = [str(k.cget("text")) for k in widgets(top, ("TButton", "Button"))]
gedrueckt["c"] = druecke(top, "C: nur Storno")
if not gedrueckt["c"] and rest:
f.after(100, lambda: klick(rest - 1))
meta = [m for m in BE.rechnungen_im_ordner(ordner)
if str(m.get("rechnungsnummer")) == "2026-001"][0]
f.after(200, klick)
f._storno_anlegen(meta, ordner)
pruefe(gedrueckt["c"], f"der Knopf 'C: nur Storno' ist im Fenster ({gedrueckt['knoepfe']})")
pruefe(len(gedrueckt["knoepfe"]) == 4,
f"und die Wahl steht in EINEM Schritt: A, B, C, Abbrechen ({gedrueckt['knoepfe']})")
storno_datei = [n for n in os.listdir(ordner) if n.startswith("Storno_")]
pruefe(len(storno_datei) == 1, f"das Storno-Blatt liegt im Ordner ({storno_datei})")
# ---- Was auf dem Blatt steht ---------------------------------------------
pfad = os.path.join(ordner, storno_datei[0])
m = BE.kenndaten_lesen(pfad) or {}
pruefe(m.get("art") == "storno", f"art=storno ({m.get('art')!r})")
pruefe(m.get("nur_storno") is True,
f"die Kenndaten sagen 'nur_storno' ({m.get('nur_storno')!r})")
pruefe(not m.get("korrektur_nummer"),
f"KEINE Folgenummer in den Kenndaten ({m.get('korrektur_nummer')!r})")
pruefe(m.get("ersatz_vorhanden") is False,
f"und kein Verweis auf eine bleibende Rechnung ({m.get('ersatz_vorhanden')!r})")
pruefe(str(m.get("naechte")) == "-3",
f"der Storno traegt -3 Naechte ({m.get('naechte')!r})")
from pypdf import PdfReader # noqa: E402
text = " ".join((s.extract_text() or "") for s in PdfReader(pfad).pages).replace("\n", " ")
pruefe("Die Leistung wird nicht abgerechnet" in text,
"auf dem Blatt steht, dass die Leistung nicht abgerechnet wird")
pruefe("berichtigte Rechnung" not in text and "Berichtigte Rechnung" not in text,
"und KEIN Wort von einer berichtigten Rechnung, die nie kommt")
pruefe("bereits mit der Rechnung" not in text,
"und kein Verweis auf eine andere Rechnung")
# ---- Nummern und offener Vorgang -----------------------------------------
pruefe(f.storno_stand is None,
"es bleibt KEIN Vorgang offen - das Formular wartet auf nichts")
pruefe(f.cfg.get("rechnung_zaehler") == zaehler_vorher + 1,
f"nur die Storno-Nummer ist vergeben, keine zweite reserviert "
f"({zaehler_vorher} -> {f.cfg.get('rechnung_zaehler')})")
# ---- Und was das Steuerjournal daraus macht ------------------------------
sys.path.insert(0, JOURNAL)
import ablage # noqa: E402
ablage.daten_ordner = lambda: tmp
from db import Journal # noqa: E402
from modell import summiere, aufgehobene # noqa: E402
j = Journal(os.path.join(tmp, "journal.sqlite3"))
j.scanne(ordner, 5.0, voll=True)
buchungen = j.buchungen(jahr=2026)
weg = aufgehobene(buchungen)
s = summiere(buchungen, weg)
print("--- was im Journal steht ---")
for b in buchungen:
print(f" {b.voll_nummer} {b.nachname:10s} {b.naechte:>3} Naechte {b.gezahlt:>8.2f}")
pruefe(len(buchungen) == 2, f"Rechnung und Storno stehen beide im Journal ({len(buchungen)})")
pruefe(s.buchungen == 0 and s.naechte == 0 and abs(s.gezahlt) < 0.005,
f"gemeldet wird NICHTS: 0 Rechnungen, 0 Naechte, 0,00 Euro "
f"(ist {s.buchungen}, {s.naechte}, {s.gezahlt})")
j.schliessen()
f.destroy()
_wecker.cancel()
shutil.rmtree(tmp, ignore_errors=True)
print("---")
print("alles gruen" if not fehler else f"{len(fehler)} FEHLER")
sys.exit(1 if fehler else 0)