rechnungstool/app.py
TheMockTv ca9ac212cf Amt-Blatt raus, Bedienung ueber die API, Erststart fragt nach zwei Wegen
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
2026-09-05 18:24:59 +02:00

1640 lines
79 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

# -*- coding: utf-8 -*-
"""
Rechnungstool Campinghof Bartl - Desktop-GUI (Tkinter).
Bedienung:
1. Kundenadresse + An-/Abreise eingeben
2. bei den passenden Leistungen Anzahl (und ggf. Personen) eintragen
3. "PDF erstellen" -> druckfertige Rechnung im Ordner rechnungen/
Steuersaetze, Bettensteuer, Preise und Firmendaten -> Menue "Einstellungen".
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()