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:
parent
d22631467d
commit
3ea31b3421
9 changed files with 117 additions and 17 deletions
4
app.py
4
app.py
|
|
@ -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
|
||||
|
|
|
|||
35
bestand.py
35
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 <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.
|
||||
|
||||
|
|
|
|||
44
korrektur.py
44
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 = ""
|
||||
|
|
|
|||
|
|
@ -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", ""),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue