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

View file

@ -27,13 +27,15 @@ Knopfdruck die Excel-Mappe fürs Amt.
Über das Menü gibt es zusätzlich den **Jahresbericht**. Gedruckt wird aus dem
PDF-Programm heraus.
Eine Zeile per **Doppelklick** bearbeiten, mit **Entf** aus dem Journal löschen
(die PDF bleibt liegen und wird beim nächsten Einlesen wieder gefunden).
**Im Journal wird nichts von Hand geändert** (seit v1.4): weder anlegen noch
bearbeiten. Jede Zeile gehört zu einer Rechnung aus dem Rechnungstool – auch die
Barzahlung. Wer hier Betrag oder Nummer verstellte, meldete dem Amt etwas anderes,
als auf dem Beleg steht; beim nächsten Einlesen käme die Änderung ohnehin zurück.
Stimmt eine Rechnung nicht, wird sie **im Rechnungstool** storniert oder berichtigt.
**Buchungen von Hand anlegen gibt es nicht** (seit v1.4 ausgebaut): jede Zeile im
Journal gehört zu einer Rechnung aus dem Rechnungstool. Wer eine Buchung braucht –
auch bei Barzahlung –, schreibt die Rechnung. Sonst meldet das Journal ans Amt, was
in keinem Rechnungsordner steht, und der Bericht lässt sich nicht mehr belegen.
Löschen bleibt: **Entf** oder die Haken-Spalte mit dem Papierkorb. Das entfernt nur
die Zeile aus dem Zwischenspeicher – die PDF bleibt liegen und wird beim nächsten
Einlesen wieder gefunden.
Der Briefkopf des Berichts (Firma, Logo, Bankzeile) wird aus der `config.json` des
Rechnungstools gelesen – also nur an einer Stelle gepflegt.
@ -95,7 +97,6 @@ Tabelle grau dargestellt. Rechnungsnummern werden dabei vereinheitlicht
|-------|-------|
| `app.py` | Fenster, Menü, Aktionen |
| `gui_monat.py` | ein Monatsblatt (Tabelle + Summenzeile) |
| `gui_dialoge.py` | eine bestehende Buchung nachbessern (Anlegen gibt es nicht) |
| `pdf_parser.py` | Rechnungs-PDF → Buchung |
| `bericht_pdf.py` | Monats-/Jahresbericht fürs Amt (reportlab) |
| `firma.py` | Briefkopf aus der `config.json` des Rechnungstools |

152
app.py
View file

