rechnungstool/app.py
TheMockTv 4cdc286d5b Erprobungshinweis beim Start - mit Kenntnisnahme und Signatur unter Info
Sein Wortlaut (06.09.2026):

  "bei jedem oeffnen, solange wir die testen, ein popup, dass dies eine
   experimentelle software ist und ich keine haftung fuer irgendwelche falsch
   ausgestellten uebernehme. der user muss selbst pruefen ueber den vorschau
   button auf beiden tools, ob das alles seine richtigkeit hat. wenn nicht, auf
   dem link eine issue erstellen. und das alles auch noch mal in die info mit
   signatur: user hat dies gelesen und zur kenntnis genommen. wenn er nein
   drueckt oder ablehnt, schliesst sich die software und geht beim naechsten
   start wieder mit dem selben auf, bis er auf ja drueckt."

WIE ES GEBAUT IST

hinweis.py - neu, liegt spaeter in BEIDEN Programmen gleich (wie gemeinsam.py):
Text, Fehleradresse je Programm, Kenntnisnahme lesen/schreiben, Signaturzeile.

  * Der Hinweis kommt als ERSTES beim Start. Erst nach der Bestaetigung faehrt
    der uebrige Start an (_start_fortsetzen) - vorher wird nichts geoeffnet,
    nichts gefragt, nichts geladen. Sonst stuenden beim Ablehnen noch Timer in
    der Warteschlange, die auf ein Fenster zugreifen, das es nicht mehr gibt.
  * "Nein" beendet das Programm. Es wird NICHTS vermerkt - ein Nein, das
    trotzdem als Kenntnisnahme gespeichert wuerde, waere eine Luege.
  * "Ja" wird mit Benutzername, Zeitpunkt und Fassung in der config vermerkt.
    Das ist die Signatur.
  * Aendert sich der TEXT (FASSUNG), wird erneut gefragt. Eine Zustimmung gilt
    fuer das, was dagestanden hat, nicht fuer alles Kuenftige.
  * Unter Hilfe -> Info steht derselbe Text dauerhaft, mit der Signaturzeile.

UMBENENNUNG

Das frueher "beherbergungssteuer" genannte Repository heisst jetzt
Steuerrechnungstool:
  https://git.pcore.de/TheMockTv/Steuerrechnungstool
  https://git.pcore.de/TheMockTv/rechnungstool
In beiden READMEs vermerkt, samt "git remote set-url". Der LOKALE Ordner heisst
weiter beherbergungssteuer - der Pfad steht in Pruefstaenden und Spec-Dateien
fest, das ist ein eigener Schritt.

PRUEFSTAND

pruef_hinweis.py prueft die ganze Strecke: Hinweis kommt und nennt Haftung,
Vorschau und Adresse; Nein schliesst das Fenster und vermerkt nichts; neuer
Start fragt wieder; Ja hinterlaesst Fassung, Zeit, Benutzer und eine lesbare
Signaturzeile; danach wird nicht mehr gefragt; bei geaenderter Fassung wieder.

Alle 17 Pruefstaende gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-06 15:55:56 +02:00

