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
249 lines
10 KiB
Python
249 lines
10 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Pruefstand: das unverbindliche Angebot.
|
|
|
|
Sein Auftrag: ein Knopf, im Popup waehlt man Rechnung oder Angebot - *"musst
|
|
nur auf passen das der rechner dann die rechnungs nummer nicht aendert, sonst
|
|
muessen wir wieder die nummern aenderbar machen und dann macht er wieder
|
|
fehler"*.
|
|
|
|
Genau das ist hier die Hauptpruefung: das Angebot laeuft in einer EIGENEN
|
|
Reihe (2026-A001) und laesst Nummernfeld, Zaehler und Nummernbuch unberuehrt.
|
|
|
|
Dazu die Rechtslage (nachgeschlagen am 03.09.2026):
|
|
* keine Nummer aus dem regulaeren Kreis - die fortlaufende Nummerierung nach
|
|
§ 14 Abs. 4 Nr. 4 UStG gilt nur fuer echte Rechnungen
|
|
* kein Steuersatz und kein Steuerbetrag auf dem Blatt (§ 14c UStG: wer Steuer
|
|
ausweist, schuldet sie)
|
|
* deutlich kennzeichnen, sonst kann das Finanzamt das Blatt als echte Rechnung
|
|
werten (§ 14 Abs. 1 S. 1 UStG: die Bezeichnung ist gleichgueltig). "Angebot"
|
|
ist im Camping- und Hotelgeschaeft der uebliche Begriff, "Kostenanschlag"
|
|
gehoert ins Werkvertragsrecht (§ 632 Abs. 3, § 649 BGB).
|
|
|
|
Gefahren wird das ECHTE Popup - die Knoepfe werden angeklickt, nicht umgangen.
|
|
"""
|
|
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)
|
|
|
|
|
|
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="angebot_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)
|
|
|
|
import app as A # noqa: E402
|
|
import bestand as BE # noqa: E402
|
|
from tkinter import messagebox # noqa: E402
|
|
from pruefhelfer import fehler, pruefe
|
|
|
|
antworten = {"Fertig": False, "Hausnummer": True}
|
|
|
|
|
|
def askyesno(titel, text, **kw):
|
|
for schlue, wert in antworten.items():
|
|
if schlue in titel:
|
|
return wert
|
|
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
|
|
|
|
|
|
# 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
|
|
warnungen = []
|
|
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
|
|
# Dieser Pruefstand will das ECHTE Popup sehen und seine Knoepfe druecken.
|
|
f._dialog_zeigen = True
|
|
|
|
|
|
def knoepfe_von(fenster):
|
|
"""Alle Knopf-Widgets eines Fensters, egal wie tief."""
|
|
aus = []
|
|
|
|
def gehe(w):
|
|
for k in w.winfo_children():
|
|
if k.winfo_class() in ("TButton", "Button"):
|
|
aus.append(k)
|
|
gehe(k)
|
|
|
|
gehe(fenster)
|
|
return aus
|
|
|
|
|
|
def klicke_im_popup(teil):
|
|
"""Wartet auf das Popup und drueckt den Knopf, dessen Text `teil` enthaelt."""
|
|
def versuch(rest=40):
|
|
tops = [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"]
|
|
if tops:
|
|
for k in knoepfe_von(tops[-1]):
|
|
if teil.lower() in str(k.cget("text")).lower():
|
|
k.invoke()
|
|
return
|
|
if rest:
|
|
f.after(100, lambda: versuch(rest - 1))
|
|
f.after(200, versuch)
|
|
|
|
|
|
def kunde(vorname, nachname):
|
|
f.k_vorname.set(vorname)
|
|
f.k_name.set(nachname)
|
|
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("3")
|
|
f.update()
|
|
|
|
|
|
# ---- 1. Der eine Knopf und das Popup --------------------------------------
|
|
knopf_texte = [str(k.cget("text")) for k in knoepfe_von(f)]
|
|
pruefe("PDF erstellen" in knopf_texte, f"unten steht EIN Knopf zum Erstellen ({knopf_texte})")
|
|
pruefe(not any("Angebot" in t for t in knopf_texte),
|
|
"und kein zweiter Knopf fuers Angebot - gewaehlt wird im Popup")
|
|
|
|
kunde("Anna", "Vorab")
|
|
nummer_vorher = f.r_nummer.get()
|
|
zaehler_vorher = f.cfg.get("rechnung_zaehler")
|
|
pruefe(nummer_vorher == "2026-001", f"vorgeschlagen ist 2026-001 ({nummer_vorher})")
|
|
|
|
# ---- 2. Abbrechen schreibt nichts ----------------------------------------
|
|
klicke_im_popup("Abbrechen")
|
|
f.pdf_erstellen()
|
|
pruefe(not os.listdir(ordner), f"Abbrechen schreibt nichts ({os.listdir(ordner)})")
|
|
|
|
# ---- 3. Angebot waehlen --------------------------------------------------
|
|
klicke_im_popup("Angebot")
|
|
f.pdf_erstellen()
|
|
pruefe(sorted(os.listdir(ordner)) == ["Angebote"],
|
|
f"im Rechnungsordner liegt nur der Unterordner ({sorted(os.listdir(ordner))})")
|
|
angebote = os.path.join(ordner, "Angebote")
|
|
dateien = sorted(os.listdir(angebote))
|
|
pruefe(dateien == ["Angebot_2026-A001_Vorab.pdf"],
|
|
f"das Angebot liegt im Unterordner Angebote ({dateien})")
|
|
|
|
# ---- 4. DIE Hauptsache: die Rechnungsnummer ist unberuehrt --------------
|
|
pruefe(f.r_nummer.get() == nummer_vorher,
|
|
f"das Nummernfeld steht unveraendert auf {nummer_vorher} ({f.r_nummer.get()})")
|
|
pruefe(f.cfg.get("rechnung_zaehler") == zaehler_vorher,
|
|
f"der Rechnungszaehler ist unveraendert ({f.cfg.get('rechnung_zaehler')})")
|
|
pruefe(f.cfg.get("angebot_zaehler") == 1,
|
|
f"nur der Angebots-Zaehler ist weitergelaufen ({f.cfg.get('angebot_zaehler')})")
|
|
|
|
# ---- 5. Was auf dem Blatt steht -----------------------------------------
|
|
from pypdf import PdfReader # noqa: E402
|
|
|
|
pfad = os.path.join(angebote, "Angebot_2026-A001_Vorab.pdf")
|
|
seiten = PdfReader(pfad).pages
|
|
pruefe(len(seiten) == 1, f"eine Seite - kein Blatt fuers Amt ({len(seiten)})")
|
|
text = (seiten[0].extract_text() or "").replace("\n", " ")
|
|
pruefe("Unverbindliches Angebot" in text, "es steht deutlich Unverbindliches Angebot drauf")
|
|
pruefe("Rechnung" not in text.split("Unverbindliches Angebot")[0],
|
|
"und das Wort Rechnung steht nicht in der Ueberschrift")
|
|
pruefe("keine Rechnung im Sinne des § 14 UStG" in text,
|
|
"mit dem Satz, dass es keine Rechnung nach § 14 UStG ist")
|
|
pruefe("Angebotsnummer" in text, "die Nummer heisst Angebotsnummer")
|
|
pruefe("Rechnungsnummer" not in text,
|
|
"das Wort Rechnungsnummer kommt gar nicht mehr vor")
|
|
pruefe("2026-A001" in text and "2026-001" not in text,
|
|
"nur die Angebots-Nummer steht drauf, keine Rechnungsnummer")
|
|
pruefe("Mehrwertsteuer" not in text and "Nettobetrag" not in text,
|
|
"kein Steuerbetrag und kein Nettoausweis (§ 14c UStG)")
|
|
pruefe("Beherbergungssteuer" not in text and "Zwischensumme" not in text,
|
|
"auch die Beherbergungssteuer steht nicht drauf - gar keine Steuer wird ausgewiesen")
|
|
pruefe("alle Steuern und Abgaben enthalten" in text,
|
|
"dafuer der Hinweis, dass alles im Betrag enthalten ist")
|
|
pruefe("Voraussichtlicher Gesamtbetrag" in text,
|
|
"der Betrag heisst voraussichtlich, nicht Rechnungsbetrag")
|
|
|
|
meta = BE.kenndaten_lesen(pfad) or {}
|
|
pruefe(meta.get("art") == "angebot", f"art = angebot ({meta.get('art')})")
|
|
pruefe(float(meta.get("zwischensumme") or 0) == 0.0 and float(meta.get("gesamt") or 0) == 0.0,
|
|
f"die Kenndaten tragen Nullen ({meta.get('zwischensumme')}, {meta.get('gesamt')})")
|
|
|
|
# ---- 6. Danach die echte Rechnung: sie bekommt 2026-001 ----------------
|
|
antworten["Storno"] = True
|
|
klicke_im_popup("Rechnung 2026-001 erstellen")
|
|
f.pdf_erstellen()
|
|
pruefe(os.path.exists(os.path.join(ordner, "Rechnung_2026-001_Vorab.pdf")),
|
|
f"die Rechnung liegt bei den Rechnungen ({sorted(os.listdir(ordner))})")
|
|
pruefe(f.cfg.get("rechnung_zaehler") == 1,
|
|
f"jetzt erst laeuft der Rechnungszaehler weiter ({f.cfg.get('rechnung_zaehler')})")
|
|
|
|
# ---- 7. Das zweite Angebot zaehlt in seiner Reihe weiter ---------------
|
|
f.reset_formular()
|
|
kunde("Bernd", "Zweitvorab")
|
|
klicke_im_popup("Angebot")
|
|
f.pdf_erstellen()
|
|
pruefe(os.path.exists(os.path.join(angebote, "Angebot_2026-A002_Zweitvorab.pdf")),
|
|
f"das zweite Angebot heisst A002 ({sorted(os.listdir(angebote))})")
|
|
f.destroy()
|
|
|
|
# ---- 8. Das Steuerjournal bucht das Angebot NICHT ---------------------
|
|
sys.path.append(JOURNAL)
|
|
import db as BST # noqa: E402
|
|
|
|
journal = BST.Journal(os.path.join(tmp, "journal.sqlite3"))
|
|
journal.scanne(ordner, 5.0, voll=True)
|
|
buchungen = journal.buchungen(jahr=2026)
|
|
pruefe(len(buchungen) == 1, f"nur die echte Rechnung ist gebucht ({len(buchungen)})")
|
|
pruefe(buchungen and buchungen[0].voll_nummer == "2026-001",
|
|
f"und zwar 2026-001 ({[b.voll_nummer for b in buchungen]})")
|
|
pruefe(not any("A0" in b.voll_nummer for b in buchungen),
|
|
"keine Angebots-Nummer im Journal")
|
|
|
|
# ---- 9. Zweite Sicherung: auch ohne Kenndaten wird nichts gebucht -----
|
|
from pdf_parser import keine_buchung # noqa: E402
|
|
|
|
nur_name = os.path.join(ordner, "Angebot_2026-A099_OhneKenndaten.pdf")
|
|
shutil.copy(os.path.join(angebote, "Angebot_2026-A001_Vorab.pdf"), nur_name)
|
|
pruefe(keine_buchung(nur_name),
|
|
"ein Angebot wird auch am Dateinamen erkannt (falls die Kenndaten fehlen)")
|
|
pruefe(keine_buchung(os.path.join(angebote, "Angebot_2026-A001_Vorab.pdf")),
|
|
"und alles im Unterordner Angebote wird uebersprungen")
|
|
pruefe(not keine_buchung(os.path.join(ordner, "Rechnung_2026-001_Vorab.pdf")),
|
|
"eine echte Rechnung wird davon NICHT erfasst")
|
|
journal.schliessen()
|
|
|
|
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)
|