@ -43,13 +43,15 @@ if __name__ == "__main__":
sys.exit(0)
import db
import gemeinsam
import ordnerwahl
import xlsx_io
import firma as firma_lib
import bericht_pdf
import ablage
from modell import MONATE, STANDARD_SATZ, summiere, eur, eur_kurz, doppelte_nummern
from modell import (MONATE, STANDARD_SATZ, summiere, eur, eur_kurz,
aufgehobene, doppelte_nummern)
from gui_monat import MonatsTab, SummenLeiste, WARNUNG
from gui_dialoge import BuchungDialog
# Als EXE liegt alles in einem eigenen Datenordner - siehe ablage.py.
BASE_DIR = ablage.daten_ordner()
@ -57,6 +59,12 @@ JOURNAL = os.path.join(BASE_DIR, "journal.sqlite3")
LOGDATEI = os.path.join(BASE_DIR, "beherbergungssteuer.log")
BERICHTE = os.path.join(BASE_DIR, "berichte")
# Testlauf: kein Erststart-Dialog. Pruefstaende fahren das Fenster fern, ein
# modaler Dialog wuerde sie haengen lassen - und in der EXE kommt man an den
# Schalter ohnehin nicht heran (Doppelklick uebergibt keine Argumente).
# python app.py --test oder CAMPINGHOF_TEST=1
TESTLAUF = ("--test" in sys.argv) or os.environ.get("CAMPINGHOF_TEST") == "1"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)-8s %(message)s",
@ -73,6 +81,7 @@ class App(tk.Tk):
self.minsize(820, 520)
self.journal = db.Journal(JOURNAL)
self.buch = None # gemeinsames Nummernbuch, siehe gemeinsam.py
self.satz = float(self.journal.hole("satz", str(STANDARD_SATZ)))
self.pdf_ordner = self.journal.hole("pdf_ordner", "")
self.jahr = tk.IntVar(value=int(self.journal.hole("jahr", str(date.today().year))))
@ -94,6 +103,7 @@ class App(tk.Tk):
self.aktualisieren()
if self.pdf_ordner and os.path.isdir(self.pdf_ordner):
self.after(200, lambda: self.einlesen(still=True))
self.after(400, self._frage_gemeinsam_falls_noetig)
# ------------------------------------------------------------ Aufbau ----
def _menue(self):
@ -118,6 +128,8 @@ class App(tk.Tk):
daten.add_command(label="Steuersatz ändern…", command=self.satz_aendern)
daten.add_command(label="Firmendaten des Rechnungstools wählen…",
command=self.firma_waehlen)
daten.add_command(label="Gemeinsamer Ordner (Nummernbuch)…",
command=self.dlg_gemeinsam)
leiste.add_cascade(label="Daten", menu=daten)
self.config(menu=leiste)
@ -149,8 +161,7 @@ class App(tk.Tk):
self.blaetter.pack(fill="both", expand=True, padx=10, pady=(0, 6))
self.monate = {}
for m in range(1, 13):
tab = MonatsTab(self.blaetter, m, bei_doppelklick=self.buchung_bearbeiten,
bei_haken=self.papierkorb_auffrischen)
tab = MonatsTab(self.blaetter, m, bei_haken=self.papierkorb_auffrischen)
self.blaetter.add(tab, text=MONATE[m - 1])
self.monate[m] = tab
self.blaetter.select(min(date.today().month, 12) - 1)
@ -174,6 +185,7 @@ class App(tk.Tk):
# wenn es wirklich welche gibt - im Normalfall soll hier nichts stehen
# und auch kein leerer Streifen Platz wegnehmen.
self._doppelte = {}
self._aufgehoben = set()
self.doppelt_leiste = ttk.Frame(rahmen)
self.knopf_doppelte = ttk.Button(self.doppelt_leiste,
text="Doppelte Rechnungen anzeigen…",
@ -206,6 +218,9 @@ class App(tk.Tk):
# Ueber ALLE Buchungen des Jahres, nicht ueber die gefilterten: sonst
# versteckt die Suche den zweiten Beleg und die Dublette waere weg.
self._doppelte = doppelte_nummern(alle)
# Was ein Storno aufgehoben hat, steht weiter da - aber grau, und es
# zaehlt nicht mehr als offene Rechnung.
self._aufgehoben = aufgehobene(alle)
if self._doppelte:
# Gehoert ins Log: der Amtsbericht wird aus diesen Zeilen gebaut, und
# spaeter soll nachvollziehbar sein, wann der Fall zuerst dastand.
@ -214,7 +229,8 @@ class App(tk.Tk):
", ".join(f"{nr} ({len(v)}x)" for nr, v in self._doppelte.items()))
for m in range(1, 13):
self.monate[m].fuellen([b for b in sichtbar if b.monat == m], self._doppelte)
self.monate[m].fuellen([b for b in sichtbar if b.monat == m],
self._doppelte, self._aufgehoben)
# Reiter des Monats markieren, damit man die Dublette auch dann
# sieht, wenn gerade ein anderer Monat offen ist.
betroffen = any(b.voll_nummer in self._doppelte for b in alle if b.monat == m)
@ -245,6 +261,85 @@ class App(tk.Tk):
self.status.config(text=text)
log.info(text)
# ---- Gemeinsames Nummernbuch -------------------------------------------
def _buch_oeffnen(self):
ordner = self.journal.hole("gemeinsam_ordner", "")
if not ordner:
self.buch = None
return
taugt, grund = gemeinsam.ordner_taugt(ordner)
if not taugt:
self.buch = None
log.warning("Nummernbuch nicht erreichbar: %s", grund)
return
self.buch = gemeinsam.Nummernbuch(ordner)
def _frage_gemeinsam_falls_noetig(self):
"""Beim ersten Start nach dem gemeinsamen Ordner fragen.
Das Buch ist der Weg, auf dem das Rechnungstool die Nummern aus dem
Altbestand erfaehrt - zu denen es keine PDF gibt und die es sonst ein
zweites Mal vergeben wuerde.
"""
self._buch_oeffnen()
if self.buch is None and not TESTLAUF:
self.dlg_gemeinsam(erststart=True)
self._buch_fuellen()
def dlg_gemeinsam(self, erststart=False):
ordner = ordnerwahl.ordner_abfragen(self, "Steuerjournal",
self.journal.hole("gemeinsam_ordner", ""))
if not ordner:
if erststart:
messagebox.showwarning(
"Ohne gemeinsamen Ordner",
"Es wurde kein gemeinsamer Ordner gewählt.\n\n"
"Das Rechnungstool erfährt dann nicht, welche Nummern hier schon "
"vergeben sind - auch nicht die aus der alten Excel-Tabelle.\n\n"
"Nachholen: Daten → Gemeinsamer Ordner (Nummernbuch).", parent=self)
return
self.journal.setze("gemeinsam_ordner", ordner)
self._buch_oeffnen()
self._buch_fuellen(melden=True)
def _buch_fuellen(self, melden=False):
"""Alle Buchungen ins gemeinsame Buch schreiben.
Auch die aus der Excel-Mappe und die von Hand angelegten - genau die
kennt das Rechnungstool sonst nicht. Faellt das Buch aus (Netzlaufwerk
weg), passiert hier nichts weiter: das Journal arbeitet ohne es.
"""
if self.buch is None:
return
anzahl = 0
try:
for b in self.journal.buchungen():
self.buch.eintragen(
b.voll_nummer, jahr=b.jahr, datum=b.datum.isoformat(),
nachname=b.nachname, naechte=int(b.naechte or 0),
zwischensumme=float(b.gezahlt or 0), steuer_satz=float(b.satz or 0),
steuer_betrag=float(b.steuer or 0),
gesamt=float(b.gezahlt or 0) + float(b.steuer or 0),
art=b.art or "rechnung", storno_zu=b.storno_zu or "",
vorgang=b.vorgang or "", quelle="journal", herkunft=b.quelle,
pdf_pfad=b.pdf_pfad or "")
anzahl += 1
except Exception as e: # noqa: BLE001
log.warning("Nummernbuch: Schreiben fehlgeschlagen (%s)", e)
if melden:
messagebox.showwarning(
"Nummernbuch nicht erreichbar",
f"Die Nummern konnten nicht eingetragen werden:\n{e}", parent=self)
return
log.info("Nummernbuch: %d Buchungen eingetragen", anzahl)
if melden:
messagebox.showinfo(
"Nummernbuch",
f"{anzahl} Rechnungsnummern stehen jetzt im gemeinsamen Buch.\n\n"
"Wähle im Rechnungstool denselben Ordner - dann vergibt es keine "
"dieser Nummern noch einmal.", parent=self)
self.melde(f"Nummernbuch: {anzahl} Nummern eingetragen")
# ---- Doppelt vergebene Rechnungsnummern --------------------------------
def _doppelte_melden(self):
"""Warnleiste zeigen, solange es Dubletten gibt - sonst verschwindet sie."""
@ -260,8 +355,9 @@ class App(tk.Tk):
self.warnung.config(text=(
f"⚠ {len(self._doppelte)} Rechnungsnummer(n) doppelt vergeben: "
+ "; ".join(teile) + (f" … und {rest} weitere" if rest else "")
+ ". Die Beträge stehen trotzdem in allen Summen und im Amtsbericht – "
"hier wird nichts stillschweigend weggerechnet."))
+ ". Jede Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG) – eine "
"der beiden im Rechnungstool stornieren und mit freier Nummer neu "
"ausstellen. Bis dahin stehen die Beträge in allen Summen."))
self.doppelt_leiste.pack(fill="x", pady=(0, 4), before=self.fuss)
def doppelte_zeigen(self):
@ -277,11 +373,14 @@ class App(tk.Tk):
f"Jahr {self.jahr.get()}: diese Rechnungsnummern kommen mehrfach vor. "
"Jede Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG).\n\n"
"Alle Beträge sind in den Summen und im Bericht fürs Amt enthalten – hier wird "
"nichts weggerechnet. Zwei Zeilen zur selben Rechnung (z. B. einmal aus dem "
"Excel-Altbestand und einmal aus der PDF) melden die Übernachtung doppelt ans "
"Amt; zwei verschiedene Gäste unter einer Nummer sind ein Fehler im "
"Rechnungstool. Was zu tun ist, entscheidest du – das Programm ändert nichts "
"von allein.")).pack(anchor="w", pady=(0, 10))
"nichts weggerechnet.\n\n"
"SO WIRD ES GERADEGERÜCKT: eine der beiden Rechnungen im Rechnungstool "
"stornieren und mit einer freien Nummer neu ausstellen. Das Storno trägt "
"Nummer, Datum UND Empfänger der aufgehobenen Rechnung – bei einer doppelt "
"vergebenen Nummer wäre die Nummer allein kein eindeutiger Bezug, und "
"§ 31 Abs. 5 UStDV verlangt genau den. Danach steht die Zeile hier grau als "
"„storniert“ und die Warnung verschwindet von allein.")).pack(
anchor="w", pady=(0, 10))
innen = ttk.Frame(rahmen)
innen.pack(fill="both", expand=True)
@ -351,6 +450,8 @@ class App(tk.Tk):
self.config(cursor="")
self.aktualisieren()
# Was neu eingelesen wurde, gehoert auch ins gemeinsame Buch.
self._buch_fuellen()
text = f"{neu} neu, {akt} aktualisiert, {unveraendert} unverändert"
if fehler:
text += f", {len(fehler)} übersprungen"
@ -365,26 +466,13 @@ class App(tk.Tk):
f"{len(fehler)} Datei(en) sind keine lesbare Rechnung:\n\n{namen}",
parent=self)
# Es gibt bewusst KEIN "Buchung von Hand anlegen" mehr: jede Zeile im Journal
# gehoert zu einer Rechnung aus dem Rechnungstool. Wer eine Buchung braucht,
# schreibt die Rechnung - sonst meldet das Journal ans Amt, was in keinem
# Rechnungsordner steht. Bestehende Zeilen lassen sich weiter bearbeiten.
def buchung_bearbeiten(self, buchung):
d = BuchungDialog(self, buchung=buchung, satz=self.satz)
self.wait_window(d)
if d.loeschen:
self.journal.loeschen(buchung.id)
self.aktualisieren()
self.melde(f"Buchung {buchung.voll_nummer} gelöscht")
return
if d.ergebnis:
# Nummer/Jahr können geändert worden sein -> alte Zeile weicht der neuen
if d.ergebnis.schluessel != buchung.schluessel and buchung.id:
self.journal.loeschen(buchung.id)
self.journal.speichern(d.ergebnis)
self.aktualisieren()
self.melde(f"Buchung {d.ergebnis.voll_nummer} geändert")
# Im Journal wird NICHTS von Hand geaendert - weder angelegt noch bearbeitet.
# Jede Zeile gehoert zu einer Rechnung aus dem Rechnungstool; wer hier Betrag
# oder Nummer verstellt, meldet dem Amt etwas anderes, als auf dem Beleg
# steht, und beim naechsten Einlesen kaeme die Aenderung ohnehin zurueck.
# Stimmt eine Rechnung nicht, wird sie im Rechnungstool storniert oder
# berichtigt. Loeschen bleibt moeglich (Entf / Papierkorb) - es entfernt nur
# die Zeile aus dem Zwischenspeicher, die PDF bleibt liegen.
def _markierte(self):
return self.monate[self._monat].markierte()

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

