Doppelte Rechnungsnummern zeigen, nur ein Fenster, keine Handbuchungen

Doppelte Rechnungsnummern werden ANGEZEIGT statt still mitgeschleppt: rote
Zeile mit Warnzeichen, markierter Monatsreiter, rote Zeile unter der
Jahressumme und eine Liste aller Belege mit Dateinamen. Gerechnet wird ueber
das ganze Jahr - liegt die zweite Rechnung im naechsten Monat, sieht kein
Monat fuer sich eine Dublette, das Amt bekommt sie trotzdem doppelt. Die
Betraege bleiben in allen Summen und im Amtsbericht; gemeldet wird der Fund,
korrigiert wird nichts von allein.

Vorgeschichte: bis v1.2 war die Rechnungsnummer der Schluessel der Tabelle -
die zweite Rechnung ueberschrieb die erste still und fehlte im Amtsbericht.
v1.3 hat den Schluessel auf die PDF-Datei umgestellt, seitdem bleiben beide
Zeilen stehen. Erst jetzt sieht man den Fall auch.

Einzelinstanz aus dem Steuerrechner uebernommen (nur Mutex- und PID-Name
geaendert): zwei Fenster lesen denselben Rechnungsordner in dieselbe
journal.sqlite3 - was das eine loescht, steht im anderen noch da, und wer
dort "PDF fuers Amt" drueckt, meldet einen Stand, den es nicht mehr gibt.
Die Sperre greift vor ablage.daten_ordner() und nur beim echten Start.

"Neue Buchung" ist raus: jede Zeile gehoert zu einer Rechnung aus dem
Rechnungstool. Wer eine Buchung braucht, schreibt die Rechnung. Bestehende
Zeilen lassen sich weiter bearbeiten.

Pruefstaende: pruef_doppelte.py (27 Pruefungen am echten Fenster),
pruef_einzelinstanz.py (echte Prozesse). Beide gruen, dazu Abnahme mit den
Augen an Hauptfenster, Liste und Bearbeiten-Dialog.

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 19:47:44 +02:00
parent de71fce64f
commit 894bc81c46
8 changed files with 707 additions and 47 deletions

View file

