# -*- 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 config import pdf_renderer 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 "" 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 # 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"] 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.") # 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: 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) 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: 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)) # 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, bg=self.col_field, fg=self.col_fg, selectbackground=self.col_sel, selectforeground=self.col_fg, relief="flat", font=("Consolas", 9)) 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) 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, self._belegte_nummern()) 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") # 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. 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 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 # 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 "", "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, "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") 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.") 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")) # 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)