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
This commit is contained in:
parent
700ddc3bd1
commit
65eec9937a
3 changed files with 224 additions and 4 deletions
74
db.py
74
db.py
|
|
@ -34,10 +34,16 @@ CREATE TABLE IF NOT EXISTS buchungen (
|
|||
quelle TEXT NOT NULL DEFAULT 'pdf',
|
||||
pdf_pfad TEXT NOT NULL DEFAULT '',
|
||||
pdf_mtime REAL NOT NULL DEFAULT 0,
|
||||
pdf_groesse INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE (jahr, rechnungsnummer)
|
||||
pdf_groesse INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_datum ON buchungen (jahr, datum);
|
||||
CREATE INDEX IF NOT EXISTS idx_nummer ON buchungen (jahr, rechnungsnummer);
|
||||
-- Der Schluessel ist die DATEI, nicht die Rechnungsnummer. Vorher stand hier
|
||||
-- UNIQUE (jahr, rechnungsnummer): eine zweite Rechnung mit derselben Nummer
|
||||
-- ueberschrieb die erste still, und im Amtsbericht fehlte die Uebernachtung.
|
||||
-- Buchungen von Hand (quelle != 'pdf') haben keinen Pfad und bleiben frei.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_buchungen_pdf
|
||||
ON buchungen (pdf_pfad) WHERE pdf_pfad <> '';
|
||||
CREATE TABLE IF NOT EXISTS einstellungen (
|
||||
schluessel TEXT PRIMARY KEY,
|
||||
wert TEXT NOT NULL
|
||||
|
|
@ -51,10 +57,63 @@ class Journal:
|
|||
neu = not os.path.exists(pfad)
|
||||
self.con = sqlite3.connect(pfad)
|
||||
self.con.row_factory = sqlite3.Row
|
||||
self._alte_nummern_sperre_loesen()
|
||||
self.con.executescript(SCHEMA)
|
||||
self.con.commit()
|
||||
log.info("Journal %s (%s)", pfad, "neu angelegt" if neu else "geöffnet")
|
||||
|
||||
def _alte_nummern_sperre_loesen(self):
|
||||
"""Bestehende Journale einmalig auf den neuen Schluessel umbauen.
|
||||
|
||||
Bis hierher war (jahr, rechnungsnummer) eindeutig - eine zweite Rechnung
|
||||
mit derselben Nummer hat die erste ueberschrieben und fehlte danach im
|
||||
Amtsbericht. ALLES ODER NICHTS: bricht der Umbau ab, bleibt der alte
|
||||
Stand stehen, sonst waere die Tabelle umbenannt und die neue leer.
|
||||
"""
|
||||
da = self.con.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='buchungen'").fetchone()
|
||||
if not da:
|
||||
return
|
||||
alt_sperre = False
|
||||
for zeile in self.con.execute("PRAGMA index_list('buchungen')"):
|
||||
if zeile["unique"] and zeile["origin"] == "u":
|
||||
spalten = [r["name"] for r in
|
||||
self.con.execute(f"PRAGMA index_info('{zeile['name']}')")]
|
||||
if spalten == ["jahr", "rechnungsnummer"]:
|
||||
alt_sperre = True
|
||||
if not alt_sperre:
|
||||
return
|
||||
log.warning("Journal wird umgebaut: Schlüssel war die Rechnungsnummer, "
|
||||
"jetzt die PDF-Datei")
|
||||
try:
|
||||
self.con.execute("BEGIN IMMEDIATE")
|
||||
self.con.execute("ALTER TABLE buchungen RENAME TO buchungen_alt")
|
||||
for befehl in SCHEMA.split(";"):
|
||||
if befehl.strip():
|
||||
self.con.execute(befehl)
|
||||
# Denselben Pfad kann es mehrfach geben (alter Schluessel war die
|
||||
# Nummer) - die juengste Zeile gewinnt, sonst scheitert der Index.
|
||||
self.con.execute("""
|
||||
INSERT INTO buchungen
|
||||
(jahr, rechnungsnummer, datum, nachname, naechte, gezahlt, satz,
|
||||
quelle, pdf_pfad, pdf_mtime, pdf_groesse)
|
||||
SELECT jahr, rechnungsnummer, datum, nachname, naechte, gezahlt, satz,
|
||||
quelle, pdf_pfad, pdf_mtime, pdf_groesse
|
||||
FROM buchungen_alt
|
||||
WHERE pdf_pfad = '' OR id IN (
|
||||
SELECT MAX(id) FROM buchungen_alt WHERE pdf_pfad <> '' GROUP BY pdf_pfad
|
||||
)
|
||||
""")
|
||||
genommen = self.con.execute("SELECT COUNT(*) FROM buchungen").fetchone()[0]
|
||||
self.con.execute("DROP TABLE buchungen_alt")
|
||||
self.con.execute("COMMIT")
|
||||
except Exception as e: # noqa: BLE001 - hier gibt es keinen halben Umbau
|
||||
self.con.execute("ROLLBACK")
|
||||
log.error("Umbau des Journals fehlgeschlagen, alter Stand bleibt: %s", e)
|
||||
raise
|
||||
log.info("Umbau fertig (%d Buchungen) - doppelte Rechnungsnummern bleiben "
|
||||
"ab jetzt sichtbar", genommen)
|
||||
|
||||
def schliessen(self):
|
||||
self.con.close()
|
||||
|
||||
|
|
@ -122,7 +181,8 @@ class Journal:
|
|||
" (jahr, rechnungsnummer, datum, nachname, naechte, gezahlt, satz, "
|
||||
" quelle, pdf_pfad, pdf_mtime, pdf_groesse) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?) "
|
||||
"ON CONFLICT(jahr, rechnungsnummer) DO UPDATE SET "
|
||||
"ON CONFLICT(pdf_pfad) WHERE pdf_pfad <> '' DO UPDATE SET "
|
||||
" jahr=excluded.jahr, rechnungsnummer=excluded.rechnungsnummer, "
|
||||
" datum=excluded.datum, nachname=excluded.nachname, naechte=excluded.naechte, "
|
||||
" gezahlt=excluded.gezahlt, satz=excluded.satz, quelle=excluded.quelle, "
|
||||
" pdf_pfad=excluded.pdf_pfad, pdf_mtime=excluded.pdf_mtime, "
|
||||
|
|
@ -154,7 +214,7 @@ class Journal:
|
|||
|
||||
Gibt (neu, aktualisiert, uebersprungen, fehler) zurück.
|
||||
"""
|
||||
from pdf_parser import parse_pdf, ParserFehler # lokal: hält modell/db leichtgewichtig
|
||||
from pdf_parser import parse_pdf, keine_buchung, ParserFehler # lokal: hält modell/db leichtgewichtig
|
||||
|
||||
neu = akt = uebersprungen = 0
|
||||
fehler = []
|
||||
|
|
@ -169,6 +229,12 @@ class Journal:
|
|||
if not voll and self._pdf_bekannt(pfad):
|
||||
uebersprungen += 1
|
||||
continue
|
||||
# Berichtigungsblatt: traegt die Nummer der Rechnung und Nullen.
|
||||
# Frueher haette es die echte Buchung ueberschrieben.
|
||||
if keine_buchung(pfad):
|
||||
self.con.execute("DELETE FROM buchungen WHERE pdf_pfad=?",
|
||||
(os.path.abspath(pfad),))
|
||||
continue
|
||||
try:
|
||||
b = parse_pdf(pfad, standard_satz)
|
||||
except ParserFehler as e:
|
||||
|
|
|
|||
|
|
@ -177,6 +177,18 @@ def _aus_metadaten(pfad: str):
|
|||
return daten
|
||||
|
||||
|
||||
def keine_buchung(pfad: str) -> bool:
|
||||
"""True fuer Blaetter, die keine Uebernachtung abrechnen.
|
||||
|
||||
Ein Berichtigungsdokument (§ 31 Abs. 5 UStDV) reicht nur fehlende
|
||||
Empfaengerangaben nach. Es traegt in den Kenndaten die Nummer DER RECHNUNG,
|
||||
die es berichtigt, und lauter Nullen. Wer es einliest, ueberschreibt damit
|
||||
die echte Buchung - im Amtsbericht fehlt die Uebernachtung dann.
|
||||
"""
|
||||
kopf = _aus_metadaten(pfad)
|
||||
return bool(kopf and kopf.get("art") == "berichtigung")
|
||||
|
||||
|
||||
def parse_pdf(pfad: str, standard_satz: float = STANDARD_SATZ) -> Buchung:
|
||||
"""Rechnungs-PDF -> Buchung. Wirft ParserFehler, wenn Pflichtfelder fehlen."""
|
||||
meta = _aus_metadaten(pfad)
|
||||
|
|
@ -294,6 +306,8 @@ def scanne_ordner(ordner: str, standard_satz: float = STANDARD_SATZ, rekursiv: b
|
|||
if not name.lower().endswith(".pdf"):
|
||||
continue
|
||||
pfad = os.path.join(wurzel, name)
|
||||
if keine_buchung(pfad):
|
||||
continue
|
||||
try:
|
||||
buchungen.append(parse_pdf(pfad, standard_satz))
|
||||
except ParserFehler as e:
|
||||
|
|
|
|||
140
pruef_berichtigung.py
Normal file
140
pruef_berichtigung.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
# -*- 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue