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:
|
||||
|
|
|
|||
13
config.py
13
config.py
|
|
@ -96,6 +96,18 @@ DEFAULT_KATALOG = [
|
|||
{"nr": 17, "leistung": "Gasfüllung 11 Kg (zuzüglich Pfand 35 €)", "satz": 19, "preis": 31.5, "pro_person": False},
|
||||
]
|
||||
|
||||
# Die Montage-Rechnung ist eine eigene Sorte Beleg (eigener Reiter im Fenster):
|
||||
# ein Monteur mietet monatsweise, nicht naechteweise. Deshalb steht sie NICHT im
|
||||
# Leistungskatalog - sie hat eine einzige Zeile mit einem Monatspreis, und im
|
||||
# Katalog wuerde sie zwischen den Nachtpreisen nur Unheil stiften.
|
||||
# preis = BRUTTO je Monat (wie im Katalog), die USt wird herausgerechnet.
|
||||
# Die Beherbergungssteuer kommt wie ueberall obendrauf (500 + 5 % = 525).
|
||||
DEFAULT_MONTAGE = {
|
||||
"leistung": "Miete für Montagearbeiter",
|
||||
"satz": 7,
|
||||
"preis": 500.0,
|
||||
}
|
||||
|
||||
# Firmen- und Fusszeilen-Daten – Platzhalter.
|
||||
# Die echten Daten stehen in der config.json neben dem Programm (nicht im Repo)
|
||||
# und werden im Menü "Einstellungen -> Firmendaten & Texte" gepflegt.
|
||||
|
|
@ -127,6 +139,7 @@ DEFAULT_CONFIG = {
|
|||
"firma": DEFAULT_FIRMA,
|
||||
"texte": DEFAULT_TEXTE,
|
||||
"katalog": DEFAULT_KATALOG,
|
||||
"montage": DEFAULT_MONTAGE,
|
||||
# Alle Steuern als EINE benannte Liste.
|
||||
# - extra_blatt = False: USt-Satz, wird auf das Netto der zugeordneten Leistungen gerechnet.
|
||||
# - extra_blatt = True : Aufschlag auf die Endsumme (z.B. Beherbergungssteuer), eigenes Blatt.
|
||||
|
|
|
|||
|
|
@ -132,6 +132,70 @@ class EinstellungenMixin:
|
|||
ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4)
|
||||
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
|
||||
|
||||
def dlg_montage(self):
|
||||
"""Bezeichnung, Steuersatz und Monatspreis der Montage-Rechnung.
|
||||
|
||||
Eigener Dialog und NICHT im Leistungskatalog: die Montage-Rechnung ist
|
||||
eine Monatsmiete, im Katalog stuende sie zwischen lauter Nachtpreisen und
|
||||
wuerde auf jeder Campingrechnung mit angeboten.
|
||||
"""
|
||||
d = tk.Toplevel(self)
|
||||
d.title("Montage-Rechnung")
|
||||
d.transient(self)
|
||||
d.grab_set()
|
||||
self._style_dialog(d)
|
||||
|
||||
m = self.cfg.get("montage", {})
|
||||
v_leistung = tk.StringVar(value=str(m.get("leistung", "")))
|
||||
v_satz = tk.StringVar(value=str(int(parse_num(str(m.get("satz", 7))))))
|
||||
v_preis = tk.StringVar(value=berechnung.eur_kurz(m.get("preis", 0)))
|
||||
|
||||
rahmen = ttk.Frame(d, padding=12)
|
||||
rahmen.pack(fill="both", expand=True)
|
||||
ttk.Label(rahmen, wraplength=520, justify="left",
|
||||
text="So steht die eine Zeile auf dem Montage-Blatt. Der Preis ist ein "
|
||||
"BRUTTO-Monatspreis wie im Leistungskatalog - die Umsatzsteuer wird "
|
||||
"herausgerechnet, die Beherbergungssteuer kommt obendrauf.").grid(
|
||||
row=0, column=0, columnspan=2, sticky="w", pady=(0, 10))
|
||||
|
||||
saetze = [s2 for s2 in self.cfg.get("steuersaetze", []) if not s2.get("extra_blatt")]
|
||||
zeilen = [("Bezeichnung", v_leistung), ("Umsatzsteuer %", v_satz),
|
||||
("Monatspreis brutto (EUR)", v_preis)]
|
||||
for i, (lbl, var) in enumerate(zeilen, start=1):
|
||||
ttk.Label(rahmen, text=lbl + ":").grid(row=i, column=0, sticky="w", pady=3)
|
||||
if var is v_satz:
|
||||
w = ttk.Combobox(rahmen, textvariable=var, width=10, state="readonly",
|
||||
values=[str(x.get("satz")) for x in saetze] or ["7", "19"])
|
||||
else:
|
||||
w = ttk.Entry(rahmen, textvariable=var, width=44)
|
||||
w.grid(row=i, column=1, sticky="ew", padx=(10, 0), pady=3)
|
||||
rahmen.columnconfigure(1, weight=1)
|
||||
|
||||
def speichern():
|
||||
name = v_leistung.get().strip()
|
||||
preis = parse_num(v_preis.get())
|
||||
if not name:
|
||||
messagebox.showwarning("Bezeichnung fehlt",
|
||||
"Bitte eine Bezeichnung eintragen.", parent=d)
|
||||
return
|
||||
if preis <= 0:
|
||||
messagebox.showwarning("Preis fehlt",
|
||||
"Bitte einen Monatspreis groesser als 0 eintragen.",
|
||||
parent=d)
|
||||
return
|
||||
self.cfg["montage"] = {"leistung": name,
|
||||
"satz": int(parse_num(v_satz.get())),
|
||||
"preis": preis}
|
||||
config.save_config(self.cfg)
|
||||
self.recalc()
|
||||
d.destroy()
|
||||
|
||||
knoepfe = ttk.Frame(rahmen)
|
||||
knoepfe.grid(row=len(zeilen) + 1, column=0, columnspan=2, sticky="e", pady=(14, 0))
|
||||
ttk.Button(knoepfe, text="Abbrechen", command=d.destroy).pack(side="right", padx=4)
|
||||
ttk.Button(knoepfe, text="Speichern", command=speichern).pack(side="right", padx=4)
|
||||
d.bind("<Escape>", lambda _e: d.destroy())
|
||||
|
||||
def dlg_katalog(self):
|
||||
d = tk.Toplevel(self)
|
||||
d.title("Leistungskatalog / Preise")
|
||||
|
|
|
|||
24
korrektur.py
24
korrektur.py
|
|
@ -405,14 +405,28 @@ class KorrekturMixin:
|
|||
self.r_abreise.set(str(meta.get("abreise", "") or ""))
|
||||
self.r_datum.set(datetime.date.today().strftime("%d.%m.%Y"))
|
||||
|
||||
# Eine Montage-Rechnung gehoert in ihren Reiter: ihre Position 1 ist die
|
||||
# Monatsmiete, im Campingformular waere es Position 1 des Katalogs
|
||||
# (Wohnwagen pro Nacht) - der Storno haette stillschweigend etwas
|
||||
# anderes berichtigt.
|
||||
montage = str(meta.get("vorlage", "") or "") == "montage"
|
||||
try:
|
||||
self.blaetter.select(1 if montage else 0)
|
||||
except (AttributeError, tk.TclError):
|
||||
pass
|
||||
|
||||
for nr, var in self.anzahl_vars.items():
|
||||
var.set("")
|
||||
self.personen_vars[nr].set("")
|
||||
for p in meta.get("positionen") or []:
|
||||
nr = p.get("nr")
|
||||
if nr in self.anzahl_vars:
|
||||
self.anzahl_vars[nr].set(str(p.get("anzahl", "") or ""))
|
||||
self.personen_vars[nr].set(str(p.get("personen", "") or ""))
|
||||
if montage:
|
||||
erste = (meta.get("positionen") or [{}])[0]
|
||||
self.m_monate.set(str(erste.get("anzahl", "1") or "1"))
|
||||
else:
|
||||
for p in meta.get("positionen") or []:
|
||||
nr = p.get("nr")
|
||||
if nr in self.anzahl_vars:
|
||||
self.anzahl_vars[nr].set(str(p.get("anzahl", "") or ""))
|
||||
self.personen_vars[nr].set(str(p.get("personen", "") or ""))
|
||||
self.recalc()
|
||||
|
||||
hinweise = []
|
||||
|
|
|
|||
211
pdf_renderer.py
211
pdf_renderer.py
|
|
@ -188,85 +188,8 @@ def _kopf_fuss(canvas, doc, cfg, amt=False):
|
|||
|
||||
|
||||
# ---- Hauptfunktion ----------------------------------------------------------
|
||||
def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
||||
"""Baut den kompletten Seiteninhalt (Absender -> Positionen -> Summen -> Danktexte).
|
||||
|
||||
gesamtrechnung_betont=True hebt die Zwischensumme-Zeile wie den Gesamtbetrag hervor
|
||||
(fett + zwei Streifen) -> fuer das Amt-Blatt. Sonst 1:1 die Kundenseite.
|
||||
"""
|
||||
# Absenderzeile (klein, unterstrichen)
|
||||
story.append(Paragraph(
|
||||
f"<u>{cfg['firma'].get('absender','')}</u>",
|
||||
ParagraphStyle("abs", fontName="Helvetica", fontSize=7.5, textColor=DARK)))
|
||||
story.append(Spacer(1, 5 * mm))
|
||||
|
||||
# Kundenadresse (links) | Rechnungsinfos (rechts)
|
||||
kunde_flow = [Paragraph("Rechnung an:", st["addr_label"])]
|
||||
for z in kopf.get("kunde_zeilen", []):
|
||||
if str(z).strip():
|
||||
kunde_flow.append(Paragraph(str(z), st["addr"]))
|
||||
if len(kunde_flow) == 1:
|
||||
kunde_flow.append(Paragraph(" ", st["addr"]))
|
||||
|
||||
art = kopf.get("art", "rechnung")
|
||||
info_rows = [
|
||||
("Stornonummer:" if art == "storno" else "Rechnungsnummer:",
|
||||
kopf.get("rechnungsnummer", "")),
|
||||
("Rechnungsdatum:", kopf.get("datum", "")),
|
||||
]
|
||||
# Der Bezug auf die alte Rechnung ist Pflicht (§ 31 Abs. 5 UStDV): Nummer UND Datum.
|
||||
if art == "storno" and kopf.get("storno_zu"):
|
||||
# Nummer und Datum in zwei Zeilen - zusammen brechen sie in der schmalen
|
||||
# Spalte um und der Bezug sieht aus wie zwei halbe Angaben.
|
||||
info_rows.append(("Storno zu Rechnung:", kopf.get("storno_zu", "")))
|
||||
if kopf.get("storno_datum"):
|
||||
info_rows.append(("Rechnung vom:", kopf["storno_datum"]))
|
||||
# Die Nummer, unter der die berichtigte Rechnung kommt. Damit steht die
|
||||
# ganze Kette auf dem Blatt: alte Nummer -> Storno -> neue Nummer.
|
||||
if kopf.get("korrektur_nummer"):
|
||||
info_rows.append(("Neue Rechnung:", kopf["korrektur_nummer"]))
|
||||
elif art == "korrektur" and kopf.get("ersetzt"):
|
||||
info_rows.append(("Ersetzt Rechnung:", kopf.get("ersetzt", "")))
|
||||
elif art == "berichtigung":
|
||||
# Die Rechnung behaelt Nummer und Datum - dazu kommt der Tag der
|
||||
# Berichtigung, damit man die Reihenfolge sieht.
|
||||
if kopf.get("berichtigt_am"):
|
||||
info_rows.append(("Berichtigt am:", kopf["berichtigt_am"]))
|
||||
if kopf.get("anreise"):
|
||||
info_rows.append(("Anreise:", kopf.get("anreise", "")))
|
||||
if kopf.get("abreise"):
|
||||
info_rows.append(("Abreise:", kopf.get("abreise", "")))
|
||||
if kopf.get("naechte") not in (None, "", 0, "0"):
|
||||
info_rows.append(("Zeitraum:", f"{kopf.get('naechte')} Nächte"))
|
||||
info_tbl = Table(
|
||||
[[Paragraph(k, st["info_l"]), Paragraph(str(v), st["info_v"])] for k, v in info_rows],
|
||||
colWidths=[39 * mm, 25 * mm])
|
||||
info_tbl.setStyle(TableStyle([
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 0),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 1),
|
||||
]))
|
||||
|
||||
head = Table([[kunde_flow, info_tbl]], colWidths=[CONTENT_W - 64 * mm, 64 * mm])
|
||||
head.setStyle(TableStyle([
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 0),
|
||||
]))
|
||||
story.append(head)
|
||||
story.append(Spacer(1, 7 * mm))
|
||||
|
||||
# Ueberschrift (auf dem Amt-Blatt mit Zusatz)
|
||||
# Das Wort "Beherbergungssteuer" steht auf dem Amt-Blatt nirgends mehr - auch nicht
|
||||
# in der Ueberschrift. Der Zusatz sagt nur noch, fuer wen das Blatt ist.
|
||||
# Das Wort "Stornorechnung" muss auf dem Blatt stehen, damit der Beleg
|
||||
# eindeutig als Aufhebung erkennbar ist.
|
||||
titel = {"storno": "Stornorechnung",
|
||||
"berichtigung": "Berichtigte Rechnung"}.get(art, "Rechnungsübersicht")
|
||||
titel += " (für das Amt)" if gesamtrechnung_betont else ""
|
||||
story.append(Paragraph(titel, st["h"]))
|
||||
|
||||
# Positionstabelle
|
||||
def _positionstabelle(story, st, rechnung):
|
||||
"""Die gewohnte Positionstabelle der Campingrechnung."""
|
||||
col_w = [10 * mm, 70 * mm, 12 * mm, 21 * mm, 13 * mm, 22 * mm, 26 * mm]
|
||||
data = [[
|
||||
Paragraph("Pos.", st["th"]),
|
||||
|
|
@ -305,6 +228,126 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
|||
("RIGHTPADDING", (0, 0), (-1, -1), 3),
|
||||
]))
|
||||
story.append(items)
|
||||
|
||||
|
||||
def _montage_zeile(story, st, rechnung, kopf):
|
||||
"""Das Montage-Blatt: eine Textzeile, darunter Netto / USt / Brutto.
|
||||
|
||||
Bartl schreibt dieses Blatt seit Jahren von Hand ("Miete fuer Montagearbeiter
|
||||
inkl. 7 % MwSt. fuer den Monat August 2026"), darum hat es keine Tabelle mit
|
||||
Anzahl und Einzelpreis. Gerechnet wird trotzdem im selben Rechenkern - die
|
||||
Zahlen kommen aus rechnung.zeilen, nicht aus dem Text.
|
||||
"""
|
||||
text = kopf.get("montage_text") or ""
|
||||
if not text:
|
||||
text = ", ".join(z.leistung for z in rechnung.zeilen)
|
||||
story.append(Paragraph(text, st["leistung"]))
|
||||
story.append(Spacer(1, 3 * mm))
|
||||
|
||||
netto = sum(z.netto for z in rechnung.zeilen)
|
||||
ust = rechnung.brutto - netto
|
||||
dreier = Table(
|
||||
[[Paragraph(f"Netto {eur(netto)}", st["cell_c"]),
|
||||
Paragraph(f"USt. {eur(ust)}", st["cell_c"]),
|
||||
Paragraph(f"Brutto {eur(rechnung.brutto)}", st["cell_c"])]],
|
||||
colWidths=[CONTENT_W / 3.0] * 3)
|
||||
dreier.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, -1), GREY_BG),
|
||||
("LINEBELOW", (0, 0), (-1, -1), 0.8, ACCENT),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 5),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
||||
]))
|
||||
story.append(dreier)
|
||||
|
||||
|
||||
def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
||||
"""Baut den kompletten Seiteninhalt (Absender -> Positionen -> Summen -> Danktexte).
|
||||
|
||||
gesamtrechnung_betont=True hebt die Zwischensumme-Zeile wie den Gesamtbetrag hervor
|
||||
(fett + zwei Streifen) -> fuer das Amt-Blatt. Sonst 1:1 die Kundenseite.
|
||||
"""
|
||||
# Absenderzeile (klein, unterstrichen)
|
||||
story.append(Paragraph(
|
||||
f"<u>{cfg['firma'].get('absender','')}</u>",
|
||||
ParagraphStyle("abs", fontName="Helvetica", fontSize=7.5, textColor=DARK)))
|
||||
story.append(Spacer(1, 5 * mm))
|
||||
|
||||
# Kundenadresse (links) | Rechnungsinfos (rechts)
|
||||
kunde_flow = [Paragraph("Rechnung an:", st["addr_label"])]
|
||||
for z in kopf.get("kunde_zeilen", []):
|
||||
if str(z).strip():
|
||||
kunde_flow.append(Paragraph(str(z), st["addr"]))
|
||||
if len(kunde_flow) == 1:
|
||||
kunde_flow.append(Paragraph(" ", st["addr"]))
|
||||
|
||||
art = kopf.get("art", "rechnung")
|
||||
# Montage-Rechnung: eigenes Blatt fuer die Monatsmiete eines Monteurs.
|
||||
# Keine Positionstabelle, keine Naechte im Kopf - der Zeitraum steht in der
|
||||
# einen Textzeile, so wie Bartl das Blatt bisher von Hand geschrieben hat.
|
||||
montage = kopf.get("vorlage") == "montage"
|
||||
info_rows = [
|
||||
("Stornonummer:" if art == "storno" else "Rechnungsnummer:",
|
||||
kopf.get("rechnungsnummer", "")),
|
||||
("Rechnungsdatum:", kopf.get("datum", "")),
|
||||
]
|
||||
# Der Bezug auf die alte Rechnung ist Pflicht (§ 31 Abs. 5 UStDV): Nummer UND Datum.
|
||||
if art == "storno" and kopf.get("storno_zu"):
|
||||
# Nummer und Datum in zwei Zeilen - zusammen brechen sie in der schmalen
|
||||
# Spalte um und der Bezug sieht aus wie zwei halbe Angaben.
|
||||
info_rows.append(("Storno zu Rechnung:", kopf.get("storno_zu", "")))
|
||||
if kopf.get("storno_datum"):
|
||||
info_rows.append(("Rechnung vom:", kopf["storno_datum"]))
|
||||
# Die Nummer, unter der die berichtigte Rechnung kommt. Damit steht die
|
||||
# ganze Kette auf dem Blatt: alte Nummer -> Storno -> neue Nummer.
|
||||
if kopf.get("korrektur_nummer"):
|
||||
info_rows.append(("Neue Rechnung:", kopf["korrektur_nummer"]))
|
||||
elif art == "korrektur" and kopf.get("ersetzt"):
|
||||
info_rows.append(("Ersetzt Rechnung:", kopf.get("ersetzt", "")))
|
||||
elif art == "berichtigung":
|
||||
# Die Rechnung behaelt Nummer und Datum - dazu kommt der Tag der
|
||||
# Berichtigung, damit man die Reihenfolge sieht.
|
||||
if kopf.get("berichtigt_am"):
|
||||
info_rows.append(("Berichtigt am:", kopf["berichtigt_am"]))
|
||||
if not montage:
|
||||
if kopf.get("anreise"):
|
||||
info_rows.append(("Anreise:", kopf.get("anreise", "")))
|
||||
if kopf.get("abreise"):
|
||||
info_rows.append(("Abreise:", kopf.get("abreise", "")))
|
||||
if kopf.get("naechte") not in (None, "", 0, "0"):
|
||||
info_rows.append(("Zeitraum:", f"{kopf.get('naechte')} Nächte"))
|
||||
info_tbl = Table(
|
||||
[[Paragraph(k, st["info_l"]), Paragraph(str(v), st["info_v"])] for k, v in info_rows],
|
||||
colWidths=[39 * mm, 25 * mm])
|
||||
info_tbl.setStyle(TableStyle([
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 0),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 1),
|
||||
]))
|
||||
|
||||
head = Table([[kunde_flow, info_tbl]], colWidths=[CONTENT_W - 64 * mm, 64 * mm])
|
||||
head.setStyle(TableStyle([
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 0),
|
||||
]))
|
||||
story.append(head)
|
||||
story.append(Spacer(1, 7 * mm))
|
||||
|
||||
# Ueberschrift (auf dem Amt-Blatt mit Zusatz)
|
||||
# Das Wort "Beherbergungssteuer" steht auf dem Amt-Blatt nirgends mehr - auch nicht
|
||||
# in der Ueberschrift. Der Zusatz sagt nur noch, fuer wen das Blatt ist.
|
||||
# Das Wort "Stornorechnung" muss auf dem Blatt stehen, damit der Beleg
|
||||
# eindeutig als Aufhebung erkennbar ist.
|
||||
titel = {"storno": "Stornorechnung",
|
||||
"berichtigung": "Berichtigte Rechnung"}.get(art, "Rechnungsübersicht")
|
||||
titel += " (für das Amt)" if gesamtrechnung_betont else ""
|
||||
story.append(Paragraph(titel, st["h"]))
|
||||
|
||||
if montage:
|
||||
_montage_zeile(story, st, rechnung, kopf)
|
||||
else:
|
||||
_positionstabelle(story, st, rechnung)
|
||||
story.append(Spacer(1, 3 * mm))
|
||||
|
||||
# ---- Summenblock: Netto -> USt je Satz -> Zwischensumme (brutto)
|
||||
|
|
@ -343,7 +386,7 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
|||
|
||||
endsumme = rechnung.brutto if gesamtrechnung_betont else rechnung.gesamt
|
||||
summen_rows.append([
|
||||
Paragraph("Gesamtbetrag:", st["total_l"]),
|
||||
Paragraph("Rechnungsbetrag Insgesamt:" if montage else "Gesamtbetrag:", st["total_l"]),
|
||||
Paragraph(eur(endsumme), st["total_v"]),
|
||||
])
|
||||
style_cmds = [
|
||||
|
|
@ -507,6 +550,14 @@ def _kenndaten(rechnung, kopf):
|
|||
"kunde": kopf.get("kunde", {}),
|
||||
"positionen": kopf.get("positionen", []),
|
||||
}
|
||||
# Montage-Rechnung: welches Formular sie geschrieben hat und der Text der
|
||||
# einen Zeile. "art" bleibt "rechnung" - fuer das Steuerjournal ist sie eine
|
||||
# ganz normale Buchung (Miete mit Beherbergungssteuer), nur eben monatsweise.
|
||||
# Damit kann das Formular sie spaeter wieder laden und der Storno-Weg
|
||||
# erkennt, in welchen Reiter sie gehoert.
|
||||
if kopf.get("vorlage"):
|
||||
daten["vorlage"] = kopf["vorlage"]
|
||||
daten["montage_text"] = kopf.get("montage_text", "")
|
||||
return json.dumps(daten, ensure_ascii=False)
|
||||
|
||||
|
||||
|
|
|
|||
247
pruef_montage.py
Normal file
247
pruef_montage.py
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Pruefstand fuer die Montage-Rechnung - der ganze Weg an echten Dateien.
|
||||
|
||||
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 467,29 USt 32,71 Brutto 500,00
|
||||
|
||||
Im Rechnungstool ist das ein eigener Reiter. Geprueft wird deshalb nicht nur
|
||||
gerechnet, sondern der Weg, den er geht: Reiter waehlen, Kunde und Zeitraum
|
||||
eintragen, PDF erstellen. Danach liest das STEUERJOURNAL die Datei ein - die
|
||||
Miete ist eine Beherbergung, die Steuer kommt obendrauf (500 + 25 = 525) und
|
||||
muss genau so beim Amt ankommen.
|
||||
|
||||
Die echte config.json wird nicht angefasst - es wird mit einer Kopie in einem
|
||||
Wegwerf-Ordner gearbeitet.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
QUELLE = r"C:\claude\rechnungstool"
|
||||
JOURNAL_PROG = r"C:\claude\beherbergungssteuer"
|
||||
sys.path.insert(0, QUELLE)
|
||||
|
||||
tmp = tempfile.mkdtemp(prefix="montage_pruef_")
|
||||
ordner = os.path.join(tmp, "rechnungen")
|
||||
os.makedirs(ordner)
|
||||
|
||||
import config # noqa: E402
|
||||
|
||||
shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"))
|
||||
config.CONFIG_PATH = os.path.join(tmp, "config.json")
|
||||
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
|
||||
roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0
|
||||
roh["output_dir"] = ordner
|
||||
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
|
||||
import app as A # noqa: E402
|
||||
import bestand as BE # noqa: E402
|
||||
from tkinter import messagebox # noqa: E402
|
||||
|
||||
|
||||
def _reissleine():
|
||||
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog, "
|
||||
"der nicht beantwortet wird")
|
||||
os._exit(2)
|
||||
|
||||
|
||||
_wecker = threading.Timer(150, _reissleine)
|
||||
_wecker.daemon = True
|
||||
_wecker.start()
|
||||
|
||||
fehler = []
|
||||
|
||||
|
||||
def pruefe(bedingung, text):
|
||||
zeile = (" OK | " if bedingung else "FEHLER | ") + text
|
||||
kodierung = sys.stdout.encoding or "utf-8"
|
||||
print(zeile.encode(kodierung, "replace").decode(kodierung))
|
||||
if not bedingung:
|
||||
fehler.append(text)
|
||||
|
||||
|
||||
# Dialoge automatisch beantworten; jede Frage wird protokolliert.
|
||||
antworten = {"Fertig": False, "endgültig erstellen": True, "Hausnummer": True}
|
||||
gefragt = []
|
||||
|
||||
|
||||
def askyesno(titel, text, **kw):
|
||||
gefragt.append(titel)
|
||||
for schlue, wert in antworten.items():
|
||||
if schlue in titel:
|
||||
return wert
|
||||
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
|
||||
|
||||
|
||||
warnungen = []
|
||||
messagebox.askyesno = askyesno
|
||||
messagebox.showinfo = lambda *a, **k: None
|
||||
messagebox.showwarning = lambda *a, **k: warnungen.append(a[0] if a else "")
|
||||
messagebox.showerror = lambda *a, **k: print("[fehler]", a[0] if a else "",
|
||||
a[1] if len(a) > 1 else "")
|
||||
|
||||
f = A.RechnungsApp()
|
||||
f.update()
|
||||
f.cfg["output_dir"] = ordner
|
||||
|
||||
# ---- 0. Der Reiter ---------------------------------------------------------
|
||||
reiter = [f.blaetter.tab(i, "text").strip() for i in range(f.blaetter.index("end"))]
|
||||
pruefe(reiter == ["Campingrechnung", "Montage"], f"zwei Reiter da ({reiter})")
|
||||
pruefe(not f.ist_montage(), "beim Start liegt die Campingrechnung oben")
|
||||
pruefe(f.datum_labels["von"].cget("text").startswith("Anreise"),
|
||||
"dort heisst das Feld Anreise")
|
||||
|
||||
f.blaetter.select(1)
|
||||
f.update()
|
||||
pruefe(f.ist_montage(), "nach dem Wechsel liegt Montage oben")
|
||||
pruefe(f.datum_labels["von"].cget("text").startswith("Zeitraum von"),
|
||||
f"dort heisst dasselbe Feld Zeitraum von ({f.datum_labels['von'].cget('text')!r})")
|
||||
|
||||
# ---- 1. Die Rechnung, wie er sie tippt ------------------------------------
|
||||
f.k_anrede.set("Herr")
|
||||
f.k_vorname.set("Dominik")
|
||||
f.k_name.set("Schmidt")
|
||||
f.k_strasse.set("Musterweg 1")
|
||||
f.k_plzort.set("12345 Musterstadt")
|
||||
f.r_datum.set("12.05.2026")
|
||||
f.r_anreise.set("01.08.2026")
|
||||
f.r_abreise.set("31.08.2026")
|
||||
f.m_monate.set("1")
|
||||
f.update()
|
||||
|
||||
pruefe(f.r_naechte.get() == "30", f"30 Naechte im Zeitraum ({f.r_naechte.get()})")
|
||||
text = f.m_text.cget("text")
|
||||
pruefe("Miete für Montagearbeiter" in text and "7 % MwSt." in text
|
||||
and "01.08.2026" in text and "31.08.2026" in text,
|
||||
f"die Zeile steht so da, wie sie aufs Blatt gehoert ({text!r})")
|
||||
|
||||
f.pdf_erstellen()
|
||||
pdf = os.path.join(ordner, "Rechnung_2026-001_Schmidt_Montage.pdf")
|
||||
pruefe(os.path.exists(pdf), f"Montage-Rechnung geschrieben ({os.path.basename(pdf)})")
|
||||
|
||||
# ---- 2. Was in den Kenndaten steht ----------------------------------------
|
||||
meta = BE.kenndaten_lesen(pdf) or {}
|
||||
pruefe(meta.get("vorlage") == "montage", f"vorlage = montage ({meta.get('vorlage')!r})")
|
||||
pruefe(meta.get("art") == "rechnung",
|
||||
f"art bleibt rechnung - fuer das Journal eine normale Buchung ({meta.get('art')!r})")
|
||||
pruefe(float(meta.get("zwischensumme") or 0) == 500.0,
|
||||
f"Zwischensumme 500,00 ({meta.get('zwischensumme')})")
|
||||
pruefe(float(meta.get("steuer_satz") or 0) == 5.0,
|
||||
f"Beherbergungssteuer 5 % ({meta.get('steuer_satz')})")
|
||||
pruefe(float(meta.get("steuer_betrag") or 0) == 25.0,
|
||||
f"Steuer 25,00 - obendrauf, nicht drin ({meta.get('steuer_betrag')})")
|
||||
pruefe(float(meta.get("gesamt") or 0) == 525.0, f"Gesamt 525,00 ({meta.get('gesamt')})")
|
||||
pruefe(str(meta.get("naechte")) == "30",
|
||||
f"30 Naechte in den Kenndaten - die Meldung ans Amt ({meta.get('naechte')!r})")
|
||||
pruefe(meta.get("nachname") == "Schmidt", f"Nachname ({meta.get('nachname')!r})")
|
||||
bloecke = meta.get("ust_bloecke") or []
|
||||
pruefe(len(bloecke) == 1 and int(bloecke[0]["satz"]) == 7,
|
||||
f"eine USt-Zeile mit 7 % ({bloecke})")
|
||||
pruefe(bloecke and abs(bloecke[0]["netto"] - 467.29) < 0.005
|
||||
and abs(bloecke[0]["ust"] - 32.71) < 0.005,
|
||||
f"Netto 467,29 und USt 32,71 wie auf seinem Blatt ({bloecke})")
|
||||
pos = meta.get("positionen") or []
|
||||
pruefe(len(pos) == 1 and float(pos[0]["anzahl"]) == 1.0, f"eine Position, ein Monat ({pos})")
|
||||
|
||||
# ---- 3. Was auf dem Blatt steht -------------------------------------------
|
||||
from pypdf import PdfReader # noqa: E402
|
||||
|
||||
seiten = [s.extract_text() or "" for s in PdfReader(pdf).pages]
|
||||
pruefe(len(seiten) == 2, f"zwei Seiten - Kunde und Amt ({len(seiten)})")
|
||||
kunde_seite = seiten[0].replace("\n", " ")
|
||||
pruefe("Miete für Montagearbeiter" in kunde_seite, "die Miet-Zeile steht auf dem Blatt")
|
||||
pruefe("Zeitraum 01.08.2026" in kunde_seite, "mit dem Zeitraum")
|
||||
pruefe("Rechnungsbetrag Insgesamt" in kunde_seite,
|
||||
"und die Schlusszeile heisst wie bei ihm: Rechnungsbetrag Insgesamt")
|
||||
pruefe("Tage/Menge" not in kunde_seite and "Pos." not in kunde_seite,
|
||||
"keine Positionstabelle auf dem Montage-Blatt")
|
||||
pruefe("525,00" in kunde_seite and "500,00" in kunde_seite,
|
||||
"500,00 und 525,00 stehen drauf")
|
||||
pruefe("Beherbergungssteuer" in kunde_seite, "die Steuer ist auf der Kundenseite ausgewiesen")
|
||||
amt_seite = seiten[1].replace("\n", " ")
|
||||
pruefe("Beherbergungssteuer" not in amt_seite,
|
||||
"auf dem Amtsblatt steht sie nicht - wie bei jeder anderen Rechnung")
|
||||
|
||||
# ---- 4. Das Steuerjournal liest die Datei ---------------------------------
|
||||
sys.path.append(JOURNAL_PROG)
|
||||
import db as BST # noqa: E402
|
||||
|
||||
journal = BST.Journal(os.path.join(tmp, "journal.sqlite3"))
|
||||
neu, akt, uebersprungen, kaputt = journal.scanne(ordner, 5.0, voll=True)
|
||||
pruefe(neu == 1 and not kaputt, f"das Journal liest sie ein ({neu} neu, Fehler: {kaputt})")
|
||||
buchungen = journal.buchungen(jahr=2026)
|
||||
pruefe(len(buchungen) == 1, f"eine Buchung im Journal ({len(buchungen)})")
|
||||
if buchungen:
|
||||
b = buchungen[0]
|
||||
pruefe(b.nachname == "Schmidt", f"auf Schmidt gebucht ({b.nachname!r})")
|
||||
pruefe(abs(b.gezahlt - 500.0) < 0.005, f"Gezahlt 500,00 ({b.gezahlt})")
|
||||
pruefe(abs(b.steuer - 25.0) < 0.005, f"Steuer 25,00 ({b.steuer})")
|
||||
pruefe(b.naechte == 30, f"30 Uebernachtungen ({b.naechte})")
|
||||
pruefe(b.datum.strftime("%d.%m.%Y") == "12.05.2026", f"Datum ({b.datum})")
|
||||
journal.schliessen()
|
||||
|
||||
# ---- 5. Storno einer Montage-Rechnung -------------------------------------
|
||||
offen = [d for d in BE.rechnungen_im_ordner(ordner) if d.get("art") != "storno"]
|
||||
pruefe(len(offen) == 1, "eine stornierbare Rechnung gefunden")
|
||||
f._storno_anlegen(offen[0], ordner)
|
||||
storno_pdf = f.storno_stand["pfad"] if f.storno_stand else ""
|
||||
pruefe(os.path.exists(storno_pdf), f"Storno geschrieben ({os.path.basename(storno_pdf)})")
|
||||
m2 = BE.kenndaten_lesen(storno_pdf) or {}
|
||||
pruefe(float(m2.get("zwischensumme") or 0) == -500.0,
|
||||
f"Storno-Zwischensumme -500,00 ({m2.get('zwischensumme')})")
|
||||
pruefe(float(m2.get("steuer_betrag") or 0) == -25.0,
|
||||
f"Beherbergungssteuer wird mitstorniert ({m2.get('steuer_betrag')})")
|
||||
pruefe(f.ist_montage(), "das Formular steht danach wieder im Montage-Reiter")
|
||||
pruefe(f.m_monate.get() in ("1", "1.0"), f"mit einem Monat ({f.m_monate.get()!r})")
|
||||
f._storno_verwerfen("Pruefstand")
|
||||
|
||||
# ---- 6. Die Campingrechnung darf sich nicht veraendert haben --------------
|
||||
f.blaetter.select(0)
|
||||
f.update()
|
||||
pruefe(not f.ist_montage(), "zurueck im Campingreiter")
|
||||
pruefe(f.datum_labels["von"].cget("text").startswith("Anreise"), "Feld heisst wieder Anreise")
|
||||
f.r_anreise.set("01.09.2026")
|
||||
f.r_abreise.set("05.09.2026")
|
||||
erste_pos = sorted(f.anzahl_vars)[0]
|
||||
f.anzahl_vars[erste_pos].set("4")
|
||||
f.update()
|
||||
f.pdf_erstellen()
|
||||
camping = [d for d in os.listdir(ordner) if d.endswith(".pdf") and "Montage" not in d
|
||||
and not d.startswith("Storno")]
|
||||
pruefe(len(camping) == 1, f"die Campingrechnung entsteht wie immer ({camping})")
|
||||
if camping:
|
||||
t = (PdfReader(os.path.join(ordner, camping[0])).pages[0].extract_text() or "").replace("\n", " ")
|
||||
pruefe("Tage/Menge" in t, "und hat weiter ihre Positionstabelle")
|
||||
|
||||
# ---- 7. Datum: Tag bis 31, Monat bis 12 -----------------------------------
|
||||
p = A.RechnungsApp._datum_ziffern_pruefen
|
||||
faelle = [("5", "05"), ("9", "09"), ("35", "3"), ("00", "0"), ("31", "31"),
|
||||
("013", "0103"), ("1213", "121"), ("1200", "120"), ("3112", "3112")]
|
||||
for ein, soll in faelle:
|
||||
pruefe(p(ein) == soll, f"Tippen {ein!r} ergibt {p(ein)!r} (soll {soll!r})")
|
||||
|
||||
f.r_datum.set("31.02.2026") # Tag und Monat je fuer sich moeglich - den Tag gibt es nicht
|
||||
kaputt = f._datumsfelder_pruefen()
|
||||
pruefe(len(kaputt) == 1 and "31.02.2026" in kaputt[0],
|
||||
f"31.02.2026 wird als Datum beanstandet ({kaputt})")
|
||||
vorher = len([d for d in os.listdir(ordner) if d.endswith(".pdf")])
|
||||
warnungen.clear()
|
||||
f.pdf_erstellen()
|
||||
nachher = len([d for d in os.listdir(ordner) if d.endswith(".pdf")])
|
||||
pruefe(nachher == vorher, "und es wird keine Rechnung mit diesem Datum geschrieben")
|
||||
pruefe(any("Datum" in w for w in warnungen), f"mit Meldung an den Benutzer ({warnungen})")
|
||||
|
||||
f.destroy()
|
||||
if not fehler:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
else:
|
||||
print(f" (Wegwerf-Ordner bleibt: {tmp})")
|
||||
print("---")
|
||||
print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")
|
||||
sys.exit(1 if fehler else 0)
|
||||
14
theme.py
14
theme.py
|
|
@ -94,6 +94,20 @@ class ThemeMixin:
|
|||
style.map(sbs, background=[("active", accent)],
|
||||
troughcolor=[("disabled", bg)])
|
||||
style.configure("TSeparator", background=self.col_line)
|
||||
# Reiter (Campingrechnung / Montage): ohne eigene Farben zeichnet clam den
|
||||
# AKTIVEN Reiter dunkler als die ruhenden - man sieht dann nicht, wo man
|
||||
# ist. Der offene Reiter bekommt darum den Hintergrund des Blattes und die
|
||||
# Akzentfarbe, die ruhenden bleiben zurueckgenommen.
|
||||
style.configure("TNotebook", background=bg, bordercolor=self.col_line,
|
||||
tabmargins=(2, 4, 2, 0))
|
||||
style.configure("TNotebook.Tab", background=field, foreground=self.col_muted,
|
||||
bordercolor=self.col_line, padding=(16, 7),
|
||||
font=("Segoe UI", 10))
|
||||
style.map("TNotebook.Tab",
|
||||
background=[("selected", bg), ("active", sel)],
|
||||
foreground=[("selected", accent), ("active", fg)],
|
||||
font=[("selected", ("Segoe UI", 10, "bold"))],
|
||||
expand=[("selected", (1, 1, 1, 0))])
|
||||
# Klassische tk-Widgets (Canvas, Toplevel, Text) erben diese Defaults
|
||||
self.option_add("*background", bg)
|
||||
self.option_add("*foreground", fg)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue