Windows-Kopien zaehlen mit, und bei doppelter Nummer wird nicht geraten

Duplikatanzeige gehoert nicht hierher, sondern in den Steuerrechner - wieder
ausgebaut (duplikate.py, pruef_duplikate.py).

Zwei echte Loecher, die dabei auffielen:

1. vorhandene_rechnungen() uebersah Windows-Kopien. "Rechnung_2026-013 (1).pdf"
   und "... - Kopie.pdf" galten nicht als vergeben, die Nummer wurde also ein
   zweites Mal ausgegeben. Genau so ist der Altbestand entstanden. Jetzt zaehlt
   alles mit, was nach der Nummer KEINE weitere Ziffer hat - "2026-0135" bleibt
   getrennt.

2. Liegt eine Nummer mehrfach im Ordner, nahm der Storno-Dialog blind den ersten
   Treffer. Jetzt erscheint eine Auswahlliste mit Datum, Name, Betrag und
   Dateiname; ohne Auswahl passiert nichts, und die Meldung nennt die konkrete
   Datei.

pruef_kernregeln.py: 37 Pruefungen (10 neue), pruef_nummern.py 10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
This commit is contained in:
TheMockTv 2026-09-02 20:20:13 +02:00
parent 67a09e4085
commit d22631467d
10 changed files with 156 additions and 335 deletions

10
app.py
View file

@ -35,11 +35,9 @@ from einzelinstanz import (eigene_pid_merken, einzelinstanz_pruefen, fenster_nac
from theme import ThemeMixin
from korrektur import KorrekturMixin
from einstellungen import EinstellungenMixin
from duplikate import DuplikateMixin
class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin,
DuplikateMixin, tk.Tk):
class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
def __init__(self):
super().__init__()
self.cfg = config.load_config()
@ -76,7 +74,6 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin,
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)
self.after(400, self.duplikate_pruefen_beim_start)
# ---------------------------------------------------------------- Menue
@ -89,7 +86,6 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin,
("PDF erstellen", self.pdf_erstellen),
("Formular zurücksetzen", self.reset_formular),
("Ausgabeordner öffnen", self._ordner_oeffnen),
("Doppelte Rechnungen…", self.dlg_duplikate),
("-", None),
("Beenden", self._beenden), # fragt nach, wenn ein Storno offen ist
],
@ -351,10 +347,6 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin,
status.pack(side="bottom", fill="x")
ttk.Label(status, text="powered by ravokk", foreground=self.col_muted,
font=("Segoe UI", 8)).pack(side="right")
# Meldet sich nur, wenn im Ordner wirklich etwas doppelt liegt.
self.duplikat_hinweis = tk.StringVar(value="")
ttk.Label(status, textvariable=self.duplikat_hinweis, foreground="#b00020",
font=("Segoe UI", 8, "bold")).pack(side="left")
f = ttk.Frame(self, padding=(12, 6, 12, 10))
f.pack(side="bottom", fill="x")

View file

@ -33,9 +33,14 @@ def vorhandene_rechnungen(ordner, nummer):
if not name.lower().endswith(".pdf"):
continue
stamm = name[:-4]
# exakt die Nummer oder Nummer + "_Kundenname" - "2026-130" faellt raus
if any(stamm == k or stamm.startswith(k + "_") for k in koepfe):
# Nach der Nummer darf alles kommen, nur keine weitere Ziffer. Damit
# zaehlen auch die Windows-Kopien mit - "Rechnung_2026-013 (1).pdf",
# "... - Kopie.pdf", "Kopie von ...". Genau so sind die alten Dubletten
# entstanden. "2026-0135" faellt raus, weil dort eine Ziffer folgt.
for k in koepfe:
if stamm.startswith(k) and (len(stamm) == len(k) or not stamm[len(k)].isdigit()):
treffer.append(os.path.join(ordner, name))
break
return sorted(treffer)
def kenndaten_lesen(pfad):
"""Kenndaten (Feld /Subject) aus einer vom Tool erzeugten PDF; None wenn keine.

View file

@ -1,193 +0,0 @@
# -*- coding: utf-8 -*-
"""Doppelte Rechnungen im Ordner finden und anzeigen.
Zwei verschiedene Sorten Dublette, und sie bedeuten NICHT dasselbe:
Nummer doppelt - zwei Geldbelege tragen dieselbe Rechnungsnummer. Das ist
der harte Fall: § 14 Abs. 4 Nr. 4 UStG verlangt, dass jede
Nummer nur einmal vergeben wird. Muss man ansehen.
Inhalt doppelt - gleicher Name, gleiches Datum, gleicher Betrag, aber
verschiedene Nummern. Sieht nach derselben Rechnung
zweimal aus - kann aber auch echt sein (zwei Nächte für
denselben Gast am selben Tag abgerechnet). Nur ein Hinweis.
Nicht als Dublette zaehlen:
- Stornorechnungen und berichtigte Rechnungen haben eigene Nummern, das ist
gewollt.
- Ein Berichtigungsblatt traegt die Nummer der Rechnung, die es berichtigt.
Das ist der Sinn der Sache und keine Doppelvergabe.
"""
import os
import tkinter as tk
from tkinter import ttk
import berechnung
from bestand import kenndaten_lesen
from helfer import nummer_normalisieren
# Belege, die eine eigene Rechnungsnummer verbrauchen
GELDBELEGE = ("rechnung", "korrektur", "storno")
def _nummer_aus_dateiname(pfad):
"""'Rechnung_2026-013_Meier.pdf' -> '2026-013'. Fuer PDFs ohne Kenndaten."""
teile = os.path.basename(pfad)[:-4].split("_")
return teile[1] if len(teile) > 1 else ""
def belege_lesen(ordner):
"""Alle PDFs des Ordners als flache Liste - auch die ohne Kenndaten.
PDFs ohne Kenndaten (fremde Dateien, kaputte Belege) kommen mit
art="unbekannt" mit; ihre Nummer wird aus dem Dateinamen gelesen. Sonst
wuerde ausgerechnet der ungepruefte Altbestand aus der Pruefung fallen.
"""
belege = []
try:
namen = sorted(os.listdir(ordner))
except OSError:
return belege
for name in namen:
if not name.lower().endswith(".pdf"):
continue
pfad = os.path.join(ordner, name)
daten = kenndaten_lesen(pfad)
if daten:
daten["_datei"] = name
belege.append(daten)
else:
belege.append({"art": "unbekannt", "_pfad": pfad, "_datei": name,
"rechnungsnummer": _nummer_aus_dateiname(pfad),
"datum": "", "nachname": "", "gesamt": 0.0})
return belege
def duplikate_finden(ordner):
"""(nummern, inhalte, unlesbar) - je eine Liste von Gruppen bzw. Dateien."""
belege = belege_lesen(ordner)
nach_nummer = {}
for b in belege:
if b.get("art") not in GELDBELEGE and b.get("art") != "unbekannt":
continue # Berichtigungsblatt verbraucht keine Nummer
nr = nummer_normalisieren(b.get("rechnungsnummer"))
if nr:
nach_nummer.setdefault(nr, []).append(b)
nummern = [(nr, gruppe) for nr, gruppe in sorted(nach_nummer.items())
if len(gruppe) > 1]
# Inhaltsgleich: derselbe Gast, dasselbe Datum, derselbe Betrag - aber
# verschiedene Nummern. Stornos bleiben aussen vor, die sind absichtlich
# das Spiegelbild einer anderen Rechnung.
nach_inhalt = {}
for b in belege:
if b.get("art") not in ("rechnung", "korrektur"):
continue
schluessel = (str(b.get("nachname") or "").strip().lower(),
str(b.get("datum") or ""),
round(float(b.get("gesamt") or 0), 2))
if schluessel[0] or schluessel[2]:
nach_inhalt.setdefault(schluessel, []).append(b)
inhalte = []
for schluessel, gruppe in sorted(nach_inhalt.items(), key=lambda x: str(x[0])):
nummern_der_gruppe = {nummer_normalisieren(g.get("rechnungsnummer")) for g in gruppe}
if len(gruppe) > 1 and len(nummern_der_gruppe) > 1:
inhalte.append((schluessel, gruppe))
unlesbar = [b["_datei"] for b in belege if b.get("art") == "unbekannt"]
return nummern, inhalte, unlesbar
class DuplikateMixin:
"""Menuepunkt und Anzeige. Erwartet die Felder der RechnungsApp."""
def duplikate_pruefen_beim_start(self):
"""Stille Prüfung beim Start - meldet sich nur, wenn etwas da ist."""
try:
import config
nummern, inhalte, unlesbar = duplikate_finden(config.ensure_output_dir(self.cfg))
except Exception as e: # noqa: BLE001 - beim Start nie im Weg stehen
print("[duplikate] Prüfung beim Start fehlgeschlagen:", e)
return
if nummern:
self.duplikat_hinweis.set(
f"⚠ {len(nummern)} Rechnungsnummer(n) doppelt vergeben – "
"Datei ▸ Doppelte Rechnungen")
elif inhalte:
self.duplikat_hinweis.set(
f"{len(inhalte)} Rechnung(en) sehen doppelt aus – "
"Datei ▸ Doppelte Rechnungen")
else:
self.duplikat_hinweis.set("")
def dlg_duplikate(self):
"""Zeigt, was im Ordner doppelt ist."""
import config
ordner = config.ensure_output_dir(self.cfg)
nummern, inhalte, unlesbar = duplikate_finden(ordner)
d = tk.Toplevel(self)
d.title("Doppelte Rechnungen")
d.transient(self)
d.grab_set()
self._style_dialog(d)
d.geometry("820x520")
frm = ttk.Frame(d, padding=12)
frm.pack(fill="both", expand=True)
ttk.Label(frm, wraplength=780, justify="left", text=(
f"Geprüft: {ordner}\n\n"
"Doppelte NUMMER ist der harte Fall – jede Rechnungsnummer darf es nur "
"einmal geben (§ 14 Abs. 4 Nr. 4 UStG). Doppelter INHALT ist nur ein "
"Hinweis: gleicher Gast, gleiches Datum, gleicher Betrag unter zwei "
"Nummern. Das kann auch echt sein.")).pack(anchor="w", pady=(0, 10))
rahmen = ttk.Frame(frm)
rahmen.pack(fill="both", expand=True)
leiste = ttk.Scrollbar(rahmen, orient="vertical")
leiste.pack(side="right", fill="y")
text = tk.Text(rahmen, wrap="none", yscrollcommand=leiste.set,
bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg,
relief="flat", font=("Consolas", 9))
text.pack(side="left", fill="both", expand=True)
leiste.configure(command=text.yview)
def zeile(b):
return (f" {b.get('art',''):12s} {b.get('datum',''):11s} "
f"{str(b.get('nachname') or ''):18s} "
f"{berechnung.eur(b.get('gesamt') or 0):>12s} {b.get('_datei','')}\n")
if nummern:
text.insert("end", f"DOPPELTE RECHNUNGSNUMMERN ({len(nummern)})\n\n")
for nr, gruppe in nummern:
text.insert("end", f" {nr} – {len(gruppe)} Belege:\n")
for b in gruppe:
text.insert("end", zeile(b))
text.insert("end", "\n")
else:
text.insert("end", "DOPPELTE RECHNUNGSNUMMERN: keine\n\n")
if inhalte:
text.insert("end", f"SIEHT DOPPELT AUS ({len(inhalte)})\n\n")
for (name, datum, betrag), gruppe in inhalte:
text.insert("end", f" {name or '(ohne Namen)'} · {datum} · "
f"{berechnung.eur(betrag)}:\n")
for b in gruppe:
text.insert("end", f" Nr. {b.get('rechnungsnummer','')} "
f"{b.get('_datei','')}\n")
text.insert("end", "\n")
else:
text.insert("end", "SIEHT DOPPELT AUS: nichts\n\n")
if unlesbar:
text.insert("end", f"OHNE KENNDATEN ({len(unlesbar)}) – von Hand ansehen:\n\n")
for name in unlesbar:
text.insert("end", f" {name}\n")
text.configure(state="disabled")
btns = ttk.Frame(frm)
btns.pack(side="bottom", fill="x", pady=(12, 0))
ttk.Button(btns, text="Ordner öffnen", command=self._ordner_oeffnen).pack(side="left")
ttk.Button(btns, text="Schließen", command=d.destroy).pack(side="right")
d.bind("<Escape>", lambda e: d.destroy())

View file

@ -183,24 +183,71 @@ class KorrekturMixin:
ttk.Label(frm, textvariable=v_info, justify="left", wraplength=430).grid(
row=2, column=0, columnspan=2, sticky="w", pady=(10, 0))
# Wird nur sichtbar, wenn eine Nummer MEHRFACH im Ordner liegt. Genau das
# ist der Altbestand: solange zwei Fenster gleichzeitig laufen konnten,
# wurde dieselbe Nummer zweimal vergeben, teils als Windows-Kopie mit
# "(1)" im Namen. Dann darf das Programm nicht raten, welche gemeint ist.
self._auswahl = tk.Listbox(frm, height=4, exportselection=False,
bg=self.col_field, fg=self.col_fg,
selectbackground=self.col_sel, selectforeground=self.col_fg,
relief="flat", font=("Consolas", 9))
self._auswahl.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(8, 0))
self._auswahl.grid_remove()
self._kandidaten = []
def kandidaten_zur_nummer(nr):
return [x for x in alle
if nummer_normalisieren(x.get("rechnungsnummer")) == nr
and x.get("art") != "berichtigung"]
def suche():
"""(Rechnung, Meldung) zur eingetippten Nummer. Rechnung=None -> Meldung sagt warum."""
nr = nummer_normalisieren(ent.get())
if not nr:
self._kandidaten = []
self._auswahl.grid_remove()
return None, "Nummer eintragen – das Programm sucht die Rechnung."
treffer = next((x for x in alle
if nummer_normalisieren(x.get("rechnungsnummer")) == nr
and x.get("art") != "berichtigung"), None)
if not treffer:
gefunden = kandidaten_zur_nummer(nr)
if not gefunden:
self._kandidaten = []
self._auswahl.grid_remove()
return None, f"Zu {nr} liegt in\n{out_dir}\nkeine Rechnung."
# Mehrere Belege unter derselben Nummer -> auswählen lassen.
if len(gefunden) > 1:
if gefunden != self._kandidaten:
self._kandidaten = gefunden
self._auswahl.delete(0, "end")
for x in gefunden:
self._auswahl.insert("end",
f"{x.get('datum',''):11s} {str(x.get('nachname') or '?'):16s} "
f"{berechnung.eur(x.get('gesamt') or 0):>11s} "
f"{os.path.basename(x.get('_pfad',''))}")
self._auswahl.grid()
self._auswahl.selection_clear(0, "end")
auswahl = self._auswahl.curselection()
if not auswahl:
return None, (f"⚠ Die Nummer {nr} liegt {len(gefunden)}× im Ordner – "
"das darf es nicht geben.\nBitte unten auswählen, "
"welcher Beleg gemeint ist.")
treffer = gefunden[auswahl[0]]
else:
self._kandidaten = []
self._auswahl.grid_remove()
treffer = gefunden[0]
if treffer.get("art") == "storno":
return None, f"{nr} ist selbst eine Stornorechnung."
if nummer_normalisieren(treffer.get("rechnungsnummer")) in stornierte:
return None, f"{nr} wurde bereits storniert – ein zweites Mal geht nicht."
gesamt = float(treffer.get("gesamt") or 0)
mehrfach = (f"⚠ Achtung: die Nummer liegt {len(gefunden)}× im Ordner.\n"
if len(gefunden) > 1 else "")
return treffer, (
mehrfach +
f"Rechnung {treffer.get('rechnungsnummer')} vom {treffer.get('datum','')}"
f" {treffer.get('nachname','')}\n"
f" {treffer.get('nachname','')}"
+ (f" ({os.path.basename(treffer.get('_pfad',''))})" if mehrfach else "") + "\n"
f"Rechnungsbetrag {berechnung.eur(treffer.get('zwischensumme') or 0)}, "
f"Gesamt {berechnung.eur(gesamt)}\n"
f"Der Storno lautet über {berechnung.eur(-abs(gesamt))}.")
@ -208,6 +255,7 @@ class KorrekturMixin:
def vorschau(*_a):
v_info.set(suche()[1])
ent.bind("<KeyRelease>", vorschau)
self._auswahl.bind("<<ListboxSelect>>", vorschau)
def anlegen(*_a):
meta, meldung = suche()
@ -222,7 +270,7 @@ class KorrekturMixin:
ent.bind("<Return>", anlegen)
btns = ttk.Frame(frm)
btns.grid(row=3, column=0, columnspan=2, pady=(14, 0), sticky="e")
btns.grid(row=4, column=0, columnspan=2, pady=(14, 0), sticky="e")
ttk.Button(btns, text="Storno erstellen" if storno else "Rechnung laden",
command=anlegen).pack(side="right", padx=4)
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")

View file

@ -52,7 +52,7 @@ f.update()
f.pdf_erstellen()
DIALOGE = ["dlg_steuern", "dlg_katalog", "dlg_firma", "dlg_nummer",
"dlg_storno", "dlg_berichtigen", "dlg_duplikate"]
"dlg_storno", "dlg_berichtigen"]
for name in DIALOGE:
offen_vorher = [w for w in f.winfo_children() if isinstance(w, tk.Toplevel)]

View file

@ -1,120 +0,0 @@
# -*- coding: utf-8 -*-
"""Pruefstand: findet die Duplikatpruefung das Richtige - und NUR das?
Der schwierige Teil ist nicht das Finden, sondern das Nicht-Finden: eine Kette
aus Rechnung, Storno und berichtigter Rechnung sieht auf den ersten Blick nach
lauter Dubletten aus, ist aber genau richtig so. Ein Berichtigungsblatt traegt
sogar absichtlich die Nummer der Rechnung, die es berichtigt.
Der Ordner wird darum von Hand gebaut - mit dem echten Renderer, aber ohne die
Sperren des Programms. Genau so sieht der Altbestand aus, den niemand geprueft
hat.
"""
import json
import os
import shutil
import sys
import tempfile
QUELLE = r"C:\claude\rechnungstool"
sys.path.insert(0, QUELLE)
tmp = tempfile.mkdtemp(prefix="dubl_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")
import berechnung # noqa: E402
import pdf_renderer # noqa: E402
from duplikate import duplikate_finden # noqa: E402
cfg = config.load_config()
fehler = []
def pruefe(bedingung, text):
print((" OK | " if bedingung else "FEHLER | ") + text)
if not bedingung:
fehler.append(text)
def schreibe(datei, nummer, name, datum, tage=2, **extra):
"""Schreibt eine PDF direkt - ohne die Sperren des Programms."""
r = berechnung.berechne(cfg["katalog"], {1: {"anzahl": tage, "personen": 0}}, cfg)
kopf = {"rechnungsnummer": nummer, "datum": datum, "anreise": "", "abreise": "",
"naechte": "", "nachname": name, "vorname": "Anna",
"kunde_zeilen": ["", f"Anna {name}", "", "Musterweg 1", "12345 Musterstadt"]}
kopf.update(extra)
pdf_renderer.render_rechnung(os.path.join(ordner, datei), cfg, r, kopf)
# --- der Schlamassel: zweimal dieselbe Nummer, verschiedene Gaeste -----------
schreibe("Rechnung_2026-013_Meier.pdf", "2026-013", "Meier", "12.08.2026", 2)
schreibe("Rechnung_2026-013_Schmidt.pdf", "2026-013", "Schmidt", "14.08.2026", 3)
# --- eine saubere Kette: darf NICHT als Dublette gelten ---------------------
schreibe("Rechnung_2026-020_Lang.pdf", "2026-020", "Lang", "20.08.2026", 4)
schreibe("Storno_2026-021_Lang.pdf", "2026-021", "Lang", "21.08.2026", 4,
art="storno", storno_zu="2026-020", storno_datum="20.08.2026", vorgang="2026-020")
schreibe("Rechnung_2026-022_Lang.pdf", "2026-022", "Lang", "21.08.2026", 3,
art="korrektur", ersetzt="2026-020", storno_nummer="2026-021", vorgang="2026-020")
# Berichtigungsblatt traegt absichtlich die Nummer der Rechnung 2026-022
schreibe("Berichtigte Rechnung_2026-08-22_2026-022.pdf", "2026-022", "Lang", "21.08.2026", 3,
art="berichtigung", berichtigt_zu="2026-022", berichtigt_am="22.08.2026",
vorgang="2026-020")
# --- inhaltsgleich unter zwei Nummern: nur ein Hinweis ----------------------
schreibe("Rechnung_2026-030_Weber.pdf", "2026-030", "Weber", "25.08.2026", 2)
schreibe("Rechnung_2026-031_Weber.pdf", "2026-031", "Weber", "25.08.2026", 2)
# --- eine fremde PDF ohne Kenndaten ----------------------------------------
with open(os.path.join(ordner, "Irgendwas.pdf"), "wb") as f:
f.write(b"%PDF-1.4 keine Kenndaten")
nummern, inhalte, unlesbar = duplikate_finden(ordner)
print("--- gefunden ---")
for nr, gruppe in nummern:
print(f" Nummer {nr}: {[b['_datei'] for b in gruppe]}")
for (name, datum, betrag), gruppe in inhalte:
print(f" Inhalt {name} {datum} {betrag}: {[b.get('rechnungsnummer') for b in gruppe]}")
print(f" ohne Kenndaten: {unlesbar}")
pruefe(len(nummern) == 1, f"genau EINE doppelte Nummer gemeldet ({len(nummern)})")
pruefe(nummern and nummern[0][0] == "2026-013", "und zwar 2026-013")
pruefe(nummern and {b["nachname"] for b in nummern[0][1]} == {"Meier", "Schmidt"},
"mit beiden Gästen")
gemeldete = {nr for nr, _ in nummern}
pruefe("2026-021" not in gemeldete and "2026-020" not in gemeldete,
"die Storno-Kette gilt NICHT als Dublette")
pruefe("2026-022" not in gemeldete,
"das Berichtigungsblatt zählt die Nummer der Rechnung NICHT doppelt")
pruefe(len(inhalte) == 1, f"genau ein inhaltsgleicher Fall ({len(inhalte)})")
pruefe(inhalte and {b["rechnungsnummer"] for b in inhalte[0][1]} == {"2026-030", "2026-031"},
"Weber taucht unter zwei Nummern auf")
pruefe(inhalte and inhalte[0][0][0] == "weber", f"und wird beim Namen genannt ({inhalte[0][0]})")
pruefe(unlesbar == ["Irgendwas.pdf"], f"die fremde PDF wird gemeldet ({unlesbar})")
# --- sauberer Ordner meldet nichts ------------------------------------------
leer = os.path.join(tmp, "leer")
os.makedirs(leer)
schreibe_ordner = ordner
ordner = leer
schreibe("Rechnung_2026-001_Neu.pdf", "2026-001", "Neu", "01.09.2026", 1)
schreibe("Rechnung_2026-002_Alt.pdf", "2026-002", "Alt", "02.09.2026", 2)
n2, i2, u2 = duplikate_finden(leer)
pruefe(not n2 and not i2 and not u2,
f"ein sauberer Ordner meldet nichts ({n2}, {i2}, {u2})")
pruefe(duplikate_finden(os.path.join(tmp, "gibtsnicht")) == ([], [], []),
"fehlender Ordner knallt nicht")
print("---")
print(f"Ordner: {schreibe_ordner}")
print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")
sys.exit(1 if fehler else 0)

View file

@ -45,7 +45,11 @@ fehler = []
def pruefe(bedingung, text):
print((" OK | " if bedingung else "FEHLER | ") + text)
# Die Windows-Konsole ist cp1252 - ein Sonderzeichen im Text darf den Lauf
# nicht mit einem UnicodeEncodeError abbrechen.
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)
@ -284,6 +288,74 @@ pruefe(any("ersetzen" in t for t in gefragt), f"vor dem Ersetzen wird gefragt ({
ok, warum = unveraendert(erste_fassung)
pruefe(ok, f"bei Nein bleibt die erste Fassung unangetastet ({warum})")
# =============================================================================
# Der Altbestand: dieselbe Nummer liegt MEHRFACH im Ordner (Windows-Kopie).
# Dann darf der Storno-Dialog nicht raten, welcher Beleg gemeint ist.
# =============================================================================
print("--- zwei Belege unter einer Nummer ---")
# Die abgelehnte Berichtigung liegt noch im Zwischenspeicher - so wie der Nutzer
# es dann macht: zuruecksetzen.
f.reset_formular()
pruefe(f.berichtigung_stand is None, "Zurücksetzen räumt die offene Berichtigung weg")
import shutil as _sh # noqa: E402
quelle = os.path.join(ordner, "Rechnung_2026-002_Zweit.pdf")
kopie = os.path.join(ordner, "Rechnung_2026-002_Zweit (1).pdf")
_sh.copy(quelle, kopie)
pruefe(len(BE.vorhandene_rechnungen(ordner, "2026-002")) == 2,
"die Windows-Kopie zählt bei der Nummernvergabe mit")
f.dlg_storno()
f.update()
dlg = [w for w in f.winfo_children() if isinstance(w, tk.Toplevel)][-1]
feld = liste = knopf = None
for rahmen in dlg.winfo_children():
for w in rahmen.winfo_children():
if isinstance(w, ttk.Entry):
feld = w
elif isinstance(w, tk.Listbox):
liste = w
elif isinstance(w, ttk.Frame):
for b in w.winfo_children():
if isinstance(b, ttk.Button) and "Storno erstellen" in str(b.cget("text")):
knopf = b
elif isinstance(w, ttk.Label) and w.cget("textvariable"):
meldezeile = w
pruefe(liste is not None, "der Dialog hat eine Auswahlliste")
feld.delete(0, "end")
feld.insert(0, "2026-002")
feld.event_generate("<KeyRelease>")
f.update()
meldung = f.getvar(meldezeile.cget("textvariable"))
pruefe("2×" in meldung or "2x" in meldung, f"die Doppelvergabe wird gemeldet ({meldung.splitlines()[0]})")
pruefe(liste.winfo_ismapped() and liste.size() == 2,
f"beide Belege stehen zur Auswahl ({liste.size()})")
vorher = bestand_lesen()
knopf.invoke() # ohne Auswahl darf NICHTS passieren
f.update()
pruefe(f.storno_stand is None, "ohne Auswahl wird kein Storno angelegt")
pruefe(bestand_lesen() == vorher, "und nichts geschrieben")
# bewusst den ZWEITEN Eintrag wählen - welche Datei das ist, sagt die Liste
zweiter = liste.get(1)
datei_zwei = zweiter.split()[-1]
liste.selection_set(1)
liste.event_generate("<<ListboxSelect>>")
f.update()
gewaehlt = f.getvar(meldezeile.cget("textvariable"))
pruefe(datei_zwei in gewaehlt,
f"die Meldung nennt genau die gewählte Datei ({datei_zwei})")
pruefe(liste.get(0).split()[-1] not in gewaehlt,
"und NICHT die andere Datei mit derselben Nummer")
knopf.invoke()
f.update()
pruefe(f.storno_stand is not None and f.storno_stand["zu"] == "2026-002",
f"nach der Auswahl läuft das Storno ({f.storno_stand})")
f._storno_verwerfen("Pruefstand raeumt auf")
os.remove(kopie)
f.destroy()
print("---")
print(f"Ordner: {ordner}")

View file

@ -44,7 +44,11 @@ fehler = []
def pruefe(bedingung, text):
print((" OK | " if bedingung else "FEHLER | ") + text)
# Die Windows-Konsole ist cp1252 - ein Sonderzeichen im Text darf den Lauf
# nicht mit einem UnicodeEncodeError abbrechen.
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)

View file

@ -18,6 +18,11 @@ dateien = [
"Rechnung_2026-0135_Schmidt.pdf", # laengere Nummer - darf NICHT treffen
"Rechnung_2026-777_Gross.PDF", # Endung GROSS geschrieben - zaehlt trotzdem
"Rechnung_2026-013_Meier.txt", # richtiger Name, aber kein PDF
"Rechnung_2026-050_Alt.pdf", # und drei Windows-Kopien davon:
"Rechnung_2026-050_Alt (1).pdf",
"Rechnung_2026-050_Alt - Kopie.pdf",
"Rechnung_2026-050 (2).pdf",
"Rechnung_2026-0501_Neu.pdf", # laengere Nummer - darf NICHT mitzaehlen
"Rechnung_2026-014_Meier.pdf", # andere Nummer
"Rechnung_2026-020.pdf", # ohne Kundenname
"Storno_2026-021_Meier.pdf", # Storno verbraucht die Nummer auch
@ -33,6 +38,10 @@ faelle = [
("2026-777", ["Rechnung_2026-777_Gross.PDF"], "Endung GROSS geschrieben wird gefunden"),
("2026-020", ["Rechnung_2026-020.pdf"], "ohne Kundenname im Dateinamen"),
("2026-021", ["Storno_2026-021_Meier.pdf"], "Nummer ist von einem Storno belegt"),
("2026-050", ["Rechnung_2026-050 (2).pdf", "Rechnung_2026-050_Alt (1).pdf",
"Rechnung_2026-050_Alt - Kopie.pdf", "Rechnung_2026-050_Alt.pdf"],
"Windows-Kopien (1)/- Kopie zaehlen mit"),
("2026-0501", ["Rechnung_2026-0501_Neu.pdf"], "laengere Nummer bleibt getrennt"),
("2026-099", [], "freie Nummer"),
("2026-01", [], "Teilnummer darf nicht auf 2026-013 anspringen"),
]

View file

@ -40,7 +40,11 @@ fehler = []
def pruefe(bedingung, text):
print((" OK | " if bedingung else "FEHLER | ") + text)
# Die Windows-Konsole ist cp1252 - ein Sonderzeichen im Text darf den Lauf
# nicht mit einem UnicodeEncodeError abbrechen.
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)