# -*- 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())