Storno und Berichtigung kommen an, gemeinsames Nummernbuch, kein Bearbeiten mehr

Storno: das Journal zaehlte die Uebernachtungen der aufgehobenen Rechnung
weiter mit (4 - 0 + 3 = 7 statt 3 gemeldeter Naechte). Die Betraege kuerzten
sich, die Naechte nicht. Der Storno traegt jetzt die negative Zahl, und das
Journal kennt Art, Bezug und Datum des Belegs (vier neue Spalten, alte
Journale bauen sich beim Oeffnen um).

Berichtigung: das Blatt wurde nur uebersprungen. Der berichtigte NAME kommt
jetzt in der Buchung an - im Amtsbericht stand sonst weiter der falsche.
Angewandt wird erst am Ende des Scans, das Blatt kann vor seiner Rechnung im
Ordner liegen.

Doppelte Nummern: eine stornierte Rechnung ist keine offene mehr und faellt aus
der Meldung. Zugeordnet wird ueber Nummer + Datum + Empfaenger, nicht ueber die
Nummer allein - bei einer doppelt vergebenen Nummer waere die kein eindeutiger
Bezug, und genau den verlangt § 31 Abs. 5 UStDV. Die Texte nennen jetzt den
Weg: eine der beiden im Rechnungstool stornieren und mit freier Nummer neu
ausstellen (§ 14 Abs. 4 Nr. 4 UStG - jede Nummer nur einmal).

Neue Spalte "Art": Storno zu ..., storniert, Neuausstellung. Aufgehobene Zeilen
stehen grau - geloescht wird nichts.

Gemeinsames Nummernbuch (gemeinsam.py, ordnerwahl.py - in beiden Programmen
dieselbe Datei): eine SQLite in einem Ordner, den beide kennen. Das Journal
traegt seine Nummern ein, auch die aus der Excel-Mappe ohne PDF - genau die
kannte das Rechnungstool nicht und vergab sie ein zweites Mal. Beim ersten
Start wird nach dem Ordner gefragt (auswaehlen, OK, Sicherheitsfrage, bei Nein
zurueck ins Feld); wer ihn nicht hat, kommt mit "Spaeter einrichten" weiter.

Kein Doppelklick zum Bearbeiten mehr (gui_dialoge.py geloescht): wer hier Betrag
oder Nummer verstellt, meldet dem Amt etwas anderes, als auf dem Beleg steht.
Loeschen bleibt.

--test / CAMPINGHOF_TEST=1 unterdrueckt den Erststart-Dialog (Pruefstaende).

Pruefstaende: pruef_storno_journal.py (neu, ueber BEIDE Programme: Storno,
Berichtigung, doppelte Nummer aufloesen), pruef_doppelte, pruef_berichtigung,
pruef_einzelinstanz - alle gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx
This commit is contained in:
TheMockTv 2026-09-03 20:38:38 +02:00
parent 9e564466c7
commit 59d2db75af
12 changed files with 906 additions and 212 deletions

86
db.py
View file

