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)