From 1060a6421ff1579724c74c940d79ffbcff4f32e8 Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Wed, 2 Sep 2026 20:03:17 +0200 Subject: [PATCH] Vorgangsnummer: eine Klammer um die ganze Kette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher zeigte jeder Beleg nur auf seinen Vorgaenger (storno_zu, ersetzt, storno_nummer). Ein zweites Programm musste sich die Kette daraus zusammensuchen. Jetzt tragen ALLE Blaetter einer Kette dieselbe Vorgangsnummer - die der ersten Rechnung. Gruppieren heisst dort: nach "vorgang" sortieren, fertig. Eine normale Rechnung ist ihr eigener Vorgang. Wichtig fuer den Fall "schon korrigierte Rechnung wird noch einmal storniert": Storno und Berichtigung uebernehmen den VORGANG der alten Rechnung, nicht deren Nummer - sonst zerfaellt die Kette in zwei. Die Einzelbezuege bleiben zusaetzlich stehen, damit man die Reihenfolge innerhalb der Kette lesen kann. Neu pruef_kette.py (13 Pruefungen): spielt den schlimmsten Fall durch - Rechnung, Storno, berichtigte Rechnung, Berichtigung der Anschrift, zweiter Storno, zweite berichtigte Rechnung. Alle sechs landen unter einem Schluessel, die Summe der Kette ergibt genau die letzte gueltige Rechnung (27,30 €), und der Steuerrechner liest davon 5 Geldbelege - das Berichtigungsblatt zaehlt nicht mit. 148 Pruefungen gruen (100 + 27 + 13 + 8) plus 7 Dialoge. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X --- app.py | 2 + korrektur.py | 6 ++ pdf_renderer.py | 7 +++ pruef_kette.py | 159 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 174 insertions(+) create mode 100644 pruef_kette.py diff --git a/app.py b/app.py index defd759..bc9c7f3 100644 --- a/app.py +++ b/app.py @@ -509,6 +509,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): } if stand: kopf["art"] = "korrektur" + kopf["vorgang"] = stand.get("vorgang") or stand["zu"] kopf["ersetzt"] = stand["zu"] kopf["storno_nummer"] = stand["nummer"] out_dir = config.ensure_output_dir(self.cfg) @@ -517,6 +518,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): ber = self.berichtigung_stand if ber: kopf["art"] = "berichtigung" + kopf["vorgang"] = ber.get("vorgang") or ber["zu"] kopf["berichtigt_zu"] = ber["zu"] kopf["berichtigt_am"] = datetime.date.today().strftime("%d.%m.%Y") if not messagebox.askyesno( diff --git a/korrektur.py b/korrektur.py index f6946a1..d2f605f 100644 --- a/korrektur.py +++ b/korrektur.py @@ -57,6 +57,7 @@ class KorrekturMixin: nummer = str(meta.get("rechnungsnummer", "")) fehlend = self._formular_aus_meta(meta) self.berichtigung_stand = {"zu": nummer, "datum": str(meta.get("datum") or ""), + "vorgang": str(meta.get("vorgang") or nummer), "meta": meta} # Die Rechnung behaelt ihre Nummer - genau das ist der Unterschied zum Storno. self.r_nummer.set(nummer) @@ -268,6 +269,10 @@ class KorrekturMixin: "nachname": kunde.get("nachname", ""), "vorname": kunde.get("vorname", ""), "art": "storno", + # Vorgang der alten Rechnung uebernehmen, nicht deren Nummer: wird eine + # schon berichtigte Rechnung noch einmal storniert, bleibt die ganze + # Kette unter EINEM Schluessel. + "vorgang": str(meta.get("vorgang") or alt_nummer), "storno_zu": alt_nummer, "storno_datum": str(meta.get("datum") or ""), "kunde": kunde, @@ -286,6 +291,7 @@ class KorrekturMixin: self.storno_stand = { "pfad": pfad, "nummer": nummer, "zu": alt_nummer, + "vorgang": str(meta.get("vorgang") or alt_nummer), "zu_datum": str(meta.get("datum") or ""), "jahr_vorher": self.cfg.get("rechnung_jahr", datetime.date.today().year), "zaehler_vorher": self.cfg.get("rechnung_zaehler", 0), diff --git a/pdf_renderer.py b/pdf_renderer.py index 3b4d5f4..f5ca977 100644 --- a/pdf_renderer.py +++ b/pdf_renderer.py @@ -450,6 +450,7 @@ def _kenndaten(rechnung, kopf): # nicht kennt, zaehlt dann 0,00 € statt den Umsatz ein zweites Mal. return json.dumps({ "quelle": "rechnungstool", "version": 3, "art": "berichtigung", + "vorgang": kopf.get("vorgang") or kopf.get("berichtigt_zu", ""), "rechnungsnummer": kopf.get("berichtigt_zu", ""), "berichtigt_zu": kopf.get("berichtigt_zu", ""), "berichtigt_am": kopf.get("berichtigt_am", ""), @@ -482,6 +483,12 @@ def _kenndaten(rechnung, kopf): ], # neu in Version 3 "art": kopf.get("art", "rechnung"), + # Klammer um die ganze Kette: Rechnung, ihr Storno, die berichtigte + # Rechnung und jede Berichtigung tragen DIESELBE Vorgangsnummer - die der + # allerersten Rechnung. Ein anderes Programm gruppiert danach in einem + # Schritt, statt sich die Kette ueber storno_zu/ersetzt zusammenzusuchen. + # Eine normale Rechnung ist ihr eigener Vorgang. + "vorgang": kopf.get("vorgang") or kopf.get("rechnungsnummer", ""), "storno_zu": kopf.get("storno_zu", ""), "storno_datum": kopf.get("storno_datum", ""), "ersetzt": kopf.get("ersetzt", ""), diff --git a/pruef_kette.py b/pruef_kette.py new file mode 100644 index 0000000..0c715ac --- /dev/null +++ b/pruef_kette.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- +"""Pruefstand: haelt die Kette zusammen, und kann ein anderes Programm sie ordnen? + +Der Ordner ist die Buchhaltung. Ein zweites Programm (Steuerrechner, +Steuerjournal) muss aus den PDFs allein erkennen koennen, was zusammengehoert: +welche Rechnung wurde storniert, welche ersetzt sie, welches Blatt berichtigt +nur die Anschrift. + +Dafuer traegt JEDES Blatt einer Kette dieselbe Vorgangsnummer - die der ersten +Rechnung. Gruppieren heisst dann: nach "vorgang" sortieren, fertig. Ohne den +Schluessel muesste man sich die Kette ueber storno_zu/ersetzt zusammensuchen. + +Durchgespielt wird der schlimmste Fall: Rechnung -> Storno -> berichtigte +Rechnung -> Berichtigung der Anschrift -> noch ein Storno -> zweite berichtigte +Rechnung. Alle sechs muessen unter EINEM Schluessel landen und in der Summe +genau die letzte gueltige Rechnung ergeben. +""" +import json +import os +import shutil +import sys +import tempfile + +QUELLE = r"C:\claude\rechnungstool" +sys.path.insert(0, QUELLE) + +tmp = tempfile.mkdtemp(prefix="kette_pruef_") +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["output_dir"] = ordner +json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False) + +import app as A # noqa: E402 +import bestand as BE # noqa: E402 +from tkinter import messagebox # noqa: E402 + +fehler = [] + + +def pruefe(bedingung, text): + print((" OK | " if bedingung else "FEHLER | ") + text) + if not bedingung: + fehler.append(text) + + +antworten = {"Fertig": False, "Storno wirklich": True, + "Berichtigte Rechnung erstellen": True, "Hausnummer": True} + + +def askyesno(titel, text="", **kw): + for schluessel, wert in antworten.items(): + if schluessel in titel: + return wert + raise AssertionError(f"unangemeldeter Dialog: {titel!r}") + + +messagebox.askyesno = askyesno +messagebox.showinfo = messagebox.showwarning = messagebox.showerror = \ + lambda *a, **k: None + +f = A.RechnungsApp() +f.update() +f.cfg["output_dir"] = ordner +erste = sorted(f.anzahl_vars)[0] + + +def kunde(strasse="Musterweg 1", tage="4"): + f.k_vorname.set("Anna") + f.k_name.set("Beispiel") + f.k_strasse.set(strasse) + f.k_plzort.set("12345 Musterstadt") + f.anzahl_vars[erste].set(tage) + f.update() + + +def rechnung_mit(nummer): + return next(x for x in BE.rechnungen_im_ordner(ordner) + if x.get("rechnungsnummer") == nummer and x.get("art") != "berichtigung") + + +# 1. die urspruengliche Rechnung +kunde(tage="4") +f.pdf_erstellen() +# 2. + 3. Storno und berichtigte Rechnung (falsche Naechtezahl) +f._storno_anlegen(rechnung_mit("2026-001"), ordner) +kunde(tage="3") +f.pdf_erstellen() +# 4. Berichtigung der Anschrift auf der berichtigten Rechnung +f._berichtigung_starten(rechnung_mit("2026-003"), ordner) +f.k_strasse.set("Richtige Straße 7") +f.update() +f.pdf_erstellen() +# 5. + 6. dieselbe Rechnung noch einmal stornieren und neu schreiben +f._storno_anlegen(rechnung_mit("2026-003"), ordner) +kunde(strasse="Richtige Straße 7", tage="2") +f.pdf_erstellen() +f.destroy() + +alle = BE.rechnungen_im_ordner(ordner) +print("--- was im Ordner liegt ---") +for x in sorted(alle, key=lambda d: (d.get("vorgang", ""), d.get("rechnungsnummer", ""))): + print(f" {x.get('vorgang',''):10s} {x.get('art',''):12s} " + f"{x.get('rechnungsnummer',''):10s} {float(x.get('gesamt') or 0):9.2f} " + f"{os.path.basename(x['_pfad'])}") + +pruefe(len(alle) == 6, f"sechs Belege im Ordner ({len(alle)})") +pruefe(all(x.get("vorgang") for x in alle), "jeder Beleg trägt eine Vorgangsnummer") + +# Genau das macht das andere Programm: nach "vorgang" gruppieren. +ketten = {} +for x in alle: + ketten.setdefault(x["vorgang"], []).append(x) +pruefe(len(ketten) == 1, f"alle sechs hängen an EINEM Vorgang ({list(ketten)})") +pruefe(list(ketten) == ["2026-001"], + f"der Schlüssel ist die erste Rechnung ({list(ketten)})") + +kette = ketten.get("2026-001", []) +arten = sorted(x.get("art") for x in kette) +pruefe(arten == ["berichtigung", "korrektur", "korrektur", "rechnung", "storno", "storno"], + f"Arten der Kette stimmen ({arten})") + +# Die Summe ueber die Kette muss die letzte gueltige Rechnung sein. +letzte = max((x for x in kette if x.get("art") in ("rechnung", "korrektur")), + key=lambda d: d["rechnungsnummer"]) +summe = round(sum(float(x.get("gesamt") or 0) for x in kette), 2) +pruefe(abs(summe - float(letzte["gesamt"])) < 0.005, + f"Summe der Kette = letzte gültige Rechnung ({summe:.2f} vs {letzte['gesamt']:.2f})") + +# Jeder Beleg zeigt ausserdem einzeln auf seinen Vorgaenger. +for x in kette: + if x.get("art") == "storno": + pruefe(bool(x.get("storno_zu")), f"Storno {x['rechnungsnummer']} nennt seine Rechnung") + elif x.get("art") == "korrektur": + pruefe(bool(x.get("ersetzt")) and bool(x.get("storno_nummer")), + f"Korrektur {x['rechnungsnummer']} nennt Rechnung und Storno") + elif x.get("art") == "berichtigung": + pruefe(bool(x.get("berichtigt_zu")), + f"Berichtigung nennt die Rechnung ({x.get('berichtigt_zu')})") + +# Gegenprobe mit dem Steuerrechner: er darf nur die Geldbelege sehen. +sys.path.insert(0, r"C:\claude\steuerrechner") +import einnahmen # noqa: E402 +liste, kaputt = einnahmen.scanne(ordner, {"ust_saetze": [7, 19]}) +pruefe(len(liste) == 5 and not kaputt, + f"Steuerrechner liest 5 Belege, kein Fehler ({len(liste)}, {kaputt})") +jahr = round(sum(e.gesamt for e in liste), 2) +pruefe(abs(jahr - float(letzte["gesamt"])) < 0.005, + f"Jahressumme = letzte gültige Rechnung ({jahr:.2f} vs {letzte['gesamt']:.2f})") + +print("---") +print(f"Ordner: {ordner}") +print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}") +sys.exit(1 if fehler else 0)