From 5ef4e0a769ec70b5d0adaa7617eb0b65777c513e Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Thu, 3 Sep 2026 20:12:04 +0200 Subject: [PATCH 1/2] 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 Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx --- app.py | 225 ++++++++++++++++++++++++++++++++++++++++-- config.py | 13 +++ einstellungen.py | 64 ++++++++++++ korrektur.py | 24 ++++- pdf_renderer.py | 211 +++++++++++++++++++++++++--------------- pruef_montage.py | 247 +++++++++++++++++++++++++++++++++++++++++++++++ theme.py | 14 +++ 7 files changed, 703 insertions(+), 95 deletions(-) create mode 100644 pruef_montage.py diff --git a/app.py b/app.py index 8b05b7f..07f7ed0 100644 --- a/app.py +++ b/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("", 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("<>", 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_" 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: diff --git a/config.py b/config.py index 218280d..f36fc11 100644 --- a/config.py +++ b/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. diff --git a/einstellungen.py b/einstellungen.py index 200aa7c..190a226 100644 --- a/einstellungen.py +++ b/einstellungen.py @@ -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("", lambda _e: d.destroy()) + def dlg_katalog(self): d = tk.Toplevel(self) d.title("Leistungskatalog / Preise") diff --git a/korrektur.py b/korrektur.py index 77038df..43fcfc7 100644 --- a/korrektur.py +++ b/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 = [] diff --git a/pdf_renderer.py b/pdf_renderer.py index bfc69ff..50c5971 100644 --- a/pdf_renderer.py +++ b/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"{cfg['firma'].get('absender','')}", - 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"{cfg['firma'].get('absender','')}", + 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) diff --git a/pruef_montage.py b/pruef_montage.py new file mode 100644 index 0000000..1aff9dc --- /dev/null +++ b/pruef_montage.py @@ -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) diff --git a/theme.py b/theme.py index cf89143..8c53c46 100644 --- a/theme.py +++ b/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) From ecad4b00ea136470a20d609ebbe2d1fb747b83cf Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Thu, 3 Sep 2026 20:38:57 +0200 Subject: [PATCH 2/2] Gemeinsames Nummernbuch, Storno kuerzt die Naechte, Testlauf-Schalter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das Rechnungstool kannte nur die Nummern seiner eigenen PDFs. Im Steuerjournal stehen aber Buchungen ohne PDF - der Altbestand aus der Excel-Mappe - und deren Nummern hat es ein zweites Mal vergeben. § 14 Abs. 4 Nr. 4 UStG laesst jede Nummer nur einmal zu. Deshalb fuehren beide Programme jetzt EIN gemeinsames Buch (gemeinsam.py): eine SQLite in einem Ordner, den beide kennen. Es ist ein Register, keine zweite Buchhaltung - die Wahrheit bleiben die PDFs. Was hineinkommt: jede geschriebene Rechnung, jeder Storno (mit storno_zu), jede Neuausstellung. Die Nummernvergabe und die Kollisionspruefung beim Erstellen fragen es mit ab; ein verworfener Storno gibt seine Nummer wieder frei. Faellt der Ordner aus (Netzlaufwerk weg), laeuft alles weiter - dann steht es in der Fussleiste. ordnerwahl.py fragt den Ordner beim ersten Start ab: auswaehlen, OK, Sicherheitsfrage, bei Nein zurueck ins Feld. Zwei Fehler daran gleich behoben, bevor sie jemand treffen konnte: das Fenster war zu schmal (der Auswahl-Knopf lag ausserhalb) und der Griff (grab_set) muss waehrend des Windows-Ordner- dialogs los sein, sonst nimmt der keine Eingabe an. Storno: er ist das Spiegelbild der Rechnung - auch bei den UEBERNACHTUNGEN. Vorher stand dort nichts, und das Steuerjournal meldete dem Amt die Naechte der aufgehobenen Rechnung weiter mit. Auf dem Blatt steht "4 Naechte (aufgehoben)", in den Kenndaten -4. "Neue Rechnung:" auf dem Storno-Blatt heisst jetzt "Berichtigte Rechnung:" - die alte Beschriftung las sich wie die Nummer DIESES Blattes. --test / CAMPINGHOF_TEST=1 unterdrueckt den Erststart-Dialog. In der EXE kommt man ohnehin nicht daran, und die Pruefstaende bleiben sonst im Dialog stehen. Pruefstand pruef_nummernbuch.py (neu): beide Programme auf einem Ordner - das Journal traegt drei Altbestands-Nummern ein, das Rechnungstool schlaegt danach 2026-004 statt 2026-001 vor. Alle uebrigen Pruefstaende gruen. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx --- app.py | 206 ++++++++++++++++++++++++++++++++++++++++++- bestand.py | 18 ++-- gemeinsam.py | 160 +++++++++++++++++++++++++++++++++ korrektur.py | 27 +++++- ordnerwahl.py | 183 ++++++++++++++++++++++++++++++++++++++ pdf_renderer.py | 16 +++- pruef_dialoge.py | 2 + pruef_kernregeln.py | 2 + pruef_kette.py | 2 + pruef_montage.py | 2 + pruef_nummernbuch.py | 186 ++++++++++++++++++++++++++++++++++++++ pruef_storno.py | 2 + 12 files changed, 790 insertions(+), 16 deletions(-) create mode 100644 gemeinsam.py create mode 100644 ordnerwahl.py create mode 100644 pruef_nummernbuch.py diff --git a/app.py b/app.py index 07f7ed0..60c0169 100644 --- a/app.py +++ b/app.py @@ -27,9 +27,12 @@ from tkinter import ttk, messagebox import config import berechnung +import gemeinsam +import ordnerwahl import pdf_renderer from helfer import ANREDEN, kunde_maengel, parse_date, parse_num, saeubern, safe_filename, ziffern -from bestand import naechste_freie_nummer, vorhandene_rechnungen +from bestand import (naechste_freie_nummer, rechnungen_im_ordner, + vorhandene_rechnungen) from einzelinstanz import (eigene_pid_merken, einzelinstanz_pruefen, fenster_nach_vorn, laufende_pid_lesen) from theme import ThemeMixin @@ -37,6 +40,13 @@ from korrektur import KorrekturMixin from einstellungen import EinstellungenMixin +# Testlauf: kein Erststart-Dialog. Pruefstaende fahren das Fenster fern, ein +# modaler Dialog wuerde sie haengen lassen - und in der EXE kommt man an den +# Schalter ohnehin nicht heran (Doppelklick uebergibt keine Argumente). +# python app.py --test oder CAMPINGHOF_TEST=1 +TESTLAUF = ("--test" in sys.argv) or os.environ.get("CAMPINGHOF_TEST") == "1" + + class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): def __init__(self): super().__init__() @@ -53,6 +63,9 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): # 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 # Zwischenspeicher fuer eine reine Berichtigung (§ 31 Abs. 5 UStDV): # die naechste PDF ist dann ein berichtigtes Blatt zu dieser Rechnung. self.berichtigung_stand = None @@ -74,6 +87,8 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): self.after(60, lambda: self._dark_titlebar(self)) # Beim ersten Start nach dem Speicherort fragen (wenn noch keiner gesetzt ist) self.after(250, self._frage_speicherort_falls_noetig) + # ... und danach nach dem gemeinsamen Ordner fuer das Nummernbuch. + self.after(400, self._frage_gemeinsam_falls_noetig) # ---------------------------------------------------------------- Menue @@ -96,6 +111,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): ("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)], } @@ -484,6 +500,11 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): status.pack(side="bottom", fill="x") ttk.Label(status, text="powered by ravokk", foreground=self.col_muted, font=("Segoe UI", 8)).pack(side="right") + # Steht der gemeinsame Ordner? Das gehoert sichtbar hin - ohne ihn kennt + # das Programm die Nummern des Steuerjournals nicht. + self.buch_lbl = ttk.Label(status, text="", foreground=self.col_muted, + font=("Segoe UI", 8)) + self.buch_lbl.pack(side="left") f = ttk.Frame(self, padding=(12, 6, 12, 10)) f.pack(side="bottom", fill="x") @@ -502,6 +523,161 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): width=14).pack(side="left", padx=4) ttk.Button(links, text="Zurücksetzen", command=self.reset_formular, width=14).pack(side="left", padx=4) + # --------------------------------------------------- Gemeinsames Buch + def _buch_oeffnen(self): + """Nummernbuch aus dem eingestellten Ordner - oder None.""" + ordner = str(self.cfg.get("gemeinsam_ordner", "") or "") + if not ordner: + self.buch = None + return + taugt, grund = gemeinsam.ordner_taugt(ordner) + if not taugt: + self.buch = None + print("[nummernbuch] nicht erreichbar:", grund) + return + self.buch = gemeinsam.Nummernbuch(ordner) + + def _frage_gemeinsam_falls_noetig(self): + """Beim ersten Start nach dem gemeinsamen Ordner fragen. + + Wer abbricht, kann weiterarbeiten - dann steht aber in der Fussleiste, + dass die Nummern des Steuerjournals unbekannt sind. Beim naechsten Start + wird wieder gefragt. + """ + self._buch_oeffnen() + if self.buch is None and not TESTLAUF: + self.dlg_gemeinsam(erststart=True) + self._buch_anzeigen() + + def dlg_gemeinsam(self, erststart=False): + ordner = ordnerwahl.ordner_abfragen( + self, "Rechnungstool", str(self.cfg.get("gemeinsam_ordner", "") or "")) + if not ordner: + if erststart: + messagebox.showwarning( + "Ohne gemeinsamen Ordner", + "Es wurde kein gemeinsamer Ordner gewählt.\n\n" + "Das Rechnungstool kennt jetzt nur die Nummern seiner eigenen " + "PDFs - eine Buchung, die nur im Steuerjournal steht, kann eine " + "Nummer ein zweites Mal bekommen.\n\n" + "Nachholen: Einstellungen -> Gemeinsamer Ordner (Nummernbuch).") + return + self.cfg["gemeinsam_ordner"] = ordner + config.save_config(self.cfg) + self._buch_oeffnen() + self._nachtragen() + self._buch_anzeigen() + self._neue_nummer_vorschlagen() + + def _buch_anzeigen(self): + """Fussleiste: welcher Ordner, wie viele Nummern - oder die Warnung.""" + if not hasattr(self, "buch_lbl"): + return + if self.buch is None: + self.buch_lbl.configure( + text="kein gemeinsamer Ordner - Nummern des Steuerjournals unbekannt", + foreground="#c04040") + return + try: + anzahl = len(self.buch.nummern()) + except Exception as e: # noqa: BLE001 + self.buch_lbl.configure(text=f"Nummernbuch nicht lesbar: {e}", + foreground="#c04040") + return + self.buch_lbl.configure(text=f"Nummernbuch: {anzahl} Nummern", + foreground=self.col_muted) + + def _belegte_nummern(self): + """Alle im Buch vergebenen Nummern. Faellt das Buch aus, ist die Menge + leer - eine Rechnung muss auch dann geschrieben werden koennen.""" + if self.buch is None: + return set() + try: + return self.buch.nummern() + except Exception as e: # noqa: BLE001 + print("[nummernbuch] Lesen fehlgeschlagen:", e) + return set() + + def _ins_buch(self, nummer, kopf, rechnung, pdf_pfad=""): + """Eine geschriebene Rechnung ins gemeinsame Buch eintragen.""" + if self.buch is None or not nummer: + return + aufschlag = rechnung.aufschlaege[0] if rechnung.aufschlaege else None + datum = parse_date(str(kopf.get("datum", ""))) + try: + jahr = int(str(nummer).split("-")[0]) + except (ValueError, IndexError): + jahr = datum.year if datum else datetime.date.today().year + try: + self.buch.eintragen( + nummer, + jahr=jahr, + datum=datum.isoformat() if datum else "", + nachname=str(kopf.get("nachname", "") or ""), + vorname=str(kopf.get("vorname", "") or ""), + naechte=int(parse_num(str(kopf.get("naechte") or 0)) or 0), + zwischensumme=berechnung.runde(rechnung.brutto), + steuer_satz=float(aufschlag.satz) if aufschlag else 0.0, + steuer_betrag=berechnung.runde(aufschlag.betrag) if aufschlag else 0.0, + gesamt=berechnung.runde(rechnung.gesamt), + art=str(kopf.get("art", "rechnung") or "rechnung"), + vorgang=str(kopf.get("vorgang", "") or ""), + storno_zu=str(kopf.get("storno_zu", "") or ""), + ersetzt=str(kopf.get("ersetzt", "") or ""), + berichtigt_zu=str(kopf.get("berichtigt_zu", "") or ""), + vorlage=str(kopf.get("vorlage", "") or ""), + quelle="rechnungstool", herkunft="pdf", pdf_pfad=pdf_pfad) + except Exception as e: # noqa: BLE001 - die PDF ist schon geschrieben + print("[nummernbuch] Eintragen fehlgeschlagen:", e) + messagebox.showwarning( + "Nummernbuch nicht erreichbar", + "Die Rechnung ist geschrieben, konnte aber nicht ins gemeinsame " + f"Nummernbuch eingetragen werden:\n{e}\n\n" + "Das Steuerjournal sieht sie trotzdem - es liest die PDFs.") + + def _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 = {} @@ -569,7 +745,8 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): """ if self.storno_stand or self.berichtigung_stand: return - frei = naechste_freie_nummer(config.ensure_output_dir(self.cfg), self.cfg) + frei = naechste_freie_nummer(config.ensure_output_dir(self.cfg), self.cfg, + self._belegte_nummern()) if frei: self.r_nummer.set(frei) @@ -770,6 +947,17 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): print("[berichtigung] FEHLER:", e) return print(f"[berichtigung] erstellt -> {pfad}") + # Das Blatt verbraucht keine Nummer und bucht nichts - im Buch wird + # nur der berichtigte Name der Rechnung nachgezogen. Wuerde man hier + # den ganzen Eintrag schreiben, stuenden dort die Nullen des Blattes. + if self.buch is not None: + try: + self.buch.eintragen(ber["zu"], + nachname=kopf.get("nachname", ""), + vorname=kopf.get("vorname", ""), + berichtigt_zu=ber["zu"]) + except Exception as e: # noqa: BLE001 + print("[nummernbuch] Berichtigung nicht eingetragen:", e) self.berichtigung_stand = None self._storno_anzeigen() if messagebox.askyesno("Fertig", @@ -784,9 +972,14 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): # 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) - if vorhanden: + # 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) - frei = naechste_freie_nummer(out_dir, self.cfg) + if im_buch and not vorhanden: + liste = "- steht im gemeinsamen Nummernbuch (Steuerjournal)" + frei = naechste_freie_nummer(out_dir, self.cfg, self._belegte_nummern()) if not frei: messagebox.showerror( "Rechnungsnummer schon vergeben", @@ -823,6 +1016,11 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): 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() + # Rechnungsnummer-Zaehler hochsetzen, falls Format JJJJ-NNN self._zaehler_aus_nummer_speichern(nummer) diff --git a/bestand.py b/bestand.py index baac192..3333712 100644 --- a/bestand.py +++ b/bestand.py @@ -168,14 +168,14 @@ def rechnungen_im_ordner(ordner): gefunden.append(daten) gefunden.sort(key=lambda d: str(d.get("rechnungsnummer", "")), reverse=True) return gefunden -def naechste_freie_nummer_nach(ordner, cfg, vergeben): +def naechste_freie_nummer_nach(ordner, cfg, vergeben, belegt=None): """Erste freie Nummer NACH - fuer die Rechnung, die auf ein Storno folgt. Das Storno hat seine Nummer gerade bekommen; je nachdem, ob der Zaehler schon mitgezogen ist, liefert naechste_freie_nummer() sonst genau diese Nummer noch einmal. """ - kandidat = naechste_freie_nummer(ordner, cfg) + kandidat = naechste_freie_nummer(ordner, cfg, belegt) if kandidat and kandidat != vergeben: return kandidat ersatz = dict(cfg) @@ -184,15 +184,21 @@ def naechste_freie_nummer_nach(ordner, cfg, vergeben): ersatz["rechnung_jahr"], ersatz["rechnung_zaehler"] = int(jahr), int(z) except (ValueError, AttributeError): return kandidat - return naechste_freie_nummer(ordner, ersatz) + return naechste_freie_nummer(ordner, ersatz, belegt) -def naechste_freie_nummer(ordner, cfg): - """Erste Nummer JJJJ-NNN, zu der im Ordner noch kein PDF liegt. +def naechste_freie_nummer(ordner, cfg, belegt=None): + """Erste Nummer JJJJ-NNN, die weder im Ordner noch im Nummernbuch steht. Gezaehlt wird ab dem zuletzt vergebenen Stand aus der config; im Ordner hoeher liegende Nummern werden dabei uebersprungen, weil sie nicht frei sind. + + `belegt` sind zusaetzlich vergebene Nummern - die aus dem gemeinsamen + Nummernbuch. Ohne sie kennt das Rechnungstool nur die eigenen PDFs und + vergibt eine Nummer aus dem Steuerjournal (Altbestand aus der Excel-Mappe, + zu dem es keine PDF gibt) ein zweites Mal. """ + belegt = set(belegt or ()) jahr = cfg.get("rechnung_jahr", datetime.date.today().year) z = cfg.get("rechnung_zaehler", 0) # Jahreswechsel: neues Jahr faengt bei 001 an. Muss HIER stehen und nirgends @@ -203,6 +209,6 @@ def naechste_freie_nummer(ordner, cfg): for _ in range(1000): z += 1 kandidat = config.format_rechnungsnummer(jahr, z) - if not vorhandene_rechnungen(ordner, kandidat): + if kandidat not in belegt and not vorhandene_rechnungen(ordner, kandidat): return kandidat return None diff --git a/gemeinsam.py b/gemeinsam.py new file mode 100644 index 0000000..1bf3a19 --- /dev/null +++ b/gemeinsam.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- +"""Das gemeinsame Nummernbuch von Rechnungstool und Steuerjournal. + +WARUM ES DAS GIBT +----------------- +Bis hierher wusste jedes Programm nur, was in SEINEM Ordner lag. Das +Rechnungstool sucht die naechste freie Nummer in den PDFs im Rechnungsordner - +im Steuerjournal stehen aber auch Buchungen, zu denen es gar keine PDF gibt: +der Altbestand aus der Excel-Mappe und die frueher von Hand eingetragenen +Zeilen. Deren Nummern kannte das Rechnungstool nicht und hat sie ein zweites +Mal vergeben. Jede Rechnungsnummer darf es aber nur einmal geben +(Paragraph 14 Abs. 4 Nr. 4 UStG). + +Deshalb fuehren beide Programme ab jetzt EIN gemeinsames Buch: eine +SQLite-Datei in einem Ordner, den beide kennen. Wer eine Nummer vergibt oder +eine Buchung anlegt, traegt sie dort ein; wer eine neue Nummer sucht, schaut +zuerst hier nach. + +Die Datei ist ein REGISTER, keine zweite Buchhaltung: die Wahrheit bleiben die +Rechnungs-PDFs. Geht das Buch verloren, fehlt keine Rechnung - es muss nur neu +gefuellt werden (beide Programme tun das beim naechsten Einlesen von selbst). + +Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie +hinueber - sonst schreiben zwei Fassungen in dieselbe Datei. +""" + +import logging +import os +import sqlite3 + +log = logging.getLogger("gemeinsam") + +DATEINAME = "campinghof-nummernbuch.sqlite3" + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS nummern ( + nummer TEXT PRIMARY KEY, -- '2026-442', immer die volle Nummer + jahr INTEGER NOT NULL DEFAULT 0, + datum TEXT NOT NULL DEFAULT '', -- ISO JJJJ-MM-TT + nachname TEXT NOT NULL DEFAULT '', + vorname TEXT NOT NULL DEFAULT '', + naechte INTEGER NOT NULL DEFAULT 0, + zwischensumme REAL NOT NULL DEFAULT 0, + steuer_satz REAL NOT NULL DEFAULT 0, + steuer_betrag REAL NOT NULL DEFAULT 0, + gesamt REAL NOT NULL DEFAULT 0, + art TEXT NOT NULL DEFAULT 'rechnung', -- rechnung|storno|korrektur|berichtigung + vorgang TEXT NOT NULL DEFAULT '', -- Klammer um Rechnung+Storno+Korrektur + storno_zu TEXT NOT NULL DEFAULT '', -- welche Rechnung hebt dieser Storno auf + ersetzt TEXT NOT NULL DEFAULT '', -- welche Rechnung ersetzt diese hier + berichtigt_zu TEXT NOT NULL DEFAULT '', -- zu welcher Rechnung gehoert das Blatt + vorlage TEXT NOT NULL DEFAULT '', -- '' oder 'montage' + quelle TEXT NOT NULL DEFAULT '', -- rechnungstool|journal + herkunft TEXT NOT NULL DEFAULT '', -- pdf|xlsx|manuell + pdf_pfad TEXT NOT NULL DEFAULT '', + geaendert TEXT NOT NULL DEFAULT '' -- ISO-Zeitstempel +); +CREATE INDEX IF NOT EXISTS idx_nummern_jahr ON nummern (jahr); +""" + +FELDER = ("jahr", "datum", "nachname", "vorname", "naechte", "zwischensumme", + "steuer_satz", "steuer_betrag", "gesamt", "art", "vorgang", "storno_zu", + "ersetzt", "berichtigt_zu", "vorlage", "quelle", "herkunft", "pdf_pfad") + + +def pfad_zur_datei(ordner): + """Vollstaendiger Pfad der gemeinsamen Datei in diesem Ordner.""" + return os.path.join(ordner or "", DATEINAME) + + +def ordner_taugt(ordner): + """Kann in diesem Ordner wirklich gearbeitet werden? Wird ausprobiert, + nicht geraten - ein nicht verbundenes Netzlaufwerk sieht sonst gut aus, + bis die erste Rechnung geschrieben wird.""" + if not ordner or not os.path.isdir(ordner): + return False, "Den Ordner gibt es nicht." + probe = os.path.join(ordner, ".schreibprobe") + try: + with open(probe, "w", encoding="ascii") as f: + f.write("x") + os.remove(probe) + except OSError as e: + return False, f"In den Ordner kann nicht geschrieben werden ({e.strerror or e})." + return True, "" + + +class Nummernbuch: + """Das gemeinsame Register. Wird zum Schreiben kurz geoeffnet und wieder + geschlossen - zwei Programme haben die Datei sonst dauerhaft in der Hand, + und auf einem Netzlaufwerk gibt das gesperrte Dateien.""" + + def __init__(self, ordner): + self.ordner = ordner + self.pfad = pfad_zur_datei(ordner) + + def _verbindung(self): + con = sqlite3.connect(self.pfad, timeout=10) + con.row_factory = sqlite3.Row + con.executescript(SCHEMA) + return con + + # ---- Lesen ------------------------------------------------------------- + def nummern(self, jahr=None): + """Alle vergebenen Nummern (als Menge), auf Wunsch nur eines Jahres.""" + with self._verbindung() as con: + if jahr: + zeilen = con.execute("SELECT nummer FROM nummern WHERE jahr=?", (int(jahr),)) + else: + zeilen = con.execute("SELECT nummer FROM nummern") + return {z["nummer"] for z in zeilen} + + def eintrag(self, nummer): + with self._verbindung() as con: + z = con.execute("SELECT * FROM nummern WHERE nummer=?", (str(nummer),)).fetchone() + return dict(z) if z else None + + def alle(self): + with self._verbindung() as con: + return [dict(z) for z in con.execute("SELECT * FROM nummern ORDER BY nummer")] + + def belegt(self, nummer): + return self.eintrag(nummer) is not None + + # ---- Schreiben --------------------------------------------------------- + def eintragen(self, nummer, **werte): + """Nummer eintragen oder ihre Angaben auffrischen. + + Eine schon eingetragene Nummer wird NICHT als Fehler behandelt: beide + Programme sehen dieselbe Rechnung (das Rechnungstool beim Schreiben, + das Journal beim Einlesen der PDF) und sollen sich gegenseitig + ergaenzen duerfen. + """ + import datetime + nummer = str(nummer or "").strip() + if not nummer: + return False + daten = {f: werte.get(f) for f in FELDER if werte.get(f) is not None} + daten["geaendert"] = datetime.datetime.now().isoformat(timespec="seconds") + spalten = ["nummer"] + list(daten) + platzhalter = ",".join("?" * len(spalten)) + setzen = ",".join(f"{k}=excluded.{k}" for k in daten) + with self._verbindung() as con: + con.execute( + f"INSERT INTO nummern ({','.join(spalten)}) VALUES ({platzhalter}) " + f"ON CONFLICT(nummer) DO UPDATE SET {setzen}", + [nummer] + list(daten.values())) + log.info("Nummernbuch: %s eingetragen (%s)", nummer, daten.get("quelle", "")) + return True + + def storniert(self): + """Nummern, die durch einen Storno aufgehoben sind.""" + with self._verbindung() as con: + return {z["storno_zu"] for z in + con.execute("SELECT storno_zu FROM nummern WHERE storno_zu <> ''")} + + def entfernen(self, nummer): + """Nur fuer den Rueckweg: ein verworfener Storno gibt seine Nummer zurueck.""" + with self._verbindung() as con: + con.execute("DELETE FROM nummern WHERE nummer=?", (str(nummer),)) + log.info("Nummernbuch: %s entfernt", nummer) diff --git a/korrektur.py b/korrektur.py index 43fcfc7..7c7cdcd 100644 --- a/korrektur.py +++ b/korrektur.py @@ -27,7 +27,7 @@ import config import pdf_renderer from bestand import (kundendaten_ermitteln, naechste_freie_nummer, naechste_freie_nummer_nach, rechnungen_im_ordner) -from helfer import kunde_maengel, nummer_normalisieren, safe_filename +from helfer import kunde_maengel, nummer_normalisieren, parse_num, safe_filename from storno import storno_rechnung_bauen @@ -106,6 +106,13 @@ class KorrekturMixin: "Zähler nicht gespeichert", f"Die Einstellungen ließen sich nicht schreiben:\n{e}\n\n" "Bitte im Menü unter „Rechnungsnummer…“ prüfen.") + # Die Nummer ist wieder frei - auch im gemeinsamen Buch, sonst + # ueberspringt das Rechnungstool sie fuer immer. + if self.buch is not None and stand.get("nummer"): + try: + self.buch.entfernen(stand["nummer"]) + except Exception as e: # noqa: BLE001 + print("[nummernbuch] Storno nicht ausgetragen:", e) self.storno_stand = None self._storno_anzeigen() self._neue_nummer_vorschlagen() @@ -298,7 +305,7 @@ class KorrekturMixin: def _storno_anlegen(self, meta, out_dir): """Schreibt die Storno-PDF und füllt das Formular für die berichtigte Rechnung.""" alt_nummer = str(meta.get("rechnungsnummer", "")) - nummer = naechste_freie_nummer(out_dir, self.cfg) + nummer = naechste_freie_nummer(out_dir, self.cfg, self._belegte_nummern()) if not nummer: messagebox.showerror("Keine freie Nummer", "Es wurde keine freie Rechnungsnummer gefunden.") @@ -310,7 +317,7 @@ class KorrekturMixin: # die naechste freie NACH dem Storno. Sie kommt aufs Storno-Blatt, damit # die Kette aus dem Beleg allein lesbar ist und kein Zustand gemerkt # werden muss: alte Nummer -> Storno -> neue Nummer, alles auf einem Blatt. - folge_nummer = naechste_freie_nummer_nach(out_dir, self.cfg, nummer) + folge_nummer = naechste_freie_nummer_nach(out_dir, self.cfg, nummer, self._belegte_nummern()) # Der Storno geht an denselben Empfaenger wie die alte Rechnung - was in # deren Kenndaten fehlt, wird aus ihrem Adressblock geholt. kunde, woher = kundendaten_ermitteln(meta) @@ -333,10 +340,17 @@ class KorrekturMixin: icon="warning", default="no"): return + # Der Storno ist das Spiegelbild der Rechnung - auch bei den + # UEBERNACHTUNGEN. Stand hier vorher nichts, blieben die Naechte der + # aufgehobenen Rechnung im Steuerjournal stehen: die Betraege kuerzten + # sich, die Zahl der Uebernachtungen nicht, und das Amt bekam sie + # doppelt gemeldet (4 - 0 + 4 = 8 statt 4). + alt_naechte = int(parse_num(str(meta.get("naechte") or 0)) or 0) kopf = { "rechnungsnummer": nummer, "datum": heute, - "anreise": "", "abreise": "", "naechte": "", + "anreise": "", "abreise": "", + "naechte": -abs(alt_naechte) if alt_naechte else "", "kunde_zeilen": kunde_zeilen, "nachname": kunde.get("nachname", ""), "vorname": kunde.get("vorname", ""), @@ -373,6 +387,11 @@ class KorrekturMixin: } self._zaehler_aus_nummer_speichern(nummer) print(f"[storno] erstellt -> {pfad}") + # Der Storno hat eine eigene Nummer - die gehoert ins gemeinsame Buch, + # sonst vergibt sie das andere Programm noch einmal, und das Journal + # erfaehrt nicht, dass die Rechnung aufgehoben ist. + self._ins_buch(nummer, kopf, r, pfad) + self._buch_anzeigen() fehlend = self._formular_aus_meta(meta) self.r_nummer.set(folge_nummer or "") diff --git a/ordnerwahl.py b/ordnerwahl.py new file mode 100644 index 0000000..616cab6 --- /dev/null +++ b/ordnerwahl.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- +"""Den gemeinsamen Ordner abfragen - in beiden Programmen gleich. + +Rechnungstool und Steuerjournal fuehren ein gemeinsames Nummernbuch +(siehe gemeinsam.py). Damit sie sich finden, muss BEIDEN derselbe Ordner +genannt werden - deshalb fragt jedes Programm beim ersten Start danach und +sagt ausdruecklich dazu, dass es derselbe sein muss. + +Der Weg ist bewusst umstaendlich: auswaehlen, dann OK, dann noch einmal +bestaetigen. Ein falscher Ordner faellt sonst erst auf, wenn eine +Rechnungsnummer zum zweiten Mal vergeben wurde. + +Wer jetzt keinen Ordner hat, kommt mit "Spaeter einrichten" weiter - das +Programm laeuft dann ohne das gemeinsame Buch und sagt das in der Fussleiste. + +⚠ Das Fenster bekommt seine Groesse ausdruecklich gesetzt. Ohne das war es zu +schmal, der Knopf zum Auswaehlen lag ausserhalb - und der Dialog war nicht zu +bedienen (gefunden am 03.09.2026, bevor es jemand benutzen musste). + +Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie +hinueber. +""" + +import os +import tkinter as tk +from tkinter import ttk, filedialog, messagebox + +import gemeinsam + +BREITE = 780 + + +class OrdnerWahl(tk.Toplevel): + """Modaler Dialog. Nach dem Schliessen steht das Ergebnis in `.ergebnis` + (gewaehlter Ordner) oder es ist None (spaeter/abgebrochen).""" + + def __init__(self, master, programm: str, vorgabe: str = ""): + super().__init__(master) + self.ergebnis = None + self.title("Gemeinsamer Ordner für das Nummernbuch") + self.transient(master) + self.pfad = tk.StringVar(value=vorgabe or "") + + rahmen = ttk.Frame(self, padding=18) + rahmen.pack(fill="both", expand=True) + rahmen.columnconfigure(0, weight=1) + breite = BREITE - 60 + + ttk.Label(rahmen, text="Bitte den gemeinsamen Ordner auswählen", + font=("Segoe UI", 13, "bold")).grid(row=0, column=0, sticky="w") + ttk.Label(rahmen, wraplength=breite, justify="left", text=( + "Rechnungstool und Steuerjournal führen zusammen ein Nummernbuch. Darin steht " + "jede vergebene Rechnungsnummer – auch die aus der alten Excel-Tabelle, zu der " + "es gar keine PDF gibt.\n\n" + "Ohne dieses Buch kennt jedes Programm nur seine eigenen Nummern und vergibt " + "eine Nummer ein zweites Mal. Jede Nummer darf es aber nur einmal geben " + "(§ 14 Abs. 4 Nr. 4 UStG).")).grid(row=1, column=0, sticky="w", pady=(8, 12)) + + tk.Label(rahmen, wraplength=breite, justify="left", fg="#a00000", + font=("Segoe UI", 10, "bold"), + text="WICHTIG: In BEIDEN Programmen muss derselbe Ordner stehen – am besten " + "der Ordner, in dem die Programme liegen.").grid( + row=2, column=0, sticky="w", pady=(0, 14)) + + wahl = ttk.Frame(rahmen) + wahl.grid(row=3, column=0, sticky="ew") + wahl.columnconfigure(0, weight=1) + self.feld = ttk.Entry(wahl, textvariable=self.pfad, state="readonly") + self.feld.grid(row=0, column=0, sticky="ew") + ttk.Button(wahl, text="Install-Ordner auswählen…", command=self.waehlen, + width=26).grid(row=0, column=1, sticky="w", padx=(10, 0)) + + self.stand = tk.Label(rahmen, text="", wraplength=breite, justify="left", + font=("Segoe UI", 9)) + self.stand.grid(row=4, column=0, sticky="w", pady=(10, 0)) + + knoepfe = ttk.Frame(rahmen) + knoepfe.grid(row=5, column=0, sticky="ew", pady=(18, 0)) + self.ok = ttk.Button(knoepfe, text="OK", command=self.uebernehmen, width=14) + self.ok.pack(side="right") + ttk.Button(knoepfe, text="Später einrichten", command=self.spaeter, + width=18).pack(side="left") + + self._stand_zeigen() + self.protocol("WM_DELETE_WINDOW", self.spaeter) + self.bind("", lambda _e: self.spaeter()) + + # Groesse ausdruecklich setzen: sonst schneidet Tk das Fenster an der + # zweiten Spalte ab und der Auswahl-Knopf ist nicht erreichbar. + self.update_idletasks() + hoehe = max(self.winfo_reqheight(), 300) + x = master.winfo_rootx() + max(0, (master.winfo_width() - BREITE) // 2) + y = master.winfo_rooty() + 90 + self.geometry(f"{BREITE}x{hoehe}+{max(0, x)}+{max(0, y)}") + self.minsize(BREITE, hoehe) + self.grab_set() + self.lift() + self.focus_force() + + # ------------------------------------------------------------------ Weg + def waehlen(self): + # Der Griff (grab_set) muss waehrend des Windows-Ordnerdialogs los sein: + # sonst kann der native Dialog hinter dem gesperrten Fenster landen und + # nimmt keine Eingabe an - man klickt und es passiert nichts. + try: + self.grab_release() + except tk.TclError: + pass + try: + ordner = filedialog.askdirectory( + title="Gemeinsamen Ordner wählen (in beiden Programmen derselbe!)", + initialdir=self.pfad.get() or os.path.expanduser("~"), parent=self) + finally: + try: + self.grab_set() + except tk.TclError: + pass + if ordner: + self.pfad.set(os.path.abspath(ordner)) + self._stand_zeigen() + + def _stand_zeigen(self): + """Sagt, was in dem gewaehlten Ordner schon liegt. + + Ein bereits vorhandenes Nummernbuch ist das beste Zeichen, dass es der + richtige Ordner ist - dann hat das andere Programm ihn schon. + """ + ordner = self.pfad.get().strip() + if not ordner: + self.stand.configure(text="Noch kein Ordner gewählt.", fg="#a00000") + self.ok.state(["disabled"]) + return + taugt, grund = gemeinsam.ordner_taugt(ordner) + if not taugt: + self.stand.configure(text=grund, fg="#a00000") + self.ok.state(["disabled"]) + return + self.ok.state(["!disabled"]) + if os.path.exists(gemeinsam.pfad_zur_datei(ordner)): + try: + anzahl = len(gemeinsam.Nummernbuch(ordner).nummern()) + except Exception: # noqa: BLE001 + anzahl = "?" + self.stand.configure( + text=f"Hier liegt schon ein Nummernbuch mit {anzahl} Rechnungsnummern – " + "das ist der Ordner des anderen Programms. Genau richtig.", + fg="#1f5c3d") + else: + self.stand.configure( + text="Hier ist noch kein Nummernbuch – es wird neu angelegt. Wähle im " + "anderen Programm denselben Ordner.", fg="#444444") + + def uebernehmen(self): + ordner = self.pfad.get().strip() + taugt, grund = gemeinsam.ordner_taugt(ordner) + if not taugt: + messagebox.showwarning("Ordner geht nicht", grund or "Bitte einen Ordner wählen.", + parent=self) + return + if not messagebox.askyesno( + "Sind Sie sicher?", + f"Gemeinsamer Ordner:\n\n{ordner}\n\n" + "Steht im anderen Programm derselbe Ordner? Nur dann kennen beide " + "dieselben Rechnungsnummern.\n\n" + "Ja = Ordner übernehmen\n" + "Nein = zurück zur Auswahl", + icon="question", default="yes", parent=self): + # Zurueck ins Auswahlfeld - der Dialog bleibt stehen, nichts ist gesetzt. + self.feld.focus_set() + return + self.ergebnis = ordner + self.destroy() + + def spaeter(self): + self.ergebnis = None + self.destroy() + + +def ordner_abfragen(fenster, programm, vorgabe=""): + """Dialog zeigen und den gewaehlten Ordner zurueckgeben (oder None).""" + d = OrdnerWahl(fenster, programm, vorgabe) + fenster.wait_window(d) + return d.ergebnis diff --git a/pdf_renderer.py b/pdf_renderer.py index 50c5971..a95b013 100644 --- a/pdf_renderer.py +++ b/pdf_renderer.py @@ -301,7 +301,9 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): # 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"])) + # "Neue Rechnung" war missverstaendlich - man liest es als die Nummer + # DIESES Blattes. Gemeint ist die Rechnung, die den Storno ersetzt. + info_rows.append(("Berichtigte Rechnung:", kopf["korrektur_nummer"])) elif art == "korrektur" and kopf.get("ersetzt"): info_rows.append(("Ersetzt Rechnung:", kopf.get("ersetzt", ""))) elif art == "berichtigung": @@ -315,7 +317,17 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): 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")) + # Auf dem Storno stehen die Naechte negativ in den Kenndaten (das + # Spiegelbild fuer das Steuerjournal). Auf dem BLATT liest sich "-4 + # Naechte" wie ein Tippfehler - dort steht, was aufgehoben wird. + try: + anzahl = int(float(str(kopf.get("naechte")).replace(",", "."))) + except (TypeError, ValueError): + anzahl = 0 + if anzahl < 0: + info_rows.append(("Zeitraum:", f"{abs(anzahl)} Nächte (aufgehoben)")) + else: + 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]) diff --git a/pruef_dialoge.py b/pruef_dialoge.py index 284dfb0..bac300b 100644 --- a/pruef_dialoge.py +++ b/pruef_dialoge.py @@ -29,6 +29,8 @@ roh = json.load(open(config.CONFIG_PATH, encoding="utf-8")) roh["output_dir"] = ordner json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False) +# Kein Erststart-Dialog im Pruefstand - er wuerde den Lauf haengen lassen. +os.environ["CAMPINGHOF_TEST"] = "1" import app as A # noqa: E402 from tkinter import messagebox # noqa: E402 diff --git a/pruef_kernregeln.py b/pruef_kernregeln.py index 723a4d1..e5369a5 100644 --- a/pruef_kernregeln.py +++ b/pruef_kernregeln.py @@ -37,6 +37,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) +# Kein Erststart-Dialog im Pruefstand - er wuerde den Lauf haengen lassen. +os.environ["CAMPINGHOF_TEST"] = "1" import app as A # noqa: E402 import bestand as BE # noqa: E402 from tkinter import messagebox # noqa: E402 diff --git a/pruef_kette.py b/pruef_kette.py index 163ae59..3bb28fe 100644 --- a/pruef_kette.py +++ b/pruef_kette.py @@ -36,6 +36,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) +# Kein Erststart-Dialog im Pruefstand - er wuerde den Lauf haengen lassen. +os.environ["CAMPINGHOF_TEST"] = "1" import app as A # noqa: E402 import bestand as BE # noqa: E402 from tkinter import messagebox # noqa: E402 diff --git a/pruef_montage.py b/pruef_montage.py index 1aff9dc..cea63cb 100644 --- a/pruef_montage.py +++ b/pruef_montage.py @@ -40,6 +40,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) +# Kein Erststart-Dialog im Pruefstand - er wuerde den Lauf haengen lassen. +os.environ["CAMPINGHOF_TEST"] = "1" import app as A # noqa: E402 import bestand as BE # noqa: E402 from tkinter import messagebox # noqa: E402 diff --git a/pruef_nummernbuch.py b/pruef_nummernbuch.py new file mode 100644 index 0000000..e857f84 --- /dev/null +++ b/pruef_nummernbuch.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- +"""Pruefstand: das gemeinsame Nummernbuch von Rechnungstool und Steuerjournal. + +Der Fall, um den es geht (sein Satz vom 03.09.2026): + + "er hat manuelle eingetragen die das andere programm die rechnungs nummer + nicht kennt - mit der sqlite beheben wir das" + +Im Steuerjournal stehen Buchungen aus der alten Excel-Mappe, zu denen es keine +PDF gibt. Das Rechnungstool sucht die naechste freie Nummer bisher nur in +seinem eigenen Ordner - und vergibt so eine Nummer zum zweiten Mal +(§ 14 Abs. 4 Nr. 4 UStG: jede Nummer nur einmal). + +Geprueft wird mit BEIDEN echten Programmen auf EINEM gemeinsamen Ordner. +""" +import json +import os +import shutil +import sys +import tempfile +import threading + +TOOL = r"C:\claude\rechnungstool" +JOURNAL = r"C:\claude\beherbergungssteuer" + +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) + + +def _reissleine(): + print("ABBRUCH | Pruefstand haengt (Zeitgrenze)") + os._exit(2) + + +_wecker = threading.Timer(150, _reissleine) +_wecker.daemon = True +_wecker.start() + +os.environ["CAMPINGHOF_TEST"] = "1" # kein Erststart-Dialog + +tmp = tempfile.mkdtemp(prefix="buch_pruef_") +gemeinsam_ordner = os.path.join(tmp, "install") # hier liegt das Buch +rechnungen = os.path.join(tmp, "rechnungen") +journal_daten = os.path.join(tmp, "journal_daten") +for d in (gemeinsam_ordner, rechnungen, journal_daten): + os.makedirs(d) + +# ---- Erst das Steuerjournal (eigener Datenordner) -------------------------- +sys.path.insert(0, JOURNAL) +import ablage # noqa: E402 + +ablage.daten_ordner = lambda: journal_daten +import app as JA # noqa: E402 - das Journal +from modell import Buchung # noqa: E402 +import gemeinsam # noqa: E402 + +del sys.modules["app"] +sys.path.remove(JOURNAL) + +from datetime import date # noqa: E402 +from tkinter import messagebox # noqa: E402 + +messagebox.showinfo = lambda *a, **k: None +messagebox.showwarning = lambda *a, **k: None + +journal = JA.App() +journal.withdraw() +# Altbestand wie aus der Excel-Mappe: Nummern OHNE PDF. +for nr, name in (("001", "Alt Eins"), ("002", "Alt Zwei"), ("003", "Alt Drei")): + journal.journal.speichern(Buchung(date(2026, 5, 1), nr, 2026, name, 2, 50.0, + quelle="xlsx")) +journal.jahr.set(2026) +journal.aktualisieren() +journal.journal.setze("gemeinsam_ordner", gemeinsam_ordner) +journal._buch_oeffnen() +journal._buch_fuellen() + +buch = gemeinsam.Nummernbuch(gemeinsam_ordner) +pruefe(os.path.exists(gemeinsam.pfad_zur_datei(gemeinsam_ordner)), + "das Journal legt das Nummernbuch im gemeinsamen Ordner an") +nummern = buch.nummern() +pruefe(nummern == {"2026-001", "2026-002", "2026-003"}, + f"die drei Altbestands-Nummern stehen drin ({sorted(nummern)})") +eintrag = buch.eintrag("2026-002") +pruefe(eintrag and eintrag["herkunft"] == "xlsx" and eintrag["quelle"] == "journal", + f"mit Herkunft und Quelle ({eintrag and (eintrag['quelle'], eintrag['herkunft'])})") + +# ---- Jetzt das Rechnungstool auf denselben Ordner -------------------------- +sys.path.insert(0, TOOL) +import config # noqa: E402 + +shutil.copy(os.path.join(TOOL, "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"] = rechnungen +roh["gemeinsam_ordner"] = gemeinsam_ordner +json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2) + +import app as A # noqa: E402 - das Rechnungstool +import bestand as BE # noqa: E402 + +antworten = {"Fertig": False, "endgültig erstellen": True, "Hausnummer": True, + "Storno wirklich": True} + + +def askyesno(titel, text, **kw): + for schlue, wert in antworten.items(): + if schlue in titel: + return wert + raise AssertionError(f"unangemeldeter Dialog: {titel!r}") + + +messagebox.askyesno = askyesno + +tool = A.RechnungsApp() +tool.cfg["output_dir"] = rechnungen +tool._buch_oeffnen() +pruefe(tool.buch is not None, "das Rechnungstool findet dasselbe Buch") +tool._neue_nummer_vorschlagen() +pruefe(tool.r_nummer.get() == "2026-004", + f"es schlaegt 2026-004 vor und ueberspringt den Altbestand ({tool.r_nummer.get()})") + +# ---- Eine Rechnung schreiben: sie muss im Buch landen --------------------- +tool.k_vorname.set("Anna") +tool.k_name.set("Neu") +tool.k_strasse.set("Musterweg 1") +tool.k_plzort.set("12345 Musterstadt") +tool.r_anreise.set("01.09.2026") +tool.r_abreise.set("03.09.2026") +tool.anzahl_vars[sorted(tool.anzahl_vars)[0]].set("2") +tool.update() +tool.pdf_erstellen() +pruefe(os.path.exists(os.path.join(rechnungen, "Rechnung_2026-004_Neu.pdf")), + "die Rechnung 2026-004 ist geschrieben") +e4 = buch.eintrag("2026-004") +pruefe(e4 is not None, "und steht im gemeinsamen Buch") +pruefe(e4 and e4["quelle"] == "rechnungstool" and e4["nachname"] == "Neu", + f"mit Quelle rechnungstool und Namen ({e4 and (e4['quelle'], e4['nachname'])})") +pruefe(e4 and abs(float(e4["zwischensumme"]) - 26.0) < 0.005, + f"und dem Betrag ({e4 and e4['zwischensumme']})") + +# ---- Storno: eigene Nummer ins Buch, beim Verwerfen wieder raus ----------- +offen = [m for m in BE.rechnungen_im_ordner(rechnungen) if m.get("art") != "storno"] +tool._storno_anlegen(offen[0], rechnungen) +storno_nr = tool.storno_stand["nummer"] if tool.storno_stand else "" +pruefe(buch.belegt(storno_nr), f"der Storno {storno_nr} steht im Buch") +e_storno = buch.eintrag(storno_nr) +pruefe(e_storno and e_storno["storno_zu"] == "2026-004", + f"und sagt, welche Rechnung er aufhebt ({e_storno and e_storno['storno_zu']})") +tool._storno_verwerfen("Pruefstand") +pruefe(not buch.belegt(storno_nr), + f"beim Verwerfen ist die Nummer wieder frei ({storno_nr})") + +# ---- Das Journal sieht die neue Rechnung im Buch -------------------------- +journal._buch_oeffnen() +pruefe("2026-004" in journal.buch.nummern(), + "das Journal sieht die Nummer des Rechnungstools im Buch") + +# ---- Faellt das Buch aus, laeuft trotzdem alles weiter -------------------- +tool.cfg["gemeinsam_ordner"] = os.path.join(tmp, "gibtsnicht") +tool._buch_oeffnen() +pruefe(tool.buch is None, "ein nicht erreichbarer Ordner schaltet das Buch ab") +pruefe(tool._belegte_nummern() == set(), "die belegten Nummern sind dann leer") +tool._neue_nummer_vorschlagen() +pruefe(bool(tool.r_nummer.get()), f"und es wird trotzdem eine Nummer vorgeschlagen " + f"({tool.r_nummer.get()})") + +tool.destroy() +journal.journal.schliessen() +journal.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) diff --git a/pruef_storno.py b/pruef_storno.py index 1f29489..232279b 100644 --- a/pruef_storno.py +++ b/pruef_storno.py @@ -32,6 +32,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) +# Kein Erststart-Dialog im Pruefstand - er wuerde den Lauf haengen lassen. +os.environ["CAMPINGHOF_TEST"] = "1" import app as A # noqa: E402 import bestand as BE # noqa: E402 from tkinter import messagebox # noqa: E402