Das zweite Blatt "fuer das Amt" trug DIESELBE Rechnungsnummer, aber einen anderen Gesamtbetrag. Mehrere Blaetter zum selben Umsatz mit verschiedenen Endbetraegen sehen aus, als werde ueber verschiedene Umsaetze abgerechnet - bei einer Pruefung ein Problem. Ersatzlos entfallen; was die Stadt braucht, rechnet das Steuerjournal aus den Kenndaten der PDF. Mit dem Blatt fielen _AmtMarke, gesamtrechnung_betont und der doppelte Renderlauf weg. Die API kann das Fenster jetzt BEDIENEN - mit Schluessel wie bei einem SSH-Key: 32 Byte Zufall in api_schluessel.txt im gemeinsamen Ordner, den nur das Steuerjournal und der Betreiber lesen. Ohne gueltigen Schluessel kein Zutritt (compare_digest). ping und schau_nach bleiben schluessellos, sonst kann ein aelteres Journal nicht mehr anklopfen. Was einen BELEG schreibt, geht nur im Testlauf - eine Fernbedienung, die echte Rechnungen ausstellt, hat in einer Buchhaltung nichts zu suchen. api.py liegt wie immer in beiden Programmen. Der Erststart fragte nach "der zuletzt vergebenen Nummer" - das holt niemanden ab, der bei 0 anfaengt. Jetzt zwei Wege: bei 0 anfangen ODER die eigene Zaehlung weiterfuehren. Unter das schon Vergebene kommt keiner der beiden. pruef_nummernsperre bediente den Dialog nur halb: nach einer abgelehnten Zahl bleibt er offen (so soll er), niemand drueckte "Spaeter" - das Programm wartete in wait_window() ewig. Das war schon vor diesem Umbau so. Neu: pruef_api.py. Alle 14 Pruefstaende gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
251 lines
11 KiB
Python
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
|
|
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
|
|
|
|
|
|
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()
|
|
|
|
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)
|
|
|
|
|
|
# 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 = []
|
|
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)
|