From a0cfc31ae6d95b1bf6a2a214b4d8e7826e4066e6 Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Sat, 1 Aug 2026 12:33:53 +0200 Subject: [PATCH 01/14] Kenndaten-Metadaten v2: USt-Aufteilung je Satz Der Steuerrechner braucht Brutto->Netto je Steuersatz; auf einer Rechnung stecken 7 % (Uebernachtung) und 19 % (Waesche, Gas, Rad) gemischt drin, und die Aufteilung stand bisher nur im Fliesstext. Neu in /Subject: netto_gesamt, ust_gesamt, ust_bloecke[{satz,netto,ust,brutto}]. Alle Felder der Version 1 bleiben unveraendert - am zwischensumme/steuer_satz/ steuer_betrag haengt der Import des Steuerjournals. Verifiziert an einer gemischten Testrechnung (7 % + 19 %): Summe der Bloecke = Zwischensumme = netto_gesamt + ust_gesamt; Steuerjournal liest die v2-PDF unveraendert ein. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TxFbNUnzkEk7FRFcZQoepm --- pdf_renderer.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/pdf_renderer.py b/pdf_renderer.py index c554322..d1fcce9 100644 --- a/pdf_renderer.py +++ b/pdf_renderer.py @@ -368,11 +368,17 @@ def _kenndaten(rechnung, kopf): Das Steuerjournal (Programm "Beherbergungssteuer") liest sie von dort aus, statt Nachname und Betraege aus dem Fliesstext zu raten. Fuer den Ausdruck aendert sich nichts - Metadaten stehen nicht auf dem Blatt. + + Version 2 ergaenzt die USt-Aufteilung ("ust_bloecke") fuers dritte Programm, + den Steuerrechner: der braucht Brutto->Netto je Steuersatz, und 7 % und 19 % + stecken auf einer Rechnung gemischt drin. Alle Felder der Version 1 bleiben + unveraendert stehen - am "zwischensumme"/"steuer_satz"/"steuer_betrag" haengt + der Import des Steuerjournals, das darf nicht brechen. """ aufschlag = rechnung.aufschlaege[0] if rechnung.aufschlaege else None daten = { "quelle": "rechnungstool", - "version": 1, + "version": 2, "rechnungsnummer": kopf.get("rechnungsnummer", ""), "datum": kopf.get("datum", ""), "nachname": kopf.get("nachname", ""), @@ -382,6 +388,14 @@ def _kenndaten(rechnung, kopf): "steuer_satz": float(aufschlag.satz) if aufschlag else 0.0, "steuer_betrag": runde(aufschlag.betrag) if aufschlag else 0.0, "gesamt": runde(rechnung.gesamt), + # neu in Version 2: je USt-Satz ein Block (Netto, enthaltene USt, Brutto) + "netto_gesamt": runde(rechnung.netto_gesamt), + "ust_gesamt": runde(rechnung.ust_gesamt), + "ust_bloecke": [ + {"satz": b.satz, "netto": runde(b.netto), "ust": runde(b.ust), + "brutto": runde(b.brutto)} + for b in rechnung.bloecke + ], } return json.dumps(daten, ensure_ascii=False) From 1564d769c9f753c6b67dc858f7222eaa834e4fd8 Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Wed, 2 Sep 2026 18:28:58 +0200 Subject: [PATCH 02/14] Nur eine Instanz: zweiter Start holt das offene Fenster nach vorn Zwei offene Fenster lesen beim Start denselben Zaehler aus der config und schlagen darum dieselbe Rechnungsnummer vor - die Nummer wurde doppelt vergeben. - Windows-Mutex sperrt eine zweite Instanz ab. - Die laufende Instanz legt ihre PID neben der config.json ab; die zweite sucht darueber das Fenster (nicht ueber den Titel - der haengt am Firmennamen aus der config) und holt es nach vorn. - Popup nur noch als Rueckfall, wenn kein Fenster gefunden wird. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X --- .gitignore | 1 + app.py | 105 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/.gitignore b/.gitignore index c0d17af..3833f58 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ config.json _tmp/ *.7z.* release/ +rechnungstool.pid diff --git a/app.py b/app.py index 905f65f..c60d9d9 100644 --- a/app.py +++ b/app.py @@ -48,6 +48,94 @@ def safe_filename(s): return "".join(c for c in s if c.isalnum() or c in keep).strip() or "Rechnung" +def einzelinstanz_pruefen(): + """Sperrt das Programm gegen ein zweites Fenster. + + Beide Fenster lesen beim Start denselben Zaehler aus der config und + schlagen darum dieselbe Rechnungsnummer vor -> die Nummer wird doppelt + vergeben. Darum: nur eine Instanz je Windows-Sitzung. + + Rueckgabe: Handle (offen halten, solange das Programm laeuft) oder + False, wenn schon eine Instanz laeuft. + """ + if not sys.platform.startswith("win"): + return None + import ctypes + from ctypes import wintypes + ERROR_ALREADY_EXISTS = 183 + k32 = ctypes.WinDLL("kernel32", use_last_error=True) + k32.CreateMutexW.argtypes = [wintypes.LPVOID, wintypes.BOOL, wintypes.LPCWSTR] + k32.CreateMutexW.restype = wintypes.HANDLE + handle = k32.CreateMutexW(None, False, "RechnungstoolCampinghofBartl") + if ctypes.get_last_error() == ERROR_ALREADY_EXISTS: + return False + return handle + + + +# Merkzettel mit der PID der laufenden Instanz - liegt neben der config.json. +PID_DATEI = os.path.join(config.BASE_DIR, "rechnungstool.pid") + + +def eigene_pid_merken(): + """Schreibt die eigene PID, damit eine zweite Instanz das Fenster findet.""" + try: + with open(PID_DATEI, "w", encoding="ascii") as f: + f.write(str(os.getpid())) + except OSError: + pass + + +def laufende_pid_lesen(): + """PID der laufenden Instanz aus dem Merkzettel; None wenn unlesbar.""" + try: + with open(PID_DATEI, "r", encoding="ascii") as f: + return int(f.read().strip()) + except (OSError, ValueError): + return None + + +def fenster_nach_vorn(pid): + """Holt das Fenster des Prozesses nach vorn. True bei Erfolg. + + Gesucht wird ueber die PID, nicht ueber den Fenstertitel - der Titel + haengt am Firmennamen aus der config und aendert sich mit ihm. + """ + if not pid or not sys.platform.startswith("win"): + return False + import ctypes + from ctypes import wintypes + SW_RESTORE = 9 + u32 = ctypes.WinDLL("user32", use_last_error=True) + u32.IsWindowVisible.argtypes = [wintypes.HWND] + u32.IsIconic.argtypes = [wintypes.HWND] + u32.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int] + u32.SetForegroundWindow.argtypes = [wintypes.HWND] + u32.GetWindowTextLengthW.argtypes = [wintypes.HWND] + u32.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)] + + treffer = [] + + def besuch(hwnd, _lparam): + fremd = wintypes.DWORD() + u32.GetWindowThreadProcessId(hwnd, ctypes.byref(fremd)) + # nur das echte Hauptfenster: sichtbar und mit Titel + if fremd.value == pid and u32.IsWindowVisible(hwnd) and u32.GetWindowTextLengthW(hwnd): + treffer.append(hwnd) + return False + return True + + CB = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + u32.EnumWindows(CB(besuch), 0) + if not treffer: + return False + hwnd = treffer[0] + if u32.IsIconic(hwnd): + u32.ShowWindow(hwnd, SW_RESTORE) + u32.SetForegroundWindow(hwnd) + return True + + def windows_dark_mode(): """True, wenn Windows auf das dunkle App-Theme eingestellt ist.""" if not sys.platform.startswith("win"): @@ -931,5 +1019,22 @@ class RechnungsApp(tk.Tk): if __name__ == "__main__": + # Muss VOR dem Fenster passieren - das Handle bleibt bis zum Ende offen. + _sperre = einzelinstanz_pruefen() + if _sperre is False: + # Laeuft schon: dessen Fenster nach vorn holen und still beenden. + if not fenster_nach_vorn(laufende_pid_lesen()): + _r = tk.Tk() + _r.withdraw() + messagebox.showerror( + "Rechnungstool läuft bereits", + """Das Rechnungstool ist schon geöffnet, das Fenster ließ sich +aber nicht nach vorn holen - bitte über die Taskleiste zurückwechseln. + +Ein zweites Fenster würde dieselbe Rechnungsnummer noch einmal vergeben.""") + _r.destroy() + sys.exit(0) + + eigene_pid_merken() app = RechnungsApp() app.mainloop() From 790ac4c0f688f9d88ef02979ba6ef47580e958c3 Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Wed, 2 Sep 2026 19:06:45 +0200 Subject: [PATCH 03/14] Storno statt Ueberschreiben: Rechnungskette 005 -> Storno -> Korrektur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eine ausgestellte Rechnung darf nicht geaendert und nicht geloescht werden (§ 146 Abs. 4 AO), und jede Nummer gibt es nur einmal (§ 14 Abs. 4 Nr. 4 UStG). Korrigiert wird deshalb ueber ein eigenes Dokument mit neuer Nummer, das sich auf die alte bezieht (§ 31 Abs. 5 UStDV). - Knopf "Rechnung stornieren": alte Nummer waehlen, Programm liest deren PDF und schreibt eine Stornorechnung mit eigener neuer Nummer, Betraege negativ, mit Bezug auf Nummer und Datum der alten Rechnung. - Danach steht das Formular auf der naechsten Nummer, gefuellt aus der alten Rechnung; die Nummer ist gesperrt. "PDF erstellen" fragt noch einmal nach und legt bei Nein das Storno-PDF wieder weg - dann bleibt allein das Original stehen. - Die alte Rechnung wird nie angefasst. - Metadaten v3: art / storno_zu / ersetzt / storno_nummer, dazu Anschrift und Positionen, damit eine Rechnung wieder ins Formular geladen werden kann. Alle Felder aus v1/v2 bleiben unveraendert - daran haengt das Steuerjournal. - Zaehler laeuft nur noch vorwaerts; eine vergebene Nummer wird nicht mehr ueberschrieben, sondern die naechste freie angeboten. - Combobox im Storno-Dialog blieb leer: die StringVar hing nur an einer lokalen Variable und wurde weggeraeumt. Jetzt ohne textvariable. Prueflauf (pruef_storno.py, 36 Pruefungen gruen): echte PDFs in einem Wegwerf-Ordner, danach liest der Steuerrechner den Ordner - 001 + Storno + Korrektur ergeben genau die berichtigte Rechnung, kein Beleg unsicher. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X --- app.py | 451 ++++++++++++++++++++++++++++++++++++++++++++++- pdf_renderer.py | 85 +++++++-- pruef_nummern.py | 50 ++++++ pruef_storno.py | 169 ++++++++++++++++++ 4 files changed, 739 insertions(+), 16 deletions(-) create mode 100644 pruef_nummern.py create mode 100644 pruef_storno.py diff --git a/app.py b/app.py index c60d9d9..ef07034 100644 --- a/app.py +++ b/app.py @@ -10,6 +10,7 @@ Bedienung: Steuersaetze, Bettensteuer, Preise und Firmendaten -> Menue "Einstellungen". """ +import json import os import sys import datetime @@ -48,6 +49,140 @@ def safe_filename(s): return "".join(c for c in s if c.isalnum() or c in keep).strip() or "Rechnung" +def vorhandene_rechnungen(ordner, nummer): + """Alle PDFs im Ordner, die schon zu dieser Rechnungsnummer gehoeren. + + Der Kundenname haengt mit im Dateinamen ("Rechnung_2026-013_Meier.pdf"), + darum wird nur der Nummernteil verglichen - sonst rutscht dieselbe Nummer + unter einem anderen Namen als zweite Datei durch. + """ + kopf = "Rechnung_" + safe_filename(nummer) + try: + namen = os.listdir(ordner) + except OSError: + return [] + treffer = [] + for name in namen: + if not name.lower().endswith(".pdf"): + continue + stamm = name[:-4] + # exakt die Nummer oder Nummer + "_Kundenname" - "2026-130" faellt raus + if stamm == kopf or stamm.startswith(kopf + "_"): + treffer.append(os.path.join(ordner, name)) + return sorted(treffer) + + +def kenndaten_lesen(pfad): + """Kenndaten (Feld /Subject) aus einer vom Tool erzeugten PDF; None wenn keine. + + Gelesen wird der Bestand, nicht das Formular - ein Storno muss genau das + spiegeln, was rausgegangen ist. + """ + try: + from pypdf import PdfReader + roh = (PdfReader(pfad).metadata or {}).get("/Subject") or "" + if not str(roh).strip().startswith("{"): + return None + daten = json.loads(str(roh)) + except Exception as e: # noqa: BLE001 - fremde oder kaputte PDF + print(f"[storno] {os.path.basename(pfad)}: keine lesbaren Kenndaten ({e})") + return None + if daten.get("quelle") != "rechnungstool" or not daten.get("rechnungsnummer"): + return None + daten["_pfad"] = pfad + return daten + + +def rechnungen_im_ordner(ordner): + """Alle lesbaren Rechnungen des Ordners, neueste Nummer zuerst. + + Stornos und schon stornierte Rechnungen sind mit drin - welche davon + stornierbar ist, entscheidet der Aufrufer. + """ + try: + namen = sorted(os.listdir(ordner)) + except OSError: + return [] + gefunden = [] + for name in namen: + if not name.lower().endswith(".pdf") or not name.startswith("Rechnung_"): + continue + daten = kenndaten_lesen(os.path.join(ordner, name)) + if daten: + gefunden.append(daten) + gefunden.sort(key=lambda d: str(d.get("rechnungsnummer", "")), reverse=True) + return gefunden + + +def storno_rechnung_bauen(meta, cfg): + """Baut aus den Kenndaten der alten Rechnung den spiegelbildlichen Storno. + + Alle Betraege negativ. Je USt-Satz eine Zeile - die einzelnen Leistungen der + alten Rechnung werden NICHT wiederholt: aufgehoben wird der Beleg als Ganzes, + und die Positionen stehen in aelteren PDFs gar nicht in den Kenndaten. + """ + r = berechnung.Rechnung() + nummer = meta.get("rechnungsnummer", "") + bloecke = meta.get("ust_bloecke") or [] + if not bloecke: + # v1-PDF ohne Aufteilung: alles auf den ermaessigten Satz, sonst faellt + # die USt unter den Tisch. + brutto = float(meta.get("zwischensumme") or 0) + satz = int((cfg.get("steuersaetze") or [{"satz": 7}])[0].get("satz", 7)) + netto = berechnung.runde(brutto * 100 / (100 + satz)) if satz else brutto + bloecke = [{"satz": satz, "netto": netto, "ust": berechnung.runde(brutto - netto), + "brutto": brutto}] + + saetze = {int(s.get("satz", -1)): s for s in cfg.get("steuersaetze", [])} + for i, b in enumerate(bloecke, start=1): + satz = int(float(b.get("satz", 0) or 0)) + brutto = -abs(berechnung.runde(b.get("brutto") or 0)) + netto = -abs(berechnung.runde(b.get("netto") or 0)) + ust = -abs(berechnung.runde(b.get("ust") or 0)) + r.zeilen.append(berechnung.PositionsZeile( + nr=i, leistung=f"Storno der Rechnung {nummer} ({satz} % USt)", + satz=satz, preis=brutto, anzahl=1, personen=0, pro_person=False, + brutto=brutto, netto=netto)) + info = saetze.get(satz, {}) + r.bloecke.append(berechnung.Steuerblock( + satz=satz, netto=netto, ust=ust, + bezeichnung=str(info.get("bezeichnung", "") or "Mehrwertsteuer"), + extra_blatt=bool(info.get("extra_blatt", False)))) + + r.netto_gesamt = berechnung.runde(sum(b.netto for b in r.bloecke)) + r.ust_gesamt = berechnung.runde(sum(b.ust for b in r.bloecke)) + r.brutto = berechnung.runde(r.netto_gesamt + r.ust_gesamt) + + # Beherbergungssteuer wird mitstorniert, sonst stimmt die Meldung ans Amt nicht. + # Die Bezeichnung kommt aus dem Satz mit "extra_blatt" - genau dem, aus dem + # berechne() den Aufschlag baut. + steuer = berechnung.runde(meta.get("steuer_betrag") or 0) + if steuer: + extra = next((s for s in cfg.get("steuersaetze", []) if s.get("extra_blatt")), {}) + r.aufschlaege.append(berechnung.Aufschlag( + satz=float(meta.get("steuer_satz") or extra.get("satz", 0) or 0), + bezeichnung=str(extra.get("bezeichnung", "") or "Beherbergungssteuer"), + basis=r.brutto, betrag=-abs(steuer), extra_blatt=True)) + r.gesamt = berechnung.runde(r.brutto + sum(a.betrag for a in r.aufschlaege)) + return r + + +def naechste_freie_nummer(ordner, cfg): + """Erste Nummer JJJJ-NNN, zu der im Ordner noch kein PDF liegt. + + Gezaehlt wird ab dem zuletzt vergebenen Stand aus der config; im Ordner + hoeher liegende Nummern werden dabei uebersprungen, weil sie nicht frei sind. + """ + jahr = cfg.get("rechnung_jahr", datetime.date.today().year) + z = cfg.get("rechnung_zaehler", 0) + for _ in range(1000): + z += 1 + kandidat = config.format_rechnungsnummer(jahr, z) + if not vorhandene_rechnungen(ordner, kandidat): + return kandidat + return None + + def einzelinstanz_pruefen(): """Sperrt das Programm gegen ein zweites Fenster. @@ -162,6 +297,9 @@ class RechnungsApp(tk.Tk): self.anzahl_vars = {} # nr -> StringVar self.personen_vars = {} # nr -> StringVar self.gesamt_lbls = {} # nr -> Label + # Zwischenspeicher: geschriebenes Storno, das noch bestaetigt werden muss. + # Solange es hier liegt, gehoert zur naechsten PDF die Storno-Kette. + self.storno_stand = None self._apply_theme() # Farben nach Windows hell/dunkel self._build_menu() @@ -223,10 +361,20 @@ class RechnungsApp(tk.Tk): style.configure("TCheckbutton", background=bg, foreground=fg) style.map("TCheckbutton", background=[("active", bg)], foreground=[("active", fg)]) style.configure("TEntry", fieldbackground=field, foreground=fg, insertcolor=fg) + # Bei state="readonly" zeichnet die Combobox ihren Text als MARKIERUNG, + # sobald sie den Fokus hat - ohne diese beiden Farben steht der Wert + # unsichtbar im Feld. style.configure("TCombobox", fieldbackground=field, foreground=fg, - background=field, arrowcolor=fg) + background=field, arrowcolor=fg, + selectbackground=field, selectforeground=fg) style.map("TCombobox", fieldbackground=[("readonly", field)], - foreground=[("readonly", fg)], background=[("readonly", field)]) + foreground=[("readonly", fg)], background=[("readonly", field)], + selectbackground=[("readonly", field)], selectforeground=[("readonly", fg)]) + # Die aufgeklappte Liste ist ein klassisches tk-Listbox-Widget + self.option_add("*TCombobox*Listbox.background", field) + self.option_add("*TCombobox*Listbox.foreground", fg) + self.option_add("*TCombobox*Listbox.selectBackground", sel) + self.option_add("*TCombobox*Listbox.selectForeground", fg) for sbs in ("Vertical.TScrollbar", "Horizontal.TScrollbar", "TScrollbar"): style.configure(sbs, troughcolor=bg, background=self.col_scroll, bordercolor=bg, lightcolor=bg, darkcolor=bg, @@ -390,11 +538,20 @@ class RechnungsApp(tk.Tk): ttk.Label(info, text=lbl).grid(row=i, column=0, sticky="w", pady=2) ent = ttk.Entry(info, textvariable=var, width=18) ent.grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2) + if var is self.r_nummer: + # Waehrend eines Stornos wird das Feld gesperrt - die Nummern + # vergibt dann das Programm, damit keine doppelt sein kann. + self.nummer_entry = ent if var in date_vars: # Datum: Punkte automatisch, Cursor ans Ende ent.bind("", self._auto_datum_evt) ttk.Label(info, text="Nächte").grid(row=4, column=0, sticky="w", pady=2) ttk.Label(info, textvariable=self.r_naechte, font=("Segoe UI", 10, "bold")).grid( row=4, column=1, sticky="w", padx=(8, 0)) + # Storno-Hinweis: steht nur da, solange ein Storno im Zwischenspeicher liegt. + self.storno_hinweis = tk.StringVar(value="") + ttk.Label(info, textvariable=self.storno_hinweis, foreground="#b00020", + font=("Segoe UI", 9, "bold"), wraplength=200, justify="left").grid( + row=5, column=0, columnspan=2, sticky="w", pady=(6, 0)) info.columnconfigure(1, weight=1) self.r_anreise.trace_add("write", lambda *a: self._update_naechte()) self.r_abreise.trace_add("write", lambda *a: self._update_naechte()) @@ -527,6 +684,7 @@ class RechnungsApp(tk.Tk): links = ttk.Frame(f) links.pack(side="left") ttk.Button(links, text="PDF erstellen", command=self.pdf_erstellen, width=18).pack(side="left", padx=4) + ttk.Button(links, text="Rechnung stornieren", command=self.dlg_storno, width=20).pack(side="left", padx=4) ttk.Button(links, text="Zurücksetzen", command=self.reset_formular, width=14).pack(side="left", padx=4) # --------------------------------------------------------- Berechnung @@ -578,6 +736,219 @@ class RechnungsApp(tk.Tk): jahr, zaehler = heute_jahr, 0 self.r_nummer.set(config.format_rechnungsnummer(jahr, zaehler + 1)) + # ------------------------------------------------------------- Storno + def _storno_anzeigen(self): + """Hinweiszeile und Sperre der Rechnungsnummer an den Zwischenspeicher anpassen.""" + stand = self.storno_stand + if stand: + self.storno_hinweis.set( + f"Storno {stand['nummer']} zu Rechnung {stand['zu']} liegt bereit.\n" + f"Beim Erstellen wird noch einmal gefragt.") + self.nummer_entry.configure(state="readonly") + else: + self.storno_hinweis.set("") + self.nummer_entry.configure(state="normal") + + def _storno_verwerfen(self, grund=""): + """Storno rückgängig: PDF löschen, Zähler zurück, Zwischenspeicher leeren. + + Zulaessig ist das nur hier: die Storno-PDF ist Sekunden alt und hat das + Haus nie verlassen. Die stornierte Rechnung selbst wird nie angefasst. + """ + stand = self.storno_stand + if not stand: + return + try: + if os.path.exists(stand["pfad"]): + os.remove(stand["pfad"]) + print(f"[storno] verworfen, PDF gelöscht -> {stand['pfad']}") + except OSError as e: # noqa: BLE001 + messagebox.showwarning("Storno-PDF blieb liegen", + f"Die Storno-PDF konnte nicht gelöscht werden:\n{e}") + self.cfg["rechnung_jahr"] = stand["jahr_vorher"] + self.cfg["rechnung_zaehler"] = stand["zaehler_vorher"] + config.save_config(self.cfg) + self.storno_stand = None + self._storno_anzeigen() + self._neue_nummer_vorschlagen() + if grund: + print(f"[storno] {grund}") + + def dlg_storno(self): + """Storno-Dialog: alte Nummer wählen -> Storno-PDF schreiben -> Formular füllen.""" + if self.storno_stand: + messagebox.showinfo( + "Storno liegt schon bereit", + f"Der Storno {self.storno_stand['nummer']} zu Rechnung " + f"{self.storno_stand['zu']} ist noch offen.\n\n" + "Erst die berichtigte Rechnung erstellen oder den Storno dort verwerfen.") + return + + 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"} + offen = [d for d in alle + if d.get("art") != "storno" and d.get("rechnungsnummer") not in stornierte] + if not offen: + messagebox.showwarning( + "Nichts zu stornieren", + f"Im Ordner\n{out_dir}\nliegt keine stornierbare Rechnung.") + return + + d = tk.Toplevel(self) + d.title("Rechnung stornieren") + d.transient(self) + d.grab_set() + self._style_dialog(d) + frm = ttk.Frame(d, padding=12) + frm.pack(fill="both", expand=True) + + ttk.Label(frm, justify="left", text= + "Die alte Rechnung bleibt unverändert liegen – sie darf weder geändert\n" + "noch gelöscht werden. Das Programm schreibt eine Stornorechnung mit\n" + "eigener neuer Nummer, die sich auf sie bezieht.").grid( + row=0, column=0, columnspan=2, sticky="w", pady=(0, 10)) + + ttk.Label(frm, text="Alte Rechnungsnummer").grid(row=1, column=0, sticky="w", pady=3) + # 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) + + 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)) + + def gewaehlt(): + i = box.current() + return offen[i] if 0 <= i < len(offen) else None + + 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() + + def anlegen(): + meta = gewaehlt() + if not meta: + return + d.destroy() + self._storno_anlegen(meta, out_dir) + + btns = ttk.Frame(frm) + btns.grid(row=3, column=0, columnspan=2, pady=(14, 0), sticky="e") + ttk.Button(btns, text="Storno erstellen", command=anlegen).pack(side="right", padx=4) + 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", "")) + nummer = naechste_freie_nummer(out_dir, self.cfg) + if not nummer: + messagebox.showerror("Keine freie Nummer", + "Es wurde keine freie Rechnungsnummer gefunden.") + return + + r = storno_rechnung_bauen(meta, self.cfg) + heute = datetime.date.today().strftime("%d.%m.%Y") + namenszeile = " ".join(t for t in (str(meta.get("vorname") or ""), + str(meta.get("nachname") or "")) if t) + kunde = meta.get("kunde") or {} + kunde_zeilen = ([kunde.get("anrede", ""), namenszeile, kunde.get("zusatz", ""), + kunde.get("strasse", ""), kunde.get("plzort", "")] + if kunde else ["", namenszeile, "", "", ""]) + kopf = { + "rechnungsnummer": nummer, + "datum": heute, + "anreise": "", "abreise": "", "naechte": "", + "kunde_zeilen": kunde_zeilen, + "nachname": str(meta.get("nachname") or ""), + "vorname": str(meta.get("vorname") or ""), + "art": "storno", + "storno_zu": alt_nummer, + "storno_datum": str(meta.get("datum") or ""), + "kunde": kunde, + } + name = f"Storno_{safe_filename(nummer)}" + if kopf["nachname"]: + name += "_" + safe_filename(kopf["nachname"]) + pfad = os.path.join(out_dir, name + ".pdf") + try: + pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf) + except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen + messagebox.showerror("Fehler beim Erstellen", + f"Storno-PDF konnte nicht erstellt werden:\n{e}") + print("[storno] FEHLER:", e) + return + + self.storno_stand = { + "pfad": pfad, "nummer": nummer, "zu": alt_nummer, + "zu_datum": str(meta.get("datum") or ""), + "jahr_vorher": self.cfg.get("rechnung_jahr", datetime.date.today().year), + "zaehler_vorher": self.cfg.get("rechnung_zaehler", 0), + } + self._zaehler_aus_nummer_speichern(nummer) + print(f"[storno] erstellt -> {pfad}") + + fehlend = self._formular_aus_meta(meta) + self.r_nummer.set(naechste_freie_nummer(out_dir, self.cfg) or "") + self._storno_anzeigen() + + hinweis = "" + if fehlend: + hinweis = ("\n\nAus der alten PDF ließen sich nicht alle Felder übernehmen " + f"({fehlend}) – die stammt noch aus einer älteren Fassung. " + "Bitte vor dem Erstellen prüfen.") + messagebox.showinfo( + "Storno erstellt", + f"Stornorechnung {nummer} zu Rechnung {alt_nummer} liegt hier:\n{pfad}\n\n" + f"Jetzt die berichtigte Rechnung {self.r_nummer.get()} anpassen und " + f"\"PDF erstellen\" drücken.{hinweis}") + + def _formular_aus_meta(self, meta): + """Formular mit den Daten der alten Rechnung füllen. + + Gibt zurück, was NICHT übernommen werden konnte - aeltere PDFs (v1/v2) + tragen weder Anschrift noch Positionen in den Kenndaten. + """ + self.k_vorname.set(str(meta.get("vorname") or "")) + self.k_name.set(str(meta.get("nachname") or "")) + kunde = meta.get("kunde") or {} + self.k_anrede.set(str(kunde.get("anrede", "") or "")) + self.k_zusatz.set(str(kunde.get("zusatz", "") or "")) + self.k_strasse.set(str(kunde.get("strasse", "") or "")) + self.k_plzort.set(str(kunde.get("plzort", "") or "")) + self.r_anreise.set(str(meta.get("anreise", "") or "")) + self.r_abreise.set(str(meta.get("abreise", "") or "")) + self.r_datum.set(datetime.date.today().strftime("%d.%m.%Y")) + + for nr, var in self.anzahl_vars.items(): + var.set("") + self.personen_vars[nr].set("") + for p in meta.get("positionen") or []: + nr = p.get("nr") + if nr in self.anzahl_vars: + self.anzahl_vars[nr].set(str(p.get("anzahl", "") or "")) + self.personen_vars[nr].set(str(p.get("personen", "") or "")) + self.recalc() + + fehlt = [] + if not kunde: + fehlt.append("Anschrift") + if not (meta.get("positionen") or []): + fehlt.append("Leistungen") + return ", ".join(fehlt) + def pdf_erstellen(self): r = self.recalc() if not r.zeilen: @@ -601,6 +972,26 @@ class RechnungsApp(tk.Tk): namenszeile = " ".join(t for t in (vorname, nachname) if t) kunde_zeilen = [self.k_anrede.get(), namenszeile, self.k_zusatz.get(), self.k_strasse.get(), self.k_plzort.get()] + # Liegt ein Storno im Zwischenspeicher, ist das hier die berichtigte Rechnung. + # Letzte Gelegenheit umzukehren: sagt er Nein, wird das Storno-PDF wieder + # geloescht und es bleibt allein die urspruengliche Rechnung stehen. + stand = self.storno_stand + if stand: + if not messagebox.askyesno( + "Storno wirklich durchführen?", + f"Storno {stand['nummer']} hebt die Rechnung {stand['zu']} auf, " + f"und {nummer} wird die berichtigte Rechnung.\n\n" + "Beide gehen an den Gast – die Berichtigung wirkt erst, wenn sie " + "ihm zugegangen ist.\n\n" + "Ja = Storno behalten und berichtigte Rechnung erstellen\n" + f"Nein = alles verwerfen, es bleibt allein Rechnung {stand['zu']}", + icon="warning", default="yes"): + self._storno_verwerfen("vom Benutzer verworfen") + messagebox.showinfo( + "Storno verworfen", + f"Der Storno wurde gelöscht. Es bleibt allein die Rechnung {stand['zu']}.") + return + kopf = { "rechnungsnummer": nummer, "datum": self.r_datum.get().strip() or datetime.date.today().strftime("%d.%m.%Y"), @@ -610,8 +1001,46 @@ class RechnungsApp(tk.Tk): "kunde_zeilen": kunde_zeilen, "nachname": nachname, "vorname": vorname, + # Fuer Version 3: kompletter Formularstand, damit die Rechnung spaeter + # wieder ins Formular geladen werden kann statt abgetippt zu werden. + "kunde": {"anrede": self.k_anrede.get().strip(), "vorname": vorname, + "nachname": nachname, "zusatz": self.k_zusatz.get().strip(), + "strasse": self.k_strasse.get().strip(), + "plzort": self.k_plzort.get().strip()}, + "positionen": [{"nr": z.nr, "anzahl": z.anzahl, "personen": z.personen} + for z in r.zeilen], } + if stand: + kopf["art"] = "korrektur" + kopf["ersetzt"] = stand["zu"] + kopf["storno_nummer"] = stand["nummer"] out_dir = config.ensure_output_dir(self.cfg) + + # Nummer schon vergeben? Dann NICHT drueberschreiben: eine ausgestellte + # Rechnung muss unveraendert bestehen bleiben (§ 146 Abs. 4 AO), und jede + # Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG). Also neue Nummer. + vorhanden = vorhandene_rechnungen(out_dir, nummer) + if vorhanden: + liste = "\n".join("- " + os.path.basename(x) for x in vorhanden) + frei = naechste_freie_nummer(out_dir, self.cfg) + if not frei: + messagebox.showerror( + "Rechnungsnummer schon vergeben", + f"Die Rechnungsnummer {nummer} ist schon vergeben:\n\n{liste}\n\n" + "Es wurde keine freie Nummer gefunden - bitte die Nummer von Hand setzen.") + return + if not messagebox.askyesno( + "Rechnungsnummer schon vergeben", + f"Die Rechnungsnummer {nummer} ist schon vergeben:\n\n{liste}\n\n" + "Die vorhandene Rechnung bleibt bestehen - sie darf weder überschrieben " + "noch gelöscht werden (§ 14 Abs. 4 Nr. 4 UStG, § 146 Abs. 4 AO).\n\n" + f"Stattdessen die nächste freie Nummer {frei} verwenden?", + icon="warning", default="yes"): + return + nummer = frei + self.r_nummer.set(nummer) + kopf["rechnungsnummer"] = nummer + fname = f"Rechnung_{safe_filename(nummer)}" if self.k_name.get().strip(): fname += "_" + safe_filename(self.k_name.get().strip()) @@ -630,8 +1059,19 @@ class RechnungsApp(tk.Tk): print(f"[pdf] erstellt -> {pfad}") hinweis = "\n\n(Seite 1 = Kunde, Seite 2 = Beherbergungssteuer fürs Amt)" if r.aufschlaege else "" + storno_pfad = None + if stand: + # Storno ist jetzt bestaetigt - Zwischenspeicher leeren, Sperre loesen. + storno_pfad = stand["pfad"] + self.storno_stand = None + self._storno_anzeigen() + hinweis = (f"\n\nDazu gehört die Stornorechnung {stand['nummer']} " + f"({os.path.basename(storno_pfad)}).\n" + "Beide Blätter gehören zum Gast.") + hinweis if messagebox.askyesno("Fertig", f"Rechnung erstellt:\n{pfad}{hinweis}\n\nJetzt öffnen (zum Drucken)?"): self._oeffne_datei(pfad) + if storno_pfad: + self._oeffne_datei(storno_pfad) def _zaehler_aus_nummer_speichern(self, nummer): try: @@ -639,6 +1079,13 @@ class RechnungsApp(tk.Tk): jahr, z = int(jahr_s), int(z_s) except (ValueError, AttributeError): return + # Der Zaehler darf nur vorwaerts. Wird eine alte Rechnung ueberschrieben, + # wuerde er sonst zurueckfallen und die naechsten Nummern noch einmal + # vergeben - genau der Fehler, den wir loswerden wollen. + alt_jahr = self.cfg.get("rechnung_jahr", 0) + alt_z = self.cfg.get("rechnung_zaehler", 0) + if jahr < alt_jahr or (jahr == alt_jahr and z <= alt_z): + return self.cfg["rechnung_jahr"] = jahr self.cfg["rechnung_zaehler"] = z config.save_config(self.cfg) diff --git a/pdf_renderer.py b/pdf_renderer.py index d1fcce9..65c4db3 100644 --- a/pdf_renderer.py +++ b/pdf_renderer.py @@ -208,10 +208,21 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): if len(kunde_flow) == 1: kunde_flow.append(Paragraph(" ", st["addr"])) + art = kopf.get("art", "rechnung") info_rows = [ - ("Rechnungsnummer:", kopf.get("rechnungsnummer", "")), + ("Stornonummer:" if art == "storno" else "Rechnungsnummer:", + kopf.get("rechnungsnummer", "")), ("Rechnungsdatum:", kopf.get("datum", "")), ] + # Der Bezug auf die alte Rechnung ist Pflicht (§ 31 Abs. 5 UStDV): Nummer UND Datum. + if art == "storno" and kopf.get("storno_zu"): + # Nummer und Datum in zwei Zeilen - zusammen brechen sie in der schmalen + # Spalte um und der Bezug sieht aus wie zwei halbe Angaben. + info_rows.append(("Storno zu Rechnung:", kopf.get("storno_zu", ""))) + if kopf.get("storno_datum"): + info_rows.append(("Rechnung vom:", kopf["storno_datum"])) + elif art == "korrektur" and kopf.get("ersetzt"): + info_rows.append(("Ersetzt Rechnung:", kopf.get("ersetzt", ""))) if kopf.get("anreise"): info_rows.append(("Anreise:", kopf.get("anreise", ""))) if kopf.get("abreise"): @@ -239,7 +250,10 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): # Ueberschrift (auf dem Amt-Blatt mit Zusatz) # Das Wort "Beherbergungssteuer" steht auf dem Amt-Blatt nirgends mehr - auch nicht # in der Ueberschrift. Der Zusatz sagt nur noch, fuer wen das Blatt ist. - titel = "Rechnungsübersicht" + (" (für das Amt)" if gesamtrechnung_betont else "") + # Das Wort "Stornorechnung" muss auf dem Blatt stehen, damit der Beleg + # eindeutig als Aufhebung erkennbar ist. + titel = "Stornorechnung" if art == "storno" else "Rechnungsübersicht" + titel += " (für das Amt)" if gesamtrechnung_betont else "" story.append(Paragraph(titel, st["h"])) # Positionstabelle @@ -342,22 +356,44 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): continue satz_txt = eur_kurz(a.satz).replace(",00", "") story.append(Spacer(1, 1.5 * mm)) - story.append(Paragraph( - f"Die {a.bezeichnung} ({satz_txt} %) wird gemäß kommunaler Satzung auf die " - f"Zwischensumme erhoben und ist im Gesamtbetrag enthalten.", st["dank"])) + if art == "storno": + # Auf dem Storno wird nichts erhoben, sondern zurueckgenommen. + story.append(Paragraph( + f"Die mit der stornierten Rechnung erhobene {a.bezeichnung} " + f"({satz_txt} %) wird mit aufgehoben.", st["dank"])) + else: + story.append(Paragraph( + f"Die {a.bezeichnung} ({satz_txt} %) wird gemäß kommunaler Satzung auf die " + f"Zwischensumme erhoben und ist im Gesamtbetrag enthalten.", st["dank"])) story.append(Spacer(1, 8 * mm)) - # ---- Danktexte + Hinweis (nur Kundenseite; auf dem Amt-Blatt unnoetig) ---- + # ---- Schlusstexte (nur Kundenseite; auf dem Amt-Blatt unnoetig) ---- if not gesamtrechnung_betont: texte = cfg.get("texte", {}) block = [] - if texte.get("dank1"): - block.append(Paragraph(texte["dank1"], st["dank"])) - if texte.get("dank2"): - block.append(Paragraph(texte["dank2"], st["dank"])) - if texte.get("hinweis"): - block.append(Paragraph(texte["hinweis"], st["hinweis"])) + if art == "storno": + # Auf einem Storno sind Dank- und Zahlungstexte falsch - hier wird + # nichts geschuldet, sondern aufgehoben. + bezug = kopf.get("storno_zu", "") + if kopf.get("storno_datum"): + bezug += f" vom {kopf['storno_datum']}" + 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"])) + else: + if art == "korrektur" and kopf.get("ersetzt"): + hinweis = f"Diese Rechnung ersetzt die Rechnung {kopf['ersetzt']}" + if kopf.get("storno_nummer"): + hinweis += f", die mit der Stornorechnung {kopf['storno_nummer']} aufgehoben wurde" + block.append(Paragraph(hinweis + ".", st["dank"])) + if texte.get("dank1"): + block.append(Paragraph(texte["dank1"], st["dank"])) + if texte.get("dank2"): + block.append(Paragraph(texte["dank2"], st["dank"])) + if texte.get("hinweis"): + block.append(Paragraph(texte["hinweis"], st["hinweis"])) story.append(KeepTogether(block)) @@ -374,11 +410,21 @@ def _kenndaten(rechnung, kopf): stecken auf einer Rechnung gemischt drin. Alle Felder der Version 1 bleiben unveraendert stehen - am "zwischensumme"/"steuer_satz"/"steuer_betrag" haengt der Import des Steuerjournals, das darf nicht brechen. + + Version 3 kommt mit dem Storno dazu: + "art" - "rechnung", "storno" oder "korrektur" + "storno_zu" - beim Storno die Nummer der aufgehobenen Rechnung + "ersetzt" - bei der Korrektur die Nummer der falschen Rechnung + "kunde" / "positionen" / "anreise" / "abreise" - der komplette Formularstand, + damit eine Rechnung spaeter wieder ins Formular geladen werden kann, statt + sie abzutippen. Aeltere PDFs (v1/v2) haben das nicht. + Beim Storno sind alle Betraege NEGATIV - das Steuerjournal und der + Steuerrechner summieren dadurch von allein richtig (+100 -100 +120 = 120). """ aufschlag = rechnung.aufschlaege[0] if rechnung.aufschlaege else None daten = { "quelle": "rechnungstool", - "version": 2, + "version": 3, "rechnungsnummer": kopf.get("rechnungsnummer", ""), "datum": kopf.get("datum", ""), "nachname": kopf.get("nachname", ""), @@ -396,6 +442,16 @@ def _kenndaten(rechnung, kopf): "brutto": runde(b.brutto)} for b in rechnung.bloecke ], + # neu in Version 3 + "art": kopf.get("art", "rechnung"), + "storno_zu": kopf.get("storno_zu", ""), + "storno_datum": kopf.get("storno_datum", ""), + "ersetzt": kopf.get("ersetzt", ""), + "storno_nummer": kopf.get("storno_nummer", ""), + "anreise": kopf.get("anreise", ""), + "abreise": kopf.get("abreise", ""), + "kunde": kopf.get("kunde", {}), + "positionen": kopf.get("positionen", []), } return json.dumps(daten, ensure_ascii=False) @@ -419,7 +475,8 @@ def render_rechnung(pfad, cfg, rechnung, kopf): ziel, pagesize=A4, leftMargin=MARGIN_L, rightMargin=MARGIN_R, topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT, - title=f"Rechnung {kopf.get('rechnungsnummer','')}", + title=("Stornorechnung" if kopf.get("art") == "storno" else "Rechnung") + + f" {kopf.get('rechnungsnummer','')}", author=cfg["firma"].get("name", ""), subject=_kenndaten(rechnung, kopf), # maschinenlesbar fuers Steuerjournal ) diff --git a/pruef_nummern.py b/pruef_nummern.py new file mode 100644 index 0000000..8357824 --- /dev/null +++ b/pruef_nummern.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +"""Pruefstand: findet vorhandene_rechnungen() eine schon vergebene Nummer? + +Kein Rateraum: legt echte Dateien in einem Wegwerf-Ordner an und fragt die +Funktion aus app.py direkt. +""" +import os +import sys +import tempfile + +sys.path.insert(0, r"C:\claude\rechnungstool") +from app import vorhandene_rechnungen # noqa: E402 + +ordner = tempfile.mkdtemp(prefix="rechnungspruef_") +dateien = [ + "Rechnung_2026-013_Meier.pdf", # dieselbe Nummer, Kunde Meier + "Rechnung_2026-130_Schmidt.pdf", # laengere Nummer - darf NICHT treffen + "Rechnung_2026-014_Meier.pdf", # andere Nummer + "Rechnung_2026-020.pdf", # ohne Kundenname + "Notiz_2026-013.txt", # kein PDF +] +for d in dateien: + with open(os.path.join(ordner, d), "w") as f: + f.write("x") + +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-099", [], "freie Nummer"), + ("2026-01", [], "Teilnummer darf nicht auf 2026-013 anspringen"), +] + +fehler = 0 +for nummer, erwartet, was in faelle: + ist = [os.path.basename(p) for p in vorhandene_rechnungen(ordner, nummer)] + ok = ist == erwartet + fehler += 0 if ok else 1 + print((" OK " if ok else "FEHLER") + f" | {nummer} -> {ist} ({was})") + +# leerer / nicht vorhandener Ordner darf nicht knallen +if vorhandene_rechnungen(os.path.join(ordner, "gibtsnicht"), "2026-013") != []: + print("FEHLER | fehlender Ordner") + fehler += 1 +else: + print(" OK | fehlender Ordner gibt leere Liste") + +print("---") +print("alles gruen" if fehler == 0 else f"{fehler} Fehler") +sys.exit(1 if fehler else 0) diff --git a/pruef_storno.py b/pruef_storno.py new file mode 100644 index 0000000..26a4e3d --- /dev/null +++ b/pruef_storno.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +"""Pruefstand fuer die Storno-Kette - kompletter Durchlauf in einem Wegwerf-Ordner. + +Geprueft wird an ECHTEN PDFs, die das Programm selbst schreibt: + 1. Rechnung 2026-001 erstellen + 2. sie stornieren -> Storno 2026-002 mit negativen Betraegen + 3. berichtigte Rechnung 2026-003 mit geaenderter Menge + 4. der Steuerrechner liest den Ordner: 001 + 002 + 003 muss 003 ergeben + 5. Rueckweg: Storno anlegen und verwerfen -> PDF weg, Zaehler zurueck, Original da + +Die echte config.json wird NICHT angefasst - es wird mit einer Kopie im +Wegwerf-Ordner gearbeitet. +""" +import json +import os +import shutil +import sys +import tempfile + +QUELLE = r"C:\claude\rechnungstool" +sys.path.insert(0, QUELLE) + +tmp = tempfile.mkdtemp(prefix="storno_pruef_") +ordner = os.path.join(tmp, "rechnungen") +os.makedirs(ordner) + +import config # noqa: E402 +shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json")) +config.CONFIG_PATH = os.path.join(tmp, "config.json") +roh = json.load(open(config.CONFIG_PATH, encoding="utf-8")) +roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0 +roh["output_dir"] = ordner +json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2) + +import app as A # noqa: E402 +from tkinter import messagebox # noqa: E402 + +fehler = [] + + +def pruefe(bedingung, text): + print((" OK | " if bedingung else "FEHLER | ") + text) + if not bedingung: + fehler.append(text) + + +# Dialoge automatisch beantworten; jede Frage wird protokolliert. +antworten = {} +gefragt = [] + + +def askyesno(titel, text, **kw): + gefragt.append(titel) + for schlue, wert in antworten.items(): + if schlue in titel: + return wert + return False + + +messagebox.askyesno = askyesno +messagebox.showinfo = lambda *a, **k: None +messagebox.showwarning = lambda *a, **k: print("[warnung]", 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() +f.update() +f.cfg["output_dir"] = ordner + +# ---- 1. normale Rechnung ------------------------------------------------ +f.k_vorname.set("Anna") +f.k_name.set("Beispiel") +f.k_strasse.set("Musterweg 1") +f.k_plzort.set("12345 Musterstadt") +f.r_anreise.set("01.08.2026") +f.r_abreise.set("05.08.2026") +erste = sorted(f.anzahl_vars)[0] +f.anzahl_vars[erste].set("4") +f.update() +f.pdf_erstellen() +erste_pdf = os.path.join(ordner, "Rechnung_2026-001_Beispiel.pdf") +pruefe(os.path.exists(erste_pdf), "Rechnung 2026-001 geschrieben") + +meta1 = A.kenndaten_lesen(erste_pdf) +pruefe(bool(meta1), "Kenndaten der Rechnung lesbar") +pruefe(meta1.get("version") == 3, f"Metadaten Version 3 (ist {meta1.get('version')})") +pruefe(meta1.get("art") == "rechnung", "art = rechnung") +pruefe(len(meta1.get("positionen") or []) == 1, "Positionen stehen in den Kenndaten") +pruefe(bool(meta1.get("kunde", {}).get("plzort")), "Anschrift steht in den Kenndaten") +brutto1 = float(meta1["zwischensumme"]) +gesamt1 = float(meta1["gesamt"]) +print(f" Rechnung 001: brutto {brutto1:.2f}, gesamt {gesamt1:.2f}") + +# ---- 2. stornieren ------------------------------------------------------ +offen = [d for d in A.rechnungen_im_ordner(ordner) if d.get("art") != "storno"] +pruefe(len(offen) == 1, "eine stornierbare Rechnung gefunden") +f._storno_anlegen(offen[0], ordner) +pruefe(f.storno_stand is not None, "Storno liegt im Zwischenspeicher") +storno_pdf = f.storno_stand["pfad"] if f.storno_stand else "" +pruefe(os.path.exists(storno_pdf), f"Storno-PDF geschrieben ({os.path.basename(storno_pdf)})") + +meta2 = A.kenndaten_lesen(storno_pdf) +pruefe(meta2.get("art") == "storno", "art = storno") +pruefe(meta2.get("storno_zu") == "2026-001", "storno_zu zeigt auf 2026-001") +pruefe(meta2.get("storno_datum") == meta1.get("datum"), "Datum der alten Rechnung steht drin") +pruefe(float(meta2["zwischensumme"]) == -brutto1, f"Zwischensumme negativ ({meta2['zwischensumme']})") +pruefe(float(meta2["gesamt"]) == -gesamt1, f"Gesamt negativ ({meta2['gesamt']})") +pruefe(all(b["netto"] <= 0 and b["ust"] <= 0 for b in meta2["ust_bloecke"]), + "alle USt-Bloecke negativ") +pruefe(f.r_nummer.get() == "2026-003", f"Formular steht auf 2026-003 (ist {f.r_nummer.get()})") +pruefe(str(f.nummer_entry.cget("state")) == "readonly", "Nummernfeld gesperrt") +pruefe(f.k_plzort.get() == "12345 Musterstadt", "Anschrift ins Formular uebernommen") +pruefe(f.anzahl_vars[erste].get() == "4.0", f"Menge uebernommen (ist {f.anzahl_vars[erste].get()})") + +# ---- 3. berichtigte Rechnung ------------------------------------------- +antworten["Storno wirklich"] = True +f.anzahl_vars[erste].set("3") # das war der Fehler: 4 statt 3 Naechte +f.update() +f.pdf_erstellen() +dritte = os.path.join(ordner, "Rechnung_2026-003_Beispiel.pdf") +pruefe(os.path.exists(dritte), "berichtigte Rechnung 2026-003 geschrieben") +pruefe(f.storno_stand is None, "Zwischenspeicher nach dem Erstellen leer") +pruefe(str(f.nummer_entry.cget("state")) == "normal", "Nummernfeld wieder frei") +meta3 = A.kenndaten_lesen(dritte) +pruefe(meta3.get("art") == "korrektur", "art = korrektur") +pruefe(meta3.get("ersetzt") == "2026-001", "ersetzt zeigt auf 2026-001") +pruefe(meta3.get("storno_nummer") == "2026-002", "storno_nummer zeigt auf 2026-002") +pruefe(os.path.exists(erste_pdf), "die urspruengliche Rechnung liegt unangetastet da") +gesamt3 = float(meta3["gesamt"]) +print(f" Rechnung 003: gesamt {gesamt3:.2f}") + +# ---- 4. was sieht der Steuerrechner? ------------------------------------ +sys.path.insert(0, r"C:\claude\steuerrechner") +import einnahmen # noqa: E402 +liste, kaputt = einnahmen.scanne(ordner, {"ust_saetze": [7, 19]}) +pruefe(len(liste) == 3, f"Steuerrechner liest 3 Belege (liest {len(liste)})") +pruefe(not kaputt, f"kein Beleg uebersprungen ({kaputt})") +pruefe(not any(e.unsicher for e in liste), + "kein Beleg als unsicher markiert") +summe = round(sum(e.gesamt for e in liste), 2) +netto = round(sum(e.netto for e in liste), 2) +ust = round(sum(e.ust for e in liste), 2) +pruefe(abs(summe - gesamt3) < 0.005, + f"Jahressumme = nur die berichtigte Rechnung ({summe:.2f} vs {gesamt3:.2f})") +print(f" Summe {summe:.2f} | Netto {netto:.2f} | USt {ust:.2f}") + +# ---- 5. Rueckweg: Storno anlegen und verwerfen -------------------------- +antworten["Storno wirklich"] = False +offen = [d for d in A.rechnungen_im_ordner(ordner) + if d.get("art") == "korrektur"] +f._storno_anlegen(offen[0], ordner) +storno2 = f.storno_stand["pfad"] +zaehler_mit_storno = f.cfg["rechnung_zaehler"] +pruefe(os.path.exists(storno2), "zweites Storno geschrieben") +f.anzahl_vars[erste].set("2") +f.update() +f.pdf_erstellen() # -> Frage wird mit Nein beantwortet +pruefe(not os.path.exists(storno2), "verworfenes Storno-PDF ist geloescht") +pruefe(f.storno_stand is None, "Zwischenspeicher geleert") +pruefe(f.cfg["rechnung_zaehler"] == zaehler_mit_storno - 1, + f"Zaehler zurueckgesetzt ({f.cfg['rechnung_zaehler']} statt {zaehler_mit_storno})") +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") + +f.destroy() +print("---") +print(f"Ordner: {ordner}") +print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}") +sys.exit(1 if fehler else 0) From d2fee38bfd1a8645cff37be92376f3c5f008e220 Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Wed, 2 Sep 2026 19:11:37 +0200 Subject: [PATCH 04/14] Storno: Nummer eintippen statt Auswahlliste, Anrede als Liste, Pflichtfelder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X --- app.py | 129 ++++++++++++++++++++++++++++++++++------------- pruef_nummern.py | 2 + pruef_storno.py | 69 ++++++++++++++++++++++++- 3 files changed, 163 insertions(+), 37 deletions(-) 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}") From 0095305176784c73d152c64f334e02d7e9c5fb38 Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Wed, 2 Sep 2026 19:14:24 +0200 Subject: [PATCH 05/14] Kundenfelder: nicht nur "irgendwas", sondern brauchbar geprueft Leerzeichen allein waren schon vorher raus, aber "x" oder "123" als Nachname und "12345" ohne Ort gingen durch. - Felder werden vor der Pruefung gesaeubert (Rand- und Mehrfachleerzeichen), damit geprueft wird, was nachher gedruckt wird. - Vorname, Nachname/Firma, Strasse brauchen mindestens zwei Buchstaben. - PLZ/Ort braucht mindestens vier Ziffern UND zwei Buchstaben - damit gehen auch 4-stellige Postleitzahlen (AT, NL) durch, aber nicht PLZ ohne Ort. - Fehlende Hausnummer wird nur nachgefragt, nicht gesperrt: Postfach- und Feldweg-Anschriften gibt es wirklich, eine falsche Sperre waere schlimmer. pruef_storno.py: 67 Pruefungen gruen, darunter Auslandsanschriften und kurze Namen, die durchgehen MUESSEN. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X --- app.py | 64 +++++++++++++++++++++++++++++++++++++++++++------ pruef_storno.py | 54 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 7 deletions(-) diff --git a/app.py b/app.py index e6a6953..f0e085b 100644 --- a/app.py +++ b/app.py @@ -75,6 +75,23 @@ def vorhandene_rechnungen(ordner, nummer): return sorted(treffer) +def saeubern(s): + """Rand-Leerzeichen weg, Mehrfachleerzeichen zu einem. + + Wird vor der Pruefung auf die Felder angewendet - geprueft werden muss + genau das, was nachher auf dem Blatt steht. + """ + return " ".join(str(s or "").split()) + + +def buchstaben(s): + return sum(1 for c in str(s) if c.isalpha()) + + +def ziffern(s): + return sum(1 for c in str(s) if c.isdigit()) + + # 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"] @@ -996,6 +1013,35 @@ class RechnungsApp(tk.Tk): fehlt.append("Leistungen") return ", ".join(fehlt) + def kunde_pruefen(self): + """Beanstandungen an den Kundenfeldern; leere Liste = in Ordnung. + + Prueft nicht nur "irgendwas eingetippt": ein Feld aus Leerzeichen, + Bindestrichen oder einem einzelnen Zeichen ist kein Name und keine + Anschrift. Die Felder werden dabei gesaeubert. + """ + for var in (self.k_anrede, self.k_vorname, self.k_name, self.k_zusatz, + self.k_strasse, self.k_plzort): + var.set(saeubern(var.get())) + + maengel = [] + for feld, var in (("Vorname", self.k_vorname), + ("Nachname / Firma", self.k_name), + ("Straße / Nr.", self.k_strasse)): + wert = var.get() + if not wert: + maengel.append(f"{feld}: ist leer") + elif buchstaben(wert) < 2: + maengel.append(f"{feld}: „{wert}“ ergibt keinen Namen") + + ort = self.k_plzort.get() + if not ort: + maengel.append("PLZ / Ort: ist leer") + elif ziffern(ort) < 4 or buchstaben(ort) < 2: + maengel.append(f"PLZ / Ort: „{ort}“ – bitte Postleitzahl UND Ort, " + "z. B. 04416 Markkleeberg") + return maengel + def pdf_erstellen(self): r = self.recalc() if not r.zeilen: @@ -1008,18 +1054,22 @@ class RechnungsApp(tk.Tk): 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: + maengel = self.kunde_pruefen() + if maengel: messagebox.showwarning( "Kundenangaben fehlen", - "Diese Felder müssen ausgefüllt sein:\n\n- " + "\n- ".join(fehlt) + + "So kann die Rechnung nicht geschrieben werden:\n\n- " + "\n- ".join(maengel) + "\n\nName und vollständige Anschrift des Kunden gehören auf jede " "Rechnung (§ 14 Abs. 4 Nr. 1 UStG).") return + # Hausnummer nur nachfragen, nicht blockieren - es gibt Anschriften ohne + # (Postfach, Haus am Feldweg), und eine falsche Sperre waere schlimmer. + if not ziffern(self.k_strasse.get()): + if not messagebox.askyesno( + "Hausnummer fehlt", + f"In „{self.k_strasse.get()}“ steht keine Hausnummer.\n\n" + "Trotzdem so auf die Rechnung schreiben?"): + 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_storno.py b/pruef_storno.py index a7233ac..b5cab48 100644 --- a/pruef_storno.py +++ b/pruef_storno.py @@ -229,6 +229,60 @@ f.pdf_erstellen() pruefe(len(os.listdir(ordner)) == len(vorher) + 1, "mit allen Pflichtfeldern entsteht die PDF") +# ---- 8. Was zaehlt als "richtig ausgefuellt"? --------------------------- +def kunde_setzen(vor, nach, strasse, ort): + f.k_vorname.set(vor) + f.k_name.set(nach) + f.k_strasse.set(strasse) + f.k_plzort.set(ort) + return f.kunde_pruefen() + + +faelle_schlecht = [ + (" ", "Meier", "Musterweg 1", "12345 Musterstadt", "Vorname nur Leerzeichen"), + ("Anna", " ", "Musterweg 1", "12345 Musterstadt", "Nachname nur Leerzeichen"), + ("Anna", "x", "Musterweg 1", "12345 Musterstadt", "Nachname ein Buchstabe"), + ("Anna", "123", "Musterweg 1", "12345 Musterstadt", "Nachname nur Ziffern"), + ("Anna", "Meier", "-", "12345 Musterstadt", "Strasse nur ein Strich"), + ("Anna", "Meier", "Musterweg 1", "12345", "nur PLZ, kein Ort"), + ("Anna", "Meier", "Musterweg 1", "Musterstadt", "nur Ort, keine PLZ"), + ("Anna", "Meier", "Musterweg 1", " ", "Ort nur Leerzeichen"), +] +for vor, nach, strasse, ort, was in faelle_schlecht: + pruefe(bool(kunde_setzen(vor, nach, strasse, ort)), f"abgelehnt: {was}") + +faelle_gut = [ + ("Anna", "Meier", "Musterweg 1", "12345 Musterstadt", "normaler Fall"), + ("Li", "Ng", "Hauptstr. 2", "04416 Markkleeberg", "kurze Namen"), + ("Jan", "de Vries", "Kerkstraat 12", "1234 AB Amsterdam", "Niederlande, 4 Ziffern"), + ("Hans", "Huber", "Seeweg 3", "5020 Salzburg", "Oesterreich, 4 Ziffern"), + ("Anna", "Camping Nord GmbH", "Am Deich 7a", "12345 Musterstadt", "Firma"), +] +for vor, nach, strasse, ort, was in faelle_gut: + m = kunde_setzen(vor, nach, strasse, ort) + pruefe(not m, f"angenommen: {was} {m if m else ''}") + +# Saeubern: Rand- und Mehrfachleerzeichen verschwinden aus den Feldern +kunde_setzen(" Anna ", " von Meier ", " Musterweg 1 ", " 12345 Musterstadt ") +pruefe(f.k_vorname.get() == "Anna", f"Vorname getrimmt ({f.k_vorname.get()!r})") +pruefe(f.k_name.get() == "von Meier", f"Doppelleerzeichen weg ({f.k_name.get()!r})") +pruefe(f.k_plzort.get() == "12345 Musterstadt", f"Ort gesaeubert ({f.k_plzort.get()!r})") + +# ---- 9. Strasse ohne Hausnummer: fragen, nicht sperren ------------------ +kunde_setzen("Anna", "Beispiel", "Am Seeufer", "12345 Musterstadt") +pruefe(not f.kunde_pruefen(), "Anschrift ohne Hausnummer ist kein Mangel") +vorher = sorted(os.listdir(ordner)) +gefragt.clear() +antworten["Hausnummer"] = False # Nutzer sagt: nein, doch nicht +f.anzahl_vars[erste].set("2") +f.update() +f.pdf_erstellen() +pruefe(any("Hausnummer" in t for t in gefragt), f"nach der Hausnummer wird gefragt ({gefragt})") +pruefe(sorted(os.listdir(ordner)) == vorher, "bei Nein wird nichts geschrieben") +antworten["Hausnummer"] = True # Nutzer sagt: ja, so ist es richtig +f.pdf_erstellen() +pruefe(len(os.listdir(ordner)) == len(vorher) + 1, "bei Ja entsteht die PDF trotzdem") + f.destroy() print("---") print(f"Ordner: {ordner}") From abb462725f230aeada0de012b9f1e3b889f2263c Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Wed, 2 Sep 2026 19:20:05 +0200 Subject: [PATCH 06/14] Altrechnungen: Namen aufteilen und Anschrift aus dem Rechnungstext holen Die bisherigen Rechnungen tragen den ganzen Namen in der Vornamen-Zeile und oft gar keine Anschrift. Beim Stornieren stand dann nur "Andres Schaefer" da und niemand wusste, wer der Gast ist. - name_aufteilen(): am Leerzeichen trennen, das letzte Wort ist der Nachname. - adresse_aus_pdf(): der Adressblock steht im Fliesstext zwischen "Rechnung an:" und der Rechnungsnummer. Anrede wird an der Auswahlliste erkannt, PLZ/Ort an der 4- bis 5-stelligen Zahl am Zeilenanfang, die Zeile davor ist die Strasse - aber nur, wenn sie nicht die Namenszeile ist. - kundendaten_ermitteln(): Kenndaten zuerst, Text als Rueckfall; benutzt vom Storno (der geht an denselben Empfaenger) und vom Formular. - Der Hinweis nach dem Storno sagt jetzt, was aus dem Text gelesen wurde und was in der alten Rechnung schlicht nicht drinsteht. Es wird nichts erfunden: fehlt die Anschrift in der alten PDF, bleiben die Felder leer und die Pflichtpruefung greift. pruef_storno.py: 80 Pruefungen gruen, darunter eine echte Altrechnung im Muster "alles in der Vornamen-Zeile" - mit und ohne Anschrift. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X --- app.py | 134 +++++++++++++++++++++++++++++++++++++++++------- pruef_storno.py | 45 ++++++++++++++++ 2 files changed, 160 insertions(+), 19 deletions(-) diff --git a/app.py b/app.py index f0e085b..e9976db 100644 --- a/app.py +++ b/app.py @@ -133,6 +133,96 @@ def kenndaten_lesen(pfad): return daten +def adresse_aus_pdf(pfad): + """Adressblock aus dem Fliesstext einer Rechnung lesen. + + Fuer alte PDFs (v1/v2), die den Kunden noch nicht in den Kenndaten fuehren. + Der Block steht zwischen "Rechnung an:" und der Rechnungsnummer. + """ + try: + from pypdf import PdfReader + zeilen = (PdfReader(pfad).pages[0].extract_text() or "").splitlines() + except Exception as e: # noqa: BLE001 - kaputte oder fremde PDF + print(f"[storno] {os.path.basename(pfad)}: Text nicht lesbar ({e})") + return {} + + start = next((i for i, z in enumerate(zeilen) + if z.strip().startswith("Rechnung an")), None) + if start is None: + return {} + block = [] + for z in zeilen[start + 1:start + 8]: + t = z.strip() + if not t or t.startswith(("Rechnungsnummer", "Stornonummer")): + break + block.append(t) + if not block: + return {} + + daten = {} + if block[0] in ANREDEN: + daten["anrede"] = block.pop(0) + # PLZ/Ort ist die Zeile, die mit einer 4- bis 5-stelligen Zahl beginnt und + # noch etwas dahinter hat. Die Zeile davor ist die Strasse - aber nur, wenn + # sie nicht selbst die Namenszeile ist. + for i in range(len(block) - 1, -1, -1): + teile = block[i].split() + if len(teile) > 1 and teile[0].isdigit() and 4 <= len(teile[0]) <= 5: + daten["plzort"] = block.pop(i) + if i - 1 >= 1: + daten["strasse"] = block.pop(i - 1) + break + if block: + daten["name"] = block.pop(0) + if block: + daten["zusatz"] = " ".join(block) + return daten + + +def name_aufteilen(ganz): + """'Andres Schäfer' -> ('Andres', 'Schäfer'); ein einzelnes Wort wird Nachname.""" + teile = str(ganz or "").split() + if len(teile) > 1: + return " ".join(teile[:-1]), teile[-1] + return "", (teile[0] if teile else "") + + +def kundendaten_ermitteln(meta): + """Kunde einer alten Rechnung so vollstaendig wie moeglich. + + Reihenfolge: Kenndaten (v3), sonst der Adressblock aus dem Fliesstext. + Steht der ganze Name in der Vornamen-Zeile - so sind die Altrechnungen + entstanden -, wird das letzte Wort zum Nachnamen. + + Rueckgabe: (kunde-dict, woher) - woher ist eine Liste der Felder, die nur + aus dem Text kamen und deshalb geprueft gehoeren. + """ + kunde = dict(meta.get("kunde") or {}) + woher = [] + vorname = saeubern(kunde.get("vorname") or meta.get("vorname")) + nachname = saeubern(kunde.get("nachname") or meta.get("nachname")) + + if not kunde.get("plzort") and meta.get("_pfad"): + aus_text = adresse_aus_pdf(meta["_pfad"]) + for feld, klartext in (("anrede", "Anrede"), ("zusatz", "Zusatz"), + ("strasse", "Straße"), ("plzort", "PLZ / Ort")): + if aus_text.get(feld) and not kunde.get(feld): + kunde[feld] = aus_text[feld] + woher.append(klartext) + if not (vorname or nachname) and aus_text.get("name"): + vorname = aus_text["name"] + woher.append("Name") + + if not nachname: + # Alte Rechnungen tragen den ganzen Namen in der Vornamen-Zeile. Getrennt + # wird am Leerzeichen: das letzte Wort ist der Nachname. + vorname, nachname = name_aufteilen(vorname) + if nachname: + woher.append("Nachname vom Vornamen abgetrennt") + kunde["vorname"], kunde["nachname"] = vorname, nachname + return kunde, woher + + def rechnungen_im_ordner(ordner): """Alle lesbaren Rechnungen des Ordners, neueste Nummer zuerst. @@ -925,19 +1015,20 @@ class RechnungsApp(tk.Tk): r = storno_rechnung_bauen(meta, self.cfg) heute = datetime.date.today().strftime("%d.%m.%Y") - namenszeile = " ".join(t for t in (str(meta.get("vorname") or ""), - str(meta.get("nachname") or "")) if t) - kunde = meta.get("kunde") or {} - kunde_zeilen = ([kunde.get("anrede", ""), namenszeile, kunde.get("zusatz", ""), - kunde.get("strasse", ""), kunde.get("plzort", "")] - if kunde else ["", namenszeile, "", "", ""]) + # 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) + namenszeile = " ".join(t for t in (kunde.get("vorname", ""), + kunde.get("nachname", "")) if t) + kunde_zeilen = [kunde.get("anrede", ""), namenszeile, kunde.get("zusatz", ""), + kunde.get("strasse", ""), kunde.get("plzort", "")] kopf = { "rechnungsnummer": nummer, "datum": heute, "anreise": "", "abreise": "", "naechte": "", "kunde_zeilen": kunde_zeilen, - "nachname": str(meta.get("nachname") or ""), - "vorname": str(meta.get("vorname") or ""), + "nachname": kunde.get("nachname", ""), + "vorname": kunde.get("vorname", ""), "art": "storno", "storno_zu": alt_nummer, "storno_datum": str(meta.get("datum") or ""), @@ -970,9 +1061,8 @@ class RechnungsApp(tk.Tk): hinweis = "" if fehlend: - hinweis = ("\n\nAus der alten PDF ließen sich nicht alle Felder übernehmen " - f"({fehlend}) – die stammt noch aus einer älteren Fassung. " - "Bitte vor dem Erstellen prüfen.") + hinweis = (f"\n\nZum Kunden: {fehlend}.\nBitte oben prüfen und ergänzen – " + "ohne vollständige Anschrift wird die Rechnung nicht geschrieben.") messagebox.showinfo( "Storno erstellt", f"Stornorechnung {nummer} zu Rechnung {alt_nummer} liegt hier:\n{pfad}\n\n" @@ -985,9 +1075,9 @@ class RechnungsApp(tk.Tk): Gibt zurück, was NICHT übernommen werden konnte - aeltere PDFs (v1/v2) tragen weder Anschrift noch Positionen in den Kenndaten. """ - self.k_vorname.set(str(meta.get("vorname") or "")) - self.k_name.set(str(meta.get("nachname") or "")) - kunde = meta.get("kunde") or {} + kunde, woher = kundendaten_ermitteln(meta) + self.k_vorname.set(kunde.get("vorname", "")) + self.k_name.set(kunde.get("nachname", "")) self.k_anrede.set(str(kunde.get("anrede", "") or "")) self.k_zusatz.set(str(kunde.get("zusatz", "") or "")) self.k_strasse.set(str(kunde.get("strasse", "") or "")) @@ -1006,12 +1096,18 @@ class RechnungsApp(tk.Tk): self.personen_vars[nr].set(str(p.get("personen", "") or "")) self.recalc() - fehlt = [] - if not kunde: - fehlt.append("Anschrift") + hinweise = [] + if woher: + hinweise.append("aus dem Rechnungstext gelesen: " + ", ".join(woher)) + fehlt = [feld for feld, wert in (("Straße", self.k_strasse.get()), + ("PLZ / Ort", self.k_plzort.get()), + ("Nachname", self.k_name.get())) + if not wert] + if fehlt: + hinweise.append("steht in der alten Rechnung nicht drin: " + ", ".join(fehlt)) if not (meta.get("positionen") or []): - fehlt.append("Leistungen") - return ", ".join(fehlt) + hinweise.append("Leistungen konnten nicht übernommen werden") + return "; ".join(hinweise) def kunde_pruefen(self): """Beanstandungen an den Kundenfeldern; leere Liste = in Ordnung. diff --git a/pruef_storno.py b/pruef_storno.py index b5cab48..f075e73 100644 --- a/pruef_storno.py +++ b/pruef_storno.py @@ -283,6 +283,51 @@ antworten["Hausnummer"] = True # Nutzer sagt: ja, so ist es richtig f.pdf_erstellen() pruefe(len(os.listdir(ordner)) == len(vorher) + 1, "bei Ja entsteht die PDF trotzdem") +# ---- 10. Altrechnung im Muster "alles in der Vornamen-Zeile" ----------- +import berechnung as B # noqa: E402 +import pdf_renderer as P # noqa: E402 + +def alt_rechnung(name, dateiname, kunde_zeilen): + """Schreibt eine Rechnung wie die alte Fassung: ohne kunde/positionen in den Kenndaten.""" + rr = B.berechne(f.cfg["katalog"], {erste: {"anzahl": 3, "personen": 0}}, f.cfg) + kopf = {"rechnungsnummer": "2026-900", "datum": "12.08.2026", "anreise": "", "abreise": "", + "naechte": "", "kunde_zeilen": kunde_zeilen, "nachname": "", "vorname": name} + ziel = os.path.join(ordner, dateiname) + P.render_rechnung(ziel, f.cfg, rr, kopf) + return ziel + + +# a) mit Anschrift im Text, Name komplett in der Vornamen-Zeile +alt = alt_rechnung("Andres Schäfer", "Rechnung_2026-900.pdf", + ["Herr", "Andres Schäfer", "c/o Stellplatz 12", + "Bornaer Chaussee 36", "04416 Markkleeberg"]) +meta_alt = A.kenndaten_lesen(alt) +pruefe(not meta_alt.get("kunde"), "Altrechnung hat keine Kundendaten in den Kenndaten") +kunde, woher = A.kundendaten_ermitteln(meta_alt) +pruefe(kunde["vorname"] == "Andres", f"Vorname aufgeteilt ({kunde['vorname']!r})") +pruefe(kunde["nachname"] == "Schäfer", f"Nachname aufgeteilt ({kunde['nachname']!r})") +pruefe(kunde.get("anrede") == "Herr", f"Anrede aus dem Text ({kunde.get('anrede')!r})") +pruefe(kunde.get("strasse") == "Bornaer Chaussee 36", f"Straße aus dem Text ({kunde.get('strasse')!r})") +pruefe(kunde.get("plzort") == "04416 Markkleeberg", f"PLZ/Ort aus dem Text ({kunde.get('plzort')!r})") +pruefe(kunde.get("zusatz") == "c/o Stellplatz 12", f"Zusatz aus dem Text ({kunde.get('zusatz')!r})") + +# b) sein echter Fall: NUR der Name, keine Anschrift +alt2 = alt_rechnung("Andres Schäfer", "Rechnung_2026-901.pdf", ["", "Andres Schäfer", "", "", ""]) +kunde2, _ = A.kundendaten_ermitteln(A.kenndaten_lesen(alt2)) +pruefe(kunde2["nachname"] == "Schäfer", "auch ohne Anschrift wird der Name aufgeteilt") +pruefe(not kunde2.get("plzort"), "keine Anschrift erfunden") + +# c) Formular fuellen: was fehlt, wird benannt +meldung = f._formular_aus_meta(A.kenndaten_lesen(alt2)) +pruefe(f.k_vorname.get() == "Andres" and f.k_name.get() == "Schäfer", + "Formular bekommt Vor- und Nachnamen getrennt") +pruefe("Straße" in meldung and "PLZ" in meldung and "Nachname vom Vornamen" in meldung, f"fehlende Anschrift wird benannt ({meldung})") +meldung = f._formular_aus_meta(A.kenndaten_lesen(alt)) +pruefe(f.k_plzort.get() == "04416 Markkleeberg", "Anschrift landet im Formular") +pruefe("aus dem Rechnungstext gelesen" in meldung, f"Herkunft wird gesagt ({meldung})") +for name in ("Rechnung_2026-900.pdf", "Rechnung_2026-901.pdf"): + os.remove(os.path.join(ordner, name)) + f.destroy() print("---") print(f"Ordner: {ordner}") From d3962ec8d1a9ca2b798f8ea6a95620f015649fff Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Wed, 2 Sep 2026 19:32:24 +0200 Subject: [PATCH 07/14] Berichtigen: zweiter Knopf im Storno-Popup, alles haengt an einem Flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fuer Rechnungen, bei denen nur die Kundenangaben falsch sind - falsche Betraege bleiben der Storno-Weg. - Kein eigener Dialog: das Storno-Popup hat jetzt zwei Knoepfe. "Nur berichtigen" laedt die Rechnung ins Formular, sperrt die Nummer und setzt ein Flag; "PDF erstellen" schreibt daraus das berichtigte Blatt. - Die Rechnung behaelt Nummer, Datum und Betraege; dazu kommt "Berichtigt am". Der Zaehler bleibt stehen - das Blatt verbraucht keine Nummer. - Ueberschrift "Berichtigte Rechnung", darunter die vollstaendige Rechnung, darunter der Bezug nach § 31 Abs. 5 UStDV und der Satz, dass es KEINE zusaetzliche Leistung und keine zweite Rechnung ist. Ohne den kann ein zweites Blatt mit ausgewiesener USt die Steuer nach § 14c UStG ein zweites Mal ausloesen. - Kenndaten des Blattes tragen Nullen und "art": "berichtigung". Damit zaehlt ein Programm, das die Art nicht kennt, 0,00 € statt den Umsatz doppelt; der Steuerrechner ueberspringt es ganz (einnahmen.py). - Dateiname "Berichtigte Rechnung_JJJJ-MM-TT_.pdf". pruef_storno.py: 100 Pruefungen gruen, darunter der ganze Berichtigungsweg inklusive Text auf dem Blatt und Gegenprobe mit dem Steuerrechner. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X --- app.py | 150 +++++++++++++++++++++++++++++++++++++++--------- pdf_renderer.py | 37 +++++++++++- pruef_storno.py | 63 +++++++++++++++++++- 3 files changed, 220 insertions(+), 30 deletions(-) diff --git a/app.py b/app.py index e9976db..8fc8ffe 100644 --- a/app.py +++ b/app.py @@ -179,6 +179,33 @@ def adresse_aus_pdf(pfad): return daten +def kunde_maengel(kunde): + """Beanstandungen an Name und Anschrift; leere Liste = in Ordnung. + + Prueft nicht nur "irgendwas eingetippt": ein Feld aus Leerzeichen, + Bindestrichen oder einem einzelnen Zeichen ist kein Name und keine + Anschrift. Vier Ziffern reichen, damit auch oesterreichische und + niederlaendische Postleitzahlen durchgehen. + """ + maengel = [] + for feld, schluessel in (("Vorname", "vorname"), + ("Nachname / Firma", "nachname"), + ("Straße / Nr.", "strasse")): + wert = saeubern(kunde.get(schluessel)) + if not wert: + maengel.append(f"{feld}: ist leer") + elif buchstaben(wert) < 2: + maengel.append(f"{feld}: „{wert}“ ergibt keinen Namen") + + ort = saeubern(kunde.get("plzort")) + if not ort: + maengel.append("PLZ / Ort: ist leer") + elif ziffern(ort) < 4 or buchstaben(ort) < 2: + maengel.append(f"PLZ / Ort: „{ort}“ – bitte Postleitzahl UND Ort, " + "z. B. 04416 Markkleeberg") + return maengel + + def name_aufteilen(ganz): """'Andres Schäfer' -> ('Andres', 'Schäfer'); ein einzelnes Wort wird Nachname.""" teile = str(ganz or "").split() @@ -432,6 +459,9 @@ class RechnungsApp(tk.Tk): # Zwischenspeicher: geschriebenes Storno, das noch bestaetigt werden muss. # Solange es hier liegt, gehoert zur naechsten PDF die Storno-Kette. self.storno_stand = None + # Zwischenspeicher fuer eine reine Berichtigung (§ 31 Abs. 5 UStDV): + # die naechste PDF ist dann ein berichtigtes Blatt zu dieser Rechnung. + self.berichtigung_stand = None self._apply_theme() # Farben nach Windows hell/dunkel self._build_menu() @@ -825,7 +855,8 @@ class RechnungsApp(tk.Tk): links = ttk.Frame(f) links.pack(side="left") ttk.Button(links, text="PDF erstellen", command=self.pdf_erstellen, width=18).pack(side="left", padx=4) - ttk.Button(links, text="Rechnung stornieren", command=self.dlg_storno, width=20).pack(side="left", padx=4) + ttk.Button(links, text="Stornieren / berichtigen", command=self.dlg_storno, + width=22).pack(side="left", padx=4) ttk.Button(links, text="Zurücksetzen", command=self.reset_formular, width=14).pack(side="left", padx=4) # --------------------------------------------------------- Berechnung @@ -886,10 +917,33 @@ class RechnungsApp(tk.Tk): f"Storno {stand['nummer']} zu Rechnung {stand['zu']} liegt bereit.\n" f"Beim Erstellen wird noch einmal gefragt.") self.nummer_entry.configure(state="readonly") + elif self.berichtigung_stand: + self.storno_hinweis.set( + f"Berichtigung der Rechnung {self.berichtigung_stand['zu']}.\n" + "Nummer und Beträge bleiben, nur die Kundenangaben ändern sich.") + self.nummer_entry.configure(state="readonly") else: self.storno_hinweis.set("") self.nummer_entry.configure(state="normal") + def _berichtigung_starten(self, meta, out_dir): + """Rechnung ins Formular holen; "PDF erstellen" schreibt das berichtigte Blatt.""" + nummer = str(meta.get("rechnungsnummer", "")) + fehlend = self._formular_aus_meta(meta) + self.berichtigung_stand = {"zu": nummer, "datum": str(meta.get("datum") or ""), + "meta": meta} + # Die Rechnung behaelt ihre Nummer - genau das ist der Unterschied zum Storno. + self.r_nummer.set(nummer) + self.r_datum.set(str(meta.get("datum") or self.r_datum.get())) + self._storno_anzeigen() + hinweis = f"\n\nZum Kunden: {fehlend}." if fehlend else "" + messagebox.showinfo( + "Rechnung geladen", + f"Rechnung {nummer} vom {meta.get('datum','')} ist im Formular.\n\n" + "Jetzt die Kundenangaben richtigstellen und \"PDF erstellen\" drücken – " + "es entsteht ein berichtigtes Blatt zu dieser Rechnung. Die alte Rechnung " + f"bleibt liegen.{hinweis}") + def _storno_verwerfen(self, grund=""): """Storno rückgängig: PDF löschen, Zähler zurück, Zwischenspeicher leeren. @@ -930,7 +984,7 @@ class RechnungsApp(tk.Tk): 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" + if d.get("art") not in ("storno", "berichtigung") and nummer_normalisieren(d.get("rechnungsnummer")) not in stornierte] if not offen: messagebox.showwarning( @@ -946,10 +1000,14 @@ class RechnungsApp(tk.Tk): frm = ttk.Frame(d, padding=12) frm.pack(fill="both", expand=True) - ttk.Label(frm, justify="left", text= - "Die alte Rechnung bleibt unverändert liegen – sie darf weder geändert\n" - "noch gelöscht werden. Das Programm schreibt eine Stornorechnung mit\n" - "eigener neuer Nummer, die sich auf sie bezieht.").grid( + ttk.Label(frm, justify="left", wraplength=470, text= + "Die alte Rechnung bleibt unverändert liegen – sie darf weder geändert noch " + "gelöscht werden.\n\n" + "Storno erstellen: falsche Beträge oder Leistungen. Es entsteht eine " + "Stornorechnung mit eigener neuer Nummer und danach die berichtigte Rechnung.\n" + "Nur berichtigen: es stimmen nur die Angaben zum Kunden nicht. Die Rechnung " + "behält Nummer und Beträge, es wird nur ein berichtigtes Blatt nachgereicht " + "(§ 31 Abs. 5 UStDV).").grid( row=0, column=0, columnspan=2, sticky="w", pady=(0, 10)) ttk.Label(frm, text="Alte Rechnungsnummer").grid(row=1, column=0, sticky="w", pady=3) @@ -970,7 +1028,8 @@ class RechnungsApp(tk.Tk): 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 nummer_normalisieren(x.get("rechnungsnummer")) == nr + and x.get("art") != "berichtigung"), None) if not treffer: return None, f"Zu {nr} liegt in\n{out_dir}\nkeine Rechnung." if treffer.get("art") == "storno": @@ -998,9 +1057,18 @@ class RechnungsApp(tk.Tk): self._storno_anlegen(meta, out_dir) ent.bind("", anlegen) + def berichtigen(): + meta, meldung = suche() + if not meta: + v_info.set(meldung) + return + d.destroy() + self._berichtigung_starten(meta, out_dir) + btns = ttk.Frame(frm) btns.grid(row=3, column=0, columnspan=2, pady=(14, 0), sticky="e") ttk.Button(btns, text="Storno erstellen", command=anlegen).pack(side="right", padx=4) + ttk.Button(btns, text="Nur berichtigen", command=berichtigen).pack(side="right", padx=4) ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right") frm.columnconfigure(1, weight=1) @@ -1112,31 +1180,15 @@ class RechnungsApp(tk.Tk): def kunde_pruefen(self): """Beanstandungen an den Kundenfeldern; leere Liste = in Ordnung. - Prueft nicht nur "irgendwas eingetippt": ein Feld aus Leerzeichen, - Bindestrichen oder einem einzelnen Zeichen ist kein Name und keine - Anschrift. Die Felder werden dabei gesaeubert. + Saeubert die Felder und prueft sie mit kunde_maengel(). """ for var in (self.k_anrede, self.k_vorname, self.k_name, self.k_zusatz, self.k_strasse, self.k_plzort): var.set(saeubern(var.get())) - - maengel = [] - for feld, var in (("Vorname", self.k_vorname), - ("Nachname / Firma", self.k_name), - ("Straße / Nr.", self.k_strasse)): - wert = var.get() - if not wert: - maengel.append(f"{feld}: ist leer") - elif buchstaben(wert) < 2: - maengel.append(f"{feld}: „{wert}“ ergibt keinen Namen") - - ort = self.k_plzort.get() - if not ort: - maengel.append("PLZ / Ort: ist leer") - elif ziffern(ort) < 4 or buchstaben(ort) < 2: - maengel.append(f"PLZ / Ort: „{ort}“ – bitte Postleitzahl UND Ort, " - "z. B. 04416 Markkleeberg") - return maengel + return kunde_maengel({"vorname": self.k_vorname.get(), + "nachname": self.k_name.get(), + "strasse": self.k_strasse.get(), + "plzort": self.k_plzort.get()}) def pdf_erstellen(self): r = self.recalc() @@ -1219,6 +1271,48 @@ class RechnungsApp(tk.Tk): kopf["storno_nummer"] = stand["nummer"] out_dir = config.ensure_output_dir(self.cfg) + # ---- reine Berichtigung: gleiche Nummer, gleiche Betraege, neues Blatt ---- + ber = self.berichtigung_stand + if ber: + kopf["art"] = "berichtigung" + kopf["berichtigt_zu"] = ber["zu"] + kopf["berichtigt_am"] = datetime.date.today().strftime("%d.%m.%Y") + if not messagebox.askyesno( + "Berichtigte Rechnung erstellen?", + f"Zu Rechnung {ber['zu']} vom {ber['datum']} wird ein berichtigtes " + "Blatt geschrieben.\n\n" + "Nummer, Leistungen und Beträge bleiben unverändert – die alte " + "Rechnung bleibt liegen. Das Blatt gehört zum Gast: erst wenn es " + "ihm zugegangen ist, ist die Rechnung berichtigt.\n\nJetzt erstellen?", + default="yes"): + return + # Dateiname: "Berichtigte Rechnung", Datum, Nummer der Rechnung. Das + # Blatt verbraucht keine eigene Nummer, das Datum haelt mehrere + # Berichtigungen zur selben Rechnung auseinander (sortierbar). + pfad = os.path.join( + out_dir, f"Berichtigte Rechnung_{datetime.date.today():%Y-%m-%d}" + f"_{safe_filename(ber['zu'])}.pdf") + if os.path.exists(pfad) and not messagebox.askyesno( + "Berichtigung von heute ersetzen?", + f"Zu Rechnung {ber['zu']} gibt es von heute schon eine Berichtigung:\n" + f"{os.path.basename(pfad)}\n\nDurch die neue ersetzen?"): + return + try: + pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf) + except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen + messagebox.showerror("Fehler beim Erstellen", + f"Berichtigung konnte nicht erstellt werden:\n{e}") + print("[berichtigung] FEHLER:", e) + return + print(f"[berichtigung] erstellt -> {pfad}") + self.berichtigung_stand = None + self._storno_anzeigen() + if messagebox.askyesno("Fertig", + f"Berichtigte Rechnung zu {ber['zu']}:\n{pfad}\n\n" + "Jetzt öffnen (zum Drucken)?"): + self._oeffne_datei(pfad) + return + # Nummer schon vergeben? Dann NICHT drueberschreiben: eine ausgestellte # Rechnung muss unveraendert bestehen bleiben (§ 146 Abs. 4 AO), und jede # Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG). Also neue Nummer. diff --git a/pdf_renderer.py b/pdf_renderer.py index 65c4db3..ec5ea58 100644 --- a/pdf_renderer.py +++ b/pdf_renderer.py @@ -223,6 +223,11 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): info_rows.append(("Rechnung vom:", kopf["storno_datum"])) elif art == "korrektur" and kopf.get("ersetzt"): info_rows.append(("Ersetzt Rechnung:", kopf.get("ersetzt", ""))) + elif art == "berichtigung": + # Die Rechnung behaelt Nummer und Datum - dazu kommt der Tag der + # Berichtigung, damit man die Reihenfolge sieht. + if kopf.get("berichtigt_am"): + info_rows.append(("Berichtigt am:", kopf["berichtigt_am"])) if kopf.get("anreise"): info_rows.append(("Anreise:", kopf.get("anreise", ""))) if kopf.get("abreise"): @@ -252,7 +257,8 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): # in der Ueberschrift. Der Zusatz sagt nur noch, fuer wen das Blatt ist. # Das Wort "Stornorechnung" muss auf dem Blatt stehen, damit der Beleg # eindeutig als Aufhebung erkennbar ist. - titel = "Stornorechnung" if art == "storno" else "Rechnungsübersicht" + titel = {"storno": "Stornorechnung", + "berichtigung": "Berichtigte Rechnung"}.get(art, "Rechnungsübersicht") titel += " (für das Amt)" if gesamtrechnung_betont else "" story.append(Paragraph(titel, st["h"])) @@ -382,6 +388,19 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. " "Bereits gezahlte Beträge werden erstattet oder mit der berichtigten " "Rechnung verrechnet.", st["dank"])) + elif art == "berichtigung": + # Der Satz ist kein Beiwerk: ein zweites Blatt mit ausgewiesener USt, + # das nicht klar als Berichtigung erkennbar ist, kann die Steuer ein + # zweites Mal ausloesen (§ 14c UStG). + block.append(Paragraph( + f"Dieses Blatt berichtigt die Angaben zur Rechnung " + f"{kopf.get('berichtigt_zu','')} vom {kopf.get('datum','')} nach " + "§ 31 Abs. 5 UStDV. Leistungen, Beträge und Umsatzsteuer sind " + "unverändert – es handelt sich nicht um eine zusätzliche " + "Leistung und nicht um eine zweite Rechnung. Die Rechnung behält " + "ihre Nummer.", st["dank"])) + if texte.get("dank1"): + block.append(Paragraph(texte["dank1"], st["dank"])) else: if art == "korrektur" and kopf.get("ersetzt"): hinweis = f"Diese Rechnung ersetzt die Rechnung {kopf['ersetzt']}" @@ -422,6 +441,22 @@ def _kenndaten(rechnung, kopf): Steuerrechner summieren dadurch von allein richtig (+100 -100 +120 = 120). """ aufschlag = rechnung.aufschlaege[0] if rechnung.aufschlaege else None + if kopf.get("art") == "berichtigung": + # Das berichtigte Blatt zeigt dieselben Betraege wie die Rechnung, ist aber + # KEINE zweite Einnahme. Es traegt deshalb Nullen: ein Programm, das "art" + # nicht kennt, zaehlt dann 0,00 € statt den Umsatz ein zweites Mal. + return json.dumps({ + "quelle": "rechnungstool", "version": 3, "art": "berichtigung", + "rechnungsnummer": kopf.get("berichtigt_zu", ""), + "berichtigt_zu": kopf.get("berichtigt_zu", ""), + "berichtigt_am": kopf.get("berichtigt_am", ""), + "datum": kopf.get("datum", ""), + "nachname": kopf.get("nachname", ""), "vorname": kopf.get("vorname", ""), + "zwischensumme": 0.0, "gesamt": 0.0, + "steuer_satz": 0.0, "steuer_betrag": 0.0, + "netto_gesamt": 0.0, "ust_gesamt": 0.0, "ust_bloecke": [], + "kunde": kopf.get("kunde", {}), + }, ensure_ascii=False) daten = { "quelle": "rechnungstool", "version": 3, diff --git a/pruef_storno.py b/pruef_storno.py index f075e73..eef1327 100644 --- a/pruef_storno.py +++ b/pruef_storno.py @@ -290,7 +290,9 @@ import pdf_renderer as P # noqa: E402 def alt_rechnung(name, dateiname, kunde_zeilen): """Schreibt eine Rechnung wie die alte Fassung: ohne kunde/positionen in den Kenndaten.""" rr = B.berechne(f.cfg["katalog"], {erste: {"anzahl": 3, "personen": 0}}, f.cfg) - kopf = {"rechnungsnummer": "2026-900", "datum": "12.08.2026", "anreise": "", "abreise": "", + # Nummer aus dem Dateinamen, damit Kenndaten und Datei zusammenpassen + nummer = dateiname.split("_")[1].replace(".pdf", "") + kopf = {"rechnungsnummer": nummer, "datum": "12.08.2026", "anreise": "", "abreise": "", "naechte": "", "kunde_zeilen": kunde_zeilen, "nachname": "", "vorname": name} ziel = os.path.join(ordner, dateiname) P.render_rechnung(ziel, f.cfg, rr, kopf) @@ -328,6 +330,65 @@ pruefe("aus dem Rechnungstext gelesen" in meldung, f"Herkunft wird gesagt ({meld for name in ("Rechnung_2026-900.pdf", "Rechnung_2026-901.pdf"): os.remove(os.path.join(ordner, name)) +# ---- 11. Berichtigung: gleicher Dialog, zweiter Knopf ------------------- +alt3 = alt_rechnung("Andres Schäfer", "Rechnung_2026-910.pdf", ["", "Andres Schäfer", "", "", ""]) +meta3 = A.kenndaten_lesen(alt3) +vorher = sorted(os.listdir(ordner)) +zaehler_vor_berichtigung = f.cfg["rechnung_zaehler"] + +f._berichtigung_starten(meta3, ordner) +pruefe(f.berichtigung_stand is not None, "Flag fuer die Berichtigung gesetzt") +pruefe(f.r_nummer.get() == "2026-910", f"Rechnung behaelt ihre Nummer ({f.r_nummer.get()})") +pruefe(str(f.nummer_entry.cget("state")) == "readonly", "Nummernfeld gesperrt") +pruefe(f.k_vorname.get() == "Andres" and f.k_name.get() == "Schäfer", + "Name aufgeteilt im Formular") + +# ohne Anschrift wird auch hier nichts geschrieben +warnungen.clear() +f.anzahl_vars[erste].set("3") +f.update() +f.pdf_erstellen() +pruefe(warnungen and "Kundenangaben" in warnungen[0], "Pflichtpruefung gilt auch hier") +pruefe(sorted(os.listdir(ordner)) == vorher, "nichts geschrieben") + +# Anschrift ergaenzen und erstellen +f.k_strasse.set("Bornaer Chaussee 36") +f.k_plzort.set("04416 Markkleeberg") +antworten["Berichtigte Rechnung erstellen"] = True +f.update() +f.pdf_erstellen() +import datetime as _dt +ziel = os.path.join(ordner, f"Berichtigte Rechnung_{_dt.date.today():%Y-%m-%d}_2026-910.pdf") +pruefe(os.path.exists(ziel), f"Blatt geschrieben ({os.path.basename(ziel)})") +pruefe(f.berichtigung_stand is None, "Flag nach dem Erstellen wieder weg") +pruefe(str(f.nummer_entry.cget("state")) == "normal", "Nummernfeld wieder frei") +pruefe(os.path.exists(alt3), "die berichtigte Rechnung liegt unangetastet da") +pruefe(f.cfg["rechnung_zaehler"] == zaehler_vor_berichtigung, + f"Zaehler unveraendert - die Berichtigung verbraucht keine Nummer " + f"({f.cfg['rechnung_zaehler']} vs {zaehler_vor_berichtigung})") + +meta_b = A.kenndaten_lesen(ziel) +pruefe(meta_b.get("art") == "berichtigung", "art = berichtigung") +pruefe(meta_b.get("berichtigt_zu") == "2026-910", "Bezug auf die Rechnung") +pruefe(float(meta_b.get("gesamt") or 0) == 0.0, + f"Kenndaten tragen 0 - keine zweite Einnahme ({meta_b.get('gesamt')})") + +from pypdf import PdfReader as _PR # noqa: E402 +text = _PR(ziel).pages[0].extract_text() +pruefe("Berichtigte Rechnung" in text, "Ueberschrift steht auf dem Blatt") +pruefe("2026-910" in text, "Rechnungsnummer steht drauf") +pruefe("nicht" in text and "31 Abs. 5 UStDV" in text, "der Schutzsatz steht drauf") +pruefe("04416 Markkleeberg" in text, "berichtigte Anschrift steht drauf") + +liste3, kaputt3 = einnahmen.scanne(ordner, {"ust_saetze": [7, 19]}) +pruefe(not any("Berichtigte" in os.path.basename(e.pdf_pfad) for e in liste3), + "Steuerrechner zaehlt das Blatt nicht als Einnahme") +pruefe(not any("Berichtigte" in os.path.basename(p) for p, _ in kaputt3), + f"und meldet es nicht als Fehler ({kaputt3})") + +for name in ("Rechnung_2026-910.pdf", os.path.basename(ziel)): + os.remove(os.path.join(ordner, name)) + f.destroy() print("---") print(f"Ordner: {ordner}") From 2277a12e0f7a9e859e50adb926ef85059d1dfdd1 Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Wed, 2 Sep 2026 19:41:00 +0200 Subject: [PATCH 08/14] Zwei getrennte Knoepfe und app.py in Module aufgeteilt Knoepfe: "Storno" und "Berichtigen" setzen den Weg schon beim Oeffnen fest, das Popup fragt dann nur noch die Nummer. Vorher entschied ein zweiter Knopf im Popup - der ist raus. app.py war 1802 Zeilen, jetzt 672. Neu daneben: helfer.py Zahlen, Datum, Namen, Pruefungen (87) bestand.py was im Rechnungsordner liegt, nur lesen (171) storno.py den spiegelbildlichen Storno rechnen (57) einzelinstanz.py nur ein Fenster zulassen (90) theme.py Farben nach Windows hell/dunkel (120) korrektur.py Storno- und Berichtigungsweg (306) einstellungen.py die Dialoge aus dem Menue (358) Die Oberflaeche bleibt eine Klasse - die Teile sind Mixins, damit kein einziger self-Zugriff umgeschrieben werden musste. Kein Modul importiert app.py zurueck, keine ungenutzten Importe mehr. Die Pruefstaende holen die Funktionen jetzt aus dem Modul, dem sie gehoeren. pruef_storno.py 100 gruen, pruef_nummern.py 7 gruen, Fenster nach der Aufteilung gestartet und angesehen. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X --- app.py | 1156 +--------------------------------------------- bestand.py | 171 +++++++ einstellungen.py | 358 ++++++++++++++ einzelinstanz.py | 90 ++++ helfer.py | 87 ++++ korrektur.py | 306 ++++++++++++ pruef_nummern.py | 2 +- pruef_storno.py | 25 +- storno.py | 57 +++ theme.py | 120 +++++ 10 files changed, 1225 insertions(+), 1147 deletions(-) create mode 100644 bestand.py create mode 100644 einstellungen.py create mode 100644 einzelinstanz.py create mode 100644 helfer.py create mode 100644 korrektur.py create mode 100644 storno.py create mode 100644 theme.py diff --git a/app.py b/app.py index 8fc8ffe..eb796bb 100644 --- a/app.py +++ b/app.py @@ -8,442 +8,36 @@ Bedienung: 3. "PDF erstellen" -> druckfertige Rechnung im Ordner rechnungen/ Steuersaetze, Bettensteuer, Preise und Firmendaten -> Menue "Einstellungen". + +Aufgeteilt in: + helfer.py - Zahlen, Datum, Namen, Pruefungen + bestand.py - was im Rechnungsordner liegt (nur lesen) + storno.py - den spiegelbildlichen Storno rechnen + einzelinstanz.py - nur ein Fenster zulassen + theme.py - Farben nach Windows hell/dunkel + korrektur.py - Storno- und Berichtigungsweg + einstellungen.py - die Dialoge aus dem Menue "Einstellungen" """ -import json import os import sys import datetime import tkinter as tk -from tkinter import ttk, messagebox, filedialog +from tkinter import ttk, messagebox import config import berechnung import pdf_renderer +from helfer import ANREDEN, kunde_maengel, parse_date, parse_num, saeubern, safe_filename, ziffern +from bestand import naechste_freie_nummer, vorhandene_rechnungen +from einzelinstanz import (eigene_pid_merken, einzelinstanz_pruefen, fenster_nach_vorn, + laufende_pid_lesen) +from theme import ThemeMixin +from korrektur import KorrekturMixin +from einstellungen import EinstellungenMixin -def parse_num(s): - """'12', '3,5' -> float; leer/ungueltig -> 0.0""" - s = (s or "").strip().replace(",", ".") - if not s: - return 0.0 - try: - return float(s) - except ValueError: - return 0.0 - - -def parse_date(s): - """'28.01.2026' -> date; sonst None""" - s = (s or "").strip() - for fmt in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d"): - try: - return datetime.datetime.strptime(s, fmt).date() - except ValueError: - continue - return None - - -def safe_filename(s): - keep = "-_.() " - return "".join(c for c in s if c.isalnum() or c in keep).strip() or "Rechnung" - - -def vorhandene_rechnungen(ordner, nummer): - """Alle PDFs im Ordner, die schon zu dieser Rechnungsnummer gehoeren. - - Der Kundenname haengt mit im Dateinamen ("Rechnung_2026-013_Meier.pdf"), - darum wird nur der Nummernteil verglichen - sonst rutscht dieselbe Nummer - unter einem anderen Namen als zweite Datei durch. - """ - # 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: - return [] - treffer = [] - for name in namen: - if not name.lower().endswith(".pdf"): - continue - stamm = name[:-4] - # exakt die Nummer oder Nummer + "_Kundenname" - "2026-130" faellt raus - if any(stamm == k or stamm.startswith(k + "_") for k in koepfe): - treffer.append(os.path.join(ordner, name)) - return sorted(treffer) - - -def saeubern(s): - """Rand-Leerzeichen weg, Mehrfachleerzeichen zu einem. - - Wird vor der Pruefung auf die Felder angewendet - geprueft werden muss - genau das, was nachher auf dem Blatt steht. - """ - return " ".join(str(s or "").split()) - - -def buchstaben(s): - return sum(1 for c in str(s) if c.isalpha()) - - -def ziffern(s): - return sum(1 for c in str(s) if c.isdigit()) - - -# 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. - - Gelesen wird der Bestand, nicht das Formular - ein Storno muss genau das - spiegeln, was rausgegangen ist. - """ - try: - from pypdf import PdfReader - roh = (PdfReader(pfad).metadata or {}).get("/Subject") or "" - if not str(roh).strip().startswith("{"): - return None - daten = json.loads(str(roh)) - except Exception as e: # noqa: BLE001 - fremde oder kaputte PDF - print(f"[storno] {os.path.basename(pfad)}: keine lesbaren Kenndaten ({e})") - return None - if daten.get("quelle") != "rechnungstool" or not daten.get("rechnungsnummer"): - return None - daten["_pfad"] = pfad - return daten - - -def adresse_aus_pdf(pfad): - """Adressblock aus dem Fliesstext einer Rechnung lesen. - - Fuer alte PDFs (v1/v2), die den Kunden noch nicht in den Kenndaten fuehren. - Der Block steht zwischen "Rechnung an:" und der Rechnungsnummer. - """ - try: - from pypdf import PdfReader - zeilen = (PdfReader(pfad).pages[0].extract_text() or "").splitlines() - except Exception as e: # noqa: BLE001 - kaputte oder fremde PDF - print(f"[storno] {os.path.basename(pfad)}: Text nicht lesbar ({e})") - return {} - - start = next((i for i, z in enumerate(zeilen) - if z.strip().startswith("Rechnung an")), None) - if start is None: - return {} - block = [] - for z in zeilen[start + 1:start + 8]: - t = z.strip() - if not t or t.startswith(("Rechnungsnummer", "Stornonummer")): - break - block.append(t) - if not block: - return {} - - daten = {} - if block[0] in ANREDEN: - daten["anrede"] = block.pop(0) - # PLZ/Ort ist die Zeile, die mit einer 4- bis 5-stelligen Zahl beginnt und - # noch etwas dahinter hat. Die Zeile davor ist die Strasse - aber nur, wenn - # sie nicht selbst die Namenszeile ist. - for i in range(len(block) - 1, -1, -1): - teile = block[i].split() - if len(teile) > 1 and teile[0].isdigit() and 4 <= len(teile[0]) <= 5: - daten["plzort"] = block.pop(i) - if i - 1 >= 1: - daten["strasse"] = block.pop(i - 1) - break - if block: - daten["name"] = block.pop(0) - if block: - daten["zusatz"] = " ".join(block) - return daten - - -def kunde_maengel(kunde): - """Beanstandungen an Name und Anschrift; leere Liste = in Ordnung. - - Prueft nicht nur "irgendwas eingetippt": ein Feld aus Leerzeichen, - Bindestrichen oder einem einzelnen Zeichen ist kein Name und keine - Anschrift. Vier Ziffern reichen, damit auch oesterreichische und - niederlaendische Postleitzahlen durchgehen. - """ - maengel = [] - for feld, schluessel in (("Vorname", "vorname"), - ("Nachname / Firma", "nachname"), - ("Straße / Nr.", "strasse")): - wert = saeubern(kunde.get(schluessel)) - if not wert: - maengel.append(f"{feld}: ist leer") - elif buchstaben(wert) < 2: - maengel.append(f"{feld}: „{wert}“ ergibt keinen Namen") - - ort = saeubern(kunde.get("plzort")) - if not ort: - maengel.append("PLZ / Ort: ist leer") - elif ziffern(ort) < 4 or buchstaben(ort) < 2: - maengel.append(f"PLZ / Ort: „{ort}“ – bitte Postleitzahl UND Ort, " - "z. B. 04416 Markkleeberg") - return maengel - - -def name_aufteilen(ganz): - """'Andres Schäfer' -> ('Andres', 'Schäfer'); ein einzelnes Wort wird Nachname.""" - teile = str(ganz or "").split() - if len(teile) > 1: - return " ".join(teile[:-1]), teile[-1] - return "", (teile[0] if teile else "") - - -def kundendaten_ermitteln(meta): - """Kunde einer alten Rechnung so vollstaendig wie moeglich. - - Reihenfolge: Kenndaten (v3), sonst der Adressblock aus dem Fliesstext. - Steht der ganze Name in der Vornamen-Zeile - so sind die Altrechnungen - entstanden -, wird das letzte Wort zum Nachnamen. - - Rueckgabe: (kunde-dict, woher) - woher ist eine Liste der Felder, die nur - aus dem Text kamen und deshalb geprueft gehoeren. - """ - kunde = dict(meta.get("kunde") or {}) - woher = [] - vorname = saeubern(kunde.get("vorname") or meta.get("vorname")) - nachname = saeubern(kunde.get("nachname") or meta.get("nachname")) - - if not kunde.get("plzort") and meta.get("_pfad"): - aus_text = adresse_aus_pdf(meta["_pfad"]) - for feld, klartext in (("anrede", "Anrede"), ("zusatz", "Zusatz"), - ("strasse", "Straße"), ("plzort", "PLZ / Ort")): - if aus_text.get(feld) and not kunde.get(feld): - kunde[feld] = aus_text[feld] - woher.append(klartext) - if not (vorname or nachname) and aus_text.get("name"): - vorname = aus_text["name"] - woher.append("Name") - - if not nachname: - # Alte Rechnungen tragen den ganzen Namen in der Vornamen-Zeile. Getrennt - # wird am Leerzeichen: das letzte Wort ist der Nachname. - vorname, nachname = name_aufteilen(vorname) - if nachname: - woher.append("Nachname vom Vornamen abgetrennt") - kunde["vorname"], kunde["nachname"] = vorname, nachname - return kunde, woher - - -def rechnungen_im_ordner(ordner): - """Alle lesbaren Rechnungen des Ordners, neueste Nummer zuerst. - - Stornos und schon stornierte Rechnungen sind mit drin - welche davon - stornierbar ist, entscheidet der Aufrufer. - """ - try: - namen = sorted(os.listdir(ordner)) - except OSError: - return [] - gefunden = [] - for name in namen: - # 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: - gefunden.append(daten) - gefunden.sort(key=lambda d: str(d.get("rechnungsnummer", "")), reverse=True) - return gefunden - - -def storno_rechnung_bauen(meta, cfg): - """Baut aus den Kenndaten der alten Rechnung den spiegelbildlichen Storno. - - Alle Betraege negativ. Je USt-Satz eine Zeile - die einzelnen Leistungen der - alten Rechnung werden NICHT wiederholt: aufgehoben wird der Beleg als Ganzes, - und die Positionen stehen in aelteren PDFs gar nicht in den Kenndaten. - """ - r = berechnung.Rechnung() - nummer = meta.get("rechnungsnummer", "") - bloecke = meta.get("ust_bloecke") or [] - if not bloecke: - # v1-PDF ohne Aufteilung: alles auf den ermaessigten Satz, sonst faellt - # die USt unter den Tisch. - brutto = float(meta.get("zwischensumme") or 0) - satz = int((cfg.get("steuersaetze") or [{"satz": 7}])[0].get("satz", 7)) - netto = berechnung.runde(brutto * 100 / (100 + satz)) if satz else brutto - bloecke = [{"satz": satz, "netto": netto, "ust": berechnung.runde(brutto - netto), - "brutto": brutto}] - - saetze = {int(s.get("satz", -1)): s for s in cfg.get("steuersaetze", [])} - for i, b in enumerate(bloecke, start=1): - satz = int(float(b.get("satz", 0) or 0)) - brutto = -abs(berechnung.runde(b.get("brutto") or 0)) - netto = -abs(berechnung.runde(b.get("netto") or 0)) - ust = -abs(berechnung.runde(b.get("ust") or 0)) - r.zeilen.append(berechnung.PositionsZeile( - nr=i, leistung=f"Storno der Rechnung {nummer} ({satz} % USt)", - satz=satz, preis=brutto, anzahl=1, personen=0, pro_person=False, - brutto=brutto, netto=netto)) - info = saetze.get(satz, {}) - r.bloecke.append(berechnung.Steuerblock( - satz=satz, netto=netto, ust=ust, - bezeichnung=str(info.get("bezeichnung", "") or "Mehrwertsteuer"), - extra_blatt=bool(info.get("extra_blatt", False)))) - - r.netto_gesamt = berechnung.runde(sum(b.netto for b in r.bloecke)) - r.ust_gesamt = berechnung.runde(sum(b.ust for b in r.bloecke)) - r.brutto = berechnung.runde(r.netto_gesamt + r.ust_gesamt) - - # Beherbergungssteuer wird mitstorniert, sonst stimmt die Meldung ans Amt nicht. - # Die Bezeichnung kommt aus dem Satz mit "extra_blatt" - genau dem, aus dem - # berechne() den Aufschlag baut. - steuer = berechnung.runde(meta.get("steuer_betrag") or 0) - if steuer: - extra = next((s for s in cfg.get("steuersaetze", []) if s.get("extra_blatt")), {}) - r.aufschlaege.append(berechnung.Aufschlag( - satz=float(meta.get("steuer_satz") or extra.get("satz", 0) or 0), - bezeichnung=str(extra.get("bezeichnung", "") or "Beherbergungssteuer"), - basis=r.brutto, betrag=-abs(steuer), extra_blatt=True)) - r.gesamt = berechnung.runde(r.brutto + sum(a.betrag for a in r.aufschlaege)) - return r - - -def naechste_freie_nummer(ordner, cfg): - """Erste Nummer JJJJ-NNN, zu der im Ordner noch kein PDF liegt. - - Gezaehlt wird ab dem zuletzt vergebenen Stand aus der config; im Ordner - hoeher liegende Nummern werden dabei uebersprungen, weil sie nicht frei sind. - """ - jahr = cfg.get("rechnung_jahr", datetime.date.today().year) - z = cfg.get("rechnung_zaehler", 0) - for _ in range(1000): - z += 1 - kandidat = config.format_rechnungsnummer(jahr, z) - if not vorhandene_rechnungen(ordner, kandidat): - return kandidat - return None - - -def einzelinstanz_pruefen(): - """Sperrt das Programm gegen ein zweites Fenster. - - Beide Fenster lesen beim Start denselben Zaehler aus der config und - schlagen darum dieselbe Rechnungsnummer vor -> die Nummer wird doppelt - vergeben. Darum: nur eine Instanz je Windows-Sitzung. - - Rueckgabe: Handle (offen halten, solange das Programm laeuft) oder - False, wenn schon eine Instanz laeuft. - """ - if not sys.platform.startswith("win"): - return None - import ctypes - from ctypes import wintypes - ERROR_ALREADY_EXISTS = 183 - k32 = ctypes.WinDLL("kernel32", use_last_error=True) - k32.CreateMutexW.argtypes = [wintypes.LPVOID, wintypes.BOOL, wintypes.LPCWSTR] - k32.CreateMutexW.restype = wintypes.HANDLE - handle = k32.CreateMutexW(None, False, "RechnungstoolCampinghofBartl") - if ctypes.get_last_error() == ERROR_ALREADY_EXISTS: - return False - return handle - - - -# Merkzettel mit der PID der laufenden Instanz - liegt neben der config.json. -PID_DATEI = os.path.join(config.BASE_DIR, "rechnungstool.pid") - - -def eigene_pid_merken(): - """Schreibt die eigene PID, damit eine zweite Instanz das Fenster findet.""" - try: - with open(PID_DATEI, "w", encoding="ascii") as f: - f.write(str(os.getpid())) - except OSError: - pass - - -def laufende_pid_lesen(): - """PID der laufenden Instanz aus dem Merkzettel; None wenn unlesbar.""" - try: - with open(PID_DATEI, "r", encoding="ascii") as f: - return int(f.read().strip()) - except (OSError, ValueError): - return None - - -def fenster_nach_vorn(pid): - """Holt das Fenster des Prozesses nach vorn. True bei Erfolg. - - Gesucht wird ueber die PID, nicht ueber den Fenstertitel - der Titel - haengt am Firmennamen aus der config und aendert sich mit ihm. - """ - if not pid or not sys.platform.startswith("win"): - return False - import ctypes - from ctypes import wintypes - SW_RESTORE = 9 - u32 = ctypes.WinDLL("user32", use_last_error=True) - u32.IsWindowVisible.argtypes = [wintypes.HWND] - u32.IsIconic.argtypes = [wintypes.HWND] - u32.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int] - u32.SetForegroundWindow.argtypes = [wintypes.HWND] - u32.GetWindowTextLengthW.argtypes = [wintypes.HWND] - u32.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)] - - treffer = [] - - def besuch(hwnd, _lparam): - fremd = wintypes.DWORD() - u32.GetWindowThreadProcessId(hwnd, ctypes.byref(fremd)) - # nur das echte Hauptfenster: sichtbar und mit Titel - if fremd.value == pid and u32.IsWindowVisible(hwnd) and u32.GetWindowTextLengthW(hwnd): - treffer.append(hwnd) - return False - return True - - CB = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) - u32.EnumWindows(CB(besuch), 0) - if not treffer: - return False - hwnd = treffer[0] - if u32.IsIconic(hwnd): - u32.ShowWindow(hwnd, SW_RESTORE) - u32.SetForegroundWindow(hwnd) - return True - - -def windows_dark_mode(): - """True, wenn Windows auf das dunkle App-Theme eingestellt ist.""" - if not sys.platform.startswith("win"): - return False - try: - import winreg - with winreg.OpenKey(winreg.HKEY_CURRENT_USER, - r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize") as k: - return winreg.QueryValueEx(k, "AppsUseLightTheme")[0] == 0 - except OSError: - return False - - -class RechnungsApp(tk.Tk): +class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): def __init__(self): super().__init__() self.cfg = config.load_config() @@ -477,100 +71,6 @@ class RechnungsApp(tk.Tk): # Beim ersten Start nach dem Speicherort fragen (wenn noch keiner gesetzt ist) self.after(250, self._frage_speicherort_falls_noetig) - # ----------------------------------------------------------------- Theme - def _apply_theme(self): - """Farben passend zum Windows-Theme (hell/dunkel) setzen.""" - dark = windows_dark_mode() - self._dark_mode = dark - if dark: - self.col_bg = "#23272e" # Fenster-Hintergrund - self.col_fg = "#e6e6e6" # Schrift - self.col_field = "#2f343c" # Eingabefelder - self.col_sel = "#3a4a44" # aktiv/hover - self.col_accent = "#5fb98f" # Akzent (Gruen, hell) - self.col_line = "#3a3f47" - self.col_scroll = "#4a505a" # Scroll-Griff - self.col_muted = "#8a929c" # gedaempfte Schrift (z.B. "powered by") - else: - self.col_bg = "#f3f3f3" - self.col_fg = "#1a1a1a" - self.col_field = "#ffffff" - self.col_sel = "#cfe3d8" - self.col_accent = "#1f5c3d" - self.col_line = "#cccccc" - self.col_scroll = "#b8b8b8" - self.col_muted = "#777777" # gedaempfte Schrift (z.B. "powered by") - - self.configure(bg=self.col_bg) - style = ttk.Style(self) - try: - style.theme_use("clam") - except tk.TclError: - pass - bg, fg, field, sel, accent = (self.col_bg, self.col_fg, self.col_field, - self.col_sel, self.col_accent) - style.configure(".", background=bg, foreground=fg, fieldbackground=field, - bordercolor=self.col_line, lightcolor=bg, darkcolor=bg) - style.configure("TFrame", background=bg) - style.configure("TLabel", background=bg, foreground=fg) - style.configure("TLabelframe", background=bg, bordercolor=self.col_line) - style.configure("TLabelframe.Label", background=bg, foreground=accent) - style.configure("TButton", background=field, foreground=fg, bordercolor=self.col_line) - style.map("TButton", background=[("active", sel)], foreground=[("active", fg)]) - style.configure("Menu.TMenubutton", background=bg, foreground=fg, relief="flat", - arrowcolor=bg, padding=(10, 3)) - style.map("Menu.TMenubutton", background=[("active", sel)], foreground=[("active", fg)]) - style.configure("TCheckbutton", background=bg, foreground=fg) - style.map("TCheckbutton", background=[("active", bg)], foreground=[("active", fg)]) - style.configure("TEntry", fieldbackground=field, foreground=fg, insertcolor=fg) - # Bei state="readonly" zeichnet die Combobox ihren Text als MARKIERUNG, - # sobald sie den Fokus hat - ohne diese beiden Farben steht der Wert - # unsichtbar im Feld. - style.configure("TCombobox", fieldbackground=field, foreground=fg, - background=field, arrowcolor=fg, - selectbackground=field, selectforeground=fg) - style.map("TCombobox", fieldbackground=[("readonly", field)], - foreground=[("readonly", fg)], background=[("readonly", field)], - selectbackground=[("readonly", field)], selectforeground=[("readonly", fg)]) - # Die aufgeklappte Liste ist ein klassisches tk-Listbox-Widget - self.option_add("*TCombobox*Listbox.background", field) - self.option_add("*TCombobox*Listbox.foreground", fg) - self.option_add("*TCombobox*Listbox.selectBackground", sel) - self.option_add("*TCombobox*Listbox.selectForeground", fg) - for sbs in ("Vertical.TScrollbar", "Horizontal.TScrollbar", "TScrollbar"): - style.configure(sbs, troughcolor=bg, background=self.col_scroll, - bordercolor=bg, lightcolor=bg, darkcolor=bg, - arrowcolor=fg, relief="flat", gripcount=0) - style.map(sbs, background=[("active", accent)], - troughcolor=[("disabled", bg)]) - style.configure("TSeparator", background=self.col_line) - # Klassische tk-Widgets (Canvas, Toplevel, Text) erben diese Defaults - self.option_add("*background", bg) - self.option_add("*foreground", fg) - - def _style_dialog(self, d): - """Toplevel-Dialog an das Theme angleichen.""" - d.configure(bg=self.col_bg) - # erst nach dem Anzeigen setzen, sonst greift DWM noch nicht - d.after(20, lambda: self._dark_titlebar(d)) - - def _dark_titlebar(self, win): - """Windows-Titelleiste des Fensters dunkel machen (nur im Dark-Mode).""" - if not (sys.platform.startswith("win") and getattr(self, "_dark_mode", False)): - return - try: - import ctypes - win.update_idletasks() - hwnd = ctypes.windll.user32.GetParent(win.winfo_id()) - val = ctypes.c_int(1) - for attr in (20, 19): # DWMWA_USE_IMMERSIVE_DARK_MODE (Win11=20, frühes Win10=19) - ctypes.windll.dwmapi.DwmSetWindowAttribute( - hwnd, attr, ctypes.byref(val), ctypes.sizeof(val)) - # Frame neu zeichnen lassen (ohne Flackern/Fokusverlust) - SWP = 0x0001 | 0x0002 | 0x0004 | 0x0020 # NOSIZE|NOMOVE|NOZORDER|FRAMECHANGED - ctypes.windll.user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP) - except Exception as e: # noqa: BLE001 - print("[theme] Titelleiste:", e) # ---------------------------------------------------------------- Menue def _build_menu(self): @@ -855,8 +355,10 @@ class RechnungsApp(tk.Tk): links = ttk.Frame(f) links.pack(side="left") ttk.Button(links, text="PDF erstellen", command=self.pdf_erstellen, width=18).pack(side="left", padx=4) - ttk.Button(links, text="Stornieren / berichtigen", command=self.dlg_storno, - width=22).pack(side="left", padx=4) + ttk.Button(links, text="Storno", command=self.dlg_storno, + width=12).pack(side="left", padx=4) + ttk.Button(links, text="Berichtigen", command=self.dlg_berichtigen, + width=14).pack(side="left", padx=4) ttk.Button(links, text="Zurücksetzen", command=self.reset_formular, width=14).pack(side="left", padx=4) # --------------------------------------------------------- Berechnung @@ -908,274 +410,6 @@ class RechnungsApp(tk.Tk): jahr, zaehler = heute_jahr, 0 self.r_nummer.set(config.format_rechnungsnummer(jahr, zaehler + 1)) - # ------------------------------------------------------------- Storno - def _storno_anzeigen(self): - """Hinweiszeile und Sperre der Rechnungsnummer an den Zwischenspeicher anpassen.""" - stand = self.storno_stand - if stand: - self.storno_hinweis.set( - f"Storno {stand['nummer']} zu Rechnung {stand['zu']} liegt bereit.\n" - f"Beim Erstellen wird noch einmal gefragt.") - self.nummer_entry.configure(state="readonly") - elif self.berichtigung_stand: - self.storno_hinweis.set( - f"Berichtigung der Rechnung {self.berichtigung_stand['zu']}.\n" - "Nummer und Beträge bleiben, nur die Kundenangaben ändern sich.") - self.nummer_entry.configure(state="readonly") - else: - self.storno_hinweis.set("") - self.nummer_entry.configure(state="normal") - - def _berichtigung_starten(self, meta, out_dir): - """Rechnung ins Formular holen; "PDF erstellen" schreibt das berichtigte Blatt.""" - nummer = str(meta.get("rechnungsnummer", "")) - fehlend = self._formular_aus_meta(meta) - self.berichtigung_stand = {"zu": nummer, "datum": str(meta.get("datum") or ""), - "meta": meta} - # Die Rechnung behaelt ihre Nummer - genau das ist der Unterschied zum Storno. - self.r_nummer.set(nummer) - self.r_datum.set(str(meta.get("datum") or self.r_datum.get())) - self._storno_anzeigen() - hinweis = f"\n\nZum Kunden: {fehlend}." if fehlend else "" - messagebox.showinfo( - "Rechnung geladen", - f"Rechnung {nummer} vom {meta.get('datum','')} ist im Formular.\n\n" - "Jetzt die Kundenangaben richtigstellen und \"PDF erstellen\" drücken – " - "es entsteht ein berichtigtes Blatt zu dieser Rechnung. Die alte Rechnung " - f"bleibt liegen.{hinweis}") - - def _storno_verwerfen(self, grund=""): - """Storno rückgängig: PDF löschen, Zähler zurück, Zwischenspeicher leeren. - - Zulaessig ist das nur hier: die Storno-PDF ist Sekunden alt und hat das - Haus nie verlassen. Die stornierte Rechnung selbst wird nie angefasst. - """ - stand = self.storno_stand - if not stand: - return - try: - if os.path.exists(stand["pfad"]): - os.remove(stand["pfad"]) - print(f"[storno] verworfen, PDF gelöscht -> {stand['pfad']}") - except OSError as e: # noqa: BLE001 - messagebox.showwarning("Storno-PDF blieb liegen", - f"Die Storno-PDF konnte nicht gelöscht werden:\n{e}") - self.cfg["rechnung_jahr"] = stand["jahr_vorher"] - self.cfg["rechnung_zaehler"] = stand["zaehler_vorher"] - config.save_config(self.cfg) - self.storno_stand = None - self._storno_anzeigen() - self._neue_nummer_vorschlagen() - if grund: - print(f"[storno] {grund}") - - def dlg_storno(self): - """Storno-Dialog: alte Nummer wählen -> Storno-PDF schreiben -> Formular füllen.""" - if self.storno_stand: - messagebox.showinfo( - "Storno liegt schon bereit", - f"Der Storno {self.storno_stand['nummer']} zu Rechnung " - f"{self.storno_stand['zu']} ist noch offen.\n\n" - "Erst die berichtigte Rechnung erstellen oder den Storno dort verwerfen.") - return - - 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"} - offen = [d for d in alle - if d.get("art") not in ("storno", "berichtigung") - and nummer_normalisieren(d.get("rechnungsnummer")) not in stornierte] - if not offen: - messagebox.showwarning( - "Nichts zu stornieren", - f"Im Ordner\n{out_dir}\nliegt keine stornierbare Rechnung.") - return - - d = tk.Toplevel(self) - d.title("Rechnung stornieren") - d.transient(self) - d.grab_set() - self._style_dialog(d) - frm = ttk.Frame(d, padding=12) - frm.pack(fill="both", expand=True) - - ttk.Label(frm, justify="left", wraplength=470, text= - "Die alte Rechnung bleibt unverändert liegen – sie darf weder geändert noch " - "gelöscht werden.\n\n" - "Storno erstellen: falsche Beträge oder Leistungen. Es entsteht eine " - "Stornorechnung mit eigener neuer Nummer und danach die berichtigte Rechnung.\n" - "Nur berichtigen: es stimmen nur die Angaben zum Kunden nicht. Die Rechnung " - "behält Nummer und Beträge, es wird nur ein berichtigtes Blatt nachgereicht " - "(§ 31 Abs. 5 UStDV).").grid( - row=0, column=0, columnspan=2, sticky="w", pady=(0, 10)) - - ttk.Label(frm, text="Alte Rechnungsnummer").grid(row=1, column=0, sticky="w", pady=3) - # 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. - 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="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 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 - and x.get("art") != "berichtigung"), 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): - v_info.set(suche()[1]) - ent.bind("", vorschau) - - 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) - - def berichtigen(): - meta, meldung = suche() - if not meta: - v_info.set(meldung) - return - d.destroy() - self._berichtigung_starten(meta, out_dir) - - btns = ttk.Frame(frm) - btns.grid(row=3, column=0, columnspan=2, pady=(14, 0), sticky="e") - ttk.Button(btns, text="Storno erstellen", command=anlegen).pack(side="right", padx=4) - ttk.Button(btns, text="Nur berichtigen", command=berichtigen).pack(side="right", padx=4) - 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", "")) - nummer = naechste_freie_nummer(out_dir, self.cfg) - if not nummer: - messagebox.showerror("Keine freie Nummer", - "Es wurde keine freie Rechnungsnummer gefunden.") - return - - r = storno_rechnung_bauen(meta, self.cfg) - heute = datetime.date.today().strftime("%d.%m.%Y") - # 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) - namenszeile = " ".join(t for t in (kunde.get("vorname", ""), - kunde.get("nachname", "")) if t) - kunde_zeilen = [kunde.get("anrede", ""), namenszeile, kunde.get("zusatz", ""), - kunde.get("strasse", ""), kunde.get("plzort", "")] - kopf = { - "rechnungsnummer": nummer, - "datum": heute, - "anreise": "", "abreise": "", "naechte": "", - "kunde_zeilen": kunde_zeilen, - "nachname": kunde.get("nachname", ""), - "vorname": kunde.get("vorname", ""), - "art": "storno", - "storno_zu": alt_nummer, - "storno_datum": str(meta.get("datum") or ""), - "kunde": kunde, - } - name = f"Storno_{safe_filename(nummer)}" - if kopf["nachname"]: - name += "_" + safe_filename(kopf["nachname"]) - pfad = os.path.join(out_dir, name + ".pdf") - try: - pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf) - except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen - messagebox.showerror("Fehler beim Erstellen", - f"Storno-PDF konnte nicht erstellt werden:\n{e}") - print("[storno] FEHLER:", e) - return - - self.storno_stand = { - "pfad": pfad, "nummer": nummer, "zu": alt_nummer, - "zu_datum": str(meta.get("datum") or ""), - "jahr_vorher": self.cfg.get("rechnung_jahr", datetime.date.today().year), - "zaehler_vorher": self.cfg.get("rechnung_zaehler", 0), - } - self._zaehler_aus_nummer_speichern(nummer) - print(f"[storno] erstellt -> {pfad}") - - fehlend = self._formular_aus_meta(meta) - self.r_nummer.set(naechste_freie_nummer(out_dir, self.cfg) or "") - self._storno_anzeigen() - - hinweis = "" - if fehlend: - hinweis = (f"\n\nZum Kunden: {fehlend}.\nBitte oben prüfen und ergänzen – " - "ohne vollständige Anschrift wird die Rechnung nicht geschrieben.") - messagebox.showinfo( - "Storno erstellt", - f"Stornorechnung {nummer} zu Rechnung {alt_nummer} liegt hier:\n{pfad}\n\n" - f"Jetzt die berichtigte Rechnung {self.r_nummer.get()} anpassen und " - f"\"PDF erstellen\" drücken.{hinweis}") - - def _formular_aus_meta(self, meta): - """Formular mit den Daten der alten Rechnung füllen. - - Gibt zurück, was NICHT übernommen werden konnte - aeltere PDFs (v1/v2) - tragen weder Anschrift noch Positionen in den Kenndaten. - """ - kunde, woher = kundendaten_ermitteln(meta) - self.k_vorname.set(kunde.get("vorname", "")) - self.k_name.set(kunde.get("nachname", "")) - self.k_anrede.set(str(kunde.get("anrede", "") or "")) - self.k_zusatz.set(str(kunde.get("zusatz", "") or "")) - self.k_strasse.set(str(kunde.get("strasse", "") or "")) - self.k_plzort.set(str(kunde.get("plzort", "") or "")) - self.r_anreise.set(str(meta.get("anreise", "") or "")) - self.r_abreise.set(str(meta.get("abreise", "") or "")) - self.r_datum.set(datetime.date.today().strftime("%d.%m.%Y")) - - for nr, var in self.anzahl_vars.items(): - var.set("") - self.personen_vars[nr].set("") - for p in meta.get("positionen") or []: - nr = p.get("nr") - if nr in self.anzahl_vars: - self.anzahl_vars[nr].set(str(p.get("anzahl", "") or "")) - self.personen_vars[nr].set(str(p.get("personen", "") or "")) - self.recalc() - - hinweise = [] - if woher: - hinweise.append("aus dem Rechnungstext gelesen: " + ", ".join(woher)) - fehlt = [feld for feld, wert in (("Straße", self.k_strasse.get()), - ("PLZ / Ort", self.k_plzort.get()), - ("Nachname", self.k_name.get())) - if not wert] - if fehlt: - hinweise.append("steht in der alten Rechnung nicht drin: " + ", ".join(fehlt)) - if not (meta.get("positionen") or []): - hinweise.append("Leistungen konnten nicht übernommen werden") - return "; ".join(hinweise) def kunde_pruefen(self): """Beanstandungen an den Kundenfeldern; leere Liste = in Ordnung. @@ -1403,28 +637,6 @@ class RechnungsApp(tk.Tk): def _ordner_oeffnen(self): self._oeffne_datei(config.ensure_output_dir(self.cfg)) - def _frage_speicherort_falls_noetig(self): - if (self.cfg.get("output_dir") or "").strip(): - return - vorschlag = config.default_output_dir() - messagebox.showinfo( - "Speicherort der Rechnungen", - "Bitte wähle einen Ordner, in dem die fertigen Rechnungen (PDF) gespeichert werden.\n\n" - f"Vorschlag: {vorschlag}\n\n(Lässt sich später unter Einstellungen ändern.)") - d = filedialog.askdirectory(title="Ordner für die Rechnungen wählen", - initialdir=os.path.expanduser("~")) - self.cfg["output_dir"] = d if d else vorschlag - config.save_config(self.cfg) - config.ensure_output_dir(self.cfg) - - def dlg_speicherort(self): - aktuell = config.ensure_output_dir(self.cfg) - d = filedialog.askdirectory(title="Ordner für die Rechnungen wählen", initialdir=aktuell) - if d: - self.cfg["output_dir"] = d - config.save_config(self.cfg) - config.ensure_output_dir(self.cfg) - messagebox.showinfo("Speicherort", f"Rechnungen werden gespeichert in:\n{d}") def _oeffne_datei(self, pfad): try: @@ -1437,330 +649,6 @@ class RechnungsApp(tk.Tk): except Exception as e: # noqa: BLE001 messagebox.showinfo("Pfad", f"{pfad}\n\n(Konnte nicht automatisch geöffnet werden: {e})") - def _info(self): - d = tk.Toplevel(self) - d.title("Info") - d.transient(self) - d.resizable(False, False) - self._style_dialog(d) - frm = ttk.Frame(d, padding=18) - frm.pack(fill="both", expand=True) - ttk.Label(frm, text="Rechnungstool Campinghof Bartl", font=("Segoe UI", 11, "bold"), - foreground=self.col_accent).pack(anchor="w") - ttk.Label(frm, justify="left", - text="Daten eingeben, PDF erstellen, drucken.\n" - "Steuersätze, Preise und Firmendaten lassen sich\n" - "im Menü Einstellungen anpassen.").pack(anchor="w", pady=(8, 12)) - ttk.Label(frm, text="powered by ravokk", foreground=self.col_muted).pack(anchor="w") - ttk.Button(frm, text="OK", command=d.destroy, width=10).pack(anchor="e", pady=(14, 0)) - d.bind("", lambda e: d.destroy()) - d.bind("", lambda e: d.destroy()) - d.grab_set() - - # ====================================================== Einstellungen - def dlg_steuern(self): - d = tk.Toplevel(self) - d.title("Steuersätze") - d.transient(self) - d.grab_set() - self._style_dialog(d) - d.geometry("620x470") - - frm = ttk.Frame(d, padding=12) - frm.pack(fill="both", expand=True) - - ttk.Label(frm, wraplength=580, justify="left", font=("Segoe UI", 9, "bold"), - text="Steuersätze: Satz % + Name. Die Katalog-Preise sind BRUTTO; die USt dieses " - "Satzes wird herausgerechnet und als 'enthalten' ausgewiesen. Haken bei " - "'Aufschlag': diese Steuer (z.B. Beherbergungssteuer) wird stattdessen auf den " - "Rechnungsbetrag aufgeschlagen und direkt darunter ausgewiesen.").pack(anchor="w", pady=(0, 8)) - - liste = ttk.Frame(frm) - liste.pack(fill="x") - arbeit = [] - for s in self.cfg.get("steuersaetze", []): - arbeit.append({"satz": tk.StringVar(value=str(s.get("satz", ""))), - "bez": tk.StringVar(value=s.get("bezeichnung", "")), - "extra": tk.BooleanVar(value=bool(s.get("extra_blatt", False)))}) - - def render_saetze(): - for w in liste.winfo_children(): - w.destroy() - for c, t in enumerate(["Satz %", "Bezeichnung (Name der Steuer)", "Aufschlag", ""]): - ttk.Label(liste, text=t, font=("Segoe UI", 9, "bold")).grid( - row=0, column=c, padx=4, pady=(0, 3), sticky="w") - for i, row in enumerate(arbeit, start=1): - ttk.Entry(liste, textvariable=row["satz"], width=8, justify="center").grid(row=i, column=0, padx=4, pady=2) - ttk.Entry(liste, textvariable=row["bez"], width=32).grid(row=i, column=1, padx=4, pady=2, sticky="w") - ttk.Checkbutton(liste, variable=row["extra"]).grid(row=i, column=2, padx=14, pady=2) - ttk.Button(liste, text="✕", width=3, - command=lambda r=row: (arbeit.remove(r), render_saetze())).grid(row=i, column=3, padx=4) - - def satz_hinzufuegen(): - arbeit.append({"satz": tk.StringVar(value="0"), - "bez": tk.StringVar(value="Mehrwertsteuer"), - "extra": tk.BooleanVar(value=False)}) - render_saetze() - - render_saetze() - ttk.Button(frm, text="+ Steuersatz hinzufügen", command=satz_hinzufuegen).pack(anchor="w", pady=(8, 0)) - - def speichern(): - neu = [] - for row in arbeit: - s = row["satz"].get().strip() - if s == "": - continue - neu.append({"satz": int(parse_num(s)), - "bezeichnung": row["bez"].get().strip() or "USt", - "extra_blatt": bool(row["extra"].get())}) - if not neu: - messagebox.showwarning("Leer", "Mindestens ein Steuersatz ist nötig.", parent=d) - return - self.cfg["steuersaetze"] = neu - config.save_config(self.cfg) - self._render_positionszeilen() - self.recalc() - d.destroy() - - btns = ttk.Frame(frm) - btns.pack(side="bottom", fill="x", pady=(12, 0)) - ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4) - ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right") - - def dlg_katalog(self): - d = tk.Toplevel(self) - d.title("Leistungskatalog / Preise") - d.transient(self) - d.grab_set() - self._style_dialog(d) - - info_lbl = ttk.Label(d, padding=(10, 8), wraplength=900, justify="left", - text="Leistungen bearbeiten: Bezeichnung, Steuersatz (Auswahl), Preis in € und ob " - "pro Person gerechnet wird. Weitere Steuersätze legst du unter " - "Einstellungen > Steuersätze an. Unten neue Zeilen anlegen oder mit dem X " - "rechts eine Leistung entfernen.") - info_lbl.pack(side="top", fill="x") - - # alle definierten Steuersaetze sind je Position waehlbar - saetze_cfg = self.cfg.get("steuersaetze", []) - - def satz_label(satz): - for s in saetze_cfg: - if int(s.get("satz", -1)) == int(satz or 0): - return f"{s['satz']} % – {s.get('bezeichnung', '')}".rstrip(" –") - return f"{satz} %" - - def combo_werte(aktuell): - opts = [f"{s['satz']} % – {s.get('bezeichnung', '')}".rstrip(" –") for s in saetze_cfg] - al = satz_label(aktuell) - if al not in opts: - opts.append(al) - return opts - - def satz_aus_label(lbl): - try: - return int(parse_num(str(lbl).split("%")[0])) - except (ValueError, IndexError): - return 0 - - # Arbeitskopie als Liste von Variablen-Dicts (erlaubt Hinzufuegen/Loeschen) - arbeit = [] - for pos in self.cfg["katalog"]: - arbeit.append({ - "leistung": tk.StringVar(value=pos["leistung"]), - "satz": tk.StringVar(value=str(pos["satz"])), - "preis": tk.StringVar(value=berechnung.eur_kurz(pos["preis"])), - "pro_person": tk.BooleanVar(value=bool(pos["pro_person"])), - }) - - def render(): - for w in inner.winfo_children(): - w.destroy() - for c, t in enumerate(["Pos.", "Bezeichnung", "Steuersatz", "Preis €", "pro Person", ""]): - ttk.Label(inner, text=t, font=("Segoe UI", 9, "bold")).grid( - row=0, column=c, padx=4, pady=(0, 4), sticky="w") - for i, row in enumerate(arbeit, start=1): - ttk.Label(inner, text=str(i), width=4).grid(row=i, column=0, padx=4, pady=1) - ttk.Entry(inner, textvariable=row["leistung"], width=64).grid(row=i, column=1, padx=4, pady=1, sticky="we") - cb = ttk.Combobox(inner, width=22, state="readonly", values=combo_werte(row["satz"].get())) - cb.set(satz_label(row["satz"].get())) - cb.bind("<>", - lambda ev, rr=row, cc=cb: rr["satz"].set(str(satz_aus_label(cc.get())))) - cb.grid(row=i, column=2, padx=4, pady=1, sticky="w") - ttk.Entry(inner, textvariable=row["preis"], width=10, justify="right").grid(row=i, column=3, padx=4, pady=1) - ttk.Checkbutton(inner, variable=row["pro_person"]).grid(row=i, column=4, padx=4, pady=1) - ttk.Button(inner, text="✕", width=3, - command=lambda r=row: (arbeit.remove(r), render())).grid(row=i, column=5, padx=4, pady=1) - - def hinzufuegen(): - arbeit.append({ - "leistung": tk.StringVar(value="Neue Leistung"), - "satz": tk.StringVar(value=str(saetze_cfg[0]["satz"]) if saetze_cfg else "19"), - "preis": tk.StringVar(value="0,00"), - "pro_person": tk.BooleanVar(value=False), - }) - render() - canvas.update_idletasks() - canvas.configure(scrollregion=canvas.bbox("all")) - canvas.yview_moveto(1.0) - - def speichern(): - neu = [] - for i, row in enumerate(arbeit, start=1): - name = row["leistung"].get().strip() - if not name: - continue # leere Zeilen verwerfen - neu.append({ - "nr": i, - "leistung": name, - "satz": int(parse_num(row["satz"].get())), - "preis": parse_num(row["preis"].get()), - "pro_person": bool(row["pro_person"].get()), - }) - if not neu: - messagebox.showwarning("Leer", "Es muss mindestens eine Leistung übrig bleiben.", parent=d) - return - self.cfg["katalog"] = neu - config.save_config(self.cfg) - self._render_positionszeilen() - self.recalc() - d.destroy() - - # Buttonleiste unten zuerst -> bleibt immer sichtbar - btns = ttk.Frame(d, padding=8) - btns.pack(side="bottom", fill="x") - ttk.Button(btns, text="+ Position hinzufügen", command=hinzufuegen).pack(side="left", padx=4) - ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4) - ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right", padx=4) - - # Scrollbereich - body = ttk.Frame(d) - body.pack(side="top", fill="both", expand=True) - canvas = tk.Canvas(body, highlightthickness=0, bg=self.col_bg) - sb = ttk.Scrollbar(body, orient="vertical", command=canvas.yview) - inner = ttk.Frame(canvas, padding=8) - inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) - canvas.create_window((0, 0), window=inner, anchor="nw") - canvas.configure(yscrollcommand=sb.set) - canvas.pack(side="left", fill="both", expand=True) - sb.pack(side="right", fill="y") - - render() - - # Fenstergröße an den Inhalt anpassen (Breite = breiteste Zeile) - d.update_idletasks() - inhalt_w = inner.winfo_reqwidth() - canvas.configure(width=inhalt_w) - win_w = min(inhalt_w + sb.winfo_reqwidth() + 26, d.winfo_screenwidth() - 80) - win_h = min(inner.winfo_reqheight() + btns.winfo_reqheight() + 90, - d.winfo_screenheight() - 120) - d.geometry(f"{win_w}x{int(win_h)}") - d.minsize(min(win_w, 820), 420) - info_lbl.configure(wraplength=win_w - 24) - - def dlg_firma(self): - d = tk.Toplevel(self) - d.title("Firmendaten & Texte") - d.transient(self) - d.grab_set() - self._style_dialog(d) - d.geometry("640x640") - - frm = ttk.Frame(d, padding=12) - frm.pack(fill="both", expand=True) - felder = [ - ("name", "Firmenname"), ("untertitel", "Untertitel"), - ("absender", "Absenderzeile"), ("inhaber", "Inhaber"), - ("bank", "Bank"), ("iban", "IBAN"), ("bic", "SWIFT/BIC"), - ("steuernummer", "Steuernummer"), ("homepage", "Homepage"), - ("email", "E-Mail"), ("telefon", "Telefon"), - ] - vars_ = {} - for i, (key, lbl) in enumerate(felder): - ttk.Label(frm, text=lbl).grid(row=i, column=0, sticky="w", pady=2) - v = tk.StringVar(value=self.cfg["firma"].get(key, "")) - ttk.Entry(frm, textvariable=v, width=58).grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2) - vars_[key] = v - frm.columnconfigure(1, weight=1) - base = len(felder) - - # Logo (Bilddatei) mit Durchsuchen / Entfernen - ttk.Label(frm, text="Logo (oben links)").grid(row=base, column=0, sticky="w", pady=2) - logo_row = ttk.Frame(frm) - logo_row.grid(row=base, column=1, sticky="ew", padx=(8, 0), pady=2) - v_logo = tk.StringVar(value=self.cfg["firma"].get("logo", "")) - ttk.Entry(logo_row, textvariable=v_logo).pack(side="left", fill="x", expand=True) - - def logo_waehlen(): - p = filedialog.askopenfilename( - title="Logo auswählen", - filetypes=[("Bilder", "*.png *.jpg *.jpeg *.gif"), ("Alle Dateien", "*.*")]) - if p: - v_logo.set(p) - - ttk.Button(logo_row, text="Durchsuchen…", command=logo_waehlen).pack(side="left", padx=(6, 0)) - ttk.Button(logo_row, text="Entfernen", command=lambda: v_logo.set("")).pack(side="left", padx=(4, 0)) - - ttk.Label(frm, text="Danktext 1").grid(row=base + 1, column=0, sticky="nw", pady=2) - t1 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg) - t1.insert("1.0", self.cfg["texte"].get("dank1", "")) - t1.grid(row=base + 1, column=1, sticky="ew", padx=(8, 0), pady=2) - ttk.Label(frm, text="Danktext 2").grid(row=base + 2, column=0, sticky="nw", pady=2) - t2 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg) - t2.insert("1.0", self.cfg["texte"].get("dank2", "")) - t2.grid(row=base + 2, column=1, sticky="ew", padx=(8, 0), pady=2) - ttk.Label(frm, text="Hinweiszeile").grid(row=base + 3, column=0, sticky="w", pady=2) - vh = tk.StringVar(value=self.cfg["texte"].get("hinweis", "")) - ttk.Entry(frm, textvariable=vh, width=58).grid(row=base + 3, column=1, sticky="ew", padx=(8, 0), pady=2) - - def speichern(): - for key, v in vars_.items(): - self.cfg["firma"][key] = v.get() - self.cfg["firma"]["logo"] = v_logo.get().strip() - self.cfg["texte"]["dank1"] = t1.get("1.0", "end").strip() - self.cfg["texte"]["dank2"] = t2.get("1.0", "end").strip() - self.cfg["texte"]["hinweis"] = vh.get().strip() - config.save_config(self.cfg) - self.title("Rechnungstool - " + self.cfg["firma"].get("name", "")) - d.destroy() - - btns = ttk.Frame(frm) - btns.grid(row=base + 4, column=0, columnspan=2, pady=(12, 0), sticky="e") - ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4) - ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right") - - def dlg_nummer(self): - d = tk.Toplevel(self) - d.title("Rechnungsnummer") - d.transient(self) - d.grab_set() - self._style_dialog(d) - frm = ttk.Frame(d, padding=12) - frm.pack(fill="both", expand=True) - - v_jahr = tk.StringVar(value=str(self.cfg.get("rechnung_jahr", datetime.date.today().year))) - v_z = tk.StringVar(value=str(self.cfg.get("rechnung_zaehler", 0))) - ttk.Label(frm, text="Format: JJJJ-NNN (z. B. 2026-013). Der Zähler ist der zuletzt\n" - "vergebene Wert – beim nächsten Beleg wird +1 vorgeschlagen.", - justify="left").grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 8)) - ttk.Label(frm, text="Jahr").grid(row=1, column=0, sticky="w", pady=3) - ttk.Entry(frm, textvariable=v_jahr, width=10).grid(row=1, column=1, sticky="w", pady=3) - ttk.Label(frm, text="Zuletzt vergebener Zähler").grid(row=2, column=0, sticky="w", pady=3) - ttk.Entry(frm, textvariable=v_z, width=10).grid(row=2, column=1, sticky="w", pady=3) - - def speichern(): - self.cfg["rechnung_jahr"] = int(parse_num(v_jahr.get())) - self.cfg["rechnung_zaehler"] = int(parse_num(v_z.get())) - config.save_config(self.cfg) - self._neue_nummer_vorschlagen() - d.destroy() - - btns = ttk.Frame(frm) - btns.grid(row=3, column=0, columnspan=2, pady=(12, 0), sticky="e") - ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4) - ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right") - if __name__ == "__main__": # Muss VOR dem Fenster passieren - das Handle bleibt bis zum Ende offen. diff --git a/bestand.py b/bestand.py new file mode 100644 index 0000000..c687ba8 --- /dev/null +++ b/bestand.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +"""Was im Rechnungsordner liegt - lesen, nicht schreiben. + +Der Ordner ist die Buchhaltung: welche Nummern vergeben sind, was in einer alten +Rechnung steht, welche Nummer als nächste frei ist. Hier wird nur gelesen. +""" + +import datetime +import json +import os + +import config +from helfer import ANREDEN, name_aufteilen, saeubern, safe_filename + + +def vorhandene_rechnungen(ordner, nummer): + """Alle PDFs im Ordner, die schon zu dieser Rechnungsnummer gehoeren. + + Der Kundenname haengt mit im Dateinamen ("Rechnung_2026-013_Meier.pdf"), + darum wird nur der Nummernteil verglichen - sonst rutscht dieselbe Nummer + unter einem anderen Namen als zweite Datei durch. + """ + # 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: + return [] + treffer = [] + for name in namen: + if not name.lower().endswith(".pdf"): + continue + stamm = name[:-4] + # exakt die Nummer oder Nummer + "_Kundenname" - "2026-130" faellt raus + if any(stamm == k or stamm.startswith(k + "_") for k in koepfe): + treffer.append(os.path.join(ordner, name)) + return sorted(treffer) +def kenndaten_lesen(pfad): + """Kenndaten (Feld /Subject) aus einer vom Tool erzeugten PDF; None wenn keine. + + Gelesen wird der Bestand, nicht das Formular - ein Storno muss genau das + spiegeln, was rausgegangen ist. + """ + try: + from pypdf import PdfReader + roh = (PdfReader(pfad).metadata or {}).get("/Subject") or "" + if not str(roh).strip().startswith("{"): + return None + daten = json.loads(str(roh)) + except Exception as e: # noqa: BLE001 - fremde oder kaputte PDF + print(f"[storno] {os.path.basename(pfad)}: keine lesbaren Kenndaten ({e})") + return None + if daten.get("quelle") != "rechnungstool" or not daten.get("rechnungsnummer"): + return None + daten["_pfad"] = pfad + return daten +def adresse_aus_pdf(pfad): + """Adressblock aus dem Fliesstext einer Rechnung lesen. + + Fuer alte PDFs (v1/v2), die den Kunden noch nicht in den Kenndaten fuehren. + Der Block steht zwischen "Rechnung an:" und der Rechnungsnummer. + """ + try: + from pypdf import PdfReader + zeilen = (PdfReader(pfad).pages[0].extract_text() or "").splitlines() + except Exception as e: # noqa: BLE001 - kaputte oder fremde PDF + print(f"[storno] {os.path.basename(pfad)}: Text nicht lesbar ({e})") + return {} + + start = next((i for i, z in enumerate(zeilen) + if z.strip().startswith("Rechnung an")), None) + if start is None: + return {} + block = [] + for z in zeilen[start + 1:start + 8]: + t = z.strip() + if not t or t.startswith(("Rechnungsnummer", "Stornonummer")): + break + block.append(t) + if not block: + return {} + + daten = {} + if block[0] in ANREDEN: + daten["anrede"] = block.pop(0) + # PLZ/Ort ist die Zeile, die mit einer 4- bis 5-stelligen Zahl beginnt und + # noch etwas dahinter hat. Die Zeile davor ist die Strasse - aber nur, wenn + # sie nicht selbst die Namenszeile ist. + for i in range(len(block) - 1, -1, -1): + teile = block[i].split() + if len(teile) > 1 and teile[0].isdigit() and 4 <= len(teile[0]) <= 5: + daten["plzort"] = block.pop(i) + if i - 1 >= 1: + daten["strasse"] = block.pop(i - 1) + break + if block: + daten["name"] = block.pop(0) + if block: + daten["zusatz"] = " ".join(block) + return daten +def kundendaten_ermitteln(meta): + """Kunde einer alten Rechnung so vollstaendig wie moeglich. + + Reihenfolge: Kenndaten (v3), sonst der Adressblock aus dem Fliesstext. + Steht der ganze Name in der Vornamen-Zeile - so sind die Altrechnungen + entstanden -, wird das letzte Wort zum Nachnamen. + + Rueckgabe: (kunde-dict, woher) - woher ist eine Liste der Felder, die nur + aus dem Text kamen und deshalb geprueft gehoeren. + """ + kunde = dict(meta.get("kunde") or {}) + woher = [] + vorname = saeubern(kunde.get("vorname") or meta.get("vorname")) + nachname = saeubern(kunde.get("nachname") or meta.get("nachname")) + + if not kunde.get("plzort") and meta.get("_pfad"): + aus_text = adresse_aus_pdf(meta["_pfad"]) + for feld, klartext in (("anrede", "Anrede"), ("zusatz", "Zusatz"), + ("strasse", "Straße"), ("plzort", "PLZ / Ort")): + if aus_text.get(feld) and not kunde.get(feld): + kunde[feld] = aus_text[feld] + woher.append(klartext) + if not (vorname or nachname) and aus_text.get("name"): + vorname = aus_text["name"] + woher.append("Name") + + if not nachname: + # Alte Rechnungen tragen den ganzen Namen in der Vornamen-Zeile. Getrennt + # wird am Leerzeichen: das letzte Wort ist der Nachname. + vorname, nachname = name_aufteilen(vorname) + if nachname: + woher.append("Nachname vom Vornamen abgetrennt") + kunde["vorname"], kunde["nachname"] = vorname, nachname + return kunde, woher +def rechnungen_im_ordner(ordner): + """Alle lesbaren Rechnungen des Ordners, neueste Nummer zuerst. + + Stornos und schon stornierte Rechnungen sind mit drin - welche davon + stornierbar ist, entscheidet der Aufrufer. + """ + try: + namen = sorted(os.listdir(ordner)) + except OSError: + return [] + gefunden = [] + for name in namen: + # 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: + gefunden.append(daten) + gefunden.sort(key=lambda d: str(d.get("rechnungsnummer", "")), reverse=True) + return gefunden +def naechste_freie_nummer(ordner, cfg): + """Erste Nummer JJJJ-NNN, zu der im Ordner noch kein PDF liegt. + + Gezaehlt wird ab dem zuletzt vergebenen Stand aus der config; im Ordner + hoeher liegende Nummern werden dabei uebersprungen, weil sie nicht frei sind. + """ + jahr = cfg.get("rechnung_jahr", datetime.date.today().year) + z = cfg.get("rechnung_zaehler", 0) + for _ in range(1000): + z += 1 + kandidat = config.format_rechnungsnummer(jahr, z) + if not vorhandene_rechnungen(ordner, kandidat): + return kandidat + return None diff --git a/einstellungen.py b/einstellungen.py new file mode 100644 index 0000000..23a92ad --- /dev/null +++ b/einstellungen.py @@ -0,0 +1,358 @@ +# -*- coding: utf-8 -*- +"""Die Dialoge aus dem Menue "Einstellungen" - Steuersaetze, Katalog, Firma, Nummer. + +Alles, was hier eingestellt wird, landet in der config.json. Nichts davon steht +hart im Code: Steuersaetze, Preise, Firmendaten, Rechnungsnummernkreis. +""" + +import os +import tkinter as tk +from tkinter import ttk, messagebox, filedialog + +import config +from helfer import parse_num + + +class EinstellungenMixin: + """Einstellungsdialoge. Erwartet die Felder der RechnungsApp.""" + + def _frage_speicherort_falls_noetig(self): + if (self.cfg.get("output_dir") or "").strip(): + return + vorschlag = config.default_output_dir() + messagebox.showinfo( + "Speicherort der Rechnungen", + "Bitte wähle einen Ordner, in dem die fertigen Rechnungen (PDF) gespeichert werden.\n\n" + f"Vorschlag: {vorschlag}\n\n(Lässt sich später unter Einstellungen ändern.)") + d = filedialog.askdirectory(title="Ordner für die Rechnungen wählen", + initialdir=os.path.expanduser("~")) + self.cfg["output_dir"] = d if d else vorschlag + config.save_config(self.cfg) + config.ensure_output_dir(self.cfg) + def dlg_speicherort(self): + aktuell = config.ensure_output_dir(self.cfg) + d = filedialog.askdirectory(title="Ordner für die Rechnungen wählen", initialdir=aktuell) + if d: + self.cfg["output_dir"] = d + config.save_config(self.cfg) + config.ensure_output_dir(self.cfg) + messagebox.showinfo("Speicherort", f"Rechnungen werden gespeichert in:\n{d}") + def _info(self): + d = tk.Toplevel(self) + d.title("Info") + d.transient(self) + d.resizable(False, False) + self._style_dialog(d) + frm = ttk.Frame(d, padding=18) + frm.pack(fill="both", expand=True) + ttk.Label(frm, text="Rechnungstool Campinghof Bartl", font=("Segoe UI", 11, "bold"), + foreground=self.col_accent).pack(anchor="w") + ttk.Label(frm, justify="left", + text="Daten eingeben, PDF erstellen, drucken.\n" + "Steuersätze, Preise und Firmendaten lassen sich\n" + "im Menü Einstellungen anpassen.").pack(anchor="w", pady=(8, 12)) + ttk.Label(frm, text="powered by ravokk", foreground=self.col_muted).pack(anchor="w") + ttk.Button(frm, text="OK", command=d.destroy, width=10).pack(anchor="e", pady=(14, 0)) + d.bind("", lambda e: d.destroy()) + d.bind("", lambda e: d.destroy()) + d.grab_set() + # ====================================================== Einstellungen + def dlg_steuern(self): + d = tk.Toplevel(self) + d.title("Steuersätze") + d.transient(self) + d.grab_set() + self._style_dialog(d) + d.geometry("620x470") + + frm = ttk.Frame(d, padding=12) + frm.pack(fill="both", expand=True) + + ttk.Label(frm, wraplength=580, justify="left", font=("Segoe UI", 9, "bold"), + text="Steuersätze: Satz % + Name. Die Katalog-Preise sind BRUTTO; die USt dieses " + "Satzes wird herausgerechnet und als 'enthalten' ausgewiesen. Haken bei " + "'Aufschlag': diese Steuer (z.B. Beherbergungssteuer) wird stattdessen auf den " + "Rechnungsbetrag aufgeschlagen und direkt darunter ausgewiesen.").pack(anchor="w", pady=(0, 8)) + + liste = ttk.Frame(frm) + liste.pack(fill="x") + arbeit = [] + for s in self.cfg.get("steuersaetze", []): + arbeit.append({"satz": tk.StringVar(value=str(s.get("satz", ""))), + "bez": tk.StringVar(value=s.get("bezeichnung", "")), + "extra": tk.BooleanVar(value=bool(s.get("extra_blatt", False)))}) + + def render_saetze(): + for w in liste.winfo_children(): + w.destroy() + for c, t in enumerate(["Satz %", "Bezeichnung (Name der Steuer)", "Aufschlag", ""]): + ttk.Label(liste, text=t, font=("Segoe UI", 9, "bold")).grid( + row=0, column=c, padx=4, pady=(0, 3), sticky="w") + for i, row in enumerate(arbeit, start=1): + ttk.Entry(liste, textvariable=row["satz"], width=8, justify="center").grid(row=i, column=0, padx=4, pady=2) + ttk.Entry(liste, textvariable=row["bez"], width=32).grid(row=i, column=1, padx=4, pady=2, sticky="w") + ttk.Checkbutton(liste, variable=row["extra"]).grid(row=i, column=2, padx=14, pady=2) + ttk.Button(liste, text="✕", width=3, + command=lambda r=row: (arbeit.remove(r), render_saetze())).grid(row=i, column=3, padx=4) + + def satz_hinzufuegen(): + arbeit.append({"satz": tk.StringVar(value="0"), + "bez": tk.StringVar(value="Mehrwertsteuer"), + "extra": tk.BooleanVar(value=False)}) + render_saetze() + + render_saetze() + ttk.Button(frm, text="+ Steuersatz hinzufügen", command=satz_hinzufuegen).pack(anchor="w", pady=(8, 0)) + + def speichern(): + neu = [] + for row in arbeit: + s = row["satz"].get().strip() + if s == "": + continue + neu.append({"satz": int(parse_num(s)), + "bezeichnung": row["bez"].get().strip() or "USt", + "extra_blatt": bool(row["extra"].get())}) + if not neu: + messagebox.showwarning("Leer", "Mindestens ein Steuersatz ist nötig.", parent=d) + return + self.cfg["steuersaetze"] = neu + config.save_config(self.cfg) + self._render_positionszeilen() + self.recalc() + d.destroy() + + btns = ttk.Frame(frm) + btns.pack(side="bottom", fill="x", pady=(12, 0)) + ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4) + ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right") + def dlg_katalog(self): + d = tk.Toplevel(self) + d.title("Leistungskatalog / Preise") + d.transient(self) + d.grab_set() + self._style_dialog(d) + + info_lbl = ttk.Label(d, padding=(10, 8), wraplength=900, justify="left", + text="Leistungen bearbeiten: Bezeichnung, Steuersatz (Auswahl), Preis in € und ob " + "pro Person gerechnet wird. Weitere Steuersätze legst du unter " + "Einstellungen > Steuersätze an. Unten neue Zeilen anlegen oder mit dem X " + "rechts eine Leistung entfernen.") + info_lbl.pack(side="top", fill="x") + + # alle definierten Steuersaetze sind je Position waehlbar + saetze_cfg = self.cfg.get("steuersaetze", []) + + def satz_label(satz): + for s in saetze_cfg: + if int(s.get("satz", -1)) == int(satz or 0): + return f"{s['satz']} % – {s.get('bezeichnung', '')}".rstrip(" –") + return f"{satz} %" + + def combo_werte(aktuell): + opts = [f"{s['satz']} % – {s.get('bezeichnung', '')}".rstrip(" –") for s in saetze_cfg] + al = satz_label(aktuell) + if al not in opts: + opts.append(al) + return opts + + def satz_aus_label(lbl): + try: + return int(parse_num(str(lbl).split("%")[0])) + except (ValueError, IndexError): + return 0 + + # Arbeitskopie als Liste von Variablen-Dicts (erlaubt Hinzufuegen/Loeschen) + arbeit = [] + for pos in self.cfg["katalog"]: + arbeit.append({ + "leistung": tk.StringVar(value=pos["leistung"]), + "satz": tk.StringVar(value=str(pos["satz"])), + "preis": tk.StringVar(value=berechnung.eur_kurz(pos["preis"])), + "pro_person": tk.BooleanVar(value=bool(pos["pro_person"])), + }) + + def render(): + for w in inner.winfo_children(): + w.destroy() + for c, t in enumerate(["Pos.", "Bezeichnung", "Steuersatz", "Preis €", "pro Person", ""]): + ttk.Label(inner, text=t, font=("Segoe UI", 9, "bold")).grid( + row=0, column=c, padx=4, pady=(0, 4), sticky="w") + for i, row in enumerate(arbeit, start=1): + ttk.Label(inner, text=str(i), width=4).grid(row=i, column=0, padx=4, pady=1) + ttk.Entry(inner, textvariable=row["leistung"], width=64).grid(row=i, column=1, padx=4, pady=1, sticky="we") + cb = ttk.Combobox(inner, width=22, state="readonly", values=combo_werte(row["satz"].get())) + cb.set(satz_label(row["satz"].get())) + cb.bind("<>", + lambda ev, rr=row, cc=cb: rr["satz"].set(str(satz_aus_label(cc.get())))) + cb.grid(row=i, column=2, padx=4, pady=1, sticky="w") + ttk.Entry(inner, textvariable=row["preis"], width=10, justify="right").grid(row=i, column=3, padx=4, pady=1) + ttk.Checkbutton(inner, variable=row["pro_person"]).grid(row=i, column=4, padx=4, pady=1) + ttk.Button(inner, text="✕", width=3, + command=lambda r=row: (arbeit.remove(r), render())).grid(row=i, column=5, padx=4, pady=1) + + def hinzufuegen(): + arbeit.append({ + "leistung": tk.StringVar(value="Neue Leistung"), + "satz": tk.StringVar(value=str(saetze_cfg[0]["satz"]) if saetze_cfg else "19"), + "preis": tk.StringVar(value="0,00"), + "pro_person": tk.BooleanVar(value=False), + }) + render() + canvas.update_idletasks() + canvas.configure(scrollregion=canvas.bbox("all")) + canvas.yview_moveto(1.0) + + def speichern(): + neu = [] + for i, row in enumerate(arbeit, start=1): + name = row["leistung"].get().strip() + if not name: + continue # leere Zeilen verwerfen + neu.append({ + "nr": i, + "leistung": name, + "satz": int(parse_num(row["satz"].get())), + "preis": parse_num(row["preis"].get()), + "pro_person": bool(row["pro_person"].get()), + }) + if not neu: + messagebox.showwarning("Leer", "Es muss mindestens eine Leistung übrig bleiben.", parent=d) + return + self.cfg["katalog"] = neu + config.save_config(self.cfg) + self._render_positionszeilen() + self.recalc() + d.destroy() + + # Buttonleiste unten zuerst -> bleibt immer sichtbar + btns = ttk.Frame(d, padding=8) + btns.pack(side="bottom", fill="x") + ttk.Button(btns, text="+ Position hinzufügen", command=hinzufuegen).pack(side="left", padx=4) + ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4) + ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right", padx=4) + + # Scrollbereich + body = ttk.Frame(d) + body.pack(side="top", fill="both", expand=True) + canvas = tk.Canvas(body, highlightthickness=0, bg=self.col_bg) + sb = ttk.Scrollbar(body, orient="vertical", command=canvas.yview) + inner = ttk.Frame(canvas, padding=8) + inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=inner, anchor="nw") + canvas.configure(yscrollcommand=sb.set) + canvas.pack(side="left", fill="both", expand=True) + sb.pack(side="right", fill="y") + + render() + + # Fenstergröße an den Inhalt anpassen (Breite = breiteste Zeile) + d.update_idletasks() + inhalt_w = inner.winfo_reqwidth() + canvas.configure(width=inhalt_w) + win_w = min(inhalt_w + sb.winfo_reqwidth() + 26, d.winfo_screenwidth() - 80) + win_h = min(inner.winfo_reqheight() + btns.winfo_reqheight() + 90, + d.winfo_screenheight() - 120) + d.geometry(f"{win_w}x{int(win_h)}") + d.minsize(min(win_w, 820), 420) + info_lbl.configure(wraplength=win_w - 24) + def dlg_firma(self): + d = tk.Toplevel(self) + d.title("Firmendaten & Texte") + d.transient(self) + d.grab_set() + self._style_dialog(d) + d.geometry("640x640") + + frm = ttk.Frame(d, padding=12) + frm.pack(fill="both", expand=True) + felder = [ + ("name", "Firmenname"), ("untertitel", "Untertitel"), + ("absender", "Absenderzeile"), ("inhaber", "Inhaber"), + ("bank", "Bank"), ("iban", "IBAN"), ("bic", "SWIFT/BIC"), + ("steuernummer", "Steuernummer"), ("homepage", "Homepage"), + ("email", "E-Mail"), ("telefon", "Telefon"), + ] + vars_ = {} + for i, (key, lbl) in enumerate(felder): + ttk.Label(frm, text=lbl).grid(row=i, column=0, sticky="w", pady=2) + v = tk.StringVar(value=self.cfg["firma"].get(key, "")) + ttk.Entry(frm, textvariable=v, width=58).grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2) + vars_[key] = v + frm.columnconfigure(1, weight=1) + base = len(felder) + + # Logo (Bilddatei) mit Durchsuchen / Entfernen + ttk.Label(frm, text="Logo (oben links)").grid(row=base, column=0, sticky="w", pady=2) + logo_row = ttk.Frame(frm) + logo_row.grid(row=base, column=1, sticky="ew", padx=(8, 0), pady=2) + v_logo = tk.StringVar(value=self.cfg["firma"].get("logo", "")) + ttk.Entry(logo_row, textvariable=v_logo).pack(side="left", fill="x", expand=True) + + def logo_waehlen(): + p = filedialog.askopenfilename( + title="Logo auswählen", + filetypes=[("Bilder", "*.png *.jpg *.jpeg *.gif"), ("Alle Dateien", "*.*")]) + if p: + v_logo.set(p) + + ttk.Button(logo_row, text="Durchsuchen…", command=logo_waehlen).pack(side="left", padx=(6, 0)) + ttk.Button(logo_row, text="Entfernen", command=lambda: v_logo.set("")).pack(side="left", padx=(4, 0)) + + ttk.Label(frm, text="Danktext 1").grid(row=base + 1, column=0, sticky="nw", pady=2) + t1 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg) + t1.insert("1.0", self.cfg["texte"].get("dank1", "")) + t1.grid(row=base + 1, column=1, sticky="ew", padx=(8, 0), pady=2) + ttk.Label(frm, text="Danktext 2").grid(row=base + 2, column=0, sticky="nw", pady=2) + t2 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg) + t2.insert("1.0", self.cfg["texte"].get("dank2", "")) + t2.grid(row=base + 2, column=1, sticky="ew", padx=(8, 0), pady=2) + ttk.Label(frm, text="Hinweiszeile").grid(row=base + 3, column=0, sticky="w", pady=2) + vh = tk.StringVar(value=self.cfg["texte"].get("hinweis", "")) + ttk.Entry(frm, textvariable=vh, width=58).grid(row=base + 3, column=1, sticky="ew", padx=(8, 0), pady=2) + + def speichern(): + for key, v in vars_.items(): + self.cfg["firma"][key] = v.get() + self.cfg["firma"]["logo"] = v_logo.get().strip() + self.cfg["texte"]["dank1"] = t1.get("1.0", "end").strip() + self.cfg["texte"]["dank2"] = t2.get("1.0", "end").strip() + self.cfg["texte"]["hinweis"] = vh.get().strip() + config.save_config(self.cfg) + self.title("Rechnungstool - " + self.cfg["firma"].get("name", "")) + d.destroy() + + btns = ttk.Frame(frm) + btns.grid(row=base + 4, column=0, columnspan=2, pady=(12, 0), sticky="e") + ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4) + ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right") + def dlg_nummer(self): + d = tk.Toplevel(self) + d.title("Rechnungsnummer") + d.transient(self) + d.grab_set() + self._style_dialog(d) + frm = ttk.Frame(d, padding=12) + frm.pack(fill="both", expand=True) + + v_jahr = tk.StringVar(value=str(self.cfg.get("rechnung_jahr", datetime.date.today().year))) + v_z = tk.StringVar(value=str(self.cfg.get("rechnung_zaehler", 0))) + ttk.Label(frm, text="Format: JJJJ-NNN (z. B. 2026-013). Der Zähler ist der zuletzt\n" + "vergebene Wert – beim nächsten Beleg wird +1 vorgeschlagen.", + justify="left").grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 8)) + ttk.Label(frm, text="Jahr").grid(row=1, column=0, sticky="w", pady=3) + ttk.Entry(frm, textvariable=v_jahr, width=10).grid(row=1, column=1, sticky="w", pady=3) + ttk.Label(frm, text="Zuletzt vergebener Zähler").grid(row=2, column=0, sticky="w", pady=3) + ttk.Entry(frm, textvariable=v_z, width=10).grid(row=2, column=1, sticky="w", pady=3) + + def speichern(): + self.cfg["rechnung_jahr"] = int(parse_num(v_jahr.get())) + self.cfg["rechnung_zaehler"] = int(parse_num(v_z.get())) + config.save_config(self.cfg) + self._neue_nummer_vorschlagen() + d.destroy() + + btns = ttk.Frame(frm) + btns.grid(row=3, column=0, columnspan=2, pady=(12, 0), sticky="e") + ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4) + ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right") diff --git a/einzelinstanz.py b/einzelinstanz.py new file mode 100644 index 0000000..cdd10cc --- /dev/null +++ b/einzelinstanz.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +"""Nur ein Fenster zulassen und das offene nach vorn holen. + +Zwei Fenster lesen beim Start denselben Zähler und schlagen dieselbe +Rechnungsnummer vor - die Nummer wird dann doppelt vergeben. +""" + +import os +import sys + +import config + + +def einzelinstanz_pruefen(): + """Sperrt das Programm gegen ein zweites Fenster. + + Beide Fenster lesen beim Start denselben Zaehler aus der config und + schlagen darum dieselbe Rechnungsnummer vor -> die Nummer wird doppelt + vergeben. Darum: nur eine Instanz je Windows-Sitzung. + + Rueckgabe: Handle (offen halten, solange das Programm laeuft) oder + False, wenn schon eine Instanz laeuft. + """ + if not sys.platform.startswith("win"): + return None + import ctypes + from ctypes import wintypes + ERROR_ALREADY_EXISTS = 183 + k32 = ctypes.WinDLL("kernel32", use_last_error=True) + k32.CreateMutexW.argtypes = [wintypes.LPVOID, wintypes.BOOL, wintypes.LPCWSTR] + k32.CreateMutexW.restype = wintypes.HANDLE + handle = k32.CreateMutexW(None, False, "RechnungstoolCampinghofBartl") + if ctypes.get_last_error() == ERROR_ALREADY_EXISTS: + return False + return handle +# Merkzettel mit der PID der laufenden Instanz - liegt neben der config.json. +PID_DATEI = os.path.join(config.BASE_DIR, "rechnungstool.pid") +def eigene_pid_merken(): + """Schreibt die eigene PID, damit eine zweite Instanz das Fenster findet.""" + try: + with open(PID_DATEI, "w", encoding="ascii") as f: + f.write(str(os.getpid())) + except OSError: + pass +def laufende_pid_lesen(): + """PID der laufenden Instanz aus dem Merkzettel; None wenn unlesbar.""" + try: + with open(PID_DATEI, "r", encoding="ascii") as f: + return int(f.read().strip()) + except (OSError, ValueError): + return None +def fenster_nach_vorn(pid): + """Holt das Fenster des Prozesses nach vorn. True bei Erfolg. + + Gesucht wird ueber die PID, nicht ueber den Fenstertitel - der Titel + haengt am Firmennamen aus der config und aendert sich mit ihm. + """ + if not pid or not sys.platform.startswith("win"): + return False + import ctypes + from ctypes import wintypes + SW_RESTORE = 9 + u32 = ctypes.WinDLL("user32", use_last_error=True) + u32.IsWindowVisible.argtypes = [wintypes.HWND] + u32.IsIconic.argtypes = [wintypes.HWND] + u32.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int] + u32.SetForegroundWindow.argtypes = [wintypes.HWND] + u32.GetWindowTextLengthW.argtypes = [wintypes.HWND] + u32.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)] + + treffer = [] + + def besuch(hwnd, _lparam): + fremd = wintypes.DWORD() + u32.GetWindowThreadProcessId(hwnd, ctypes.byref(fremd)) + # nur das echte Hauptfenster: sichtbar und mit Titel + if fremd.value == pid and u32.IsWindowVisible(hwnd) and u32.GetWindowTextLengthW(hwnd): + treffer.append(hwnd) + return False + return True + + CB = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + u32.EnumWindows(CB(besuch), 0) + if not treffer: + return False + hwnd = treffer[0] + if u32.IsIconic(hwnd): + u32.ShowWindow(hwnd, SW_RESTORE) + u32.SetForegroundWindow(hwnd) + return True diff --git a/helfer.py b/helfer.py new file mode 100644 index 0000000..1f6acd1 --- /dev/null +++ b/helfer.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +"""Kleine Helfer, die überall gebraucht werden: Zahlen, Datum, Namen, Prüfungen.""" + +import datetime + +import config + + +def parse_num(s): + """'12', '3,5' -> float; leer/ungueltig -> 0.0""" + s = (s or "").strip().replace(",", ".") + if not s: + return 0.0 + try: + return float(s) + except ValueError: + return 0.0 +def parse_date(s): + """'28.01.2026' -> date; sonst None""" + s = (s or "").strip() + for fmt in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d"): + try: + return datetime.datetime.strptime(s, fmt).date() + except ValueError: + continue + return None +def safe_filename(s): + keep = "-_.() " + return "".join(c for c in s if c.isalnum() or c in keep).strip() or "Rechnung" +def saeubern(s): + """Rand-Leerzeichen weg, Mehrfachleerzeichen zu einem. + + Wird vor der Pruefung auf die Felder angewendet - geprueft werden muss + genau das, was nachher auf dem Blatt steht. + """ + return " ".join(str(s or "").split()) +def buchstaben(s): + return sum(1 for c in str(s) if c.isalpha()) +def ziffern(s): + return sum(1 for c in str(s) if c.isdigit()) +# 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 kunde_maengel(kunde): + """Beanstandungen an Name und Anschrift; leere Liste = in Ordnung. + + Prueft nicht nur "irgendwas eingetippt": ein Feld aus Leerzeichen, + Bindestrichen oder einem einzelnen Zeichen ist kein Name und keine + Anschrift. Vier Ziffern reichen, damit auch oesterreichische und + niederlaendische Postleitzahlen durchgehen. + """ + maengel = [] + for feld, schluessel in (("Vorname", "vorname"), + ("Nachname / Firma", "nachname"), + ("Straße / Nr.", "strasse")): + wert = saeubern(kunde.get(schluessel)) + if not wert: + maengel.append(f"{feld}: ist leer") + elif buchstaben(wert) < 2: + maengel.append(f"{feld}: „{wert}“ ergibt keinen Namen") + + ort = saeubern(kunde.get("plzort")) + if not ort: + maengel.append("PLZ / Ort: ist leer") + elif ziffern(ort) < 4 or buchstaben(ort) < 2: + maengel.append(f"PLZ / Ort: „{ort}“ – bitte Postleitzahl UND Ort, " + "z. B. 04416 Markkleeberg") + return maengel +def name_aufteilen(ganz): + """'Andres Schäfer' -> ('Andres', 'Schäfer'); ein einzelnes Wort wird Nachname.""" + teile = str(ganz or "").split() + if len(teile) > 1: + return " ".join(teile[:-1]), teile[-1] + return "", (teile[0] if teile else "") diff --git a/korrektur.py b/korrektur.py new file mode 100644 index 0000000..a49cd1c --- /dev/null +++ b/korrektur.py @@ -0,0 +1,306 @@ +# -*- coding: utf-8 -*- +"""Storno- und Berichtigungsweg. + +Zwei Wege, ein Popup-Bauplan: + Storno - falsche Betraege. Stornorechnung mit eigener neuer Nummer, + danach die berichtigte Rechnung. Zwei Nummern verbraucht. + Berichtigung - nur die Kundenangaben stimmen nicht. Die Rechnung behaelt + Nummer, Datum und Betraege (§ 31 Abs. 5 UStDV), es wird nur + ein berichtigtes Blatt nachgereicht. Keine Nummer verbraucht. + +Die alte Rechnung wird in beiden Faellen NIE angefasst (§ 146 Abs. 4 AO). +""" + +import datetime +import os +import tkinter as tk +from tkinter import ttk, messagebox + +import berechnung +import config +import pdf_renderer +from bestand import kundendaten_ermitteln, naechste_freie_nummer, rechnungen_im_ordner +from helfer import nummer_normalisieren, safe_filename +from storno import storno_rechnung_bauen + + +class KorrekturMixin: + """Storno und Berichtigung. Erwartet die Felder der RechnungsApp.""" + + # ------------------------------------------------------------- Storno + def _storno_anzeigen(self): + """Hinweiszeile und Sperre der Rechnungsnummer an den Zwischenspeicher anpassen.""" + stand = self.storno_stand + if stand: + self.storno_hinweis.set( + f"Storno {stand['nummer']} zu Rechnung {stand['zu']} liegt bereit.\n" + f"Beim Erstellen wird noch einmal gefragt.") + self.nummer_entry.configure(state="readonly") + elif self.berichtigung_stand: + self.storno_hinweis.set( + f"Berichtigung der Rechnung {self.berichtigung_stand['zu']}.\n" + "Nummer und Beträge bleiben, nur die Kundenangaben ändern sich.") + self.nummer_entry.configure(state="readonly") + else: + self.storno_hinweis.set("") + self.nummer_entry.configure(state="normal") + def _berichtigung_starten(self, meta, out_dir): + """Rechnung ins Formular holen; "PDF erstellen" schreibt das berichtigte Blatt.""" + nummer = str(meta.get("rechnungsnummer", "")) + fehlend = self._formular_aus_meta(meta) + self.berichtigung_stand = {"zu": nummer, "datum": str(meta.get("datum") or ""), + "meta": meta} + # Die Rechnung behaelt ihre Nummer - genau das ist der Unterschied zum Storno. + self.r_nummer.set(nummer) + self.r_datum.set(str(meta.get("datum") or self.r_datum.get())) + self._storno_anzeigen() + hinweis = f"\n\nZum Kunden: {fehlend}." if fehlend else "" + messagebox.showinfo( + "Rechnung geladen", + f"Rechnung {nummer} vom {meta.get('datum','')} ist im Formular.\n\n" + "Jetzt die Kundenangaben richtigstellen und \"PDF erstellen\" drücken – " + "es entsteht ein berichtigtes Blatt zu dieser Rechnung. Die alte Rechnung " + f"bleibt liegen.{hinweis}") + def _storno_verwerfen(self, grund=""): + """Storno rückgängig: PDF löschen, Zähler zurück, Zwischenspeicher leeren. + + Zulaessig ist das nur hier: die Storno-PDF ist Sekunden alt und hat das + Haus nie verlassen. Die stornierte Rechnung selbst wird nie angefasst. + """ + stand = self.storno_stand + if not stand: + return + try: + if os.path.exists(stand["pfad"]): + os.remove(stand["pfad"]) + print(f"[storno] verworfen, PDF gelöscht -> {stand['pfad']}") + except OSError as e: # noqa: BLE001 + messagebox.showwarning("Storno-PDF blieb liegen", + f"Die Storno-PDF konnte nicht gelöscht werden:\n{e}") + self.cfg["rechnung_jahr"] = stand["jahr_vorher"] + self.cfg["rechnung_zaehler"] = stand["zaehler_vorher"] + config.save_config(self.cfg) + self.storno_stand = None + self._storno_anzeigen() + self._neue_nummer_vorschlagen() + if grund: + print(f"[storno] {grund}") + def dlg_storno(self): + """Knopf „Storno“ – setzt den Weg auf Storno und öffnet das Popup.""" + self._dlg_korrektur("storno") + def dlg_berichtigen(self): + """Knopf „Berichtigen“ – setzt den Weg auf Berichtigung und öffnet das Popup.""" + self._dlg_korrektur("berichtigung") + def _dlg_korrektur(self, modus): + """Popup für beide Wege. Welcher gilt, steht schon beim Öffnen fest. + + "storno" -> Storno-PDF schreiben, danach die berichtigte Rechnung + "berichtigung" -> nur die Kundenangaben, Rechnung behält ihre Nummer + """ + storno = modus == "storno" + if self.storno_stand: + messagebox.showinfo( + "Storno liegt schon bereit", + f"Der Storno {self.storno_stand['nummer']} zu Rechnung " + f"{self.storno_stand['zu']} ist noch offen.\n\n" + "Erst die berichtigte Rechnung erstellen oder den Storno dort verwerfen.") + return + if self.berichtigung_stand: + messagebox.showinfo( + "Berichtigung läuft schon", + f"Die Rechnung {self.berichtigung_stand['zu']} liegt gerade zum Berichtigen " + "im Formular.\n\nErst „PDF erstellen“ drücken oder das Formular zurücksetzen.") + return + + 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"} + offen = [d for d in alle + if d.get("art") not in ("storno", "berichtigung") + and nummer_normalisieren(d.get("rechnungsnummer")) not in stornierte] + if not offen: + messagebox.showwarning( + "Nichts zu korrigieren", + f"Im Ordner\n{out_dir}\nliegt keine Rechnung, die dafür in Frage kommt.") + return + + d = tk.Toplevel(self) + d.title("Rechnung stornieren" if storno else "Rechnung berichtigen") + d.transient(self) + d.grab_set() + self._style_dialog(d) + frm = ttk.Frame(d, padding=12) + frm.pack(fill="both", expand=True) + + ttk.Label(frm, justify="left", wraplength=470, text=( + "Für falsche Beträge oder Leistungen. Die alte Rechnung bleibt unverändert " + "liegen – sie darf weder geändert noch gelöscht werden. Es entsteht eine " + "Stornorechnung mit eigener neuer Nummer, die sich auf sie bezieht, und danach " + "die berichtigte Rechnung." + if storno else + "Wenn nur die Angaben zum Kunden nicht stimmen. Die Rechnung behält Nummer, " + "Datum und Beträge und bleibt gültig – es wird nur ein berichtigtes Blatt " + "nachgereicht (§ 31 Abs. 5 UStDV). Für falsche Beträge ist das der falsche Weg, " + "dafür gibt es den Storno.")).grid( + row=0, column=0, columnspan=2, sticky="w", pady=(0, 10)) + + ttk.Label(frm, text="Alte Rechnungsnummer").grid(row=1, column=0, sticky="w", pady=3) + # 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. + 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="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 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 + and x.get("art") != "berichtigung"), 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): + v_info.set(suche()[1]) + ent.bind("", vorschau) + + def anlegen(*_a): + meta, meldung = suche() + if not meta: + v_info.set(meldung) + return + d.destroy() + if storno: + self._storno_anlegen(meta, out_dir) + else: + self._berichtigung_starten(meta, out_dir) + ent.bind("", anlegen) + + btns = ttk.Frame(frm) + btns.grid(row=3, column=0, columnspan=2, pady=(14, 0), sticky="e") + ttk.Button(btns, text="Storno erstellen" if storno else "Rechnung laden", + command=anlegen).pack(side="right", padx=4) + 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", "")) + nummer = naechste_freie_nummer(out_dir, self.cfg) + if not nummer: + messagebox.showerror("Keine freie Nummer", + "Es wurde keine freie Rechnungsnummer gefunden.") + return + + r = storno_rechnung_bauen(meta, self.cfg) + heute = datetime.date.today().strftime("%d.%m.%Y") + # 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) + namenszeile = " ".join(t for t in (kunde.get("vorname", ""), + kunde.get("nachname", "")) if t) + kunde_zeilen = [kunde.get("anrede", ""), namenszeile, kunde.get("zusatz", ""), + kunde.get("strasse", ""), kunde.get("plzort", "")] + kopf = { + "rechnungsnummer": nummer, + "datum": heute, + "anreise": "", "abreise": "", "naechte": "", + "kunde_zeilen": kunde_zeilen, + "nachname": kunde.get("nachname", ""), + "vorname": kunde.get("vorname", ""), + "art": "storno", + "storno_zu": alt_nummer, + "storno_datum": str(meta.get("datum") or ""), + "kunde": kunde, + } + name = f"Storno_{safe_filename(nummer)}" + if kopf["nachname"]: + name += "_" + safe_filename(kopf["nachname"]) + pfad = os.path.join(out_dir, name + ".pdf") + try: + pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf) + except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen + messagebox.showerror("Fehler beim Erstellen", + f"Storno-PDF konnte nicht erstellt werden:\n{e}") + print("[storno] FEHLER:", e) + return + + self.storno_stand = { + "pfad": pfad, "nummer": nummer, "zu": alt_nummer, + "zu_datum": str(meta.get("datum") or ""), + "jahr_vorher": self.cfg.get("rechnung_jahr", datetime.date.today().year), + "zaehler_vorher": self.cfg.get("rechnung_zaehler", 0), + } + self._zaehler_aus_nummer_speichern(nummer) + print(f"[storno] erstellt -> {pfad}") + + fehlend = self._formular_aus_meta(meta) + self.r_nummer.set(naechste_freie_nummer(out_dir, self.cfg) or "") + self._storno_anzeigen() + + hinweis = "" + if fehlend: + hinweis = (f"\n\nZum Kunden: {fehlend}.\nBitte oben prüfen und ergänzen – " + "ohne vollständige Anschrift wird die Rechnung nicht geschrieben.") + messagebox.showinfo( + "Storno erstellt", + f"Stornorechnung {nummer} zu Rechnung {alt_nummer} liegt hier:\n{pfad}\n\n" + f"Jetzt die berichtigte Rechnung {self.r_nummer.get()} anpassen und " + f"\"PDF erstellen\" drücken.{hinweis}") + def _formular_aus_meta(self, meta): + """Formular mit den Daten der alten Rechnung füllen. + + Gibt zurück, was NICHT übernommen werden konnte - aeltere PDFs (v1/v2) + tragen weder Anschrift noch Positionen in den Kenndaten. + """ + kunde, woher = kundendaten_ermitteln(meta) + self.k_vorname.set(kunde.get("vorname", "")) + self.k_name.set(kunde.get("nachname", "")) + self.k_anrede.set(str(kunde.get("anrede", "") or "")) + self.k_zusatz.set(str(kunde.get("zusatz", "") or "")) + self.k_strasse.set(str(kunde.get("strasse", "") or "")) + self.k_plzort.set(str(kunde.get("plzort", "") or "")) + self.r_anreise.set(str(meta.get("anreise", "") or "")) + self.r_abreise.set(str(meta.get("abreise", "") or "")) + self.r_datum.set(datetime.date.today().strftime("%d.%m.%Y")) + + for nr, var in self.anzahl_vars.items(): + var.set("") + self.personen_vars[nr].set("") + for p in meta.get("positionen") or []: + nr = p.get("nr") + if nr in self.anzahl_vars: + self.anzahl_vars[nr].set(str(p.get("anzahl", "") or "")) + self.personen_vars[nr].set(str(p.get("personen", "") or "")) + self.recalc() + + hinweise = [] + if woher: + hinweise.append("aus dem Rechnungstext gelesen: " + ", ".join(woher)) + fehlt = [feld for feld, wert in (("Straße", self.k_strasse.get()), + ("PLZ / Ort", self.k_plzort.get()), + ("Nachname", self.k_name.get())) + if not wert] + if fehlt: + hinweise.append("steht in der alten Rechnung nicht drin: " + ", ".join(fehlt)) + if not (meta.get("positionen") or []): + hinweise.append("Leistungen konnten nicht übernommen werden") + return "; ".join(hinweise) diff --git a/pruef_nummern.py b/pruef_nummern.py index bb916c1..596b918 100644 --- a/pruef_nummern.py +++ b/pruef_nummern.py @@ -9,7 +9,7 @@ import sys import tempfile sys.path.insert(0, r"C:\claude\rechnungstool") -from app import vorhandene_rechnungen # noqa: E402 +from bestand import vorhandene_rechnungen # noqa: E402 ordner = tempfile.mkdtemp(prefix="rechnungspruef_") dateien = [ diff --git a/pruef_storno.py b/pruef_storno.py index eef1327..c17d132 100644 --- a/pruef_storno.py +++ b/pruef_storno.py @@ -33,6 +33,7 @@ roh["output_dir"] = ordner json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2) import app as A # noqa: E402 +import bestand as BE # noqa: E402 from tkinter import messagebox # noqa: E402 fehler = [] @@ -81,7 +82,7 @@ f.pdf_erstellen() erste_pdf = os.path.join(ordner, "Rechnung_2026-001_Beispiel.pdf") pruefe(os.path.exists(erste_pdf), "Rechnung 2026-001 geschrieben") -meta1 = A.kenndaten_lesen(erste_pdf) +meta1 = BE.kenndaten_lesen(erste_pdf) pruefe(bool(meta1), "Kenndaten der Rechnung lesbar") pruefe(meta1.get("version") == 3, f"Metadaten Version 3 (ist {meta1.get('version')})") pruefe(meta1.get("art") == "rechnung", "art = rechnung") @@ -92,14 +93,14 @@ gesamt1 = float(meta1["gesamt"]) print(f" Rechnung 001: brutto {brutto1:.2f}, gesamt {gesamt1:.2f}") # ---- 2. stornieren ------------------------------------------------------ -offen = [d for d in A.rechnungen_im_ordner(ordner) if d.get("art") != "storno"] +offen = [d for d in BE.rechnungen_im_ordner(ordner) if d.get("art") != "storno"] pruefe(len(offen) == 1, "eine stornierbare Rechnung gefunden") f._storno_anlegen(offen[0], ordner) pruefe(f.storno_stand is not None, "Storno liegt im Zwischenspeicher") storno_pdf = f.storno_stand["pfad"] if f.storno_stand else "" pruefe(os.path.exists(storno_pdf), f"Storno-PDF geschrieben ({os.path.basename(storno_pdf)})") -meta2 = A.kenndaten_lesen(storno_pdf) +meta2 = BE.kenndaten_lesen(storno_pdf) pruefe(meta2.get("art") == "storno", "art = storno") pruefe(meta2.get("storno_zu") == "2026-001", "storno_zu zeigt auf 2026-001") pruefe(meta2.get("storno_datum") == meta1.get("datum"), "Datum der alten Rechnung steht drin") @@ -121,7 +122,7 @@ dritte = os.path.join(ordner, "Rechnung_2026-003_Beispiel.pdf") pruefe(os.path.exists(dritte), "berichtigte Rechnung 2026-003 geschrieben") pruefe(f.storno_stand is None, "Zwischenspeicher nach dem Erstellen leer") pruefe(str(f.nummer_entry.cget("state")) == "normal", "Nummernfeld wieder frei") -meta3 = A.kenndaten_lesen(dritte) +meta3 = BE.kenndaten_lesen(dritte) pruefe(meta3.get("art") == "korrektur", "art = korrektur") pruefe(meta3.get("ersetzt") == "2026-001", "ersetzt zeigt auf 2026-001") pruefe(meta3.get("storno_nummer") == "2026-002", "storno_nummer zeigt auf 2026-002") @@ -146,7 +147,7 @@ print(f" Summe {summe:.2f} | Netto {netto:.2f} | USt {ust:.2f}") # ---- 5. Rueckweg: Storno anlegen und verwerfen -------------------------- antworten["Storno wirklich"] = False -offen = [d for d in A.rechnungen_im_ordner(ordner) +offen = [d for d in BE.rechnungen_im_ordner(ordner) if d.get("art") == "korrektur"] f._storno_anlegen(offen[0], ordner) storno2 = f.storno_stand["pfad"] @@ -303,9 +304,9 @@ def alt_rechnung(name, dateiname, kunde_zeilen): alt = alt_rechnung("Andres Schäfer", "Rechnung_2026-900.pdf", ["Herr", "Andres Schäfer", "c/o Stellplatz 12", "Bornaer Chaussee 36", "04416 Markkleeberg"]) -meta_alt = A.kenndaten_lesen(alt) +meta_alt = BE.kenndaten_lesen(alt) pruefe(not meta_alt.get("kunde"), "Altrechnung hat keine Kundendaten in den Kenndaten") -kunde, woher = A.kundendaten_ermitteln(meta_alt) +kunde, woher = BE.kundendaten_ermitteln(meta_alt) pruefe(kunde["vorname"] == "Andres", f"Vorname aufgeteilt ({kunde['vorname']!r})") pruefe(kunde["nachname"] == "Schäfer", f"Nachname aufgeteilt ({kunde['nachname']!r})") pruefe(kunde.get("anrede") == "Herr", f"Anrede aus dem Text ({kunde.get('anrede')!r})") @@ -315,16 +316,16 @@ pruefe(kunde.get("zusatz") == "c/o Stellplatz 12", f"Zusatz aus dem Text ({kunde # b) sein echter Fall: NUR der Name, keine Anschrift alt2 = alt_rechnung("Andres Schäfer", "Rechnung_2026-901.pdf", ["", "Andres Schäfer", "", "", ""]) -kunde2, _ = A.kundendaten_ermitteln(A.kenndaten_lesen(alt2)) +kunde2, _ = BE.kundendaten_ermitteln(BE.kenndaten_lesen(alt2)) pruefe(kunde2["nachname"] == "Schäfer", "auch ohne Anschrift wird der Name aufgeteilt") pruefe(not kunde2.get("plzort"), "keine Anschrift erfunden") # c) Formular fuellen: was fehlt, wird benannt -meldung = f._formular_aus_meta(A.kenndaten_lesen(alt2)) +meldung = f._formular_aus_meta(BE.kenndaten_lesen(alt2)) pruefe(f.k_vorname.get() == "Andres" and f.k_name.get() == "Schäfer", "Formular bekommt Vor- und Nachnamen getrennt") pruefe("Straße" in meldung and "PLZ" in meldung and "Nachname vom Vornamen" in meldung, f"fehlende Anschrift wird benannt ({meldung})") -meldung = f._formular_aus_meta(A.kenndaten_lesen(alt)) +meldung = f._formular_aus_meta(BE.kenndaten_lesen(alt)) pruefe(f.k_plzort.get() == "04416 Markkleeberg", "Anschrift landet im Formular") pruefe("aus dem Rechnungstext gelesen" in meldung, f"Herkunft wird gesagt ({meldung})") for name in ("Rechnung_2026-900.pdf", "Rechnung_2026-901.pdf"): @@ -332,7 +333,7 @@ for name in ("Rechnung_2026-900.pdf", "Rechnung_2026-901.pdf"): # ---- 11. Berichtigung: gleicher Dialog, zweiter Knopf ------------------- alt3 = alt_rechnung("Andres Schäfer", "Rechnung_2026-910.pdf", ["", "Andres Schäfer", "", "", ""]) -meta3 = A.kenndaten_lesen(alt3) +meta3 = BE.kenndaten_lesen(alt3) vorher = sorted(os.listdir(ordner)) zaehler_vor_berichtigung = f.cfg["rechnung_zaehler"] @@ -367,7 +368,7 @@ pruefe(f.cfg["rechnung_zaehler"] == zaehler_vor_berichtigung, f"Zaehler unveraendert - die Berichtigung verbraucht keine Nummer " f"({f.cfg['rechnung_zaehler']} vs {zaehler_vor_berichtigung})") -meta_b = A.kenndaten_lesen(ziel) +meta_b = BE.kenndaten_lesen(ziel) pruefe(meta_b.get("art") == "berichtigung", "art = berichtigung") pruefe(meta_b.get("berichtigt_zu") == "2026-910", "Bezug auf die Rechnung") pruefe(float(meta_b.get("gesamt") or 0) == 0.0, diff --git a/storno.py b/storno.py new file mode 100644 index 0000000..e772d33 --- /dev/null +++ b/storno.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +"""Den spiegelbildlichen Storno zu einer Rechnung rechnen.""" + +import berechnung + + +def storno_rechnung_bauen(meta, cfg): + """Baut aus den Kenndaten der alten Rechnung den spiegelbildlichen Storno. + + Alle Betraege negativ. Je USt-Satz eine Zeile - die einzelnen Leistungen der + alten Rechnung werden NICHT wiederholt: aufgehoben wird der Beleg als Ganzes, + und die Positionen stehen in aelteren PDFs gar nicht in den Kenndaten. + """ + r = berechnung.Rechnung() + nummer = meta.get("rechnungsnummer", "") + bloecke = meta.get("ust_bloecke") or [] + if not bloecke: + # v1-PDF ohne Aufteilung: alles auf den ermaessigten Satz, sonst faellt + # die USt unter den Tisch. + brutto = float(meta.get("zwischensumme") or 0) + satz = int((cfg.get("steuersaetze") or [{"satz": 7}])[0].get("satz", 7)) + netto = berechnung.runde(brutto * 100 / (100 + satz)) if satz else brutto + bloecke = [{"satz": satz, "netto": netto, "ust": berechnung.runde(brutto - netto), + "brutto": brutto}] + + saetze = {int(s.get("satz", -1)): s for s in cfg.get("steuersaetze", [])} + for i, b in enumerate(bloecke, start=1): + satz = int(float(b.get("satz", 0) or 0)) + brutto = -abs(berechnung.runde(b.get("brutto") or 0)) + netto = -abs(berechnung.runde(b.get("netto") or 0)) + ust = -abs(berechnung.runde(b.get("ust") or 0)) + r.zeilen.append(berechnung.PositionsZeile( + nr=i, leistung=f"Storno der Rechnung {nummer} ({satz} % USt)", + satz=satz, preis=brutto, anzahl=1, personen=0, pro_person=False, + brutto=brutto, netto=netto)) + info = saetze.get(satz, {}) + r.bloecke.append(berechnung.Steuerblock( + satz=satz, netto=netto, ust=ust, + bezeichnung=str(info.get("bezeichnung", "") or "Mehrwertsteuer"), + extra_blatt=bool(info.get("extra_blatt", False)))) + + r.netto_gesamt = berechnung.runde(sum(b.netto for b in r.bloecke)) + r.ust_gesamt = berechnung.runde(sum(b.ust for b in r.bloecke)) + r.brutto = berechnung.runde(r.netto_gesamt + r.ust_gesamt) + + # Beherbergungssteuer wird mitstorniert, sonst stimmt die Meldung ans Amt nicht. + # Die Bezeichnung kommt aus dem Satz mit "extra_blatt" - genau dem, aus dem + # berechne() den Aufschlag baut. + steuer = berechnung.runde(meta.get("steuer_betrag") or 0) + if steuer: + extra = next((s for s in cfg.get("steuersaetze", []) if s.get("extra_blatt")), {}) + r.aufschlaege.append(berechnung.Aufschlag( + satz=float(meta.get("steuer_satz") or extra.get("satz", 0) or 0), + bezeichnung=str(extra.get("bezeichnung", "") or "Beherbergungssteuer"), + basis=r.brutto, betrag=-abs(steuer), extra_blatt=True)) + r.gesamt = berechnung.runde(r.brutto + sum(a.betrag for a in r.aufschlaege)) + return r diff --git a/theme.py b/theme.py new file mode 100644 index 0000000..23aba1d --- /dev/null +++ b/theme.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +"""Farben nach dem Windows-Theme (hell/dunkel) - inklusive Titelleiste. + +Native tk-Menues und -Popups lassen sich unter Windows nicht einfaerben, darum +baut das Programm Menueleiste und Dropdowns selbst; hier stehen nur die Farben. +""" + +import sys +import tkinter as tk +from tkinter import ttk + + +def windows_dark_mode(): + """True, wenn Windows auf das dunkle App-Theme eingestellt ist.""" + if not sys.platform.startswith("win"): + return False + try: + import winreg + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize") as k: + return winreg.QueryValueEx(k, "AppsUseLightTheme")[0] == 0 + except OSError: + return False + + +class ThemeMixin: + """Farben setzen. Erwartet ein tk.Tk als Basis.""" + + # ----------------------------------------------------------------- Theme + def _apply_theme(self): + """Farben passend zum Windows-Theme (hell/dunkel) setzen.""" + dark = windows_dark_mode() + self._dark_mode = dark + if dark: + self.col_bg = "#23272e" # Fenster-Hintergrund + self.col_fg = "#e6e6e6" # Schrift + self.col_field = "#2f343c" # Eingabefelder + self.col_sel = "#3a4a44" # aktiv/hover + self.col_accent = "#5fb98f" # Akzent (Gruen, hell) + self.col_line = "#3a3f47" + self.col_scroll = "#4a505a" # Scroll-Griff + self.col_muted = "#8a929c" # gedaempfte Schrift (z.B. "powered by") + else: + self.col_bg = "#f3f3f3" + self.col_fg = "#1a1a1a" + self.col_field = "#ffffff" + self.col_sel = "#cfe3d8" + self.col_accent = "#1f5c3d" + self.col_line = "#cccccc" + self.col_scroll = "#b8b8b8" + self.col_muted = "#777777" # gedaempfte Schrift (z.B. "powered by") + + self.configure(bg=self.col_bg) + style = ttk.Style(self) + try: + style.theme_use("clam") + except tk.TclError: + pass + bg, fg, field, sel, accent = (self.col_bg, self.col_fg, self.col_field, + self.col_sel, self.col_accent) + style.configure(".", background=bg, foreground=fg, fieldbackground=field, + bordercolor=self.col_line, lightcolor=bg, darkcolor=bg) + style.configure("TFrame", background=bg) + style.configure("TLabel", background=bg, foreground=fg) + style.configure("TLabelframe", background=bg, bordercolor=self.col_line) + style.configure("TLabelframe.Label", background=bg, foreground=accent) + style.configure("TButton", background=field, foreground=fg, bordercolor=self.col_line) + style.map("TButton", background=[("active", sel)], foreground=[("active", fg)]) + style.configure("Menu.TMenubutton", background=bg, foreground=fg, relief="flat", + arrowcolor=bg, padding=(10, 3)) + style.map("Menu.TMenubutton", background=[("active", sel)], foreground=[("active", fg)]) + style.configure("TCheckbutton", background=bg, foreground=fg) + style.map("TCheckbutton", background=[("active", bg)], foreground=[("active", fg)]) + style.configure("TEntry", fieldbackground=field, foreground=fg, insertcolor=fg) + # Bei state="readonly" zeichnet die Combobox ihren Text als MARKIERUNG, + # sobald sie den Fokus hat - ohne diese beiden Farben steht der Wert + # unsichtbar im Feld. + style.configure("TCombobox", fieldbackground=field, foreground=fg, + background=field, arrowcolor=fg, + selectbackground=field, selectforeground=fg) + style.map("TCombobox", fieldbackground=[("readonly", field)], + foreground=[("readonly", fg)], background=[("readonly", field)], + selectbackground=[("readonly", field)], selectforeground=[("readonly", fg)]) + # Die aufgeklappte Liste ist ein klassisches tk-Listbox-Widget + self.option_add("*TCombobox*Listbox.background", field) + self.option_add("*TCombobox*Listbox.foreground", fg) + self.option_add("*TCombobox*Listbox.selectBackground", sel) + self.option_add("*TCombobox*Listbox.selectForeground", fg) + for sbs in ("Vertical.TScrollbar", "Horizontal.TScrollbar", "TScrollbar"): + style.configure(sbs, troughcolor=bg, background=self.col_scroll, + bordercolor=bg, lightcolor=bg, darkcolor=bg, + arrowcolor=fg, relief="flat", gripcount=0) + style.map(sbs, background=[("active", accent)], + troughcolor=[("disabled", bg)]) + style.configure("TSeparator", background=self.col_line) + # Klassische tk-Widgets (Canvas, Toplevel, Text) erben diese Defaults + self.option_add("*background", bg) + self.option_add("*foreground", fg) + def _style_dialog(self, d): + """Toplevel-Dialog an das Theme angleichen.""" + d.configure(bg=self.col_bg) + # erst nach dem Anzeigen setzen, sonst greift DWM noch nicht + d.after(20, lambda: self._dark_titlebar(d)) + def _dark_titlebar(self, win): + """Windows-Titelleiste des Fensters dunkel machen (nur im Dark-Mode).""" + if not (sys.platform.startswith("win") and getattr(self, "_dark_mode", False)): + return + try: + import ctypes + win.update_idletasks() + hwnd = ctypes.windll.user32.GetParent(win.winfo_id()) + val = ctypes.c_int(1) + for attr in (20, 19): # DWMWA_USE_IMMERSIVE_DARK_MODE (Win11=20, frühes Win10=19) + ctypes.windll.dwmapi.DwmSetWindowAttribute( + hwnd, attr, ctypes.byref(val), ctypes.sizeof(val)) + # Frame neu zeichnen lassen (ohne Flackern/Fokusverlust) + SWP = 0x0001 | 0x0002 | 0x0004 | 0x0020 # NOSIZE|NOMOVE|NOZORDER|FRAMECHANGED + ctypes.windll.user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP) + except Exception as e: # noqa: BLE001 + print("[theme] Titelleiste:", e) From d67448c58f6f227cbd0585ba7d3ade0c4561ca02 Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Wed, 2 Sep 2026 19:57:31 +0200 Subject: [PATCH 09/14] Funde der drei Agenten: zwei kaputte Dialoge, haengende Flags, scharfe Pruefstaende MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REGRESSION aus der Modulaufteilung (Code-Review-Agent): - einstellungen.py fehlten "berechnung" und "datetime". Menue > Leistungskatalog und > Rechnungsnummer stuerzten mit NameError ab und liessen ein leeres, modales Fenster stehen. Preise und Nummernkreis waren nicht mehr einstellbar. KEIN Pruefstand hat das gemerkt - keiner oeffnete je einen Dialog. Neu: pruef_dialoge.py oeffnet ALLE Dialoge und schliesst sie wieder. HAENGENDE ZWISCHENSPEICHER (derselbe Agent, gemessen): - "Zuruecksetzen" liess storno_stand/berichtigung_stand stehen. Danach gehoerten die Daten eines FREMDEN Gastes zum Storno der alten Rechnung, und die Berichtigung schrieb ein Blatt mit Nummer A im Kenndatensatz und Nummer B auf dem Blatt. reset_formular fragt jetzt nach und verwirft sauber. - _neue_nummer_vorschlagen fasst die Nummer nicht mehr an, solange ein Vorgang laeuft (traf auch dlg_nummer). - Fenster schliessen mit offenem Storno fragt jetzt nach (WM_DELETE_WINDOW). - _storno_verwerfen bricht ab, wenn das PDF nicht geloescht werden kann, statt den Zaehler trotzdem zurueckzudrehen. - Der Storno prueft die Kundenangaben und fragt, wenn die alte Rechnung sie nicht hergibt. - Zaehler nicht speicherbar -> sichtbare Meldung statt Traceback ins Nichts. - storno.py: v1-Rueckfall nimmt den ersten Satz OHNE extra_blatt. - EINE Quelle fuer die naechste Nummer: _neue_nummer_vorschlagen benutzt jetzt naechste_freie_nummer, der Jahreswechsel steht nur noch dort. Vorher schlug das Formular 2026-001 vor, waehrend der Storno-Weg 2025-088 nahm. PRUEFSTAENDE (Audit-Agent: 16 von 27 eingebauten Fehlern blieben unbemerkt): - Neu pruef_kernregeln.py (27 Pruefungen) fuer die zwei Regeln, um die es geht: Nummer von HAND auf eine vergebene setzen, Zaehler hinter dem Ordner, Jahreswechsel, Zaehler nur vorwaerts, Storno ueber den DIALOGKNOPF statt der internen Methode, zweite Berichtigung am selben Tag. Der Bestand wird ueber SHA256 verglichen - "Datei ist noch da" heisst nicht "unveraendert". - Der Text des Storno-PDFs wird gelesen: Positionszeile negativ, nicht nur der Summenblock. - Tautologien raus: Selbstvergleich beim storno_datum, all() ueber eine leere Liste, "nicht" in einem deutschen Text. - Unangemeldeter Dialog laesst den Lauf scheitern, statt still "Nein" zu sagen. - pruef_nummern.py: .PDF gross geschrieben, Rechnung_*.txt, echte Praefix-Falle. - Gegenprobe: sechs Mutationen eingebaut, die vorher gruen blieben - alle sechs werden jetzt rot. RECHT (Gegenpruefungs-Agent): Zitate praezisiert. § 146 Abs. 4 AO verbietet nur Aenderungen, bei denen der urspruengliche Inhalt nicht mehr feststellbar ist - ein protokollierter Storno-Vermerk waere erlaubt, wir verzichten trotzdem bewusst darauf. Der § 14c-Hinweis bleibt, gilt aber gegenueber Endverbrauchern nicht (EuGH C-378/21, BMF v. 27.02.2024) - er zielt auf Firmengaeste. 142 Pruefungen gruen (100 + 27 + 8 + 7). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X --- app.py | 67 ++++++++-- bestand.py | 5 + einstellungen.py | 8 ++ korrektur.py | 52 +++++++- pdf_renderer.py | 9 +- pruef_dialoge.py | 91 ++++++++++++++ pruef_kernregeln.py | 291 ++++++++++++++++++++++++++++++++++++++++++++ pruef_nummern.py | 16 ++- pruef_storno.py | 19 ++- storno.py | 6 +- theme.py | 3 + 11 files changed, 538 insertions(+), 29 deletions(-) create mode 100644 pruef_dialoge.py create mode 100644 pruef_kernregeln.py diff --git a/app.py b/app.py index eb796bb..defd759 100644 --- a/app.py +++ b/app.py @@ -66,6 +66,10 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): self._neue_nummer_vorschlagen() self.recalc() + # Beim Schliessen nachfragen, wenn ein Storno noch offen ist - sonst liegt + # es morgen ohne berichtigte Rechnung im Ordner und keiner weiss warum. + self.protocol("WM_DELETE_WINDOW", self._beenden) + # Titelleiste dunkel (nach dem Mappen des Fensters) self.after(60, lambda: self._dark_titlebar(self)) # Beim ersten Start nach dem Speicherort fragen (wenn noch keiner gesetzt ist) @@ -83,7 +87,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): ("Formular zurücksetzen", self.reset_formular), ("Ausgabeordner öffnen", self._ordner_oeffnen), ("-", None), - ("Beenden", self.destroy), + ("Beenden", self._beenden), # fragt nach, wenn ein Storno offen ist ], "Einstellungen": [ ("Steuersätze…", self.dlg_steuern), @@ -403,12 +407,16 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): # ----------------------------------------------------------- Aktionen def _neue_nummer_vorschlagen(self): - jahr = self.cfg.get("rechnung_jahr", datetime.date.today().year) - zaehler = self.cfg.get("rechnung_zaehler", 0) - heute_jahr = datetime.date.today().year - if heute_jahr != jahr: - jahr, zaehler = heute_jahr, 0 - self.r_nummer.set(config.format_rechnungsnummer(jahr, zaehler + 1)) + """Nächste freie Nummer ins Formular - EINE Quelle, sonst laufen sie auseinander. + + Läuft gerade ein Storno oder eine Berichtigung, gehört die Nummer im Feld + zu diesem Vorgang und wird nicht angefasst. + """ + if self.storno_stand or self.berichtigung_stand: + return + frei = naechste_freie_nummer(config.ensure_output_dir(self.cfg), self.cfg) + if frei: + self.r_nummer.set(frei) def kunde_pruefen(self): @@ -548,7 +556,9 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): return # Nummer schon vergeben? Dann NICHT drueberschreiben: eine ausgestellte - # Rechnung muss unveraendert bestehen bleiben (§ 146 Abs. 4 AO), und jede + # Rechnung bleibt liegen - § 146 Abs. 4 AO verbietet Aenderungen, bei denen + # der urspruengliche Inhalt nicht mehr feststellbar ist; ein unangetasteter + # Beleg ist im Pruefungsfall die einfachere Geschichte. Und jede # Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG). Also neue Nummer. vorhanden = vorhandene_rechnungen(out_dir, nummer) if vorhanden: @@ -619,10 +629,49 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): return self.cfg["rechnung_jahr"] = jahr self.cfg["rechnung_zaehler"] = z - config.save_config(self.cfg) + try: + config.save_config(self.cfg) + except OSError as e: # noqa: BLE001 - die PDF ist schon geschrieben + # Ohne diese Meldung merkt niemand, dass der Zaehler stehengeblieben + # ist: die Ausgabe geht in eine Konsole, die es in der EXE nicht gibt. + messagebox.showwarning( + "Rechnungsnummer nicht gespeichert", + f"Die Rechnung ist geschrieben, der Zählerstand aber nicht:\n{e}\n\n" + f"Bitte im Menü unter „Rechnungsnummer…“ den Zähler auf {z} setzen, " + "sonst wird die nächste Nummer noch einmal vergeben.") self._neue_nummer_vorschlagen() + def _beenden(self): + """Schliessen - mit Rückfrage, wenn ein Storno noch offen ist.""" + if self.storno_stand: + if not messagebox.askyesno( + "Storno ist noch offen", + f"Der Storno {self.storno_stand['nummer']} zu Rechnung " + f"{self.storno_stand['zu']} liegt im Ordner, die berichtigte " + "Rechnung fehlt noch.\n\n" + "Beim Schließen bleibt er so liegen – morgen weiß niemand mehr, " + "warum.\n\nTrotzdem beenden?", icon="warning", default="no"): + return + self.destroy() + def reset_formular(self): + # Ein laufender Vorgang darf nicht stumm liegenbleiben: sonst gehören die + # neu eingetippten Daten plötzlich zum Storno der fremden Rechnung. + if self.storno_stand: + if not messagebox.askyesno( + "Storno verwerfen?", + f"Der Storno {self.storno_stand['nummer']} zu Rechnung " + f"{self.storno_stand['zu']} liegt noch bereit.\n\n" + "Zurücksetzen verwirft ihn – das Storno-PDF wird gelöscht und " + f"es bleibt allein die Rechnung {self.storno_stand['zu']}.\n\n" + "Wirklich zurücksetzen?", icon="warning", default="no"): + return + self._storno_verwerfen("beim Zurücksetzen verworfen") + elif self.berichtigung_stand: + # Da ist noch nichts geschrieben - einfach fallenlassen. + self.berichtigung_stand = None + self._storno_anzeigen() + for v in (self.k_anrede, self.k_vorname, self.k_name, self.k_zusatz, self.k_strasse, self.k_plzort, self.r_anreise, self.r_abreise): v.set("") diff --git a/bestand.py b/bestand.py index c687ba8..ae9afa8 100644 --- a/bestand.py +++ b/bestand.py @@ -163,6 +163,11 @@ def naechste_freie_nummer(ordner, cfg): """ jahr = cfg.get("rechnung_jahr", datetime.date.today().year) z = cfg.get("rechnung_zaehler", 0) + # Jahreswechsel: neues Jahr faengt bei 001 an. Muss HIER stehen und nirgends + # sonst - sonst schlaegt das Formular 2026-001 vor und der Storno nimmt + # gleichzeitig noch 2025-088. + if datetime.date.today().year != jahr: + jahr, z = datetime.date.today().year, 0 for _ in range(1000): z += 1 kandidat = config.format_rechnungsnummer(jahr, z) diff --git a/einstellungen.py b/einstellungen.py index 23a92ad..200aa7c 100644 --- a/einstellungen.py +++ b/einstellungen.py @@ -5,10 +5,12 @@ Alles, was hier eingestellt wird, landet in der config.json. Nichts davon steht hart im Code: Steuersaetze, Preise, Firmendaten, Rechnungsnummernkreis. """ +import datetime import os import tkinter as tk from tkinter import ttk, messagebox, filedialog +import berechnung import config from helfer import parse_num @@ -29,6 +31,7 @@ class EinstellungenMixin: self.cfg["output_dir"] = d if d else vorschlag config.save_config(self.cfg) config.ensure_output_dir(self.cfg) + def dlg_speicherort(self): aktuell = config.ensure_output_dir(self.cfg) d = filedialog.askdirectory(title="Ordner für die Rechnungen wählen", initialdir=aktuell) @@ -37,6 +40,7 @@ class EinstellungenMixin: config.save_config(self.cfg) config.ensure_output_dir(self.cfg) messagebox.showinfo("Speicherort", f"Rechnungen werden gespeichert in:\n{d}") + def _info(self): d = tk.Toplevel(self) d.title("Info") @@ -57,6 +61,7 @@ class EinstellungenMixin: d.bind("", lambda e: d.destroy()) d.grab_set() # ====================================================== Einstellungen + def dlg_steuern(self): d = tk.Toplevel(self) d.title("Steuersätze") @@ -126,6 +131,7 @@ class EinstellungenMixin: btns.pack(side="bottom", fill="x", pady=(12, 0)) ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4) ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right") + def dlg_katalog(self): d = tk.Toplevel(self) d.title("Leistungskatalog / Preise") @@ -256,6 +262,7 @@ class EinstellungenMixin: d.geometry(f"{win_w}x{int(win_h)}") d.minsize(min(win_w, 820), 420) info_lbl.configure(wraplength=win_w - 24) + def dlg_firma(self): d = tk.Toplevel(self) d.title("Firmendaten & Texte") @@ -326,6 +333,7 @@ class EinstellungenMixin: btns.grid(row=base + 4, column=0, columnspan=2, pady=(12, 0), sticky="e") ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4) ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right") + def dlg_nummer(self): d = tk.Toplevel(self) d.title("Rechnungsnummer") diff --git a/korrektur.py b/korrektur.py index a49cd1c..f6946a1 100644 --- a/korrektur.py +++ b/korrektur.py @@ -8,7 +8,13 @@ Zwei Wege, ein Popup-Bauplan: Nummer, Datum und Betraege (§ 31 Abs. 5 UStDV), es wird nur ein berichtigtes Blatt nachgereicht. Keine Nummer verbraucht. -Die alte Rechnung wird in beiden Faellen NIE angefasst (§ 146 Abs. 4 AO). +Die alte Rechnung wird in beiden Faellen NIE angefasst. § 146 Abs. 4 AO verbietet +genau genommen nur Aenderungen, bei denen der urspruengliche Inhalt nicht mehr +feststellbar ist - ein protokollierter Storno-Vermerk waere also erlaubt. Dieses +Programm verzichtet trotzdem darauf: ein unberuehrter Ursprungsbeleg ist im +Pruefungsfall die einfachere Geschichte. Korrigiert wird ueber ein eigenes +Dokument - so sieht es das Gesetz selbst vor (§ 14 Abs. 6 Nr. 5 UStG, +§ 31 Abs. 5 UStDV). """ import datetime @@ -20,7 +26,7 @@ import berechnung import config import pdf_renderer from bestand import kundendaten_ermitteln, naechste_freie_nummer, rechnungen_im_ordner -from helfer import nummer_normalisieren, safe_filename +from helfer import kunde_maengel, nummer_normalisieren, safe_filename from storno import storno_rechnung_bauen @@ -28,6 +34,7 @@ class KorrekturMixin: """Storno und Berichtigung. Erwartet die Felder der RechnungsApp.""" # ------------------------------------------------------------- Storno + def _storno_anzeigen(self): """Hinweiszeile und Sperre der Rechnungsnummer an den Zwischenspeicher anpassen.""" stand = self.storno_stand @@ -44,6 +51,7 @@ class KorrekturMixin: else: self.storno_hinweis.set("") self.nummer_entry.configure(state="normal") + def _berichtigung_starten(self, meta, out_dir): """Rechnung ins Formular holen; "PDF erstellen" schreibt das berichtigte Blatt.""" nummer = str(meta.get("rechnungsnummer", "")) @@ -61,6 +69,7 @@ class KorrekturMixin: "Jetzt die Kundenangaben richtigstellen und \"PDF erstellen\" drücken – " "es entsteht ein berichtigtes Blatt zu dieser Rechnung. Die alte Rechnung " f"bleibt liegen.{hinweis}") + def _storno_verwerfen(self, grund=""): """Storno rückgängig: PDF löschen, Zähler zurück, Zwischenspeicher leeren. @@ -75,22 +84,38 @@ class KorrekturMixin: os.remove(stand["pfad"]) print(f"[storno] verworfen, PDF gelöscht -> {stand['pfad']}") except OSError as e: # noqa: BLE001 - messagebox.showwarning("Storno-PDF blieb liegen", - f"Die Storno-PDF konnte nicht gelöscht werden:\n{e}") + # Abbrechen, NICHT weiterlaufen: sonst liegt das Storno noch im Ordner, + # der Zähler ist zurückgedreht und die Rechnung gilt als storniert, + # obwohl der Nutzer sie behalten wollte. + messagebox.showerror( + "Storno konnte nicht gelöscht werden", + f"Die Datei\n{stand['pfad']}\nließ sich nicht löschen:\n{e}\n\n" + "Ist sie noch in einem PDF-Betrachter offen? Bitte dort schließen " + "und noch einmal versuchen. Es wurde nichts verändert.") + return self.cfg["rechnung_jahr"] = stand["jahr_vorher"] self.cfg["rechnung_zaehler"] = stand["zaehler_vorher"] - config.save_config(self.cfg) + try: + config.save_config(self.cfg) + except OSError as e: # noqa: BLE001 + messagebox.showwarning( + "Zähler nicht gespeichert", + f"Die Einstellungen ließen sich nicht schreiben:\n{e}\n\n" + "Bitte im Menü unter „Rechnungsnummer…“ prüfen.") self.storno_stand = None self._storno_anzeigen() self._neue_nummer_vorschlagen() if grund: print(f"[storno] {grund}") + def dlg_storno(self): """Knopf „Storno“ – setzt den Weg auf Storno und öffnet das Popup.""" self._dlg_korrektur("storno") + def dlg_berichtigen(self): """Knopf „Berichtigen“ – setzt den Weg auf Berichtigung und öffnet das Popup.""" self._dlg_korrektur("berichtigung") + def _dlg_korrektur(self, modus): """Popup für beide Wege. Welcher gilt, steht schon beim Öffnen fest. @@ -201,6 +226,7 @@ class KorrekturMixin: command=anlegen).pack(side="right", padx=4) 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", "")) @@ -219,6 +245,21 @@ class KorrekturMixin: kunde.get("nachname", "")) if t) kunde_zeilen = [kunde.get("anrede", ""), namenszeile, kunde.get("zusatz", ""), kunde.get("strasse", ""), kunde.get("plzort", "")] + + # Der Storno ist der Beleg, der die Rechnung aufhebt - er geht an den Gast + # und braucht dieselben Angaben wie sie. Steht in der alten Rechnung nichts, + # kann das Programm nichts erfinden; dann muss der Nutzer entscheiden. + maengel = kunde_maengel(kunde) + if maengel: + if not messagebox.askyesno( + "Angaben zum Kunden unvollständig", + f"In Rechnung {alt_nummer} steht nicht alles:\n\n- " + + "\n- ".join(maengel) + + "\n\nDas Storno geht an den Gast und trägt dieselben Angaben. " + "Was fehlt, fehlt auch dort.\n\nTrotzdem erstellen?", + icon="warning", default="no"): + return + kopf = { "rechnungsnummer": nummer, "datum": heute, @@ -265,6 +306,7 @@ class KorrekturMixin: f"Stornorechnung {nummer} zu Rechnung {alt_nummer} liegt hier:\n{pfad}\n\n" f"Jetzt die berichtigte Rechnung {self.r_nummer.get()} anpassen und " f"\"PDF erstellen\" drücken.{hinweis}") + def _formular_aus_meta(self, meta): """Formular mit den Daten der alten Rechnung füllen. diff --git a/pdf_renderer.py b/pdf_renderer.py index ec5ea58..3b4d5f4 100644 --- a/pdf_renderer.py +++ b/pdf_renderer.py @@ -389,9 +389,12 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): "Bereits gezahlte Beträge werden erstattet oder mit der berichtigten " "Rechnung verrechnet.", st["dank"])) elif art == "berichtigung": - # Der Satz ist kein Beiwerk: ein zweites Blatt mit ausgewiesener USt, - # das nicht klar als Berichtigung erkennbar ist, kann die Steuer ein - # zweites Mal ausloesen (§ 14c UStG). + # Der Satz ist kein Beiwerk: mehrere Rechnungen ueber dieselbe Leistung + # ohne Kennzeichnung loesen die Steuer erneut aus (Abschn. 14c.1 Abs. 4 + # Satz 4 UStAE). Gegenueber Endverbrauchern entsteht sie zwar nicht + # (EuGH C-378/21, BMF v. 27.02.2024) - aber Firmengaeste und + # gewerbliche Dauercamper gibt es auch, und der Nachweis liegt beim + # Aussteller. Also bleibt der Satz drauf. block.append(Paragraph( f"Dieses Blatt berichtigt die Angaben zur Rechnung " f"{kopf.get('berichtigt_zu','')} vom {kopf.get('datum','')} nach " diff --git a/pruef_dialoge.py b/pruef_dialoge.py new file mode 100644 index 0000000..37621f8 --- /dev/null +++ b/pruef_dialoge.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +"""Pruefstand: geht jeder Dialog ueberhaupt auf? + +Klingt banal, ist es nicht: beim Aufteilen von app.py in Module sind zwei +Dialoge stumm kaputtgegangen (fehlende Importe), und kein anderer Pruefstand +hat es gemerkt, weil keiner sie oeffnet. Dieser hier oeffnet ALLE. + +Geprueft wird ausserdem, dass jeder Dialog auch wieder zugeht - ein modales +Fenster, das haengenbleibt, sperrt das Programm. +""" +import json +import os +import shutil +import sys +import tempfile +import tkinter as tk + +QUELLE = r"C:\claude\rechnungstool" +sys.path.insert(0, QUELLE) + +tmp = tempfile.mkdtemp(prefix="dialog_pruef_") +ordner = os.path.join(tmp, "rechnungen") +os.makedirs(ordner) + +import config # noqa: E402 +shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json")) +config.CONFIG_PATH = os.path.join(tmp, "config.json") +roh = json.load(open(config.CONFIG_PATH, encoding="utf-8")) +roh["output_dir"] = ordner +json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False) + +import app as A # noqa: E402 +from tkinter import messagebox # noqa: E402 + +messagebox.showinfo = lambda *a, **k: None +messagebox.showwarning = lambda *a, **k: None +messagebox.showerror = lambda *a, **k: None +messagebox.askyesno = lambda *a, **k: False + +fehler = 0 +f = A.RechnungsApp() +f.update() + +# Damit "Storno"/"Berichtigen" nicht an "nichts zu korrigieren" scheitern: +# eine echte Rechnung in den Wegwerf-Ordner legen. +f.k_vorname.set("Anna") +f.k_name.set("Beispiel") +f.k_strasse.set("Musterweg 1") +f.k_plzort.set("12345 Musterstadt") +f.anzahl_vars[sorted(f.anzahl_vars)[0]].set("2") +f.update() +f.pdf_erstellen() + +DIALOGE = ["dlg_steuern", "dlg_katalog", "dlg_firma", "dlg_nummer", + "dlg_storno", "dlg_berichtigen"] + +for name in DIALOGE: + offen_vorher = [w for w in f.winfo_children() if isinstance(w, tk.Toplevel)] + try: + getattr(f, name)() + f.update() + except Exception as e: # noqa: BLE001 - genau das wollen wir sehen + print(f"ABSTURZ | {name}: {type(e).__name__}: {e}") + fehler += 1 + continue + neu = [w for w in f.winfo_children() + if isinstance(w, tk.Toplevel) and w not in offen_vorher] + if not neu: + print(f"FEHLER | {name}: kein Fenster aufgegangen") + fehler += 1 + continue + titel = neu[-1].title() + for w in neu: + w.grab_release() + w.destroy() + f.update() + print(f" OK | {name} -> „{titel}“") + +# Der Speicherort-Dialog oeffnet einen Systemdialog - nur die Vorstufe pruefen +try: + f._frage_speicherort_falls_noetig() + f.update() + print(" OK | _frage_speicherort_falls_noetig") +except Exception as e: # noqa: BLE001 + print(f"ABSTURZ | _frage_speicherort_falls_noetig: {type(e).__name__}: {e}") + fehler += 1 + +f.destroy() +print("---") +print("alles gruen" if not fehler else f"{fehler} Fehler") +sys.exit(1 if fehler else 0) diff --git a/pruef_kernregeln.py b/pruef_kernregeln.py new file mode 100644 index 0000000..8d50a5c --- /dev/null +++ b/pruef_kernregeln.py @@ -0,0 +1,291 @@ +# -*- coding: utf-8 -*- +"""Pruefstand fuer die ZWEI Regeln, um die es dem Programm eigentlich geht: + + 1. Eine Rechnungsnummer wird nie zweimal vergeben. + 2. Eine ausgestellte Rechnung wird nie veraendert und nie geloescht. + +Warum es diesen Pruefstand gibt: pruef_storno.py lief 100x gruen und pruefte +trotzdem keine der beiden Regeln - er ging immer nur den gluecklichen Weg mit +sauber hochgezaehltem Zaehler. Ein Mutationstest hat gezeigt, dass man BEIDE +Sperren gegen die doppelte Nummer ausbauen kann, ohne dass er rot wird. + +Darum hier: + - Der Bestand wird ueber PRUEFSUMMEN verglichen, nicht ueber os.path.exists. + Eine Datei, die noch da ist, kann trotzdem veraendert worden sein. + - Die Nummer wird von HAND ins Feld getippt, so wie es der Nutzer tut. + - Unbekannte Dialoge lassen den Lauf scheitern, statt still "Nein" zu sagen. +""" +import hashlib +import json +import os +import shutil +import sys +import tempfile + +QUELLE = r"C:\claude\rechnungstool" +sys.path.insert(0, QUELLE) + +tmp = tempfile.mkdtemp(prefix="kern_pruef_") +ordner = os.path.join(tmp, "rechnungen") +os.makedirs(ordner) + +import config # noqa: E402 +shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json")) +config.CONFIG_PATH = os.path.join(tmp, "config.json") +roh = json.load(open(config.CONFIG_PATH, encoding="utf-8")) +roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0 +roh["output_dir"] = ordner +json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False) + +import app as A # noqa: E402 +import bestand as BE # noqa: E402 +from tkinter import messagebox # noqa: E402 + +fehler = [] + + +def pruefe(bedingung, text): + print((" OK | " if bedingung else "FEHLER | ") + text) + if not bedingung: + fehler.append(text) + + +# ---- Dialoge: jede Frage muss angemeldet sein -------------------------------- +antworten = {} +gefragt = [] +meldungen = [] + + +def askyesno(titel, text="", **kw): + gefragt.append(titel) + for schluessel, wert in antworten.items(): + if schluessel in titel: + return wert + # Kein stilles "Nein": sonst haelt der Pruefstand einen abgebrochenen + # Ablauf faelschlich fuer ein Ergebnis. + raise AssertionError(f"unangemeldeter Dialog: {titel!r} — {text[:80]!r}") + + +messagebox.askyesno = askyesno +messagebox.showinfo = lambda *a, **k: meldungen.append(("info", a[0] if a else "")) +messagebox.showwarning = lambda *a, **k: meldungen.append(("warnung", a[0] if a else "")) +messagebox.showerror = lambda *a, **k: meldungen.append(("fehler", a[0] if a else "")) + + +def bestand_lesen(): + """Pruefsumme je PDF im Ordner - erkennt auch stille Aenderungen am Inhalt.""" + stand = {} + for name in sorted(os.listdir(ordner)): + if name.lower().endswith(".pdf"): + with open(os.path.join(ordner, name), "rb") as f: + stand[name] = hashlib.sha256(f.read()).hexdigest() + return stand + + +def unveraendert(vorher, ausser=()): + """Alle Dateien aus sind byteweise unveraendert (bis auf ).""" + jetzt = bestand_lesen() + for name, summe in vorher.items(): + if name in ausser: + continue + if jetzt.get(name) != summe: + return False, (f"{name} " + ("ist weg" if name not in jetzt else "wurde verändert")) + return True, "" + + +def kunde_setzen(vor, nach, tage="2"): + f.k_vorname.set(vor) + f.k_name.set(nach) + f.k_strasse.set("Musterweg 1") + f.k_plzort.set("12345 Musterstadt") + f.anzahl_vars[erste].set(tage) + f.update() + + +f = A.RechnungsApp() +f.update() +f.cfg["output_dir"] = ordner +erste = sorted(f.anzahl_vars)[0] +antworten["Fertig"] = False # "jetzt oeffnen?" immer nein + +# ---- Ausgangslage: zwei echte Rechnungen ------------------------------------ +kunde_setzen("Anna", "Beispiel", "4") +f.pdf_erstellen() +kunde_setzen("Bernd", "Zweit", "3") +f.pdf_erstellen() +pruefe(sorted(bestand_lesen()) == ["Rechnung_2026-001_Beispiel.pdf", + "Rechnung_2026-002_Zweit.pdf"], + f"zwei Rechnungen angelegt ({sorted(bestand_lesen())})") + +# ============================================================================= +# REGEL 1: eine Nummer nie zweimal - auch nicht, wenn sie von Hand kommt +# ============================================================================= +print("--- Regel 1: Nummer von Hand auf eine vergebene setzen ---") +vorher = bestand_lesen() +gefragt.clear() +antworten["Rechnungsnummer schon vergeben"] = True # "naechste freie nehmen?" +kunde_setzen("Carla", "Dritt", "1") +f.r_nummer.set("2026-001") # genau das tut der Nutzer von Hand +f.update() +f.pdf_erstellen() + +pruefe(any("schon vergeben" in t for t in gefragt), + f"Programm meldet die vergebene Nummer ({gefragt})") +ok, warum = unveraendert(vorher) +pruefe(ok, f"die beiden alten Rechnungen sind byteweise unverändert ({warum})") +pruefe(os.path.exists(os.path.join(ordner, "Rechnung_2026-003_Dritt.pdf")), + "die neue Rechnung bekam die nächste FREIE Nummer 2026-003") +pruefe(f.r_nummer.get() != "2026-001", f"Feld zeigt nicht mehr die alte Nummer ({f.r_nummer.get()})") + +print("--- Regel 1: Nutzer lehnt die Ausweichnummer ab ---") +vorher = bestand_lesen() +antworten["Rechnungsnummer schon vergeben"] = False +kunde_setzen("Doris", "Vier", "1") +f.r_nummer.set("2026-002") +f.update() +f.pdf_erstellen() +ok, warum = unveraendert(vorher) +pruefe(ok and bestand_lesen() == vorher, + f"bei Nein wird gar nichts geschrieben und nichts verändert ({warum})") + +print("--- Regel 1: Zähler steht HINTER dem Ordner (config aus dem Backup) ---") +f.cfg["rechnung_zaehler"] = 0 # so sieht eine zurückgespielte config aus +f.berichtigung_stand = None +f.storno_stand = None +f._neue_nummer_vorschlagen() +pruefe(f.r_nummer.get() == "2026-004", + f"Vorschlag überspringt die belegten Nummern ({f.r_nummer.get()})") + +print("--- Regel 1: der Zähler darf nur vorwärts ---") +# Zweite Verteidigungslinie hinter dem Ordner-Abgleich: wer von Hand eine +# niedrigere freie Nummer nimmt, darf den Zähler nicht zurückdrehen - sonst +# schlägt das Programm die dazwischenliegenden Nummern ein zweites Mal vor. +kunde_setzen("Emil", "Hoch", "1") +f.r_nummer.set("2026-009") +f.update() +f.pdf_erstellen() +pruefe(f.cfg["rechnung_zaehler"] == 9, f"Zähler steht auf 9 ({f.cfg['rechnung_zaehler']})") +kunde_setzen("Frida", "Tief", "1") +f.r_nummer.set("2026-007") +f.update() +f.pdf_erstellen() +pruefe(os.path.exists(os.path.join(ordner, "Rechnung_2026-007_Tief.pdf")), + "die niedrigere freie Nummer 2026-007 wurde geschrieben") +pruefe(f.cfg["rechnung_zaehler"] == 9, + f"Zähler bleibt auf 9, fällt NICHT auf 7 zurück ({f.cfg['rechnung_zaehler']})") +f._neue_nummer_vorschlagen() +pruefe(f.r_nummer.get() == "2026-010", + f"nächster Vorschlag ist 2026-010, keine schon vergebene ({f.r_nummer.get()})") + +print("--- Regel 1: Jahreswechsel - beide Quellen müssen dasselbe sagen ---") +f.cfg["rechnung_jahr"] = 2025 +f.cfg["rechnung_zaehler"] = 87 +f._neue_nummer_vorschlagen() +vom_formular = f.r_nummer.get() +vom_storno = BE.naechste_freie_nummer(ordner, f.cfg) +pruefe(vom_formular == vom_storno, + f"Formular und Storno-Weg schlagen dieselbe Nummer vor ({vom_formular} / {vom_storno})") +pruefe(vom_formular.startswith("2026-"), + f"neues Jahr fängt im aktuellen Jahr an ({vom_formular})") +f.cfg["rechnung_jahr"], f.cfg["rechnung_zaehler"] = 2026, 3 +config.save_config(f.cfg) + +# ============================================================================= +# REGEL 2: ausgestellte Rechnung nie anfassen +# ============================================================================= +print("--- Regel 2: Storno über den Dialogknopf, nicht über die interne Methode ---") +vorher = bestand_lesen() +gefragt.clear() +f.dlg_storno() +f.update() +import tkinter as tk # noqa: E402 +from tkinter import ttk # noqa: E402 +dlg = [w for w in f.winfo_children() if isinstance(w, tk.Toplevel)][-1] +feld = knopf = None +for rahmen in dlg.winfo_children(): + for w in rahmen.winfo_children(): + if isinstance(w, ttk.Entry): + feld = w + elif isinstance(w, ttk.Frame): + for b in w.winfo_children(): + if isinstance(b, ttk.Button) and "Storno erstellen" in str(b.cget("text")): + knopf = b +pruefe(feld is not None and knopf is not None, "Storno-Dialog hat Feld und Knopf") +feld.delete(0, "end") +feld.insert(0, "2026-001") +knopf.invoke() # der ECHTE Weg, nicht _storno_anlegen() +f.update() + +pruefe(f.storno_stand is not None and f.storno_stand["zu"] == "2026-001", + f"der Knopf hat wirklich ein Storno gestartet ({f.storno_stand})") +storno_datei = os.path.basename(f.storno_stand["pfad"]) if f.storno_stand else "" +ok, warum = unveraendert(vorher) +pruefe(ok, f"alle bisherigen Rechnungen unverändert ({warum})") + +print("--- Regel 2: was steht auf dem Storno-Blatt? ---") +from pypdf import PdfReader # noqa: E402 +text = PdfReader(f.storno_stand["pfad"]).pages[0].extract_text() +pruefe("Stornorechnung" in text, "Überschrift „Stornorechnung“ steht drauf") +pruefe("2026-001" in text, "Bezug auf die alte Nummer steht drauf") +# pypdf legt jeden Tabellenwert in eine eigene Zeile - die Betraege stehen also +# NACH ihrer Beschriftung, nicht daneben. +zeilen = text.splitlines() + + +def betraege_nach(label, wieviele=6): + """Die Zahlenwerte, die auf die Zeile mit