Das Rechnungstool meldet dem Steuerjournal, was es geschrieben hat

Beide Programme stehen nebeneinander offen. Bisher haette das Journal die neue
Rechnung erst beim naechsten Einlesen gesehen.

Kein Absuchen des Rechnungsordners und kein Server: nach jeder Rechnung und
jedem Storno schaut das Rechnungstool nach, ob das Journal ueberhaupt laeuft -
ueber dessen PID, nicht ueber ein Fenster und nicht ueber eine Uhrzeit. Windows
beantwortet das genau; PIDs werden wiederverwendet, deshalb wird zusaetzlich der
Name der EXE verglichen. Laeuft es, kommt eine Zeile in den Briefkasten.

Der Briefkasten IST die gemeinsame SQLite (seine Ansage): zwei Tabellen mehr -
"laeuft" (wer ist offen, mit PID und EXE) und "nachrichten". Damit liegt im
gemeinsamen Ordner genau EINE Datei. Karteileichen (abgestuerztes Programm)
werden beim Nachsehen weggeraeumt, alte Meldungen beim Melden.

Ist das Journal zu, passiert nichts - es liest den Ordner beim naechsten Start
ohnehin. Die Meldung ist eine Anklopfhilfe, keine Datenquelle.

Pruefstand pruef_wache.py (im Steuerjournal, faehrt BEIDE Programme): PID-Pruefung
mit toter Nummer und fremdem EXE-Namen, genau eine Datei im Ordner, Meldung nach
Rechnung und nach Storno, Einlesen samt Anzeige, Ruhe ohne Meldung, kein Melden
an ein geschlossenes Journal.

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 21:02:11 +02:00
parent ecad4b00ea
commit 8163213960
3 changed files with 180 additions and 0 deletions

24
app.py
View file

