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:
parent
790ac4c0f6
commit
d2fee38bfd
3 changed files with 163 additions and 37 deletions
129
app.py
129
app.py
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue