From d22631467de02b51ab4f692e521ed4a02ef82224 Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Wed, 2 Sep 2026 20:20:13 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X --- app.py | 10 +-- bestand.py | 11 ++- duplikate.py | 193 -------------------------------------------- korrektur.py | 60 ++++++++++++-- pruef_dialoge.py | 2 +- pruef_duplikate.py | 120 --------------------------- pruef_kernregeln.py | 74 ++++++++++++++++- pruef_kette.py | 6 +- pruef_nummern.py | 9 +++ pruef_storno.py | 6 +- 10 files changed, 156 insertions(+), 335 deletions(-) delete mode 100644 duplikate.py delete mode 100644 pruef_duplikate.py diff --git a/app.py b/app.py index aca4a13..bc9c7f3 100644 --- a/app.py +++ b/app.py @@ -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") diff --git a/bestand.py b/bestand.py index ae9afa8..3c4b2cc 100644 --- a/bestand.py +++ b/bestand.py @@ -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): - treffer.append(os.path.join(ordner, name)) + # 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. diff --git a/duplikate.py b/duplikate.py deleted file mode 100644 index 02c0649..0000000 --- a/duplikate.py +++ /dev/null @@ -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("", lambda e: d.destroy()) diff --git a/korrektur.py b/korrektur.py index d2f605f..61e5cfe 100644 --- a/korrektur.py +++ b/korrektur.py @@ -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("", vorschau) + self._auswahl.bind("<>", vorschau) def anlegen(*_a): meta, meldung = suche() @@ -222,7 +270,7 @@ class KorrekturMixin: ent.bind("", 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") diff --git a/pruef_dialoge.py b/pruef_dialoge.py index cead9ce..37621f8 100644 --- a/pruef_dialoge.py +++ b/pruef_dialoge.py @@ -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)] diff --git a/pruef_duplikate.py b/pruef_duplikate.py deleted file mode 100644 index 21c1069..0000000 --- a/pruef_duplikate.py +++ /dev/null @@ -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) diff --git a/pruef_kernregeln.py b/pruef_kernregeln.py index 8d50a5c..ffe39a4 100644 --- a/pruef_kernregeln.py +++ b/pruef_kernregeln.py @@ -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("") +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("<>") +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}") diff --git a/pruef_kette.py b/pruef_kette.py index 0c715ac..ef95042 100644 --- a/pruef_kette.py +++ b/pruef_kette.py @@ -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) diff --git a/pruef_nummern.py b/pruef_nummern.py index a6efda4..8790709 100644 --- a/pruef_nummern.py +++ b/pruef_nummern.py @@ -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"), ] diff --git a/pruef_storno.py b/pruef_storno.py index 9c621ce..037ed58 100644 --- a/pruef_storno.py +++ b/pruef_storno.py @@ -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)