# -*- 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". """ import json import os import sys import datetime import tkinter as tk from tkinter import ttk, messagebox, filedialog import config import berechnung import pdf_renderer def parse_num(s): """'12', '3,5' -> float; leer/ungueltig -> 0.0""" s = (s or "").strip().replace(",", ".") if not s: return 0.0 try: return float(s) except ValueError: return 0.0 def parse_date(s): """'28.01.2026' -> date; sonst None""" s = (s or "").strip() for fmt in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d"): try: return datetime.datetime.strptime(s, fmt).date() except ValueError: continue return None def safe_filename(s): keep = "-_.() " return "".join(c for c in s if c.isalnum() or c in keep).strip() or "Rechnung" def vorhandene_rechnungen(ordner, nummer): """Alle PDFs im Ordner, die schon zu dieser Rechnungsnummer gehoeren. Der Kundenname haengt mit im Dateinamen ("Rechnung_2026-013_Meier.pdf"), darum wird nur der Nummernteil verglichen - sonst rutscht dieselbe Nummer unter einem anderen Namen als zweite Datei durch. """ # Stornos heissen "Storno_2026-002_Meier.pdf" - sie verbrauchen dieselben # Nummern und muessen deshalb mitzaehlen, sonst wird eine Nummer zweimal # vergeben. koepfe = ("Rechnung_" + safe_filename(nummer), "Storno_" + safe_filename(nummer)) try: namen = os.listdir(ordner) except OSError: return [] treffer = [] for name in namen: if not name.lower().endswith(".pdf"): continue stamm = name[:-4] # exakt die Nummer oder Nummer + "_Kundenname" - "2026-130" faellt raus if any(stamm == k or stamm.startswith(k + "_") for k in koepfe): treffer.append(os.path.join(ordner, name)) return sorted(treffer) # Auswahl fuer das Anrede-Feld. Die Liste ist nicht bindend - das Feld bleibt # tippbar, damit Sonderfaelle nicht am Programm scheitern. ANREDEN = ["Herr", "Frau", "Herr und Frau", "Familie", "Firma", "Eheleute"] def nummer_normalisieren(s): """'2026-5', ' 2026-005 ' -> '2026-005'; alles andere kommt getrimmt zurueck. Damit findet die Suche die Rechnung auch, wenn die fuehrenden Nullen fehlen. """ s = str(s or "").strip() teile = s.split("-") if len(teile) == 2: try: return config.format_rechnungsnummer(int(teile[0]), int(teile[1])) except ValueError: pass return s def kenndaten_lesen(pfad): """Kenndaten (Feld /Subject) aus einer vom Tool erzeugten PDF; None wenn keine. Gelesen wird der Bestand, nicht das Formular - ein Storno muss genau das spiegeln, was rausgegangen ist. """ try: from pypdf import PdfReader roh = (PdfReader(pfad).metadata or {}).get("/Subject") or "" if not str(roh).strip().startswith("{"): return None daten = json.loads(str(roh)) except Exception as e: # noqa: BLE001 - fremde oder kaputte PDF print(f"[storno] {os.path.basename(pfad)}: keine lesbaren Kenndaten ({e})") return None if daten.get("quelle") != "rechnungstool" or not daten.get("rechnungsnummer"): return None daten["_pfad"] = pfad return daten def rechnungen_im_ordner(ordner): """Alle lesbaren Rechnungen des Ordners, neueste Nummer zuerst. Stornos und schon stornierte Rechnungen sind mit drin - welche davon stornierbar ist, entscheidet der Aufrufer. """ try: namen = sorted(os.listdir(ordner)) except OSError: return [] gefunden = [] for name in namen: # Kein Filter auf den Dateinamen: Stornos heissen "Storno_...", und ob # eine PDF von uns stammt, sagen ohnehin erst die Kenndaten. if not name.lower().endswith(".pdf"): continue daten = kenndaten_lesen(os.path.join(ordner, name)) if daten: gefunden.append(daten) gefunden.sort(key=lambda d: str(d.get("rechnungsnummer", "")), reverse=True) return gefunden def storno_rechnung_bauen(meta, cfg): """Baut aus den Kenndaten der alten Rechnung den spiegelbildlichen Storno. Alle Betraege negativ. Je USt-Satz eine Zeile - die einzelnen Leistungen der alten Rechnung werden NICHT wiederholt: aufgehoben wird der Beleg als Ganzes, und die Positionen stehen in aelteren PDFs gar nicht in den Kenndaten. """ r = berechnung.Rechnung() nummer = meta.get("rechnungsnummer", "") bloecke = meta.get("ust_bloecke") or [] if not bloecke: # v1-PDF ohne Aufteilung: alles auf den ermaessigten Satz, sonst faellt # die USt unter den Tisch. brutto = float(meta.get("zwischensumme") or 0) satz = int((cfg.get("steuersaetze") or [{"satz": 7}])[0].get("satz", 7)) netto = berechnung.runde(brutto * 100 / (100 + satz)) if satz else brutto bloecke = [{"satz": satz, "netto": netto, "ust": berechnung.runde(brutto - netto), "brutto": brutto}] saetze = {int(s.get("satz", -1)): s for s in cfg.get("steuersaetze", [])} for i, b in enumerate(bloecke, start=1): satz = int(float(b.get("satz", 0) or 0)) brutto = -abs(berechnung.runde(b.get("brutto") or 0)) netto = -abs(berechnung.runde(b.get("netto") or 0)) ust = -abs(berechnung.runde(b.get("ust") or 0)) r.zeilen.append(berechnung.PositionsZeile( nr=i, leistung=f"Storno der Rechnung {nummer} ({satz} % USt)", satz=satz, preis=brutto, anzahl=1, personen=0, pro_person=False, brutto=brutto, netto=netto)) info = saetze.get(satz, {}) r.bloecke.append(berechnung.Steuerblock( satz=satz, netto=netto, ust=ust, bezeichnung=str(info.get("bezeichnung", "") or "Mehrwertsteuer"), extra_blatt=bool(info.get("extra_blatt", False)))) r.netto_gesamt = berechnung.runde(sum(b.netto for b in r.bloecke)) r.ust_gesamt = berechnung.runde(sum(b.ust for b in r.bloecke)) r.brutto = berechnung.runde(r.netto_gesamt + r.ust_gesamt) # Beherbergungssteuer wird mitstorniert, sonst stimmt die Meldung ans Amt nicht. # Die Bezeichnung kommt aus dem Satz mit "extra_blatt" - genau dem, aus dem # berechne() den Aufschlag baut. steuer = berechnung.runde(meta.get("steuer_betrag") or 0) if steuer: extra = next((s for s in cfg.get("steuersaetze", []) if s.get("extra_blatt")), {}) r.aufschlaege.append(berechnung.Aufschlag( satz=float(meta.get("steuer_satz") or extra.get("satz", 0) or 0), bezeichnung=str(extra.get("bezeichnung", "") or "Beherbergungssteuer"), basis=r.brutto, betrag=-abs(steuer), extra_blatt=True)) r.gesamt = berechnung.runde(r.brutto + sum(a.betrag for a in r.aufschlaege)) return r def naechste_freie_nummer(ordner, cfg): """Erste Nummer JJJJ-NNN, zu der im Ordner noch kein PDF liegt. Gezaehlt wird ab dem zuletzt vergebenen Stand aus der config; im Ordner hoeher liegende Nummern werden dabei uebersprungen, weil sie nicht frei sind. """ jahr = cfg.get("rechnung_jahr", datetime.date.today().year) z = cfg.get("rechnung_zaehler", 0) for _ in range(1000): z += 1 kandidat = config.format_rechnungsnummer(jahr, z) if not vorhandene_rechnungen(ordner, kandidat): return kandidat return None def einzelinstanz_pruefen(): """Sperrt das Programm gegen ein zweites Fenster. Beide Fenster lesen beim Start denselben Zaehler aus der config und schlagen darum dieselbe Rechnungsnummer vor -> die Nummer wird doppelt vergeben. Darum: nur eine Instanz je Windows-Sitzung. Rueckgabe: Handle (offen halten, solange das Programm laeuft) oder False, wenn schon eine Instanz laeuft. """ if not sys.platform.startswith("win"): return None import ctypes from ctypes import wintypes ERROR_ALREADY_EXISTS = 183 k32 = ctypes.WinDLL("kernel32", use_last_error=True) k32.CreateMutexW.argtypes = [wintypes.LPVOID, wintypes.BOOL, wintypes.LPCWSTR] k32.CreateMutexW.restype = wintypes.HANDLE handle = k32.CreateMutexW(None, False, "RechnungstoolCampinghofBartl") if ctypes.get_last_error() == ERROR_ALREADY_EXISTS: return False return handle # Merkzettel mit der PID der laufenden Instanz - liegt neben der config.json. PID_DATEI = os.path.join(config.BASE_DIR, "rechnungstool.pid") def eigene_pid_merken(): """Schreibt die eigene PID, damit eine zweite Instanz das Fenster findet.""" try: with open(PID_DATEI, "w", encoding="ascii") as f: f.write(str(os.getpid())) except OSError: pass def laufende_pid_lesen(): """PID der laufenden Instanz aus dem Merkzettel; None wenn unlesbar.""" try: with open(PID_DATEI, "r", encoding="ascii") as f: return int(f.read().strip()) except (OSError, ValueError): return None def fenster_nach_vorn(pid): """Holt das Fenster des Prozesses nach vorn. True bei Erfolg. Gesucht wird ueber die PID, nicht ueber den Fenstertitel - der Titel haengt am Firmennamen aus der config und aendert sich mit ihm. """ if not pid or not sys.platform.startswith("win"): return False import ctypes from ctypes import wintypes SW_RESTORE = 9 u32 = ctypes.WinDLL("user32", use_last_error=True) u32.IsWindowVisible.argtypes = [wintypes.HWND] u32.IsIconic.argtypes = [wintypes.HWND] u32.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int] u32.SetForegroundWindow.argtypes = [wintypes.HWND] u32.GetWindowTextLengthW.argtypes = [wintypes.HWND] u32.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)] treffer = [] def besuch(hwnd, _lparam): fremd = wintypes.DWORD() u32.GetWindowThreadProcessId(hwnd, ctypes.byref(fremd)) # nur das echte Hauptfenster: sichtbar und mit Titel if fremd.value == pid and u32.IsWindowVisible(hwnd) and u32.GetWindowTextLengthW(hwnd): treffer.append(hwnd) return False return True CB = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) u32.EnumWindows(CB(besuch), 0) if not treffer: return False hwnd = treffer[0] if u32.IsIconic(hwnd): u32.ShowWindow(hwnd, SW_RESTORE) u32.SetForegroundWindow(hwnd) return True def windows_dark_mode(): """True, wenn Windows auf das dunkle App-Theme eingestellt ist.""" if not sys.platform.startswith("win"): return False try: import winreg with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize") as k: return winreg.QueryValueEx(k, "AppsUseLightTheme")[0] == 0 except OSError: return False class RechnungsApp(tk.Tk): 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 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() # 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) # ----------------------------------------------------------------- Theme def _apply_theme(self): """Farben passend zum Windows-Theme (hell/dunkel) setzen.""" dark = windows_dark_mode() self._dark_mode = dark if dark: self.col_bg = "#23272e" # Fenster-Hintergrund self.col_fg = "#e6e6e6" # Schrift self.col_field = "#2f343c" # Eingabefelder self.col_sel = "#3a4a44" # aktiv/hover self.col_accent = "#5fb98f" # Akzent (Gruen, hell) self.col_line = "#3a3f47" self.col_scroll = "#4a505a" # Scroll-Griff self.col_muted = "#8a929c" # gedaempfte Schrift (z.B. "powered by") else: self.col_bg = "#f3f3f3" self.col_fg = "#1a1a1a" self.col_field = "#ffffff" self.col_sel = "#cfe3d8" self.col_accent = "#1f5c3d" self.col_line = "#cccccc" self.col_scroll = "#b8b8b8" self.col_muted = "#777777" # gedaempfte Schrift (z.B. "powered by") self.configure(bg=self.col_bg) style = ttk.Style(self) try: style.theme_use("clam") except tk.TclError: pass bg, fg, field, sel, accent = (self.col_bg, self.col_fg, self.col_field, self.col_sel, self.col_accent) style.configure(".", background=bg, foreground=fg, fieldbackground=field, bordercolor=self.col_line, lightcolor=bg, darkcolor=bg) style.configure("TFrame", background=bg) style.configure("TLabel", background=bg, foreground=fg) style.configure("TLabelframe", background=bg, bordercolor=self.col_line) style.configure("TLabelframe.Label", background=bg, foreground=accent) style.configure("TButton", background=field, foreground=fg, bordercolor=self.col_line) style.map("TButton", background=[("active", sel)], foreground=[("active", fg)]) style.configure("Menu.TMenubutton", background=bg, foreground=fg, relief="flat", arrowcolor=bg, padding=(10, 3)) style.map("Menu.TMenubutton", background=[("active", sel)], foreground=[("active", fg)]) style.configure("TCheckbutton", background=bg, foreground=fg) style.map("TCheckbutton", background=[("active", bg)], foreground=[("active", fg)]) style.configure("TEntry", fieldbackground=field, foreground=fg, insertcolor=fg) # Bei state="readonly" zeichnet die Combobox ihren Text als MARKIERUNG, # sobald sie den Fokus hat - ohne diese beiden Farben steht der Wert # unsichtbar im Feld. style.configure("TCombobox", fieldbackground=field, foreground=fg, background=field, arrowcolor=fg, selectbackground=field, selectforeground=fg) style.map("TCombobox", fieldbackground=[("readonly", field)], foreground=[("readonly", fg)], background=[("readonly", field)], selectbackground=[("readonly", field)], selectforeground=[("readonly", fg)]) # Die aufgeklappte Liste ist ein klassisches tk-Listbox-Widget self.option_add("*TCombobox*Listbox.background", field) self.option_add("*TCombobox*Listbox.foreground", fg) self.option_add("*TCombobox*Listbox.selectBackground", sel) self.option_add("*TCombobox*Listbox.selectForeground", fg) for sbs in ("Vertical.TScrollbar", "Horizontal.TScrollbar", "TScrollbar"): style.configure(sbs, troughcolor=bg, background=self.col_scroll, bordercolor=bg, lightcolor=bg, darkcolor=bg, arrowcolor=fg, relief="flat", gripcount=0) style.map(sbs, background=[("active", accent)], troughcolor=[("disabled", bg)]) style.configure("TSeparator", background=self.col_line) # Klassische tk-Widgets (Canvas, Toplevel, Text) erben diese Defaults self.option_add("*background", bg) self.option_add("*foreground", fg) def _style_dialog(self, d): """Toplevel-Dialog an das Theme angleichen.""" d.configure(bg=self.col_bg) # erst nach dem Anzeigen setzen, sonst greift DWM noch nicht d.after(20, lambda: self._dark_titlebar(d)) def _dark_titlebar(self, win): """Windows-Titelleiste des Fensters dunkel machen (nur im Dark-Mode).""" if not (sys.platform.startswith("win") and getattr(self, "_dark_mode", False)): return try: import ctypes win.update_idletasks() hwnd = ctypes.windll.user32.GetParent(win.winfo_id()) val = ctypes.c_int(1) for attr in (20, 19): # DWMWA_USE_IMMERSIVE_DARK_MODE (Win11=20, frühes Win10=19) ctypes.windll.dwmapi.DwmSetWindowAttribute( hwnd, attr, ctypes.byref(val), ctypes.sizeof(val)) # Frame neu zeichnen lassen (ohne Flackern/Fokusverlust) SWP = 0x0001 | 0x0002 | 0x0004 | 0x0020 # NOSIZE|NOMOVE|NOZORDER|FRAMECHANGED ctypes.windll.user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP) except Exception as e: # noqa: BLE001 print("[theme] Titelleiste:", e) # ---------------------------------------------------------------- Menue def _build_menu(self): # 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.destroy), ], "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="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 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): jahr = self.cfg.get("rechnung_jahr", datetime.date.today().year) zaehler = self.cfg.get("rechnung_zaehler", 0) heute_jahr = datetime.date.today().year if heute_jahr != jahr: jahr, zaehler = heute_jahr, 0 self.r_nummer.set(config.format_rechnungsnummer(jahr, zaehler + 1)) # ------------------------------------------------------------- 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 = {nummer_normalisieren(d.get("storno_zu")) for d in alle if d.get("art") == "storno"} offen = [d for d in alle if d.get("art") != "storno" and nummer_normalisieren(d.get("rechnungsnummer")) not in stornierte] if not offen: messagebox.showwarning( "Nichts zu stornieren", f"Im Ordner\n{out_dir}\nliegt keine stornierbare Rechnung.") return d = tk.Toplevel(self) d.title("Rechnung stornieren") d.transient(self) d.grab_set() self._style_dialog(d) frm = ttk.Frame(d, padding=12) frm.pack(fill="both", expand=True) ttk.Label(frm, justify="left", 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. ent = ttk.Entry(frm, width=20) ent.grid(row=1, column=1, sticky="w", pady=3, padx=(8, 0)) ent.focus_set() v_info = tk.StringVar(value="Nummer eintragen – das Programm sucht die Rechnung.") ttk.Label(frm, textvariable=v_info, justify="left", wraplength=430).grid( row=2, column=0, columnspan=2, sticky="w", pady=(10, 0)) def suche(): """(Rechnung, Meldung) zur eingetippten Nummer. Rechnung=None -> Meldung sagt warum.""" nr = nummer_normalisieren(ent.get()) if not nr: return None, "Nummer eintragen – das Programm sucht die Rechnung." treffer = next((x for x in alle if nummer_normalisieren(x.get("rechnungsnummer")) == nr), None) if not treffer: return None, f"Zu {nr} liegt in\n{out_dir}\nkeine Rechnung." if treffer.get("art") == "storno": return None, f"{nr} ist selbst eine Stornorechnung." if nummer_normalisieren(treffer.get("rechnungsnummer")) in stornierte: return None, f"{nr} wurde bereits storniert – ein zweites Mal geht nicht." gesamt = float(treffer.get("gesamt") or 0) return treffer, ( f"Rechnung {treffer.get('rechnungsnummer')} vom {treffer.get('datum','')}" f" {treffer.get('nachname','')}\n" f"Rechnungsbetrag {berechnung.eur(treffer.get('zwischensumme') or 0)}, " f"Gesamt {berechnung.eur(gesamt)}\n" f"Der Storno lautet über {berechnung.eur(-abs(gesamt))}.") def vorschau(*_a): v_info.set(suche()[1]) ent.bind("", vorschau) def anlegen(*_a): meta, meldung = suche() if not meta: v_info.set(meldung) return d.destroy() self._storno_anlegen(meta, out_dir) ent.bind("", anlegen) 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: 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. fehlt = [name for name, var in (("Vorname", self.k_vorname), ("Nachname / Firma", self.k_name), ("Straße / Nr.", self.k_strasse), ("PLZ / Ort", self.k_plzort)) if not var.get().strip()] if fehlt: messagebox.showwarning( "Kundenangaben fehlen", "Diese Felder müssen ausgefüllt sein:\n\n- " + "\n- ".join(fehlt) + "\n\nName und vollständige Anschrift des Kunden gehören auf jede " "Rechnung (§ 14 Abs. 4 Nr. 1 UStG).") return # Auf der Rechnung stehen Vor- und Nachname wie gewohnt in einer Zeile; # der Nachname geht zusätzlich getrennt mit, damit das Steuerjournal ihn # 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["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()) 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 config.save_config(self.cfg) self._neue_nummer_vorschlagen() def reset_formular(self): 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 _frage_speicherort_falls_noetig(self): if (self.cfg.get("output_dir") or "").strip(): return vorschlag = config.default_output_dir() messagebox.showinfo( "Speicherort der Rechnungen", "Bitte wähle einen Ordner, in dem die fertigen Rechnungen (PDF) gespeichert werden.\n\n" f"Vorschlag: {vorschlag}\n\n(Lässt sich später unter Einstellungen ändern.)") d = filedialog.askdirectory(title="Ordner für die Rechnungen wählen", initialdir=os.path.expanduser("~")) self.cfg["output_dir"] = d if d else vorschlag config.save_config(self.cfg) config.ensure_output_dir(self.cfg) def dlg_speicherort(self): aktuell = config.ensure_output_dir(self.cfg) d = filedialog.askdirectory(title="Ordner für die Rechnungen wählen", initialdir=aktuell) if d: self.cfg["output_dir"] = d config.save_config(self.cfg) config.ensure_output_dir(self.cfg) messagebox.showinfo("Speicherort", f"Rechnungen werden gespeichert in:\n{d}") def _oeffne_datei(self, pfad): try: 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})") def _info(self): d = tk.Toplevel(self) d.title("Info") d.transient(self) d.resizable(False, False) self._style_dialog(d) frm = ttk.Frame(d, padding=18) frm.pack(fill="both", expand=True) ttk.Label(frm, text="Rechnungstool Campinghof Bartl", font=("Segoe UI", 11, "bold"), foreground=self.col_accent).pack(anchor="w") ttk.Label(frm, justify="left", text="Daten eingeben, PDF erstellen, drucken.\n" "Steuersätze, Preise und Firmendaten lassen sich\n" "im Menü Einstellungen anpassen.").pack(anchor="w", pady=(8, 12)) ttk.Label(frm, text="powered by ravokk", foreground=self.col_muted).pack(anchor="w") ttk.Button(frm, text="OK", command=d.destroy, width=10).pack(anchor="e", pady=(14, 0)) d.bind("", lambda e: d.destroy()) d.bind("", lambda e: d.destroy()) d.grab_set() # ====================================================== Einstellungen def dlg_steuern(self): d = tk.Toplevel(self) d.title("Steuersätze") d.transient(self) d.grab_set() self._style_dialog(d) d.geometry("620x470") frm = ttk.Frame(d, padding=12) frm.pack(fill="both", expand=True) ttk.Label(frm, wraplength=580, justify="left", font=("Segoe UI", 9, "bold"), text="Steuersätze: Satz % + Name. Die Katalog-Preise sind BRUTTO; die USt dieses " "Satzes wird herausgerechnet und als 'enthalten' ausgewiesen. Haken bei " "'Aufschlag': diese Steuer (z.B. Beherbergungssteuer) wird stattdessen auf den " "Rechnungsbetrag aufgeschlagen und direkt darunter ausgewiesen.").pack(anchor="w", pady=(0, 8)) liste = ttk.Frame(frm) liste.pack(fill="x") arbeit = [] for s in self.cfg.get("steuersaetze", []): arbeit.append({"satz": tk.StringVar(value=str(s.get("satz", ""))), "bez": tk.StringVar(value=s.get("bezeichnung", "")), "extra": tk.BooleanVar(value=bool(s.get("extra_blatt", False)))}) def render_saetze(): for w in liste.winfo_children(): w.destroy() for c, t in enumerate(["Satz %", "Bezeichnung (Name der Steuer)", "Aufschlag", ""]): ttk.Label(liste, text=t, font=("Segoe UI", 9, "bold")).grid( row=0, column=c, padx=4, pady=(0, 3), sticky="w") for i, row in enumerate(arbeit, start=1): ttk.Entry(liste, textvariable=row["satz"], width=8, justify="center").grid(row=i, column=0, padx=4, pady=2) ttk.Entry(liste, textvariable=row["bez"], width=32).grid(row=i, column=1, padx=4, pady=2, sticky="w") ttk.Checkbutton(liste, variable=row["extra"]).grid(row=i, column=2, padx=14, pady=2) ttk.Button(liste, text="✕", width=3, command=lambda r=row: (arbeit.remove(r), render_saetze())).grid(row=i, column=3, padx=4) def satz_hinzufuegen(): arbeit.append({"satz": tk.StringVar(value="0"), "bez": tk.StringVar(value="Mehrwertsteuer"), "extra": tk.BooleanVar(value=False)}) render_saetze() render_saetze() ttk.Button(frm, text="+ Steuersatz hinzufügen", command=satz_hinzufuegen).pack(anchor="w", pady=(8, 0)) def speichern(): neu = [] for row in arbeit: s = row["satz"].get().strip() if s == "": continue neu.append({"satz": int(parse_num(s)), "bezeichnung": row["bez"].get().strip() or "USt", "extra_blatt": bool(row["extra"].get())}) if not neu: messagebox.showwarning("Leer", "Mindestens ein Steuersatz ist nötig.", parent=d) return self.cfg["steuersaetze"] = neu config.save_config(self.cfg) self._render_positionszeilen() self.recalc() d.destroy() btns = ttk.Frame(frm) btns.pack(side="bottom", fill="x", pady=(12, 0)) ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4) ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right") def dlg_katalog(self): d = tk.Toplevel(self) d.title("Leistungskatalog / Preise") d.transient(self) d.grab_set() self._style_dialog(d) info_lbl = ttk.Label(d, padding=(10, 8), wraplength=900, justify="left", text="Leistungen bearbeiten: Bezeichnung, Steuersatz (Auswahl), Preis in € und ob " "pro Person gerechnet wird. Weitere Steuersätze legst du unter " "Einstellungen > Steuersätze an. Unten neue Zeilen anlegen oder mit dem X " "rechts eine Leistung entfernen.") info_lbl.pack(side="top", fill="x") # alle definierten Steuersaetze sind je Position waehlbar saetze_cfg = self.cfg.get("steuersaetze", []) def satz_label(satz): for s in saetze_cfg: if int(s.get("satz", -1)) == int(satz or 0): return f"{s['satz']} % – {s.get('bezeichnung', '')}".rstrip(" –") return f"{satz} %" def combo_werte(aktuell): opts = [f"{s['satz']} % – {s.get('bezeichnung', '')}".rstrip(" –") for s in saetze_cfg] al = satz_label(aktuell) if al not in opts: opts.append(al) return opts def satz_aus_label(lbl): try: return int(parse_num(str(lbl).split("%")[0])) except (ValueError, IndexError): return 0 # Arbeitskopie als Liste von Variablen-Dicts (erlaubt Hinzufuegen/Loeschen) arbeit = [] for pos in self.cfg["katalog"]: arbeit.append({ "leistung": tk.StringVar(value=pos["leistung"]), "satz": tk.StringVar(value=str(pos["satz"])), "preis": tk.StringVar(value=berechnung.eur_kurz(pos["preis"])), "pro_person": tk.BooleanVar(value=bool(pos["pro_person"])), }) def render(): for w in inner.winfo_children(): w.destroy() for c, t in enumerate(["Pos.", "Bezeichnung", "Steuersatz", "Preis €", "pro Person", ""]): ttk.Label(inner, text=t, font=("Segoe UI", 9, "bold")).grid( row=0, column=c, padx=4, pady=(0, 4), sticky="w") for i, row in enumerate(arbeit, start=1): ttk.Label(inner, text=str(i), width=4).grid(row=i, column=0, padx=4, pady=1) ttk.Entry(inner, textvariable=row["leistung"], width=64).grid(row=i, column=1, padx=4, pady=1, sticky="we") cb = ttk.Combobox(inner, width=22, state="readonly", values=combo_werte(row["satz"].get())) cb.set(satz_label(row["satz"].get())) cb.bind("<>", lambda ev, rr=row, cc=cb: rr["satz"].set(str(satz_aus_label(cc.get())))) cb.grid(row=i, column=2, padx=4, pady=1, sticky="w") ttk.Entry(inner, textvariable=row["preis"], width=10, justify="right").grid(row=i, column=3, padx=4, pady=1) ttk.Checkbutton(inner, variable=row["pro_person"]).grid(row=i, column=4, padx=4, pady=1) ttk.Button(inner, text="✕", width=3, command=lambda r=row: (arbeit.remove(r), render())).grid(row=i, column=5, padx=4, pady=1) def hinzufuegen(): arbeit.append({ "leistung": tk.StringVar(value="Neue Leistung"), "satz": tk.StringVar(value=str(saetze_cfg[0]["satz"]) if saetze_cfg else "19"), "preis": tk.StringVar(value="0,00"), "pro_person": tk.BooleanVar(value=False), }) render() canvas.update_idletasks() canvas.configure(scrollregion=canvas.bbox("all")) canvas.yview_moveto(1.0) def speichern(): neu = [] for i, row in enumerate(arbeit, start=1): name = row["leistung"].get().strip() if not name: continue # leere Zeilen verwerfen neu.append({ "nr": i, "leistung": name, "satz": int(parse_num(row["satz"].get())), "preis": parse_num(row["preis"].get()), "pro_person": bool(row["pro_person"].get()), }) if not neu: messagebox.showwarning("Leer", "Es muss mindestens eine Leistung übrig bleiben.", parent=d) return self.cfg["katalog"] = neu config.save_config(self.cfg) self._render_positionszeilen() self.recalc() d.destroy() # Buttonleiste unten zuerst -> bleibt immer sichtbar btns = ttk.Frame(d, padding=8) btns.pack(side="bottom", fill="x") ttk.Button(btns, text="+ Position hinzufügen", command=hinzufuegen).pack(side="left", padx=4) ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4) ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right", padx=4) # Scrollbereich body = ttk.Frame(d) body.pack(side="top", fill="both", expand=True) canvas = tk.Canvas(body, highlightthickness=0, bg=self.col_bg) sb = ttk.Scrollbar(body, orient="vertical", command=canvas.yview) inner = ttk.Frame(canvas, padding=8) inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) canvas.create_window((0, 0), window=inner, anchor="nw") canvas.configure(yscrollcommand=sb.set) canvas.pack(side="left", fill="both", expand=True) sb.pack(side="right", fill="y") render() # Fenstergröße an den Inhalt anpassen (Breite = breiteste Zeile) d.update_idletasks() inhalt_w = inner.winfo_reqwidth() canvas.configure(width=inhalt_w) win_w = min(inhalt_w + sb.winfo_reqwidth() + 26, d.winfo_screenwidth() - 80) win_h = min(inner.winfo_reqheight() + btns.winfo_reqheight() + 90, d.winfo_screenheight() - 120) d.geometry(f"{win_w}x{int(win_h)}") d.minsize(min(win_w, 820), 420) info_lbl.configure(wraplength=win_w - 24) def dlg_firma(self): d = tk.Toplevel(self) d.title("Firmendaten & Texte") d.transient(self) d.grab_set() self._style_dialog(d) d.geometry("640x640") frm = ttk.Frame(d, padding=12) frm.pack(fill="both", expand=True) felder = [ ("name", "Firmenname"), ("untertitel", "Untertitel"), ("absender", "Absenderzeile"), ("inhaber", "Inhaber"), ("bank", "Bank"), ("iban", "IBAN"), ("bic", "SWIFT/BIC"), ("steuernummer", "Steuernummer"), ("homepage", "Homepage"), ("email", "E-Mail"), ("telefon", "Telefon"), ] vars_ = {} for i, (key, lbl) in enumerate(felder): ttk.Label(frm, text=lbl).grid(row=i, column=0, sticky="w", pady=2) v = tk.StringVar(value=self.cfg["firma"].get(key, "")) ttk.Entry(frm, textvariable=v, width=58).grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2) vars_[key] = v frm.columnconfigure(1, weight=1) base = len(felder) # Logo (Bilddatei) mit Durchsuchen / Entfernen ttk.Label(frm, text="Logo (oben links)").grid(row=base, column=0, sticky="w", pady=2) logo_row = ttk.Frame(frm) logo_row.grid(row=base, column=1, sticky="ew", padx=(8, 0), pady=2) v_logo = tk.StringVar(value=self.cfg["firma"].get("logo", "")) ttk.Entry(logo_row, textvariable=v_logo).pack(side="left", fill="x", expand=True) def logo_waehlen(): p = filedialog.askopenfilename( title="Logo auswählen", filetypes=[("Bilder", "*.png *.jpg *.jpeg *.gif"), ("Alle Dateien", "*.*")]) if p: v_logo.set(p) ttk.Button(logo_row, text="Durchsuchen…", command=logo_waehlen).pack(side="left", padx=(6, 0)) ttk.Button(logo_row, text="Entfernen", command=lambda: v_logo.set("")).pack(side="left", padx=(4, 0)) ttk.Label(frm, text="Danktext 1").grid(row=base + 1, column=0, sticky="nw", pady=2) t1 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg) t1.insert("1.0", self.cfg["texte"].get("dank1", "")) t1.grid(row=base + 1, column=1, sticky="ew", padx=(8, 0), pady=2) ttk.Label(frm, text="Danktext 2").grid(row=base + 2, column=0, sticky="nw", pady=2) t2 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg) t2.insert("1.0", self.cfg["texte"].get("dank2", "")) t2.grid(row=base + 2, column=1, sticky="ew", padx=(8, 0), pady=2) ttk.Label(frm, text="Hinweiszeile").grid(row=base + 3, column=0, sticky="w", pady=2) vh = tk.StringVar(value=self.cfg["texte"].get("hinweis", "")) ttk.Entry(frm, textvariable=vh, width=58).grid(row=base + 3, column=1, sticky="ew", padx=(8, 0), pady=2) def speichern(): for key, v in vars_.items(): self.cfg["firma"][key] = v.get() self.cfg["firma"]["logo"] = v_logo.get().strip() self.cfg["texte"]["dank1"] = t1.get("1.0", "end").strip() self.cfg["texte"]["dank2"] = t2.get("1.0", "end").strip() self.cfg["texte"]["hinweis"] = vh.get().strip() config.save_config(self.cfg) self.title("Rechnungstool - " + self.cfg["firma"].get("name", "")) d.destroy() btns = ttk.Frame(frm) btns.grid(row=base + 4, column=0, columnspan=2, pady=(12, 0), sticky="e") ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4) ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right") def dlg_nummer(self): d = tk.Toplevel(self) d.title("Rechnungsnummer") d.transient(self) d.grab_set() self._style_dialog(d) frm = ttk.Frame(d, padding=12) frm.pack(fill="both", expand=True) v_jahr = tk.StringVar(value=str(self.cfg.get("rechnung_jahr", datetime.date.today().year))) v_z = tk.StringVar(value=str(self.cfg.get("rechnung_zaehler", 0))) ttk.Label(frm, text="Format: JJJJ-NNN (z. B. 2026-013). Der Zähler ist der zuletzt\n" "vergebene Wert – beim nächsten Beleg wird +1 vorgeschlagen.", justify="left").grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 8)) ttk.Label(frm, text="Jahr").grid(row=1, column=0, sticky="w", pady=3) ttk.Entry(frm, textvariable=v_jahr, width=10).grid(row=1, column=1, sticky="w", pady=3) ttk.Label(frm, text="Zuletzt vergebener Zähler").grid(row=2, column=0, sticky="w", pady=3) ttk.Entry(frm, textvariable=v_z, width=10).grid(row=2, column=1, sticky="w", pady=3) def speichern(): self.cfg["rechnung_jahr"] = int(parse_num(v_jahr.get())) self.cfg["rechnung_zaehler"] = int(parse_num(v_z.get())) config.save_config(self.cfg) self._neue_nummer_vorschlagen() d.destroy() btns = ttk.Frame(frm) btns.grid(row=3, column=0, columnspan=2, pady=(12, 0), sticky="e") ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4) ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right") if __name__ == "__main__": # Muss VOR dem Fenster passieren - das Handle bleibt bis zum Ende offen. _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()