160
gemeinsam.py Normal file
View file

@ -0,0 +1,160 @@
# -*- coding: utf-8 -*-
"""Das gemeinsame Nummernbuch von Rechnungstool und Steuerjournal.
WARUM ES DAS GIBT
-----------------
Bis hierher wusste jedes Programm nur, was in SEINEM Ordner lag. Das
Rechnungstool sucht die naechste freie Nummer in den PDFs im Rechnungsordner -
im Steuerjournal stehen aber auch Buchungen, zu denen es gar keine PDF gibt:
der Altbestand aus der Excel-Mappe und die frueher von Hand eingetragenen
Zeilen. Deren Nummern kannte das Rechnungstool nicht und hat sie ein zweites
Mal vergeben. Jede Rechnungsnummer darf es aber nur einmal geben
(Paragraph 14 Abs. 4 Nr. 4 UStG).
Deshalb fuehren beide Programme ab jetzt EIN gemeinsames Buch: eine
SQLite-Datei in einem Ordner, den beide kennen. Wer eine Nummer vergibt oder
eine Buchung anlegt, traegt sie dort ein; wer eine neue Nummer sucht, schaut
zuerst hier nach.
Die Datei ist ein REGISTER, keine zweite Buchhaltung: die Wahrheit bleiben die
Rechnungs-PDFs. Geht das Buch verloren, fehlt keine Rechnung - es muss nur neu
gefuellt werden (beide Programme tun das beim naechsten Einlesen von selbst).
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
hinueber - sonst schreiben zwei Fassungen in dieselbe Datei.
"""
import logging
import os
import sqlite3
log = logging.getLogger("gemeinsam")
DATEINAME = "campinghof-nummernbuch.sqlite3"
SCHEMA = """
CREATE TABLE IF NOT EXISTS nummern (
nummer TEXT PRIMARY KEY, -- '2026-442', immer die volle Nummer
jahr INTEGER NOT NULL DEFAULT 0,
datum TEXT NOT NULL DEFAULT '', -- ISO JJJJ-MM-TT
nachname TEXT NOT NULL DEFAULT '',
vorname TEXT NOT NULL DEFAULT '',
naechte INTEGER NOT NULL DEFAULT 0,
zwischensumme REAL NOT NULL DEFAULT 0,
steuer_satz REAL NOT NULL DEFAULT 0,
steuer_betrag REAL NOT NULL DEFAULT 0,
gesamt REAL NOT NULL DEFAULT 0,
art TEXT NOT NULL DEFAULT 'rechnung', -- rechnung|storno|korrektur|berichtigung
vorgang TEXT NOT NULL DEFAULT '', -- Klammer um Rechnung+Storno+Korrektur
storno_zu TEXT NOT NULL DEFAULT '', -- welche Rechnung hebt dieser Storno auf
ersetzt TEXT NOT NULL DEFAULT '', -- welche Rechnung ersetzt diese hier
berichtigt_zu TEXT NOT NULL DEFAULT '', -- zu welcher Rechnung gehoert das Blatt
vorlage TEXT NOT NULL DEFAULT '', -- '' oder 'montage'
quelle TEXT NOT NULL DEFAULT '', -- rechnungstool|journal
herkunft TEXT NOT NULL DEFAULT '', -- pdf|xlsx|manuell
pdf_pfad TEXT NOT NULL DEFAULT '',
geaendert TEXT NOT NULL DEFAULT '' -- ISO-Zeitstempel
);
CREATE INDEX IF NOT EXISTS idx_nummern_jahr ON nummern (jahr);
"""
FELDER = ("jahr", "datum", "nachname", "vorname", "naechte", "zwischensumme",
"steuer_satz", "steuer_betrag", "gesamt", "art", "vorgang", "storno_zu",
"ersetzt", "berichtigt_zu", "vorlage", "quelle", "herkunft", "pdf_pfad")
def pfad_zur_datei(ordner):
"""Vollstaendiger Pfad der gemeinsamen Datei in diesem Ordner."""
return os.path.join(ordner or "", DATEINAME)
def ordner_taugt(ordner):
"""Kann in diesem Ordner wirklich gearbeitet werden? Wird ausprobiert,
nicht geraten - ein nicht verbundenes Netzlaufwerk sieht sonst gut aus,
bis die erste Rechnung geschrieben wird."""
if not ordner or not os.path.isdir(ordner):
return False, "Den Ordner gibt es nicht."
probe = os.path.join(ordner, ".schreibprobe")
try:
with open(probe, "w", encoding="ascii") as f:
f.write("x")
os.remove(probe)
except OSError as e:
return False, f"In den Ordner kann nicht geschrieben werden ({e.strerror or e})."
return True, ""
class Nummernbuch:
"""Das gemeinsame Register. Wird zum Schreiben kurz geoeffnet und wieder
geschlossen - zwei Programme haben die Datei sonst dauerhaft in der Hand,
und auf einem Netzlaufwerk gibt das gesperrte Dateien."""
def __init__(self, ordner):
self.ordner = ordner
self.pfad = pfad_zur_datei(ordner)
def _verbindung(self):
con = sqlite3.connect(self.pfad, timeout=10)
con.row_factory = sqlite3.Row
con.executescript(SCHEMA)
return con
# ---- Lesen -------------------------------------------------------------
def nummern(self, jahr=None):
"""Alle vergebenen Nummern (als Menge), auf Wunsch nur eines Jahres."""
with self._verbindung() as con:
if jahr:
zeilen = con.execute("SELECT nummer FROM nummern WHERE jahr=?", (int(jahr),))
else:
zeilen = con.execute("SELECT nummer FROM nummern")
return {z["nummer"] for z in zeilen}
def eintrag(self, nummer):
with self._verbindung() as con:
z = con.execute("SELECT * FROM nummern WHERE nummer=?", (str(nummer),)).fetchone()
return dict(z) if z else None
def alle(self):
with self._verbindung() as con:
return [dict(z) for z in con.execute("SELECT * FROM nummern ORDER BY nummer")]
def belegt(self, nummer):
return self.eintrag(nummer) is not None
# ---- Schreiben ---------------------------------------------------------
def eintragen(self, nummer, **werte):
"""Nummer eintragen oder ihre Angaben auffrischen.
Eine schon eingetragene Nummer wird NICHT als Fehler behandelt: beide
Programme sehen dieselbe Rechnung (das Rechnungstool beim Schreiben,
das Journal beim Einlesen der PDF) und sollen sich gegenseitig
ergaenzen duerfen.
"""
import datetime
nummer = str(nummer or "").strip()
if not nummer:
return False
daten = {f: werte.get(f) for f in FELDER if werte.get(f) is not None}
daten["geaendert"] = datetime.datetime.now().isoformat(timespec="seconds")
spalten = ["nummer"] + list(daten)
platzhalter = ",".join("?" * len(spalten))
setzen = ",".join(f"{k}=excluded.{k}" for k in daten)
with self._verbindung() as con:
con.execute(
f"INSERT INTO nummern ({','.join(spalten)}) VALUES ({platzhalter}) "
f"ON CONFLICT(nummer) DO UPDATE SET {setzen}",
[nummer] + list(daten.values()))
log.info("Nummernbuch: %s eingetragen (%s)", nummer, daten.get("quelle", ""))
return True
def storniert(self):
"""Nummern, die durch einen Storno aufgehoben sind."""
with self._verbindung() as con:
return {z["storno_zu"] for z in
con.execute("SELECT storno_zu FROM nummern WHERE storno_zu <> ''")}
def entfernen(self, nummer):
"""Nur fuer den Rueckweg: ein verworfener Storno gibt seine Nummer zurueck."""
with self._verbindung() as con:
con.execute("DELETE FROM nummern WHERE nummer=?", (str(nummer),))
log.info("Nummernbuch: %s entfernt", nummer)

View file

@ -1,144 +0,0 @@
# -*- coding: utf-8 -*-
"""
Dialog zum Nachbessern einer Zeile, die schon im Journal steht.
Buchungen von Hand ANLEGEN gibt es bewusst nicht mehr: jede Zeile gehört zu
einer Rechnung aus dem Rechnungstool. Wer eine Buchung braucht, schreibt die
Rechnung - sonst meldet das Journal ans Amt, was in keinem Rechnungsordner
steht.
Schließen mit ESC, Speichern mit Enter oder Knopf.
"""
import tkinter as tk
from tkinter import ttk, messagebox
from datetime import datetime
from modell import (Buchung, STANDARD_SATZ, steuer_betrag, eur, zahl_aus_text,
zerlege_nummer)
class BuchungDialog(tk.Toplevel):
"""
Modal. Nach dem Schließen steht das Ergebnis in
.ergebnis – die gespeicherte Buchung (oder None, wenn abgebrochen)
.loeschen – True, wenn die Buchung gelöscht werden soll
"""
def __init__(self, master, buchung: Buchung, satz: float = STANDARD_SATZ):
super().__init__(master)
self.ergebnis = None
self.loeschen = False
self._vorlage = buchung
self._jahr = buchung.jahr
self.title("Buchung bearbeiten")
self.resizable(False, False)
self.transient(master)
self.grab_set()
rahmen = ttk.Frame(self, padding=14)
rahmen.grid(sticky="nsew")
self.felder = {}
zeilen = [
("datum", "Rechnungsdatum", buchung.datum.strftime("%d.%m.%Y")),
("nummer", "Rechnungsnummer", buchung.rechnungsnummer),
("nachname", "Nachname", buchung.nachname),
("naechte", "Übernachtungen", str(buchung.naechte)),
("gezahlt", "Gezahlt (Zwischensumme)", f"{buchung.gezahlt:.2f}".replace(".", ",")),
("satz", "Steuersatz %", f"{(buchung.satz or satz):g}".replace(".", ",")),
]
for i, (schluessel, beschriftung, wert) in enumerate(zeilen):
ttk.Label(rahmen, text=beschriftung + ":").grid(row=i, column=0, sticky="w", pady=3, padx=(0, 10))
e = ttk.Entry(rahmen, width=26)
e.insert(0, wert)
e.grid(row=i, column=1, sticky="ew", pady=3)
e.bind("<KeyRelease>", lambda _e: self._vorschau())
self.felder[schluessel] = e
self.vorschau = ttk.Label(rahmen, text="", foreground="black",
font=("Segoe UI", 10, "bold"), anchor="e")
self.vorschau.grid(row=len(zeilen), column=0, columnspan=2, sticky="ew", pady=(10, 4))
knoepfe = ttk.Frame(rahmen)
knoepfe.grid(row=len(zeilen) + 1, column=0, columnspan=2, sticky="ew", pady=(6, 0))
ttk.Button(knoepfe, text="Speichern", command=self._speichern) \
.pack(side="left", fill="x", expand=True)
ttk.Button(knoepfe, text="Löschen", command=self._loeschen) \
.pack(side="left", fill="x", expand=True, padx=(8, 0))
rahmen.columnconfigure(1, weight=1)
self.bind("<Escape>", lambda _e: self.destroy())
self.bind("<Return>", lambda _e: self._speichern())
self.felder["datum"].focus_set()
self._vorschau()
self.update_idletasks()
x = master.winfo_rootx() + (master.winfo_width() - self.winfo_width()) // 2
y = master.winfo_rooty() + 120
self.geometry(f"+{max(0, x)}+{max(0, y)}")
# ---- Eingaben lesen ----------------------------------------------------
def _werte(self):
gezahlt = zahl_aus_text(self.felder["gezahlt"].get())
satz = zahl_aus_text(self.felder["satz"].get())
return gezahlt, satz
def _vorschau(self):
gezahlt, satz = self._werte()
self.vorschau.config(text=f"Beherbergungssteuer: {eur(steuer_betrag(gezahlt, satz))}")
def _loeschen(self):
b = self._vorlage
hinweis = ("\n\nDie Rechnungs-PDF bleibt liegen – beim nächsten Einlesen "
"taucht die Buchung wieder auf.") if b.quelle == "pdf" else ""
if not messagebox.askyesno(
"Buchung löschen",
f"Rechnung {b.voll_nummer} ({b.nachname or 'ohne Namen'}) "
f"aus dem Journal löschen?{hinweis}", parent=self):
return
self.loeschen = True
self.destroy()
def _speichern(self):
text_datum = self.felder["datum"].get().strip()
datum = None
for fmt in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d"):
try:
datum = datetime.strptime(text_datum, fmt).date()
break
except ValueError:
continue
if not datum:
messagebox.showerror("Datum", f"Datum nicht lesbar: {text_datum}\n"
"Erwartet wird z.B. 12.07.2026", parent=self)
return
# "238" und "2026-238" werden beide angenommen
jahr, nummer = zerlege_nummer(self.felder["nummer"].get(), datum.year)
if not nummer:
messagebox.showerror("Rechnungsnummer", "Bitte eine Rechnungsnummer eintragen "
"(z.B. 238).", parent=self)
return
gezahlt, satz = self._werte()
if gezahlt <= 0:
messagebox.showerror("Gezahlt", "Bitte den Rechnungsbetrag (Zwischensumme) "
"eintragen.", parent=self)
return
self.ergebnis = Buchung(
datum=datum,
rechnungsnummer=nummer,
jahr=jahr,
nachname=self.felder["nachname"].get().strip(),
naechte=int(zahl_aus_text(self.felder["naechte"].get()) or 0),
gezahlt=gezahlt,
satz=satz,
quelle=self._vorlage.quelle,
pdf_pfad=self._vorlage.pdf_pfad,
id=self._vorlage.id,
)
self.destroy()

View file

@ -16,6 +16,7 @@ SPALTEN = [
("haken", HAKEN_AUS, 34, "center"), # zum Anhaken fürs Löschen
("datum", "Datum", 90, "center"),
("nummer", "Rechnung", 95, "center"),
("art", "Art", 132, "center"), # Storno zu ... / storniert / Neuausstellung
("nachname", "Nachname", 200, "w"),
("naechte", "Nächte", 65, "center"),
("gezahlt", "Gezahlt", 105, "e"),
@ -30,6 +31,7 @@ BG = "#eef1ee" # heller Streifen wie im PDF-Bericht
RAHMEN = "#c3ccc5"
GRUEN = "#1f5c3d" # Campinghof-Grün für den Steuerbetrag
WARNUNG = "#a00000" # doppelt vergebene Rechnungsnummer
GRAU = "#8a8a8a" # aufgehobene Buchung - steht da, zaehlt aber nicht mehr
class SummenLeiste(tk.Frame):
@ -87,10 +89,9 @@ class SummenLeiste(tk.Frame):
class MonatsTab(ttk.Frame):
"""Tabelle eines Monats. `bei_auswahl` wird mit der markierten Buchung gerufen."""
def __init__(self, master, monat: int, bei_doppelklick=None, bei_haken=None):
def __init__(self, master, monat: int, bei_haken=None):
super().__init__(master, padding=(8, 8, 8, 6))
self.monat = monat
self.bei_doppelklick = bei_doppelklick
self.bei_haken = bei_haken # meldet dem Fenster, dass sich die Auswahl änderte
self._buchungen = []
self._angehakt = set() # ids der angehakten Buchungen
@ -111,6 +112,9 @@ class MonatsTab(ttk.Frame):
self.baum.tag_configure(tag, foreground="black")
# Rot ist einzig für den Fehlerfall reserviert: die Nummer gibt es zweimal.
self.baum.tag_configure("doppelt", foreground=WARNUNG)
# Storno und die von ihm aufgehobene Rechnung: beide bleiben stehen
# (geloescht wird nichts), sie sind nur zurueckgenommen.
self.baum.tag_configure("aufgehoben", foreground=GRAU)
self.summen = SummenLeiste(self)
self.summen.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(8, 0))
@ -119,8 +123,8 @@ class MonatsTab(ttk.Frame):
self.rowconfigure(0, weight=1)
self.baum.bind("<Button-1>", self._klick)
if bei_doppelklick:
self.baum.bind("<Double-1>", lambda _e: self._doppelklick())
# Kein Doppelklick zum Bearbeiten: die Zahlen kommen aus der Rechnung,
# nicht aus diesem Fenster.
# ---- Anhaken -----------------------------------------------------------
def _klick(self, ereignis):
@ -148,11 +152,6 @@ class MonatsTab(ttk.Frame):
def haken_loesen(self):
self._angehakt.clear()
def _doppelklick(self):
b = self.markierte()
if b:
self.bei_doppelklick(b)
def markierte(self):
"""Aktuell markierte Buchung oder None."""
sel = self.baum.selection()
@ -168,23 +167,39 @@ class MonatsTab(ttk.Frame):
+ [schrift.measure("Nachname")])
self.baum.column("nachname", width=breit + 24, minwidth=90)
def fuellen(self, buchungen, doppelte=()):
def fuellen(self, buchungen, doppelte=(), aufgehoben=()):
"""Zeilen des Monats. `doppelte` sind die doppelt vergebenen Nummern des
ganzen JAHRES (aus modell.doppelte_nummern) - eine Nummer, deren Zwilling
in einem anderen Monat liegt, gehört hier trotzdem rot markiert."""
doppelte = set(doppelte)
aufgehoben = set(aufgehoben)
self._buchungen = sorted(buchungen, key=lambda b: (b.datum, b.rechnungsnummer))
# Haken nur für Zeilen behalten, die es noch gibt
self._angehakt &= {b.id for b in self._buchungen}
self._namensspalte_anpassen()
self.baum.delete(*self.baum.get_children())
for i, b in enumerate(self._buchungen):
zweimal = b.voll_nummer in doppelte
self.baum.insert("", "end", iid=str(i),
tags=("doppelt",) if zweimal else (b.quelle,), values=(
weg = b.id in aufgehoben or bool(b.storno_zu)
zweimal = b.voll_nummer in doppelte and not weg
if weg:
marke = ("aufgehoben",)
elif zweimal:
marke = ("doppelt",)
else:
marke = (b.quelle,)
if b.storno_zu:
art_text = f"Storno zu {b.storno_zu}"
elif b.id in aufgehoben:
art_text = "storniert"
elif b.art == "korrektur":
art_text = "Neuausstellung"
else:
art_text = ""
self.baum.insert("", "end", iid=str(i), tags=marke, values=(
HAKEN_AN if b.id in self._angehakt else HAKEN_AUS,
b.datum.strftime("%d.%m.%Y"),
("⚠ " + b.voll_nummer) if zweimal else b.voll_nummer,
art_text,
b.nachname,
b.naechte,
eur_kurz(b.gezahlt),

View file

@ -50,6 +50,13 @@ class Buchung:
quelle: str = "pdf" # pdf | xlsx | manuell
pdf_pfad: str = ""
id: int = 0
# Aus den Kenndaten der PDF: was fuer ein Beleg ist das, und worauf bezieht
# er sich. Ohne diese vier Felder ist eine Stornozeile im Journal nur eine
# Buchung mit Minusbetraegen - man sieht nicht, welche Rechnung sie aufhebt.
art: str = "rechnung" # rechnung | storno | korrektur
storno_zu: str = "" # Nummer der aufgehobenen Rechnung
storno_datum: str = "" # deren Datum (TT.MM.JJJJ) - Teil des Bezugs
vorgang: str = "" # Klammer um Rechnung, Storno und Neuausstellung
@property
def steuer(self) -> float:
@ -70,6 +77,36 @@ class Buchung:
return f"{self.jahr}-{self.rechnungsnummer}"
def aufgehobene(buchungen) -> set:
"""ids der Buchungen, die durch einen Storno aufgehoben sind.
Der Bezug ist Nummer + Datum + Nachname, nicht die Nummer allein. Genau
darum geht es hier: bei einer DOPPELT vergebenen Nummer waere die Nummer
kein eindeutiger Bezug - § 31 Abs. 5 UStDV verlangt aber, dass sich ein
berichtigendes Dokument "spezifisch und eindeutig" auf die Rechnung
bezieht. Steht auf dem Storno also 2026-020 vom 05.08. fuer Zweitgast, dann
ist die Rechnung von Erstgast NICHT aufgehoben.
"""
stornos = [b for b in buchungen if b.storno_zu]
if not stornos:
return set()
weg = set()
for s in stornos:
for b in buchungen:
if b.id in weg or b is s or not b.id:
continue
if b.voll_nummer != s.storno_zu:
continue
# Datum und Name muessen passen, WENN der Storno sie nennt.
if s.storno_datum and b.datum.strftime("%d.%m.%Y") != s.storno_datum:
continue
if s.nachname and b.nachname and s.nachname != b.nachname:
continue
weg.add(b.id)
break
return weg
def doppelte_nummern(buchungen) -> dict:
"""Rechnungsnummern, die mehr als einmal im Journal stehen.
@ -85,10 +122,15 @@ def doppelte_nummern(buchungen) -> dict:
nur einmal geben (Paragraph 14 Abs. 4 Nr. 4 UStG) - was zu tun ist,
entscheidet der Wirt, nicht das Programm.
"""
weg = aufgehobene(buchungen)
nach_nummer = {}
for b in buchungen:
if b.rechnungsnummer:
nach_nummer.setdefault(b.voll_nummer, []).append(b)
# Stornos tragen eine eigene Nummer und sind nie eine Dublette. Und was
# storniert wurde, ist keine offene Rechnung mehr - sonst bliebe die
# Meldung stehen, obwohl der Fall richtiggestellt ist.
if not b.rechnungsnummer or b.storno_zu or b.id in weg:
continue
nach_nummer.setdefault(b.voll_nummer, []).append(b)
return {nr: sorted(liste, key=lambda b: (b.datum, b.id))
for nr, liste in sorted(nach_nummer.items()) if len(liste) > 1}

183
ordnerwahl.py Normal file
View file

@ -0,0 +1,183 @@
# -*- coding: utf-8 -*-
"""Den gemeinsamen Ordner abfragen - in beiden Programmen gleich.
Rechnungstool und Steuerjournal fuehren ein gemeinsames Nummernbuch
(siehe gemeinsam.py). Damit sie sich finden, muss BEIDEN derselbe Ordner
genannt werden - deshalb fragt jedes Programm beim ersten Start danach und
sagt ausdruecklich dazu, dass es derselbe sein muss.
Der Weg ist bewusst umstaendlich: auswaehlen, dann OK, dann noch einmal
bestaetigen. Ein falscher Ordner faellt sonst erst auf, wenn eine
Rechnungsnummer zum zweiten Mal vergeben wurde.
Wer jetzt keinen Ordner hat, kommt mit "Spaeter einrichten" weiter - das
Programm laeuft dann ohne das gemeinsame Buch und sagt das in der Fussleiste.
⚠ Das Fenster bekommt seine Groesse ausdruecklich gesetzt. Ohne das war es zu
schmal, der Knopf zum Auswaehlen lag ausserhalb - und der Dialog war nicht zu
bedienen (gefunden am 03.09.2026, bevor es jemand benutzen musste).
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
hinueber.
"""
import os
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import gemeinsam
BREITE = 780
class OrdnerWahl(tk.Toplevel):
"""Modaler Dialog. Nach dem Schliessen steht das Ergebnis in `.ergebnis`
(gewaehlter Ordner) oder es ist None (spaeter/abgebrochen)."""
def __init__(self, master, programm: str, vorgabe: str = ""):
super().__init__(master)
self.ergebnis = None
self.title("Gemeinsamer Ordner für das Nummernbuch")
self.transient(master)
self.pfad = tk.StringVar(value=vorgabe or "")
rahmen = ttk.Frame(self, padding=18)
rahmen.pack(fill="both", expand=True)
rahmen.columnconfigure(0, weight=1)
breite = BREITE - 60
ttk.Label(rahmen, text="Bitte den gemeinsamen Ordner auswählen",
font=("Segoe UI", 13, "bold")).grid(row=0, column=0, sticky="w")
ttk.Label(rahmen, wraplength=breite, justify="left", text=(
"Rechnungstool und Steuerjournal führen zusammen ein Nummernbuch. Darin steht "
"jede vergebene Rechnungsnummer – auch die aus der alten Excel-Tabelle, zu der "
"es gar keine PDF gibt.\n\n"
"Ohne dieses Buch kennt jedes Programm nur seine eigenen Nummern und vergibt "
"eine Nummer ein zweites Mal. Jede Nummer darf es aber nur einmal geben "
"(§ 14 Abs. 4 Nr. 4 UStG).")).grid(row=1, column=0, sticky="w", pady=(8, 12))
tk.Label(rahmen, wraplength=breite, justify="left", fg="#a00000",
font=("Segoe UI", 10, "bold"),
text="WICHTIG: In BEIDEN Programmen muss derselbe Ordner stehen – am besten "
"der Ordner, in dem die Programme liegen.").grid(
row=2, column=0, sticky="w", pady=(0, 14))
wahl = ttk.Frame(rahmen)
wahl.grid(row=3, column=0, sticky="ew")
wahl.columnconfigure(0, weight=1)
self.feld = ttk.Entry(wahl, textvariable=self.pfad, state="readonly")
self.feld.grid(row=0, column=0, sticky="ew")
ttk.Button(wahl, text="Install-Ordner auswählen…", command=self.waehlen,
width=26).grid(row=0, column=1, sticky="w", padx=(10, 0))
self.stand = tk.Label(rahmen, text="", wraplength=breite, justify="left",
font=("Segoe UI", 9))
self.stand.grid(row=4, column=0, sticky="w", pady=(10, 0))
knoepfe = ttk.Frame(rahmen)
knoepfe.grid(row=5, column=0, sticky="ew", pady=(18, 0))
self.ok = ttk.Button(knoepfe, text="OK", command=self.uebernehmen, width=14)
self.ok.pack(side="right")
ttk.Button(knoepfe, text="Später einrichten", command=self.spaeter,
width=18).pack(side="left")
self._stand_zeigen()
self.protocol("WM_DELETE_WINDOW", self.spaeter)
self.bind("<Escape>", lambda _e: self.spaeter())
# Groesse ausdruecklich setzen: sonst schneidet Tk das Fenster an der
# zweiten Spalte ab und der Auswahl-Knopf ist nicht erreichbar.
self.update_idletasks()
hoehe = max(self.winfo_reqheight(), 300)
x = master.winfo_rootx() + max(0, (master.winfo_width() - BREITE) // 2)
y = master.winfo_rooty() + 90
self.geometry(f"{BREITE}x{hoehe}+{max(0, x)}+{max(0, y)}")
self.minsize(BREITE, hoehe)
self.grab_set()
self.lift()
self.focus_force()
# ------------------------------------------------------------------ Weg
def waehlen(self):
# Der Griff (grab_set) muss waehrend des Windows-Ordnerdialogs los sein:
# sonst kann der native Dialog hinter dem gesperrten Fenster landen und
# nimmt keine Eingabe an - man klickt und es passiert nichts.
try:
self.grab_release()
except tk.TclError:
pass
try:
ordner = filedialog.askdirectory(
title="Gemeinsamen Ordner wählen (in beiden Programmen derselbe!)",
initialdir=self.pfad.get() or os.path.expanduser("~"), parent=self)
finally:
try:
self.grab_set()
except tk.TclError:
pass
if ordner:
self.pfad.set(os.path.abspath(ordner))
self._stand_zeigen()
def _stand_zeigen(self):
"""Sagt, was in dem gewaehlten Ordner schon liegt.
Ein bereits vorhandenes Nummernbuch ist das beste Zeichen, dass es der
richtige Ordner ist - dann hat das andere Programm ihn schon.
"""
ordner = self.pfad.get().strip()
if not ordner:
self.stand.configure(text="Noch kein Ordner gewählt.", fg="#a00000")
self.ok.state(["disabled"])
return
taugt, grund = gemeinsam.ordner_taugt(ordner)
if not taugt:
self.stand.configure(text=grund, fg="#a00000")
self.ok.state(["disabled"])
return
self.ok.state(["!disabled"])
if os.path.exists(gemeinsam.pfad_zur_datei(ordner)):
try:
anzahl = len(gemeinsam.Nummernbuch(ordner).nummern())
except Exception: # noqa: BLE001
anzahl = "?"
self.stand.configure(
text=f"Hier liegt schon ein Nummernbuch mit {anzahl} Rechnungsnummern – "
"das ist der Ordner des anderen Programms. Genau richtig.",
fg="#1f5c3d")
else:
self.stand.configure(
text="Hier ist noch kein Nummernbuch – es wird neu angelegt. Wähle im "
"anderen Programm denselben Ordner.", fg="#444444")
def uebernehmen(self):
ordner = self.pfad.get().strip()
taugt, grund = gemeinsam.ordner_taugt(ordner)
if not taugt:
messagebox.showwarning("Ordner geht nicht", grund or "Bitte einen Ordner wählen.",
parent=self)
return
if not messagebox.askyesno(
"Sind Sie sicher?",
f"Gemeinsamer Ordner:\n\n{ordner}\n\n"
"Steht im anderen Programm derselbe Ordner? Nur dann kennen beide "
"dieselben Rechnungsnummern.\n\n"
"Ja = Ordner übernehmen\n"
"Nein = zurück zur Auswahl",
icon="question", default="yes", parent=self):
# Zurueck ins Auswahlfeld - der Dialog bleibt stehen, nichts ist gesetzt.
self.feld.focus_set()
return
self.ergebnis = ordner
self.destroy()
def spaeter(self):
self.ergebnis = None
self.destroy()
def ordner_abfragen(fenster, programm, vorgabe=""):
"""Dialog zeigen und den gewaehlten Ordner zurueckgeben (oder None)."""
d = OrdnerWahl(fenster, programm, vorgabe)
fenster.wait_window(d)
return d.ergebnis

View file

@ -177,6 +177,19 @@ def _aus_metadaten(pfad: str):
return daten
def berichtigung_daten(pfad: str):
"""Kenndaten eines Berichtigungsblatts (§ 31 Abs. 5 UStDV), sonst None.
Das Blatt bucht nichts - es reicht die Angaben zum Empfaenger nach. Genau
die will das Journal aber uebernehmen: steht auf der Rechnung noch der
falsche Name, muss der Amtsbericht den berichtigten zeigen.
"""
kopf = _aus_metadaten(pfad)
if not kopf or kopf.get("art") != "berichtigung":
return None
return kopf
def keine_buchung(pfad: str) -> bool:
"""True fuer Blaetter, die keine Uebernachtung abrechnen.
@ -185,8 +198,7 @@ def keine_buchung(pfad: str) -> bool:
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")
return berichtigung_daten(pfad) is not None
def parse_pdf(pfad: str, standard_satz: float = STANDARD_SATZ) -> Buchung:
@ -205,6 +217,10 @@ def parse_pdf(pfad: str, standard_satz: float = STANDARD_SATZ) -> Buchung:
satz=float(meta.get("steuer_satz") or 0) or standard_satz,
quelle="pdf",
pdf_pfad=os.path.abspath(pfad),
art=str(meta.get("art") or "rechnung"),
storno_zu=str(meta.get("storno_zu") or ""),
storno_datum=str(meta.get("storno_datum") or ""),
vorgang=str(meta.get("vorgang") or ""),
)
log.debug("aus Metadaten: %s | %s | %s | %s Nächte | %.2f €",
os.path.basename(pfad), b.voll_nummer, b.nachname, b.naechte, b.gezahlt)

View file

@ -94,12 +94,20 @@ pruefe(abs(s.steuer - 29.0) < 0.005, f"Steuer unveraendert 29,00 ({s.steuer})")
def zeilen(monat):
"""Was im Monatsblatt WIRKLICH steht: (Nachname, Nummerspalte, Tags)."""
"""Was im Monatsblatt WIRKLICH steht: (Nachname, Nummerspalte, Tags).
Die Spalten kommen aus gui_monat.SPALTEN - nicht abgezaehlt, sonst
verrutscht der Pruefstand still, sobald eine Spalte dazukommt (genau das
ist beim Einbau der Spalte "Art" passiert).
"""
from gui_monat import SPALTEN
namen = [sp[0] for sp in SPALTEN]
i_name, i_nr = namen.index("nachname"), namen.index("nummer")
baum = fenster.monate[monat].baum
aus = []
for iid in baum.get_children():
w = baum.item(iid, "values")
aus.append((w[3], w[2], tuple(baum.item(iid, "tags"))))
aus.append((w[i_name], w[i_nr], tuple(baum.item(iid, "tags"))))
return aus

View file

@ -68,7 +68,9 @@ for name in os.listdir(HIER):
def starte():
return subprocess.Popen([sys.executable, "app.py"], cwd=prog,
# --test: kein Erststart-Dialog nach dem gemeinsamen Ordner. Die Sperre
# selbst greift unabhaengig davon - genau die wird hier geprueft.
return subprocess.Popen([sys.executable, "app.py", "--test"], cwd=prog,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)

255
pruef_storno_journal.py Normal file
View file

@ -0,0 +1,255 @@
# -*- coding: utf-8 -*-
"""Pruefstand ueber BEIDE Programme: was das Rechnungstool storniert und
berichtigt, muss im Steuerjournal ankommen.
Der Weg ist echt: das Rechnungstool schreibt die PDFs (Rechnung, Storno,
berichtigte Rechnung, Berichtigungsblatt), danach liest das Journal denselben
Ordner ein. Geprueft wird, was am Ende ans Amt gemeldet wuerde.
Zwei Faelle, beide am 03.09.2026 an echten Belegen gefunden:
1. Der Storno kuerzte die BETRAEGE, aber nicht die UEBERNACHTUNGEN - aus
4 - 0 + 3 wurden 7 statt 3 gemeldeter Naechte.
2. Das Berichtigungsblatt wurde nur uebersprungen. Der berichtigte NAME kam
nie im Journal an, im Amtsbericht stand weiter der falsche.
"""
import json
import os
import shutil
import sys
import tempfile
import threading
TOOL = r"C:\claude\rechnungstool"
JOURNAL = r"C:\claude\beherbergungssteuer"
sys.path.insert(0, TOOL)
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)
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
os._exit(2)
_wecker = threading.Timer(150, _reissleine)
_wecker.daemon = True
_wecker.start()
tmp = tempfile.mkdtemp(prefix="storno_journal_")
ordner = os.path.join(tmp, "rechnungen")
os.makedirs(ordner)
import config # noqa: E402
shutil.copy(os.path.join(TOOL, "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
antworten = {"Fertig": False, "endgültig erstellen": True, "Hausnummer": True,
"Storno wirklich": True, "Berichtigte Rechnung erstellen": True,
"Berichtigung": True, "Storno erstellen": True}
def askyesno(titel, text, **kw):
for schlue, wert in antworten.items():
if schlue in titel:
return wert
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
messagebox.askyesno = askyesno
messagebox.showinfo = lambda *a, **k: None
messagebox.showwarning = lambda *a, **k: print(" [warnung]", a[0] if a else "")
messagebox.showerror = lambda *a, **k: print(" [fehler]", a[0] if a else "")
f = A.RechnungsApp()
f.cfg["output_dir"] = ordner
def kunde(vorname, nachname):
f.k_vorname.set(vorname)
f.k_name.set(nachname)
f.k_strasse.set("Musterweg 1")
f.k_plzort.set("12345 Musterstadt")
# ---- Fall 1: Rechnung -> Storno -> berichtigte Rechnung -------------------
kunde("Anna", "Beispiel")
f.r_anreise.set("01.08.2026")
f.r_abreise.set("05.08.2026") # 4 Naechte
erste_pos = sorted(f.anzahl_vars)[0]
f.anzahl_vars[erste_pos].set("4")
f.update()
f.pdf_erstellen()
offen = [d for d in BE.rechnungen_im_ordner(ordner) if d.get("art") != "storno"]
pruefe(len(offen) == 1, "eine stornierbare Rechnung")
f._storno_anlegen(offen[0], ordner)
storno_pdf = f.storno_stand["pfad"] if f.storno_stand else ""
m_storno = BE.kenndaten_lesen(storno_pdf) or {}
pruefe(str(m_storno.get("naechte")) == "-4",
f"der Storno traegt -4 Naechte in den Kenndaten ({m_storno.get('naechte')!r})")
f.r_abreise.set("04.08.2026") # richtig waren 3 Naechte
f.anzahl_vars[erste_pos].set("3")
f.update()
f.pdf_erstellen()
# ---- Fall 2: Rechnung mit falschem Namen -> Berichtigungsblatt ------------
f.reset_formular()
kunde("Bernd", "Falsch")
f.r_anreise.set("10.08.2026")
f.r_abreise.set("12.08.2026") # 2 Naechte
f.anzahl_vars[erste_pos].set("2")
f.update()
f.pdf_erstellen()
zu_berichtigen = [d for d in BE.rechnungen_im_ordner(ordner)
if d.get("nachname") == "Falsch"]
pruefe(len(zu_berichtigen) == 1, "die Rechnung mit dem falschen Namen ist da")
f._berichtigung_starten(zu_berichtigen[0], ordner)
f.k_name.set("Richtig") # der Gast heisst anders
f.update()
f.pdf_erstellen()
blaetter = [d for d in os.listdir(ordner) if d.startswith("Berichtigte")]
pruefe(len(blaetter) == 1, f"das Berichtigungsblatt liegt im Ordner ({blaetter})")
f.destroy()
print("--- was im Ordner liegt ---")
for d in sorted(os.listdir(ordner)):
m = BE.kenndaten_lesen(os.path.join(ordner, d)) or {}
print(f" {d:44s} art={str(m.get('art')):12s} nr={m.get('rechnungsnummer')} "
f"naechte={str(m.get('naechte')):>4} summe={m.get('zwischensumme')}")
# ---- Das Journal liest denselben Ordner ----------------------------------
sys.path.append(JOURNAL)
import db as BST # noqa: E402
from modell import summiere # noqa: E402
journal = BST.Journal(os.path.join(tmp, "journal.sqlite3"))
journal.scanne(ordner, 5.0, voll=True)
buchungen = journal.buchungen(jahr=2026)
print("--- was im Journal steht ---")
for b in buchungen:
print(f" {b.voll_nummer} {b.nachname:10s} {b.naechte:>3} Nächte "
f"{b.gezahlt:>8.2f} € Steuer {b.steuer:>6.2f} €")
s = summiere(buchungen)
# Fall 1: 4 - 4 + 3 = 3 Naechte, 52,00 - 52,00 + 39,00 = 39,00
# Fall 2: 2 Naechte, 26,00
pruefe(s.naechte == 5,
f"gemeldet werden 5 Übernachtungen (3 + 2), nicht 9 (ist {s.naechte})")
pruefe(abs(s.gezahlt - 65.0) < 0.005, f"Summe 65,00 € (39,00 + 26,00) (ist {s.gezahlt})")
pruefe(abs(s.steuer - 3.25) < 0.005, f"Steuer 3,25 € (ist {s.steuer})")
storno_zeilen = [b for b in buchungen if b.gezahlt < 0]
pruefe(len(storno_zeilen) == 1 and storno_zeilen[0].naechte == -4,
f"der Storno steht mit -4 Nächten im Journal ({[(b.voll_nummer, b.naechte) for b in storno_zeilen]})")
namen = {b.nachname for b in buchungen}
pruefe("Richtig" in namen and "Falsch" not in namen,
f"der berichtigte Name ist übernommen ({sorted(namen)})")
berichtigte = [b for b in buchungen if b.nachname == "Richtig"]
pruefe(len(berichtigte) == 1 and abs(berichtigte[0].gezahlt - 26.0) < 0.005,
f"und die Buchung selbst ist unverändert ({[(b.voll_nummer, b.gezahlt) for b in berichtigte]})")
pruefe(len(buchungen) == 4,
f"vier Zeilen: Rechnung, Storno, berichtigte Rechnung, zweite Rechnung ({len(buchungen)})")
# ---- Fall 3: DIESELBE Nummer auf zwei Namen, aufgeloest ------------------
# Sein Fall aus dem Altbestand (2026-331, 2026-333, 2026-347 stehen dort auf je
# zwei Namen). Beide PDFs werden mit dem Renderer geschrieben - so, wie sie das
# alte Rechnungstool geschrieben hat, das die Nummer noch ein zweites Mal
# vergab. Danach wird eine davon storniert und mit freier Nummer neu
# ausgestellt: § 14 Abs. 4 Nr. 4 UStG laesst jede Nummer nur einmal zu, und das
# Storno traegt Nummer, Datum UND Empfaenger - § 31 Abs. 5 UStDV verlangt einen
# "spezifischen und eindeutigen" Bezug, und die Nummer allein ist hier keiner.
import berechnung # noqa: E402
import pdf_renderer # noqa: E402
f2 = A.RechnungsApp()
f2.cfg["output_dir"] = ordner
pos_nr = sorted(f2.anzahl_vars)[0]
def alt_rechnung(datei, nummer, nachname, datum, naechte):
r = berechnung.berechne(f2.cfg["katalog"], {pos_nr: {"anzahl": naechte, "personen": 0}},
f2.cfg)
kopf = {"rechnungsnummer": nummer, "datum": datum, "anreise": datum, "abreise": "",
"naechte": str(naechte), "nachname": nachname, "vorname": "Carla",
"kunde_zeilen": ["", f"Carla {nachname}", "", "Musterweg 9", "12345 Musterstadt"],
"kunde": {"vorname": "Carla", "nachname": nachname, "strasse": "Musterweg 9",
"plzort": "12345 Musterstadt"},
"positionen": [{"nr": z.nr, "anzahl": z.anzahl, "personen": z.personen}
for z in r.zeilen]}
pdf_renderer.render_rechnung(os.path.join(ordner, datei), f2.cfg, r, kopf)
alt_rechnung("Rechnung_2026-900_ZwillingA.pdf", "2026-900", "ZwillingA", "20.08.2026", 5)
alt_rechnung("Rechnung_2026-900_ZwillingB.pdf", "2026-900", "ZwillingB", "22.08.2026", 3)
journal2 = BST.Journal(os.path.join(tmp, "journal2.sqlite3"))
journal2.scanne(ordner, 5.0, voll=True)
from modell import aufgehobene, doppelte_nummern # noqa: E402
d = doppelte_nummern(journal2.buchungen(jahr=2026))
pruefe("2026-900" in d, f"die doppelt vergebene Nummer wird gemeldet ({sorted(d)})")
pruefe(len(d.get("2026-900", [])) == 2,
f"mit beiden Gästen ({[b.nachname for b in d.get('2026-900', [])]})")
journal2.schliessen()
# ---- Die Dublette aufloesen: stornieren und neu ausstellen ----------------
zwilling = [m for m in BE.rechnungen_im_ordner(ordner) if m.get("nachname") == "ZwillingB"]
pruefe(len(zwilling) == 1, "die zweite Rechnung ist stornierbar")
f2._storno_anlegen(zwilling[0], ordner)
m_storno2 = BE.kenndaten_lesen(f2.storno_stand["pfad"]) if f2.storno_stand else {}
pruefe(m_storno2.get("storno_zu") == "2026-900" and m_storno2.get("storno_datum") == "22.08.2026",
f"das Storno nennt Nummer UND Datum ({m_storno2.get('storno_zu')}, {m_storno2.get('storno_datum')})")
pruefe(m_storno2.get("nachname") == "ZwillingB",
f"und den Empfänger - sonst kein eindeutiger Bezug ({m_storno2.get('nachname')!r})")
neue_nummer = f2.r_nummer.get()
pruefe(neue_nummer != "2026-900", f"die Neuausstellung bekommt eine freie Nummer ({neue_nummer})")
f2.update()
f2.pdf_erstellen() # berichtigte Rechnung mit neuer Nummer
f2.destroy()
journal3 = BST.Journal(os.path.join(tmp, "journal3.sqlite3"))
journal3.scanne(ordner, 5.0, voll=True)
neu_buchungen = journal3.buchungen(jahr=2026)
d2 = doppelte_nummern(neu_buchungen)
pruefe("2026-900" not in d2,
f"nach Storno + Neuausstellung ist die Nummer nicht mehr doppelt ({sorted(d2)})")
weg = aufgehobene(neu_buchungen)
storniert = [b for b in neu_buchungen if b.id in weg and b.voll_nummer == "2026-900"]
pruefe(len(storniert) == 1 and storniert[0].nachname == "ZwillingB",
f"genau die stornierte Rechnung ist aufgehoben ({[(b.voll_nummer, b.nachname) for b in storniert]})")
erst = [b for b in neu_buchungen if b.voll_nummer == "2026-900" and b.nachname == "ZwillingA"]
pruefe(len(erst) == 1 and erst[0].id not in weg,
"die andere Rechnung mit derselben Nummer bleibt unangetastet")
journal3.schliessen()
journal.schliessen()
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)