@ -32,6 +32,10 @@ CREATE TABLE IF NOT EXISTS buchungen (
gezahlt REAL NOT NULL DEFAULT 0,
satz REAL NOT NULL DEFAULT 5,
quelle TEXT NOT NULL DEFAULT 'pdf',
art TEXT NOT NULL DEFAULT 'rechnung',
storno_zu TEXT NOT NULL DEFAULT '',
storno_datum TEXT NOT NULL DEFAULT '',
vorgang TEXT NOT NULL DEFAULT '',
pdf_pfad TEXT NOT NULL DEFAULT '',
pdf_mtime REAL NOT NULL DEFAULT 0,
pdf_groesse INTEGER NOT NULL DEFAULT 0
@ -59,6 +63,7 @@ class Journal:
self.con.row_factory = sqlite3.Row
self._alte_nummern_sperre_loesen()
self.con.executescript(SCHEMA)
self._spalten_ergaenzen()
self.con.commit()
log.info("Journal %s (%s)", pfad, "neu angelegt" if neu else "geöffnet")
@ -114,6 +119,21 @@ class Journal:
log.info("Umbau fertig (%d Buchungen) - doppelte Rechnungsnummern bleiben "
"ab jetzt sichtbar", genommen)
def _spalten_ergaenzen(self):
"""Spalten nachziehen, die es in aelteren Journalen noch nicht gab.
CREATE TABLE IF NOT EXISTS aendert eine bestehende Tabelle nicht - ohne
das hier faenden die neuen Felder in einer alten Datei kein Zuhause.
"""
da = {z["name"] for z in self.con.execute("PRAGMA table_info('buchungen')")}
for name, vorgabe in (("art", "'rechnung'"), ("storno_zu", "''"),
("storno_datum", "''"), ("vorgang", "''")):
if name not in da:
self.con.execute(
f"ALTER TABLE buchungen ADD COLUMN {name} TEXT NOT NULL DEFAULT {vorgabe}")
log.info("Journal um Spalte %s ergänzt", name)
self.con.commit()
def schliessen(self):
self.con.close()
@ -156,6 +176,10 @@ class Journal:
datum=datetime.strptime(r["datum"], "%Y-%m-%d").date(),
nachname=r["nachname"], naechte=r["naechte"], gezahlt=r["gezahlt"],
satz=r["satz"], quelle=r["quelle"], pdf_pfad=r["pdf_pfad"],
art=(r["art"] if "art" in r.keys() else "rechnung"),
storno_zu=(r["storno_zu"] if "storno_zu" in r.keys() else ""),
storno_datum=(r["storno_datum"] if "storno_datum" in r.keys() else ""),
vorgang=(r["vorgang"] if "vorgang" in r.keys() else ""),
)
def _pdf_bekannt(self, pfad: str) -> bool:
@ -179,16 +203,20 @@ class Journal:
cur = self.con.execute(
"INSERT INTO buchungen "
" (jahr, rechnungsnummer, datum, nachname, naechte, gezahlt, satz, "
" quelle, pdf_pfad, pdf_mtime, pdf_groesse) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?) "
" quelle, pdf_pfad, pdf_mtime, pdf_groesse, art, storno_zu, "
" storno_datum, vorgang) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "
"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, "
" pdf_groesse=excluded.pdf_groesse",
" pdf_groesse=excluded.pdf_groesse, art=excluded.art, "
" storno_zu=excluded.storno_zu, storno_datum=excluded.storno_datum, "
" vorgang=excluded.vorgang",
(b.jahr, b.rechnungsnummer, b.datum.isoformat(), b.nachname, int(b.naechte or 0),
float(b.gezahlt or 0), float(b.satz or 0), b.quelle, b.pdf_pfad, mtime, groesse))
float(b.gezahlt or 0), float(b.satz or 0), b.quelle, b.pdf_pfad, mtime, groesse,
b.art or "rechnung", b.storno_zu or "", b.storno_datum or "", b.vorgang or ""))
self.con.commit()
return cur.lastrowid
@ -214,10 +242,15 @@ class Journal:
Gibt (neu, aktualisiert, uebersprungen, fehler) zurück.
"""
from pdf_parser import parse_pdf, keine_buchung, ParserFehler # lokal: hält modell/db leichtgewichtig
from pdf_parser import (parse_pdf, berichtigung_daten,
ParserFehler) # lokal: hält modell/db leichtgewichtig
neu = akt = uebersprungen = 0
fehler = []
# Berichtigungsblaetter werden gesammelt und ERST AM ENDE angewandt: das
# Blatt kann vor "seiner" Rechnung im Ordner liegen, und dann gaebe es
# noch nichts zu berichtigen.
berichtigungen = []
if not os.path.isdir(ordner):
return 0, 0, 0, [(ordner, "Ordner existiert nicht")]
@ -230,10 +263,14 @@ class Journal:
uebersprungen += 1
continue
# Berichtigungsblatt: traegt die Nummer der Rechnung und Nullen.
# Frueher haette es die echte Buchung ueberschrieben.
if keine_buchung(pfad):
# Frueher haette es die echte Buchung ueberschrieben. Es bucht
# nichts - aber die berichtigten Angaben zum Gast gehoeren
# uebernommen, sonst steht im Amtsbericht weiter der falsche Name.
berichtigt = berichtigung_daten(pfad)
if berichtigt is not None:
self.con.execute("DELETE FROM buchungen WHERE pdf_pfad=?",
(os.path.abspath(pfad),))
berichtigungen.append(berichtigt)
continue
try:
b = parse_pdf(pfad, standard_satz)
@ -255,6 +292,37 @@ class Journal:
else:
neu += 1
log.info("Scan fertig: %d neu, %d aktualisiert, %d unverändert, %d Fehler",
neu, akt, uebersprungen, len(fehler))
berichtigt_anzahl = self._berichtigungen_anwenden(berichtigungen)
log.info("Scan fertig: %d neu, %d aktualisiert, %d unverändert, %d berichtigt, "
"%d Fehler", neu, akt, uebersprungen, berichtigt_anzahl, len(fehler))
return neu, akt, uebersprungen, fehler
def _berichtigungen_anwenden(self, berichtigungen):
"""Berichtigte Empfaengerangaben in die zugehoerige Buchung uebernehmen.
Nach dem Datum der Berichtigung sortiert - gibt es zu einer Rechnung
mehrere Blaetter, gilt das juengste.
"""
from modell import zerlege_nummer
def wann(k):
teile = str(k.get("berichtigt_am") or "").split(".")
return tuple(reversed(teile)) if len(teile) == 3 else ("",)
geaendert = 0
for kopf in sorted(berichtigungen, key=wann):
nachname = str(kopf.get("nachname") or "").strip()
bezug = str(kopf.get("berichtigt_zu") or kopf.get("rechnungsnummer") or "")
if not nachname or not bezug:
continue
jahr, nummer = zerlege_nummer(bezug, date.today().year)
cur = self.con.execute(
"UPDATE buchungen SET nachname=? "
"WHERE jahr=? AND rechnungsnummer=? AND nachname<>?",
(nachname, jahr, nummer, nachname))
if cur.rowcount:
geaendert += cur.rowcount
log.info("Berichtigung %s-%s: Name jetzt %r", jahr, nummer, nachname)
if geaendert:
self.con.commit()
return geaendert