rechnungstool/pruef_montage.py
TheMockTv 2b09d15cd0 Aufraeumen nach der Pruefkette: doppelter Code an eine Stelle
Die Runde vom 07.09.2026 hat neben den zwei echten Fehlern eine Reihe
Wiederholungen und stumme Stellen gemeldet. Abgearbeitet in Etappen, jede fuer
sich pruefbar - nach jeder liefen beide Pruefketten gruen.

* Menueleiste: der Wechsel von einem Titel zum anderen brauchte ZWEI Klicks.
  Der grab_set der offenen Liste schluckt den ersten, die <Button-1>-Bindung
  des Titels feuert gar nicht. Das native tk.Menu, das diese Leiste ersetzt
  hat, wechselte schon beim blossen Ueberfahren - zwei Klicks waren also eine
  Verschlechterung gegenueber dem, was vorher da war. Die Klappliste wird
  ausserdem jetzt wie jede andere Position ueber theme.auf_bildschirm()
  begrenzt; am unteren Fensterrand lief sie vorher hinaus.
  Neu: pruef_menueleiste.py misst beides ueber echte Klickereignisse.

* %LOCALAPPDATA%/ravokk wurde an drei Stellen einzeln ausgerechnet. Jetzt
  fragen alle gemeinsam.standard_ordner(). Laufen die je auseinander, fuehren
  die beiden Programme zwei Nummernbuecher - und das faellt erst auf, wenn
  eine Rechnungsnummer zum zweiten Mal vergeben ist (§ 14 Abs. 4 Nr. 4 UStG).

* pruefe() stand 35-mal im Quelltext, in sechs Fassungen -> pruefhelfer.py.
  Die zwei Ausreisser sind mitgezogen: pruef_bilder.py schrieb ein eigenes
  Format, pruef_storno_verrechnet.py zaehlte in einer Zahl und konnte am Ende
  nicht sagen, WAS fehlschlug.

* dlg_darstellung() und die drei Handgriffe des Umschaltens standen in beiden
  app.py fast wortgleich -> ThemeMixin in theme.py. Beide trugen inzwischen
  denselben langen Kommentar zu demselben Fehler; das war der Beweis, dass es
  eine Sache ist. Programmspezifisch bleiben der Hinweistext im Dialog (R31)
  und die Frage, ob die Einstellungen ausdruecklich gespeichert werden muessen.

* pruef_gemeinsam_automatisch.py sagte im Text "genau ein Buch" und prueft
  ">= 1" mit einem any() - eine Pruefung, die nicht rot werden kann. Jetzt
  == 1, und der Dateiname wird mitgeprueft.
  Dazu neu: die neun Dateien, die im Kopf zusagen, sie laegen in beiden
  Programmen gleich, werden byteweise verglichen. Bei hinweis.py stimmte die
  Zusage seit dem 06.09. nicht mehr - gleicher Inhalt, CRLF gegen LF.

* .gitattributes, damit die Zeilenenden nicht von der Maschine abhaengen.
  Ohne das meldet genau diese Pruefung nach einem frischen Checkout einen
  Unterschied, den es im Repository gar nicht gibt.

* Sechs stumme "except OSError" sagen jetzt, warum sie schweigen duerfen, und
  einer meldet statt zu schweigen: schlaegt die Uebernahme einer alten
  config.json fehl, faengt das Programm ohne Firmendaten, Katalog und Zaehler
  neu an - und der Erfolgsfall schrieb eine Zeile, der Fehlerfall nicht. Die
  24 stummen tk.TclError bleiben: dort wird ein Widget angefasst, das gerade
  zerstoert wurde, und eine Meldung waere Rauschen.

* Tote Parameter und Variablen aus dem Umbau des Vortags: symbol_setzen(dunkel=),
  kachel(grund=, radius=), mit_trennlinie, sechs x/y-Berechnungen, die von
  theme.mittig() sofort ueberschrieben wurden, vier lokale "import ctypes".

39 Pruefstaende, 0 rot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 18:39:03 +02:00

251 lines
11 KiB
Python

# -*- coding: utf-8 -*-
"""Pruefstand fuer die Montage-Rechnung - der ganze Weg an echten Dateien.
Bartl vermietet auch monatsweise an Montagearbeiter. Dieses Blatt schrieb er
bisher von Hand in Word:
Miete fuer Montagearbeiter inkl. 7 % MwSt. fuer den Monat August 2026
Netto 467,29 USt 32,71 Brutto 500,00
Im Rechnungstool ist das ein eigener Reiter. Geprueft wird deshalb nicht nur
gerechnet, sondern der Weg, den er geht: Reiter waehlen, Kunde und Zeitraum
eintragen, PDF erstellen. Danach liest das STEUERJOURNAL die Datei ein - die
Miete ist eine Beherbergung, die Steuer kommt obendrauf (500 + 25 = 525) und
muss genau so beim Amt ankommen.
Die echte config.json wird nicht angefasst - es wird mit einer Kopie in einem
Wegwerf-Ordner gearbeitet.
"""
import json
import os
import shutil
import sys
import tempfile
import threading
QUELLE = r"C:\claude\rechnungstool"
JOURNAL_PROG = r"C:\claude\beherbergungssteuer"
sys.path.insert(0, QUELLE)
tmp = tempfile.mkdtemp(prefix="montage_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")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0
# Der Erststart-Zustand gehoert dem Pruefstand, nicht der config des
# Entwicklerrechners: sonst laeuft er in einen anderen Zweig, sobald hier
# jemand die Nummer einmal eingerichtet hat.
roh["nummer_eingerichtet"] = False
roh["output_dir"] = ordner
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
# Kein Erststart-Dialog im Pruefstand - er wuerde den Lauf haengen lassen.
os.environ["CAMPINGHOF_TEST"] = "1"
import app as A # noqa: E402
import bestand as BE # noqa: E402
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog, "
"der nicht beantwortet wird")
os._exit(2)
_wecker = threading.Timer(150, _reissleine)
_wecker.daemon = True
_wecker.start()
# Dialoge automatisch beantworten; jede Frage wird protokolliert.
antworten = {"Fertig": False, "endgültig erstellen": True, "Hausnummer": True}
gefragt = []
def askyesno(titel, text, **kw):
gefragt.append(titel)
for schlue, wert in antworten.items():
if schlue in titel:
return wert
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
warnungen = []
# Die Freigabe vor dem Beleg (§ 14 UStG) wird bewusst bejaht - geprueft wird hier
# der Ablauf DANACH. Dass ein "Nein" keinen Beleg erzeugt, prueft pruef_freigabe.py.
antworten["jetzt erstellen?"] = True
# Der Erprobungshinweis wird hier bejaht - geprueft wird er in pruef_hinweis.py.
antworten["Erprobungsfassung"] = True
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 "",
a[1] if len(a) > 1 else "")
f = A.RechnungsApp()
f.update()
f.cfg["output_dir"] = ordner
# ---- 0. Der Reiter ---------------------------------------------------------
reiter = [f.blaetter.tab(i, "text").strip() for i in range(f.blaetter.index("end"))]
pruefe(reiter == ["Campingrechnung", "Montage"], f"zwei Reiter da ({reiter})")
pruefe(not f.ist_montage(), "beim Start liegt die Campingrechnung oben")
pruefe(f.datum_labels["von"].cget("text").startswith("Anreise"),
"dort heisst das Feld Anreise")
f.blaetter.select(1)
f.update()
pruefe(f.ist_montage(), "nach dem Wechsel liegt Montage oben")
pruefe(f.datum_labels["von"].cget("text").startswith("Zeitraum von"),
f"dort heisst dasselbe Feld Zeitraum von ({f.datum_labels['von'].cget('text')!r})")
# ---- 1. Die Rechnung, wie er sie tippt ------------------------------------
f.k_anrede.set("Herr")
f.k_vorname.set("Dominik")
f.k_name.set("Schmidt")
f.k_strasse.set("Musterweg 1")
f.k_plzort.set("12345 Musterstadt")
f.r_datum.set("12.05.2026")
f.r_anreise.set("01.08.2026")
f.r_abreise.set("31.08.2026")
f.m_monate.set("1")
f.update()
pruefe(f.r_naechte.get() == "30", f"30 Naechte im Zeitraum ({f.r_naechte.get()})")
text = f.m_text.cget("text")
pruefe("Miete für Montagearbeiter" in text and "7 % MwSt." in text
and "01.08.2026" in text and "31.08.2026" in text,
f"die Zeile steht so da, wie sie aufs Blatt gehoert ({text!r})")
f.pdf_erstellen()
pdf = os.path.join(ordner, "Rechnung_2026-001_Schmidt_Montage.pdf")
pruefe(os.path.exists(pdf), f"Montage-Rechnung geschrieben ({os.path.basename(pdf)})")
# ---- 2. Was in den Kenndaten steht ----------------------------------------
meta = BE.kenndaten_lesen(pdf) or {}
pruefe(meta.get("vorlage") == "montage", f"vorlage = montage ({meta.get('vorlage')!r})")
pruefe(meta.get("art") == "rechnung",
f"art bleibt rechnung - fuer das Journal eine normale Buchung ({meta.get('art')!r})")
pruefe(float(meta.get("zwischensumme") or 0) == 500.0,
f"Zwischensumme 500,00 ({meta.get('zwischensumme')})")
pruefe(float(meta.get("steuer_satz") or 0) == 5.0,
f"Beherbergungssteuer 5 % ({meta.get('steuer_satz')})")
pruefe(float(meta.get("steuer_betrag") or 0) == 25.0,
f"Steuer 25,00 - obendrauf, nicht drin ({meta.get('steuer_betrag')})")
pruefe(float(meta.get("gesamt") or 0) == 525.0, f"Gesamt 525,00 ({meta.get('gesamt')})")
pruefe(str(meta.get("naechte")) == "30",
f"30 Naechte in den Kenndaten - die Meldung ans Amt ({meta.get('naechte')!r})")
pruefe(meta.get("nachname") == "Schmidt", f"Nachname ({meta.get('nachname')!r})")
bloecke = meta.get("ust_bloecke") or []
pruefe(len(bloecke) == 1 and int(bloecke[0]["satz"]) == 7,
f"eine USt-Zeile mit 7 % ({bloecke})")
pruefe(bloecke and abs(bloecke[0]["netto"] - 467.29) < 0.005
and abs(bloecke[0]["ust"] - 32.71) < 0.005,
f"Netto 467,29 und USt 32,71 wie auf seinem Blatt ({bloecke})")
pos = meta.get("positionen") or []
pruefe(len(pos) == 1 and float(pos[0]["anzahl"]) == 1.0, f"eine Position, ein Monat ({pos})")
# ---- 3. Was auf dem Blatt steht -------------------------------------------
from pypdf import PdfReader # noqa: E402
seiten = [s.extract_text() or "" for s in PdfReader(pdf).pages]
# Seit 05.09.2026 gibt es nur noch EIN Blatt. Ein zweites mit derselben Nummer,
# aber anderem Gesamtbetrag ("fuer das Amt") sieht bei einer Pruefung aus, als
# werde ueber zwei verschiedene Umsaetze abgerechnet.
pruefe(len(seiten) == 1, f"genau EIN Blatt - kein zweites fuers Amt ({len(seiten)})")
kunde_seite = seiten[0].replace("\n", " ")
pruefe("Miete für Montagearbeiter" in kunde_seite, "die Miet-Zeile steht auf dem Blatt")
pruefe("Zeitraum 01.08.2026" in kunde_seite, "mit dem Zeitraum")
pruefe("Rechnungsbetrag Insgesamt" in kunde_seite,
"und die Schlusszeile heisst wie bei ihm: Rechnungsbetrag Insgesamt")
pruefe("Tage/Menge" not in kunde_seite and "Pos." not in kunde_seite,
"keine Positionstabelle auf dem Montage-Blatt")
pruefe("525,00" in kunde_seite and "500,00" in kunde_seite,
"500,00 und 525,00 stehen drauf")
pruefe("Beherbergungssteuer" in kunde_seite, "die Steuer ist auf der Kundenseite ausgewiesen")
pruefe(kunde_seite.count("Rechnungsbetrag Insgesamt") == 1,
"der Gesamtbetrag steht genau einmal in der Datei")
# ---- 4. Das Steuerjournal liest die Datei ---------------------------------
sys.path.append(JOURNAL_PROG)
import db as BST # noqa: E402
journal = BST.Journal(os.path.join(tmp, "journal.sqlite3"))
neu, akt, uebersprungen, kaputt = journal.scanne(ordner, 5.0, voll=True)
pruefe(neu == 1 and not kaputt, f"das Journal liest sie ein ({neu} neu, Fehler: {kaputt})")
buchungen = journal.buchungen(jahr=2026)
pruefe(len(buchungen) == 1, f"eine Buchung im Journal ({len(buchungen)})")
if buchungen:
b = buchungen[0]
pruefe(b.nachname == "Schmidt", f"auf Schmidt gebucht ({b.nachname!r})")
pruefe(abs(b.gezahlt - 500.0) < 0.005, f"Gezahlt 500,00 ({b.gezahlt})")
pruefe(abs(b.steuer - 25.0) < 0.005, f"Steuer 25,00 ({b.steuer})")
pruefe(b.naechte == 30, f"30 Uebernachtungen ({b.naechte})")
pruefe(b.datum.strftime("%d.%m.%Y") == "12.05.2026", f"Datum ({b.datum})")
journal.schliessen()
# ---- 5. Storno einer Montage-Rechnung -------------------------------------
offen = [d for d in BE.rechnungen_im_ordner(ordner) if d.get("art") != "storno"]
pruefe(len(offen) == 1, "eine stornierbare Rechnung gefunden")
f._storno_anlegen(offen[0], ordner)
storno_pdf = f.storno_stand["pfad"] if f.storno_stand else ""
pruefe(os.path.exists(storno_pdf), f"Storno geschrieben ({os.path.basename(storno_pdf)})")
m2 = BE.kenndaten_lesen(storno_pdf) or {}
pruefe(float(m2.get("zwischensumme") or 0) == -500.0,
f"Storno-Zwischensumme -500,00 ({m2.get('zwischensumme')})")
pruefe(float(m2.get("steuer_betrag") or 0) == -25.0,
f"Beherbergungssteuer wird mitstorniert ({m2.get('steuer_betrag')})")
pruefe(f.ist_montage(), "das Formular steht danach wieder im Montage-Reiter")
pruefe(f.m_monate.get() in ("1", "1.0"), f"mit einem Monat ({f.m_monate.get()!r})")
f._storno_verwerfen("Pruefstand")
# ---- 6. Die Campingrechnung darf sich nicht veraendert haben --------------
f.blaetter.select(0)
f.update()
pruefe(not f.ist_montage(), "zurueck im Campingreiter")
pruefe(f.datum_labels["von"].cget("text").startswith("Anreise"), "Feld heisst wieder Anreise")
f.r_anreise.set("01.09.2026")
f.r_abreise.set("05.09.2026")
erste_pos = sorted(f.anzahl_vars)[0]
f.anzahl_vars[erste_pos].set("4")
f.update()
f.pdf_erstellen()
camping = [d for d in os.listdir(ordner) if d.endswith(".pdf") and "Montage" not in d
and not d.startswith("Storno")]
pruefe(len(camping) == 1, f"die Campingrechnung entsteht wie immer ({camping})")
if camping:
t = (PdfReader(os.path.join(ordner, camping[0])).pages[0].extract_text() or "").replace("\n", " ")
pruefe("Tage/Menge" in t, "und hat weiter ihre Positionstabelle")
# ---- 7. Datum: Tag bis 31, Monat bis 12 -----------------------------------
p = A.RechnungsApp._datum_ziffern_pruefen
faelle = [("5", "05"), ("9", "09"), ("35", "3"), ("00", "0"), ("31", "31"),
("013", "0103"), ("1213", "121"), ("1200", "120"), ("3112", "3112")]
for ein, soll in faelle:
pruefe(p(ein) == soll, f"Tippen {ein!r} ergibt {p(ein)!r} (soll {soll!r})")
f.r_datum.set("31.02.2026") # Tag und Monat je fuer sich moeglich - den Tag gibt es nicht
kaputt = f._datumsfelder_pruefen()
pruefe(len(kaputt) == 1 and "31.02.2026" in kaputt[0],
f"31.02.2026 wird als Datum beanstandet ({kaputt})")
vorher = len([d for d in os.listdir(ordner) if d.endswith(".pdf")])
warnungen.clear()
f.pdf_erstellen()
nachher = len([d for d in os.listdir(ordner) if d.endswith(".pdf")])
pruefe(nachher == vorher, "und es wird keine Rechnung mit diesem Datum geschrieben")
pruefe(any("Datum" in w for w in warnungen), f"mit Meldung an den Benutzer ({warnungen})")
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)