Vorgangsnummer: eine Klammer um die ganze Kette
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
This commit is contained in:
parent
d67448c58f
commit
1060a6421f
4 changed files with 174 additions and 0 deletions
2
app.py
2
app.py
|
|
@ -509,6 +509,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
}
|
}
|
||||||
if stand:
|
if stand:
|
||||||
kopf["art"] = "korrektur"
|
kopf["art"] = "korrektur"
|
||||||
|
kopf["vorgang"] = stand.get("vorgang") or stand["zu"]
|
||||||
kopf["ersetzt"] = stand["zu"]
|
kopf["ersetzt"] = stand["zu"]
|
||||||
kopf["storno_nummer"] = stand["nummer"]
|
kopf["storno_nummer"] = stand["nummer"]
|
||||||
out_dir = config.ensure_output_dir(self.cfg)
|
out_dir = config.ensure_output_dir(self.cfg)
|
||||||
|
|
@ -517,6 +518,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
ber = self.berichtigung_stand
|
ber = self.berichtigung_stand
|
||||||
if ber:
|
if ber:
|
||||||
kopf["art"] = "berichtigung"
|
kopf["art"] = "berichtigung"
|
||||||
|
kopf["vorgang"] = ber.get("vorgang") or ber["zu"]
|
||||||
kopf["berichtigt_zu"] = ber["zu"]
|
kopf["berichtigt_zu"] = ber["zu"]
|
||||||
kopf["berichtigt_am"] = datetime.date.today().strftime("%d.%m.%Y")
|
kopf["berichtigt_am"] = datetime.date.today().strftime("%d.%m.%Y")
|
||||||
if not messagebox.askyesno(
|
if not messagebox.askyesno(
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ class KorrekturMixin:
|
||||||
nummer = str(meta.get("rechnungsnummer", ""))
|
nummer = str(meta.get("rechnungsnummer", ""))
|
||||||
fehlend = self._formular_aus_meta(meta)
|
fehlend = self._formular_aus_meta(meta)
|
||||||
self.berichtigung_stand = {"zu": nummer, "datum": str(meta.get("datum") or ""),
|
self.berichtigung_stand = {"zu": nummer, "datum": str(meta.get("datum") or ""),
|
||||||
|
"vorgang": str(meta.get("vorgang") or nummer),
|
||||||
"meta": meta}
|
"meta": meta}
|
||||||
# Die Rechnung behaelt ihre Nummer - genau das ist der Unterschied zum Storno.
|
# Die Rechnung behaelt ihre Nummer - genau das ist der Unterschied zum Storno.
|
||||||
self.r_nummer.set(nummer)
|
self.r_nummer.set(nummer)
|
||||||
|
|
@ -268,6 +269,10 @@ class KorrekturMixin:
|
||||||
"nachname": kunde.get("nachname", ""),
|
"nachname": kunde.get("nachname", ""),
|
||||||
"vorname": kunde.get("vorname", ""),
|
"vorname": kunde.get("vorname", ""),
|
||||||
"art": "storno",
|
"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_zu": alt_nummer,
|
||||||
"storno_datum": str(meta.get("datum") or ""),
|
"storno_datum": str(meta.get("datum") or ""),
|
||||||
"kunde": kunde,
|
"kunde": kunde,
|
||||||
|
|
@ -286,6 +291,7 @@ class KorrekturMixin:
|
||||||
|
|
||||||
self.storno_stand = {
|
self.storno_stand = {
|
||||||
"pfad": pfad, "nummer": nummer, "zu": alt_nummer,
|
"pfad": pfad, "nummer": nummer, "zu": alt_nummer,
|
||||||
|
"vorgang": str(meta.get("vorgang") or alt_nummer),
|
||||||
"zu_datum": str(meta.get("datum") or ""),
|
"zu_datum": str(meta.get("datum") or ""),
|
||||||
"jahr_vorher": self.cfg.get("rechnung_jahr", datetime.date.today().year),
|
"jahr_vorher": self.cfg.get("rechnung_jahr", datetime.date.today().year),
|
||||||
"zaehler_vorher": self.cfg.get("rechnung_zaehler", 0),
|
"zaehler_vorher": self.cfg.get("rechnung_zaehler", 0),
|
||||||
|
|
|
||||||
|
|
@ -450,6 +450,7 @@ def _kenndaten(rechnung, kopf):
|
||||||
# nicht kennt, zaehlt dann 0,00 € statt den Umsatz ein zweites Mal.
|
# nicht kennt, zaehlt dann 0,00 € statt den Umsatz ein zweites Mal.
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
"quelle": "rechnungstool", "version": 3, "art": "berichtigung",
|
"quelle": "rechnungstool", "version": 3, "art": "berichtigung",
|
||||||
|
"vorgang": kopf.get("vorgang") or kopf.get("berichtigt_zu", ""),
|
||||||
"rechnungsnummer": kopf.get("berichtigt_zu", ""),
|
"rechnungsnummer": kopf.get("berichtigt_zu", ""),
|
||||||
"berichtigt_zu": kopf.get("berichtigt_zu", ""),
|
"berichtigt_zu": kopf.get("berichtigt_zu", ""),
|
||||||
"berichtigt_am": kopf.get("berichtigt_am", ""),
|
"berichtigt_am": kopf.get("berichtigt_am", ""),
|
||||||
|
|
@ -482,6 +483,12 @@ def _kenndaten(rechnung, kopf):
|
||||||
],
|
],
|
||||||
# neu in Version 3
|
# neu in Version 3
|
||||||
"art": kopf.get("art", "rechnung"),
|
"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_zu": kopf.get("storno_zu", ""),
|
||||||
"storno_datum": kopf.get("storno_datum", ""),
|
"storno_datum": kopf.get("storno_datum", ""),
|
||||||
"ersetzt": kopf.get("ersetzt", ""),
|
"ersetzt": kopf.get("ersetzt", ""),
|
||||||
|
|
|
||||||
159
pruef_kette.py
Normal file
159
pruef_kette.py
Normal file
|
|
@ -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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue