- "Rechnung endgueltig erstellen?" nennt Nummer, Kunde und Betrag und weist darauf hin, dass danach nur noch Storno oder Berichtigung bleibt (§ 14 Abs. 4 Nr. 4 UStG, § 31 Abs. 5 UStDV). Geprueft ist auch, dass "Nein" wirklich nichts schreibt - sonst waere die Frage eine Attrappe. - Als EXE legt das Programm sich beim ersten Start einen Ordner "Daten" neben die EXE (config.json). Ist dort nicht schreibbar (EXE unter "Programme"), weicht es nach LOCALAPPDATA aus. Lag die config.json noch direkt neben der EXE, zieht sie um - sonst faengt das Programm ohne Firmendaten neu an. - Aufruf von _storno_fortsetzen entfernt: die Methode gibt es nicht mehr, seit die Kette auf dem Storno-Blatt steht. War unerreichbar, waere aber beim naechsten Anfassen ein AttributeError geworden. - Pruefstaende raeumen ihren Wegwerf-Ordner weg und haben eine Reissleine (Abbruch nach zwei Minuten), damit kein Dialog mehr ewig offen steht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
747 lines
36 KiB
Python
747 lines
36 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
Rechnungstool Campinghof Bartl - Desktop-GUI (Tkinter).
|
||
|
||
Bedienung:
|
||
1. Kundenadresse + An-/Abreise eingeben
|
||
2. bei den passenden Leistungen Anzahl (und ggf. Personen) eintragen
|
||
3. "PDF erstellen" -> druckfertige Rechnung im Ordner rechnungen/
|
||
|
||
Steuersaetze, Bettensteuer, Preise und Firmendaten -> Menue "Einstellungen".
|
||
|
||
Aufgeteilt in:
|
||
helfer.py - Zahlen, Datum, Namen, Pruefungen
|
||
bestand.py - was im Rechnungsordner liegt (nur lesen)
|
||
storno.py - den spiegelbildlichen Storno rechnen
|
||
einzelinstanz.py - nur ein Fenster zulassen
|
||
theme.py - Farben nach Windows hell/dunkel
|
||
korrektur.py - Storno- und Berichtigungsweg
|
||
einstellungen.py - die Dialoge aus dem Menue "Einstellungen"
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import datetime
|
||
import tkinter as tk
|
||
from tkinter import ttk, messagebox
|
||
|
||
import config
|
||
import berechnung
|
||
import pdf_renderer
|
||
from helfer import ANREDEN, kunde_maengel, parse_date, parse_num, saeubern, safe_filename, ziffern
|
||
from bestand import naechste_freie_nummer, vorhandene_rechnungen
|
||
from einzelinstanz import (eigene_pid_merken, einzelinstanz_pruefen, fenster_nach_vorn,
|
||
laufende_pid_lesen)
|
||
from theme import ThemeMixin
|
||
from korrektur import KorrekturMixin
|
||
from einstellungen import EinstellungenMixin
|
||
|
||
|
||
class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.cfg = config.load_config()
|
||
self.title("Rechnungstool - " + self.cfg["firma"].get("name", "Campinghof"))
|
||
# Fensterhoehe an den Bildschirm anpassen, damit nichts unter den Rand rutscht
|
||
hoehe = max(560, min(840, self.winfo_screenheight() - 90))
|
||
self.geometry(f"1060x{hoehe}")
|
||
self.minsize(900, 540)
|
||
|
||
self.anzahl_vars = {} # nr -> StringVar
|
||
self.personen_vars = {} # nr -> StringVar
|
||
self.gesamt_lbls = {} # nr -> Label
|
||
# Zwischenspeicher: geschriebenes Storno, das noch bestaetigt werden muss.
|
||
# Solange es hier liegt, gehoert zur naechsten PDF die Storno-Kette.
|
||
self.storno_stand = None
|
||
# Zwischenspeicher fuer eine reine Berichtigung (§ 31 Abs. 5 UStDV):
|
||
# die naechste PDF ist dann ein berichtigtes Blatt zu dieser Rechnung.
|
||
self.berichtigung_stand = None
|
||
|
||
self._apply_theme() # Farben nach Windows hell/dunkel
|
||
self._build_menu()
|
||
self._build_kopf()
|
||
self._build_summen() # untere Leiste (Summen + Buttons) zuerst -> bleibt immer sichtbar
|
||
self._build_positionen() # Positionsliste fuellt den Rest und scrollt
|
||
|
||
self._neue_nummer_vorschlagen()
|
||
self.recalc()
|
||
|
||
# Beim Schliessen nachfragen, wenn ein Storno noch offen ist - sonst liegt
|
||
# es morgen ohne berichtigte Rechnung im Ordner und keiner weiss warum.
|
||
self.protocol("WM_DELETE_WINDOW", self._beenden)
|
||
|
||
# Titelleiste dunkel (nach dem Mappen des Fensters)
|
||
self.after(60, lambda: self._dark_titlebar(self))
|
||
# Beim ersten Start nach dem Speicherort fragen (wenn noch keiner gesetzt ist)
|
||
self.after(250, self._frage_speicherort_falls_noetig)
|
||
|
||
|
||
# ---------------------------------------------------------------- Menue
|
||
def _build_menu(self):
|
||
# Vollstaendig eigene Menueleiste + eigene Dropdowns -> komplett theme-konform
|
||
# (native tk-Menubar/Popups lassen sich unter Windows nicht einfaerben).
|
||
self._open_pop = None
|
||
self._menus = {
|
||
"Datei": [
|
||
("PDF erstellen", self.pdf_erstellen),
|
||
("Formular zurücksetzen", self.reset_formular),
|
||
("Ausgabeordner öffnen", self._ordner_oeffnen),
|
||
("-", None),
|
||
("Beenden", self._beenden), # fragt nach, wenn ein Storno offen ist
|
||
],
|
||
"Einstellungen": [
|
||
("Steuersätze…", self.dlg_steuern),
|
||
("Leistungskatalog / Preise…", self.dlg_katalog),
|
||
("Firmendaten, Logo & Texte…", self.dlg_firma),
|
||
("Rechnungsnummer…", self.dlg_nummer),
|
||
("Speicherort der Rechnungen…", self.dlg_speicherort),
|
||
],
|
||
"Hilfe": [("Info", self._info)],
|
||
}
|
||
bar = tk.Frame(self, bg=self.col_bg)
|
||
bar.pack(side="top", fill="x")
|
||
for text, items in self._menus.items():
|
||
lbl = tk.Label(bar, text=text, bg=self.col_bg, fg=self.col_fg, padx=8, pady=3)
|
||
lbl.pack(side="left")
|
||
lbl.bind("<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")
|
||
# bind statt bind_all: sonst feuert Strg+P auch aus einem offenen
|
||
# Dialog heraus und schreibt im Formular dahinter eine Rechnung.
|
||
self.bind("<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", self.k_anrede),
|
||
("Vorname *", self.k_vorname),
|
||
("Nachname / Firma *", self.k_name),
|
||
("Zusatz (optional)", self.k_zusatz),
|
||
("Straße / Nr. *", self.k_strasse),
|
||
("PLZ / Ort *", self.k_plzort),
|
||
]
|
||
for i, (lbl, var) in enumerate(felder):
|
||
ttk.Label(kunde, text=lbl).grid(row=i, column=0, sticky="w", pady=2)
|
||
if var is self.k_anrede:
|
||
# Auswahlliste, aber nicht gesperrt - Sonderfaelle kann man tippen.
|
||
w = ttk.Combobox(kunde, textvariable=var, width=36, values=ANREDEN)
|
||
else:
|
||
w = ttk.Entry(kunde, textvariable=var, width=38)
|
||
w.grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||
ttk.Label(kunde, text="* Pflicht – Name und Anschrift des Kunden gehören "
|
||
"auf die Rechnung (§ 14 Abs. 4 Nr. 1 UStG).",
|
||
foreground=self.col_muted, wraplength=330, justify="left").grid(
|
||
row=len(felder), column=0, columnspan=2, sticky="w", pady=(6, 0))
|
||
kunde.columnconfigure(1, weight=1)
|
||
|
||
# Rechnungsinfos (rechts)
|
||
info = ttk.LabelFrame(top, text="Rechnungsdaten", padding=8)
|
||
info.grid(row=0, column=1, sticky="nsew")
|
||
self.r_nummer = tk.StringVar()
|
||
self.r_datum = tk.StringVar(value=datetime.date.today().strftime("%d.%m.%Y"))
|
||
self.r_anreise = tk.StringVar()
|
||
self.r_abreise = tk.StringVar()
|
||
self.r_naechte = tk.StringVar(value="–")
|
||
rows = [
|
||
("Rechnungsnummer", self.r_nummer),
|
||
("Rechnungsdatum", self.r_datum),
|
||
("Anreise (TT.MM.JJJJ)", self.r_anreise),
|
||
("Abreise (TT.MM.JJJJ)", self.r_abreise),
|
||
]
|
||
date_vars = (self.r_datum, self.r_anreise, self.r_abreise)
|
||
for i, (lbl, var) in enumerate(rows):
|
||
ttk.Label(info, text=lbl).grid(row=i, column=0, sticky="w", pady=2)
|
||
ent = ttk.Entry(info, textvariable=var, width=18)
|
||
ent.grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||
if var is self.r_nummer:
|
||
# Waehrend eines Stornos wird das Feld gesperrt - die Nummern
|
||
# vergibt dann das Programm, damit keine doppelt sein kann.
|
||
self.nummer_entry = ent
|
||
if var in date_vars: # Datum: Punkte automatisch, Cursor ans Ende
|
||
ent.bind("<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="Storno", command=self.dlg_storno,
|
||
width=12).pack(side="left", padx=4)
|
||
ttk.Button(links, text="Berichtigen", command=self.dlg_berichtigen,
|
||
width=14).pack(side="left", padx=4)
|
||
ttk.Button(links, text="Zurücksetzen", command=self.reset_formular, width=14).pack(side="left", padx=4)
|
||
|
||
# --------------------------------------------------------- Berechnung
|
||
def _eingaben(self):
|
||
e = {}
|
||
for nr, av in self.anzahl_vars.items():
|
||
e[nr] = {"anzahl": parse_num(av.get()), "personen": parse_num(self.personen_vars[nr].get())}
|
||
return e
|
||
|
||
def recalc(self):
|
||
r = berechnung.berechne(self.cfg["katalog"], self._eingaben(), self.cfg)
|
||
aktiv = {z.nr: z.brutto for z in r.zeilen}
|
||
for nr, lbl in self.gesamt_lbls.items():
|
||
lbl.configure(text=berechnung.eur_kurz(aktiv.get(nr, 0.0)))
|
||
|
||
# Summenzeilen dynamisch aufbauen: Rechnungsbetrag (brutto) -> darin enthaltene
|
||
# USt je Satz -> [Beherbergungssteuer obendrauf] -> Gesamtbetrag.
|
||
for w in self.summen_box.winfo_children():
|
||
w.destroy()
|
||
rows = [("Nettobetrag:", berechnung.eur(r.netto_gesamt), False)]
|
||
for b in r.bloecke:
|
||
name = b.bezeichnung or "USt"
|
||
rows.append((f"{name} {b.satz} %:", berechnung.eur(b.ust), False))
|
||
if r.aufschlaege:
|
||
rows.append(("Zwischensumme:", berechnung.eur(r.brutto), False))
|
||
for a in r.aufschlaege:
|
||
satz = berechnung.eur_kurz(a.satz).replace(",00", "")
|
||
rows.append((f"zzgl. {a.bezeichnung or 'Aufschlag'} ({satz} %):", berechnung.eur(a.betrag), False))
|
||
rows.append(("Gesamtbetrag:", berechnung.eur(r.gesamt), True))
|
||
|
||
for i, (lbl, val, fett) in enumerate(rows):
|
||
font = ("Segoe UI", 12, "bold") if fett else ("Segoe UI", 10)
|
||
pady = (5, 0) if fett else (1, 0)
|
||
ttk.Label(self.summen_box, text=lbl, font=font).grid(row=i, column=0, sticky="e", padx=6, pady=pady)
|
||
vkw = {"font": font, "width": 14, "anchor": "e"}
|
||
if fett:
|
||
vkw["foreground"] = self.col_accent
|
||
ttk.Label(self.summen_box, text=val, **vkw).grid(row=i, column=1, sticky="e", pady=pady)
|
||
|
||
self._letzte_rechnung = r
|
||
return r
|
||
|
||
# ----------------------------------------------------------- Aktionen
|
||
def _neue_nummer_vorschlagen(self):
|
||
"""Nächste freie Nummer ins Formular - EINE Quelle, sonst laufen sie auseinander.
|
||
|
||
Läuft gerade ein Storno oder eine Berichtigung, gehört die Nummer im Feld
|
||
zu diesem Vorgang und wird nicht angefasst.
|
||
"""
|
||
if self.storno_stand or self.berichtigung_stand:
|
||
return
|
||
frei = naechste_freie_nummer(config.ensure_output_dir(self.cfg), self.cfg)
|
||
if frei:
|
||
self.r_nummer.set(frei)
|
||
|
||
|
||
def kunde_pruefen(self):
|
||
"""Beanstandungen an den Kundenfeldern; leere Liste = in Ordnung.
|
||
|
||
Saeubert die Felder und prueft sie mit kunde_maengel().
|
||
"""
|
||
for var in (self.k_anrede, self.k_vorname, self.k_name, self.k_zusatz,
|
||
self.k_strasse, self.k_plzort):
|
||
var.set(saeubern(var.get()))
|
||
return kunde_maengel({"vorname": self.k_vorname.get(),
|
||
"nachname": self.k_name.get(),
|
||
"strasse": self.k_strasse.get(),
|
||
"plzort": self.k_plzort.get()})
|
||
|
||
def pdf_erstellen(self):
|
||
r = self.recalc()
|
||
if not r.zeilen:
|
||
messagebox.showwarning("Keine Positionen",
|
||
"Es wurde keine Leistung mit Anzahl > 0 eingetragen.")
|
||
return
|
||
nummer = self.r_nummer.get().strip()
|
||
if not nummer:
|
||
messagebox.showwarning("Rechnungsnummer fehlt", "Bitte eine Rechnungsnummer eingeben.")
|
||
return
|
||
# Name und Anschrift des Kunden sind Pflichtangaben auf der Rechnung
|
||
# (§ 14 Abs. 4 Nr. 1 UStG) - ohne sie wird nichts geschrieben.
|
||
maengel = self.kunde_pruefen()
|
||
if maengel:
|
||
messagebox.showwarning(
|
||
"Kundenangaben fehlen",
|
||
"So kann die Rechnung nicht geschrieben werden:\n\n- " + "\n- ".join(maengel) +
|
||
"\n\nName und vollständige Anschrift des Kunden gehören auf jede "
|
||
"Rechnung (§ 14 Abs. 4 Nr. 1 UStG).")
|
||
return
|
||
# Hausnummer nur nachfragen, nicht blockieren - es gibt Anschriften ohne
|
||
# (Postfach, Haus am Feldweg), und eine falsche Sperre waere schlimmer.
|
||
if not ziffern(self.k_strasse.get()):
|
||
if not messagebox.askyesno(
|
||
"Hausnummer fehlt",
|
||
f"In „{self.k_strasse.get()}“ steht keine Hausnummer.\n\n"
|
||
"Trotzdem so auf die Rechnung schreiben?"):
|
||
return
|
||
|
||
# Letzte Gelegenheit, bevor der Beleg entsteht. Danach ist Ändern nicht
|
||
# mehr: eine ausgestellte Rechnung wird nur noch über einen Storno oder
|
||
# eine Berichtigung korrigiert - beides kostet Papier und einen Weg zum
|
||
# Gast. Beim Storno- und Berichtigungsweg fragt das Programm ohnehin
|
||
# schon, darum hier nur bei einer ganz normalen Rechnung.
|
||
if not self.storno_stand and not self.berichtigung_stand:
|
||
wer = " ".join(t for t in (self.k_vorname.get().strip(),
|
||
self.k_name.get().strip()) if t)
|
||
if not messagebox.askyesno(
|
||
"Rechnung endgültig erstellen?",
|
||
f"Rechnung {nummer} für {wer or 'ohne Namen'} über "
|
||
f"{berechnung.eur(r.gesamt)}.\n\n"
|
||
"Sind alle Angaben richtig? Danach lässt sich die Rechnung "
|
||
"nicht mehr ändern.\n\n"
|
||
"Eine ausgestellte Rechnung darf weder überschrieben noch "
|
||
"gelöscht werden. Korrigiert wird sie nur noch über "
|
||
"„Storno“ (falsche Beträge) oder „Berichtigen“ (falsche "
|
||
"Kundenangaben, § 31 Abs. 5 UStDV) – und jede Nummer gibt "
|
||
"es nur einmal (§ 14 Abs. 4 Nr. 4 UStG).",
|
||
icon="question", default="yes"):
|
||
return
|
||
|
||
# Auf der Rechnung stehen Vor- und Nachname wie gewohnt in einer Zeile;
|
||
# der Nachname geht zusätzlich getrennt mit, damit das Steuerjournal ihn
|
||
# nicht aus dem Adressblock raten muss.
|
||
nachname = self.k_name.get().strip()
|
||
vorname = self.k_vorname.get().strip()
|
||
namenszeile = " ".join(t for t in (vorname, nachname) if t)
|
||
kunde_zeilen = [self.k_anrede.get(), namenszeile, self.k_zusatz.get(),
|
||
self.k_strasse.get(), self.k_plzort.get()]
|
||
# Liegt ein Storno im Zwischenspeicher, ist das hier die berichtigte Rechnung.
|
||
# Letzte Gelegenheit umzukehren: sagt er Nein, wird das Storno-PDF wieder
|
||
# geloescht und es bleibt allein die urspruengliche Rechnung stehen.
|
||
stand = self.storno_stand
|
||
if stand:
|
||
if not messagebox.askyesno(
|
||
"Storno wirklich durchführen?",
|
||
f"Storno {stand['nummer']} hebt die Rechnung {stand['zu']} auf, "
|
||
f"und {nummer} wird die berichtigte Rechnung.\n\n"
|
||
"Beide gehen an den Gast – die Berichtigung wirkt erst, wenn sie "
|
||
"ihm zugegangen ist.\n\n"
|
||
"Ja = Storno behalten und berichtigte Rechnung erstellen\n"
|
||
f"Nein = alles verwerfen, es bleibt allein Rechnung {stand['zu']}",
|
||
icon="warning", default="yes"):
|
||
self._storno_verwerfen("vom Benutzer verworfen")
|
||
messagebox.showinfo(
|
||
"Storno verworfen",
|
||
f"Der Storno wurde gelöscht. Es bleibt allein die Rechnung {stand['zu']}.")
|
||
return
|
||
|
||
kopf = {
|
||
"rechnungsnummer": nummer,
|
||
"datum": self.r_datum.get().strip() or datetime.date.today().strftime("%d.%m.%Y"),
|
||
"anreise": self.r_anreise.get().strip(),
|
||
"abreise": self.r_abreise.get().strip(),
|
||
"naechte": self.r_naechte.get() if self.r_naechte.get() != "–" else "",
|
||
"kunde_zeilen": kunde_zeilen,
|
||
"nachname": nachname,
|
||
"vorname": vorname,
|
||
# Fuer Version 3: kompletter Formularstand, damit die Rechnung spaeter
|
||
# wieder ins Formular geladen werden kann statt abgetippt zu werden.
|
||
"kunde": {"anrede": self.k_anrede.get().strip(), "vorname": vorname,
|
||
"nachname": nachname, "zusatz": self.k_zusatz.get().strip(),
|
||
"strasse": self.k_strasse.get().strip(),
|
||
"plzort": self.k_plzort.get().strip()},
|
||
"positionen": [{"nr": z.nr, "anzahl": z.anzahl, "personen": z.personen}
|
||
for z in r.zeilen],
|
||
}
|
||
if stand:
|
||
kopf["art"] = "korrektur"
|
||
kopf["vorgang"] = stand.get("vorgang") or stand["zu"]
|
||
kopf["ersetzt"] = stand["zu"]
|
||
kopf["storno_nummer"] = stand["nummer"]
|
||
out_dir = config.ensure_output_dir(self.cfg)
|
||
|
||
# ---- reine Berichtigung: gleiche Nummer, gleiche Betraege, neues Blatt ----
|
||
ber = self.berichtigung_stand
|
||
if ber:
|
||
kopf["art"] = "berichtigung"
|
||
kopf["vorgang"] = ber.get("vorgang") or ber["zu"]
|
||
kopf["berichtigt_zu"] = ber["zu"]
|
||
kopf["berichtigt_am"] = datetime.date.today().strftime("%d.%m.%Y")
|
||
if not messagebox.askyesno(
|
||
"Berichtigte Rechnung erstellen?",
|
||
f"Zu Rechnung {ber['zu']} vom {ber['datum']} wird ein berichtigtes "
|
||
"Blatt geschrieben.\n\n"
|
||
"Nummer, Leistungen und Beträge bleiben unverändert – die alte "
|
||
"Rechnung bleibt liegen. Das Blatt gehört zum Gast: erst wenn es "
|
||
"ihm zugegangen ist, ist die Rechnung berichtigt.\n\nJetzt erstellen?",
|
||
default="yes"):
|
||
return
|
||
# Dateiname: "Berichtigte Rechnung", Datum, Nummer der Rechnung. Das
|
||
# Blatt verbraucht keine eigene Nummer, das Datum haelt mehrere
|
||
# Berichtigungen zur selben Rechnung auseinander (sortierbar).
|
||
pfad = os.path.join(
|
||
out_dir, f"Berichtigte Rechnung_{datetime.date.today():%Y-%m-%d}"
|
||
f"_{safe_filename(ber['zu'])}.pdf")
|
||
if os.path.exists(pfad) and not messagebox.askyesno(
|
||
"Berichtigung von heute ersetzen?",
|
||
f"Zu Rechnung {ber['zu']} gibt es von heute schon eine Berichtigung:\n"
|
||
f"{os.path.basename(pfad)}\n\nDurch die neue ersetzen?"):
|
||
return
|
||
try:
|
||
pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf)
|
||
except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen
|
||
messagebox.showerror("Fehler beim Erstellen",
|
||
f"Berichtigung konnte nicht erstellt werden:\n{e}")
|
||
print("[berichtigung] FEHLER:", e)
|
||
return
|
||
print(f"[berichtigung] erstellt -> {pfad}")
|
||
self.berichtigung_stand = None
|
||
self._storno_anzeigen()
|
||
if messagebox.askyesno("Fertig",
|
||
f"Berichtigte Rechnung zu {ber['zu']}:\n{pfad}\n\n"
|
||
"Jetzt öffnen (zum Drucken)?"):
|
||
self._oeffne_datei(pfad)
|
||
return
|
||
|
||
# Nummer schon vergeben? Dann NICHT drueberschreiben: eine ausgestellte
|
||
# Rechnung bleibt liegen - § 146 Abs. 4 AO verbietet Aenderungen, bei denen
|
||
# der urspruengliche Inhalt nicht mehr feststellbar ist; ein unangetasteter
|
||
# Beleg ist im Pruefungsfall die einfachere Geschichte. Und jede
|
||
# Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG). Also neue Nummer.
|
||
vorhanden = vorhandene_rechnungen(out_dir, nummer)
|
||
if vorhanden:
|
||
liste = "\n".join("- " + os.path.basename(x) for x in vorhanden)
|
||
frei = naechste_freie_nummer(out_dir, self.cfg)
|
||
if not frei:
|
||
messagebox.showerror(
|
||
"Rechnungsnummer schon vergeben",
|
||
f"Die Rechnungsnummer {nummer} ist schon vergeben:\n\n{liste}\n\n"
|
||
"Es wurde keine freie Nummer gefunden - bitte die Nummer von Hand setzen.")
|
||
return
|
||
if not messagebox.askyesno(
|
||
"Rechnungsnummer schon vergeben",
|
||
f"Die Rechnungsnummer {nummer} ist schon vergeben:\n\n{liste}\n\n"
|
||
"Die vorhandene Rechnung bleibt bestehen - sie darf weder überschrieben "
|
||
"noch gelöscht werden (§ 14 Abs. 4 Nr. 4 UStG, § 146 Abs. 4 AO).\n\n"
|
||
f"Stattdessen die nächste freie Nummer {frei} verwenden?",
|
||
icon="warning", default="yes"):
|
||
return
|
||
nummer = frei
|
||
self.r_nummer.set(nummer)
|
||
kopf["rechnungsnummer"] = nummer
|
||
|
||
fname = f"Rechnung_{safe_filename(nummer)}"
|
||
if self.k_name.get().strip():
|
||
fname += "_" + safe_filename(self.k_name.get().strip())
|
||
pfad = os.path.join(out_dir, fname + ".pdf")
|
||
|
||
try:
|
||
# Ein PDF: Seite 1 = Kunden-Rechnung, Seite 2 = Beherbergungssteuer fuers Amt
|
||
pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf)
|
||
except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen
|
||
messagebox.showerror("Fehler beim Erstellen", f"PDF konnte nicht erstellt werden:\n{e}")
|
||
print("[pdf] FEHLER:", e)
|
||
return
|
||
|
||
# Rechnungsnummer-Zaehler hochsetzen, falls Format JJJJ-NNN
|
||
self._zaehler_aus_nummer_speichern(nummer)
|
||
|
||
print(f"[pdf] erstellt -> {pfad}")
|
||
hinweis = "\n\n(Seite 1 = Kunde, Seite 2 = Beherbergungssteuer fürs Amt)" if r.aufschlaege else ""
|
||
storno_pfad = None
|
||
if stand:
|
||
# Storno ist jetzt bestaetigt - Zwischenspeicher leeren, Sperre loesen.
|
||
storno_pfad = stand["pfad"]
|
||
self.storno_stand = None
|
||
self._storno_anzeigen()
|
||
hinweis = (f"\n\nDazu gehört die Stornorechnung {stand['nummer']} "
|
||
f"({os.path.basename(storno_pfad)}).\n"
|
||
"Beide Blätter gehören zum Gast.") + hinweis
|
||
if messagebox.askyesno("Fertig", f"Rechnung erstellt:\n{pfad}{hinweis}\n\nJetzt öffnen (zum Drucken)?"):
|
||
self._oeffne_datei(pfad)
|
||
if storno_pfad:
|
||
self._oeffne_datei(storno_pfad)
|
||
|
||
def _zaehler_aus_nummer_speichern(self, nummer):
|
||
try:
|
||
jahr_s, z_s = nummer.split("-")
|
||
jahr, z = int(jahr_s), int(z_s)
|
||
except (ValueError, AttributeError):
|
||
return
|
||
# Der Zaehler darf nur vorwaerts. Wird eine alte Rechnung ueberschrieben,
|
||
# wuerde er sonst zurueckfallen und die naechsten Nummern noch einmal
|
||
# vergeben - genau der Fehler, den wir loswerden wollen.
|
||
alt_jahr = self.cfg.get("rechnung_jahr", 0)
|
||
alt_z = self.cfg.get("rechnung_zaehler", 0)
|
||
if jahr < alt_jahr or (jahr == alt_jahr and z <= alt_z):
|
||
return
|
||
self.cfg["rechnung_jahr"] = jahr
|
||
self.cfg["rechnung_zaehler"] = z
|
||
try:
|
||
config.save_config(self.cfg)
|
||
except OSError as e: # noqa: BLE001 - die PDF ist schon geschrieben
|
||
# Ohne diese Meldung merkt niemand, dass der Zaehler stehengeblieben
|
||
# ist: die Ausgabe geht in eine Konsole, die es in der EXE nicht gibt.
|
||
messagebox.showwarning(
|
||
"Rechnungsnummer nicht gespeichert",
|
||
f"Die Rechnung ist geschrieben, der Zählerstand aber nicht:\n{e}\n\n"
|
||
f"Bitte im Menü unter „Rechnungsnummer…“ den Zähler auf {z} setzen, "
|
||
"sonst wird die nächste Nummer noch einmal vergeben.")
|
||
self._neue_nummer_vorschlagen()
|
||
|
||
def _beenden(self):
|
||
"""Schliessen - mit Rückfrage, wenn ein Storno noch offen ist."""
|
||
if self.storno_stand:
|
||
if not messagebox.askyesno(
|
||
"Storno ist noch offen",
|
||
f"Der Storno {self.storno_stand['nummer']} zu Rechnung "
|
||
f"{self.storno_stand['zu']} liegt im Ordner, die berichtigte "
|
||
"Rechnung fehlt noch.\n\n"
|
||
"Beim Schließen bleibt er so liegen – morgen weiß niemand mehr, "
|
||
"warum.\n\nTrotzdem beenden?", icon="warning", default="no"):
|
||
return
|
||
self.destroy()
|
||
|
||
def reset_formular(self):
|
||
# Ein laufender Vorgang darf nicht stumm liegenbleiben: sonst gehören die
|
||
# neu eingetippten Daten plötzlich zum Storno der fremden Rechnung.
|
||
if self.storno_stand:
|
||
if not messagebox.askyesno(
|
||
"Storno verwerfen?",
|
||
f"Der Storno {self.storno_stand['nummer']} zu Rechnung "
|
||
f"{self.storno_stand['zu']} liegt noch bereit.\n\n"
|
||
"Zurücksetzen verwirft ihn – das Storno-PDF wird gelöscht und "
|
||
f"es bleibt allein die Rechnung {self.storno_stand['zu']}.\n\n"
|
||
"Wirklich zurücksetzen?", icon="warning", default="no"):
|
||
return
|
||
self._storno_verwerfen("beim Zurücksetzen verworfen")
|
||
elif self.berichtigung_stand:
|
||
# Da ist noch nichts geschrieben - einfach fallenlassen.
|
||
self.berichtigung_stand = None
|
||
self._storno_anzeigen()
|
||
|
||
for v in (self.k_anrede, self.k_vorname, self.k_name, self.k_zusatz,
|
||
self.k_strasse, self.k_plzort, self.r_anreise, self.r_abreise):
|
||
v.set("")
|
||
self.r_datum.set(datetime.date.today().strftime("%d.%m.%Y"))
|
||
for av in self.anzahl_vars.values():
|
||
av.set("")
|
||
for pv in self.personen_vars.values():
|
||
pv.set("")
|
||
self._neue_nummer_vorschlagen()
|
||
self.recalc()
|
||
|
||
def _ordner_oeffnen(self):
|
||
self._oeffne_datei(config.ensure_output_dir(self.cfg))
|
||
|
||
|
||
def _oeffne_datei(self, pfad):
|
||
try:
|
||
if sys.platform.startswith("win"):
|
||
os.startfile(pfad) # noqa: S606
|
||
elif sys.platform == "darwin":
|
||
os.system(f'open "{pfad}"')
|
||
else:
|
||
os.system(f'xdg-open "{pfad}"')
|
||
except Exception as e: # noqa: BLE001
|
||
messagebox.showinfo("Pfad", f"{pfad}\n\n(Konnte nicht automatisch geöffnet werden: {e})")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
# Muss VOR dem Fenster passieren - das Handle bleibt bis zum Ende offen.
|
||
_sperre = einzelinstanz_pruefen()
|
||
if _sperre is False:
|
||
# Laeuft schon: dessen Fenster nach vorn holen und still beenden.
|
||
if not fenster_nach_vorn(laufende_pid_lesen()):
|
||
_r = tk.Tk()
|
||
_r.withdraw()
|
||
messagebox.showerror(
|
||
"Rechnungstool läuft bereits",
|
||
"""Das Rechnungstool ist schon geöffnet, das Fenster ließ sich
|
||
aber nicht nach vorn holen - bitte über die Taskleiste zurückwechseln.
|
||
|
||
Ein zweites Fenster würde dieselbe Rechnungsnummer noch einmal vergeben.""")
|
||
_r.destroy()
|
||
sys.exit(0)
|
||
|
||
eigene_pid_merken()
|
||
app = RechnungsApp()
|
||
app.mainloop()
|