@ -635,6 +635,29 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
f"Nummernbuch eingetragen werden:\n{e}\n\n"
"Das Steuerjournal sieht sie trotzdem - es liest die PDFs.")
def _journal_benachrichtigen(self, text):
"""Dem laufenden Steuerjournal Bescheid sagen.
Erst nachsehen, ob es ueberhaupt offen ist (es legt einen Herzschlag im
gemeinsamen Ordner ab), dann eine Zeile in den Briefkasten. Das Journal
liest die Rechnung daraufhin von selbst ein.
Ist es zu, passiert nichts - es liest den Ordner beim naechsten Start
ohnehin. Die Nachricht ist eine Anklopfhilfe, keine Datenquelle.
"""
if self.buch is None:
return
try:
if not self.buch.laeuft("journal"):
print("[melden] Steuerjournal laeuft nicht - es liest die Rechnung "
"beim naechsten Start")
return
self.buch.melden("rechnungstool", text)
self.buch.aufraeumen()
print(f"[melden] ans Steuerjournal: {text}")
except Exception as e: # noqa: BLE001 - die Rechnung ist schon geschrieben
print("[melden] fehlgeschlagen:", e)
def _nachtragen(self):
"""Alles, was schon im Rechnungsordner liegt, ins Buch schreiben.
@ -1020,6 +1043,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
# auch wenn es die PDF (noch) nicht eingelesen hat.
self._ins_buch(nummer, kopf, r, pfad)
self._buch_anzeigen()
self._journal_benachrichtigen(f"Rechnung {nummer} erstellt")
# Rechnungsnummer-Zaehler hochsetzen, falls Format JJJJ-NNN
self._zaehler_aus_nummer_speichern(nummer)

View file

@ -27,6 +27,8 @@ hinueber - sonst schreiben zwei Fassungen in dieselbe Datei.
import logging
import os
import sqlite3
import sys
import time
log = logging.getLogger("gemeinsam")
@ -56,6 +58,26 @@ CREATE TABLE IF NOT EXISTS nummern (
geaendert TEXT NOT NULL DEFAULT '' -- ISO-Zeitstempel
);
CREATE INDEX IF NOT EXISTS idx_nummern_jahr ON nummern (jahr);
-- Wer gerade laeuft. Eine Zeile je Programm, mit PID und EXE-Namen: ob es
-- wirklich noch laeuft, beantwortet Windows (prozess_laeuft), nicht ein
-- Zeitstempel. Ein abgestuerztes Programm hinterlaesst hier nur eine
-- Karteileiche, die beim naechsten Nachsehen weggeraeumt wird.
CREATE TABLE IF NOT EXISTS laeuft (
programm TEXT PRIMARY KEY, -- 'journal' | 'rechnungstool'
pid INTEGER NOT NULL DEFAULT 0,
exe TEXT NOT NULL DEFAULT '',
seit TEXT NOT NULL DEFAULT ''
);
-- Der Briefkasten. Das Rechnungstool legt hier ab, dass es eine Rechnung
-- geschrieben hat; das Journal liest die neuen Zeilen und liest die PDFs nach.
CREATE TABLE IF NOT EXISTS nachrichten (
id INTEGER PRIMARY KEY AUTOINCREMENT,
zeit TEXT NOT NULL DEFAULT '',
absender TEXT NOT NULL DEFAULT '',
text TEXT NOT NULL DEFAULT ''
);
"""
FELDER = ("jahr", "datum", "nachname", "vorname", "naechte", "zwischensumme",
@ -84,6 +106,78 @@ def ordner_taugt(ordner):
return True, ""
# ---------------------------------------------------------------------------
# Briefkasten und Herzschlag: das Rechnungstool sagt dem Steuerjournal Bescheid
#
# Kein Programm durchsucht dauernd den Rechnungsordner. Stattdessen legt das
# LAUFENDE Steuerjournal einen Herzschlag im gemeinsamen Ordner ab, und das
# Rechnungstool schaut nach dem Schreiben einer Rechnung nur kurz hinein: laeuft
# es, wirft es eine Nachricht in den Briefkasten. Das Journal sieht genau EINE
# Datei nach (os.stat) und liest die Rechnung dann von selbst ein.
#
# Ist das Journal zu, passiert nichts - es liest den Ordner beim naechsten Start
# ohnehin komplett. Die Nachricht ist eine Anklopfhilfe, keine Datenquelle.
def prozess_laeuft(pid, name=""):
"""Laeuft unter dieser Prozessnummer wirklich noch etwas?
Die Antwort kommt von Windows, nicht aus einer Uhrzeit und nicht aus einer
Fenstersuche. PIDs werden wiederverwendet - deshalb wird zusaetzlich der
Name der EXE verglichen, wenn einer eingetragen wurde. Sonst haette ein
fremder Prozess mit derselben Nummer das andere Programm "am Leben"
gehalten.
"""
if not pid:
return False
if not sys.platform.startswith("win"):
try:
os.kill(int(pid), 0)
return True
except (OSError, ValueError):
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:
if not name:
return True
puffer = ctypes.create_unicode_buffer(4096)
laenge = wintypes.DWORD(len(puffer))
if not k32.QueryFullProcessImageNameW(griff, 0, puffer, ctypes.byref(laenge)):
return False
return os.path.basename(puffer.value).lower() == str(name).lower()
finally:
k32.CloseHandle(griff)
def datei_stand(ordner):
"""(Aenderungszeit, Groesse) der gemeinsamen Datei - oder None.
Damit sieht ein Programm mit EINEM os.stat, ob sich im Buch etwas getan hat,
ohne dafuer die Datenbank zu oeffnen.
"""
try:
st = os.stat(pfad_zur_datei(ordner))
except OSError:
return None
return st.st_mtime, st.st_size
def journal_laeuft(ordner):
"""Ist das Steuerjournal gerade offen?"""
if not ordner or not os.path.exists(pfad_zur_datei(ordner)):
return False
try:
return Nummernbuch(ordner).laeuft("journal")
except Exception as e: # noqa: BLE001 - eine Auskunft darf nichts umwerfen
log.debug("Anmeldung nicht lesbar: %s", e)
return False
class Nummernbuch:
"""Das gemeinsame Register. Wird zum Schreiben kurz geoeffnet und wieder
geschlossen - zwei Programme haben die Datei sonst dauerhaft in der Hand,
@ -153,6 +247,67 @@ class Nummernbuch:
return {z["storno_zu"] for z in
con.execute("SELECT storno_zu FROM nummern WHERE storno_zu <> ''")}
# ---- Wer laeuft, und der Briefkasten -----------------------------------
def anmelden(self, programm, pid, exe):
"""Eintragen: dieses Programm ist offen."""
import datetime
with self._verbindung() as con:
con.execute(
"INSERT INTO laeuft (programm, pid, exe, seit) VALUES (?,?,?,?) "
"ON CONFLICT(programm) DO UPDATE SET pid=excluded.pid, exe=excluded.exe, "
"seit=excluded.seit",
(str(programm), int(pid), str(exe),
datetime.datetime.now().isoformat(timespec="seconds")))
log.info("angemeldet: %s (PID %s)", programm, pid)
def abmelden(self, programm):
with self._verbindung() as con:
con.execute("DELETE FROM laeuft WHERE programm=?", (str(programm),))
def laeuft(self, programm):
"""Laeuft dieses Programm noch? Karteileichen werden weggeraeumt."""
with self._verbindung() as con:
z = con.execute("SELECT pid, exe FROM laeuft WHERE programm=?",
(str(programm),)).fetchone()
if not z:
return False
if prozess_laeuft(z["pid"], z["exe"]):
return True
self.abmelden(programm)
return False
def melden(self, absender, text):
"""Eine Zeile in den Briefkasten legen."""
import datetime
with self._verbindung() as con:
con.execute("INSERT INTO nachrichten (zeit, absender, text) VALUES (?,?,?)",
(datetime.datetime.now().isoformat(timespec="seconds"),
str(absender), str(text)))
log.info("Nachricht von %s: %s", absender, text)
return True
def letzte_nachricht(self):
"""Hoechste Nachrichten-Nummer (0, wenn der Kasten leer ist)."""
with self._verbindung() as con:
z = con.execute("SELECT MAX(id) AS m FROM nachrichten").fetchone()
return int(z["m"] or 0)
def nachrichten_ab(self, seit_id):
"""Alle Nachrichten NACH dieser Nummer: (Liste von Texten, neue Nummer)."""
with self._verbindung() as con:
zeilen = list(con.execute(
"SELECT id, absender, text FROM nachrichten WHERE id > ? ORDER BY id",
(int(seit_id or 0),)))
if not zeilen:
return [], int(seit_id or 0)
return [z["text"] for z in zeilen], int(zeilen[-1]["id"])
def aufraeumen(self, behalten=200):
"""Alte Nachrichten wegwerfen - der Briefkasten ist kein Archiv."""
with self._verbindung() as con:
con.execute("DELETE FROM nachrichten WHERE id <= "
"(SELECT MAX(id) FROM nachrichten) - ?", (int(behalten),))
def entfernen(self, nummer):
"""Nur fuer den Rueckweg: ein verworfener Storno gibt seine Nummer zurueck."""
with self._verbindung() as con:

View file

@ -392,6 +392,7 @@ class KorrekturMixin:
# erfaehrt nicht, dass die Rechnung aufgehoben ist.
self._ins_buch(nummer, kopf, r, pfad)
self._buch_anzeigen()
self._journal_benachrichtigen(f"Storno {nummer} zu Rechnung {alt_nummer} erstellt")
fehlend = self._formular_aus_meta(meta)
self.r_nummer.set(folge_nummer or "")