From 67a09e40854e414c4d9424eaa521bc2028afd86e Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Wed, 2 Sep 2026 20:08:22 +0200 Subject: [PATCH] Doppelte Rechnungen anzeigen (Datei > Doppelte Rechnungen) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Altbestand wurde nie geprueft - die Sperre verhindert nur NEUE Dubletten. Neues Modul duplikate.py findet zwei Sorten, und sie bedeuten nicht dasselbe: Nummer doppelt - zwei Geldbelege mit derselben Rechnungsnummer. Der harte Fall (§ 14 Abs. 4 Nr. 4 UStG). Inhalt doppelt - gleicher Gast, gleiches Datum, gleicher Betrag unter zwei Nummern. Nur ein Hinweis, kann echt sein. Der schwierige Teil ist das NICHT-Finden: Storno und berichtigte Rechnung haben absichtlich eigene Nummern, und ein Berichtigungsblatt traegt sogar die Nummer der Rechnung, die es berichtigt. Nichts davon ist eine Dublette. PDFs ohne Kenndaten fallen nicht durch: ihre Nummer wird aus dem Dateinamen gelesen und sie werden getrennt aufgelistet - das ist genau der ungepruefte Altbestand. Beim Start laeuft die Pruefung still mit; liegt etwas vor, steht es rot in der Statuszeile. Kein Popup beim Hochfahren. pruef_duplikate.py (12 Pruefungen): Ordner mit dem echten Schlamassel gebaut - zweimal 2026-013 fuer zwei verschiedene Gaeste, dazu eine saubere Storno-Kette und ein Berichtigungsblatt, die NICHT gemeldet werden duerfen, ein inhaltsgleiches Paar und eine fremde PDF. Fenster angesehen. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X --- app.py | 10 ++- duplikate.py | 193 +++++++++++++++++++++++++++++++++++++++++++++ pruef_dialoge.py | 2 +- pruef_duplikate.py | 120 ++++++++++++++++++++++++++++ 4 files changed, 323 insertions(+), 2 deletions(-) create mode 100644 duplikate.py create mode 100644 pruef_duplikate.py diff --git a/app.py b/app.py index bc9c7f3..aca4a13 100644 --- a/app.py +++ b/app.py @@ -35,9 +35,11 @@ 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, tk.Tk): +class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, + DuplikateMixin, tk.Tk): def __init__(self): super().__init__() self.cfg = config.load_config() @@ -74,6 +76,7 @@ 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) + self.after(400, self.duplikate_pruefen_beim_start) # ---------------------------------------------------------------- Menue @@ -86,6 +89,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): ("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 ], @@ -347,6 +351,10 @@ 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") + # 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/duplikate.py b/duplikate.py new file mode 100644 index 0000000..02c0649 --- /dev/null +++ b/duplikate.py @@ -0,0 +1,193 @@ +# -*- 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/pruef_dialoge.py b/pruef_dialoge.py index 37621f8..cead9ce 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_storno", "dlg_berichtigen", "dlg_duplikate"] 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 new file mode 100644 index 0000000..21c1069 --- /dev/null +++ b/pruef_duplikate.py @@ -0,0 +1,120 @@ +# -*- 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)