1833 lines
89 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 (powered by ravokk) - 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 hinweis
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)
import flaeche
import icons
import reiter
import theme
from theme import MeldungMixin, 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
# Der Pruefschalter steht in config (dort als Funktion, siehe Kommentar). Hier
# nur noch der Startwert fuer alles, was ihn schon beim Laden braucht.
TESTLAUF = config.testlauf()
class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
def __init__(self):
super().__init__()
self.cfg = config.load_config()
self.title(config.PROGRAMM)
# 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)
if config.testlauf():
# Ein Pruefstand bedient das Programm, waehrend nebenan jemand
# arbeitet. Das Fenster geht deshalb NEBEN dem Bildschirm auf (auf
# dem zweiten Monitor, wenn einer da ist) - sichtbar, aber nicht im
# Weg. Unsichtbar machen waere falsch: dann sieht niemand mehr, was
# der Pruefstand eigentlich tut.
self.geometry(f"+{self.winfo_screenwidth() + 20}+40")
try:
# Kein Eintrag in der Taskleiste: das Fenster liegt neben
# dem Bildschirm, und ein Eintrag, den man anklickt, ohne
# dass etwas erscheint, ist schlimmer als gar keiner.
self.attributes("-toolwindow", True)
except tk.TclError:
pass
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_summen() # untere Leiste (Summen + Buttons) zuerst -> bleibt immer sichtbar
self._build_flaeche() # Werkzeugleiste + verschiebbare Kaesten
self._build_kopf() # Rechnungsdaten + Kunde als Kaesten
self._build_positionen() # Leistungen als Kasten, fuellt den Rest
self.flaeche.anordnen()
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)
# NICHTS laeuft an, bevor der Erprobungshinweis bestaetigt ist. Wer ihn
# ablehnt, soll gar nicht erst arbeiten koennen - und es soll auch kein
# halb gestartetes Programm hinter dem Fenster stehenbleiben.
self.after(0, self._hinweis_pruefen)
# ---------------------------------------------------------------- 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
# ------------------------------------------------------- Arbeitsflaeche
def _build_flaeche(self):
"""Die Kaesten und der Knopf, mit dem man sie verschieben darf.
Wie bei OPNsense: erst "Anordnen" druecken, dann schieben, dann
speichern. Ohne diesen Schritt sitzt alles fest - beim taeglichen
Arbeiten soll nichts verrutschen.
"""
leiste = ttk.Frame(self, padding=(16, 8, 16, 0))
leiste.pack(side="top", fill="x")
self.werkzeuge = ttk.Frame(leiste)
self.werkzeuge.pack(side="right")
self._ic_anordnen = icons.hole("dashboard_customize", 16, self.col_muted, master=self)
self._ic_speichern = icons.hole("check", 16, self.col_accent_hell, master=self)
self._ic_verwerfen = icons.hole("close", 16, self.col_muted, master=self)
self.knopf_anordnen = ttk.Button(self.werkzeuge, text=" Anordnen",
image=self._ic_anordnen, compound="left",
command=self._anordnen_umschalten)
self.knopf_anordnen.pack(side="left")
self.knopf_speichern = ttk.Button(self.werkzeuge, text=" Speichern",
image=self._ic_speichern, compound="left",
style="Haupt.TButton",
command=self._anordnen_speichern)
self.knopf_verwerfen = ttk.Button(self.werkzeuge, text=" Verwerfen",
image=self._ic_verwerfen, compound="left",
command=self._anordnen_verwerfen)
self.flaeche = flaeche.Arbeitsflaeche(self, farben=self)
self.flaeche.pack(side="top", fill="both", expand=True, padx=10, pady=(4, 0))
def _anordnen_umschalten(self):
self.flaeche.anordnen_starten()
self.knopf_anordnen.pack_forget()
self.knopf_verwerfen.pack(side="left", padx=(0, 6))
self.knopf_speichern.pack(side="left")
def _anordnen_fertig(self):
self.knopf_speichern.pack_forget()
self.knopf_verwerfen.pack_forget()
self.knopf_anordnen.pack(side="left")
def _anordnen_speichern(self):
self.flaeche.anordnen_speichern()
self._anordnen_fertig()
def _anordnen_verwerfen(self):
self.flaeche.anordnen_verwerfen()
self._anordnen_fertig()
# ---------------------------------------------------------------- Kopf
def _build_kopf(self):
# Beide Bloecke sind Kaesten der Arbeitsflaeche - sie lassen sich
# verschieben, deshalb platziert sie niemand mehr von Hand.
kunde = self.flaeche.kasten("kunde", "Rechnung an (Kunde)")
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, style="Karte.TLabel").grid(
row=i, column=0, sticky="w", pady=4)
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).",
style="KarteMuted.TLabel", wraplength=330, justify="left").grid(
row=len(felder), column=0, columnspan=2, sticky="w", pady=(6, 0))
kunde.columnconfigure(1, weight=1)
info = self.flaeche.kasten("daten", "Rechnungsdaten")
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, style="Karte.TLabel")
beschriftung.grid(row=i, column=0, sticky="w", pady=4)
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", style="Karte.TLabel")
self.naechte_label.grid(row=4, column=0, sticky="w", pady=4)
ttk.Label(info, textvariable=self.r_naechte, style="Karte.TLabel",
font=(theme.SCHRIFT_NAME, 11, "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, style="Karte.TLabel",
foreground=self.col_gefahr,
font=(theme.SCHRIFT_NAME, 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())
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.
# Eigene Reiterleiste statt ttk.Notebook: unter clam liess sich der
# ruhende Reiter nicht dunkel bekommen, er sah aus wie der offene.
# Nach aussen verhaelt sie sich wie ein Notebook (siehe reiter.py).
leistungen = self.flaeche.kasten("leistungen", "Leistungen")
self.blaetter = reiter.Reiterleiste(leistungen, farben=self)
self.blaetter.pack(side="top", fill="both", expand=True)
self.blaetter.bind("<<NotebookTabChanged>>", lambda _e: self._blatt_gewechselt())
camping = ttk.Frame(self.blaetter.bereich, padding=(2, 6))
self.blaetter.add(camping, text=" Campingrechnung ")
montage_tab = ttk.Frame(self.blaetter.bereich, padding=(14, 14))
self.blaetter.add(montage_tab, text=" Montage ")
self._build_montage(montage_tab)
wrap = ttk.LabelFrame(camping, text="LEISTUNGEN – ANZAHL (UND GGF. PERSONEN) EINTRAGEN",
padding=(12, 8), style="Karte.TLabelframe")
wrap.pack(side="top", fill="both", expand=True)
canvas = tk.Canvas(wrap, highlightthickness=0, bg=self.col_card)
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)
# Kopfzeile: derselbe durchgehende Streifen wie die Datenzeilen, sonst
# steht der zweizeilige "Anzahl Tage/Menge" als Kasten heraus.
kopfgrund = tk.Frame(self.pos_frame, bg=self.col_card)
kopfgrund.grid(row=0, column=0, columnspan=len(self.COLS), sticky="nsew")
for i, (title, anchor, _) in enumerate(self.COLS):
tk.Label(self.pos_frame, text=title.upper(), anchor=anchor,
bg=self.col_card, fg=self.col_accent,
font=(theme.SCHRIFT_NAME, 9, "bold"), padx=6, pady=6).grid(
row=0, column=i, sticky="nsew")
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())
# Eine Zeile ist EIN durchgehender Streifen, kein Satz Kaestchen:
# der Hintergrund liegt ueber alle Spalten, die Zellen sitzen darin
# und fuellen die volle Zeilenhoehe (sticky="nsew"). Sonst klafften
# bei der zweizeiligen Position 1 Luecken ueber und unter den
# einzeiligen Zellen daneben.
grund = self.col_zebra if idx % 2 else self.col_card
streifen = tk.Frame(self.pos_frame, bg=grund)
streifen.grid(row=r, column=0, columnspan=len(self.COLS), sticky="nsew")
def zelle(text, spalte, anker="center", umbruch=0):
lbl = tk.Label(self.pos_frame, text=text, bg=grund, fg=self.col_fg,
font=theme.SCHRIFT, anchor=anker, justify="left",
padx=6, pady=6)
if umbruch:
lbl.configure(wraplength=umbruch)
lbl.grid(row=r, column=spalte, sticky="nsew")
return lbl
zelle(str(nr), 0)
zelle(pos["leistung"], 1, anker="w", umbruch=330)
zelle(str(pos["satz"]), 2)
ttk.Entry(self.pos_frame, textvariable=av, width=8, justify="center",
style="Liste.TEntry").grid(
row=r, column=3, padx=6, pady=4)
# Personen-Feld NUR bei "pro Person"-Positionen anzeigen
if pos["pro_person"]:
ttk.Entry(self.pos_frame, textvariable=pv, width=6, justify="center",
style="Liste.TEntry").grid(
row=r, column=4, padx=6, pady=4)
zelle(berechnung.eur_kurz(pos["preis"]), 5, anker="e")
g = zelle("0,00", 6, anker="e")
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=(theme.SCHRIFT_NAME, 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=(theme.SCHRIFT_NAME, 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=(theme.SCHRIFT_NAME, 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=(theme.SCHRIFT_NAME, 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=config.MARKE, foreground=self.col_muted,
font=(theme.SCHRIFT_NAME, 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=(theme.SCHRIFT_NAME, 8))
self.buch_lbl.pack(side="left")
f = ttk.Frame(self, padding=(16, 12, 16, 14))
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")
# "PDF erstellen" ist der Knopf, um den es geht - der traegt die
# Akzentfarbe. Storno greift in eine ausgestellte Rechnung ein und ist
# deshalb rot markiert, nicht weil er gefaehrlich waere, sondern damit
# niemand ihn versehentlich statt "PDF erstellen" trifft.
ttk.Button(links, text="PDF erstellen", command=self.pdf_erstellen,
style="Haupt.TButton").pack(side="left", padx=(0, 6))
ttk.Button(links, text="Vorschau", command=self.vorschau_zeigen).pack(side="left", padx=6)
ttk.Button(links, text="Storno", command=self.dlg_storno,
style="Gefahr.TButton").pack(side="left", padx=6)
ttk.Button(links, text="Berichtigen", command=self.dlg_berichtigen).pack(side="left", padx=6)
ttk.Button(links, text="Zurücksetzen", command=self.reset_formular).pack(side="left", padx=6)
# --------------------------------------------------- Gemeinsames Buch
# ------------------------------------------------- Erprobungshinweis
def _hinweis_pruefen(self):
"""Hinweis auf die Erprobungsfassung. Ablehnen beendet das Programm.
Erst wenn er bestaetigt ist, faehrt der Rest des Starts an - sonst
stuenden beim Ablehnen noch Timer in der Warteschlange, die auf ein
Fenster zugreifen, das es nicht mehr gibt.
"""
if not hinweis.angenommen(self.cfg):
print(f"[hinweis] Kenntnisnahme noch offen (Fassung {hinweis.FASSUNG})")
if not self.frage(hinweis.TITEL, hinweis.text("rechnungstool"),
ja="Ja, gelesen", nein="Nein, beenden", default="no"):
print("[hinweis] abgelehnt - Programm wird beendet")
self.destroy()
return
zeile = hinweis.vermerken(self.cfg)
try:
config.save_config(self.cfg)
except OSError as e: # noqa: BLE001
print("[hinweis] konnte nicht gespeichert werden:", e)
print("[hinweis]", zeile)
self._start_fortsetzen()
def _start_fortsetzen(self):
"""Der uebrige Start - laeuft erst nach der Kenntnisnahme an."""
self.after(0, lambda: self._dark_titlebar(self))
# Beim ersten Start nach dem Speicherort fragen (wenn noch keiner gesetzt ist)
self.after(200, self._frage_speicherort_falls_noetig)
# ... und danach nach dem gemeinsamen Ordner fuer das Nummernbuch.
self.after(350, self._frage_gemeinsam_falls_noetig)
# ... und danach einmalig nach dem Stand der Rechnungsnummer.
self.after(550, self._frage_nummer_falls_noetig)
self.after(450, self._push_abholen)
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 config.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:
self.melden_warnung(
"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.
"""
# Im Pruflauf bleibt dieser Dialog zu - er wartet auf einen Klick, den dort
# niemand macht. Ein Pruefstand, der GENAU IHN pruefen will, setzt
# `_erststart_zeigen = True` (dasselbe Muster wie `_dialog_zeigen` beim
# Angebot). So bleiben alle anderen Meldungen weiterhin automatisch
# beantwortet - vorher musste dafuer der ganze Pruefschalter fallen, und
# dann hing der Lauf in der naechsten Meldung.
if (config.testlauf() and not getattr(self, "_erststart_zeigen", False)) 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=(theme.SCHRIFT_NAME, 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=(theme.SCHRIFT_NAME, 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=(theme.SCHRIFT_NAME, 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 self.frage(
"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
self.melden_warnung("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,
# Zeitstempel der ausdruecklichen Freigabe. Leer heisst: nicht
# gefragt (Beleg aus einer Fassung vor dem 06.09.2026 oder ueber
# einen Weg ohne Rueckfrage, z. B. Storno/Berichtigung mit eigener).
freigabe=str(kopf.get("freigabe", "") or ""))
except Exception as e: # noqa: BLE001 - die PDF ist schon geschrieben
print("[nummernbuch] Eintragen fehlgeschlagen:", e)
self.melden_warnung(
"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 = ((theme.SCHRIFT_NAME, 15, "bold") if fett
else (theme.SCHRIFT_NAME, 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 config.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=(theme.SCHRIFT_NAME, 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 config.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:
self.melden_info(
"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:
self._oeffne_datei(pfad)
return pfad
except OSError as e:
self.melden_fehler("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:
self.melden_warnung(
"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.
self.melden_warnung(
"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:
self.melden_warnung("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:
self.melden_warnung(
"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:
self.melden_warnung(
"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 self.frage(
"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 self.frage(
"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")
self.melden_info(
"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 self.frage(
"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 self.frage(
"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
self.melden_fehler("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 self.frage("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:
self.melden_fehler(
"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 self.frage(
"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
# ---- Freigabe durch den Benutzer, BEVOR ein Beleg entsteht --------------
# Ein Beleg ist kein Ausdruck, den man wegwerfen kann: die Nummer ist
# danach verbraucht (§ 14 Abs. 4 Nr. 4 UStG), das Blatt ist aufzubewahren
# (§ 147 Abs. 3 AO) und darf nicht mehr geaendert werden (§ 146 Abs. 4 AO).
# Deshalb wird einmal ausdruecklich gefragt, was gleich entsteht.
#
# Storno/Korrektur und Berichtigung haben ihre eigene Rueckfrage weiter
# oben - die wird nicht verdoppelt (ein Schritt pro Fenster).
if not stand and not ber:
if angebot:
titel = "Angebot jetzt erstellen?"
text = (f"Das Angebot {nummer} ist unverbindlich und ist KEINE Rechnung.\n\n"
"Es weist keine Steuer aus, verbraucht keine "
"Rechnungsnummer und wird nirgends gebucht.\n\n"
"Jetzt erstellen?")
else:
titel = "Rechnung jetzt erstellen?"
text = (f"Die Rechnung {nummer} wird als Rechnung im Sinne des "
"§ 14 UStG ausgestellt.\n\n"
"Die Nummer ist damit vergeben und darf kein zweites Mal "
"verwendet werden (§ 14 Abs. 4 Nr. 4 UStG). Das Blatt ist "
"zehn Jahre aufzubewahren (§ 147 Abs. 3 AO) und darf "
"nachträglich nicht geändert werden (§ 146 Abs. 4 AO). "
"Eine falsche Rechnung wird nicht überschrieben, sondern "
"storniert.\n\n"
"Jetzt erstellen?")
print(f"[freigabe] frage: {titel} (Nummer {nummer})")
if not self.frage(titel, text, default="yes"):
print(f"[freigabe] abgelehnt - kein Beleg, Nummer {nummer} bleibt frei")
return
kopf["freigabe"] = datetime.datetime.now().isoformat(timespec="seconds")
print(f"[freigabe] erteilt fuer {nummer} um {kopf["freigabe"]}")
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
self.melden_fehler(
"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 self.frage(
"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 Blatt: die Rechnung fuer den Gast. Ein zweites Blatt "fuers Amt"
# gibt es seit dem 05.09.2026 nicht mehr - siehe pdf_renderer.render_rechnung.
pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf)
except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen
self.melden_fehler("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}")
# Kein Seitenhinweis mehr: die Rechnung ist EIN Blatt. Der frühere Zusatz
# "Seite 2 = Beherbergungssteuer fürs Amt" beschrieb ein Blatt, das es
# seit dem 05.09.2026 nicht mehr gibt.
hinweis = ""
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 self.frage("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.
self.melden_warnung(
"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 self.frage(
"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 self.frage(
"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):
"""Datei oder Ordner im Betrachter oeffnen. Im Pruflauf: NICHT.
Ein Pruefstand schreibt Dutzende Rechnungen. Wuerde jede davon den
PDF-Betrachter aufmachen, hat der Mensch am Rechner am Ende dreissig
Fenster offen, die er selbst wegklicken darf. Gemeldet wird es trotzdem,
damit im Protokoll steht, was zu sehen gewesen waere.
"""
if config.testlauf():
print(f"[oeffnen] im Pruflauf nicht geoeffnet: {pfad}")
return False
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
self.melden_info("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()
self.melden_fehler(
"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()