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:
parent
5c68278191
commit
5ef4e0a769
7 changed files with 703 additions and 95 deletions
225
app.py
225
app.py
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue