diff --git a/app.py b/app.py index 8b05b7f..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 @@ -92,9 +107,11 @@ 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), + ("Gemeinsamer Ordner (Nummernbuch)…", self.dlg_gemeinsam), ], "Hilfe": [("Info", self._info)], } @@ -211,8 +228,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 +244,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 +269,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 +287,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 +332,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 +404,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 @@ -349,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") @@ -367,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 = {} @@ -374,11 +685,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. @@ -416,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) @@ -434,16 +764,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 +900,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"] @@ -573,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", @@ -587,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", @@ -608,9 +998,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: @@ -621,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) @@ -681,6 +1081,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/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/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/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 77038df..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 "") @@ -405,14 +424,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/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 bfc69ff..a95b013 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,138 @@ 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"): + # "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": + # 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"): + # 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]) + 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 +398,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 +562,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_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 new file mode 100644 index 0000000..cea63cb --- /dev/null +++ b/pruef_montage.py @@ -0,0 +1,249 @@ +# -*- 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) + +# 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 + + +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/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 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)