Der Rechnungszaehler kann nicht mehr hinter das Vergebene zurueck

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx
This commit is contained in:
TheMockTv 2026-09-03 22:43:23 +02:00
parent ad1ac9af6f
commit d884cff6f8
6 changed files with 326 additions and 3 deletions

43
app.py
View file

@ -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:

View file

@ -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 <vergeben> - fuer die Rechnung, die auf ein Storno folgt.

View file

@ -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()

View file

@ -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()

View file

@ -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:

208
pruef_nummernsperre.py Normal file
View file

@ -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)