MINI-API (seine Ansage: "die muessen ja sehen das die beiden auf sind"): Jedes Programm macht beim Start einen winzigen Server auf 127.0.0.1 mit einem Port, den das Betriebssystem aussucht, und traegt Port und PID in die gemeinsame SQLite ein - die ist das Telefonbuch. Zwei Nachrichten reichen: "ping" wird mit "hallo, bin noch da" beantwortet, "schau_nach" ist der Push. Der Push traegt KEINE Daten. Er sagt nur "schau in die sqlite" - dort steht die Meldung ohnehin. Eine Wahrheit, und wenn der Anruf nicht durchgeht, findet das andere Programm die Meldung beim naechsten Nachsehen. Gebunden wird nur auf Loopback: von aussen ist nichts erreichbar, Windows fragt bei 127.0.0.1 nicht nach der Firewall, und die API fuehrt keine Befehle aus. Kein Netzwerk-Server mit MySQL - dafuer braeuchte er Infrastruktur, die es nicht gibt; auf verschiedenen Rechnern waere ohnehin ein anderer Bau noetig. PROFORMA-RECHNUNG: ein Knopf, gewaehlt wird im Popup - die fruehere Sicherheitsfrage IST diese Auswahl geworden (eine Frage statt zwei). Seine Bedingung: "musst nur auf passen das der rechner dann die rechnungs nummer nicht aendert". Die Proforma laeuft in einer EIGENEN Reihe (2026-P001); Nummernfeld, Rechnungszaehler, Nummernbuch und die Meldung ans Journal bleiben unberuehrt. Genau das prueft pruef_proforma.py zuerst. An den Quellen geprueft, nicht an Ratgeberseiten: * § 14 Abs. 1 S. 1 UStG / UStAE 14.1 - Rechnung ist jedes Dokument, mit dem abgerechnet wird, "gleichgueltig, wie dieses Dokument im Geschaeftsverkehr bezeichnet wird". Das Wort "Proforma" allein schuetzt also nicht. * § 14c Abs. 2 UStG - wer Steuer gesondert ausweist, ohne berechtigt zu sein, schuldet den Betrag; Korrektur nur mit Antrag und Zustimmung des Finanzamts. * § 14 Abs. 4 Nr. 4 UStG - die fortlaufende, einmalig vergebene Nummer gilt fuer Rechnungen. Deshalb: Ueberschrift "Proforma-Rechnung", der Satz "keine Rechnung im Sinne des § 14 UStG ... nicht zu bezahlen", "Proforma an:" statt "Rechnung an:", die Nummer ausdruecklich als KEINE Rechnungsnummer, KEIN Steuersatz und KEIN Steuerbetrag (auch die Beherbergungssteuer nicht - sonst waere das Blatt halb ausgewiesen, halb nicht), dafuer "Voraussichtlicher Gesamtbetrag" mit dem Hinweis, dass alles enthalten ist. Kein Blatt fuers Amt, kein Zahlungstext. TESTLAUF: _testwahl sagt, was im Auswahlfenster geklickt worden waere; pruef_proforma setzt _dialog_zeigen und drueckt die echten Knoepfe. Pruefstaende: pruef_proforma.py (neu, mit dem echten Popup), pruef_montage, pruef_storno, pruef_kette, pruef_kernregeln, pruef_nummernbuch, pruef_nummern, pruef_dialoge - alle gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx
194 lines
6.4 KiB
Python
194 lines
6.4 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Mini-API zwischen Rechnungstool und Steuerjournal.
|
|
|
|
WARUM
|
|
-----
|
|
Beide Programme stehen nebeneinander offen. Schreibt das Rechnungstool eine
|
|
Rechnung, soll sie im Journal SOFORT auftauchen - nicht erst beim naechsten
|
|
Einlesen und nicht nach einer Wartezeit.
|
|
|
|
Seine Ansage dazu: *"machst ein kleinen py server der hin und her pingt mit
|
|
einem push vom rechnungstool: eh schau mal in die sqlite"* und *"die muessen ja
|
|
sehen das die beiden auf sind"*.
|
|
|
|
WIE
|
|
---
|
|
Jedes Programm macht beim Start einen winzigen Server auf **127.0.0.1** mit
|
|
einem Port, den das Betriebssystem aussucht (Port 0). Wo es lauscht, traegt es
|
|
in die gemeinsame SQLite ein - die ist das Telefonbuch. Wer jemanden erreichen
|
|
will, schlaegt dort Port und PID nach, prueft die PID und ruft an.
|
|
|
|
Zwei Nachrichten reichen:
|
|
|
|
{"was": "ping"} -> {"antwort": "hallo, bin noch da"}
|
|
{"was": "schau_nach"} -> {"antwort": "ok"} (und das Fenster liest nach)
|
|
|
|
Der Push traegt keine Daten. Er sagt nur "schau in die SQLite" - dort steht die
|
|
Meldung ohnehin. Damit gibt es genau EINE Wahrheit, und wenn der Anruf nicht
|
|
durchgeht (Programm zu, Port belegt), findet das andere die Meldung beim
|
|
naechsten Nachsehen trotzdem.
|
|
|
|
SICHERHEIT
|
|
----------
|
|
Gebunden wird ausschliesslich auf 127.0.0.1 - von aussen ist da nichts zu
|
|
erreichen, und Windows fragt bei Loopback nicht nach der Firewall. Angenommen
|
|
werden nur die zwei Woerter oben; alles andere wird verworfen. Die API fuehrt
|
|
keine Befehle aus und uebertraegt keine Daten.
|
|
|
|
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
|
|
hinueber.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import socket
|
|
import socketserver
|
|
import sys
|
|
import threading
|
|
|
|
log = logging.getLogger("api")
|
|
|
|
ANTWORT_HALLO = "hallo, bin noch da"
|
|
|
|
|
|
class _Handler(socketserver.StreamRequestHandler):
|
|
timeout = 3
|
|
|
|
def handle(self):
|
|
# Nur der eigene Rechner darf reden - gebunden ist ohnehin nur Loopback.
|
|
if self.client_address[0] not in ("127.0.0.1", "::1"):
|
|
return
|
|
try:
|
|
zeile = self.rfile.readline(4096).decode("utf-8", "replace").strip()
|
|
except OSError:
|
|
return
|
|
if not zeile:
|
|
return
|
|
try:
|
|
was = str(json.loads(zeile).get("was", ""))
|
|
except (ValueError, AttributeError):
|
|
was = ""
|
|
if was == "ping":
|
|
antwort = ANTWORT_HALLO
|
|
elif was == "schau_nach":
|
|
antwort = "ok"
|
|
self.server.melden() # Fenster benachrichtigen (threadsicher)
|
|
else:
|
|
antwort = "haeh?"
|
|
try:
|
|
self.wfile.write((json.dumps({"antwort": antwort}) + "\n").encode("utf-8"))
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
class _Server(socketserver.ThreadingTCPServer):
|
|
allow_reuse_address = True
|
|
daemon_threads = True
|
|
|
|
def __init__(self, adresse, bei_push):
|
|
self.melden = bei_push
|
|
super().__init__(adresse, _Handler)
|
|
|
|
|
|
class MiniApi:
|
|
"""Der kleine Server eines Programms.
|
|
|
|
`bei_push` wird aus einem FREMDEN Faden gerufen - Tkinter darf man von dort
|
|
nicht anfassen. Der Aufrufer legt die Meldung deshalb in eine Warteschlange
|
|
und holt sie im Fenster mit after() ab.
|
|
"""
|
|
|
|
def __init__(self, programm, bei_push):
|
|
self.programm = programm
|
|
self.bei_push = bei_push
|
|
self.server = None
|
|
self.faden = None
|
|
self.port = 0
|
|
|
|
def starten(self, buch):
|
|
"""Server aufmachen und im Telefonbuch (SQLite) eintragen."""
|
|
if self.server is not None:
|
|
return self.port
|
|
try:
|
|
self.server = _Server(("127.0.0.1", 0), self.bei_push)
|
|
except OSError as e:
|
|
log.warning("API konnte nicht starten: %s", e)
|
|
self.server = None
|
|
return 0
|
|
self.port = self.server.server_address[1]
|
|
self.faden = threading.Thread(target=self.server.serve_forever,
|
|
name=f"api-{self.programm}", daemon=True)
|
|
self.faden.start()
|
|
log.info("API von %s laeuft auf 127.0.0.1:%s", self.programm, self.port)
|
|
self.eintragen(buch)
|
|
return self.port
|
|
|
|
def eintragen(self, buch):
|
|
"""Port und PID ins Telefonbuch. Ohne Buch gibt es keine API-Auskunft."""
|
|
if buch is None or not self.port:
|
|
return
|
|
try:
|
|
buch.anmelden(self.programm, os.getpid(),
|
|
os.path.basename(sys.executable), self.port)
|
|
except Exception as e: # noqa: BLE001
|
|
log.warning("Eintrag ins Telefonbuch fehlgeschlagen: %s", e)
|
|
|
|
def stoppen(self, buch=None):
|
|
if buch is not None:
|
|
try:
|
|
buch.abmelden(self.programm)
|
|
except Exception: # noqa: BLE001 - beim Schliessen nichts erzwingen
|
|
pass
|
|
if self.server is not None:
|
|
try:
|
|
self.server.shutdown()
|
|
self.server.server_close()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
self.server = None
|
|
self.port = 0
|
|
|
|
|
|
def _reden(port, was, zeitgrenze=1.5):
|
|
"""Eine Zeile hinschicken und die Antwort lesen. None = niemand da."""
|
|
try:
|
|
with socket.create_connection(("127.0.0.1", int(port)), timeout=zeitgrenze) as s:
|
|
s.settimeout(zeitgrenze)
|
|
s.sendall((json.dumps({"was": was}) + "\n").encode("utf-8"))
|
|
roh = s.makefile("r", encoding="utf-8").readline()
|
|
return json.loads(roh).get("antwort", "")
|
|
except (OSError, ValueError):
|
|
return None
|
|
|
|
|
|
def erreichbar(buch, programm):
|
|
"""Meldet sich das andere Programm? ("hallo, bin noch da")
|
|
|
|
Zuerst die PID - die beantwortet Windows und kostet nichts. Erst wenn die
|
|
stimmt, wird ueberhaupt angerufen.
|
|
"""
|
|
if buch is None:
|
|
return False
|
|
try:
|
|
eintrag = buch.wer_laeuft(programm)
|
|
except Exception as e: # noqa: BLE001
|
|
log.debug("Telefonbuch nicht lesbar: %s", e)
|
|
return False
|
|
if not eintrag or not eintrag.get("port"):
|
|
return False
|
|
return _reden(eintrag["port"], "ping") == ANTWORT_HALLO
|
|
|
|
|
|
def anklopfen(buch, programm):
|
|
"""Push: "schau mal in die sqlite". True, wenn es angekommen ist."""
|
|
if buch is None:
|
|
return False
|
|
try:
|
|
eintrag = buch.wer_laeuft(programm)
|
|
except Exception as e: # noqa: BLE001
|
|
log.debug("Telefonbuch nicht lesbar: %s", e)
|
|
return False
|
|
if not eintrag or not eintrag.get("port"):
|
|
return False
|
|
return _reden(eintrag["port"], "schau_nach") == "ok"
|