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:
parent
ecad4b00ea
commit
8163213960
3 changed files with 180 additions and 0 deletions
24
app.py
24
app.py
|
|
@ -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)
|
||||
|
|
|
|||
155
gemeinsam.py
155
gemeinsam.py
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 "")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue