Storno fragt zweistufig: neue Rechnung oder schon abgerechnet
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
This commit is contained in:
parent
7692c79269
commit
1107a9bace
3 changed files with 511 additions and 4 deletions
234
korrektur.py
234
korrektur.py
|
|
@ -25,6 +25,7 @@ from tkinter import ttk, messagebox
|
|||
import berechnung
|
||||
import config
|
||||
import pdf_renderer
|
||||
import berechnung
|
||||
from bestand import (kundendaten_ermitteln, naechste_freie_nummer,
|
||||
naechste_freie_nummer_nach, rechnungen_im_ordner)
|
||||
from helfer import kunde_maengel, nummer_normalisieren, parse_num, safe_filename
|
||||
|
|
@ -302,9 +303,215 @@ class KorrekturMixin:
|
|||
frm.columnconfigure(1, weight=1)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _passt_zusammen(alt, neu):
|
||||
"""Passt die bleibende Rechnung inhaltlich zu der, die storniert wird?
|
||||
|
||||
Die Nummer allein sagt nichts - sie koennte auf einen ganz anderen Gast
|
||||
zeigen. Verglichen werden Name, Zeitraum und Betrag, und zwar getrennt,
|
||||
damit die Meldung sagt, WAS abweicht.
|
||||
|
||||
Gibt (schwer, leicht) zurueck: schwer = darf nicht durchgehen (anderer
|
||||
Gast), leicht = Hinweise, die der Nutzer bestaetigen muss.
|
||||
"""
|
||||
def wort(x):
|
||||
return " ".join(str(x or "").lower().split())
|
||||
|
||||
schwer, leicht = [], []
|
||||
if wort(alt.get("nachname")) != wort(neu.get("nachname")):
|
||||
schwer.append(f"anderer Gast: {alt.get('nachname') or '?'} / "
|
||||
f"{neu.get('nachname') or '?'}")
|
||||
for feld, name in (("anreise", "Anreise"), ("abreise", "Abreise")):
|
||||
a, b = wort(alt.get(feld)), wort(neu.get(feld))
|
||||
if a and b and a != b:
|
||||
leicht.append(f"{name} {alt.get(feld)} / {neu.get(feld)}")
|
||||
try:
|
||||
ga, gb = float(alt.get("gesamt") or 0), float(neu.get("gesamt") or 0)
|
||||
except (TypeError, ValueError):
|
||||
ga = gb = 0.0
|
||||
if abs(ga - gb) > 0.005:
|
||||
leicht.append(f"Betrag {berechnung.eur(ga)} / {berechnung.eur(gb)}")
|
||||
return schwer, leicht
|
||||
|
||||
def _storno_folge_waehlen(self, out_dir, alt_nummer, alt_meta=None):
|
||||
"""Nach der Frage WELCHE Rechnung kommt die Frage, wie es weitergeht.
|
||||
|
||||
Zwei Schritte in einem Fenster, so wie er es beschrieben hat: erst A
|
||||
oder B, und erst wenn B gedrueckt ist, kommt das Nummernfeld.
|
||||
|
||||
A - es folgt eine neue Rechnung (der bisherige Weg)
|
||||
B - die Leistung ist schon abgerechnet -> Nummer der BLEIBENDEN
|
||||
|
||||
Sein Fall: mit dem alten Programm wurde bei einem Fehler einfach eine
|
||||
NEUE Rechnung geschrieben. Jetzt liegt dieselbe Leistung mehrfach im
|
||||
Ordner, und die Ueberzaehligen muessen auf die Rechnung verweisen, die
|
||||
BLEIBT - sonst wartet der Gast auf eine Rechnung, die nie kommt.
|
||||
|
||||
Rueckgabe: ("neu", None) | ("vorhanden", "2026-338") | None (Abbruch).
|
||||
"""
|
||||
# Im Testlauf klickt niemand: dann gilt der uebliche Weg. Der Pruefstand,
|
||||
# der GENAU DIESES Fenster prueft, setzt _storno_dialog_zeigen.
|
||||
from app import TESTLAUF
|
||||
if TESTLAUF and not getattr(self, "_storno_dialog_zeigen", False):
|
||||
return getattr(self, "_folgewahl", ("neu", None))
|
||||
|
||||
d = tk.Toplevel(self)
|
||||
d.title("Wie geht es nach dem Storno weiter?")
|
||||
d.transient(self)
|
||||
d.resizable(False, False)
|
||||
self._style_dialog(d)
|
||||
wahl = {"art": None, "nummer": ""}
|
||||
gefunden = {"meta": None, "schwer": [], "leicht": []}
|
||||
eingabe = tk.StringVar()
|
||||
|
||||
rahmen = ttk.Frame(d, padding=18)
|
||||
rahmen.pack(fill="both", expand=True)
|
||||
ttk.Label(rahmen, text=f"Storno zu Rechnung {alt_nummer}",
|
||||
font=("Segoe UI", 13, "bold")).pack(anchor="w")
|
||||
|
||||
# ---- Schritt 1: A oder B ------------------------------------------
|
||||
schritt1 = ttk.Frame(rahmen)
|
||||
schritt1.pack(fill="x", pady=(8, 0))
|
||||
ttk.Label(schritt1, wraplength=620, justify="left", text=(
|
||||
"Auf dem Storno-Blatt muss stehen, wie die Leistung stattdessen abgerechnet "
|
||||
"wird – sonst weiß der Gast nicht, woran er ist.")).pack(anchor="w", pady=(0, 12))
|
||||
ttk.Label(schritt1, text="A – es folgt eine neue Rechnung",
|
||||
font=("Segoe UI", 10, "bold")).pack(anchor="w")
|
||||
ttk.Label(schritt1, wraplength=620, justify="left", text=(
|
||||
"Der übliche Weg: die Rechnung war falsch und wird nach dem Storno richtig neu "
|
||||
"geschrieben. Das Formular bleibt danach offen.")).pack(anchor="w", pady=(2, 10))
|
||||
ttk.Label(schritt1, text="B – die Leistung ist schon abgerechnet",
|
||||
font=("Segoe UI", 10, "bold")).pack(anchor="w")
|
||||
ttk.Label(schritt1, wraplength=620, justify="left", text=(
|
||||
"Für Doppelte: derselbe Aufenthalt steht mehrfach im Ordner. Im nächsten Schritt "
|
||||
"trägst du die Nummer der Rechnung ein, die bleibt.")).pack(anchor="w", pady=(2, 0))
|
||||
|
||||
# ---- Schritt 2: die Nummer (erst nach B) --------------------------
|
||||
schritt2 = ttk.Frame(rahmen)
|
||||
ttk.Label(schritt2, wraplength=620, justify="left", text=(
|
||||
"Welche Rechnung bleibt? Sie muss im Rechnungsordner liegen und denselben "
|
||||
"Aufenthalt abrechnen – das Programm sieht nach.")).pack(anchor="w", pady=(8, 10))
|
||||
zeile = ttk.Frame(schritt2)
|
||||
zeile.pack(anchor="w")
|
||||
ttk.Label(zeile, text="Bleibende Rechnung:").pack(side="left")
|
||||
feld = ttk.Entry(zeile, textvariable=eingabe, width=16, justify="center")
|
||||
feld.pack(side="left", padx=(8, 0))
|
||||
stand = tk.Label(schritt2, text="", wraplength=620, justify="left",
|
||||
font=("Segoe UI", 9))
|
||||
stand.pack(anchor="w", pady=(8, 0))
|
||||
|
||||
def suchen(*_a):
|
||||
"""Gibt es die Nummer - und passt der INHALT? Antwort aus dem Ordner."""
|
||||
nr = nummer_normalisieren(eingabe.get())
|
||||
gefunden["meta"], gefunden["schwer"], gefunden["leicht"] = None, [], []
|
||||
if not nr:
|
||||
stand.configure(text="", fg=self.col_muted)
|
||||
return None
|
||||
if nr == alt_nummer:
|
||||
stand.configure(text="Das ist die Rechnung, die gerade storniert wird.",
|
||||
fg="#a00000")
|
||||
return None
|
||||
treffer = [m for m in rechnungen_im_ordner(out_dir)
|
||||
if str(m.get("rechnungsnummer")) == nr and m.get("art") != "storno"]
|
||||
if not treffer:
|
||||
stand.configure(text=f"Zu {nr} liegt keine Rechnung im Ordner.", fg="#a00000")
|
||||
return None
|
||||
m = treffer[0]
|
||||
wer = " ".join(t for t in (str(m.get("vorname") or ""),
|
||||
str(m.get("nachname") or "")) if t)
|
||||
grund = (f"{nr} vom {m.get('datum','')} – {wer or 'ohne Namen'}, "
|
||||
f"{berechnung.eur(m.get('gesamt') or 0)}")
|
||||
schwer, leicht = self._passt_zusammen(alt_meta or {}, m)
|
||||
gefunden["meta"], gefunden["schwer"], gefunden["leicht"] = m, schwer, leicht
|
||||
if schwer:
|
||||
stand.configure(text=grund + " — " + "; ".join(schwer)
|
||||
+ ". So kann das Storno nicht darauf verweisen.",
|
||||
fg="#a00000")
|
||||
return None
|
||||
if leicht:
|
||||
stand.configure(text=grund + " — weicht ab: " + "; ".join(leicht),
|
||||
fg="#a06000")
|
||||
return nr
|
||||
stand.configure(text=grund + " — gleicher Gast, gleicher Zeitraum, gleicher Betrag.",
|
||||
fg="#1f5c3d")
|
||||
return nr
|
||||
|
||||
eingabe.trace_add("write", suchen)
|
||||
|
||||
# ---- Knopfleiste, wechselt mit dem Schritt ------------------------
|
||||
knoepfe = ttk.Frame(rahmen)
|
||||
knoepfe.pack(fill="x", pady=(16, 0))
|
||||
|
||||
def nimm_neu():
|
||||
wahl["art"] = "neu"
|
||||
d.destroy()
|
||||
|
||||
def uebernehmen():
|
||||
nr = suchen()
|
||||
if not nr:
|
||||
if not eingabe.get().strip():
|
||||
stand.configure(text="Bitte die Nummer der bleibenden Rechnung eintragen.",
|
||||
fg="#a00000")
|
||||
return
|
||||
if gefunden["leicht"] and not messagebox.askyesno(
|
||||
"Weicht ab – trotzdem?",
|
||||
f"Rechnung {nr} weicht ab: " + "; ".join(gefunden["leicht"]) +
|
||||
". Das Storno würde trotzdem darauf verweisen. Ist das dieselbe "
|
||||
"Leistung?", icon="warning", default="no", parent=d):
|
||||
return
|
||||
wahl["art"], wahl["nummer"] = "vorhanden", nr
|
||||
d.destroy()
|
||||
|
||||
def zu_schritt2():
|
||||
schritt1.pack_forget()
|
||||
schritt2.pack(fill="x")
|
||||
for w in knoepfe.winfo_children():
|
||||
w.destroy()
|
||||
ttk.Button(knoepfe, text="Übernehmen", width=16,
|
||||
command=uebernehmen).pack(side="right")
|
||||
ttk.Button(knoepfe, text="Zurück", width=12, command=zu_schritt1).pack(side="left")
|
||||
ttk.Button(knoepfe, text="Abbrechen", width=14,
|
||||
command=d.destroy).pack(side="right", padx=(0, 8))
|
||||
d.update_idletasks()
|
||||
feld.focus_set()
|
||||
|
||||
def zu_schritt1():
|
||||
schritt2.pack_forget()
|
||||
schritt1.pack(fill="x", pady=(8, 0))
|
||||
for w in knoepfe.winfo_children():
|
||||
w.destroy()
|
||||
ttk.Button(knoepfe, text="A: neue Rechnung folgt", width=24,
|
||||
command=nimm_neu).pack(side="left")
|
||||
ttk.Button(knoepfe, text="B: schon abgerechnet", width=22,
|
||||
command=zu_schritt2).pack(side="left", padx=8)
|
||||
ttk.Button(knoepfe, text="Abbrechen", width=14,
|
||||
command=d.destroy).pack(side="right")
|
||||
|
||||
zu_schritt1()
|
||||
d.protocol("WM_DELETE_WINDOW", d.destroy)
|
||||
d.bind("<Escape>", lambda _e: d.destroy())
|
||||
d.update_idletasks()
|
||||
x = self.winfo_rootx() + max(0, (self.winfo_width() - d.winfo_reqwidth()) // 2)
|
||||
d.geometry(f"+{max(0, x)}+{self.winfo_rooty() + 110}")
|
||||
d.grab_set()
|
||||
self.wait_window(d)
|
||||
if wahl["art"] == "vorhanden":
|
||||
return "vorhanden", wahl["nummer"]
|
||||
return ("neu", None) if wahl["art"] == "neu" else None
|
||||
|
||||
def _storno_anlegen(self, meta, out_dir):
|
||||
"""Schreibt die Storno-PDF und füllt das Formular für die berichtigte Rechnung."""
|
||||
alt_nummer = str(meta.get("rechnungsnummer", ""))
|
||||
# Zuerst die Frage, die frueher gefehlt hat: kommt eine neue Rechnung, oder
|
||||
# ist die Leistung schon unter einer anderen Nummer abgerechnet? Beim
|
||||
# alten Programm wurde bei einem Fehler einfach neu geschrieben - deshalb
|
||||
# liegt dieselbe Leistung mehrfach im Ordner, und die Ueberzaehligen
|
||||
# muessen auf die BLEIBENDE Rechnung verweisen.
|
||||
weiter = self._storno_folge_waehlen(out_dir, alt_nummer, meta)
|
||||
if weiter is None:
|
||||
return
|
||||
art_folge, vorhandene = weiter
|
||||
|
||||
nummer = naechste_freie_nummer(out_dir, self.cfg, self._belegte_nummern())
|
||||
if not nummer:
|
||||
messagebox.showerror("Keine freie Nummer",
|
||||
|
|
@ -317,7 +524,13 @@ class KorrekturMixin:
|
|||
# die naechste freie NACH dem Storno. Sie kommt aufs Storno-Blatt, damit
|
||||
# die Kette aus dem Beleg allein lesbar ist und kein Zustand gemerkt
|
||||
# werden muss: alte Nummer -> Storno -> neue Nummer, alles auf einem Blatt.
|
||||
folge_nummer = naechste_freie_nummer_nach(out_dir, self.cfg, nummer, self._belegte_nummern())
|
||||
if art_folge == "vorhanden":
|
||||
# Es kommt keine neue Rechnung - das Storno verweist auf die,
|
||||
# die bleibt. Es wird also auch keine Nummer dafuer reserviert.
|
||||
folge_nummer = vorhandene
|
||||
else:
|
||||
folge_nummer = naechste_freie_nummer_nach(out_dir, self.cfg, nummer,
|
||||
self._belegte_nummern())
|
||||
# Der Storno geht an denselben Empfaenger wie die alte Rechnung - was in
|
||||
# deren Kenndaten fehlt, wird aus ihrem Adressblock geholt.
|
||||
kunde, woher = kundendaten_ermitteln(meta)
|
||||
|
|
@ -363,6 +576,9 @@ class KorrekturMixin:
|
|||
# alte Nummer -> diese Nummer. Steht auf dem Blatt, damit die Kette
|
||||
# aus dem Storno allein lesbar ist.
|
||||
"korrektur_nummer": folge_nummer or "",
|
||||
# Sagt dem Blatt, ob die Nummer eine BLEIBENDE Rechnung meint
|
||||
# ("bereits abgerechnet mit") oder eine, die noch kommt.
|
||||
"ersatz_vorhanden": art_folge == "vorhanden",
|
||||
"storno_datum": str(meta.get("datum") or ""),
|
||||
"kunde": kunde,
|
||||
}
|
||||
|
|
@ -378,7 +594,9 @@ class KorrekturMixin:
|
|||
print("[storno] FEHLER:", e)
|
||||
return
|
||||
|
||||
self.storno_stand = {
|
||||
# Nur wenn eine neue Rechnung folgt, bleibt ein Vorgang offen. Bei
|
||||
# "schon abgerechnet" ist mit dem Storno alles getan.
|
||||
self.storno_stand = None if art_folge == "vorhanden" else {
|
||||
"pfad": pfad, "nummer": nummer, "zu": alt_nummer,
|
||||
"vorgang": str(meta.get("vorgang") or alt_nummer),
|
||||
"zu_datum": str(meta.get("datum") or ""),
|
||||
|
|
@ -394,6 +612,18 @@ class KorrekturMixin:
|
|||
self._buch_anzeigen()
|
||||
self._journal_benachrichtigen(f"Storno {nummer} zu Rechnung {alt_nummer} erstellt")
|
||||
|
||||
if art_folge == "vorhanden":
|
||||
self._storno_anzeigen()
|
||||
self._neue_nummer_vorschlagen()
|
||||
if messagebox.askyesno(
|
||||
"Storno erstellt",
|
||||
f"Storno {nummer} hebt Rechnung {alt_nummer} auf.\n\n"
|
||||
f"Die Leistung bleibt mit Rechnung {folge_nummer} abgerechnet – es "
|
||||
"kommt keine neue Rechnung dazu.\n\n"
|
||||
"Das Blatt gehört zum Gast.\n\nJetzt öffnen (zum Drucken)?"):
|
||||
self._oeffne_datei(pfad)
|
||||
return
|
||||
|
||||
fehlend = self._formular_aus_meta(meta)
|
||||
self.r_nummer.set(folge_nummer or "")
|
||||
self._storno_anzeigen()
|
||||
|
|
|
|||
|
|
@ -311,8 +311,11 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
|||
# ganze Kette auf dem Blatt: alte Nummer -> Storno -> neue Nummer.
|
||||
if kopf.get("korrektur_nummer"):
|
||||
# "Neue Rechnung" war missverstaendlich - man liest es als die Nummer
|
||||
# DIESES Blattes. Gemeint ist die Rechnung, die den Storno ersetzt.
|
||||
info_rows.append(("Berichtigte Rechnung:", kopf["korrektur_nummer"]))
|
||||
# DIESES Blattes. Gemeint ist die Rechnung, die den Storno ersetzt -
|
||||
# oder, bei einer doppelt geschriebenen Leistung, die BLEIBENDE.
|
||||
info_rows.append(
|
||||
("Schon abgerechnet:" if kopf.get("ersatz_vorhanden")
|
||||
else "Berichtigte Rechnung:", kopf["korrektur_nummer"]))
|
||||
elif art == "korrektur" and kopf.get("ersetzt"):
|
||||
info_rows.append(("Ersetzt Rechnung:", kopf.get("ersetzt", "")))
|
||||
elif art == "berichtigung":
|
||||
|
|
@ -479,6 +482,18 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
|||
if kopf.get("storno_datum"):
|
||||
bezug += f" vom {kopf['storno_datum']}"
|
||||
folgt = kopf.get("korrektur_nummer") or ""
|
||||
if kopf.get("ersatz_vorhanden") and folgt:
|
||||
# Doppelt geschriebene Leistung: es kommt nichts nach, sie ist
|
||||
# bereits abgerechnet. Der Satz muss das sagen - sonst wartet der
|
||||
# Gast auf eine Rechnung, die nie kommt.
|
||||
block.append(Paragraph(
|
||||
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
|
||||
f"Die Leistung wurde versehentlich doppelt in Rechnung gestellt und "
|
||||
f"ist bereits mit der Rechnung {folgt} abgerechnet – es folgt keine "
|
||||
"weitere Rechnung. Bereits gezahlte Beträge werden erstattet oder "
|
||||
"verrechnet.", st["dank"]))
|
||||
story.append(KeepTogether(block))
|
||||
return
|
||||
kette = (f" Die berichtigte Rechnung trägt die Nummer {folgt} "
|
||||
f"(also {kopf.get('storno_zu','')} → {kopf.get('rechnungsnummer','')} "
|
||||
f"→ {folgt}).") if folgt else ""
|
||||
|
|
@ -615,6 +630,7 @@ def _kenndaten(rechnung, kopf):
|
|||
"vorgang": kopf.get("vorgang") or kopf.get("rechnungsnummer", ""),
|
||||
"storno_zu": kopf.get("storno_zu", ""),
|
||||
"korrektur_nummer": kopf.get("korrektur_nummer", ""),
|
||||
"ersatz_vorhanden": bool(kopf.get("ersatz_vorhanden")),
|
||||
"storno_datum": kopf.get("storno_datum", ""),
|
||||
"ersetzt": kopf.get("ersetzt", ""),
|
||||
"storno_nummer": kopf.get("storno_nummer", ""),
|
||||
|
|
|
|||
261
pruef_doppelte_leistung.py
Normal file
261
pruef_doppelte_leistung.py
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
# -*- 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue