Sein Fall: 'der typ hat mit dem alten programm einfach eine neue rechnung gemacht, nun hat der die gleiche rechnung 3-4 mal drin, aber mit anderen nummern'. Die Ueberzaehligen muessen storniert werden - und ihr Storno muss auf die Rechnung verweisen, die BLEIBT, nicht auf eine neue. Nach dem Auswaehlen der Rechnung fragt das Storno jetzt: A - es folgt eine neue Rechnung (der bisherige Weg) B - die Leistung ist schon abgerechnet Zweistufig, weil er es so braucht: 'ich druecke b und ERST DANN kann ich die nummer eingeben ... das ein opa, immer step by step, nicht alles auf einmal, wie bei kindern'. Schritt 1 zeigt nur die Wahl, Schritt 2 das Nummernfeld, mit Zurueck. Geprueft wird nicht nur, OB es die Nummer gibt, sondern auch, ob der INHALT passt (sein Zusatz): anderer Gast = geht nicht, anderer Zeitraum oder Betrag = Nachfrage. Auf dem Blatt steht dann 'Schon abgerechnet: 2026-001' statt 'Berichtigte Rechnung', dazu der Satz, dass die Leistung versehentlich doppelt in Rechnung gestellt wurde und keine weitere Rechnung folgt. Es wird keine Nummer reserviert und kein Vorgang offen gehalten. Pruefstand pruef_doppelte_leistung.py: faehrt das echte zweistufige Fenster, prueft die Inhaltspruefung einzeln und am Ende das Steuerjournal - EIN Aufenthalt (3 Naechte, 39,00) statt zwei. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx
261 lines
10 KiB
Python
261 lines
10 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Pruefstand: dieselbe Leistung liegt mehrfach im Ordner.
|
|
|
|
Sein Fall, woertlich: *"der typ hat mit dem alten programm einfach eine neue
|
|
rechnung gemacht, nun hat der die gleiche rechnung 3-4 mal drin, aber mit
|
|
anderen nummern. nun muss man die stornieren, heisst aber, dass wir manuell auf
|
|
die neue rechnungsnummer verweisen muessen - das musst du in der storno
|
|
abfragen, ob er schon eine hat oder eine neue rechnung erstellen will."*
|
|
|
|
Also fragt das Storno jetzt zuerst:
|
|
A - es folgt eine neue Rechnung (der bisherige Weg)
|
|
B - die Leistung ist schon abgerechnet -> Nummer der BLEIBENDEN Rechnung
|
|
|
|
Geprueft wird B am echten Fenster: die Knoepfe werden gedrueckt, die Nummer
|
|
wirklich eingetippt. Und am Ende muss im Steuerjournal genau EIN Aufenthalt
|
|
stehen - nicht zwei.
|
|
"""
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
|
|
QUELLE = r"C:\claude\rechnungstool"
|
|
JOURNAL = r"C:\claude\beherbergungssteuer"
|
|
sys.path.insert(0, QUELLE)
|
|
|
|
fehler = []
|
|
|
|
|
|
def pruefe(bedingung, text):
|
|
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)
|
|
|
|
|
|
def _reissleine():
|
|
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog")
|
|
os._exit(2)
|
|
|
|
|
|
_wecker = threading.Timer(150, _reissleine)
|
|
_wecker.daemon = True
|
|
_wecker.start()
|
|
|
|
os.environ["CAMPINGHOF_TEST"] = "1"
|
|
tmp = tempfile.mkdtemp(prefix="doppelt_leistung_")
|
|
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")
|
|
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
|
|
roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0
|
|
roh["nummer_eingerichtet"] = True
|
|
roh["output_dir"] = ordner
|
|
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
|
|
|
import app as A # noqa: E402
|
|
import bestand as BE # noqa: E402
|
|
from tkinter import messagebox # noqa: E402
|
|
|
|
antworten = {"Fertig": False, "Hausnummer": True, "Storno erstellt": False,
|
|
"Angaben zum Kunden": True, "Weicht ab": True}
|
|
warnungen = []
|
|
|
|
|
|
def askyesno(titel, text, **kw):
|
|
for schlue, wert in antworten.items():
|
|
if schlue in titel:
|
|
return wert
|
|
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
|
|
|
|
|
|
messagebox.askyesno = askyesno
|
|
messagebox.showinfo = lambda *a, **k: None
|
|
messagebox.showwarning = lambda *a, **k: warnungen.append(a[0] if a else "")
|
|
messagebox.showerror = lambda *a, **k: print("[fehler]", a[0] if a else "")
|
|
|
|
f = A.RechnungsApp()
|
|
f.cfg["output_dir"] = ordner
|
|
f._storno_dialog_zeigen = True # NUR das Storno-Popup soll wirklich kommen
|
|
|
|
|
|
def widgets(w, klassen, aus=None):
|
|
aus = [] if aus is None else aus
|
|
for k in w.winfo_children():
|
|
if k.winfo_class() in klassen:
|
|
aus.append(k)
|
|
widgets(k, klassen, aus)
|
|
return aus
|
|
|
|
|
|
def rechnung(name, naechte=3):
|
|
"""Zweimal dieselbe Leistung - so, wie das alte Programm es angerichtet hat."""
|
|
f.reset_formular()
|
|
f.k_vorname.set("Anna")
|
|
f.k_name.set(name)
|
|
f.k_strasse.set("Musterweg 1")
|
|
f.k_plzort.set("12345 Musterstadt")
|
|
f.r_anreise.set("01.08.2026")
|
|
f.r_abreise.set("04.08.2026")
|
|
f.anzahl_vars[sorted(f.anzahl_vars)[0]].set(str(naechte))
|
|
f._testwahl = "rechnung"
|
|
f.update()
|
|
f.pdf_erstellen()
|
|
|
|
|
|
rechnung("Meier") # 2026-001 - die bleibt
|
|
rechnung("Meier") # 2026-002 - dieselbe Leistung, versehentlich noch einmal
|
|
pruefe(sorted(os.listdir(ordner)) == ["Rechnung_2026-001_Meier.pdf",
|
|
"Rechnung_2026-002_Meier.pdf"],
|
|
f"zwei Rechnungen ueber denselben Aufenthalt ({sorted(os.listdir(ordner))})")
|
|
|
|
|
|
def druecke(top, text):
|
|
for k in widgets(top, ("TButton", "Button")):
|
|
if text.lower() in str(k.cget("text")).lower():
|
|
k.invoke()
|
|
return True
|
|
return False
|
|
|
|
|
|
def storno_mit_popup(meta, nummer=None, schritt1="B: schon abgerechnet", danach=None):
|
|
"""Storno anstossen und das zweistufige Popup bedienen.
|
|
|
|
Erst A oder B - und ERST DANN gibt es das Nummernfeld ("step by step,
|
|
nicht alles auf einmal").
|
|
"""
|
|
zustand = {"schritt": 1}
|
|
|
|
def klick(rest=60):
|
|
tops = [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"]
|
|
if not tops:
|
|
if rest:
|
|
f.after(100, lambda: klick(rest - 1))
|
|
return
|
|
top = tops[-1]
|
|
if zustand["schritt"] == 1:
|
|
felder = widgets(top, ("TEntry", "Entry"))
|
|
zustand["feld_in_schritt1"] = bool(felder and felder[0].winfo_ismapped())
|
|
if druecke(top, schritt1):
|
|
zustand["schritt"] = 2
|
|
if nummer is None:
|
|
return
|
|
f.after(100, lambda: klick(rest - 1))
|
|
return
|
|
felder = [e for e in widgets(top, ("TEntry", "Entry")) if e.winfo_ismapped()]
|
|
if felder:
|
|
felder[0].delete(0, "end")
|
|
felder[0].insert(0, nummer)
|
|
f.update()
|
|
druecke(top, "Übernehmen")
|
|
if danach:
|
|
# Bei ungueltiger Eingabe bleibt das Fenster offen (so soll es
|
|
# sein) - der Pruefstand macht es dann selbst zu.
|
|
f.after(100, lambda: druecke(top, danach) if top.winfo_exists() else None)
|
|
return
|
|
if rest:
|
|
f.after(100, lambda: klick(rest - 1))
|
|
|
|
f.after(200, klick)
|
|
f._storno_anlegen(meta, ordner)
|
|
return zustand
|
|
|
|
|
|
zweite = [m for m in BE.rechnungen_im_ordner(ordner)
|
|
if str(m.get("rechnungsnummer")) == "2026-002"][0]
|
|
|
|
# ---- 1. Falsche Eingaben nimmt das Fenster nicht an ----------------------
|
|
zustand = storno_mit_popup(zweite, "2026-999", danach="Abbrechen") # gibt es nicht
|
|
pruefe(zustand.get("feld_in_schritt1") is False,
|
|
"im ersten Schritt gibt es noch KEIN Nummernfeld (step by step)")
|
|
pruefe(len([d for d in os.listdir(ordner) if d.startswith("Storno")]) == 0,
|
|
"eine Nummer, die es nicht gibt, schreibt kein Storno")
|
|
|
|
storno_mit_popup(zweite, "2026-002", danach="Abbrechen") # sie selbst
|
|
pruefe(len([d for d in os.listdir(ordner) if d.startswith("Storno")]) == 0,
|
|
"und auf sich selbst verweist auch keines")
|
|
|
|
# Die Inhaltspruefung selbst - was passt zusammen und was nicht
|
|
schwer, leicht = A.RechnungsApp._passt_zusammen(
|
|
{"nachname": "Meier", "anreise": "01.08.2026", "gesamt": 39.0},
|
|
{"nachname": "Schulz", "anreise": "01.08.2026", "gesamt": 39.0})
|
|
pruefe(bool(schwer) and "anderer Gast" in schwer[0],
|
|
f"ein anderer Gast ist ein harter Fehler ({schwer})")
|
|
schwer, leicht = A.RechnungsApp._passt_zusammen(
|
|
{"nachname": "Meier", "anreise": "01.08.2026", "gesamt": 39.0},
|
|
{"nachname": "Meier", "anreise": "05.08.2026", "gesamt": 52.0})
|
|
pruefe(not schwer and len(leicht) == 2,
|
|
f"anderer Zeitraum und Betrag sind Hinweise, kein Verbot ({schwer}, {leicht})")
|
|
schwer, leicht = A.RechnungsApp._passt_zusammen(
|
|
{"nachname": "Meier", "anreise": "01.08.2026", "gesamt": 39.0},
|
|
{"nachname": "meier", "anreise": "01.08.2026", "gesamt": 39.0})
|
|
pruefe(not schwer and not leicht, f"gleiche Leistung passt glatt ({schwer}, {leicht})")
|
|
|
|
# ---- 2. Der richtige Weg: auf die bleibende Rechnung verweisen ----------
|
|
storno_mit_popup(zweite, "2026-001")
|
|
f.update()
|
|
stornos = [d for d in os.listdir(ordner) if d.startswith("Storno")]
|
|
pruefe(len(stornos) == 1, f"das Storno ist geschrieben ({stornos})")
|
|
pfad = os.path.join(ordner, stornos[0])
|
|
meta = BE.kenndaten_lesen(pfad) or {}
|
|
pruefe(meta.get("storno_zu") == "2026-002", f"es hebt 2026-002 auf ({meta.get('storno_zu')})")
|
|
pruefe(meta.get("korrektur_nummer") == "2026-001",
|
|
f"und verweist auf die bleibende 2026-001 ({meta.get('korrektur_nummer')})")
|
|
pruefe(meta.get("ersatz_vorhanden") is True,
|
|
f"als BEREITS abgerechnet, nicht als kommende Rechnung ({meta.get('ersatz_vorhanden')})")
|
|
|
|
from pypdf import PdfReader # noqa: E402
|
|
|
|
text = (PdfReader(pfad).pages[0].extract_text() or "").replace("\n", " ")
|
|
pruefe("Schon abgerechnet" in text and "2026-001" in text,
|
|
"auf dem Blatt steht 'Schon abgerechnet: 2026-001'")
|
|
pruefe("doppelt in Rechnung gestellt" in text,
|
|
"und der Satz, warum es die Rechnung zweimal gab")
|
|
pruefe("es folgt keine weitere Rechnung" in text,
|
|
"sowie dass keine weitere Rechnung kommt")
|
|
pruefe("Berichtigte Rechnung" not in text,
|
|
"und NICHT 'Berichtigte Rechnung' - es kommt ja keine")
|
|
|
|
# ---- 3. Kein offener Vorgang, keine reservierte Nummer ------------------
|
|
pruefe(f.storno_stand is None, "es bleibt kein Storno-Vorgang offen")
|
|
pruefe(str(f.nummer_entry.cget("state")) == "normal", "das Nummernfeld ist nicht gesperrt")
|
|
pruefe(f.r_nummer.get() == "2026-004",
|
|
f"die naechste Rechnung waere 2026-004 (nach dem Storno 003) ({f.r_nummer.get()})")
|
|
|
|
# ---- 4. Was am Ende im Steuerjournal steht -----------------------------
|
|
sys.path.append(JOURNAL)
|
|
import db as BST # noqa: E402
|
|
from modell import summiere, aufgehobene # noqa: E402
|
|
|
|
journal = BST.Journal(os.path.join(tmp, "journal.sqlite3"))
|
|
journal.scanne(ordner, 5.0, voll=True)
|
|
buchungen = journal.buchungen(jahr=2026)
|
|
s = summiere(buchungen)
|
|
print("--- was im Journal steht ---")
|
|
for b in buchungen:
|
|
print(f" {b.voll_nummer} {b.nachname:8s} {b.naechte:>3} Nächte {b.gezahlt:>8.2f} €")
|
|
pruefe(len(buchungen) == 3, f"drei Zeilen: zwei Rechnungen und das Storno ({len(buchungen)})")
|
|
pruefe(s.naechte == 3, f"gemeldet wird EIN Aufenthalt mit 3 Nächten ({s.naechte})")
|
|
pruefe(abs(s.gezahlt - 39.0) < 0.005, f"und 39,00 € statt 78,00 € ({s.gezahlt})")
|
|
weg = aufgehobene(buchungen)
|
|
storniert = [b for b in buchungen if b.id in weg]
|
|
pruefe(len(storniert) == 1 and storniert[0].voll_nummer == "2026-002",
|
|
f"aufgehoben ist genau die zweite ({[b.voll_nummer for b in storniert]})")
|
|
journal.schliessen()
|
|
|
|
f.destroy()
|
|
if not fehler:
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
else:
|
|
print(f" (Wegwerf-Ordner bleibt: {tmp})")
|
|
print("---")
|
|
print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")
|
|
sys.exit(1 if fehler else 0)
|