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:
parent
de71fce64f
commit
894bc81c46
8 changed files with 707 additions and 47 deletions
44
README.md
44
README.md
|
|
@ -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
156
app.py
|
|
@ -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
139
einzelinstanz.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
15
gui_monat.py
15
gui_monat.py
|
|
@ -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),
|
||||
|
|
|
|||
23
modell.py
23
modell.py
|
|
@ -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
186
pruef_doppelte.py
Normal 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
149
pruef_einzelinstanz.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue