Steuerrechnungstool/pruef_berichtigung.py
TheMockTv 65eec9937a Storno und Berichtigung: das Journal darf die Buchung nicht verlieren
Das Rechnungstool schreibt seit heute auch Storno- und Berichtigungsblaetter in
denselben Ordner. Zwei Fehler, die daraus entstanden waeren - beide still:

1. Ein Berichtigungsblatt (§ 31 Abs. 5 UStDV) traegt die Nummer DER RECHNUNG,
   die es berichtigt, und lauter Nullen. Unter dem alten Schluessel
   (jahr, rechnungsnummer) hat es die echte Buchung ueberschrieben - im
   Amtsbericht fehlte die Uebernachtung dann. Wird jetzt uebersprungen
   (pdf_parser.keine_buchung), in beiden Scannern.
2. Der Schluessel ist jetzt die DATEI. Zwei Rechnungen mit derselben Nummer -
   der Altbestand, bevor das Rechnungstool eine Sperre bekam - ueberschrieben
   sich sonst gegenseitig. Bestehende Journale werden beim Oeffnen einmalig
   umgebaut, in einer Transaktion, mit Rueckfall auf den alten Stand.
   Buchungen von Hand (kein PDF-Pfad) bleiben davon unberuehrt.

Stornos zaehlen weiter mit - sie sind negativ und heben die Uebernachtung im
Bericht genau so auf, wie es sein soll.

pruef_berichtigung.py (neu): schreibt mit dem echten Renderer des
Rechnungstools eine Rechnung, ein Berichtigungsblatt dazu und zwei Rechnungen
mit derselben Nummer, liest sie ein und prueft das Ergebnis in der Tabelle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
2026-09-02 21:39:41 +02:00

140 lines
5.6 KiB
Python

# -*- coding: utf-8 -*-
"""Pruefstand: was macht das Steuerjournal mit Storno und Berichtigung?
Seit dem 02.09.2026 kann das Rechnungstool drei Sorten Blatt schreiben:
Rechnung - die Uebernachtung, mit Beherbergungssteuer
Storno - hebt sie auf, alle Betraege NEGATIV
Berichtigung - reicht nur die Kundenangaben nach; traegt die Nummer DER
RECHNUNG und lauter Nullen
Alle drei liegen im selben Ordner, den dieses Programm einliest. Der
gefaehrliche Fall ist das Berichtigungsblatt: unter dem alten Schluessel
(jahr, rechnungsnummer) hat es die echte Buchung ueberschrieben - im
Amtsbericht fehlte die Uebernachtung dann, ohne ein Wort.
Geprueft wird an ECHTEN PDFs, die der Renderer des Rechnungstools schreibt.
"""
import os
import shutil
import sys
import tempfile
HIER = r"C:\claude\beherbergungssteuer"
TOOL = r"C:\claude\rechnungstool"
sys.path.insert(0, HIER)
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)
# --- Reissleine: ein Pruefstand darf nie haengen ---
import threading as _th
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
os._exit(2)
_wecker = _th.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
tmp = tempfile.mkdtemp(prefix="bst_pruef_")
ordner = os.path.join(tmp, "rechnungen")
os.makedirs(ordner)
# ---- PDFs mit dem echten Renderer des Rechnungstools schreiben --------------
sys.path.insert(0, TOOL)
import config as tool_config # noqa: E402
shutil.copy(os.path.join(TOOL, "config.json"), os.path.join(tmp, "config.json"))
tool_config.CONFIG_PATH = os.path.join(tmp, "config.json")
import berechnung as tb # noqa: E402
import pdf_renderer # noqa: E402
cfg_tool = tool_config.load_config()
def schreibe(datei, nummer, name, datum, tage=3, **extra):
r = tb.berechne(cfg_tool["katalog"], {1: {"anzahl": tage, "personen": 0}}, cfg_tool)
kopf = {"rechnungsnummer": nummer, "datum": datum, "anreise": "", "abreise": "",
"naechte": str(tage), "nachname": name, "vorname": "Anna",
"kunde_zeilen": ["", f"Anna {name}", "", "Musterweg 1", "12345 Musterstadt"]}
kopf.update(extra)
pdf_renderer.render_rechnung(os.path.join(ordner, datei), cfg_tool, r, kopf)
return r
rechnung = schreibe("Rechnung_2026-014_Meier.pdf", "2026-014", "Meier", "12.08.2026", 3)
steuer_soll = round(sum(a.betrag for a in rechnung.aufschlaege), 2)
# Berichtigungsblatt zu genau dieser Rechnung - traegt DEREN Nummer
schreibe("Berichtigte Rechnung_2026-08-20_2026-014.pdf", "2026-014", "Meier", "12.08.2026", 3,
art="berichtigung", berichtigt_zu="2026-014", berichtigt_am="20.08.2026",
vorgang="2026-014")
# und der Altbestand: dieselbe Nummer zweimal, verschiedene Gaeste
schreibe("Rechnung_2026-020_Erst.pdf", "2026-020", "Erst", "01.08.2026", 2)
schreibe("Rechnung_2026-020_Zweit.pdf", "2026-020", "Zweit", "05.08.2026", 4)
# ---- jetzt das Steuerjournal -----------------------------------------------
sys.path.remove(TOOL)
for name in ("config", "berechnung"):
sys.modules.pop(name, None)
import db as db_mod # noqa: E402
journal = db_mod.Journal(os.path.join(tmp, "journal.sqlite3"))
neu, akt, uebersprungen, kaputt = journal.scanne(ordner, 5.0, voll=True)
# Direkt aus der Tabelle lesen - so sieht man auch, was WIRKLICH gespeichert
# wurde, nicht nur, was das Modell daraus macht.
buchungen = [dict(r) for r in journal.con.execute(
"SELECT * FROM buchungen WHERE jahr=2026 ORDER BY rechnungsnummer, id")]
print("--- was im Journal steht ---")
for b in buchungen:
print(f" {b['rechnungsnummer']:>6} {b['datum']} {b['nachname']:10} "
f"{b['naechte']} Nächte {b['gezahlt']:8.2f} {os.path.basename(b['pdf_pfad'])}")
nummern = [b["rechnungsnummer"] for b in buchungen]
# 1. Das Berichtigungsblatt darf die Rechnung NICHT ueberschrieben haben
zu_014 = [b for b in buchungen if b["rechnungsnummer"] in ("014", "2026-014")]
pruefe(len(zu_014) == 1, f"zu 2026-014 steht genau EINE Buchung im Journal ({len(zu_014)})")
pruefe(zu_014 and zu_014[0]["naechte"] == 3,
f"und sie hat die 3 Nächte der Rechnung, nicht 0 ({zu_014[0]['naechte'] if zu_014 else '-'})")
pruefe(zu_014 and zu_014[0]["gezahlt"] > 0,
f"und einen Betrag > 0 ({zu_014[0]['gezahlt'] if zu_014 else '-'})")
pruefe(zu_014 and "Berichtigte" not in os.path.basename(zu_014[0]["pdf_pfad"]),
"die Buchung stammt aus der RECHNUNG, nicht aus dem Berichtigungsblatt")
# 2. Zwei Rechnungen mit derselben Nummer muessen BEIDE dastehen
zu_020 = [b for b in buchungen if b["rechnungsnummer"] in ("020", "2026-020")]
pruefe(len(zu_020) == 2,
f"beide Rechnungen mit 2026-020 stehen im Journal ({len(zu_020)})")
pruefe({b["nachname"] for b in zu_020} == {"Erst", "Zweit"},
f"mit beiden Gästen ({[b['nachname'] for b in zu_020]})")
# 3. Die Beherbergungssteuer der Rechnung ist vollstaendig da
summe = round(sum(b["gezahlt"] * b["satz"] / 100 for b in buchungen), 2)
pruefe(summe > 0, f"Steuer über alle Buchungen: {summe:.2f} € (Rechnung allein {steuer_soll:.2f} €)")
journal.schliessen()
if not fehler:
import gc as _gc
import sqlite3 as _sq
for _obj in list(_gc.get_objects()):
if isinstance(_obj, _sq.Connection):
try:
_obj.close()
except Exception: # noqa: BLE001
pass
shutil.rmtree(tmp, ignore_errors=True)
print("---")
print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")
sys.exit(1 if fehler else 0)