# -*- coding: utf-8 -*- """ Rechnungstool Campinghof Bartl - Desktop-GUI (Tkinter). Bedienung: 1. Kundenadresse + An-/Abreise eingeben 2. bei den passenden Leistungen Anzahl (und ggf. Personen) eintragen 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 os import sys import datetime import tkinter as tk 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 class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): def __init__(self): super().__init__() self.cfg = config.load_config() self.title("Rechnungstool - " + self.cfg["firma"].get("name", "Campinghof")) # Fensterhoehe an den Bildschirm anpassen, damit nichts unter den Rand rutscht hoehe = max(560, min(840, self.winfo_screenheight() - 90)) self.geometry(f"1060x{hoehe}") self.minsize(900, 540) 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 # 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() self._build_kopf() self._build_summen() # untere Leiste (Summen + Buttons) zuerst -> bleibt immer sichtbar self._build_positionen() # Positionsliste fuellt den Rest und scrollt 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) self.after(250, self._frage_speicherort_falls_noetig) # ---------------------------------------------------------------- Menue def _build_menu(self): # Vollstaendig eigene Menueleiste + eigene Dropdowns -> komplett theme-konform # (native tk-Menubar/Popups lassen sich unter Windows nicht einfaerben). self._open_pop = None self._menus = { "Datei": [ ("PDF erstellen", self.pdf_erstellen), ("Formular zurücksetzen", self.reset_formular), ("Ausgabeordner öffnen", self._ordner_oeffnen), ("-", None), ("Beenden", self._beenden), # fragt nach, wenn ein Storno offen ist ], "Einstellungen": [ ("Steuersätze…", self.dlg_steuern), ("Leistungskatalog / Preise…", self.dlg_katalog), ("Firmendaten, Logo & Texte…", self.dlg_firma), ("Rechnungsnummer…", self.dlg_nummer), ("Speicherort der Rechnungen…", self.dlg_speicherort), ], "Hilfe": [("Info", self._info)], } bar = tk.Frame(self, bg=self.col_bg) bar.pack(side="top", fill="x") for text, items in self._menus.items(): lbl = tk.Label(bar, text=text, bg=self.col_bg, fg=self.col_fg, padx=8, pady=3) lbl.pack(side="left") lbl.bind("", lambda e, w=lbl: w.configure(bg=self.col_sel)) lbl.bind("", lambda e, w=lbl: w.configure(bg=self.col_bg)) lbl.bind("", lambda e, b=lbl, it=items: self._menu_popup(b, it)) ttk.Separator(self, orient="horizontal").pack(side="top", fill="x") self.bind_all("", lambda e: self.pdf_erstellen()) def _menu_popup(self, anchor, items): if self._open_pop: # offenes Menu zu -> Toggle self._close_pop() return pop = tk.Toplevel(self) pop.overrideredirect(True) pop.configure(bg=self.col_line) # 1px Rahmen = col_line statt weiss inner = tk.Frame(pop, bg=self.col_field) inner.pack(padx=1, pady=1) for label, cmd in items: if label == "-": tk.Frame(inner, bg=self.col_line, height=1).pack(fill="x", pady=3) continue it = tk.Label(inner, text=label, bg=self.col_field, fg=self.col_fg, anchor="w", padx=16, pady=5) it.pack(fill="x") it.bind("", lambda e, w=it: w.configure(bg=self.col_sel)) it.bind("", lambda e, w=it: w.configure(bg=self.col_field)) it.bind("", lambda e, c=cmd: self._run_menu(c)) pop.update_idletasks() pop.geometry(f"+{anchor.winfo_rootx()}+{anchor.winfo_rooty() + anchor.winfo_height()}") self._open_pop = pop pop.grab_set() pop.bind("", self._maybe_close_pop, add="+") pop.bind("", lambda e: self._close_pop()) self._dark_titlebar(pop) def _maybe_close_pop(self, event): pop = self._open_pop if not pop: return if (event.x_root < pop.winfo_rootx() or event.x_root > pop.winfo_rootx() + pop.winfo_width() or event.y_root < pop.winfo_rooty() or event.y_root > pop.winfo_rooty() + pop.winfo_height()): self._close_pop() def _run_menu(self, cmd): self._close_pop() if cmd: cmd() def _close_pop(self): if getattr(self, "_open_pop", None): try: self._open_pop.grab_release() self._open_pop.destroy() except tk.TclError: pass self._open_pop = None # ---------------------------------------------------------------- Kopf def _build_kopf(self): top = ttk.Frame(self, padding=(12, 10, 12, 4)) top.pack(fill="x") # Kunde (links) kunde = ttk.LabelFrame(top, text="Rechnung an (Kunde)", padding=8) kunde.grid(row=0, column=0, sticky="nsew", padx=(0, 10)) self.k_anrede = tk.StringVar() self.k_vorname = tk.StringVar() self.k_name = tk.StringVar() # Nachname oder Firma – die Zuordnung im Steuerjournal self.k_zusatz = tk.StringVar() self.k_strasse = tk.StringVar() self.k_plzort = tk.StringVar() felder = [ ("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), ] for i, (lbl, var) in enumerate(felder): ttk.Label(kunde, text=lbl).grid(row=i, column=0, sticky="w", 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) info = ttk.LabelFrame(top, text="Rechnungsdaten", padding=8) info.grid(row=0, column=1, sticky="nsew") self.r_nummer = tk.StringVar() self.r_datum = tk.StringVar(value=datetime.date.today().strftime("%d.%m.%Y")) self.r_anreise = tk.StringVar() self.r_abreise = tk.StringVar() self.r_naechte = tk.StringVar(value="–") rows = [ ("Rechnungsnummer", self.r_nummer), ("Rechnungsdatum", self.r_datum), ("Anreise (TT.MM.JJJJ)", self.r_anreise), ("Abreise (TT.MM.JJJJ)", self.r_abreise), ] date_vars = (self.r_datum, self.r_anreise, self.r_abreise) for i, (lbl, var) in enumerate(rows): 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()) top.columnconfigure(0, weight=1) top.columnconfigure(1, weight=0) def _auto_datum_evt(self, event): """Punkte automatisch setzen, Cursor ans Ende. Nach Tag+Monat das aktuelle Jahr ergänzen (1206 -> 12.06.2026, Jahr aus der Systemuhr). Backspace/Pfeile lassen normales Editieren zu.""" if event.keysym in ("BackSpace", "Delete", "Left", "Right", "Home", "End", "Tab"): return e = event.widget s = e.get() d = "".join(c for c in s if c.isdigit())[:8] if len(d) < 2: out = d elif len(d) == 2: out = d + "." # Tag fertig -> Punkt elif len(d) < 4: out = f"{d[:2]}.{d[2:]}" elif len(d) == 4: out = f"{d[:2]}.{d[2:4]}.{datetime.date.today().year}" # Monat fertig -> Jahr automatisch else: out = f"{d[:2]}.{d[2:4]}.{d[4:]}" # eigenes Jahr getippt if out != s: e.delete(0, "end") e.insert(0, out) e.icursor("end") if len(d) == 4: # Tag+Monat fertig, Jahr automatisch -> direkt ins nächste Feld e.tk_focusNext().focus() def _update_naechte(self): a = parse_date(self.r_anreise.get()) b = parse_date(self.r_abreise.get()) if a and b and (b - a).days > 0: self.r_naechte.set(str((b - a).days)) else: self.r_naechte.set("–") # ---------------------------------------------------------- Positionen # Spaltendefinition – Reihenfolge wie in der Excel: # (Titel, Ausrichtung, Mindestbreite px). col 1 (Leistung) bekommt das Gewicht. COLS = [ ("Pos.", "center", 38), ("Leistung", "w", 330), ("USt %", "center", 50), ("Anzahl\nTage/Menge", "center", 80), ("Pers.", "center", 56), ("E.-Preis", "e", 72), ("Gesamt", "e", 92), ] def _build_positionen(self): wrap = ttk.LabelFrame(self, text="Leistungen – Anzahl (und ggf. Personen) eintragen", padding=(6, 4)) wrap.pack(side="top", fill="both", expand=True, padx=12, pady=4) canvas = tk.Canvas(wrap, highlightthickness=0, bg=self.col_bg) sb = ttk.Scrollbar(wrap, orient="vertical", command=canvas.yview) self.pos_frame = ttk.Frame(canvas) self._pos_win = canvas.create_window((0, 0), window=self.pos_frame, anchor="nw") self.pos_frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) # pos_frame auf Canvas-Breite ziehen (minus Luft rechts zum Scrollbalken) canvas.bind("", lambda e: canvas.itemconfig(self._pos_win, width=e.width - 18)) canvas.configure(yscrollcommand=sb.set) canvas.pack(side="left", fill="both", expand=True, pady=(4, 0)) sb.pack(side="right", fill="y") canvas.bind_all("", lambda e: canvas.yview_scroll(int(-e.delta / 120), "units")) # Feste Spaltenbreiten -> Kopf und Daten teilen sich EIN Grid und fluchten for i, (_, _, msize) in enumerate(self.COLS): self.pos_frame.columnconfigure(i, minsize=msize, weight=(1 if i == 1 else 0)) self._render_positionszeilen() def _render_positionszeilen(self): for w in self.pos_frame.winfo_children(): w.destroy() self.anzahl_vars.clear() self.personen_vars.clear() self.gesamt_lbls.clear() # Kopfzeile (gleiches Grid wie die Daten) for i, (title, anchor, _) in enumerate(self.COLS): ttk.Label(self.pos_frame, text=title, anchor=anchor, font=("Segoe UI", 9, "bold")).grid(row=0, column=i, padx=3, pady=(0, 3), sticky="ew") ttk.Separator(self.pos_frame, orient="horizontal").grid( row=1, column=0, columnspan=len(self.COLS), sticky="ew", pady=(0, 2)) for idx, pos in enumerate(self.cfg["katalog"]): r = idx + 2 nr = pos["nr"] av = tk.StringVar() pv = tk.StringVar() self.anzahl_vars[nr] = av self.personen_vars[nr] = pv av.trace_add("write", lambda *a: self.recalc()) pv.trace_add("write", lambda *a: self.recalc()) ttk.Label(self.pos_frame, text=str(nr), anchor="center").grid(row=r, column=0, padx=3, pady=2, sticky="ew") ttk.Label(self.pos_frame, text=pos["leistung"], anchor="w", wraplength=320, justify="left").grid(row=r, column=1, padx=3, pady=2, sticky="ew") ttk.Label(self.pos_frame, text=str(pos["satz"]), anchor="center").grid(row=r, column=2, padx=3, pady=2, sticky="ew") ttk.Entry(self.pos_frame, textvariable=av, width=8, justify="center").grid(row=r, column=3, padx=3, pady=2) # Personen-Feld NUR bei "pro Person"-Positionen anzeigen if pos["pro_person"]: ttk.Entry(self.pos_frame, textvariable=pv, width=6, justify="center").grid(row=r, column=4, padx=3, pady=2) ttk.Label(self.pos_frame, text=berechnung.eur_kurz(pos["preis"]), anchor="e").grid(row=r, column=5, padx=3, pady=2, sticky="ew") g = ttk.Label(self.pos_frame, text="0,00", anchor="e") g.grid(row=r, column=6, padx=3, pady=2, sticky="ew") self.gesamt_lbls[nr] = g # ------------------------------------------------------------- Summen def _build_summen(self): # Statuszeile ganz unten status = ttk.Frame(self, padding=(12, 2)) status.pack(side="bottom", fill="x") ttk.Label(status, text="powered by ravokk", foreground=self.col_muted, font=("Segoe UI", 8)).pack(side="right") f = ttk.Frame(self, padding=(12, 6, 12, 10)) f.pack(side="bottom", fill="x") ttk.Separator(self, orient="horizontal").pack(side="bottom", fill="x") # Summen werden in recalc() dynamisch befuellt (Netto -> USt je Satz -> Gesamt) self.summen_box = ttk.Frame(f) self.summen_box.pack(side="right") 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="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 def _eingaben(self): e = {} for nr, av in self.anzahl_vars.items(): e[nr] = {"anzahl": parse_num(av.get()), "personen": parse_num(self.personen_vars[nr].get())} return e def recalc(self): r = berechnung.berechne(self.cfg["katalog"], self._eingaben(), self.cfg) aktiv = {z.nr: z.brutto for z in r.zeilen} for nr, lbl in self.gesamt_lbls.items(): lbl.configure(text=berechnung.eur_kurz(aktiv.get(nr, 0.0))) # Summenzeilen dynamisch aufbauen: Rechnungsbetrag (brutto) -> darin enthaltene # USt je Satz -> [Beherbergungssteuer obendrauf] -> Gesamtbetrag. for w in self.summen_box.winfo_children(): w.destroy() rows = [("Nettobetrag:", berechnung.eur(r.netto_gesamt), False)] for b in r.bloecke: name = b.bezeichnung or "USt" rows.append((f"{name} {b.satz} %:", berechnung.eur(b.ust), False)) if r.aufschlaege: rows.append(("Zwischensumme:", berechnung.eur(r.brutto), False)) for a in r.aufschlaege: satz = berechnung.eur_kurz(a.satz).replace(",00", "") rows.append((f"zzgl. {a.bezeichnung or 'Aufschlag'} ({satz} %):", berechnung.eur(a.betrag), False)) rows.append(("Gesamtbetrag:", berechnung.eur(r.gesamt), True)) for i, (lbl, val, fett) in enumerate(rows): font = ("Segoe UI", 12, "bold") if fett else ("Segoe UI", 10) pady = (5, 0) if fett else (1, 0) ttk.Label(self.summen_box, text=lbl, font=font).grid(row=i, column=0, sticky="e", padx=6, pady=pady) vkw = {"font": font, "width": 14, "anchor": "e"} if fett: vkw["foreground"] = self.col_accent ttk.Label(self.summen_box, text=val, **vkw).grid(row=i, column=1, sticky="e", pady=pady) self._letzte_rechnung = r return r # ----------------------------------------------------------- Aktionen def _neue_nummer_vorschlagen(self): """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): """Beanstandungen an den Kundenfeldern; leere Liste = in Ordnung. 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())) 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() if not r.zeilen: messagebox.showwarning("Keine Positionen", "Es wurde keine Leistung mit Anzahl > 0 eingetragen.") return nummer = self.r_nummer.get().strip() if not nummer: messagebox.showwarning("Rechnungsnummer fehlt", "Bitte eine Rechnungsnummer eingeben.") return # Name und Anschrift des Kunden sind Pflichtangaben auf der Rechnung # (§ 14 Abs. 4 Nr. 1 UStG) - ohne sie wird nichts geschrieben. maengel = self.kunde_pruefen() if maengel: messagebox.showwarning( "Kundenangaben fehlen", "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 # nicht aus dem Adressblock raten muss. nachname = self.k_name.get().strip() vorname = self.k_vorname.get().strip() 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"), "anreise": self.r_anreise.get().strip(), "abreise": self.r_abreise.get().strip(), "naechte": self.r_naechte.get() if self.r_naechte.get() != "–" else "", "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["vorgang"] = stand.get("vorgang") or stand["zu"] kopf["ersetzt"] = stand["zu"] 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["vorgang"] = ber.get("vorgang") or ber["zu"] 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 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: 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()) pfad = os.path.join(out_dir, fname + ".pdf") try: # Ein PDF: Seite 1 = Kunden-Rechnung, Seite 2 = Beherbergungssteuer fuers Amt 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"PDF konnte nicht erstellt werden:\n{e}") print("[pdf] FEHLER:", e) return # Rechnungsnummer-Zaehler hochsetzen, falls Format JJJJ-NNN self._zaehler_aus_nummer_speichern(nummer) 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: jahr_s, z_s = nummer.split("-") 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 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("") self.r_datum.set(datetime.date.today().strftime("%d.%m.%Y")) for av in self.anzahl_vars.values(): av.set("") for pv in self.personen_vars.values(): pv.set("") self._neue_nummer_vorschlagen() self.recalc() def _ordner_oeffnen(self): self._oeffne_datei(config.ensure_output_dir(self.cfg)) def _oeffne_datei(self, pfad): try: if sys.platform.startswith("win"): os.startfile(pfad) # noqa: S606 elif sys.platform == "darwin": os.system(f'open "{pfad}"') else: os.system(f'xdg-open "{pfad}"') except Exception as e: # noqa: BLE001 messagebox.showinfo("Pfad", f"{pfad}\n\n(Konnte nicht automatisch geöffnet werden: {e})") 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()