diff --git a/app.py b/app.py index ef07034..e6a6953 100644 --- a/app.py +++ b/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("<>", vorschau) - vorschau() + v_info.set(suche()[1]) + ent.bind("", 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("", 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 diff --git a/pruef_nummern.py b/pruef_nummern.py index 8357824..bb916c1 100644 --- a/pruef_nummern.py +++ b/pruef_nummern.py @@ -17,6 +17,7 @@ dateien = [ "Rechnung_2026-130_Schmidt.pdf", # laengere Nummer - darf NICHT treffen "Rechnung_2026-014_Meier.pdf", # andere Nummer "Rechnung_2026-020.pdf", # ohne Kundenname + "Storno_2026-021_Meier.pdf", # Storno verbraucht die Nummer auch "Notiz_2026-013.txt", # kein PDF ] for d in dateien: @@ -27,6 +28,7 @@ faelle = [ ("2026-013", ["Rechnung_2026-013_Meier.pdf"], "Nummer vergeben, anderer Kundenname"), ("2026-130", ["Rechnung_2026-130_Schmidt.pdf"], "laengere Nummer trifft sich selbst"), ("2026-020", ["Rechnung_2026-020.pdf"], "ohne Kundenname im Dateinamen"), + ("2026-021", ["Storno_2026-021_Meier.pdf"], "Nummer ist von einem Storno belegt"), ("2026-099", [], "freie Nummer"), ("2026-01", [], "Teilnummer darf nicht auf 2026-013 anspringen"), ] diff --git a/pruef_storno.py b/pruef_storno.py index 26a4e3d..a7233ac 100644 --- a/pruef_storno.py +++ b/pruef_storno.py @@ -59,7 +59,8 @@ def askyesno(titel, text, **kw): messagebox.askyesno = askyesno messagebox.showinfo = lambda *a, **k: None -messagebox.showwarning = lambda *a, **k: print("[warnung]", a[0] if a else "") +warnungen = [] +messagebox.showwarning = lambda *a, **k: warnungen.append(a[0] if a else "") messagebox.showerror = lambda *a, **k: print("[fehler]", a[0] if a else "", a[1] if len(a) > 1 else "") f = A.RechnungsApp() @@ -162,6 +163,72 @@ pruefe(os.path.exists(dritte), "die Rechnung 2026-003 steht unveraendert da") pruefe(len([n for n in os.listdir(ordner) if n.endswith('.pdf')]) == 3, "es liegen wieder genau 3 PDFs im Ordner") +# ---- 6. Suche im Storno-Dialog (Nummer eintippen, nicht auswaehlen) ----- +import tkinter as tk # noqa: E402 +from tkinter import ttk # noqa: E402 + +f.dlg_storno() +f.update() +dialog = [w for w in f.winfo_children() if isinstance(w, tk.Toplevel)][-1] +feld = None +schild = None +for rahmen in dialog.winfo_children(): + for w in rahmen.winfo_children(): + if isinstance(w, ttk.Entry): + feld = w + elif isinstance(w, ttk.Label) and w.cget("textvariable"): + schild = w +pruefe(feld is not None and schild is not None, "Eingabefeld und Meldezeile im Dialog da") + + +def tippe(text): + feld.delete(0, "end") + feld.insert(0, text) + feld.event_generate("") + f.update() + return f.getvar(schild.cget("textvariable")) + + +meldung = tippe("2026-003") +pruefe("Der Storno lautet" in meldung, f"gueltige Nummer gefunden: {meldung.splitlines()[0]}") +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") +meldung = tippe("2026-002") +pruefe("selbst eine Stornorechnung" in meldung, "Storno selbst ist nicht stornierbar") +meldung = tippe("2026-999") +pruefe("keine Rechnung" in meldung, "unbekannte Nummer wird abgelehnt") +meldung = tippe("") +pruefe("Nummer eintragen" in meldung, "leeres Feld sagt was zu tun ist") +dialog.destroy() + +# ---- 7. Pflichtfelder: ohne Anschrift wird nichts geschrieben ----------- +vorher = sorted(os.listdir(ordner)) +warnungen.clear() +f.k_strasse.set("") +f.k_plzort.set("") +f.anzahl_vars[erste].set("2") +f.update() +f.pdf_erstellen() +pruefe(warnungen and "Kundenangaben fehlen" in warnungen[0], + f"fehlende Anschrift wird gemeldet ({warnungen})") +pruefe(sorted(os.listdir(ordner)) == vorher, "ohne Anschrift wurde keine PDF geschrieben") + +warnungen.clear() +f.k_vorname.set("") +f.k_strasse.set("Musterweg 1") +f.k_plzort.set("12345 Musterstadt") +f.update() +f.pdf_erstellen() +pruefe(sorted(os.listdir(ordner)) == vorher, "auch ohne Vornamen wird nichts geschrieben") + +f.k_vorname.set("Anna") +f.update() +f.pdf_erstellen() +pruefe(len(os.listdir(ordner)) == len(vorher) + 1, + "mit allen Pflichtfeldern entsteht die PDF") + f.destroy() print("---") print(f"Ordner: {ordner}")