From d884cff6f83375eb0a77d6710017c335fc98a360 Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Thu, 3 Sep 2026 22:43:23 +0200 Subject: [PATCH] Der Rechnungszaehler kann nicht mehr hinter das Vergebene zurueck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sein Einwand: 'er kann jetzt bescheissen, indem er das in den einstellungen aendert' - und sein Vorschlag: 'dann koennte man noch die pdfs nutzen als referenz, die haben auch die nummern'. Der hoechste je vergebene Stand kommt jetzt aus ZWEI Quellen: * den PDFs im Rechnungsordner (der Dateiname traegt die Nummer - kein Oeffnen noetig) * dem gemeinsamen Nummernbuch (kennt auch Nummern OHNE PDF, den Altbestand) Der Zaehler in der config taugt nicht als Wahrheit, er ist ja gerade das, was sich aendern laesst. Deshalb: beim Nummernvorschlag wird ein zurueckgedrehter Zaehler stillschweigend wieder angehoben, und die Einstellungen nehmen keinen Wert mehr an, der hinter dem Vergebenen liegt - mit Meldung, welcher Stand schon erreicht ist und woher er kommt. Nach VORN bleibt er frei: eine lueckenlose Folge verlangt das Gesetz nicht, jede Nummer aber nur einmal (§ 14 Abs. 4 Nr. 4 UStG). Pruefstand pruef_nummernsperre.py: 17 Pruefungen - zurueckgedrehter Zaehler, Einstellungen vor und zurueck, nur Ordner, nur Buch, und beides weg. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx --- app.py | 43 ++++++++- bestand.py | 24 +++++ einstellungen.py | 29 +++++- gemeinsam.py | 13 +++ pruef_angebot.py | 12 +++ pruef_nummernsperre.py | 208 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 326 insertions(+), 3 deletions(-) create mode 100644 pruef_nummernsperre.py diff --git a/app.py b/app.py index 960fe55..56734b9 100644 --- a/app.py +++ b/app.py @@ -33,7 +33,8 @@ 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, rechnungen_im_ordner, +from bestand import (hoechster_zaehler_im_ordner, naechste_freie_nummer, + rechnungen_im_ordner, vorhandene_rechnungen) from einzelinstanz import (eigene_pid_merken, einzelinstanz_pruefen, fenster_nach_vorn, laufende_pid_lesen) @@ -595,6 +596,42 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): self.buch_lbl.configure(text=f"Nummernbuch: {anzahl} Nummern", foreground=self.col_muted) + def hoechster_stand(self, jahr=None): + """Hoechster JE VERGEBENER Zaehler dieses Jahres - aus beiden Quellen. + + Der Zaehler in der config allein taugt nicht als Wahrheit: er laesst + sich in den Einstellungen zuruecksetzen. Deshalb zaehlt, was WIRKLICH + vergeben ist - die PDFs im Ordner und das gemeinsame Nummernbuch (das + kennt auch die Nummern aus dem Altbestand, zu denen es keine PDF gibt). + """ + jahr = int(jahr or self.cfg.get("rechnung_jahr", 0) or datetime.date.today().year) + hoch = hoechster_zaehler_im_ordner(config.ensure_output_dir(self.cfg), jahr) + if self.buch is not None: + try: + hoch = max(hoch, self.buch.hoechster_zaehler(jahr)) + except Exception as e: # noqa: BLE001 - ohne Buch bleibt der Ordner + print("[nummernbuch] Hoechststand nicht lesbar:", e) + return hoch + + def _zaehler_absichern(self): + """Einen zurueckgedrehten Zaehler stillschweigend wieder anheben. + + Nicht als Vorwurf - es reicht ein alter Rechner, eine wiederhergestellte + config oder ein Jahreswechsel von Hand. Vergeben ist vergeben. + """ + jahr = int(self.cfg.get("rechnung_jahr", 0) or datetime.date.today().year) + if jahr != datetime.date.today().year: + return + hoch = self.hoechster_stand(jahr) + if hoch > int(self.cfg.get("rechnung_zaehler", 0) or 0): + print(f"[nummern] Zaehler stand auf {self.cfg.get('rechnung_zaehler')}, " + f"vergeben ist bis {hoch} - angehoben") + self.cfg["rechnung_zaehler"] = hoch + try: + config.save_config(self.cfg) + except OSError as e: # noqa: BLE001 + print("[nummern] Zaehler nicht gespeichert:", e) + 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.""" @@ -803,6 +840,10 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): """ if self.storno_stand or self.berichtigung_stand: return + # Erst sicherstellen, dass der Zaehler nicht hinter dem steht, was + # schon vergeben ist - sonst schlaegt das Programm eine Nummer vor, + # die es ein zweites Mal vergeben wuerde. + self._zaehler_absichern() frei = naechste_freie_nummer(config.ensure_output_dir(self.cfg), self.cfg, self._belegte_nummern()) if frei: diff --git a/bestand.py b/bestand.py index 3333712..abfd31e 100644 --- a/bestand.py +++ b/bestand.py @@ -8,6 +8,7 @@ Rechnung steht, welche Nummer als nächste frei ist. Hier wird nur gelesen. import datetime import json import os +import re import config from helfer import ANREDEN, name_aufteilen, saeubern, safe_filename @@ -168,6 +169,29 @@ def rechnungen_im_ordner(ordner): gefunden.append(daten) gefunden.sort(key=lambda d: str(d.get("rechnungsnummer", "")), reverse=True) return gefunden +def hoechster_zaehler_im_ordner(ordner, jahr): + """Groesster Zaehler dieses Jahres, den die PDFs im Ordner hergeben. + + Gelesen wird der DATEINAME (Rechnung_2026-442_..., Storno_2026-443_...) - + das geht ohne jede PDF zu oeffnen und reicht: das Programm schreibt die + Nummer immer in den Namen. Was jemand von Hand umbenannt hat, faellt hier + durch; dafuer gibt es das gemeinsame Nummernbuch als zweite Quelle. + """ + hoch = 0 + muster = re.compile(rf"(?:Rechnung|Storno)_{int(jahr)}-(\d+)", re.IGNORECASE) + try: + namen = os.listdir(ordner) + except OSError: + return 0 + for name in namen: + if not name.lower().endswith(".pdf"): + continue + m = muster.search(name) + if m: + hoch = max(hoch, int(m.group(1))) + return hoch + + def naechste_freie_nummer_nach(ordner, cfg, vergeben, belegt=None): """Erste freie Nummer NACH - fuer die Rechnung, die auf ein Storno folgt. diff --git a/einstellungen.py b/einstellungen.py index 190a226..0ea2588 100644 --- a/einstellungen.py +++ b/einstellungen.py @@ -412,14 +412,39 @@ class EinstellungenMixin: ttk.Label(frm, text="Format: JJJJ-NNN (z. B. 2026-013). Der Zähler ist der zuletzt\n" "vergebene Wert – beim nächsten Beleg wird +1 vorgeschlagen.", justify="left").grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 8)) + hoch_jetzt = self.hoechster_stand() + ttk.Label(frm, foreground=self.col_muted, justify="left", + text=f"Schon vergeben ist bis {self.cfg.get('rechnung_jahr')}-" + f"{hoch_jetzt:03d} (aus den PDFs im Ordner und dem gemeinsamen\n" + "Nummernbuch). Dahinter zurück geht nicht – nach vorn schon.").grid( + row=4, column=0, columnspan=2, sticky="w", pady=(10, 0)) ttk.Label(frm, text="Jahr").grid(row=1, column=0, sticky="w", pady=3) ttk.Entry(frm, textvariable=v_jahr, width=10).grid(row=1, column=1, sticky="w", pady=3) ttk.Label(frm, text="Zuletzt vergebener Zähler").grid(row=2, column=0, sticky="w", pady=3) ttk.Entry(frm, textvariable=v_z, width=10).grid(row=2, column=1, sticky="w", pady=3) def speichern(): - self.cfg["rechnung_jahr"] = int(parse_num(v_jahr.get())) - self.cfg["rechnung_zaehler"] = int(parse_num(v_z.get())) + jahr = int(parse_num(v_jahr.get())) + zaehler = int(parse_num(v_z.get())) + # Zurueck geht nicht: was vergeben ist, ist vergeben. Geprueft wird + # gegen die PDFs im Ordner UND das gemeinsame Nummernbuch - der + # Zaehler in der config allein waere ja gerade das, was hier + # geaendert wird. Vorwaerts ist erlaubt: eine lueckenlose Folge + # verlangt das Gesetz nicht, jede Nummer aber nur einmal + # (§ 14 Abs. 4 Nr. 4 UStG). + hoch = self.hoechster_stand(jahr) + if jahr == int(self.cfg.get("rechnung_jahr", jahr) or jahr) and zaehler < hoch: + messagebox.showwarning( + "Nummer schon vergeben", + f"Für {jahr} ist bereits bis {jahr}-{hoch:03d} vergeben.\n\n" + f"Der Zähler kann nicht auf {zaehler} zurückgesetzt werden – sonst " + "gäbe es eine Nummer zweimal, und jede Rechnungsnummer darf nur " + "einmal vergeben werden (§ 14 Abs. 4 Nr. 4 UStG).\n\n" + "Nach vorn ist er frei: Lücken sind erlaubt.", parent=d) + v_z.set(str(hoch)) + return + self.cfg["rechnung_jahr"] = jahr + self.cfg["rechnung_zaehler"] = zaehler config.save_config(self.cfg) self._neue_nummer_vorschlagen() d.destroy() diff --git a/gemeinsam.py b/gemeinsam.py index fced977..6c2872d 100644 --- a/gemeinsam.py +++ b/gemeinsam.py @@ -209,6 +209,19 @@ class Nummernbuch: zeilen = con.execute("SELECT nummer FROM nummern") return {z["nummer"] for z in zeilen} + def hoechster_zaehler(self, jahr): + """Groesster Zaehler dieses Jahres im Buch (0, wenn nichts drinsteht). + + Aus '2026-442' wird 442. Nummern anderer Reihen (Angebote 2026-A001) + zaehlen NICHT mit - die haben mit der Rechnungsnummer nichts zu tun. + """ + hoch = 0 + for nummer in self.nummern(jahr): + teil = str(nummer).split("-")[-1] + if teil.isdigit(): + hoch = max(hoch, int(teil)) + return hoch + def eintrag(self, nummer): with self._verbindung() as con: z = con.execute("SELECT * FROM nummern WHERE nummer=?", (str(nummer),)).fetchone() diff --git a/pruef_angebot.py b/pruef_angebot.py index d4f8e54..e7229f2 100644 --- a/pruef_angebot.py +++ b/pruef_angebot.py @@ -226,6 +226,18 @@ pruefe(buchungen and buchungen[0].voll_nummer == "2026-001", f"und zwar 2026-001 ({[b.voll_nummer for b in buchungen]})") pruefe(not any("A0" in b.voll_nummer for b in buchungen), "keine Angebots-Nummer im Journal") + +# ---- 9. Zweite Sicherung: auch ohne Kenndaten wird nichts gebucht ----- +from pdf_parser import keine_buchung # noqa: E402 + +nur_name = os.path.join(ordner, "Angebot_2026-A099_OhneKenndaten.pdf") +shutil.copy(os.path.join(angebote, "Angebot_2026-A001_Vorab.pdf"), nur_name) +pruefe(keine_buchung(nur_name), + "ein Angebot wird auch am Dateinamen erkannt (falls die Kenndaten fehlen)") +pruefe(keine_buchung(os.path.join(angebote, "Angebot_2026-A001_Vorab.pdf")), + "und alles im Unterordner Angebote wird uebersprungen") +pruefe(not keine_buchung(os.path.join(ordner, "Rechnung_2026-001_Vorab.pdf")), + "eine echte Rechnung wird davon NICHT erfasst") journal.schliessen() if not fehler: diff --git a/pruef_nummernsperre.py b/pruef_nummernsperre.py new file mode 100644 index 0000000..436a7f6 --- /dev/null +++ b/pruef_nummernsperre.py @@ -0,0 +1,208 @@ +# -*- coding: utf-8 -*- +"""Pruefstand: der Rechnungszaehler kann nicht mehr zurueck. + +Sein Einwand: *"er kann jetzt bescheissen, indem er das in den einstellungen +aendert"* - und der Vorschlag dazu: *"dann koennte man noch die pdfs nutzen als +referenz, die haben auch die nummern"*. + +Geprueft wird beides als Quelle: + * die PDFs im Rechnungsordner (Dateiname traegt die Nummer) + * das gemeinsame Nummernbuch (kennt auch Nummern OHNE PDF - Altbestand) + +Vorwaerts darf der Zaehler: eine lueckenlose Folge verlangt das Gesetz nicht. +Zurueck nicht: jede Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG). +""" +import json +import os +import shutil +import sys +import tempfile +import threading + +QUELLE = r"C:\claude\rechnungstool" +sys.path.insert(0, QUELLE) + +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" +tmp = tempfile.mkdtemp(prefix="sperre_pruef_") +ordner = os.path.join(tmp, "rechnungen") +gemeinsam_ordner = os.path.join(tmp, "install") +os.makedirs(ordner) +os.makedirs(gemeinsam_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 gemeinsam # noqa: E402 +from tkinter import messagebox # noqa: E402 + +warnungen = [] +messagebox.askyesno = lambda t, x, **k: ("Fertig" not in t) +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 "") + +f = A.RechnungsApp() +f.cfg["output_dir"] = ordner + +JAHR = 2026 +if int(f.cfg.get("rechnung_jahr", 0)) != JAHR: + print("HINWEIS | Der Pruefstand rechnet mit 2026 - heute ist ein anderes Jahr.") + + +def rechnung(name): + f.reset_formular() + f.k_vorname.set("Anna") + f.k_name.set(name) + f.k_strasse.set("Musterweg 1") + f.k_plzort.set("12345 Musterstadt") + f.r_anreise.set("01.08.2026") + f.r_abreise.set("03.08.2026") + f.anzahl_vars[sorted(f.anzahl_vars)[0]].set("2") + f._testwahl = "rechnung" + f.update() + f.pdf_erstellen() + + +# ---- 1. Drei Rechnungen schreiben ---------------------------------------- +for name in ("Eins", "Zwei", "Drei"): + rechnung(name) +dateien = sorted(d for d in os.listdir(ordner) if d.endswith(".pdf")) +pruefe(len(dateien) == 3, f"drei Rechnungen geschrieben ({dateien})") +pruefe(f.cfg.get("rechnung_zaehler") == 3, f"Zaehler steht auf 3 ({f.cfg.get('rechnung_zaehler')})") +pruefe(f.hoechster_stand(JAHR) == 3, f"hoechster Stand aus den PDFs: 3 ({f.hoechster_stand(JAHR)})") + +# ---- 2. Zaehler von Hand zurueckdrehen (der Betrugsfall) ----------------- +f.cfg["rechnung_zaehler"] = 0 +config.save_config(f.cfg) +f._neue_nummer_vorschlagen() +pruefe(f.r_nummer.get() == "2026-004", + f"trotz zurueckgedrehtem Zaehler wird 2026-004 vorgeschlagen ({f.r_nummer.get()})") +pruefe(f.cfg.get("rechnung_zaehler") == 3, + f"und der Zaehler ist wieder angehoben ({f.cfg.get('rechnung_zaehler')})") + +# ---- 3. Auch die Einstellungen lassen ihn nicht zurueck ------------------ +def knoepfe_von(w, aus=None): + aus = [] if aus is None else aus + for k in w.winfo_children(): + if k.winfo_class() in ("TButton", "Button"): + aus.append(k) + knoepfe_von(k, aus) + return aus + + +def felder_von(w, aus=None): + aus = [] if aus is None else aus + for k in w.winfo_children(): + if k.winfo_class() in ("TEntry", "Entry"): + aus.append(k) + felder_von(k, aus) + return aus + + +def nummerndialog(neuer_zaehler): + """Dialog oeffnen, Zaehler eintragen, Speichern druecken.""" + f.dlg_nummer() + f.update() + top = [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"][-1] + felder = felder_von(top) + felder[1].delete(0, "end") + felder[1].insert(0, str(neuer_zaehler)) + f.update() + for k in knoepfe_von(top): + if "Speichern" in str(k.cget("text")): + k.invoke() + break + f.update() + noch_offen = [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"] + for w in noch_offen: + w.destroy() + return bool(noch_offen) + + +warnungen.clear() +blieb_offen = nummerndialog(1) +pruefe(f.cfg.get("rechnung_zaehler") == 3, + f"die Einstellungen setzen den Zaehler NICHT auf 1 ({f.cfg.get('rechnung_zaehler')})") +pruefe(any("vergeben" in w for w in warnungen), f"mit Meldung an den Benutzer ({warnungen})") +pruefe(blieb_offen, "und der Dialog bleibt offen, statt still zu schlucken") + +warnungen.clear() +nummerndialog(50) +pruefe(f.cfg.get("rechnung_zaehler") == 50, + f"nach VORN darf er (Luecken sind erlaubt) ({f.cfg.get('rechnung_zaehler')})") +pruefe(not warnungen, f"und dabei wird nicht gewarnt ({warnungen})") + +# ---- 4. Das Nummernbuch kennt Nummern OHNE PDF -------------------------- +f.cfg["gemeinsam_ordner"] = gemeinsam_ordner +f._buch_oeffnen() +pruefe(f.buch is not None, "das gemeinsame Nummernbuch ist da") +f.buch.eintragen("2026-090", jahr=2026, nachname="AltbestandOhnePDF", + quelle="journal", herkunft="xlsx") +pruefe(f.hoechster_stand(JAHR) == 90, + f"der hoechste Stand kommt jetzt aus dem Buch: 90 ({f.hoechster_stand(JAHR)})") + +f.cfg["rechnung_zaehler"] = 5 +config.save_config(f.cfg) +f._neue_nummer_vorschlagen() +pruefe(f.r_nummer.get() == "2026-091", + f"vorgeschlagen wird 2026-091 - hinter dem Altbestand ({f.r_nummer.get()})") + +warnungen.clear() +nummerndialog(10) +pruefe(f.cfg.get("rechnung_zaehler") == 90, + f"und die Einstellungen kommen auch nicht hinter das Buch zurueck " + f"({f.cfg.get('rechnung_zaehler')})") + +# ---- 5. Ohne PDFs im Ordner: das Buch allein haelt die Sperre ----------- +for d in os.listdir(ordner): + if d.endswith(".pdf"): + os.remove(os.path.join(ordner, d)) +pruefe(f.hoechster_stand(JAHR) == 90, + f"auch ohne PDFs bleibt der Stand bei 90 ({f.hoechster_stand(JAHR)})") + +# ---- 6. Und ohne Buch: die PDFs allein halten sie ---------------------- +rechnung("Nachher") # schreibt 2026-091 +f.buch = None +f.cfg["gemeinsam_ordner"] = "" +pruefe(f.hoechster_stand(JAHR) == 91, + f"ohne Buch zaehlen die PDFs: 91 ({f.hoechster_stand(JAHR)})") +warnungen.clear() +nummerndialog(2) +pruefe(f.cfg.get("rechnung_zaehler") == 91, + f"und auch dann geht es nicht zurueck ({f.cfg.get('rechnung_zaehler')})") + +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)