Steuerrechnungstool/einzelinstanz.py
TheMockTv 894bc81c46 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
2026-09-03 19:47:44 +02:00

139 lines
5.4 KiB
Python

# -*- 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