@ -29,7 +29,11 @@ Knopfdruck die Excel-Mappe fürs Amt.
Eine Zeile per **Doppelklick** bearbeiten, mit **Entf** aus dem Journal löschen
(die PDF bleibt liegen und wird beim nächsten Einlesen wieder gefunden).
Barzahlungen ohne PDF gehen über **Neue Buchung**.
**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.
Der Briefkopf des Berichts (Firma, Logo, Bankzeile) wird aus der `config.json` des
Rechnungstools gelesen – also nur an einer Stelle gepflegt.
@ -48,6 +52,35 @@ Rechnungstools gelesen – also nur an einer Stelle gepflegt.
Weicht der in der PDF ausgewiesene Steuerbetrag von der eigenen Rechnung ab,
steht das als Warnung in `beherbergungssteuer.log`.
## Doppelt vergebene Rechnungsnummern
Jede Rechnungsnummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG). Kommt eine
Nummer trotzdem zweimal vor, **zeigt das Journal sie an, statt eine der beiden
verschwinden zu lassen**:
- die betroffenen Zeilen stehen **rot** und tragen ein ⚠ vor der Nummer,
- der **Monatsreiter** bekommt ebenfalls ein ⚠ – auch dann, wenn die zweite Rechnung
in einem anderen Monat liegt (gerechnet wird über das ganze Jahr),
- unter der Jahressumme steht eine rote Zeile mit dem Knopf **„Doppelte Rechnungen
anzeigen…"**: dort stehen alle Belege mit Datum, Gast, Betrag und **Dateiname**.
Die Beträge bleiben in allen Summen und im Bericht fürs Amt – es wird nichts
stillschweigend 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,
entscheidet der Wirt – das Programm ändert von allein nichts.
> Bis Version 1.2 war die Rechnungsnummer der Schlüssel der Datenbank: die zweite
> Rechnung hat die erste **still überschrieben** und fehlte danach in der Meldung ans
> Amt. Seit v1.3 ist der Schlüssel die PDF-Datei, seit v1.4 sieht man den Fall auch.
## Nur ein Fenster
Das Programm lässt sich **nicht zweimal öffnen**. Ein zweiter Doppelklick holt das
offene Fenster nach vorn. Grund: beide Fenster würden denselben Rechnungsordner in
dieselbe `journal.sqlite3` einlesen und danach verschiedene Stände zeigen – wer im
älteren Fenster auf „PDF fürs Amt" drückt, meldet einen Stand, den es nicht mehr gibt.
## Excel einlesen
`Datei → Excel-Tabelle einlesen…` zieht die bisherige `Beherbergungsteuer.xlsx` ins
@ -62,13 +95,18 @@ Tabelle grau dargestellt. Rechnungsnummern werden dabei vereinheitlicht
|-------|-------|
| `app.py` | Fenster, Menü, Aktionen |
| `gui_monat.py` | ein Monatsblatt (Tabelle + Summenzeile) |
| `gui_dialoge.py` | Buchung von Hand anlegen/bearbeiten |
| `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 |
| `db.py` | Journal (SQLite) + PDF-Scan mit Cache |
| `xlsx_io.py` | Excel-Import |
| `modell.py` | Datenmodell + Steuerrechnung (die einzige Rechenstelle) |
| `modell.py` | Datenmodell + Steuerrechnung + doppelte Nummern (die einzige Rechenstelle) |
| `ablage.py` | wo der Datenordner liegt (neben der EXE oder in LOCALAPPDATA) |
| `einzelinstanz.py` | Sperre gegen ein zweites Fenster |
| `pruef_doppelte.py` | Prüfstand: werden doppelte Nummern angezeigt? |
| `pruef_einzelinstanz.py` | Prüfstand: geht das Programm wirklich nur einmal auf? |
| `pruef_berichtigung.py` | Prüfstand: Storno/Berichtigung überschreiben nichts |
| `journal.sqlite3` | die Daten (wird beim 1. Start angelegt) |
| `berichte/` | die erzeugten Amts-PDFs |

156
app.py
View file

@ -17,13 +17,38 @@ from datetime import date
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
# Nur EIN Fenster. Zwei Instanzen lesen denselben Rechnungsordner in dieselbe
# journal.sqlite3: was das eine loescht, steht im anderen noch da, und der
# Bericht fuers Amt kaeme aus dem veralteten Fenster. Die Sperre steht VOR
# ablage.daten_ordner() - der legt beim ersten Start den Datenordner an und
# zieht alte Dateien um; das darf nicht zweimal gleichzeitig laufen.
# Nur beim echten Start: Pruefstaende importieren app als Modul und sollen
# nicht an der Sperre des laufenden Programms scheitern.
from einzelinstanz import (eigene_pid_merken, einzelinstanz_pruefen,
fenster_nach_vorn, laufende_pid_lesen)
_SPERRE = None
if __name__ == "__main__":
_SPERRE = einzelinstanz_pruefen()
if _SPERRE is False:
if not fenster_nach_vorn(laufende_pid_lesen()):
_wurzel = tk.Tk()
_wurzel.withdraw()
_wurzel.attributes("-topmost", True)
messagebox.showerror(
"Beherbergungssteuer läuft bereits",
"Das Steuerjournal ist schon geöffnet, das Fenster ließ sich aber "
"nicht nach vorn holen – bitte über die Taskleiste zurückwechseln.")
_wurzel.destroy()
sys.exit(0)
import db
import xlsx_io
import firma as firma_lib
import bericht_pdf
import ablage
from modell import MONATE, STANDARD_SATZ, summiere, eur
from gui_monat import MonatsTab, SummenLeiste
from modell import MONATE, STANDARD_SATZ, summiere, eur, eur_kurz, 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.
@ -88,7 +113,6 @@ class App(tk.Tk):
daten.add_command(label="Rechnungen einlesen (F5)", command=self.einlesen)
daten.add_command(label="Alle Rechnungen neu einlesen", command=lambda: self.einlesen(voll=True))
daten.add_separator()
daten.add_command(label="Buchung von Hand anlegen…", command=self.buchung_anlegen)
daten.add_command(label="Markierte Buchung löschen (Entf)", command=self.buchung_loeschen)
daten.add_separator()
daten.add_command(label="Steuersatz ändern…", command=self.satz_aendern)
@ -108,8 +132,7 @@ class App(tk.Tk):
self.jahr_wahl.bind("<<ComboboxSelected>>", lambda _e: self.jahr_gewechselt())
ttk.Button(leiste, text="Rechnungen einlesen", command=self.einlesen).pack(side="left")
ttk.Button(leiste, text="Neue Buchung", command=self.buchung_anlegen).pack(side="left", padx=6)
ttk.Button(leiste, text="PDF fürs Amt", command=self.bericht_monat).pack(side="left")
ttk.Button(leiste, text="PDF fürs Amt", command=self.bericht_monat).pack(side="left", padx=(6, 0))
self.papierkorb = ttk.Button(leiste, text="🗑 Löschen", state="disabled",
command=self.angehakte_loeschen)
@ -140,13 +163,26 @@ class App(tk.Tk):
self.jahr_summe = SummenLeiste(rahmen, mit_schnitt=True)
self.jahr_summe.pack(fill="x", pady=(2, 4))
fuss = ttk.Frame(rahmen)
fuss.pack(fill="x")
self.status = ttk.Label(fuss, text="", foreground="black")
self.fuss = ttk.Frame(rahmen)
self.fuss.pack(fill="x")
self.status = ttk.Label(self.fuss, text="", foreground="black")
self.status.pack(side="left")
self.ordner_anzeige = ttk.Label(fuss, text="", foreground="black")
self.ordner_anzeige = ttk.Label(self.fuss, text="", foreground="black")
self.ordner_anzeige.pack(side="right")
# Doppelt vergebene Rechnungsnummern. Die Leiste wird erst eingehaengt,
# wenn es wirklich welche gibt - im Normalfall soll hier nichts stehen
# und auch kein leerer Streifen Platz wegnehmen.
self._doppelte = {}
self.doppelt_leiste = ttk.Frame(rahmen)
self.knopf_doppelte = ttk.Button(self.doppelt_leiste,
text="Doppelte Rechnungen anzeigen…",
command=self.doppelte_zeigen)
self.knopf_doppelte.pack(side="right", padx=(10, 0))
self.warnung = tk.Label(self.doppelt_leiste, text="", fg=WARNUNG, anchor="w",
justify="left", wraplength=760, font=("Segoe UI", 9))
self.warnung.pack(side="left", fill="x", expand=True)
# ----------------------------------------------------------- Anzeige ----
def _jahre(self):
vorhanden = set(self.journal.jahre()) | {date.today().year, self.jahr.get()}
@ -167,8 +203,17 @@ class App(tk.Tk):
begriff = self.suche.get().strip().lower()
sichtbar = [b for b in alle if self._passt(b, begriff)] if begriff else alle
# 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)
for m in range(1, 13):
self.monate[m].fuellen([b for b in sichtbar if b.monat == m])
self.monate[m].fuellen([b for b in sichtbar if b.monat == m], self._doppelte)
# 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)
self.blaetter.tab(m - 1, text=("⚠ " if betroffen else "") + MONATE[m - 1])
self._doppelte_melden()
if begriff:
self.jahr_titel.config(text=f"Jahr {jahr} – Treffer für „{self.suche.get().strip()}“")
@ -194,6 +239,78 @@ class App(tk.Tk):
self.status.config(text=text)
log.info(text)
# ---- Doppelt vergebene Rechnungsnummern --------------------------------
def _doppelte_melden(self):
"""Warnleiste zeigen, solange es Dubletten gibt - sonst verschwindet sie."""
if not self._doppelte:
self.doppelt_leiste.pack_forget()
self.warnung.config(text="")
return
teile = []
for nr, liste in list(self._doppelte.items())[:3]:
namen = ", ".join(sorted({b.nachname or "?" for b in liste}))
teile.append(f"{nr} ({len(liste)}× – {namen})")
rest = len(self._doppelte) - len(teile)
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."))
self.doppelt_leiste.pack(fill="x", pady=(0, 4), before=self.fuss)
def doppelte_zeigen(self):
"""Alle Belege auflisten, die sich eine Rechnungsnummer teilen."""
fenster = tk.Toplevel(self)
fenster.title("Doppelt vergebene Rechnungsnummern")
fenster.transient(self)
fenster.geometry("940x520")
rahmen = ttk.Frame(fenster, padding=12)
rahmen.pack(fill="both", expand=True)
ttk.Label(rahmen, wraplength=900, justify="left", text=(
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))
innen = ttk.Frame(rahmen)
innen.pack(fill="both", expand=True)
innen.rowconfigure(0, weight=1)
innen.columnconfigure(0, weight=1)
senkrecht = ttk.Scrollbar(innen, orient="vertical")
senkrecht.grid(row=0, column=1, sticky="ns")
# Waagerecht, weil am Zeilenende der Dateiname steht - genau der sagt,
# welche der beiden Rechnungen die zweite ist.
waagerecht = ttk.Scrollbar(innen, orient="horizontal")
waagerecht.grid(row=1, column=0, sticky="ew")
text = tk.Text(innen, wrap="none", yscrollcommand=senkrecht.set,
xscrollcommand=waagerecht.set, relief="flat",
font=("Consolas", 9))
text.grid(row=0, column=0, sticky="nsew")
senkrecht.configure(command=text.yview)
waagerecht.configure(command=text.xview)
text.tag_configure("kopf", foreground=WARNUNG, font=("Consolas", 9, "bold"))
for nr, liste in self._doppelte.items():
text.insert("end", f"{nr} – {len(liste)} Belege\n", "kopf")
for b in liste:
datei = os.path.basename(b.pdf_pfad) if b.pdf_pfad else f"({b.quelle})"
text.insert("end",
f" {b.datum:%d.%m.%Y} {(b.nachname or '?'):22.22s} "
f"{b.naechte:>2} {'Nacht ' if b.naechte == 1 else 'Nächte'} "
f"{eur_kurz(b.gezahlt):>10s} "
f"Steuer {eur_kurz(b.steuer):>8s} {datei}\n")
text.insert("end", "\n")
text.configure(state="disabled")
ttk.Button(rahmen, text="Schließen", command=fenster.destroy).pack(side="right",
pady=(10, 0))
fenster.bind("<Escape>", lambda _e: fenster.destroy())
def jahr_gewechselt(self):
self.journal.setze("jahr", self.jahr.get())
self.aktualisieren()
@ -242,18 +359,12 @@ class App(tk.Tk):
f"{len(fehler)} Datei(en) sind keine lesbare Rechnung:\n\n{namen}",
parent=self)
def buchung_anlegen(self):
d = BuchungDialog(self, jahr=self.jahr.get(), satz=self.satz)
self.wait_window(d)
if d.ergebnis:
self.journal.speichern(d.ergebnis)
self.jahr.set(d.ergebnis.jahr)
self.aktualisieren()
self.blaetter.select(d.ergebnis.monat - 1)
self.melde(f"Buchung {d.ergebnis.voll_nummer} gespeichert")
# 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, jahr=buchung.jahr, satz=self.satz, buchung=buchung)
d = BuchungDialog(self, buchung=buchung, satz=self.satz)
self.wait_window(d)
if d.loeschen:
self.journal.loeschen(buchung.id)
@ -470,4 +581,7 @@ class App(tk.Tk):
if __name__ == "__main__":
log.info("Beherbergungssteuer startet (Python %s)", sys.version.split()[0])
# Die Sperre selbst greift schon ganz oben, vor dem Datenordner.
eigene_pid_merken()
App().mainloop()

139
einzelinstanz.py Normal file
View file

@ -0,0 +1,139 @@
# -*- coding: utf-8 -*-
"""Nur ein Fenster zulassen und das offene nach vorn holen.
Uebernommen aus dem Steuerrechner - dieselbe Sperre wie im Rechnungstool, nur
mit eigenem Namen. Hier geht es um das Journal: zwei Fenster lesen denselben
Rechnungsordner in dieselbe journal.sqlite3. Was das eine Fenster loescht,
steht im anderen noch auf dem Schirm, und wer dort auf "Bericht fuers Amt"
drueckt, meldet einen Stand, den es nicht mehr gibt. Der Jahresbericht ist
eine Steuermeldung - daran darf kein zweites Fenster mitschreiben.
"""
import os
import sys
def einzelinstanz_pruefen():
"""Sperrt das Programm gegen ein zweites Fenster.
Beide Fenster wuerden beim Start denselben Rechnungsordner in dieselbe
Datenbank einlesen und danach verschiedene Staende anzeigen. Darum: nur
eine Instanz je Windows-Sitzung.
Rueckgabe: Handle (offen halten, solange das Programm laeuft) oder
False, wenn schon eine Instanz laeuft.
"""
if not sys.platform.startswith("win"):
return None
import ctypes
from ctypes import wintypes
ERROR_ALREADY_EXISTS = 183
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
k32.CreateMutexW.argtypes = [wintypes.LPVOID, wintypes.BOOL, wintypes.LPCWSTR]
k32.CreateMutexW.restype = wintypes.HANDLE
handle = k32.CreateMutexW(None, False, "BeherbergungssteuerCampinghofBartl")
if ctypes.get_last_error() == ERROR_ALREADY_EXISTS:
return False
return handle
# Merkzettel mit der PID der laufenden Instanz - liegt neben der config.json.
def pid_datei():
"""Pfad der PID-Datei - bewusst OHNE ablage/Datenordner.
Sie liegt beim Benutzer (LOCALAPPDATA), nicht im Datenordner. Zwei Gruende:
1. Die Sperre muss greifen, BEVOR ablage.daten_ordner() laeuft: der legt
beim ersten Start den Datenordner an und zieht alte Dateien um. Zwei
Prozesse duerfen das nicht gleichzeitig tun.
2. Es geht um "laeuft dieses Programm schon fuer diesen Benutzer", nicht um
"in diesem Ordner". Zwei Kopien aus verschiedenen Ordnern sind
trotzdem zwei Fenster auf derselben Datenbank.
"""
heim = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
ordner = os.path.join(heim, "Beherbergungssteuer")
try:
os.makedirs(ordner, exist_ok=True)
except OSError:
pass
return os.path.join(ordner, "beherbergungssteuer.pid")
def eigene_pid_merken():
"""Schreibt die eigene PID, damit eine zweite Instanz das Fenster findet."""
try:
with open(pid_datei(), "w", encoding="ascii") as f:
f.write(str(os.getpid()))
except OSError:
pass
def laufende_pid_lesen():
"""PID der laufenden Instanz aus dem Merkzettel; None wenn unlesbar."""
try:
with open(pid_datei(), "r", encoding="ascii") as f:
return int(f.read().strip())
except (OSError, ValueError):
return None
def gehoert_uns(pid):
"""Laeuft unter dieser PID wirklich dieses Programm?
PIDs werden wiederverwendet. Steht in der alten Datei die Nummer eines
fremden Prozesses, holte das Programm sonst DESSEN Fenster nach vorn und
beendete sich wortlos - der Nutzer sieht einen Doppelklick, der nichts tut.
"""
if not pid or not sys.platform.startswith("win"):
return False
import ctypes
from ctypes import wintypes
PROCESS_QUERY_LIMITED = 0x1000
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
k32.OpenProcess.restype = wintypes.HANDLE
griff = k32.OpenProcess(PROCESS_QUERY_LIMITED, False, int(pid))
if not griff:
return False
try:
puffer = ctypes.create_unicode_buffer(4096)
laenge = wintypes.DWORD(len(puffer))
if not k32.QueryFullProcessImageNameW(griff, 0, puffer, ctypes.byref(laenge)):
return False
eigen = os.path.basename(sys.executable).lower()
fremd = os.path.basename(puffer.value).lower()
return eigen == fremd
finally:
k32.CloseHandle(griff)
def fenster_nach_vorn(pid):
"""Holt das Fenster des Prozesses <pid> nach vorn. True bei Erfolg.
Gesucht wird ueber die PID, nicht ueber den Fenstertitel - der Titel
traegt den Firmennamen und aendert sich mit ihm.
"""
if not pid or not sys.platform.startswith("win") or not gehoert_uns(pid):
return False
import ctypes
from ctypes import wintypes
SW_RESTORE = 9
u32 = ctypes.WinDLL("user32", use_last_error=True)
u32.IsWindowVisible.argtypes = [wintypes.HWND]
u32.IsIconic.argtypes = [wintypes.HWND]
u32.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int]
u32.SetForegroundWindow.argtypes = [wintypes.HWND]
u32.GetWindowTextLengthW.argtypes = [wintypes.HWND]
u32.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)]
treffer = []
def besuch(hwnd, _lparam):
fremd = wintypes.DWORD()
u32.GetWindowThreadProcessId(hwnd, ctypes.byref(fremd))
# nur das echte Hauptfenster: sichtbar und mit Titel
if fremd.value == pid and u32.IsWindowVisible(hwnd) and u32.GetWindowTextLengthW(hwnd):
treffer.append(hwnd)
return False
return True
CB = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
u32.EnumWindows(CB(besuch), 0)
if not treffer:
return False
hwnd = treffer[0]
if u32.IsIconic(hwnd):
u32.ShowWindow(hwnd, SW_RESTORE)
u32.SetForegroundWindow(hwnd)
return True

View file

@ -1,14 +1,18 @@
# -*- coding: utf-8 -*-
"""
Dialog für eine Buchung von Hand (z.B. Barzahlung ohne PDF-Rechnung)
und zum Nachbessern einer eingelesenen Zeile.
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, date
from datetime import datetime
from modell import (Buchung, STANDARD_SATZ, steuer_betrag, eur, zahl_aus_text,
zerlege_nummer)
@ -22,14 +26,14 @@ class BuchungDialog(tk.Toplevel):
.loeschen – True, wenn die Buchung gelöscht werden soll
"""
def __init__(self, master, jahr: int, satz: float = STANDARD_SATZ, buchung: Buchung = None):
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 if buchung else jahr
self._jahr = buchung.jahr
self.title("Buchung bearbeiten" if buchung else "Neue Buchung")
self.title("Buchung bearbeiten")
self.resizable(False, False)
self.transient(master)
self.grab_set()
@ -39,13 +43,12 @@ class BuchungDialog(tk.Toplevel):
self.felder = {}
zeilen = [
("datum", "Rechnungsdatum", (buchung.datum.strftime("%d.%m.%Y") if buchung
else date.today().strftime("%d.%m.%Y"))),
("nummer", "Rechnungsnummer", buchung.rechnungsnummer if buchung else ""),
("nachname", "Nachname", buchung.nachname if buchung else ""),
("naechte", "Übernachtungen", str(buchung.naechte) if buchung else "1"),
("gezahlt", "Gezahlt (Zwischensumme)", f"{buchung.gezahlt:.2f}".replace(".", ",") if buchung else ""),
("satz", "Steuersatz %", f"{(buchung.satz if buchung else satz):g}".replace(".", ",")),
("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))
@ -63,14 +66,13 @@ class BuchungDialog(tk.Toplevel):
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)
if buchung: # nur beim Bearbeiten – eine neue Buchung gibt es noch nicht zu löschen
ttk.Button(knoepfe, text="Löschen", command=self._loeschen) \
.pack(side="left", fill="x", expand=True, padx=(8, 0))
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" if buchung else "nummer"].focus_set()
self.felder["datum"].focus_set()
self._vorschau()
self.update_idletasks()
@ -135,8 +137,8 @@ class BuchungDialog(tk.Toplevel):
naechte=int(zahl_aus_text(self.felder["naechte"].get()) or 0),
gezahlt=gezahlt,
satz=satz,
quelle=self._vorlage.quelle if self._vorlage else "manuell",
pdf_pfad=self._vorlage.pdf_pfad if self._vorlage else "",
id=self._vorlage.id if self._vorlage else 0,
quelle=self._vorlage.quelle,
pdf_pfad=self._vorlage.pdf_pfad,
id=self._vorlage.id,
)
self.destroy()

View file

@ -29,6 +29,7 @@ QUELLE_TEXT = {"pdf": "PDF", "xlsx": "Excel", "manuell": "manuell"}
BG = "#eef1ee" # heller Streifen wie im PDF-Bericht
RAHMEN = "#c3ccc5"
GRUEN = "#1f5c3d" # Campinghof-Grün für den Steuerbetrag
WARNUNG = "#a00000" # doppelt vergebene Rechnungsnummer
class SummenLeiste(tk.Frame):
@ -108,6 +109,8 @@ class MonatsTab(ttk.Frame):
# Woher eine Buchung stammt, steht in der Spalte "Quelle" – nicht in der Schriftfarbe
for tag in ("pdf", "xlsx", "manuell"):
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)
self.summen = SummenLeiste(self)
self.summen.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(8, 0))
@ -165,17 +168,23 @@ class MonatsTab(ttk.Frame):
+ [schrift.measure("Nachname")])
self.baum.column("nachname", width=breit + 24, minwidth=90)
def fuellen(self, buchungen):
def fuellen(self, buchungen, doppelte=()):
"""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)
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):
self.baum.insert("", "end", iid=str(i), tags=(b.quelle,), values=(
zweimal = b.voll_nummer in doppelte
self.baum.insert("", "end", iid=str(i),
tags=("doppelt",) if zweimal else (b.quelle,), values=(
HAKEN_AN if b.id in self._angehakt else HAKEN_AUS,
b.datum.strftime("%d.%m.%Y"),
b.voll_nummer,
("⚠ " + b.voll_nummer) if zweimal else b.voll_nummer,
b.nachname,
b.naechte,
eur_kurz(b.gezahlt),

View file

@ -70,6 +70,29 @@ class Buchung:
return f"{self.jahr}-{self.rechnungsnummer}"
def doppelte_nummern(buchungen) -> dict:
"""Rechnungsnummern, die mehr als einmal im Journal stehen.
{"2026-020": [Buchung, Buchung]}
Gerechnet wird ueber das ganze JAHR, nicht je Monat: dieselbe Nummer kann
im August und im September liegen - dann sieht kein Monat fuer sich eine
Dublette, das Amt bekaeme die Uebernachtung trotzdem zweimal gemeldet.
Gemeldet wird der Fund, nichts wird stillschweigend weggerechnet: die
Betraege bleiben in allen Summen und im Amtsbericht, sonst stimmte das
Journal nicht mehr mit dem Rechnungsordner ueberein. Jede Nummer darf es
nur einmal geben (Paragraph 14 Abs. 4 Nr. 4 UStG) - was zu tun ist,
entscheidet der Wirt, nicht das Programm.
"""
nach_nummer = {}
for b in buchungen:
if b.rechnungsnummer:
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}
@dataclass
class Summe:
"""Aufsummierte Kennzahlen einer Menge von Buchungen (Monat oder Jahr)."""

186
pruef_doppelte.py Normal file
View file

@ -0,0 +1,186 @@
# -*- coding: utf-8 -*-
"""Pruefstand: zeigt das Steuerjournal doppelt vergebene Rechnungsnummern an?
Seit dem 03.09.2026 ist der Schluessel der Tabelle die PDF-Datei und nicht mehr
die Rechnungsnummer (siehe db._alte_nummern_sperre_loesen). Damit bleiben zwei
Rechnungen mit derselben Nummer beide stehen - was sie stillschweigend tun
wuerden, waere die Uebernachtung doppelt in der Meldung ans Amt. Genau deshalb
muss die Oberflaeche den Fund ZEIGEN.
Die Falle, die ein Test je Monat nicht faengt: dieselbe Nummer in ZWEI Monaten.
Kein Monat sieht fuer sich eine Dublette, das Jahr aber schon - deshalb wird
ueber das ganze Jahr gerechnet.
Geprueft wird am echten Fenster (App + MonatsTab), nicht nur am Rechenkern:
"rot faerben" ist eine Behauptung ueber die Anzeige.
"""
import os
import shutil
import sys
import tempfile
import threading
from datetime import date
HIER = r"C:\claude\beherbergungssteuer"
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)
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
os._exit(2)
_wecker = threading.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
# ---- eigener Datenordner, damit das echte Journal unberuehrt bleibt --------
tmp = tempfile.mkdtemp(prefix="bst_doppelt_")
import ablage # noqa: E402
ablage.daten_ordner = lambda: tmp
import app as app_mod # noqa: E402 (liest ablage.daten_ordner beim Import)
from modell import Buchung, doppelte_nummern, summiere # noqa: E402
pruefe(app_mod.BASE_DIR == tmp, f"Pruefstand laeuft im Testordner ({app_mod.BASE_DIR})")
# ---- Buchungen: der Fall, den das Amt nicht sehen darf ---------------------
JAHR = 2026
BUCHUNGEN = [
# zwei verschiedene Gaeste unter EINER Nummer, im selben Monat
Buchung(date(JAHR, 8, 1), "020", JAHR, "Erst", 2, 100.0, quelle="manuell"),
Buchung(date(JAHR, 8, 5), "020", JAHR, "Zweit", 4, 200.0, quelle="manuell"),
# dieselbe Nummer, aber ueber die Monatsgrenze hinweg - die eigentliche Falle
Buchung(date(JAHR, 8, 20), "030", JAHR, "August", 1, 50.0, quelle="manuell"),
Buchung(date(JAHR, 9, 2), "030", JAHR, "September", 3, 150.0, quelle="manuell"),
# und eine saubere Rechnung, die nicht angefasst werden darf
Buchung(date(JAHR, 8, 9), "021", JAHR, "Sauber", 2, 80.0, quelle="manuell"),
]
fenster = app_mod.App()
fenster.withdraw() # kein Fenster ins Gesicht, gerechnet wird trotzdem
for b in BUCHUNGEN:
fenster.journal.speichern(b)
fenster.jahr.set(JAHR)
fenster.aktualisieren()
fenster.update_idletasks()
alle = fenster.journal.buchungen(jahr=JAHR)
pruefe(len(alle) == len(BUCHUNGEN), f"alle {len(BUCHUNGEN)} Buchungen stehen im Journal ({len(alle)})")
# ---- 1. Rechenkern ---------------------------------------------------------
d = doppelte_nummern(alle)
pruefe(set(d) == {"2026-020", "2026-030"}, f"genau die zwei Dubletten gefunden ({sorted(d)})")
pruefe(all(len(v) == 2 for v in d.values()), "je zwei Belege pro Nummer")
pruefe("2026-021" not in d, "die saubere Rechnung wird nicht gemeldet")
pruefe(not doppelte_nummern([b for b in alle if b.rechnungsnummer == "021"]),
"ohne Dubletten bleibt die Meldung leer")
# ---- 2. Die Betraege bleiben vollstaendig ---------------------------------
s = summiere(alle)
pruefe(abs(s.gezahlt - 580.0) < 0.005, f"Jahressumme unveraendert 580,00 ({s.gezahlt})")
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)."""
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"))))
return aus
# ---- 3. Anzeige: rot + Warnzeichen ----------------------------------------
aug = zeilen(8)
pruefe(len(aug) == 4, f"August zeigt vier Zeilen ({len(aug)})")
rot_aug = {name for name, _nr, tags in aug if "doppelt" in tags}
pruefe(rot_aug == {"Erst", "Zweit", "August"}, f"im August sind die richtigen rot ({sorted(rot_aug)})")
pruefe(all(nr.startswith("\u26a0") for name, nr, tags in aug if "doppelt" in tags),
"die roten Zeilen tragen das Warnzeichen vor der Nummer")
sauber = [z for z in aug if z[0] == "Sauber"][0]
pruefe("doppelt" not in sauber[2] and not sauber[1].startswith("\u26a0"),
f"die saubere Rechnung bleibt schwarz und ohne Zeichen ({sauber})")
# ---- 4. Die Falle: dieselbe Nummer in zwei Monaten ------------------------
sep = zeilen(9)
pruefe(len(sep) == 1 and "doppelt" in sep[0][2],
f"September markiert seine Haelfte der Dublette ({sep})")
titel = [fenster.blaetter.tab(m - 1, "text") for m in range(1, 13)]
pruefe(titel[7].startswith("\u26a0") and titel[8].startswith("\u26a0"),
f"beide Reiter sind markiert ({titel[7]!r}, {titel[8]!r})")
pruefe(not any(t.startswith("\u26a0") for i, t in enumerate(titel) if i not in (7, 8)),
f"kein anderer Reiter ist markiert ({[t for t in titel if t.startswith(chr(0x26a0))]})")
# ---- 5. Die Warnleiste --------------------------------------------------
pruefe(bool(fenster.doppelt_leiste.winfo_manager()), "die Warnleiste ist eingeblendet")
text = fenster.warnung.cget("text")
pruefe("2026-020" in text and "2026-030" in text, f"sie nennt beide Nummern ({text[:70]}…)")
pruefe("Erst" in text and "Zweit" in text, "und die betroffenen Gaeste")
# ---- 6. Die Suche darf die Dublette nicht verstecken ---------------------
fenster.suche.set("Zweit")
fenster.update_idletasks()
gefiltert = zeilen(8)
pruefe(len(gefiltert) == 1 and "doppelt" in gefiltert[0][2],
f"auch im Suchtreffer bleibt die Zeile rot ({gefiltert})")
fenster.suche.set("")
fenster.update_idletasks()
# ---- 7. Das Fenster mit der Liste ---------------------------------------
vorher = set(fenster.winfo_children())
fenster.doppelte_zeigen()
fenster.update_idletasks()
neu = [w for w in fenster.winfo_children() if w not in vorher]
pruefe(len(neu) == 1, f"die Liste geht auf ({len(neu)} Fenster)")
if neu:
inhalt = ""
def sammeln(w):
global inhalt
import tkinter as tk
if isinstance(w, tk.Text):
inhalt += w.get("1.0", "end")
for k in w.winfo_children():
sammeln(k)
sammeln(neu[0])
pruefe("2026-020" in inhalt and "2026-030" in inhalt, "sie listet beide Nummern")
pruefe(all(n in inhalt for n in ("Erst", "Zweit", "August", "September")),
"mit allen vier Belegen")
pruefe("Sauber" not in inhalt, "und nicht die saubere Rechnung")
neu[0].destroy()
# ---- 8. Dublette aufloesen -> die Warnung muss verschwinden -------------
weg = [b for b in alle if b.nachname == "Zweit"][0]
fenster.journal.loeschen(weg.id)
weg2 = [b for b in alle if b.nachname == "September"][0]
fenster.journal.loeschen(weg2.id)
fenster.aktualisieren()
fenster.update_idletasks()
pruefe(not fenster._doppelte, f"keine Dubletten mehr ({sorted(fenster._doppelte)})")
pruefe(not fenster.doppelt_leiste.winfo_manager(), "die Warnleiste ist wieder weg")
pruefe(not any("doppelt" in t for _n, _nr, t in zeilen(8)), "keine rote Zeile mehr im August")
titel = [fenster.blaetter.tab(m - 1, "text") for m in range(1, 13)]
pruefe(not any(t.startswith("\u26a0") for t in titel), "und kein markierter Reiter mehr")
fenster.journal.schliessen()
fenster.destroy()
if not fehler:
shutil.rmtree(tmp, ignore_errors=True)
else:
print(f" (Testordner bleibt zum Nachsehen: {tmp})")
print("---")
print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")
sys.exit(1 if fehler else 0)

149
pruef_einzelinstanz.py Normal file
View file

@ -0,0 +1,149 @@
# -*- coding: utf-8 -*-
"""Pruefstand: geht das Steuerjournal wirklich nur EINMAL auf?
Zwei Fenster lesen denselben Rechnungsordner in dieselbe journal.sqlite3. Was
das eine loescht, steht im anderen noch auf dem Schirm - und wer dort auf
"Bericht fuers Amt" drueckt, meldet einen Stand, den es nicht mehr gibt.
Geprueft wird mit ECHTEN Prozessen, nicht mit einem nachgebauten Aufruf: nur so
zeigt sich, ob die Sperre wirklich vor dem Datenordner greift und ob sie sich
beim Beenden auch wieder loest. Das Programm laeuft dabei aus einer Kopie im
Temp-Ordner - die Sperre gilt trotzdem (sie haengt am Namen, nicht am Ordner),
das echte Journal wird nicht angefasst.
Waehrend des Laufs blitzt kurz ein Fenster auf. Das gehoert dazu.
"""
import os
import shutil
import subprocess
import sys
import tempfile
import threading
import time
HIER = r"C:\claude\beherbergungssteuer"
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)
laeufer = []
def _reissleine():
for p in laeufer:
try:
p.kill()
except Exception: # noqa: BLE001
pass
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog, "
"auf den niemand klickt")
os._exit(2)
_wecker = threading.Timer(150, _reissleine)
_wecker.daemon = True
_wecker.start()
from einzelinstanz import pid_datei # noqa: E402
if os.path.exists(pid_datei()):
print(f" (Merkzettel: {pid_datei()})")
# ---- Kopie des Programms, damit das echte Journal unberuehrt bleibt --------
tmp = tempfile.mkdtemp(prefix="bst_instanz_")
prog = os.path.join(tmp, "programm")
os.makedirs(prog)
for name in os.listdir(HIER):
if name.endswith(".py"):
shutil.copy(os.path.join(HIER, name), os.path.join(prog, name))
def starte():
return subprocess.Popen([sys.executable, "app.py"], cwd=prog,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
def warte_auf_ende(p, sekunden):
"""Wartet, bis der Prozess von selbst geht. None = er laeuft noch."""
ende = time.time() + sekunden
while time.time() < ende:
if p.poll() is not None:
return p.returncode
time.sleep(0.2)
return None
# ---- 1. Das erste Fenster ------------------------------------------------
erst = starte()
laeufer.append(erst)
time.sleep(6) # Tk braucht einen Moment, bis das Fenster wirklich steht
pruefe(erst.poll() is None, f"das erste Fenster laeuft (Rueckgabe {erst.poll()})")
merkzettel = None
try:
with open(pid_datei(), encoding="ascii") as f:
merkzettel = int(f.read().strip())
except (OSError, ValueError):
pass
pruefe(merkzettel == erst.pid,
f"der Merkzettel nennt genau diesen Prozess ({merkzettel} / {erst.pid})")
# ---- 2. Der zweite Doppelklick -------------------------------------------
zweit = starte()
laeufer.append(zweit)
code = warte_auf_ende(zweit, 25)
if code is None:
zweit.kill()
pruefe(code == 0, f"der zweite Start beendet sich von selbst (Rueckgabe {code})")
pruefe(erst.poll() is None, "und das erste Fenster laeuft dabei weiter")
ausgabe = ""
try:
ausgabe = (zweit.stdout.read() or b"").decode("utf-8", "replace")
except Exception: # noqa: BLE001
pass
pruefe("Traceback" not in ausgabe, f"ohne Absturz im zweiten Prozess ({ausgabe[-200:]!r})")
# Die Sperre steht VOR dem Datenordner: der zweite Prozess darf gar nicht erst
# so weit kommen, dass er das Journal oeffnet.
pruefe("Journal" not in ausgabe,
f"der zweite Prozess fasst das Journal nicht an ({ausgabe[-200:]!r})")
# ---- 3. Sperre loest sich beim Beenden ------------------------------------
erst.terminate()
warte_auf_ende(erst, 15)
pruefe(erst.poll() is not None, "das erste Fenster laesst sich beenden")
time.sleep(1.5)
dritt = starte()
laeufer.append(dritt)
code = warte_auf_ende(dritt, 8)
pruefe(code is None, f"danach geht das Programm wieder auf (Rueckgabe {code})")
dritt.terminate()
warte_auf_ende(dritt, 10)
if dritt.poll() is None:
dritt.kill()
for p in laeufer:
if p.poll() is None:
p.kill()
try:
p.stdout.close()
except Exception: # noqa: BLE001
pass
if not fehler:
shutil.rmtree(tmp, ignore_errors=True)
else:
print(f" (Kopie bleibt zum Nachsehen: {tmp})")
print("---")
print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")
sys.exit(1 if fehler else 0)