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

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