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:
parent
67a09e4085
commit
d22631467d
10 changed files with 156 additions and 335 deletions
10
app.py
10
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")
|
||||
|
|
|
|||
11
bestand.py
11
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.
|
||||
|
|
|
|||
193
duplikate.py
193
duplikate.py
|
|
@ -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())
|
||||
60
korrektur.py
60
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("<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")
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue