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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
This commit is contained in:
TheMockTv 2026-09-02 20:42:31 +02:00
parent d22631467d
commit 3ea31b3421
9 changed files with 117 additions and 17 deletions

4
app.py
View file

@ -107,7 +107,9 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
lbl.bind("<Leave>", lambda e, w=lbl: w.configure(bg=self.col_bg))
lbl.bind("<Button-1>", lambda e, b=lbl, it=items: self._menu_popup(b, it))
ttk.Separator(self, orient="horizontal").pack(side="top", fill="x")
self.bind_all("<Control-p>", 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("<Control-p>", lambda e: self.pdf_erstellen())
def _menu_popup(self, anchor, items):
if self._open_pop: # offenes Menu zu -> Toggle

View file

@ -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 <vergeben> - 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.

View file

@ -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 = ""

View file

@ -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", ""),

View file

@ -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)

View file

@ -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}")

View file

@ -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}")

View file

@ -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)

View file

@ -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}")