diff --git a/korrektur.py b/korrektur.py index 3a44c72..d0d4ccb 100644 --- a/korrektur.py +++ b/korrektur.py @@ -341,13 +341,21 @@ class KorrekturMixin: A - es folgt eine neue Rechnung (der bisherige Weg) 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 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). + 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, # der GENAU DIESES Fenster prueft, setzt _storno_dialog_zeigen. @@ -384,7 +392,13 @@ class KorrekturMixin: 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)) + "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) -------------------------- schritt2 = ttk.Frame(rahmen) @@ -446,6 +460,10 @@ class KorrekturMixin: wahl["art"] = "neu" d.destroy() + def nimm_nur(): + wahl["art"] = "nur" + d.destroy() + def uebernehmen(): nr = suchen() if not nr: @@ -484,6 +502,8 @@ class KorrekturMixin: 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="C: nur Storno", width=16, + command=nimm_nur).pack(side="left") ttk.Button(knoepfe, text="Abbrechen", width=14, command=d.destroy).pack(side="right") @@ -497,7 +517,9 @@ class KorrekturMixin: self.wait_window(d) if wahl["art"] == "vorhanden": 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): """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, # die bleibt. Es wird also auch keine Nummer dafuer reserviert. 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: folge_nummer = naechste_freie_nummer_nach(out_dir, self.cfg, nummer, self._belegte_nummern()) @@ -579,6 +606,9 @@ class KorrekturMixin: # Sagt dem Blatt, ob die Nummer eine BLEIBENDE Rechnung meint # ("bereits abgerechnet mit") oder eine, die noch kommt. "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 ""), "kunde": kunde, } @@ -596,7 +626,7 @@ class KorrekturMixin: # 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 { + self.storno_stand = None if art_folge in ("vorhanden", "nur") else { "pfad": pfad, "nummer": nummer, "zu": alt_nummer, "vorgang": str(meta.get("vorgang") or alt_nummer), "zu_datum": str(meta.get("datum") or ""), diff --git a/pdf_renderer.py b/pdf_renderer.py index e50bdf5..be483e9 100644 --- a/pdf_renderer.py +++ b/pdf_renderer.py @@ -494,6 +494,16 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): "verrechnet.", st["dank"])) story.append(KeepTogether(block)) 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} " f"(also {kopf.get('storno_zu','')} → {kopf.get('rechnungsnummer','')} " f"→ {folgt}).") if folgt else "" @@ -631,6 +641,7 @@ def _kenndaten(rechnung, kopf): "storno_zu": kopf.get("storno_zu", ""), "korrektur_nummer": kopf.get("korrektur_nummer", ""), "ersatz_vorhanden": bool(kopf.get("ersatz_vorhanden")), + "nur_storno": bool(kopf.get("nur_storno")), "storno_datum": kopf.get("storno_datum", ""), "ersetzt": kopf.get("ersetzt", ""), "storno_nummer": kopf.get("storno_nummer", ""), diff --git a/pruef_nur_storno.py b/pruef_nur_storno.py new file mode 100644 index 0000000..3888733 --- /dev/null +++ b/pruef_nur_storno.py @@ -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)