Das Rechnungstool kannte nur die Nummern seiner eigenen PDFs. Im Steuerjournal stehen aber Buchungen ohne PDF - der Altbestand aus der Excel-Mappe - und deren Nummern hat es ein zweites Mal vergeben. § 14 Abs. 4 Nr. 4 UStG laesst jede Nummer nur einmal zu. Deshalb fuehren beide Programme jetzt EIN gemeinsames Buch (gemeinsam.py): eine SQLite in einem Ordner, den beide kennen. Es ist ein Register, keine zweite Buchhaltung - die Wahrheit bleiben die PDFs. Was hineinkommt: jede geschriebene Rechnung, jeder Storno (mit storno_zu), jede Neuausstellung. Die Nummernvergabe und die Kollisionspruefung beim Erstellen fragen es mit ab; ein verworfener Storno gibt seine Nummer wieder frei. Faellt der Ordner aus (Netzlaufwerk weg), laeuft alles weiter - dann steht es in der Fussleiste. ordnerwahl.py fragt den Ordner beim ersten Start ab: auswaehlen, OK, Sicherheitsfrage, bei Nein zurueck ins Feld. Zwei Fehler daran gleich behoben, bevor sie jemand treffen konnte: das Fenster war zu schmal (der Auswahl-Knopf lag ausserhalb) und der Griff (grab_set) muss waehrend des Windows-Ordner- dialogs los sein, sonst nimmt der keine Eingabe an. Storno: er ist das Spiegelbild der Rechnung - auch bei den UEBERNACHTUNGEN. Vorher stand dort nichts, und das Steuerjournal meldete dem Amt die Naechte der aufgehobenen Rechnung weiter mit. Auf dem Blatt steht "4 Naechte (aufgehoben)", in den Kenndaten -4. "Neue Rechnung:" auf dem Storno-Blatt heisst jetzt "Berichtigte Rechnung:" - die alte Beschriftung las sich wie die Nummer DIESES Blattes. --test / CAMPINGHOF_TEST=1 unterdrueckt den Erststart-Dialog. In der EXE kommt man ohnehin nicht daran, und die Pruefstaende bleiben sonst im Dialog stehen. Pruefstand pruef_nummernbuch.py (neu): beide Programme auf einem Ordner - das Journal traegt drei Altbestands-Nummern ein, das Rechnungstool schlaegt danach 2026-004 statt 2026-001 vor. Alle uebrigen Pruefstaende gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx
1150 lines
56 KiB
Python
1150 lines
56 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 gemeinsam
|
||
import ordnerwahl
|
||
import pdf_renderer
|
||
from helfer import ANREDEN, kunde_maengel, parse_date, parse_num, saeubern, safe_filename, ziffern
|
||
from bestand import (naechste_freie_nummer, rechnungen_im_ordner,
|
||
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
|
||
|
||
|
||
# Testlauf: kein Erststart-Dialog. Pruefstaende fahren das Fenster fern, ein
|
||
# modaler Dialog wuerde sie haengen lassen - und in der EXE kommt man an den
|
||
# Schalter ohnehin nicht heran (Doppelklick uebergibt keine Argumente).
|
||
# python app.py --test oder CAMPINGHOF_TEST=1
|
||
TESTLAUF = ("--test" in sys.argv) or os.environ.get("CAMPINGHOF_TEST") == "1"
|
||
|
||
|
||
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
|
||
# Das gemeinsame Nummernbuch (siehe gemeinsam.py). None = noch keiner
|
||
# gesetzt; das Programm laeuft dann weiter, warnt aber sichtbar.
|
||
self.buch = 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)
|
||
# ... und danach nach dem gemeinsamen Ordner fuer das Nummernbuch.
|
||
self.after(400, self._frage_gemeinsam_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),
|
||
("Montage-Rechnung…", self.dlg_montage),
|
||
("Firmendaten, Logo & Texte…", self.dlg_firma),
|
||
("Rechnungsnummer…", self.dlg_nummer),
|
||
("Speicherort der Rechnungen…", self.dlg_speicherort),
|
||
("Gemeinsamer Ordner (Nummernbuch)…", self.dlg_gemeinsam),
|
||
],
|
||
"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)
|
||
self.datum_labels = {}
|
||
for i, (lbl, var) in enumerate(rows):
|
||
beschriftung = ttk.Label(info, text=lbl)
|
||
beschriftung.grid(row=i, column=0, sticky="w", pady=2)
|
||
if var is self.r_anreise:
|
||
self.datum_labels["von"] = beschriftung
|
||
elif var is self.r_abreise:
|
||
self.datum_labels["bis"] = beschriftung
|
||
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)
|
||
self.naechte_label = ttk.Label(info, text="Nächte")
|
||
self.naechte_label.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]
|
||
d = self._datum_ziffern_pruefen(d)
|
||
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()
|
||
|
||
@staticmethod
|
||
def _datum_ziffern_pruefen(d):
|
||
"""Unmögliche Tage und Monate gar nicht erst entstehen lassen.
|
||
|
||
Den 32. gibt es nicht und den 13. Monat auch nicht - das gehört beim
|
||
Tippen abgefangen, nicht erst beim Erstellen der Rechnung. Zwei Regeln:
|
||
|
||
* Eine 4 bis 9 an erster Stelle kann nur „04.… 09." heißen (kein Tag
|
||
fängt so an), ebenso eine 2 bis 9 an der ersten Monatsstelle. Die Null
|
||
wird ergänzt, statt den Anschlag zu schlucken.
|
||
* Was danach immer noch unmöglich wäre (00, 32.., 13..), fällt weg: die
|
||
Ziffer erscheint nicht, der Rest bleibt stehen.
|
||
"""
|
||
if d and d[0] > "3": # 4 -> 04
|
||
d = "0" + d
|
||
if len(d) >= 2 and not 1 <= int(d[:2]) <= 31:
|
||
d = d[:1] # 35, 00 -> die zweite Ziffer weg
|
||
if len(d) >= 3 and d[2] > "1": # 3 -> 03 (Monat)
|
||
d = d[:2] + "0" + d[2:]
|
||
if len(d) >= 4 and not 1 <= int(d[2:4]) <= 12:
|
||
d = d[:3] # 13, 00 -> die zweite Ziffer weg
|
||
return d[:8]
|
||
|
||
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):
|
||
# Zwei Sorten Rechnung, zwei Reiter: die Campingrechnung mit dem
|
||
# Leistungskatalog und die Montage-Rechnung mit der Monatsmiete. Der
|
||
# Kopf (Kunde, Nummer, Datum, Zeitraum) und die Summenleiste gelten fuer
|
||
# beide - nur die Mitte wechselt. So tippt niemand den Kunden zweimal.
|
||
self.blaetter = ttk.Notebook(self)
|
||
self.blaetter.pack(side="top", fill="both", expand=True, padx=12, pady=4)
|
||
self.blaetter.bind("<<NotebookTabChanged>>", lambda _e: self._blatt_gewechselt())
|
||
|
||
camping = ttk.Frame(self.blaetter, padding=(2, 4))
|
||
self.blaetter.add(camping, text=" Campingrechnung ")
|
||
montage_tab = ttk.Frame(self.blaetter, padding=(10, 10))
|
||
self.blaetter.add(montage_tab, text=" Montage ")
|
||
self._build_montage(montage_tab)
|
||
|
||
wrap = ttk.LabelFrame(camping, text="Leistungen – Anzahl (und ggf. Personen) eintragen", padding=(6, 4))
|
||
wrap.pack(side="top", fill="both", expand=True)
|
||
|
||
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
|
||
|
||
# ------------------------------------------------------------ Montage
|
||
def _build_montage(self, eltern):
|
||
"""Reiter fuer die Monatsmiete eines Montagearbeiters.
|
||
|
||
Eine Zeile, ein Monatspreis, ein Zeitraum - keine Nachtpreise. Der
|
||
Zeitraum kommt aus denselben beiden Feldern oben rechts, die bei der
|
||
Campingrechnung An- und Abreise heissen; die Naechte daraus meldet das
|
||
Steuerjournal spaeter ans Amt.
|
||
"""
|
||
self.m_monate = tk.StringVar(value="1")
|
||
self.m_monate.trace_add("write", lambda *a: self.recalc())
|
||
|
||
ttk.Label(eltern, text="Monatsmiete für einen Montagearbeiter",
|
||
font=("Segoe UI", 11, "bold")).grid(row=0, column=0, columnspan=4, sticky="w")
|
||
ttk.Label(eltern, wraplength=760, justify="left", foreground=self.col_muted,
|
||
text="Den Zeitraum trägst du oben rechts ein (dieselben Felder wie Anreise/Abreise). "
|
||
"Bezeichnung, Steuersatz und Monatspreis stehen unter "
|
||
"Einstellungen → Montage-Rechnung.").grid(
|
||
row=1, column=0, columnspan=4, sticky="w", pady=(2, 12))
|
||
|
||
ttk.Label(eltern, text="Monate:").grid(row=2, column=0, sticky="w", pady=3)
|
||
ttk.Entry(eltern, textvariable=self.m_monate, width=8, justify="center").grid(
|
||
row=2, column=1, sticky="w", padx=(8, 24), pady=3)
|
||
ttk.Label(eltern, text="Monatspreis (brutto):").grid(row=2, column=2, sticky="w", pady=3)
|
||
self.m_preis = ttk.Label(eltern, text="", font=("Segoe UI", 10, "bold"))
|
||
self.m_preis.grid(row=2, column=3, sticky="w", padx=(8, 0), pady=3)
|
||
|
||
ttk.Separator(eltern, orient="horizontal").grid(
|
||
row=3, column=0, columnspan=4, sticky="ew", pady=12)
|
||
|
||
ttk.Label(eltern, text="So steht es auf der Rechnung:",
|
||
font=("Segoe UI", 9, "bold")).grid(row=4, column=0, columnspan=4, sticky="w")
|
||
self.m_text = ttk.Label(eltern, text="", wraplength=760, justify="left")
|
||
self.m_text.grid(row=5, column=0, columnspan=4, sticky="w", pady=(4, 10))
|
||
self.m_zahlen = ttk.Label(eltern, text="", font=("Segoe UI", 10))
|
||
self.m_zahlen.grid(row=6, column=0, columnspan=4, sticky="w")
|
||
eltern.columnconfigure(3, weight=1)
|
||
|
||
def ist_montage(self):
|
||
"""Liegt der Montage-Reiter oben?"""
|
||
try:
|
||
return self.blaetter.index("current") == 1
|
||
except (AttributeError, tk.TclError):
|
||
return False
|
||
|
||
def _blatt_gewechselt(self):
|
||
"""Reiterwechsel: die zwei Datumsfelder heissen hier anders, und gerechnet
|
||
wird ab jetzt die andere Sorte Rechnung."""
|
||
if not hasattr(self, "datum_labels") or not hasattr(self, "m_monate"):
|
||
return
|
||
if self.ist_montage():
|
||
self.datum_labels["von"].configure(text="Zeitraum von (TT.MM.JJJJ)")
|
||
self.datum_labels["bis"].configure(text="Zeitraum bis (TT.MM.JJJJ)")
|
||
self.naechte_label.configure(text="Nächte im Zeitraum")
|
||
else:
|
||
self.datum_labels["von"].configure(text="Anreise (TT.MM.JJJJ)")
|
||
self.datum_labels["bis"].configure(text="Abreise (TT.MM.JJJJ)")
|
||
self.naechte_label.configure(text="Nächte")
|
||
self.recalc()
|
||
|
||
def _montage_text(self):
|
||
"""Die eine Zeile, die auf dem Blatt steht - Wortlaut wie bei Bartl."""
|
||
m = self.cfg.get("montage", {})
|
||
satz = int(parse_num(str(m.get("satz", 7))))
|
||
text = f"{m.get('leistung', 'Miete')} inkl. {satz} % MwSt."
|
||
von = self.r_anreise.get().strip()
|
||
bis = self.r_abreise.get().strip()
|
||
if von and bis:
|
||
text += f" für den Zeitraum {von} – {bis}"
|
||
elif von:
|
||
text += f" ab {von}"
|
||
monate = parse_num(self.m_monate.get())
|
||
if monate and abs(monate - 1) > 0.001:
|
||
anz = berechnung.eur_kurz(monate).replace(",00", "")
|
||
text += f" ({anz} Monate × {berechnung.eur(float(m.get('preis', 0) or 0))})"
|
||
return text
|
||
|
||
def _montage_katalog(self):
|
||
"""Ein-Zeilen-Katalog fuer den gemeinsamen Rechenkern.
|
||
|
||
Auch die Montage-Rechnung geht durch berechnung.berechne() - es bleibt
|
||
EINE Rechenstelle, und die Beherbergungssteuer kommt genauso obendrauf
|
||
wie bei jeder Campingrechnung.
|
||
"""
|
||
m = self.cfg.get("montage", {})
|
||
return [{"nr": 1, "leistung": self._montage_text(),
|
||
"satz": int(parse_num(str(m.get("satz", 7)))),
|
||
"preis": float(m.get("preis", 0) or 0), "pro_person": False}]
|
||
|
||
# ------------------------------------------------------------- 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")
|
||
# Steht der gemeinsame Ordner? Das gehoert sichtbar hin - ohne ihn kennt
|
||
# das Programm die Nummern des Steuerjournals nicht.
|
||
self.buch_lbl = ttk.Label(status, text="", foreground=self.col_muted,
|
||
font=("Segoe UI", 8))
|
||
self.buch_lbl.pack(side="left")
|
||
|
||
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)
|
||
|
||
# --------------------------------------------------- Gemeinsames Buch
|
||
def _buch_oeffnen(self):
|
||
"""Nummernbuch aus dem eingestellten Ordner - oder None."""
|
||
ordner = str(self.cfg.get("gemeinsam_ordner", "") or "")
|
||
if not ordner:
|
||
self.buch = None
|
||
return
|
||
taugt, grund = gemeinsam.ordner_taugt(ordner)
|
||
if not taugt:
|
||
self.buch = None
|
||
print("[nummernbuch] nicht erreichbar:", grund)
|
||
return
|
||
self.buch = gemeinsam.Nummernbuch(ordner)
|
||
|
||
def _frage_gemeinsam_falls_noetig(self):
|
||
"""Beim ersten Start nach dem gemeinsamen Ordner fragen.
|
||
|
||
Wer abbricht, kann weiterarbeiten - dann steht aber in der Fussleiste,
|
||
dass die Nummern des Steuerjournals unbekannt sind. Beim naechsten Start
|
||
wird wieder gefragt.
|
||
"""
|
||
self._buch_oeffnen()
|
||
if self.buch is None and not TESTLAUF:
|
||
self.dlg_gemeinsam(erststart=True)
|
||
self._buch_anzeigen()
|
||
|
||
def dlg_gemeinsam(self, erststart=False):
|
||
ordner = ordnerwahl.ordner_abfragen(
|
||
self, "Rechnungstool", str(self.cfg.get("gemeinsam_ordner", "") or ""))
|
||
if not ordner:
|
||
if erststart:
|
||
messagebox.showwarning(
|
||
"Ohne gemeinsamen Ordner",
|
||
"Es wurde kein gemeinsamer Ordner gewählt.\n\n"
|
||
"Das Rechnungstool kennt jetzt nur die Nummern seiner eigenen "
|
||
"PDFs - eine Buchung, die nur im Steuerjournal steht, kann eine "
|
||
"Nummer ein zweites Mal bekommen.\n\n"
|
||
"Nachholen: Einstellungen -> Gemeinsamer Ordner (Nummernbuch).")
|
||
return
|
||
self.cfg["gemeinsam_ordner"] = ordner
|
||
config.save_config(self.cfg)
|
||
self._buch_oeffnen()
|
||
self._nachtragen()
|
||
self._buch_anzeigen()
|
||
self._neue_nummer_vorschlagen()
|
||
|
||
def _buch_anzeigen(self):
|
||
"""Fussleiste: welcher Ordner, wie viele Nummern - oder die Warnung."""
|
||
if not hasattr(self, "buch_lbl"):
|
||
return
|
||
if self.buch is None:
|
||
self.buch_lbl.configure(
|
||
text="kein gemeinsamer Ordner - Nummern des Steuerjournals unbekannt",
|
||
foreground="#c04040")
|
||
return
|
||
try:
|
||
anzahl = len(self.buch.nummern())
|
||
except Exception as e: # noqa: BLE001
|
||
self.buch_lbl.configure(text=f"Nummernbuch nicht lesbar: {e}",
|
||
foreground="#c04040")
|
||
return
|
||
self.buch_lbl.configure(text=f"Nummernbuch: {anzahl} Nummern",
|
||
foreground=self.col_muted)
|
||
|
||
def _belegte_nummern(self):
|
||
"""Alle im Buch vergebenen Nummern. Faellt das Buch aus, ist die Menge
|
||
leer - eine Rechnung muss auch dann geschrieben werden koennen."""
|
||
if self.buch is None:
|
||
return set()
|
||
try:
|
||
return self.buch.nummern()
|
||
except Exception as e: # noqa: BLE001
|
||
print("[nummernbuch] Lesen fehlgeschlagen:", e)
|
||
return set()
|
||
|
||
def _ins_buch(self, nummer, kopf, rechnung, pdf_pfad=""):
|
||
"""Eine geschriebene Rechnung ins gemeinsame Buch eintragen."""
|
||
if self.buch is None or not nummer:
|
||
return
|
||
aufschlag = rechnung.aufschlaege[0] if rechnung.aufschlaege else None
|
||
datum = parse_date(str(kopf.get("datum", "")))
|
||
try:
|
||
jahr = int(str(nummer).split("-")[0])
|
||
except (ValueError, IndexError):
|
||
jahr = datum.year if datum else datetime.date.today().year
|
||
try:
|
||
self.buch.eintragen(
|
||
nummer,
|
||
jahr=jahr,
|
||
datum=datum.isoformat() if datum else "",
|
||
nachname=str(kopf.get("nachname", "") or ""),
|
||
vorname=str(kopf.get("vorname", "") or ""),
|
||
naechte=int(parse_num(str(kopf.get("naechte") or 0)) or 0),
|
||
zwischensumme=berechnung.runde(rechnung.brutto),
|
||
steuer_satz=float(aufschlag.satz) if aufschlag else 0.0,
|
||
steuer_betrag=berechnung.runde(aufschlag.betrag) if aufschlag else 0.0,
|
||
gesamt=berechnung.runde(rechnung.gesamt),
|
||
art=str(kopf.get("art", "rechnung") or "rechnung"),
|
||
vorgang=str(kopf.get("vorgang", "") or ""),
|
||
storno_zu=str(kopf.get("storno_zu", "") or ""),
|
||
ersetzt=str(kopf.get("ersetzt", "") or ""),
|
||
berichtigt_zu=str(kopf.get("berichtigt_zu", "") or ""),
|
||
vorlage=str(kopf.get("vorlage", "") or ""),
|
||
quelle="rechnungstool", herkunft="pdf", pdf_pfad=pdf_pfad)
|
||
except Exception as e: # noqa: BLE001 - die PDF ist schon geschrieben
|
||
print("[nummernbuch] Eintragen fehlgeschlagen:", e)
|
||
messagebox.showwarning(
|
||
"Nummernbuch nicht erreichbar",
|
||
"Die Rechnung ist geschrieben, konnte aber nicht ins gemeinsame "
|
||
f"Nummernbuch eingetragen werden:\n{e}\n\n"
|
||
"Das Steuerjournal sieht sie trotzdem - es liest die PDFs.")
|
||
|
||
def _nachtragen(self):
|
||
"""Alles, was schon im Rechnungsordner liegt, ins Buch schreiben.
|
||
|
||
Laeuft beim Einrichten des Ordners einmal durch - sonst faengt das Buch
|
||
bei null an und kennt die bisherigen Nummern nicht.
|
||
"""
|
||
if self.buch is None:
|
||
return
|
||
anzahl = 0
|
||
for meta in rechnungen_im_ordner(config.ensure_output_dir(self.cfg)):
|
||
nummer = str(meta.get("rechnungsnummer") or "")
|
||
if not nummer:
|
||
continue
|
||
datum = parse_date(str(meta.get("datum", "")))
|
||
try:
|
||
jahr = int(nummer.split("-")[0])
|
||
except (ValueError, IndexError):
|
||
jahr = datum.year if datum else 0
|
||
try:
|
||
self.buch.eintragen(
|
||
nummer, jahr=jahr,
|
||
datum=datum.isoformat() if datum else "",
|
||
nachname=str(meta.get("nachname", "") or ""),
|
||
vorname=str(meta.get("vorname", "") or ""),
|
||
naechte=int(parse_num(str(meta.get("naechte") or 0)) or 0),
|
||
zwischensumme=float(meta.get("zwischensumme") or 0),
|
||
steuer_satz=float(meta.get("steuer_satz") or 0),
|
||
steuer_betrag=float(meta.get("steuer_betrag") or 0),
|
||
gesamt=float(meta.get("gesamt") or 0),
|
||
art=str(meta.get("art", "rechnung") or "rechnung"),
|
||
vorgang=str(meta.get("vorgang", "") or ""),
|
||
storno_zu=str(meta.get("storno_zu", "") or ""),
|
||
ersetzt=str(meta.get("ersetzt", "") or ""),
|
||
berichtigt_zu=str(meta.get("berichtigt_zu", "") or ""),
|
||
vorlage=str(meta.get("vorlage", "") or ""),
|
||
quelle="rechnungstool", herkunft="pdf")
|
||
anzahl += 1
|
||
except Exception as e: # noqa: BLE001
|
||
print("[nummernbuch] Nachtragen fehlgeschlagen:", e)
|
||
break
|
||
if anzahl:
|
||
print(f"[nummernbuch] {anzahl} vorhandene Rechnungen nachgetragen")
|
||
|
||
# --------------------------------------------------------- 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_montage(self):
|
||
"""Montage-Reiter durchrechnen und seine Anzeige nachziehen."""
|
||
m = self.cfg.get("montage", {})
|
||
r = berechnung.berechne(self._montage_katalog(),
|
||
{1: {"anzahl": parse_num(self.m_monate.get()), "personen": 0}},
|
||
self.cfg)
|
||
self.m_preis.configure(text=berechnung.eur(float(m.get("preis", 0) or 0)))
|
||
self.m_text.configure(text=self._montage_text())
|
||
netto = sum(z.netto for z in r.zeilen)
|
||
self.m_zahlen.configure(
|
||
text=f"Netto {berechnung.eur(netto)} "
|
||
f"USt. {berechnung.eur(r.brutto - netto)} "
|
||
f"Brutto {berechnung.eur(r.brutto)}")
|
||
return r
|
||
|
||
def recalc(self):
|
||
if self.ist_montage():
|
||
r = self._recalc_montage()
|
||
else:
|
||
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,
|
||
self._belegte_nummern())
|
||
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 _datumsfelder_pruefen(self):
|
||
"""Welche Datumsfelder sind unlesbar? Leere Felder sind in Ordnung.
|
||
|
||
Geprueft wird mit parse_date - also gegen den echten Kalender. Die
|
||
Tipp-Hilfe verhindert nur 32. und 13., den 31.02. faengt erst das hier.
|
||
"""
|
||
felder = [("Rechnungsdatum", self.r_datum.get().strip())]
|
||
if self.ist_montage():
|
||
felder += [("Zeitraum von", self.r_anreise.get().strip()),
|
||
("Zeitraum bis", self.r_abreise.get().strip())]
|
||
else:
|
||
felder += [("Anreise", self.r_anreise.get().strip()),
|
||
("Abreise", self.r_abreise.get().strip())]
|
||
return [f"{name}: „{wert}“ ist kein Datum"
|
||
for name, wert in felder if wert and not parse_date(wert)]
|
||
|
||
def pdf_erstellen(self):
|
||
montage = self.ist_montage()
|
||
r = self.recalc()
|
||
if not r.zeilen:
|
||
messagebox.showwarning(
|
||
"Keine Positionen",
|
||
"Bitte die Anzahl der Monate eintragen." if montage else
|
||
"Es wurde keine Leistung mit Anzahl > 0 eingetragen.")
|
||
return
|
||
if montage and not (self.r_anreise.get().strip() and self.r_abreise.get().strip()):
|
||
# Ohne Zeitraum steht auf dem Blatt nicht, wofuer gezahlt wird - und
|
||
# das Steuerjournal haette keine Naechte fuer die Meldung ans Amt.
|
||
messagebox.showwarning(
|
||
"Zeitraum fehlt",
|
||
"Bitte oben rechts den Zeitraum eintragen (von / bis).\n\n"
|
||
"Er steht auf der Rechnung und ist die Grundlage für die "
|
||
"Übernachtungen, die ans Amt gemeldet werden.")
|
||
return
|
||
nummer = self.r_nummer.get().strip()
|
||
if not nummer:
|
||
messagebox.showwarning("Rechnungsnummer fehlt", "Bitte eine Rechnungsnummer eingeben.")
|
||
return
|
||
# Den 31.02. gibt es auch dann nicht, wenn Tag und Monat einzeln moeglich
|
||
# sind. Ein falsches Datum auf einer ausgestellten Rechnung laesst sich nur
|
||
# noch ueber eine Berichtigung geradeziehen - also hier abfangen.
|
||
kaputt = self._datumsfelder_pruefen()
|
||
if kaputt:
|
||
messagebox.showwarning(
|
||
"Datum nicht lesbar",
|
||
"So kann die Rechnung nicht geschrieben werden:" + chr(10) + chr(10) +
|
||
"- " + (chr(10) + "- ").join(kaputt) + chr(10) + chr(10) +
|
||
"Erwartet wird ein Datum wie 12.08.2026.")
|
||
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 montage:
|
||
# Das Blatt sieht anders aus (eine Zeile statt Tabelle), die Buchung
|
||
# dahinter ist eine ganz normale: Zwischensumme + Beherbergungssteuer.
|
||
kopf["vorlage"] = "montage"
|
||
kopf["montage_text"] = self._montage_text()
|
||
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}")
|
||
# Das Blatt verbraucht keine Nummer und bucht nichts - im Buch wird
|
||
# nur der berichtigte Name der Rechnung nachgezogen. Wuerde man hier
|
||
# den ganzen Eintrag schreiben, stuenden dort die Nullen des Blattes.
|
||
if self.buch is not None:
|
||
try:
|
||
self.buch.eintragen(ber["zu"],
|
||
nachname=kopf.get("nachname", ""),
|
||
vorname=kopf.get("vorname", ""),
|
||
berichtigt_zu=ber["zu"])
|
||
except Exception as e: # noqa: BLE001
|
||
print("[nummernbuch] Berichtigung nicht eingetragen:", e)
|
||
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)
|
||
# Auch das gemeinsame Nummernbuch zaehlt als vergeben: dort stehen die
|
||
# Nummern des Steuerjournals, zu denen es hier gar keine PDF gibt.
|
||
im_buch = nummer in self._belegte_nummern()
|
||
if vorhanden or im_buch:
|
||
liste = "\n".join("- " + os.path.basename(x) for x in vorhanden)
|
||
if im_buch and not vorhanden:
|
||
liste = "- steht im gemeinsamen Nummernbuch (Steuerjournal)"
|
||
frei = naechste_freie_nummer(out_dir, self.cfg, self._belegte_nummern())
|
||
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
|
||
|
||
# Der Dateiname faengt weiter mit "Rechnung_<nummer>" an - daran erkennt
|
||
# bestand.vorhandene_rechnungen() eine schon vergebene Nummer. Das
|
||
# "_Montage" haengt nur hinten dran, damit man die Sorte im Ordner sieht.
|
||
fname = f"Rechnung_{safe_filename(nummer)}"
|
||
if self.k_name.get().strip():
|
||
fname += "_" + safe_filename(self.k_name.get().strip())
|
||
if montage:
|
||
fname += "_Montage"
|
||
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
|
||
|
||
# Ins gemeinsame Nummernbuch, damit das Steuerjournal die Nummer kennt -
|
||
# auch wenn es die PDF (noch) nicht eingelesen hat.
|
||
self._ins_buch(nummer, kopf, r, pfad)
|
||
self._buch_anzeigen()
|
||
|
||
# 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):
|
||
# Der Montage-Reiter hat eine eigene Eingabe - die gehoert mit zurueck.
|
||
if hasattr(self, "m_monate"):
|
||
self.m_monate.set("1")
|
||
# 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()
|