# -*- 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 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 import os import tkinter as tk from tkinter import ttk, messagebox import berechnung import theme import config import pdf_renderer import berechnung from bestand import (kundendaten_ermitteln, naechste_freie_nummer, naechste_freie_nummer_nach, rechnungen_im_ordner) from helfer import kunde_maengel, nummer_normalisieren, parse_num, 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 ""), "vorgang": str(meta.get("vorgang") or nummer), "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 "" self.melden_info( "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 # 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. self.melden_fehler( "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"] try: config.save_config(self.cfg) except OSError as e: # noqa: BLE001 self.melden_warnung( "Zähler nicht gespeichert", f"Die Einstellungen ließen sich nicht schreiben:\n{e}\n\n" "Bitte im Menü unter „Rechnungsnummer…“ prüfen.") # Die Nummer ist wieder frei - auch im gemeinsamen Buch, sonst # ueberspringt das Rechnungstool sie fuer immer. if self.buch is not None and stand.get("nummer"): try: self.buch.entfernen(stand["nummer"]) except Exception as e: # noqa: BLE001 print("[nummernbuch] Storno nicht ausgetragen:", e) 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: self.melden_info( "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: self.melden_info( "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) stornos = {nummer_normalisieren(d.get("storno_zu")): d for d in alle if d.get("art") == "storno"} stornierte = set(stornos) # Zu welchen Rechnungen gibt es schon eine berichtigte Rechnung? ersetzte = {nummer_normalisieren(d.get("ersetzt")) for d in alle if d.get("art") == "korrektur"} 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: self.melden_warnung( "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)) # Wird nur sichtbar, wenn eine Nummer MEHRFACH im Ordner liegt. Genau das # ist der Altbestand: solange zwei Fenster gleichzeitig laufen konnten, # wurde dieselbe Nummer zweimal vergeben, teils als Windows-Kopie mit # "(1)" im Namen. Dann darf das Programm nicht raten, welche gemeint ist. self._auswahl = tk.Listbox(frm, height=4, exportselection=False, selectbackground=self.col_sel, selectforeground=self.col_fg, font=("Consolas", 9), **self.feld_optionen(einfuegemarke=False)) self._auswahl.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(8, 0)) self._auswahl.grid_remove() self._kandidaten = [] def kandidaten_zur_nummer(nr): return [x for x in alle if nummer_normalisieren(x.get("rechnungsnummer")) == nr and x.get("art") != "berichtigung"] def suche(): """(Rechnung, Meldung) zur eingetippten Nummer. Rechnung=None -> Meldung sagt warum.""" nr = nummer_normalisieren(ent.get()) if not nr: self._kandidaten = [] self._auswahl.grid_remove() return None, "Nummer eintragen – das Programm sucht die Rechnung." gefunden = kandidaten_zur_nummer(nr) if not gefunden: self._kandidaten = [] self._auswahl.grid_remove() return None, f"Zu {nr} liegt in\n{out_dir}\nkeine Rechnung." # Mehrere Belege unter derselben Nummer -> auswählen lassen. if len(gefunden) > 1: if gefunden != self._kandidaten: self._kandidaten = gefunden self._auswahl.delete(0, "end") for x in gefunden: self._auswahl.insert("end", f"{x.get('datum',''):11s} {str(x.get('nachname') or '?'):16s} " f"{berechnung.eur(x.get('gesamt') or 0):>11s} " f"{os.path.basename(x.get('_pfad',''))}") self._auswahl.grid() self._auswahl.selection_clear(0, "end") auswahl = self._auswahl.curselection() if not auswahl: return None, (f"⚠ Die Nummer {nr} liegt {len(gefunden)}× im Ordner – " "das darf es nicht geben.\nBitte unten auswählen, " "welcher Beleg gemeint ist.") treffer = gefunden[auswahl[0]] else: self._kandidaten = [] self._auswahl.grid_remove() treffer = gefunden[0] if treffer.get("art") == "storno": return None, f"{nr} ist selbst eine Stornorechnung." nr_norm = nummer_normalisieren(treffer.get("rechnungsnummer")) if nr_norm in stornierte: st = stornos.get(nr_norm) or {} folgt = st.get("korrektur_nummer") or "" if nr_norm in ersetzte or not folgt: return None, (f"{nr} wurde bereits mit {st.get('rechnungsnummer','?')} " "storniert – ein zweites Mal geht nicht.") # Auf dem Storno steht, welche Nummer die berichtigte Rechnung # bekommt. Damit braucht es keinen gemerkten Zustand: fehlt sie # noch, wird sie einfach unter dieser Nummer geschrieben. return None, (f"{nr} ist mit {st.get('rechnungsnummer','?')} storniert. " f"Die berichtigte Rechnung gehört unter die Nummer {folgt} – " "die steht auch auf dem Storno-Blatt.") gesamt = float(treffer.get("gesamt") or 0) mehrfach = (f"⚠ Achtung: die Nummer liegt {len(gefunden)}× im Ordner.\n" if len(gefunden) > 1 else "") return treffer, ( mehrfach + f"Rechnung {treffer.get('rechnungsnummer')} vom {treffer.get('datum','')}" f" {treffer.get('nachname','')}" + (f" ({os.path.basename(treffer.get('_pfad',''))})" if mehrfach else "") + "\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) self._auswahl.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=4, 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) @staticmethod def _passt_zusammen(alt, neu): """Passt die bleibende Rechnung inhaltlich zu der, die storniert wird? Die Nummer allein sagt nichts - sie koennte auf einen ganz anderen Gast zeigen. Verglichen werden Name, Zeitraum und Betrag, und zwar getrennt, damit die Meldung sagt, WAS abweicht. Gibt (schwer, leicht) zurueck: schwer = darf nicht durchgehen (anderer Gast), leicht = Hinweise, die der Nutzer bestaetigen muss. """ def wort(x): return " ".join(str(x or "").lower().split()) schwer, leicht = [], [] if wort(alt.get("nachname")) != wort(neu.get("nachname")): schwer.append(f"anderer Gast: {alt.get('nachname') or '?'} / " f"{neu.get('nachname') or '?'}") for feld, name in (("anreise", "Anreise"), ("abreise", "Abreise")): a, b = wort(alt.get(feld)), wort(neu.get(feld)) if a and b and a != b: leicht.append(f"{name} {alt.get(feld)} / {neu.get(feld)}") try: ga, gb = float(alt.get("gesamt") or 0), float(neu.get("gesamt") or 0) except (TypeError, ValueError): ga = gb = 0.0 if abs(ga - gb) > 0.005: leicht.append(f"Betrag {berechnung.eur(ga)} / {berechnung.eur(gb)}") return schwer, leicht def _storno_folge_waehlen(self, out_dir, alt_nummer, alt_meta=None): """Nach der Frage WELCHE Rechnung kommt die Frage, wie es weitergeht. Zwei Schritte in einem Fenster, so wie er es beschrieben hat: erst A oder B, und erst wenn B gedrueckt ist, kommt das Nummernfeld. A - es folgt eine neue Rechnung (der bisherige Weg) B - die Leistung ist schon abgerechnet -> Nummer der BLEIBENDEN C - nur Storno: es folgt nichts und es gibt keine andere Rechnung Sein Fall: mit dem alten Programm wurde bei einem Fehler einfach eine NEUE Rechnung geschrieben. Jetzt liegt dieselbe Leistung mehrfach im Ordner, und die Ueberzaehligen muessen auf die Rechnung verweisen, die BLEIBT - sonst wartet der Gast auf eine Rechnung, die nie kommt. C kam am 04.09.2026 dazu (*"des wegen sollte man auch eine version c mit only storno machen weil das gibt es auch"*): die Leistung wird gar nicht abgerechnet - Buchung geplatzt, Rechnung komplett irrtuemlich. Ohne C muesste man A waehlen und die versprochene neue Rechnung bliebe ewig offen, oder B und muesste auf eine Rechnung verweisen, die es nicht gibt. Rueckgabe: ("neu", None) | ("vorhanden", "2026-338") | ("nur", None) | None (Abbruch). """ # Im Testlauf klickt niemand: dann gilt der uebliche Weg. Der Pruefstand, # der GENAU DIESES Fenster prueft, setzt _storno_dialog_zeigen. from app import TESTLAUF if TESTLAUF and not getattr(self, "_storno_dialog_zeigen", False): return getattr(self, "_folgewahl", ("neu", None)) d = tk.Toplevel(self) d.title("Wie geht es nach dem Storno weiter?") d.transient(self) d.resizable(False, False) self._style_dialog(d) wahl = {"art": None, "nummer": ""} gefunden = {"meta": None, "schwer": [], "leicht": []} eingabe = tk.StringVar() rahmen = ttk.Frame(d, padding=18) rahmen.pack(fill="both", expand=True) ttk.Label(rahmen, text=f"Storno zu Rechnung {alt_nummer}", font=(theme.SCHRIFT_NAME, 13, "bold")).pack(anchor="w") # ---- Schritt 1: A oder B ------------------------------------------ schritt1 = ttk.Frame(rahmen) schritt1.pack(fill="x", pady=(8, 0)) ttk.Label(schritt1, wraplength=620, justify="left", text=( "Auf dem Storno-Blatt muss stehen, wie die Leistung stattdessen abgerechnet " "wird – sonst weiß der Gast nicht, woran er ist.")).pack(anchor="w", pady=(0, 12)) ttk.Label(schritt1, text="A – es folgt eine neue Rechnung", font=(theme.SCHRIFT_NAME, 10, "bold")).pack(anchor="w") ttk.Label(schritt1, wraplength=620, justify="left", text=( "Der übliche Weg: die Rechnung war falsch und wird nach dem Storno richtig neu " "geschrieben. Das Formular bleibt danach offen.")).pack(anchor="w", pady=(2, 10)) ttk.Label(schritt1, text="B – die Leistung ist schon abgerechnet", font=(theme.SCHRIFT_NAME, 10, "bold")).pack(anchor="w") ttk.Label(schritt1, wraplength=620, justify="left", text=( "Für Doppelte: derselbe Aufenthalt steht mehrfach im Ordner. Im nächsten Schritt " "trägst du die Nummer der Rechnung ein, die bleibt.")).pack(anchor="w", pady=(2, 10)) ttk.Label(schritt1, text="C – nur Storno", font=(theme.SCHRIFT_NAME, 10, "bold")).pack(anchor="w") ttk.Label(schritt1, wraplength=620, justify="left", text=( "Die Leistung wird gar nicht abgerechnet: Buchung geplatzt oder die Rechnung " "war komplett irrtümlich. Es folgt keine neue Rechnung und es gibt auch keine " "andere, auf die verwiesen wird.")).pack(anchor="w", pady=(2, 0)) # ---- Schritt 2: die Nummer (erst nach B) -------------------------- schritt2 = ttk.Frame(rahmen) ttk.Label(schritt2, wraplength=620, justify="left", text=( "Welche Rechnung bleibt? Sie muss im Rechnungsordner liegen und denselben " "Aufenthalt abrechnen – das Programm sieht nach.")).pack(anchor="w", pady=(8, 10)) zeile = ttk.Frame(schritt2) zeile.pack(anchor="w") ttk.Label(zeile, text="Bleibende Rechnung:").pack(side="left") feld = ttk.Entry(zeile, textvariable=eingabe, width=16, justify="center") feld.pack(side="left", padx=(8, 0)) stand = tk.Label(schritt2, text="", wraplength=620, justify="left", font=(theme.SCHRIFT_NAME, 9)) stand.pack(anchor="w", pady=(8, 0)) def suchen(*_a): """Gibt es die Nummer - und passt der INHALT? Antwort aus dem Ordner.""" nr = nummer_normalisieren(eingabe.get()) gefunden["meta"], gefunden["schwer"], gefunden["leicht"] = None, [], [] if not nr: stand.configure(text="", fg=self.col_muted) return None if nr == alt_nummer: stand.configure(text="Das ist die Rechnung, die gerade storniert wird.", fg="#a00000") return None treffer = [m for m in rechnungen_im_ordner(out_dir) if str(m.get("rechnungsnummer")) == nr and m.get("art") != "storno"] if not treffer: stand.configure(text=f"Zu {nr} liegt keine Rechnung im Ordner.", fg="#a00000") return None m = treffer[0] wer = " ".join(t for t in (str(m.get("vorname") or ""), str(m.get("nachname") or "")) if t) grund = (f"{nr} vom {m.get('datum','')} – {wer or 'ohne Namen'}, " f"{berechnung.eur(m.get('gesamt') or 0)}") schwer, leicht = self._passt_zusammen(alt_meta or {}, m) gefunden["meta"], gefunden["schwer"], gefunden["leicht"] = m, schwer, leicht if schwer: stand.configure(text=grund + " — " + "; ".join(schwer) + ". So kann das Storno nicht darauf verweisen.", fg="#a00000") return None if leicht: stand.configure(text=grund + " — weicht ab: " + "; ".join(leicht), fg="#a06000") return nr stand.configure(text=grund + " — gleicher Gast, gleicher Zeitraum, gleicher Betrag.", fg="#1f5c3d") return nr eingabe.trace_add("write", suchen) # ---- Knopfleiste, wechselt mit dem Schritt ------------------------ knoepfe = ttk.Frame(rahmen) knoepfe.pack(fill="x", pady=(16, 0)) def nimm_neu(): wahl["art"] = "neu" d.destroy() def nimm_nur(): wahl["art"] = "nur" d.destroy() def uebernehmen(): nr = suchen() if not nr: if not eingabe.get().strip(): stand.configure(text="Bitte die Nummer der bleibenden Rechnung eintragen.", fg="#a00000") return if gefunden["leicht"] and not self.frage( "Weicht ab – trotzdem?", f"Rechnung {nr} weicht ab: " + "; ".join(gefunden["leicht"]) + ". Das Storno würde trotzdem darauf verweisen. Ist das dieselbe " "Leistung?", icon="warning", default="no", parent=d): return wahl["art"], wahl["nummer"] = "vorhanden", nr d.destroy() def zu_schritt2(): schritt1.pack_forget() schritt2.pack(fill="x") for w in knoepfe.winfo_children(): w.destroy() ttk.Button(knoepfe, text="Übernehmen", width=16, command=uebernehmen).pack(side="right") ttk.Button(knoepfe, text="Zurück", width=12, command=zu_schritt1).pack(side="left") ttk.Button(knoepfe, text="Abbrechen", width=14, command=d.destroy).pack(side="right", padx=(0, 8)) d.update_idletasks() feld.focus_set() def zu_schritt1(): schritt2.pack_forget() schritt1.pack(fill="x", pady=(8, 0)) for w in knoepfe.winfo_children(): w.destroy() ttk.Button(knoepfe, text="A: neue Rechnung folgt", width=24, command=nimm_neu).pack(side="left") ttk.Button(knoepfe, text="B: schon abgerechnet", width=22, command=zu_schritt2).pack(side="left", padx=8) ttk.Button(knoepfe, text="C: nur Storno", width=16, command=nimm_nur).pack(side="left") ttk.Button(knoepfe, text="Abbrechen", width=14, command=d.destroy).pack(side="right") zu_schritt1() d.protocol("WM_DELETE_WINDOW", d.destroy) d.bind("", lambda _e: d.destroy()) d.update_idletasks() theme.mittig(d, self) d.grab_set() self.wait_window(d) if wahl["art"] == "vorhanden": return "vorhanden", wahl["nummer"] if wahl["art"] in ("neu", "nur"): return wahl["art"], None return None 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", "")) # Zuerst die Frage, die frueher gefehlt hat: kommt eine neue Rechnung, oder # ist die Leistung schon unter einer anderen Nummer abgerechnet? Beim # alten Programm wurde bei einem Fehler einfach neu geschrieben - deshalb # liegt dieselbe Leistung mehrfach im Ordner, und die Ueberzaehligen # muessen auf die BLEIBENDE Rechnung verweisen. weiter = self._storno_folge_waehlen(out_dir, alt_nummer, meta) if weiter is None: return art_folge, vorhandene = weiter nummer = naechste_freie_nummer(out_dir, self.cfg, self._belegte_nummern()) if not nummer: self.melden_fehler("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") # Welche Nummer die berichtigte Rechnung bekommt, steht schon jetzt fest - # die naechste freie NACH dem Storno. Sie kommt aufs Storno-Blatt, damit # die Kette aus dem Beleg allein lesbar ist und kein Zustand gemerkt # werden muss: alte Nummer -> Storno -> neue Nummer, alles auf einem Blatt. if art_folge == "vorhanden": # Es kommt keine neue Rechnung - das Storno verweist auf die, # die bleibt. Es wird also auch keine Nummer dafuer reserviert. folge_nummer = vorhandene elif art_folge == "nur": # Nur Storno: es folgt nichts, es gibt auch nichts, worauf zu # verweisen waere. Keine Nummer reservieren - sonst reisst die # fortlaufende Nummernfolge eine Luecke (Paragraph 14 Abs. 4 Nr. 4 UStG). folge_nummer = "" else: folge_nummer = naechste_freie_nummer_nach(out_dir, self.cfg, nummer, self._belegte_nummern()) # 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", "")] # 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 self.frage( "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 # Der Storno ist das Spiegelbild der Rechnung - auch bei den # UEBERNACHTUNGEN. Stand hier vorher nichts, blieben die Naechte der # aufgehobenen Rechnung im Steuerjournal stehen: die Betraege kuerzten # sich, die Zahl der Uebernachtungen nicht, und das Amt bekam sie # doppelt gemeldet (4 - 0 + 4 = 8 statt 4). alt_naechte = int(parse_num(str(meta.get("naechte") or 0)) or 0) kopf = { "rechnungsnummer": nummer, "datum": heute, "anreise": "", "abreise": "", "naechte": -abs(alt_naechte) if alt_naechte else "", "kunde_zeilen": kunde_zeilen, "nachname": kunde.get("nachname", ""), "vorname": kunde.get("vorname", ""), "art": "storno", # Vorgang der alten Rechnung uebernehmen, nicht deren Nummer: wird eine # schon berichtigte Rechnung noch einmal storniert, bleibt die ganze # Kette unter EINEM Schluessel. "vorgang": str(meta.get("vorgang") or alt_nummer), "storno_zu": alt_nummer, # alte Nummer -> diese Nummer. Steht auf dem Blatt, damit die Kette # aus dem Storno allein lesbar ist. "korrektur_nummer": folge_nummer or "", # Sagt dem Blatt, ob die Nummer eine BLEIBENDE Rechnung meint # ("bereits abgerechnet mit") oder eine, die noch kommt. "ersatz_vorhanden": art_folge == "vorhanden", # Sagt dem Blatt: es kommt NICHTS nach. Ohne das stuende dort der # Satz von der "berichtigten Rechnung", die es nie geben wird. "nur_storno": art_folge == "nur", "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 self.melden_fehler("Fehler beim Erstellen", f"Storno-PDF konnte nicht erstellt werden:\n{e}") print("[storno] FEHLER:", e) return # Nur wenn eine neue Rechnung folgt, bleibt ein Vorgang offen. Bei # "schon abgerechnet" ist mit dem Storno alles getan. self.storno_stand = None if art_folge in ("vorhanden", "nur") else { "pfad": pfad, "nummer": nummer, "zu": alt_nummer, "vorgang": str(meta.get("vorgang") or 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}") # Der Storno hat eine eigene Nummer - die gehoert ins gemeinsame Buch, # sonst vergibt sie das andere Programm noch einmal, und das Journal # erfaehrt nicht, dass die Rechnung aufgehoben ist. self._ins_buch(nummer, kopf, r, pfad) self._buch_anzeigen() self._journal_benachrichtigen(f"Storno {nummer} zu Rechnung {alt_nummer} erstellt") if art_folge == "vorhanden": self._storno_anzeigen() self._neue_nummer_vorschlagen() if self.frage( "Storno erstellt", f"Storno {nummer} hebt Rechnung {alt_nummer} auf.\n\n" f"Die Leistung bleibt mit Rechnung {folge_nummer} abgerechnet – es " "kommt keine neue Rechnung dazu.\n\n" "Das Blatt gehört zum Gast.\n\nJetzt öffnen (zum Drucken)?"): self._oeffne_datei(pfad) return fehlend = self._formular_aus_meta(meta) self.r_nummer.set(folge_nummer 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.") self.melden_info( "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")) # Eine Montage-Rechnung gehoert in ihren Reiter: ihre Position 1 ist die # Monatsmiete, im Campingformular waere es Position 1 des Katalogs # (Wohnwagen pro Nacht) - der Storno haette stillschweigend etwas # anderes berichtigt. montage = str(meta.get("vorlage", "") or "") == "montage" try: self.blaetter.select(1 if montage else 0) except (AttributeError, tk.TclError): pass for nr, var in self.anzahl_vars.items(): var.set("") self.personen_vars[nr].set("") if montage: erste = (meta.get("positionen") or [{}])[0] self.m_monate.set(str(erste.get("anzahl", "1") or "1")) else: 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)