Das zweite Blatt "fuer das Amt" trug DIESELBE Rechnungsnummer, aber einen anderen Gesamtbetrag. Mehrere Blaetter zum selben Umsatz mit verschiedenen Endbetraegen sehen aus, als werde ueber verschiedene Umsaetze abgerechnet - bei einer Pruefung ein Problem. Ersatzlos entfallen; was die Stadt braucht, rechnet das Steuerjournal aus den Kenndaten der PDF. Mit dem Blatt fielen _AmtMarke, gesamtrechnung_betont und der doppelte Renderlauf weg. Die API kann das Fenster jetzt BEDIENEN - mit Schluessel wie bei einem SSH-Key: 32 Byte Zufall in api_schluessel.txt im gemeinsamen Ordner, den nur das Steuerjournal und der Betreiber lesen. Ohne gueltigen Schluessel kein Zutritt (compare_digest). ping und schau_nach bleiben schluessellos, sonst kann ein aelteres Journal nicht mehr anklopfen. Was einen BELEG schreibt, geht nur im Testlauf - eine Fernbedienung, die echte Rechnungen ausstellt, hat in einer Buchhaltung nichts zu suchen. api.py liegt wie immer in beiden Programmen. Der Erststart fragte nach "der zuletzt vergebenen Nummer" - das holt niemanden ab, der bei 0 anfaengt. Jetzt zwei Wege: bei 0 anfangen ODER die eigene Zaehlung weiterfuehren. Unter das schon Vergebene kommt keiner der beiden. pruef_nummernsperre bediente den Dialog nur halb: nach einer abgelehnten Zahl bleibt er offen (so soll er), niemand drueckte "Spaeter" - das Programm wartete in wait_window() ewig. Das war schon vor diesem Umbau so. Neu: pruef_api.py. Alle 14 Pruefstaende gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
1640 lines
79 KiB
Python
1640 lines
79 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 tempfile
|
||
import sys
|
||
import datetime
|
||
import queue
|
||
import tkinter as tk
|
||
from tkinter import ttk, messagebox
|
||
|
||
import api
|
||
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 (hoechster_zaehler_im_ordner, 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
|
||
# Die API kann das Fenster auch BEDIENEN (nur mit Schluessel, siehe
|
||
# api.py). Die Warteschlange bringt die Aktion aus dem Netzfaden
|
||
# hierher - Tkinter darf nur aus seinem eigenen Faden angefasst werden.
|
||
self._api_schlange = api.Warteschlange()
|
||
self.api = api.MiniApi("rechnungstool", self._push_empfangen,
|
||
aktion=self._api_schlange.annehmen)
|
||
self.after(150, self._api_pumpe)
|
||
self._push_kasten = queue.Queue()
|
||
# 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)
|
||
# ... und danach einmalig nach dem Stand der Rechnungsnummer.
|
||
self.after(600, self._frage_nummer_falls_noetig)
|
||
self.after(500, self._push_abholen)
|
||
|
||
|
||
# ---------------------------------------------------------------- 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": [
|
||
("Vorschau ansehen", self.vorschau_zeigen),
|
||
("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="Vorschau", command=self.vorschau_zeigen, width=12).pack(side="left", padx=4)
|
||
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)
|
||
# Auch das Rechnungstool macht seinen kleinen Server auf: die beiden
|
||
# sollen sich GEGENSEITIG sehen, nicht nur einer den anderen.
|
||
self.api.starten(self.buch)
|
||
|
||
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 _frage_nummer_falls_noetig(self):
|
||
"""Beim ersten Start einmal nach dem Stand der Rechnungsnummer fragen.
|
||
|
||
Er kommt aus einer Excel-Tabelle mit laufender Nummer - die muss er
|
||
einmal nennen koennen, sonst faengt das Programm bei 001 an und vergibt
|
||
Nummern ein zweites Mal. Danach ist der Zaehler zu.
|
||
"""
|
||
if TESTLAUF or self.cfg.get("nummer_eingerichtet"):
|
||
return
|
||
jahr = int(self.cfg.get("rechnung_jahr", 0) or datetime.date.today().year)
|
||
gefunden = self.hoechster_stand(jahr)
|
||
vorschlag = max(gefunden, int(self.cfg.get("rechnung_zaehler", 0) or 0))
|
||
|
||
d = tk.Toplevel(self)
|
||
d.title("Rechnungsnummer einrichten")
|
||
d.transient(self)
|
||
d.resizable(False, False)
|
||
self._style_dialog(d)
|
||
wert = tk.StringVar(value=str(vorschlag))
|
||
fertig = {"ok": False}
|
||
|
||
rahmen = ttk.Frame(d, padding=18)
|
||
rahmen.pack(fill="both", expand=True)
|
||
ttk.Label(rahmen, text="Wo soll die Rechnungsnummer weiterlaufen?",
|
||
font=("Segoe UI", 13, "bold")).grid(row=0, column=0, columnspan=2, sticky="w")
|
||
ttk.Label(rahmen, wraplength=620, justify="left", text=(
|
||
"Zwei Wege - such dir einen aus." + chr(10) + chr(10) +
|
||
"Das wird EINMAL eingerichtet. Danach steht der Zähler fest: eine "
|
||
"ausgestellte Rechnung wird nur noch über Storno oder Berichtigung "
|
||
"korrigiert, und jede Nummer gibt es nur einmal "
|
||
"(§ 14 Abs. 4 Nr. 4 UStG).")).grid(
|
||
row=1, column=0, columnspan=2, sticky="w", pady=(8, 12))
|
||
|
||
if gefunden:
|
||
tk.Label(rahmen, wraplength=620, justify="left", fg="#1f5c3d",
|
||
font=("Segoe UI", 9, "bold"),
|
||
text=f"Im Rechnungsordner und im Nummernbuch steht schon "
|
||
f"{jahr}-{gefunden:03d} – niedriger geht es nicht.").grid(
|
||
row=2, column=0, columnspan=2, sticky="w", pady=(0, 10))
|
||
|
||
# Zwei Wege statt einer Zahl: neu anfangen oder die eigene Zaehlung
|
||
# weiterfuehren. Die Frage "trag die zuletzt vergebene Nummer ein" holt
|
||
# niemanden ab, der bei 0 anfaengt - der hat gar keine.
|
||
weg = tk.StringVar(value="weiter" if gefunden else "null")
|
||
zeile = ttk.Frame(rahmen)
|
||
# Das Eingabefeld ist ein Kind DIESER Zeile - ein Widget muss dort
|
||
# zuhause sein, wo es gepackt wird, sonst mischt Tk grid und pack im
|
||
# selben Rahmen und bricht ab.
|
||
eingabe = ttk.Entry(zeile, textvariable=wert, width=10, justify="center")
|
||
|
||
def weg_gewaehlt(*_):
|
||
eingabe.configure(state="normal" if weg.get() == "weiter" else "disabled")
|
||
|
||
ttk.Radiobutton(rahmen, variable=weg, value="null", command=weg_gewaehlt,
|
||
text=f"Bei 0 anfangen - die erste Rechnung wird {jahr}-001").grid(
|
||
row=3, column=0, columnspan=2, sticky="w", pady=(0, 4))
|
||
zeile.grid(row=4, column=0, columnspan=2, sticky="w")
|
||
ttk.Radiobutton(zeile, variable=weg, value="weiter", command=weg_gewaehlt,
|
||
text=f"Meine Zählung weiterführen - zuletzt vergeben: {jahr} -").pack(
|
||
side="left")
|
||
eingabe.pack(side="left", padx=(8, 0))
|
||
weg_gewaehlt()
|
||
hinweis = tk.Label(rahmen, text="", wraplength=620, justify="left", fg="#a00000",
|
||
font=("Segoe UI", 9))
|
||
hinweis.grid(row=5, column=0, columnspan=2, sticky="w", pady=(8, 0))
|
||
|
||
def uebernehmen():
|
||
if weg.get() == "null":
|
||
zahl = 0
|
||
else:
|
||
try:
|
||
zahl = int(parse_num(wert.get()))
|
||
except (TypeError, ValueError):
|
||
zahl = -1
|
||
if zahl < 0:
|
||
hinweis.configure(text="Bitte die zuletzt vergebene Nummer eintragen.")
|
||
return
|
||
# Unter das, was schon vergeben ist, darf niemand - egal auf welchem
|
||
# Weg. Sonst gaebe es eine Nummer zweimal (§ 14 Abs. 4 Nr. 4 UStG).
|
||
if zahl < gefunden:
|
||
hinweis.configure(
|
||
text=f"Schon vergeben ist {jahr}-{gefunden:03d} - darunter geht es nicht."
|
||
if weg.get() == "weiter" else
|
||
f"Bei 0 anfangen geht nicht mehr: {jahr}-{gefunden:03d} ist schon vergeben.")
|
||
return
|
||
if not messagebox.askyesno(
|
||
"Sind Sie sicher?",
|
||
f"Die nächste Rechnung bekommt die Nummer {jahr}-{zahl + 1:03d}.\n\n"
|
||
"Das lässt sich danach nicht mehr ändern.\n\n"
|
||
"Ja = so übernehmen\n"
|
||
"Nein = zurück zur Eingabe", icon="question", default="yes", parent=d):
|
||
return
|
||
self.cfg["rechnung_jahr"] = jahr
|
||
self.cfg["rechnung_zaehler"] = zahl
|
||
self.cfg["nummer_eingerichtet"] = True
|
||
try:
|
||
config.save_config(self.cfg)
|
||
except OSError as e: # noqa: BLE001
|
||
messagebox.showwarning("Nicht gespeichert",
|
||
f"Der Stand ließ sich nicht speichern:\n{e}", parent=d)
|
||
return
|
||
fertig["ok"] = True
|
||
d.destroy()
|
||
|
||
knoepfe = ttk.Frame(rahmen)
|
||
knoepfe.grid(row=6, column=0, columnspan=2, sticky="ew", pady=(16, 0))
|
||
ttk.Button(knoepfe, text="Übernehmen", width=16, command=uebernehmen).pack(side="right")
|
||
ttk.Button(knoepfe, text="Später", width=12, command=d.destroy).pack(side="left")
|
||
|
||
d.protocol("WM_DELETE_WINDOW", d.destroy)
|
||
d.bind("<Escape>", lambda _e: d.destroy())
|
||
d.update_idletasks()
|
||
x = self.winfo_rootx() + max(0, (self.winfo_width() - d.winfo_reqwidth()) // 2)
|
||
d.geometry(f"+{max(0, x)}+{self.winfo_rooty() + 110}")
|
||
d.grab_set()
|
||
self.wait_window(d)
|
||
if fertig["ok"]:
|
||
self._neue_nummer_vorschlagen()
|
||
|
||
def hoechster_stand(self, jahr=None):
|
||
"""Hoechster JE VERGEBENER Zaehler dieses Jahres - aus beiden Quellen.
|
||
|
||
Der Zaehler in der config allein taugt nicht als Wahrheit: er laesst
|
||
sich in den Einstellungen zuruecksetzen. Deshalb zaehlt, was WIRKLICH
|
||
vergeben ist - die PDFs im Ordner und das gemeinsame Nummernbuch (das
|
||
kennt auch die Nummern aus dem Altbestand, zu denen es keine PDF gibt).
|
||
"""
|
||
jahr = int(jahr or self.cfg.get("rechnung_jahr", 0) or datetime.date.today().year)
|
||
hoch = hoechster_zaehler_im_ordner(config.ensure_output_dir(self.cfg), jahr)
|
||
if self.buch is not None:
|
||
try:
|
||
hoch = max(hoch, self.buch.hoechster_zaehler(jahr))
|
||
except Exception as e: # noqa: BLE001 - ohne Buch bleibt der Ordner
|
||
print("[nummernbuch] Hoechststand nicht lesbar:", e)
|
||
return hoch
|
||
|
||
def _zaehler_absichern(self):
|
||
"""Einen zurueckgedrehten Zaehler stillschweigend wieder anheben.
|
||
|
||
Nicht als Vorwurf - es reicht ein alter Rechner, eine wiederhergestellte
|
||
config oder ein Jahreswechsel von Hand. Vergeben ist vergeben.
|
||
"""
|
||
jahr = int(self.cfg.get("rechnung_jahr", 0) or datetime.date.today().year)
|
||
if jahr != datetime.date.today().year:
|
||
return
|
||
hoch = self.hoechster_stand(jahr)
|
||
if hoch > int(self.cfg.get("rechnung_zaehler", 0) or 0):
|
||
print(f"[nummern] Zaehler stand auf {self.cfg.get('rechnung_zaehler')}, "
|
||
f"vergeben ist bis {hoch} - angehoben")
|
||
self.cfg["rechnung_zaehler"] = hoch
|
||
try:
|
||
config.save_config(self.cfg)
|
||
except OSError as e: # noqa: BLE001
|
||
print("[nummern] Zaehler nicht gespeichert:", e)
|
||
|
||
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 _journal_benachrichtigen(self, text):
|
||
"""Dem laufenden Steuerjournal Bescheid sagen.
|
||
|
||
Erst nachsehen, ob es ueberhaupt offen ist (es legt einen Herzschlag im
|
||
gemeinsamen Ordner ab), dann eine Zeile in den Briefkasten. Das Journal
|
||
liest die Rechnung daraufhin von selbst ein.
|
||
|
||
Ist es zu, passiert nichts - es liest den Ordner beim naechsten Start
|
||
ohnehin. Die Nachricht ist eine Anklopfhilfe, keine Datenquelle.
|
||
"""
|
||
if self.buch is None:
|
||
return
|
||
try:
|
||
if not self.buch.laeuft("journal"):
|
||
print("[melden] Steuerjournal laeuft nicht - es liest die Rechnung "
|
||
"beim naechsten Start")
|
||
return
|
||
# Erst die Meldung in die SQLite (das ist die Wahrheit), dann
|
||
# anklopfen. Geht der Anruf nicht durch, findet das Journal sie
|
||
# beim naechsten Nachsehen trotzdem.
|
||
self.buch.melden("rechnungstool", text)
|
||
self.buch.aufraeumen()
|
||
gehoert = api.anklopfen(self.buch, "journal")
|
||
print(f"[melden] ans Steuerjournal: {text}"
|
||
+ (" (angeklopft)" if gehoert else " (in der SQLite, nicht erreicht)"))
|
||
except Exception as e: # noqa: BLE001 - die Rechnung ist schon geschrieben
|
||
print("[melden] fehlgeschlagen:", e)
|
||
|
||
def _push_empfangen(self):
|
||
"""Aus dem API-Faden - hier nur einen Zettel hinterlegen."""
|
||
self._push_kasten.put(1)
|
||
|
||
def _push_abholen(self):
|
||
"""Im Fenster-Faden: hat das Journal angeklopft, Nummernvorschlag frisch holen."""
|
||
try:
|
||
geklopft = False
|
||
while True:
|
||
try:
|
||
self._push_kasten.get_nowait()
|
||
geklopft = True
|
||
except queue.Empty:
|
||
break
|
||
if geklopft:
|
||
self._buch_anzeigen()
|
||
self._neue_nummer_vorschlagen()
|
||
except Exception as e: # noqa: BLE001
|
||
print("[push]", e)
|
||
finally:
|
||
self.after(250, self._push_abholen)
|
||
|
||
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
|
||
# Erst sicherstellen, dass der Zaehler nicht hinter dem steht, was
|
||
# schon vergeben ist - sonst schlaegt das Programm eine Nummer vor,
|
||
# die es ein zweites Mal vergeben wuerde.
|
||
self._zaehler_absichern()
|
||
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 _art_waehlen(self, nummer, betrag, wer):
|
||
"""Popup: Rechnung oder Angebot? Gibt "rechnung", "angebot" oder None.
|
||
|
||
Ersetzt die fruehere Sicherheitsfrage - EIN Fenster statt zwei. Die
|
||
Warnung, dass eine ausgestellte Rechnung nicht mehr zu aendern ist,
|
||
steht weiter drin: sie gilt genau fuer diesen einen Knopf.
|
||
"""
|
||
# Im Testlauf gibt es niemanden, der klickt: dann gilt der uebliche Weg
|
||
# (Rechnung). Der Pruefstand, der GENAU DIESES Fenster pruefen will,
|
||
# setzt _dialog_zeigen und drueckt die Knoepfe wirklich.
|
||
if TESTLAUF and not getattr(self, "_dialog_zeigen", False):
|
||
# _testwahl: was der Pruefstand im Fenster geklickt haette.
|
||
# "rechnung" (Vorgabe), "angebot" oder None fuer Abbrechen.
|
||
return getattr(self, "_testwahl", "rechnung")
|
||
d = tk.Toplevel(self)
|
||
d.title("Was soll erstellt werden?")
|
||
d.transient(self)
|
||
d.resizable(False, False)
|
||
self._style_dialog(d)
|
||
wahl = {"art": None}
|
||
|
||
rahmen = ttk.Frame(d, padding=18)
|
||
rahmen.pack(fill="both", expand=True)
|
||
ttk.Label(rahmen, text=f"{wer or 'ohne Namen'} – {betrag}",
|
||
font=("Segoe UI", 12, "bold")).grid(row=0, column=0, sticky="w")
|
||
|
||
ttk.Label(rahmen, wraplength=560, justify="left", text=(
|
||
f"RECHNUNG {nummer} – der Beleg. Danach lässt sie sich nicht mehr "
|
||
"ändern: korrigiert wird nur über Storno oder Berichtigung, und jede "
|
||
"Nummer gibt es nur einmal (§ 14 Abs. 4 Nr. 4 UStG). Das Steuerjournal "
|
||
"bucht sie und meldet die Übernachtungen ans Amt.")).grid(
|
||
row=1, column=0, sticky="w", pady=(10, 8))
|
||
ttk.Label(rahmen, wraplength=560, justify="left", text=(
|
||
"ANGEBOT – unverbindliche Kostenaufstellung vorab (§ 145 BGB). Keine "
|
||
"Rechnung im Sinne des § 14 UStG, kein Vorsteuerabzug, nicht zu zahlen. "
|
||
"Es verbraucht KEINE Rechnungsnummer (eigene Reihe) und wird nirgends "
|
||
f"gebucht – die nächste Rechnung bleibt {nummer}.")).grid(
|
||
row=2, column=0, sticky="w", pady=(0, 14))
|
||
|
||
def nimm(art):
|
||
wahl["art"] = art
|
||
d.destroy()
|
||
|
||
knoepfe = ttk.Frame(rahmen)
|
||
knoepfe.grid(row=3, column=0, sticky="ew")
|
||
ttk.Button(knoepfe, text=f"Rechnung {nummer} erstellen", width=28,
|
||
command=lambda: nimm("rechnung")).pack(side="left")
|
||
ttk.Button(knoepfe, text="Angebot erstellen", width=20,
|
||
command=lambda: nimm("angebot")).pack(side="left", padx=8)
|
||
ttk.Button(knoepfe, text="Abbrechen", width=14,
|
||
command=d.destroy).pack(side="right")
|
||
|
||
d.protocol("WM_DELETE_WINDOW", d.destroy)
|
||
d.bind("<Escape>", lambda _e: d.destroy())
|
||
d.update_idletasks()
|
||
x = self.winfo_rootx() + max(0, (self.winfo_width() - d.winfo_reqwidth()) // 2)
|
||
d.geometry(f"+{max(0, x)}+{self.winfo_rooty() + 130}")
|
||
d.grab_set()
|
||
self.wait_window(d)
|
||
return wahl["art"]
|
||
|
||
def _angebot_ordner(self):
|
||
"""Unterordner "Angebote" neben den Rechnungen.
|
||
|
||
Angebote sind keine Buchhaltung - im Rechnungsordner haetten sie nur
|
||
zwischen den Belegen gelegen. Aufzubewahren sind sie trotzdem: ein
|
||
abgesandtes Angebot ist ein Handelsbrief (§ 257 HGB). Das Steuerjournal
|
||
liest den Ordner mit, ueberspringt sie aber (art="angebot").
|
||
"""
|
||
ordner = os.path.join(config.ensure_output_dir(self.cfg), "Angebote")
|
||
try:
|
||
os.makedirs(ordner, exist_ok=True)
|
||
except OSError as e: # noqa: BLE001 - dann eben neben die Rechnungen
|
||
print("[angebot] Unterordner nicht anlegbar:", e)
|
||
return config.ensure_output_dir(self.cfg)
|
||
return ordner
|
||
|
||
def _naechstes_angebot(self):
|
||
"""JJJJ-Annn aus der EIGENEN Reihe. Der Jahreswechsel faengt bei A001 an.
|
||
|
||
Wichtig: hier wird der Rechnungszaehler nicht angefasst. Ein Angebot
|
||
darf keine Rechnungsnummer verbrauchen - sonst muesste man die Nummern
|
||
wieder von Hand aendern koennen, und genau daher kamen die doppelten.
|
||
"""
|
||
jahr = datetime.date.today().year
|
||
zaehler = int(self.cfg.get("angebot_zaehler", 0) or 0)
|
||
if int(self.cfg.get("angebot_jahr", 0) or 0) != jahr:
|
||
zaehler = 0
|
||
ordner = self._angebot_ordner()
|
||
try:
|
||
vorhanden = os.listdir(ordner)
|
||
except OSError:
|
||
vorhanden = []
|
||
for _ in range(1000):
|
||
zaehler += 1
|
||
nummer = f"{jahr}-A{zaehler:03d}"
|
||
if not any(d.startswith(f"Angebot_{nummer}") for d in vorhanden):
|
||
return jahr, zaehler, nummer
|
||
return jahr, zaehler, f"{jahr}-A{zaehler:03d}"
|
||
|
||
# ------------------------------------------------- Bedienung ueber die API
|
||
def _api_pumpe(self):
|
||
"""Holt Aktionen aus der Warteschlange und fuehrt sie HIER aus.
|
||
|
||
Laeuft, solange das Fenster steht. Tkinter darf nur aus seinem eigenen
|
||
Faden angefasst werden - der API-Server lauscht in einem anderen.
|
||
"""
|
||
try:
|
||
self._api_schlange.abarbeiten(self._api_aktion)
|
||
finally:
|
||
self.after(150, self._api_pumpe)
|
||
|
||
def _api_aktion(self, name, daten):
|
||
"""Eine Bedienung ausfuehren. Der Schluessel ist schon geprueft (api.py).
|
||
|
||
Nachschlagetabelle statt if-Kette: ein neuer Knopf ist EIN Eintrag.
|
||
|
||
Was einen BELEG schreibt, geht nur im Testlauf. Eine Fernbedienung, die
|
||
im laufenden Betrieb echte Rechnungen ausstellt, hat in einer
|
||
Buchhaltung nichts zu suchen - eine ausgestellte Rechnung bekommt man
|
||
nur ueber einen Storno wieder weg.
|
||
"""
|
||
schreibt_beleg = {"rechnung", "angebot"}
|
||
tabelle = {
|
||
"hilfe": lambda _d: {"kann": ["hilfe", "zustand", "setze", "zuruecksetzen",
|
||
"vorschau", "rechnung", "angebot"]},
|
||
"zustand": lambda _d: self._api_zustand(),
|
||
"setze": self._api_setzen,
|
||
"zuruecksetzen": lambda _d: (self.reset_formular(), {"ok": True})[1],
|
||
"vorschau": lambda d: {"pfad": self.vorschau_zeigen(
|
||
oeffnen=bool(d.get("oeffnen", False)))},
|
||
"rechnung": lambda _d: (self.pdf_erstellen(), self._api_zustand())[1],
|
||
"angebot": lambda _d: (self.pdf_erstellen(angebot=True), self._api_zustand())[1],
|
||
}
|
||
if name not in tabelle:
|
||
return {"fehler": "kenne ich nicht: " + str(name), "kann": sorted(tabelle)}
|
||
if name in schreibt_beleg and not TESTLAUF:
|
||
return {"fehler": "schreibt einen Beleg - geht nur im Testlauf"}
|
||
return tabelle[name](daten or {})
|
||
|
||
def _api_zustand(self):
|
||
"""Was steht gerade im Formular? Zum Nachsehen, ohne Maus."""
|
||
r = self.recalc()
|
||
return {
|
||
"reiter": "montage" if self.ist_montage() else "camping",
|
||
"nummer": self.r_nummer.get(),
|
||
"datum": self.r_datum.get(),
|
||
"anreise": self.r_anreise.get(),
|
||
"abreise": self.r_abreise.get(),
|
||
"naechte": self.r_naechte.get(),
|
||
"kunde": {"anrede": self.k_anrede.get(), "vorname": self.k_vorname.get(),
|
||
"nachname": self.k_name.get(), "zusatz": self.k_zusatz.get(),
|
||
"strasse": self.k_strasse.get(), "plzort": self.k_plzort.get()},
|
||
"positionen": [{"nr": z.nr, "leistung": z.leistung, "anzahl": z.anzahl,
|
||
"personen": z.personen, "brutto": z.brutto} for z in r.zeilen],
|
||
"gesamt": r.gesamt,
|
||
}
|
||
|
||
def _api_setzen(self, daten):
|
||
"""Felder fuellen, wie es sonst die Tastatur taete."""
|
||
felder = {
|
||
"anrede": self.k_anrede, "vorname": self.k_vorname, "nachname": self.k_name,
|
||
"zusatz": self.k_zusatz, "strasse": self.k_strasse, "plzort": self.k_plzort,
|
||
"nummer": self.r_nummer, "datum": self.r_datum,
|
||
"anreise": self.r_anreise, "abreise": self.r_abreise,
|
||
}
|
||
for schluessel, wert in (daten.get("kunde") or {}).items():
|
||
if schluessel in felder:
|
||
felder[schluessel].set(str(wert))
|
||
for schluessel in ("nummer", "datum", "anreise", "abreise"):
|
||
if schluessel in daten:
|
||
felder[schluessel].set(str(daten[schluessel]))
|
||
for eintrag in (daten.get("positionen") or []):
|
||
nr = int(eintrag.get("nr", 0))
|
||
if nr in self.anzahl_vars:
|
||
self.anzahl_vars[nr].set(str(eintrag.get("anzahl", "")))
|
||
if nr in self.personen_vars and "personen" in eintrag:
|
||
self.personen_vars[nr].set(str(eintrag["personen"]))
|
||
self.update()
|
||
return self._api_zustand()
|
||
|
||
def _kopf_basis(self, nummer, r):
|
||
"""Die Kopfdaten, die JEDES Blatt braucht - Rechnung wie Vorschau.
|
||
|
||
Steht nur hier: sonst tippt die Vorschau denselben Adressblock ein
|
||
zweites Mal ab, und beim naechsten neuen Feld zeigt sie ein Blatt, das
|
||
es so gar nicht gibt.
|
||
|
||
Auf der Rechnung stehen Vor- und Nachname wie gewohnt in einer Zeile;
|
||
der Nachname geht zusaetzlich 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)
|
||
return {
|
||
"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": [self.k_anrede.get(), namenszeile, self.k_zusatz.get(),
|
||
self.k_strasse.get(), self.k_plzort.get()],
|
||
"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],
|
||
}
|
||
|
||
def vorschau_zeigen(self, oeffnen=True):
|
||
"""Zeigt das Blatt, BEVOR es eine Rechnung wird.
|
||
|
||
Eine ausgestellte Rechnung laesst sich nur noch ueber einen Storno
|
||
geradeziehen - Papier, Porto und ein Weg zum Gast. Deshalb dieser Knopf:
|
||
ansehen, aendern, ansehen. Die Vorschau
|
||
|
||
- verbraucht KEINE Rechnungsnummer und traegt nichts ins Nummernbuch,
|
||
- landet NICHT im Rechnungsordner, sondern im Temp-Ordner,
|
||
- traegt quer ueber jede Seite "VORSCHAU - keine Rechnung".
|
||
|
||
Gepruefte Pflichtfelder gibt es hier bewusst nicht: man will ja gerade
|
||
sehen, was noch fehlt.
|
||
"""
|
||
montage = self.ist_montage()
|
||
r = self.recalc()
|
||
if not r.zeilen:
|
||
messagebox.showinfo(
|
||
"Noch nichts einzutragen",
|
||
"Bitte die Anzahl der Monate eintragen." if montage else
|
||
"Trage erst bei einer Leistung eine Anzahl ein - dann zeigt die "
|
||
"Vorschau, wie die Rechnung aussehen wird.")
|
||
return
|
||
kopf = self._kopf_basis(self.r_nummer.get().strip() or "(noch keine Nummer)", r)
|
||
if montage:
|
||
kopf["vorlage"] = "montage"
|
||
kopf["montage_text"] = self._montage_text()
|
||
pfad = os.path.join(tempfile.gettempdir(), "Vorschau - so wird die Rechnung.pdf")
|
||
try:
|
||
pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf, vorschau=True)
|
||
print(f"[vorschau] geschrieben -> {pfad}")
|
||
if oeffnen:
|
||
os.startfile(pfad) # noqa: S606
|
||
return pfad
|
||
except OSError as e:
|
||
messagebox.showerror("Vorschau nicht möglich",
|
||
f"Die Vorschau konnte nicht geöffnet werden:\n{e}")
|
||
return ""
|
||
|
||
def pdf_erstellen(self, angebot=False):
|
||
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.
|
||
# EIN Knopf - gewaehlt wird hier: Rechnung oder Proforma. Waehrend
|
||
# eines Stornos oder einer Berichtigung gibt es nichts zu waehlen.
|
||
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)
|
||
wahl = self._art_waehlen(nummer, berechnung.eur(r.gesamt), wer)
|
||
if wahl is None:
|
||
return
|
||
angebot = wahl == "angebot"
|
||
if angebot:
|
||
# EIGENE Reihe: Nummernfeld und Rechnungszaehler bleiben
|
||
# unangetastet, die naechste Rechnung behaelt ihre Nummer.
|
||
a_jahr, a_zaehler, nummer = self._naechstes_angebot()
|
||
|
||
# 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 = self._kopf_basis(nummer, r)
|
||
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 angebot:
|
||
kopf["art"] = "angebot"
|
||
kopf["rechnungsnummer"] = nummer
|
||
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
|
||
|
||
if angebot:
|
||
name = f"Angebot_{safe_filename(nummer)}"
|
||
if self.k_name.get().strip():
|
||
name += "_" + safe_filename(self.k_name.get().strip())
|
||
pfad = os.path.join(self._angebot_ordner(), 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"Angebot konnte nicht erstellt werden:\n{e}")
|
||
print("[angebot] FEHLER:", e)
|
||
return
|
||
# NUR der Angebots-Zaehler wandert weiter. Rechnungsnummer, deren
|
||
# Zaehler, das gemeinsame Nummernbuch und die Meldung ans
|
||
# Steuerjournal bleiben unberuehrt - gebucht wird ein Angebot
|
||
# nirgends.
|
||
self.cfg["angebot_jahr"] = a_jahr
|
||
self.cfg["angebot_zaehler"] = a_zaehler
|
||
try:
|
||
config.save_config(self.cfg)
|
||
except OSError as e: # noqa: BLE001
|
||
print("[angebot] Zaehler nicht gespeichert:", e)
|
||
print(f"[angebot] erstellt -> {pfad}")
|
||
if messagebox.askyesno(
|
||
"Fertig", f"Angebot erstellt:\n{pfad}\n\n"
|
||
"Jetzt öffnen (zum Drucken)?"):
|
||
self._oeffne_datei(pfad)
|
||
return
|
||
|
||
# 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()
|
||
self._journal_benachrichtigen(f"Rechnung {nummer} erstellt")
|
||
|
||
# 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.api.stoppen(self.buch)
|
||
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()
|