Montage-Rechnung als eigener Reiter, und das Datum laesst sich nicht mehr verbiegen

Bartl vermietet auch monatsweise an Montagearbeiter. Dieses Blatt schrieb er
bisher von Hand in Word ("Miete fuer Montagearbeiter inkl. 7 % MwSt. fuer den
Monat August 2026", Netto/USt/Brutto nebeneinander). Jetzt ist es ein zweiter
Reiter neben der Campingrechnung: Kunde, Nummer, Datum und Zeitraum gelten fuer
beide - nur die Mitte wechselt, damit niemand den Kunden zweimal tippt.

Die Zeitraum-Felder sind dieselben wie Anreise/Abreise, nur anders beschriftet.
Daraus kommen die Naechte, und die meldet das Steuerjournal ans Amt.

Gerechnet wird im selben Rechenkern: der Reiter baut einen Ein-Zeilen-Katalog
mit dem Monatspreis (brutto, Vorgabe 500,00 unter Einstellungen ->
Montage-Rechnung). Die Beherbergungssteuer kommt obendrauf wie ueberall sonst -
500 + 5 % = 525. Fuer das Steuerjournal ist das eine ganz normale Buchung, art
bleibt "rechnung"; die Kenndaten tragen zusaetzlich vorlage="montage", damit der
Storno-Weg die Rechnung spaeter in den richtigen Reiter laedt (sonst waeren aus
Monaten Wohnwagen-Naechte geworden).

Datum: Tag und Monat lassen sich nicht mehr unmoeglich tippen (eine 4 wird zu
04, 35 und 13 fallen weg), und vor dem Erstellen wird gegen den Kalender
geprueft - den 31.02. faengt erst das. Vorher landete so etwas ungeprueft auf
der Rechnung.

Dazu: die Reiter bekommen Theme-Farben, sonst sieht man nicht, welcher offen ist.

Pruefstand pruef_montage.py: 45 Pruefungen am echten Fenster und an echten
PDFs - Reiterwechsel, Beschriftung, Betraege, das Blatt selbst, danach liest das
Steuerjournal die Datei ein (500,00 Basis, 25,00 Steuer, 30 Uebernachtungen),
Storno, die unveraenderte Campingrechnung und die Datumspruefung.
pruef_kernregeln, pruef_storno, pruef_kette, pruef_nummern, pruef_dialoge gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx
This commit is contained in:
TheMockTv 2026-09-03 20:12:04 +02:00
parent 5c68278191
commit 5ef4e0a769
7 changed files with 703 additions and 95 deletions

225
app.py
View file

@ -92,6 +92,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
"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),
@ -211,8 +212,14 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
("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):
ttk.Label(info, text=lbl).grid(row=i, column=0, sticky="w", pady=2)
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:
@ -221,7 +228,8 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
self.nummer_entry = ent
if var in date_vars: # Datum: Punkte automatisch, Cursor ans Ende
ent.bind("<KeyRelease>", self._auto_datum_evt)
ttk.Label(info, text="Nächte").grid(row=4, column=0, sticky="w", pady=2)
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.
@ -245,6 +253,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
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:
@ -262,6 +271,29 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
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())
@ -284,8 +316,22 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
]
def _build_positionen(self):
wrap = ttk.LabelFrame(self, text="Leistungen – Anzahl (und ggf. Personen) eintragen", padding=(6, 4))
wrap.pack(side="top", fill="both", expand=True, padx=12, pady=4)
# 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)
@ -342,6 +388,95 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
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
@ -374,11 +509,29 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
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):
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)))
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.
@ -434,16 +587,55 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
"strasse": self.k_strasse.get(),
"plzort": self.k_plzort.get()})
def _datumsfelder_pruefen(self):
"""Welche Datumsfelder sind unlesbar? Leere Felder sind in Ordnung.
Geprueft wird mit parse_date - also gegen den echten Kalender. Die
Tipp-Hilfe verhindert nur 32. und 13., den 31.02. faengt erst das hier.
"""
felder = [("Rechnungsdatum", self.r_datum.get().strip())]
if self.ist_montage():
felder += [("Zeitraum von", self.r_anreise.get().strip()),
("Zeitraum bis", self.r_abreise.get().strip())]
else:
felder += [("Anreise", self.r_anreise.get().strip()),
("Abreise", self.r_abreise.get().strip())]
return [f"{name}: „{wert}“ ist kein Datum"
for name, wert in felder if wert and not parse_date(wert)]
def pdf_erstellen(self):
montage = self.ist_montage()
r = self.recalc()
if not r.zeilen:
messagebox.showwarning("Keine Positionen",
"Es wurde keine Leistung mit Anzahl > 0 eingetragen.")
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()
@ -531,6 +723,11 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
"positionen": [{"nr": z.nr, "anzahl": z.anzahl, "personen": z.personen}
for z in r.zeilen],
}
if montage:
# Das Blatt sieht anders aus (eine Zeile statt Tabelle), die Buchung
# dahinter ist eine ganz normale: Zwischensumme + Beherbergungssteuer.
kopf["vorlage"] = "montage"
kopf["montage_text"] = self._montage_text()
if stand:
kopf["art"] = "korrektur"
kopf["vorgang"] = stand.get("vorgang") or stand["zu"]
@ -608,9 +805,14 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
self.r_nummer.set(nummer)
kopf["rechnungsnummer"] = nummer
# Der Dateiname faengt weiter mit "Rechnung_<nummer>" an - daran erkennt
# bestand.vorhandene_rechnungen() eine schon vergebene Nummer. Das
# "_Montage" haengt nur hinten dran, damit man die Sorte im Ordner sieht.
fname = f"Rechnung_{safe_filename(nummer)}"
if self.k_name.get().strip():
fname += "_" + safe_filename(self.k_name.get().strip())
if montage:
fname += "_Montage"
pfad = os.path.join(out_dir, fname + ".pdf")
try:
@ -681,6 +883,9 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
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: