rechnungstool/api.py
TheMockTv 94c2c7881a Mini-API zwischen beiden Programmen, Proforma-Rechnung
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
2026-09-03 21:22:48 +02:00

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"