From 3ea31b34219f2873c9eb5309892fc11bcc358b25 Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Wed, 2 Sep 2026 20:42:31 +0200 Subject: [PATCH] Die Kette steht auf dem Storno-Blatt - kein gemerkter Zustand noetig Statt einer Zustandsmaschine ("offener Storno" ueber den Neustart retten) traegt das Storno jetzt einfach beide Nummern: Storno zu Rechnung: 2026-002 Neue Rechnung: 2026-004 und im Text "2026-002 -> 2026-003 -> 2026-004". Damit ist die Kette aus dem Beleg allein lesbar. Wird das Programm zwischendurch geschlossen, sagt der Storno, unter welcher Nummer die berichtigte Rechnung gehoert; der Dialog nennt sie ebenfalls, statt nur "bereits storniert" zu melden. naechste_freie_nummer_nach() bestimmt die Folgenummer, damit sie nicht von der Reihenfolge abhaengt, in der der Zaehler mitzieht. Ausserdem: die Pruefstaende raeumen ihren Wegwerf-Ordner wieder weg, wenn sie gruen sind. Ein Testlauf hatte 398 Ordner im Temp-Verzeichnis hinterlassen. Bei Fehlern bleibt der Ordner stehen, damit man hineinsehen kann. Weitere Funde des Code-Agenten behoben: - "Kopie von Rechnung_...pdf" und klein geschriebene Dateinamen zaehlen jetzt bei der Nummernvergabe mit (vorher galt die Nummer als frei). - Strg+P feuerte auch aus einem offenen Dialog heraus (bind_all -> bind). 167 Pruefungen gruen. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X --- app.py | 4 +++- bestand.py | 35 +++++++++++++++++++++++++++++++---- korrektur.py | 44 +++++++++++++++++++++++++++++++++++++------- pdf_renderer.py | 11 ++++++++++- pruef_dialoge.py | 6 ++++++ pruef_kernregeln.py | 6 ++++++ pruef_kette.py | 6 ++++++ pruef_nummern.py | 13 ++++++++++--- pruef_storno.py | 9 ++++++++- 9 files changed, 117 insertions(+), 17 deletions(-) diff --git a/app.py b/app.py index bc9c7f3..595b789 100644 --- a/app.py +++ b/app.py @@ -107,7 +107,9 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): lbl.bind("", lambda e, w=lbl: w.configure(bg=self.col_bg)) lbl.bind("", lambda e, b=lbl, it=items: self._menu_popup(b, it)) ttk.Separator(self, orient="horizontal").pack(side="top", fill="x") - self.bind_all("", lambda e: self.pdf_erstellen()) + # bind statt bind_all: sonst feuert Strg+P auch aus einem offenen + # Dialog heraus und schreibt im Formular dahinter eine Rechnung. + self.bind("", lambda e: self.pdf_erstellen()) def _menu_popup(self, anchor, items): if self._open_pop: # offenes Menu zu -> Toggle diff --git a/bestand.py b/bestand.py index 3c4b2cc..baac192 100644 --- a/bestand.py +++ b/bestand.py @@ -28,17 +28,25 @@ def vorhandene_rechnungen(ordner, nummer): namen = os.listdir(ordner) except OSError: return [] + # Windows haengt beim Kopieren " - Kopie" an oder setzt "Kopie von " davor. + VORSAETZE = ("kopie von ", "kopie (2) von ", "copy of ") treffer = [] for name in namen: if not name.lower().endswith(".pdf"): continue stamm = name[:-4] + for vorsatz in VORSAETZE: + if stamm.lower().startswith(vorsatz): + stamm = stamm[len(vorsatz):] + break # Nach der Nummer darf alles kommen, nur keine weitere Ziffer. Damit - # zaehlen auch die Windows-Kopien mit - "Rechnung_2026-013 (1).pdf", - # "... - Kopie.pdf", "Kopie von ...". Genau so sind die alten Dubletten - # entstanden. "2026-0135" faellt raus, weil dort eine Ziffer folgt. + # zaehlen auch "Rechnung_2026-013 (1).pdf" und "... - Kopie.pdf" mit - + # genau so sind die alten Dubletten entstanden. "2026-0135" faellt raus, + # weil dort eine Ziffer folgt. + klein = stamm.lower() for k in koepfe: - if stamm.startswith(k) and (len(stamm) == len(k) or not stamm[len(k)].isdigit()): + if klein.startswith(k.lower()) and ( + len(stamm) == len(k) or not stamm[len(k)].isdigit()): treffer.append(os.path.join(ordner, name)) break return sorted(treffer) @@ -160,6 +168,25 @@ def rechnungen_im_ordner(ordner): gefunden.append(daten) gefunden.sort(key=lambda d: str(d.get("rechnungsnummer", "")), reverse=True) return gefunden +def naechste_freie_nummer_nach(ordner, cfg, vergeben): + """Erste freie Nummer NACH - fuer die Rechnung, die auf ein Storno folgt. + + Das Storno hat seine Nummer gerade bekommen; je nachdem, ob der Zaehler schon + mitgezogen ist, liefert naechste_freie_nummer() sonst genau diese Nummer noch + einmal. + """ + kandidat = naechste_freie_nummer(ordner, cfg) + if kandidat and kandidat != vergeben: + return kandidat + ersatz = dict(cfg) + try: + jahr, z = str(vergeben).split("-") + ersatz["rechnung_jahr"], ersatz["rechnung_zaehler"] = int(jahr), int(z) + except (ValueError, AttributeError): + return kandidat + return naechste_freie_nummer(ordner, ersatz) + + def naechste_freie_nummer(ordner, cfg): """Erste Nummer JJJJ-NNN, zu der im Ordner noch kein PDF liegt. diff --git a/korrektur.py b/korrektur.py index 61e5cfe..ebdb203 100644 --- a/korrektur.py +++ b/korrektur.py @@ -25,7 +25,8 @@ from tkinter import ttk, messagebox import berechnung import config import pdf_renderer -from bestand import kundendaten_ermitteln, naechste_freie_nummer, rechnungen_im_ordner +from bestand import (kundendaten_ermitteln, naechste_freie_nummer, + naechste_freie_nummer_nach, rechnungen_im_ordner) from helfer import kunde_maengel, nummer_normalisieren, safe_filename from storno import storno_rechnung_bauen @@ -35,6 +36,8 @@ class KorrekturMixin: # ------------------------------------------------------------- Storno + + def _storno_anzeigen(self): """Hinweiszeile und Sperre der Rechnungsnummer an den Zwischenspeicher anpassen.""" stand = self.storno_stand @@ -140,8 +143,12 @@ class KorrekturMixin: out_dir = config.ensure_output_dir(self.cfg) alle = rechnungen_im_ordner(out_dir) - stornierte = {nummer_normalisieren(d.get("storno_zu")) - for d in alle if d.get("art") == "storno"} + stornos = {nummer_normalisieren(d.get("storno_zu")): d + for d in alle if d.get("art") == "storno"} + stornierte = set(stornos) + # Zu welchen Rechnungen gibt es schon eine berichtigte Rechnung? + ersetzte = {nummer_normalisieren(d.get("ersetzt")) + for d in alle if d.get("art") == "korrektur"} offen = [d for d in alle if d.get("art") not in ("storno", "berichtigung") and nummer_normalisieren(d.get("rechnungsnummer")) not in stornierte] @@ -238,8 +245,20 @@ class KorrekturMixin: if treffer.get("art") == "storno": return None, f"{nr} ist selbst eine Stornorechnung." - if nummer_normalisieren(treffer.get("rechnungsnummer")) in stornierte: - return None, f"{nr} wurde bereits storniert – ein zweites Mal geht nicht." + nr_norm = nummer_normalisieren(treffer.get("rechnungsnummer")) + self._offener_storno = None + if nr_norm in stornierte: + st = stornos.get(nr_norm) or {} + folgt = st.get("korrektur_nummer") or "" + if nr_norm in ersetzte or not folgt: + return None, (f"{nr} wurde bereits mit {st.get('rechnungsnummer','?')} " + "storniert – ein zweites Mal geht nicht.") + # Auf dem Storno steht, welche Nummer die berichtigte Rechnung + # bekommt. Damit braucht es keinen gemerkten Zustand: fehlt sie + # noch, wird sie einfach unter dieser Nummer geschrieben. + return None, (f"{nr} ist mit {st.get('rechnungsnummer','?')} storniert. " + f"Die berichtigte Rechnung gehört unter die Nummer {folgt} – " + "die steht auch auf dem Storno-Blatt.") gesamt = float(treffer.get("gesamt") or 0) mehrfach = (f"⚠ Achtung: die Nummer liegt {len(gefunden)}× im Ordner.\n" if len(gefunden) > 1 else "") @@ -263,7 +282,9 @@ class KorrekturMixin: v_info.set(meldung) return d.destroy() - if storno: + if storno and getattr(self, "_offener_storno", None): + self._storno_fortsetzen(self._offener_storno, meta, out_dir) + elif storno: self._storno_anlegen(meta, out_dir) else: self._berichtigung_starten(meta, out_dir) @@ -276,6 +297,7 @@ class KorrekturMixin: ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right") frm.columnconfigure(1, weight=1) + 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", "")) @@ -287,6 +309,11 @@ class KorrekturMixin: r = storno_rechnung_bauen(meta, self.cfg) heute = datetime.date.today().strftime("%d.%m.%Y") + # Welche Nummer die berichtigte Rechnung bekommt, steht schon jetzt fest - + # 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) # 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) @@ -322,6 +349,9 @@ class KorrekturMixin: # Kette unter EINEM Schluessel. "vorgang": str(meta.get("vorgang") or alt_nummer), "storno_zu": alt_nummer, + # alte Nummer -> diese Nummer. Steht auf dem Blatt, damit die Kette + # aus dem Storno allein lesbar ist. + "korrektur_nummer": folge_nummer or "", "storno_datum": str(meta.get("datum") or ""), "kunde": kunde, } @@ -348,7 +378,7 @@ class KorrekturMixin: print(f"[storno] erstellt -> {pfad}") fehlend = self._formular_aus_meta(meta) - self.r_nummer.set(naechste_freie_nummer(out_dir, self.cfg) or "") + self.r_nummer.set(folge_nummer or "") self._storno_anzeigen() hinweis = "" diff --git a/pdf_renderer.py b/pdf_renderer.py index f5ca977..bfc69ff 100644 --- a/pdf_renderer.py +++ b/pdf_renderer.py @@ -221,6 +221,10 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): info_rows.append(("Storno zu Rechnung:", kopf.get("storno_zu", ""))) if kopf.get("storno_datum"): info_rows.append(("Rechnung vom:", kopf["storno_datum"])) + # Die Nummer, unter der die berichtigte Rechnung kommt. Damit steht die + # ganze Kette auf dem Blatt: alte Nummer -> Storno -> neue Nummer. + if kopf.get("korrektur_nummer"): + info_rows.append(("Neue Rechnung:", kopf["korrektur_nummer"])) elif art == "korrektur" and kopf.get("ersetzt"): info_rows.append(("Ersetzt Rechnung:", kopf.get("ersetzt", ""))) elif art == "berichtigung": @@ -384,10 +388,14 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): bezug = kopf.get("storno_zu", "") if kopf.get("storno_datum"): bezug += f" vom {kopf['storno_datum']}" + folgt = kopf.get("korrektur_nummer") or "" + kette = (f" Die berichtigte Rechnung trägt die Nummer {folgt} " + f"(also {kopf.get('storno_zu','')} → {kopf.get('rechnungsnummer','')} " + f"→ {folgt}).") if folgt else "" block.append(Paragraph( f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. " "Bereits gezahlte Beträge werden erstattet oder mit der berichtigten " - "Rechnung verrechnet.", st["dank"])) + "Rechnung verrechnet." + kette, st["dank"])) elif art == "berichtigung": # Der Satz ist kein Beiwerk: mehrere Rechnungen ueber dieselbe Leistung # ohne Kennzeichnung loesen die Steuer erneut aus (Abschn. 14c.1 Abs. 4 @@ -490,6 +498,7 @@ def _kenndaten(rechnung, kopf): # Eine normale Rechnung ist ihr eigener Vorgang. "vorgang": kopf.get("vorgang") or kopf.get("rechnungsnummer", ""), "storno_zu": kopf.get("storno_zu", ""), + "korrektur_nummer": kopf.get("korrektur_nummer", ""), "storno_datum": kopf.get("storno_datum", ""), "ersetzt": kopf.get("ersetzt", ""), "storno_nummer": kopf.get("storno_nummer", ""), diff --git a/pruef_dialoge.py b/pruef_dialoge.py index 37621f8..1bb4b4b 100644 --- a/pruef_dialoge.py +++ b/pruef_dialoge.py @@ -86,6 +86,12 @@ except Exception as e: # noqa: BLE001 fehler += 1 f.destroy() + +# Wegwerf-Ordner nur bei Fehlern behalten - sonst laeuft das Temp-Verzeichnis +# voll. Ein Pruefstand, der 400 Ordner hinterlaesst, ist selbst ein Problem. +if not fehler: # fehler ist hier ein Zaehler + import shutil as _sh + _sh.rmtree(tmp, ignore_errors=True) print("---") print("alles gruen" if not fehler else f"{fehler} Fehler") sys.exit(1 if fehler else 0) diff --git a/pruef_kernregeln.py b/pruef_kernregeln.py index ffe39a4..fb9852b 100644 --- a/pruef_kernregeln.py +++ b/pruef_kernregeln.py @@ -357,6 +357,12 @@ f._storno_verwerfen("Pruefstand raeumt auf") os.remove(kopie) f.destroy() + +# Wegwerf-Ordner nur bei Fehlern behalten - sonst laeuft das Temp-Verzeichnis +# voll. Ein Pruefstand, der 400 Ordner hinterlaesst, ist selbst ein Problem. +if not fehler: + import shutil as _sh + _sh.rmtree(tmp, ignore_errors=True) print("---") print(f"Ordner: {ordner}") print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}") diff --git a/pruef_kette.py b/pruef_kette.py index ef95042..d90e1dc 100644 --- a/pruef_kette.py +++ b/pruef_kette.py @@ -157,6 +157,12 @@ 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})") + +# Wegwerf-Ordner nur bei Fehlern behalten - sonst laeuft das Temp-Verzeichnis +# voll. Ein Pruefstand, der 400 Ordner hinterlaesst, ist selbst ein Problem. +if not fehler: + import shutil as _sh + _sh.rmtree(tmp, ignore_errors=True) print("---") print(f"Ordner: {ordner}") print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}") diff --git a/pruef_nummern.py b/pruef_nummern.py index 8790709..fbe999a 100644 --- a/pruef_nummern.py +++ b/pruef_nummern.py @@ -22,6 +22,8 @@ dateien = [ "Rechnung_2026-050_Alt (1).pdf", "Rechnung_2026-050_Alt - Kopie.pdf", "Rechnung_2026-050 (2).pdf", + "Kopie von Rechnung_2026-050_Alt.pdf", # aeltere Windows-Fassung + "rechnung_2026-050_klein.pdf", # von Hand klein geschrieben "Rechnung_2026-0501_Neu.pdf", # laengere Nummer - darf NICHT mitzaehlen "Rechnung_2026-014_Meier.pdf", # andere Nummer "Rechnung_2026-020.pdf", # ohne Kundenname @@ -38,9 +40,10 @@ faelle = [ ("2026-777", ["Rechnung_2026-777_Gross.PDF"], "Endung GROSS geschrieben wird gefunden"), ("2026-020", ["Rechnung_2026-020.pdf"], "ohne Kundenname im Dateinamen"), ("2026-021", ["Storno_2026-021_Meier.pdf"], "Nummer ist von einem Storno belegt"), - ("2026-050", ["Rechnung_2026-050 (2).pdf", "Rechnung_2026-050_Alt (1).pdf", - "Rechnung_2026-050_Alt - Kopie.pdf", "Rechnung_2026-050_Alt.pdf"], - "Windows-Kopien (1)/- Kopie zaehlen mit"), + ("2026-050", ["Kopie von Rechnung_2026-050_Alt.pdf", "Rechnung_2026-050 (2).pdf", + "Rechnung_2026-050_Alt (1).pdf", "Rechnung_2026-050_Alt - Kopie.pdf", + "Rechnung_2026-050_Alt.pdf", "rechnung_2026-050_klein.pdf"], + "alle Windows-Kopien und Kleinschreibung zaehlen mit"), ("2026-0501", ["Rechnung_2026-0501_Neu.pdf"], "laengere Nummer bleibt getrennt"), ("2026-099", [], "freie Nummer"), ("2026-01", [], "Teilnummer darf nicht auf 2026-013 anspringen"), @@ -60,6 +63,10 @@ if vorhandene_rechnungen(os.path.join(ordner, "gibtsnicht"), "2026-013") != []: else: print(" OK | fehlender Ordner gibt leere Liste") +# Wegwerf-Ordner nur bei Fehlern behalten +if not fehler: + import shutil as _sh + _sh.rmtree(ordner, ignore_errors=True) print("---") print("alles gruen" if fehler == 0 else f"{fehler} Fehler") sys.exit(1 if fehler else 0) diff --git a/pruef_storno.py b/pruef_storno.py index 037ed58..deb8639 100644 --- a/pruef_storno.py +++ b/pruef_storno.py @@ -207,7 +207,8 @@ pruefe("Der Storno lautet" in meldung, f"gueltige Nummer gefunden: {meldung.spli meldung = tippe("2026-3") pruefe("2026-003" in meldung, "kurze Schreibweise 2026-3 findet 2026-003") meldung = tippe("2026-001") -pruefe("bereits storniert" in meldung, "schon stornierte Rechnung wird abgelehnt") +pruefe("storniert" in meldung and "zweites Mal" in meldung, + f"schon stornierte Rechnung wird abgelehnt ({meldung})") meldung = tippe("2026-002") pruefe("selbst eine Stornorechnung" in meldung, "Storno selbst ist nicht stornierbar") meldung = tippe("2026-999") @@ -404,6 +405,12 @@ for name in ("Rechnung_2026-910.pdf", os.path.basename(ziel)): os.remove(os.path.join(ordner, name)) f.destroy() + +# Wegwerf-Ordner nur bei Fehlern behalten - sonst laeuft das Temp-Verzeichnis +# voll. Ein Pruefstand, der 400 Ordner hinterlaesst, ist selbst ein Problem. +if not fehler: + import shutil as _sh + _sh.rmtree(tmp, ignore_errors=True) print("---") print(f"Ordner: {ordner}") print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")