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
139 lines
5.4 KiB
Python
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
|