Storno: Nummer eintippen statt Auswahlliste, Anrede als Liste, Pflichtfelder

- Storno-Dialog: die alte Nummer wird eingetippt, das Programm sucht sie im
  Ordner und zeigt sofort Datum, Name und den Betrag des Stornos. Kurze
  Schreibweise (2026-3) findet 2026-003.
- Zwei Funde beim Pruefen: Storno-PDFs heissen "Storno_..." und fielen sowohl
  aus der Ordnerliste als auch aus der Nummernpruefung heraus. Dadurch galt
  eine schon stornierte Rechnung als stornierbar und eine vom Storno belegte
  Nummer als frei - die haette ein zweites Mal vergeben werden koennen.
- Anrede ist eine Auswahlliste (tippbar fuer Sonderfaelle).
- Vorname, Nachname, Strasse und PLZ/Ort sind Pflicht; ohne sie wird keine
  PDF geschrieben (§ 14 Abs. 4 Nr. 1 UStG). Die alte "trotzdem erstellen?"-
  Frage ist raus.

Prueflauf: pruef_storno.py 46 Pruefungen gruen, pruef_nummern.py 7 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 19:11:37 +02:00
parent 790ac4c0f6
commit d2fee38bfd
3 changed files with 163 additions and 37 deletions

129
app.py
View file

