rechnungstool/app.py
TheMockTv 790ac4c0f6 Storno statt Ueberschreiben: Rechnungskette 005 -> Storno -> Korrektur
Eine ausgestellte Rechnung darf nicht geaendert und nicht geloescht werden
(§ 146 Abs. 4 AO), und jede Nummer gibt es nur einmal (§ 14 Abs. 4 Nr. 4
UStG). Korrigiert wird deshalb ueber ein eigenes Dokument mit neuer Nummer,
das sich auf die alte bezieht (§ 31 Abs. 5 UStDV).

- Knopf "Rechnung stornieren": alte Nummer waehlen, Programm liest deren PDF
  und schreibt eine Stornorechnung mit eigener neuer Nummer, Betraege
  negativ, mit Bezug auf Nummer und Datum der alten Rechnung.
- Danach steht das Formular auf der naechsten Nummer, gefuellt aus der alten
  Rechnung; die Nummer ist gesperrt. "PDF erstellen" fragt noch einmal nach
  und legt bei Nein das Storno-PDF wieder weg - dann bleibt allein das
  Original stehen.
- Die alte Rechnung wird nie angefasst.
- Metadaten v3: art / storno_zu / ersetzt / storno_nummer, dazu Anschrift und
  Positionen, damit eine Rechnung wieder ins Formular geladen werden kann.
  Alle Felder aus v1/v2 bleiben unveraendert - daran haengt das Steuerjournal.
- Zaehler laeuft nur noch vorwaerts; eine vergebene Nummer wird nicht mehr
  ueberschrieben, sondern die naechste freie angeboten.
- Combobox im Storno-Dialog blieb leer: die StringVar hing nur an einer
  lokalen Variable und wurde weggeraeumt. Jetzt ohne textvariable.

Prueflauf (pruef_storno.py, 36 Pruefungen gruen): echte PDFs in einem
Wegwerf-Ordner, danach liest der Steuerrechner den Ordner - 001 + Storno +
Korrektur ergeben genau die berichtigte Rechnung, kein Beleg unsicher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
2026-09-02 19:06:45 +02:00

1487 lines
68 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 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.
"""
kopf = "Rechnung_" + 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 stamm == kopf or stamm.startswith(kopf + "_"):
treffer.append(os.path.join(ordner, name))
return sorted(treffer)
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:
if not name.lower().endswith(".pdf") or not name.startswith("Rechnung_"):
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 <pid> 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("<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_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 (optional)", self.k_anrede),
("Vorname (optional)", 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)
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 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("<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))
# 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("<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="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 = {str(d.get("storno_zu") or "") for d in alle if d.get("art") == "storno"}
offen = [d for d in alle
if d.get("art") != "storno" and 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.
werte = [f"{x['rechnungsnummer']} {x.get('datum','')} {x.get('nachname','')}".strip()
for x in offen]
box = ttk.Combobox(frm, values=werte, width=42, state="readonly")
box.grid(row=1, column=1, sticky="ew", pady=3, padx=(8, 0))
box.current(0)
v_info = tk.StringVar(value="")
ttk.Label(frm, textvariable=v_info, justify="left", foreground=self.col_muted).grid(
row=2, column=0, columnspan=2, sticky="w", pady=(8, 0))
def gewaehlt():
i = box.current()
return offen[i] if 0 <= i < len(offen) else None
def vorschau(*_a):
m = gewaehlt()
if not m:
return
v_info.set(f"Rechnungsbetrag {berechnung.eur(m.get('zwischensumme') or 0)}, "
f"Gesamt {berechnung.eur(m.get('gesamt') or 0)}\n"
f"Der Storno lautet über {berechnung.eur(-abs(float(m.get('gesamt') or 0)))}.")
box.bind("<<ComboboxSelected>>", vorschau)
vorschau()
def anlegen():
meta = gewaehlt()
if not meta:
return
d.destroy()
self._storno_anlegen(meta, out_dir)
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
if not self.k_name.get().strip():
if not messagebox.askyesno("Kein Kundenname",
"Es ist kein Kundenname eingetragen. Trotzdem PDF erstellen?"):
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("<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__":
# 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()