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

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()