@ -56,7 +56,10 @@ def vorhandene_rechnungen(ordner, nummer):
darum wird nur der Nummernteil verglichen - sonst rutscht dieselbe Nummer
unter einem anderen Namen als zweite Datei durch.
"""
kopf = "Rechnung_" + safe_filename(nummer)
# Stornos heissen "Storno_2026-002_Meier.pdf" - sie verbrauchen dieselben
# Nummern und muessen deshalb mitzaehlen, sonst wird eine Nummer zweimal
# vergeben.
koepfe = ("Rechnung_" + safe_filename(nummer), "Storno_" + safe_filename(nummer))
try:
namen = os.listdir(ordner)
except OSError:
@ -67,11 +70,31 @@ def vorhandene_rechnungen(ordner, nummer):
continue
stamm = name[:-4]
# exakt die Nummer oder Nummer + "_Kundenname" - "2026-130" faellt raus
if stamm == kopf or stamm.startswith(kopf + "_"):
if any(stamm == k or stamm.startswith(k + "_") for k in koepfe):
treffer.append(os.path.join(ordner, name))
return sorted(treffer)
# Auswahl fuer das Anrede-Feld. Die Liste ist nicht bindend - das Feld bleibt
# tippbar, damit Sonderfaelle nicht am Programm scheitern.
ANREDEN = ["Herr", "Frau", "Herr und Frau", "Familie", "Firma", "Eheleute"]
def nummer_normalisieren(s):
"""'2026-5', ' 2026-005 ' -> '2026-005'; alles andere kommt getrimmt zurueck.
Damit findet die Suche die Rechnung auch, wenn die fuehrenden Nullen fehlen.
"""
s = str(s or "").strip()
teile = s.split("-")
if len(teile) == 2:
try:
return config.format_rechnungsnummer(int(teile[0]), int(teile[1]))
except ValueError:
pass
return s
def kenndaten_lesen(pfad):
"""Kenndaten (Feld /Subject) aus einer vom Tool erzeugten PDF; None wenn keine.
@ -105,7 +128,9 @@ def rechnungen_im_ordner(ordner):
return []
gefunden = []
for name in namen:
if not name.lower().endswith(".pdf") or not name.startswith("Rechnung_"):
# Kein Filter auf den Dateinamen: Stornos heissen "Storno_...", und ob
# eine PDF von uns stammt, sagen ohnehin erst die Kenndaten.
if not name.lower().endswith(".pdf"):
continue
daten = kenndaten_lesen(os.path.join(ordner, name))
if daten:
@ -507,16 +532,25 @@ class RechnungsApp(tk.Tk):
self.k_strasse = tk.StringVar()
self.k_plzort = tk.StringVar()
felder = [
("Anrede (optional)", self.k_anrede),
("Vorname (optional)", self.k_vorname),
("Nachname / Firma", self.k_name),
("Anrede", self.k_anrede),
("Vorname *", self.k_vorname),
("Nachname / Firma *", self.k_name),
("Zusatz (optional)", self.k_zusatz),
("Straße / Nr.", self.k_strasse),
("PLZ / Ort", self.k_plzort),
("Straße / Nr. *", self.k_strasse),
("PLZ / Ort *", self.k_plzort),
]
for i, (lbl, var) in enumerate(felder):
ttk.Label(kunde, text=lbl).grid(row=i, column=0, sticky="w", pady=2)
ttk.Entry(kunde, textvariable=var, width=38).grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2)
if var is self.k_anrede:
# Auswahlliste, aber nicht gesperrt - Sonderfaelle kann man tippen.
w = ttk.Combobox(kunde, textvariable=var, width=36, values=ANREDEN)
else:
w = ttk.Entry(kunde, textvariable=var, width=38)
w.grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2)
ttk.Label(kunde, text="* Pflicht – Name und Anschrift des Kunden gehören "
"auf die Rechnung (§ 14 Abs. 4 Nr. 1 UStG).",
foreground=self.col_muted, wraplength=330, justify="left").grid(
row=len(felder), column=0, columnspan=2, sticky="w", pady=(6, 0))
kunde.columnconfigure(1, weight=1)
# Rechnungsinfos (rechts)
@ -786,9 +820,11 @@ class RechnungsApp(tk.Tk):
out_dir = config.ensure_output_dir(self.cfg)
alle = rechnungen_im_ordner(out_dir)
stornierte = {str(d.get("storno_zu") or "") for d in alle if d.get("art") == "storno"}
stornierte = {nummer_normalisieren(d.get("storno_zu"))
for d in alle if d.get("art") == "storno"}
offen = [d for d in alle
if d.get("art") != "storno" and d.get("rechnungsnummer") not in stornierte]
if d.get("art") != "storno"
and nummer_normalisieren(d.get("rechnungsnummer")) not in stornierte]
if not offen:
messagebox.showwarning(
"Nichts zu stornieren",
@ -813,36 +849,47 @@ class RechnungsApp(tk.Tk):
# Bewusst OHNE textvariable: eine StringVar, die nur an einer lokalen
# Variable haengt, raeumt Python nach dem Dialogaufbau weg - Tk loescht
# dann die Tcl-Variable und das Feld steht leer da.
werte = [f"{x['rechnungsnummer']} {x.get('datum','')} {x.get('nachname','')}".strip()
for x in offen]
box = ttk.Combobox(frm, values=werte, width=42, state="readonly")
box.grid(row=1, column=1, sticky="ew", pady=3, padx=(8, 0))
box.current(0)
ent = ttk.Entry(frm, width=20)
ent.grid(row=1, column=1, sticky="w", pady=3, padx=(8, 0))
ent.focus_set()
v_info = tk.StringVar(value="")
ttk.Label(frm, textvariable=v_info, justify="left", foreground=self.col_muted).grid(
row=2, column=0, columnspan=2, sticky="w", pady=(8, 0))
v_info = tk.StringVar(value="Nummer eintragen – das Programm sucht die Rechnung.")
ttk.Label(frm, textvariable=v_info, justify="left", wraplength=430).grid(
row=2, column=0, columnspan=2, sticky="w", pady=(10, 0))
def gewaehlt():
i = box.current()
return offen[i] if 0 <= i < len(offen) else None
def suche():
"""(Rechnung, Meldung) zur eingetippten Nummer. Rechnung=None -> Meldung sagt warum."""
nr = nummer_normalisieren(ent.get())
if not nr:
return None, "Nummer eintragen – das Programm sucht die Rechnung."
treffer = next((x for x in alle
if nummer_normalisieren(x.get("rechnungsnummer")) == nr), None)
if not treffer:
return None, f"Zu {nr} liegt in\n{out_dir}\nkeine Rechnung."
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."
gesamt = float(treffer.get("gesamt") or 0)
return treffer, (
f"Rechnung {treffer.get('rechnungsnummer')} vom {treffer.get('datum','')}"
f" {treffer.get('nachname','')}\n"
f"Rechnungsbetrag {berechnung.eur(treffer.get('zwischensumme') or 0)}, "
f"Gesamt {berechnung.eur(gesamt)}\n"
f"Der Storno lautet über {berechnung.eur(-abs(gesamt))}.")
def vorschau(*_a):
m = gewaehlt()
if not m:
return
v_info.set(f"Rechnungsbetrag {berechnung.eur(m.get('zwischensumme') or 0)}, "
f"Gesamt {berechnung.eur(m.get('gesamt') or 0)}\n"
f"Der Storno lautet über {berechnung.eur(-abs(float(m.get('gesamt') or 0)))}.")
box.bind("<<ComboboxSelected>>", vorschau)
vorschau()
v_info.set(suche()[1])
ent.bind("<KeyRelease>", vorschau)
def anlegen():
meta = gewaehlt()
def anlegen(*_a):
meta, meldung = suche()
if not meta:
v_info.set(meldung)
return
d.destroy()
self._storno_anlegen(meta, out_dir)
ent.bind("<Return>", anlegen)
btns = ttk.Frame(frm)
btns.grid(row=3, column=0, columnspan=2, pady=(14, 0), sticky="e")
@ -959,10 +1006,20 @@ class RechnungsApp(tk.Tk):
if not nummer:
messagebox.showwarning("Rechnungsnummer fehlt", "Bitte eine Rechnungsnummer eingeben.")
return
if not self.k_name.get().strip():
if not messagebox.askyesno("Kein Kundenname",
"Es ist kein Kundenname eingetragen. Trotzdem PDF erstellen?"):
return
# Name und Anschrift des Kunden sind Pflichtangaben auf der Rechnung
# (§ 14 Abs. 4 Nr. 1 UStG) - ohne sie wird nichts geschrieben.
fehlt = [name for name, var in (("Vorname", self.k_vorname),
("Nachname / Firma", self.k_name),
("Straße / Nr.", self.k_strasse),
("PLZ / Ort", self.k_plzort))
if not var.get().strip()]
if fehlt:
messagebox.showwarning(
"Kundenangaben fehlen",
"Diese Felder müssen ausgefüllt sein:\n\n- " + "\n- ".join(fehlt) +
"\n\nName und vollständige Anschrift des Kunden gehören auf jede "
"Rechnung (§ 14 Abs. 4 Nr. 1 UStG).")
return
# Auf der Rechnung stehen Vor- und Nachname wie gewohnt in einer Zeile;
# der Nachname geht zusätzlich getrennt mit, damit das Steuerjournal ihn