rechnungstool/app.py
TheMockTv dc170a9d9c Rechnungstool Campinghof - Rechnungen als PDF statt Excel-Vorlage
Desktop-Programm (Python/Tkinter): Kundendaten + Leistungen eingeben,
druckfertige PDF-Rechnung im Briefkopf des Betriebs. Optionale
Beherbergungssteuer wird eigen ausgewiesen und bekommt ein zweites
Blatt fuers Amt.

Firmendaten, Preise und Steuersaetze stehen in der config.json neben
dem Programm (nicht im Repo) und werden im Menue "Einstellungen"
gepflegt. Im Code stehen nur Platzhalter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:18:38 +02:00

925 lines
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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 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 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
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)
style.configure("TCombobox", fieldbackground=field, foreground=fg,
background=field, arrowcolor=fg)
style.map("TCombobox", fieldbackground=[("readonly", field)],
foreground=[("readonly", fg)], background=[("readonly", field)])
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("<Enter>", lambda e, w=lbl: w.configure(bg=self.col_sel))
lbl.bind("<Leave>", lambda e, w=lbl: w.configure(bg=self.col_bg))
lbl.bind("<Button-1>", lambda e, b=lbl, it=items: self._menu_popup(b, it))
ttk.Separator(self, orient="horizontal").pack(side="top", fill="x")
self.bind_all("<Control-p>", 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("<Enter>", lambda e, w=it: w.configure(bg=self.col_sel))
it.bind("<Leave>", lambda e, w=it: w.configure(bg=self.col_field))
it.bind("<Button-1>", 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("<Button-1>", self._maybe_close_pop, add="+")
pop.bind("<Escape>", 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_name = tk.StringVar()
self.k_zusatz = tk.StringVar()
self.k_strasse = tk.StringVar()
self.k_plzort = tk.StringVar()
felder = [
("Anrede (optional)", self.k_anrede),
("Name / 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)
ttk.Entry(kunde, textvariable=var, width=38).grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2)
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 in date_vars: # Datum: Punkte automatisch, Cursor ans Ende
ent.bind("<KeyRelease>", 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))
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("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
# pos_frame auf Canvas-Breite ziehen (minus Luft rechts zum Scrollbalken)
canvas.bind("<Configure>", 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("<MouseWheel>", 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="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))
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
if not self.k_name.get().strip():
if not messagebox.askyesno("Kein Kundenname",
"Es ist kein Kundenname eingetragen. Trotzdem PDF erstellen?"):
return
kunde_zeilen = [self.k_anrede.get(), self.k_name.get(), self.k_zusatz.get(),
self.k_strasse.get(), self.k_plzort.get()]
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,
}
out_dir = config.ensure_output_dir(self.cfg)
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 ""
if messagebox.askyesno("Fertig", f"Rechnung erstellt:\n{pfad}{hinweis}\n\nJetzt öffnen (zum Drucken)?"):
self._oeffne_datei(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
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_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("<Escape>", lambda e: d.destroy())
d.bind("<Return>", 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("<<ComboboxSelected>>",
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("<Configure>", 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__":
app = RechnungsApp()
app.mainloop()