From 790ac4c0f688f9d88ef02979ba6ef47580e958c3 Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Wed, 2 Sep 2026 19:06:45 +0200 Subject: [PATCH] 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)