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:
TheMockTv 2026-09-02 21:39:41 +02:00
parent 700ddc3bd1
commit 65eec9937a
3 changed files with 224 additions and 4 deletions

74
db.py
View file

@ -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: