Amt-Blatt raus, Bedienung ueber die API, Erststart fragt nach zwei Wegen

Das zweite Blatt "fuer das Amt" trug DIESELBE Rechnungsnummer, aber einen
anderen Gesamtbetrag. Mehrere Blaetter zum selben Umsatz mit verschiedenen
Endbetraegen sehen aus, als werde ueber verschiedene Umsaetze abgerechnet -
bei einer Pruefung ein Problem. Ersatzlos entfallen; was die Stadt braucht,
rechnet das Steuerjournal aus den Kenndaten der PDF. Mit dem Blatt fielen
_AmtMarke, gesamtrechnung_betont und der doppelte Renderlauf weg.

Die API kann das Fenster jetzt BEDIENEN - mit Schluessel wie bei einem SSH-Key:
32 Byte Zufall in api_schluessel.txt im gemeinsamen Ordner, den nur das
Steuerjournal und der Betreiber lesen. Ohne gueltigen Schluessel kein Zutritt
(compare_digest). ping und schau_nach bleiben schluessellos, sonst kann ein
aelteres Journal nicht mehr anklopfen. Was einen BELEG schreibt, geht nur im
Testlauf - eine Fernbedienung, die echte Rechnungen ausstellt, hat in einer
Buchhaltung nichts zu suchen. api.py liegt wie immer in beiden Programmen.

Der Erststart fragte nach "der zuletzt vergebenen Nummer" - das holt niemanden
ab, der bei 0 anfaengt. Jetzt zwei Wege: bei 0 anfangen ODER die eigene
Zaehlung weiterfuehren. Unter das schon Vergebene kommt keiner der beiden.

pruef_nummernsperre bediente den Dialog nur halb: nach einer abgelehnten Zahl
bleibt er offen (so soll er), niemand drueckte "Spaeter" - das Programm wartete
in wait_window() ewig. Das war schon vor diesem Umbau so.

Neu: pruef_api.py. Alle 14 Pruefstaende gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
This commit is contained in:
TheMockTv 2026-09-05 18:24:59 +02:00
parent 142dddc647
commit ca9ac212cf
11 changed files with 835 additions and 212 deletions

View file

@ -0,0 +1,107 @@
# Rechnungstool: rechtssicher, gestaltbar, mit Vorschau
**Stand 05.09.2026 — in Arbeit.** Programm `C:\claude\rechnungstool`,
Repo `git.pcore.de/TheMockTv/rechnungstool` (Pre-Tag `pre/rechtssicher-bilder`).
Seine Ansage: *„wir bauen den rechnungs ersteller so um das der nun richtig
rechtlich sicher ist und die kopf und fuss zeile mit eigenen bildern import"* —
und im Lauf des Umbaus dazu: *„das die firmen das designen können"*,
*„eine vorschau butten wäre gut … sonst haben die den storno auf wand"*,
*„das mit rechnung an das amt kommt raus, das ist nicht § conform"*.
## 1. Bilder und Farbe gehören der Firma (fertig)
Bis jetzt war das Blatt auf EINEN Betrieb gebaut: Camping-Grün fest im Code,
das Logo ein Pfad in der config.
- **`bilder.py`** (neu): ein gewähltes Bild wird in den Ordner `bilder/` neben
die `config.json` **kopiert**, gemerkt wird nur `bilder/logo.png`.
Der Grund: ein Pfad auf den Desktop ist tot, sobald dort jemand aufräumt —
und das fällt erst beim Drucken auf. Gleicher Name, anderes Bild → `-2`,
nie überschreiben.
- **Kopfbild** (Logo, oben links) und **Fußbild** (schmaler Streifen unten)
sind austauschbar. Das Layout bleibt, wie es war.
- **Firmenname, Anschrift, Bank, Steuernummer bleiben gezeichnet.** Kein Bild
kann sie ersetzen — die Steuernummer ist Pflichtangabe
(§ 14 Abs. 4 Nr. 2 UStG), und was in einem Bild steht, kann niemand prüfen.
(Ein Haken „Angaben stehen im Bild" war gebaut und ist auf seine Ansage hin
wieder rausgeflogen.)
- **Farbe der Rechnung**: EIN Wert (`design.akzent`), Hex-Feld **und**
Farbwähler in den Einstellungen. Die helle Fläche im Tabellenkopf wird daraus
gemischt — niemand muss zwei Farben aufeinander abstimmen. Eine unlesbare
Farbe fällt still auf die Vorgabe zurück, statt das Schreiben der Rechnung zu
verhindern.
Prüfstand: **`pruef_bilder.py`**.
## 2. Vorschau-Knopf (fertig)
Seine Begründung: *„sonst haben die den storno auf wand"* — eine ausgestellte
Rechnung lässt sich nur noch über Storno oder Berichtigung geradeziehen.
Der Knopf zeigt das fertige Blatt, **bevor** es eine Rechnung wird:
- verbraucht **keine** Rechnungsnummer, trägt nichts ins Nummernbuch,
- landet **nicht** im Rechnungsordner (Temp-Ordner),
- trägt quer über jeder Seite **„VORSCHAU – keine Rechnung"**,
- prüft **keine** Pflichtfelder — man will ja gerade sehen, was noch fehlt.
Prüfstand: **`pruef_vorschau.py`** (u. a.: die echte Rechnung danach trägt die
Marke NICHT).
## 3. Das Blatt „für das Amt" ist entfallen
Bis 05.09.2026 hing an jeder Rechnung mit Beherbergungssteuer ein zweites Blatt
mit **derselben Rechnungsnummer**, aber ohne die Steuer — also mit einem
**anderen Gesamtbetrag**.
Seine Ansage: *„das mit rechnung an das amt kommt raus … wenn so was in den
büchern von einem unternehmen drin ist, dann ist das ein genickbruch, wenn der
prüfer kommt"*. Vorgeschichte: Bartl wollte es so, weil es sonst seine
Steuerberaterin durcheinanderbringe; der Widerspruch stand schon damals.
Nachgeschlagen: mehrere Blätter zum selben Umsatz mit **gleicher** Nummer sind
für sich noch kein § 14c-Fall — aber unterschiedliche Endbeträge erwecken den
Eindruck, es werde über **verschiedene Umsätze** abgerechnet. Genau das fällt
bei einer Prüfung auf.
**Was an die Stelle tritt:** nichts im Rechnungstool. Das Steuerjournal liest
die Kenndaten (`/Subject`) der PDF und rechnet die Bemessungsgrundlage selbst
aus; ans Amt geht eine **Summenanmeldung**, keine Rechnungskopie mit Gastnamen.
Vorher geprüft: kein Programm liest die zweite Seite — `pruef_montage.py`
belegt weiter, dass das Journal die Datei einliest.
Mit dem Blatt entfielen: `_AmtMarke`, der Parameter `gesamtrechnung_betont` und
der doppelte Renderlauf (der existierte nur, um zu wissen, auf welcher Seite das
Amt-Blatt beginnt). Der Renderer ist dadurch deutlich kürzer.
## 4. Prüfstände nehmen keine Maus mehr
Sein Fund mitten im Lauf: *„pack doch die maus nicht immer auf das programm,
weil ich kann dir sonst nichts schreiben"*.
- `config.TESTLAUF` steht an **einer** Stelle (`--test` / `CAMPINGHOF_TEST=1`).
- Hauptfenster und **jeder** Dialog werden im Prüflauf durchsichtig gemacht und
aus dem Bild geschoben, und `grab_set` fällt weg.
- ⚠️ **Nicht `withdraw()`**: damit baut ein Fenster seine Dialoge nicht mehr
richtig auf — vier Prüfstände wurden davon rot und hätten eine Bedienung
geprüft, die es so nicht gibt.
- **`pruefsteuerung.py`** (neu): Meldungen wandern ins Protokoll, Ja/Nein-Fragen
in einen Fahrplan (`antwortet("Hausnummer", True)`). Eine **unangemeldete**
Frage bricht den Lauf ab — stillschweigend „ja" zu sagen würde genau die Fälle
übergehen, wegen denen es die Prüfung gibt.
## Offen
- **Betriebsart abfragen** (Campingplatz / Handwerker / Unternehmen) — sein
Zielbau: *„nicht jeder braucht das so wie der camping typ"*. Handwerker und
Unternehmen brauchen **freie Rechnungszeilen** statt des festen Katalogs; das
ist ein neuer Eingabemodus, kein Schalter.
- **§ 14-Lücke Leistungszeitpunkt**: ohne An-/Abreise (z. B. nur Gasfüllung)
steht heute kein Leistungsdatum auf dem Blatt — § 14 Abs. 4 Nr. 6 UStG.
- **Zahlungsziel** einstellbar.
- **E-Rechnung (ZUGFeRD)**: Pflicht ab 01.01.2028 für B2B — trifft die
Montage-Rechnung. Bibliothek `factur-x` ist geprüft und kann XML erzeugen
(Profil EN 16931), prüfen (XSD + Schematron) und einbetten (PDF/A-3).
- **GoBD**: PDFs nach dem Schreiben schreibgeschützt; Aufbewahrung seit BEG IV
8 statt 10 Jahre (§ 147 Abs. 3 AO).

181
api.py
View file

@ -18,11 +18,38 @@ 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 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. will, schlaegt dort Port und PID nach, prueft die PID und ruft an.
Zwei Nachrichten reichen: Zwei Nachrichten reichen dafuer:
{"was": "ping"} -> {"antwort": "hallo, bin noch da"} {"was": "ping"} -> {"antwort": "hallo, bin noch da"}
{"was": "schau_nach"} -> {"antwort": "ok"} (und das Fenster liest nach) {"was": "schau_nach"} -> {"antwort": "ok"} (und das Fenster liest nach)
Dazu kommt eine dritte, die das Fenster BEDIENT - damit ein Pruefstand die
Knoepfe druecken kann, ohne dem Menschen am Rechner Maus und Tastatur
wegzunehmen:
{"was": "aktion", "schluessel": "...", "name": "vorschau"} -> {"antwort": {...}}
DER SCHLUESSEL
--------------
Wer bedient, muss sich ausweisen - wie mit einem SSH-Schluessel. Beim ersten
Start legt das Programm eine Zufallszahl in `api_schluessel.txt` **im
gemeinsamen Ordner** ab (dort steht auch das Nummernbuch). Wer diesen Ordner
lesen darf, darf sprechen: das Steuerjournal und der Betreiber - sonst niemand.
Ohne gueltigen Schluessel wird eine Aktion verworfen, und zwar ohne zu
verraten, ob der Name ueberhaupt existiert.
`ping` und `schau_nach` bleiben schluessellos: sie sagen nur "ich lebe" bzw.
"schau in die SQLite" und veraendern nichts. Sonst koennte ein aelteres
Steuerjournal nicht mehr anklopfen.
WAS EINE AKTION DARF
--------------------
Alles, was einen BELEG SCHREIBT (Rechnung, Angebot, Storno), geht nur im
Testlauf (`--test` / `CAMPINGHOF_TEST=1`). Im normalen Betrieb bleiben nur
Aktionen, die nichts in die Buchhaltung schreiben: nachsehen, Felder fuellen,
Vorschau zeigen. Eine Fernbedienung, die echte Rechnungen ausstellt, hat in
einer Buchhaltung nichts zu suchen.
Der Push traegt keine Daten. Er sagt nur "schau in die SQLite" - dort steht die 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 Meldung ohnehin. Damit gibt es genau EINE Wahrheit, und wenn der Anruf nicht
durchgeht (Programm zu, Port belegt), findet das andere die Meldung beim durchgeht (Programm zu, Port belegt), findet das andere die Meldung beim
@ -42,6 +69,8 @@ hinueber.
import json import json
import logging import logging
import os import os
import queue
import secrets
import socket import socket
import socketserver import socketserver
import sys import sys
@ -50,6 +79,33 @@ import threading
log = logging.getLogger("api") log = logging.getLogger("api")
ANTWORT_HALLO = "hallo, bin noch da" ANTWORT_HALLO = "hallo, bin noch da"
SCHLUESSEL_DATEI = "api_schluessel.txt"
def schluessel_holen(ordner):
"""Schluessel aus dem Ordner lesen; ist keiner da, einen anlegen.
Der Schluessel ist eine 32 Byte lange Zufallszahl in Hex. Er liegt als
Datei neben dem Nummernbuch, damit beide Programme denselben lesen - eine
Absprache ueber den gemeinsamen Ordner, ohne Server und ohne Passwort, das
sich jemand merken muesste.
"""
if not ordner:
return ""
pfad = os.path.join(ordner, SCHLUESSEL_DATEI)
try:
if os.path.exists(pfad):
wert = open(pfad, encoding="utf-8").read().strip()
if wert:
return wert
wert = secrets.token_hex(32)
with open(pfad, "w", encoding="utf-8") as f:
f.write(wert)
log.info("neuer API-Schluessel angelegt: %s", pfad)
return wert
except OSError as e:
log.warning("API-Schluessel nicht nutzbar (%s)", e)
return ""
class _Handler(socketserver.StreamRequestHandler): class _Handler(socketserver.StreamRequestHandler):
@ -66,14 +122,17 @@ class _Handler(socketserver.StreamRequestHandler):
if not zeile: if not zeile:
return return
try: try:
was = str(json.loads(zeile).get("was", "")) nachricht = json.loads(zeile)
was = str(nachricht.get("was", ""))
except (ValueError, AttributeError): except (ValueError, AttributeError):
was = "" nachricht, was = {}, ""
if was == "ping": if was == "ping":
antwort = ANTWORT_HALLO antwort = ANTWORT_HALLO
elif was == "schau_nach": elif was == "schau_nach":
antwort = "ok" antwort = "ok"
self.server.melden() # Fenster benachrichtigen (threadsicher) self.server.melden() # Fenster benachrichtigen (threadsicher)
elif was == "aktion":
antwort = self._aktion(nachricht)
else: else:
antwort = "haeh?" antwort = "haeh?"
try: try:
@ -82,12 +141,35 @@ class _Handler(socketserver.StreamRequestHandler):
pass pass
def _aktion(self, nachricht):
"""Eine Bedienung ausfuehren - nur mit gueltigem Schluessel."""
erwartet = getattr(self.server, "schluessel", "")
gegeben = str(nachricht.get("schluessel", ""))
# compare_digest statt "==": ein Vergleich, der bei der ersten falschen
# Stelle abbricht, verraet ueber die Zeit, wie weit man richtig lag.
if not erwartet or not secrets.compare_digest(erwartet, gegeben):
log.warning("Aktion ohne gueltigen Schluessel abgewiesen")
return "kein zutritt"
if self.server.aktion is None:
return "keine bedienung moeglich"
name = str(nachricht.get("name", ""))
daten = nachricht.get("daten") or {}
log.info("Aktion ueber die API: %s", name)
try:
return self.server.aktion(name, daten)
except Exception as e: # noqa: BLE001 - der Server darf daran nicht sterben
log.warning("Aktion %r fehlgeschlagen: %s", name, e)
return {"fehler": str(e)}
class _Server(socketserver.ThreadingTCPServer): class _Server(socketserver.ThreadingTCPServer):
allow_reuse_address = True allow_reuse_address = True
daemon_threads = True daemon_threads = True
def __init__(self, adresse, bei_push): def __init__(self, adresse, bei_push, aktion=None, schluessel=""):
self.melden = bei_push self.melden = bei_push
self.aktion = aktion
self.schluessel = schluessel
super().__init__(adresse, _Handler) super().__init__(adresse, _Handler)
@ -99,19 +181,30 @@ class MiniApi:
und holt sie im Fenster mit after() ab. und holt sie im Fenster mit after() ab.
""" """
def __init__(self, programm, bei_push): def __init__(self, programm, bei_push, aktion=None):
self.programm = programm self.programm = programm
self.bei_push = bei_push self.bei_push = bei_push
self.aktion = aktion # (name, daten) -> Antwort; None = keine Bedienung
self.server = None self.server = None
self.faden = None self.faden = None
self.port = 0 self.port = 0
self.schluessel = ""
def starten(self, buch): def starten(self, buch, ordner=None):
"""Server aufmachen und im Telefonbuch (SQLite) eintragen.""" """Server aufmachen und im Telefonbuch (SQLite) eintragen.
Der Schluessel kommt aus dem Ordner des Nummernbuchs - dort liegt er
fuer beide Programme lesbar. Gibt es kein Buch, gibt es keinen
Schluessel und damit auch keine Bedienung von aussen.
"""
if self.server is not None: if self.server is not None:
return self.port return self.port
if ordner is None and buch is not None:
ordner = os.path.dirname(getattr(buch, "pfad", "") or "")
self.schluessel = schluessel_holen(ordner)
try: try:
self.server = _Server(("127.0.0.1", 0), self.bei_push) self.server = _Server(("127.0.0.1", 0), self.bei_push,
aktion=self.aktion, schluessel=self.schluessel)
except OSError as e: except OSError as e:
log.warning("API konnte nicht starten: %s", e) log.warning("API konnte nicht starten: %s", e)
self.server = None self.server = None
@ -150,12 +243,14 @@ class MiniApi:
self.port = 0 self.port = 0
def _reden(port, was, zeitgrenze=1.5): def _reden(port, was, zeitgrenze=1.5, **rest):
"""Eine Zeile hinschicken und die Antwort lesen. None = niemand da.""" """Eine Zeile hinschicken und die Antwort lesen. None = niemand da."""
nachricht = {"was": was}
nachricht.update(rest)
try: try:
with socket.create_connection(("127.0.0.1", int(port)), timeout=zeitgrenze) as s: with socket.create_connection(("127.0.0.1", int(port)), timeout=zeitgrenze) as s:
s.settimeout(zeitgrenze) s.settimeout(zeitgrenze)
s.sendall((json.dumps({"was": was}) + "\n").encode("utf-8")) s.sendall((json.dumps(nachricht) + chr(10)).encode("utf-8"))
roh = s.makefile("r", encoding="utf-8").readline() roh = s.makefile("r", encoding="utf-8").readline()
return json.loads(roh).get("antwort", "") return json.loads(roh).get("antwort", "")
except (OSError, ValueError): except (OSError, ValueError):
@ -192,3 +287,69 @@ def anklopfen(buch, programm):
if not eintrag or not eintrag.get("port"): if not eintrag or not eintrag.get("port"):
return False return False
return _reden(eintrag["port"], "schau_nach") == "ok" return _reden(eintrag["port"], "schau_nach") == "ok"
def bedienen(buch, programm, name, daten=None, ordner=None, zeitgrenze=15.0):
"""Einen Knopf im anderen Programm druecken. None = niemand da / kein Zutritt.
Fuer Pruefstaende und fuer den Betreiber: damit laesst sich das Fenster
bedienen, ohne dem Menschen am Rechner Maus und Tastatur wegzunehmen.
Der Schluessel kommt aus demselben Ordner wie das Nummernbuch - wer den
lesen darf, darf bedienen.
"""
if buch is None:
return None
try:
eintrag = buch.wer_laeuft(programm)
except Exception as e: # noqa: BLE001
log.debug("Telefonbuch nicht lesbar: %s", e)
return None
if not eintrag or not eintrag.get("port"):
return None
if ordner is None:
ordner = os.path.dirname(getattr(buch, "pfad", "") or "")
return _reden(eintrag["port"], "aktion", zeitgrenze=zeitgrenze,
schluessel=schluessel_holen(ordner), name=name,
daten=daten or {})
class Warteschlange:
"""Aktionen aus dem Netzfaden in den Fensterfaden bringen - und zurueck.
Tkinter darf nur aus SEINEM Faden angefasst werden. Der API-Server lauscht
aber in einem eigenen. Deshalb legt er die Aktion hier ab und wartet auf das
Ergebnis; das Fenster holt sie in seiner after()-Schleife ab, fuehrt sie aus
und legt die Antwort zurueck.
Kommt das Fenster nicht dazu (es haengt in einem Dialog), laeuft die
Wartezeit ab und der Anrufer bekommt eine ehrliche Absage statt einer
Verbindung, die nie antwortet.
"""
def __init__(self):
self.eingang = queue.Queue()
def annehmen(self, name, daten, zeitgrenze=12.0):
"""Aus dem Netzfaden gerufen: Aktion einreihen und auf das Ergebnis warten."""
fertig = threading.Event()
auftrag = {"name": name, "daten": daten, "fertig": fertig, "antwort": None}
self.eingang.put(auftrag)
if not fertig.wait(zeitgrenze):
return {"fehler": "das Fenster hat nicht geantwortet"}
return auftrag["antwort"]
def abarbeiten(self, ausfuehren):
"""Im Fensterfaden gerufen: alles erledigen, was eingegangen ist."""
while True:
try:
auftrag = self.eingang.get_nowait()
except queue.Empty:
return
try:
auftrag["antwort"] = ausfuehren(auftrag["name"], auftrag["daten"])
except Exception as e: # noqa: BLE001 - eine Aktion darf das Fenster nicht killen
log.warning("Aktion %r fehlgeschlagen: %s", auftrag["name"], e)
auftrag["antwort"] = {"fehler": str(e)}
finally:
auftrag["fertig"].set()

152
app.py
View file

@ -70,7 +70,13 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
# Das gemeinsame Nummernbuch (siehe gemeinsam.py). None = noch keiner # Das gemeinsame Nummernbuch (siehe gemeinsam.py). None = noch keiner
# gesetzt; das Programm laeuft dann weiter, warnt aber sichtbar. # gesetzt; das Programm laeuft dann weiter, warnt aber sichtbar.
self.buch = None self.buch = None
self.api = api.MiniApi("rechnungstool", self._push_empfangen) # Die API kann das Fenster auch BEDIENEN (nur mit Schluessel, siehe
# api.py). Die Warteschlange bringt die Aktion aus dem Netzfaden
# hierher - Tkinter darf nur aus seinem eigenen Faden angefasst werden.
self._api_schlange = api.Warteschlange()
self.api = api.MiniApi("rechnungstool", self._push_empfangen,
aktion=self._api_schlange.annehmen)
self.after(150, self._api_pumpe)
self._push_kasten = queue.Queue() self._push_kasten = queue.Queue()
# Zwischenspeicher fuer eine reine Berichtigung (§ 31 Abs. 5 UStDV): # Zwischenspeicher fuer eine reine Berichtigung (§ 31 Abs. 5 UStDV):
# die naechste PDF ist dann ein berichtigtes Blatt zu dieser Rechnung. # die naechste PDF ist dann ein berichtigtes Blatt zu dieser Rechnung.
@ -627,8 +633,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
ttk.Label(rahmen, text="Wo soll die Rechnungsnummer weiterlaufen?", ttk.Label(rahmen, text="Wo soll die Rechnungsnummer weiterlaufen?",
font=("Segoe UI", 13, "bold")).grid(row=0, column=0, columnspan=2, sticky="w") font=("Segoe UI", 13, "bold")).grid(row=0, column=0, columnspan=2, sticky="w")
ttk.Label(rahmen, wraplength=620, justify="left", text=( ttk.Label(rahmen, wraplength=620, justify="left", text=(
"Trag die zuletzt vergebene Nummer ein – die aus deiner bisherigen Liste. " "Zwei Wege - such dir einen aus." + chr(10) + chr(10) +
"Die nächste Rechnung bekommt dann die Nummer danach.\n\n"
"Das wird EINMAL eingerichtet. Danach steht der Zähler fest: eine " "Das wird EINMAL eingerichtet. Danach steht der Zähler fest: eine "
"ausgestellte Rechnung wird nur noch über Storno oder Berichtigung " "ausgestellte Rechnung wird nur noch über Storno oder Berichtigung "
"korrigiert, und jede Nummer gibt es nur einmal " "korrigiert, und jede Nummer gibt es nur einmal "
@ -642,23 +647,50 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
f"{jahr}-{gefunden:03d} – niedriger geht es nicht.").grid( f"{jahr}-{gefunden:03d} – niedriger geht es nicht.").grid(
row=2, column=0, columnspan=2, sticky="w", pady=(0, 10)) row=2, column=0, columnspan=2, sticky="w", pady=(0, 10))
ttk.Label(rahmen, text=f"Zuletzt vergeben: {jahr} –").grid(row=3, column=0, sticky="e") # Zwei Wege statt einer Zahl: neu anfangen oder die eigene Zaehlung
ttk.Entry(rahmen, textvariable=wert, width=10, justify="center").grid( # weiterfuehren. Die Frage "trag die zuletzt vergebene Nummer ein" holt
row=3, column=1, sticky="w", padx=(8, 0)) # niemanden ab, der bei 0 anfaengt - der hat gar keine.
weg = tk.StringVar(value="weiter" if gefunden else "null")
zeile = ttk.Frame(rahmen)
# Das Eingabefeld ist ein Kind DIESER Zeile - ein Widget muss dort
# zuhause sein, wo es gepackt wird, sonst mischt Tk grid und pack im
# selben Rahmen und bricht ab.
eingabe = ttk.Entry(zeile, textvariable=wert, width=10, justify="center")
def weg_gewaehlt(*_):
eingabe.configure(state="normal" if weg.get() == "weiter" else "disabled")
ttk.Radiobutton(rahmen, variable=weg, value="null", command=weg_gewaehlt,
text=f"Bei 0 anfangen - die erste Rechnung wird {jahr}-001").grid(
row=3, column=0, columnspan=2, sticky="w", pady=(0, 4))
zeile.grid(row=4, column=0, columnspan=2, sticky="w")
ttk.Radiobutton(zeile, variable=weg, value="weiter", command=weg_gewaehlt,
text=f"Meine Zählung weiterführen - zuletzt vergeben: {jahr} -").pack(
side="left")
eingabe.pack(side="left", padx=(8, 0))
weg_gewaehlt()
hinweis = tk.Label(rahmen, text="", wraplength=620, justify="left", fg="#a00000", hinweis = tk.Label(rahmen, text="", wraplength=620, justify="left", fg="#a00000",
font=("Segoe UI", 9)) font=("Segoe UI", 9))
hinweis.grid(row=4, column=0, columnspan=2, sticky="w", pady=(8, 0)) hinweis.grid(row=5, column=0, columnspan=2, sticky="w", pady=(8, 0))
def uebernehmen(): def uebernehmen():
try: if weg.get() == "null":
zahl = int(parse_num(wert.get())) zahl = 0
except (TypeError, ValueError): else:
zahl = -1 try:
if zahl < gefunden: zahl = int(parse_num(wert.get()))
hinweis.configure(text=f"Mindestens {gefunden} – so weit ist schon vergeben.") except (TypeError, ValueError):
return zahl = -1
if zahl < 0: if zahl < 0:
hinweis.configure(text="Bitte eine Zahl eintragen (0 = es geht bei 001 los).") hinweis.configure(text="Bitte die zuletzt vergebene Nummer eintragen.")
return
# Unter das, was schon vergeben ist, darf niemand - egal auf welchem
# Weg. Sonst gaebe es eine Nummer zweimal (§ 14 Abs. 4 Nr. 4 UStG).
if zahl < gefunden:
hinweis.configure(
text=f"Schon vergeben ist {jahr}-{gefunden:03d} - darunter geht es nicht."
if weg.get() == "weiter" else
f"Bei 0 anfangen geht nicht mehr: {jahr}-{gefunden:03d} ist schon vergeben.")
return return
if not messagebox.askyesno( if not messagebox.askyesno(
"Sind Sie sicher?", "Sind Sie sicher?",
@ -680,7 +712,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
d.destroy() d.destroy()
knoepfe = ttk.Frame(rahmen) knoepfe = ttk.Frame(rahmen)
knoepfe.grid(row=5, column=0, columnspan=2, sticky="ew", pady=(16, 0)) knoepfe.grid(row=6, column=0, columnspan=2, sticky="ew", pady=(16, 0))
ttk.Button(knoepfe, text="Übernehmen", width=16, command=uebernehmen).pack(side="right") ttk.Button(knoepfe, text="Übernehmen", width=16, command=uebernehmen).pack(side="right")
ttk.Button(knoepfe, text="Später", width=12, command=d.destroy).pack(side="left") ttk.Button(knoepfe, text="Später", width=12, command=d.destroy).pack(side="left")
@ -1077,6 +1109,87 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
return jahr, zaehler, nummer return jahr, zaehler, nummer
return jahr, zaehler, f"{jahr}-A{zaehler:03d}" return jahr, zaehler, f"{jahr}-A{zaehler:03d}"
# ------------------------------------------------- Bedienung ueber die API
def _api_pumpe(self):
"""Holt Aktionen aus der Warteschlange und fuehrt sie HIER aus.
Laeuft, solange das Fenster steht. Tkinter darf nur aus seinem eigenen
Faden angefasst werden - der API-Server lauscht in einem anderen.
"""
try:
self._api_schlange.abarbeiten(self._api_aktion)
finally:
self.after(150, self._api_pumpe)
def _api_aktion(self, name, daten):
"""Eine Bedienung ausfuehren. Der Schluessel ist schon geprueft (api.py).
Nachschlagetabelle statt if-Kette: ein neuer Knopf ist EIN Eintrag.
Was einen BELEG schreibt, geht nur im Testlauf. Eine Fernbedienung, die
im laufenden Betrieb echte Rechnungen ausstellt, hat in einer
Buchhaltung nichts zu suchen - eine ausgestellte Rechnung bekommt man
nur ueber einen Storno wieder weg.
"""
schreibt_beleg = {"rechnung", "angebot"}
tabelle = {
"hilfe": lambda _d: {"kann": ["hilfe", "zustand", "setze", "zuruecksetzen",
"vorschau", "rechnung", "angebot"]},
"zustand": lambda _d: self._api_zustand(),
"setze": self._api_setzen,
"zuruecksetzen": lambda _d: (self.reset_formular(), {"ok": True})[1],
"vorschau": lambda d: {"pfad": self.vorschau_zeigen(
oeffnen=bool(d.get("oeffnen", False)))},
"rechnung": lambda _d: (self.pdf_erstellen(), self._api_zustand())[1],
"angebot": lambda _d: (self.pdf_erstellen(angebot=True), self._api_zustand())[1],
}
if name not in tabelle:
return {"fehler": "kenne ich nicht: " + str(name), "kann": sorted(tabelle)}
if name in schreibt_beleg and not TESTLAUF:
return {"fehler": "schreibt einen Beleg - geht nur im Testlauf"}
return tabelle[name](daten or {})
def _api_zustand(self):
"""Was steht gerade im Formular? Zum Nachsehen, ohne Maus."""
r = self.recalc()
return {
"reiter": "montage" if self.ist_montage() else "camping",
"nummer": self.r_nummer.get(),
"datum": self.r_datum.get(),
"anreise": self.r_anreise.get(),
"abreise": self.r_abreise.get(),
"naechte": self.r_naechte.get(),
"kunde": {"anrede": self.k_anrede.get(), "vorname": self.k_vorname.get(),
"nachname": self.k_name.get(), "zusatz": self.k_zusatz.get(),
"strasse": self.k_strasse.get(), "plzort": self.k_plzort.get()},
"positionen": [{"nr": z.nr, "leistung": z.leistung, "anzahl": z.anzahl,
"personen": z.personen, "brutto": z.brutto} for z in r.zeilen],
"gesamt": r.gesamt,
}
def _api_setzen(self, daten):
"""Felder fuellen, wie es sonst die Tastatur taete."""
felder = {
"anrede": self.k_anrede, "vorname": self.k_vorname, "nachname": self.k_name,
"zusatz": self.k_zusatz, "strasse": self.k_strasse, "plzort": self.k_plzort,
"nummer": self.r_nummer, "datum": self.r_datum,
"anreise": self.r_anreise, "abreise": self.r_abreise,
}
for schluessel, wert in (daten.get("kunde") or {}).items():
if schluessel in felder:
felder[schluessel].set(str(wert))
for schluessel in ("nummer", "datum", "anreise", "abreise"):
if schluessel in daten:
felder[schluessel].set(str(daten[schluessel]))
for eintrag in (daten.get("positionen") or []):
nr = int(eintrag.get("nr", 0))
if nr in self.anzahl_vars:
self.anzahl_vars[nr].set(str(eintrag.get("anzahl", "")))
if nr in self.personen_vars and "personen" in eintrag:
self.personen_vars[nr].set(str(eintrag["personen"]))
self.update()
return self._api_zustand()
def _kopf_basis(self, nummer, r): def _kopf_basis(self, nummer, r):
"""Die Kopfdaten, die JEDES Blatt braucht - Rechnung wie Vorschau. """Die Kopfdaten, die JEDES Blatt braucht - Rechnung wie Vorschau.
@ -1111,7 +1224,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
for z in r.zeilen], for z in r.zeilen],
} }
def vorschau_zeigen(self): def vorschau_zeigen(self, oeffnen=True):
"""Zeigt das Blatt, BEVOR es eine Rechnung wird. """Zeigt das Blatt, BEVOR es eine Rechnung wird.
Eine ausgestellte Rechnung laesst sich nur noch ueber einen Storno Eine ausgestellte Rechnung laesst sich nur noch ueber einen Storno
@ -1142,10 +1255,13 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
try: try:
pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf, vorschau=True) pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf, vorschau=True)
print(f"[vorschau] geschrieben -> {pfad}") print(f"[vorschau] geschrieben -> {pfad}")
os.startfile(pfad) # noqa: S606 if oeffnen:
os.startfile(pfad) # noqa: S606
return pfad
except OSError as e: except OSError as e:
messagebox.showerror("Vorschau nicht möglich", messagebox.showerror("Vorschau nicht möglich",
f"Die Vorschau konnte nicht geöffnet werden:\n{e}") f"Die Vorschau konnte nicht geöffnet werden:\n{e}")
return ""
def pdf_erstellen(self, angebot=False): def pdf_erstellen(self, angebot=False):
montage = self.ist_montage() montage = self.ist_montage()

View file

@ -71,6 +71,13 @@ def _daten_ordner():
BASE_DIR = _daten_ordner() BASE_DIR = _daten_ordner()
CONFIG_PATH = os.path.join(BASE_DIR, "config.json") CONFIG_PATH = os.path.join(BASE_DIR, "config.json")
# Laeuft gerade ein Pruefstand? Dann baut das Programm alles wie sonst, zeigt es
# aber nicht: kein Fenster auf dem Bildschirm, kein Dialog, der Maus und
# Tastatur an sich reisst, waehrend der Mensch daneben arbeitet.
# Das Flag steht HIER, damit app.py, theme.py und die Pruefstaende dasselbe
# lesen - zwei Stellen wuerden auseinanderlaufen.
TESTLAUF = ("--test" in sys.argv) or os.environ.get("CAMPINGHOF_TEST") == "1"
# Leistungskatalog 1:1 aus der Excel-Vorlage (Rechnung Campinghof Bartl). # Leistungskatalog 1:1 aus der Excel-Vorlage (Rechnung Campinghof Bartl).
# satz = Umsatzsteuersatz in Prozent (7 = Beherbergung, 19 = Zusatzleistung) # satz = Umsatzsteuersatz in Prozent (7 = Beherbergung, 19 = Zusatzleistung)
# preis = Brutto-Einzelpreis in Euro (Endpreis inkl. USt) # preis = Brutto-Einzelpreis in Euro (Endpreis inkl. USt)

View file

@ -10,7 +10,6 @@ im Layout der Excel-Vorlage:
""" """
import os import os
import io
import json import json
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
@ -19,8 +18,7 @@ from reportlab.lib import colors
from reportlab.lib.enums import TA_RIGHT, TA_LEFT, TA_CENTER from reportlab.lib.enums import TA_RIGHT, TA_LEFT, TA_CENTER
from reportlab.lib.styles import ParagraphStyle from reportlab.lib.styles import ParagraphStyle
from reportlab.platypus import ( from reportlab.platypus import (
SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, KeepTogether, PageBreak, SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, KeepTogether,
Flowable,
) )
import bilder import bilder
@ -28,23 +26,6 @@ import config
from berechnung import eur, eur_kurz, runde from berechnung import eur, eur_kurz, runde
class _AmtMarke(Flowable):
"""Unsichtbar. Haelt fest, auf welcher Seite das Amt-Blatt beginnt.
Nur so kann die Fusszeile die Kundenseite vom Amt-Blatt unterscheiden - sie wird
gezeichnet, bevor der Seiteninhalt steht, und weiss von sich aus nichts davon.
"""
def __init__(self, merker):
super().__init__()
self._merker = merker
def wrap(self, *_):
return (0, 0)
def draw(self):
self._merker["seite"] = self.canv.getPageNumber()
# ---- Farben / Masse --------------------------------------------------------- # ---- Farben / Masse ---------------------------------------------------------
# Die Akzentfarbe gehoert der Firma, nicht dem Programm: Ueberschriften, die # Die Akzentfarbe gehoert der Firma, nicht dem Programm: Ueberschriften, die
# Linie unter dem Kopf und der Tabellenkopf tragen sie. Ein Handwerker will kein # Linie unter dem Kopf und der Tabellenkopf tragen sie. Ein Handwerker will kein
@ -157,8 +138,8 @@ def _vorschau_marke(canvas):
canvas.restoreState() canvas.restoreState()
def _kopf_fuss(canvas, doc, cfg, amt=False, vorschau=False): def _kopf_fuss(canvas, doc, cfg, vorschau=False):
"""Kopf und Fuss einer Seite. amt=True -> Blatt fuer das Amt (siehe Fusszeile).""" """Kopf und Fuss einer Seite."""
firma = cfg["firma"] firma = cfg["firma"]
canvas.saveState() canvas.saveState()
if vorschau: if vorschau:
@ -203,13 +184,8 @@ def _kopf_fuss(canvas, doc, cfg, amt=False, vorschau=False):
canvas.line(MARGIN_L, line_y, PAGE_W - MARGIN_R, line_y) canvas.line(MARGIN_L, line_y, PAGE_W - MARGIN_R, line_y)
# --- Fusszeile --- # --- Fusszeile ---
# # Bankverbindung und Steuernummer MUESSEN drauf: das ist die Rechnung, die der
# STRIKTE TRENNUNG Kunde / Amt: # Gast bezahlt, und die Steuernummer ist Pflichtangabe (§ 14 Abs. 4 Nr. 2 UStG).
# Kundenseite : voller Briefkopf-Fuss - Bankverbindung und Steuernummer MUESSEN
# drauf, das ist die Rechnung, die der Gast bezahlt.
# Amt-Blatt : KEINE Bankverbindung, KEINE Steuernummer. Das Amt zieht nichts
# ein und ueberweist nichts. Es bleiben Inhaber (wer meldet) und
# die Kontaktdaten fuer Rueckfragen.
fy = MARGIN_BOT - 4 * mm fy = MARGIN_BOT - 4 * mm
canvas.setStrokeColor(LINE) canvas.setStrokeColor(LINE)
canvas.setLineWidth(0.6) canvas.setLineWidth(0.6)
@ -220,12 +196,7 @@ def _kopf_fuss(canvas, doc, cfg, amt=False, vorschau=False):
col_m = MARGIN_L + CONTENT_W * 0.40 col_m = MARGIN_L + CONTENT_W * 0.40
col_r = PAGE_W - MARGIN_R col_r = PAGE_W - MARGIN_R
# Das Fusszeilen-Bild steht auf dem AMT-BLATT NICHT. Ein fertiges fuss_img, fiw, fih = bilder.laden(firma.get("fussbild"))
# Briefpapier-Band traegt fast immer Bank und Steuernummer - und genau die
# duerfen dort nicht auftauchen (siehe Trennung oben). Das Amt bekommt
# deshalb immer die gezeichnete Fusszeile, auch wenn der Nutzer den Haken
# "Firmenangaben stehen im Bild" gesetzt hat.
fuss_img, fiw, fih = (None, 0, 0) if amt else bilder.laden(firma.get("fussbild"))
if fuss_img: if fuss_img:
# Schmaler Streifen UNTER den Fusstexten. Die Texte bleiben, wo sie sind - # Schmaler Streifen UNTER den Fusstexten. Die Texte bleiben, wo sie sind -
# Firma, Anschrift, Bank und Steuernummer stehen auf jeder Rechnung, egal # Firma, Anschrift, Bank und Steuernummer stehen auf jeder Rechnung, egal
@ -234,9 +205,8 @@ def _kopf_fuss(canvas, doc, cfg, amt=False, vorschau=False):
canvas.drawImage(fuss_img, MARGIN_L + (CONTENT_W - dw) / 2, 7 * mm, canvas.drawImage(fuss_img, MARGIN_L + (CONTENT_W - dw) / 2, 7 * mm,
width=dw, height=dh, preserveAspectRatio=True, mask="auto") width=dw, height=dh, preserveAspectRatio=True, mask="auto")
rows_l = [] if amt else [firma.get("bank", ""), firma.get("iban", ""), firma.get("bic", "")] rows_l = [firma.get("bank", ""), firma.get("iban", ""), firma.get("bic", "")]
rows_m = [firma.get("inhaber", "")] if amt else [firma.get("inhaber", ""), rows_m = [firma.get("inhaber", ""), firma.get("steuernummer", "")]
firma.get("steuernummer", "")]
rows_r = [firma.get("homepage", ""), firma.get("email", ""), firma.get("telefon", "")] rows_r = [firma.get("homepage", ""), firma.get("email", ""), firma.get("telefon", "")]
for i, t in enumerate(rows_l): for i, t in enumerate(rows_l):
@ -327,12 +297,8 @@ def _montage_zeile(story, st, rechnung, kopf):
story.append(dreier) story.append(dreier)
def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): def _seite_inhalt(story, st, cfg, rechnung, kopf):
"""Baut den kompletten Seiteninhalt (Absender -> Positionen -> Summen -> Danktexte). """Baut den kompletten Seiteninhalt (Absender -> Positionen -> Summen -> Danktexte)."""
gesamtrechnung_betont=True hebt die Zwischensumme-Zeile wie den Gesamtbetrag hervor
(fett + zwei Streifen) -> fuer das Amt-Blatt. Sonst 1:1 die Kundenseite.
"""
# Absenderzeile (klein, unterstrichen) # Absenderzeile (klein, unterstrichen)
story.append(Paragraph( story.append(Paragraph(
f"<u>{cfg['firma'].get('absender','')}</u>", f"<u>{cfg['firma'].get('absender','')}</u>",
@ -428,7 +394,6 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
titel = {"storno": "Stornorechnung", titel = {"storno": "Stornorechnung",
"berichtigung": "Berichtigte Rechnung", "berichtigung": "Berichtigte Rechnung",
"angebot": "Unverbindliches Angebot"}.get(art, "Rechnungsübersicht") "angebot": "Unverbindliches Angebot"}.get(art, "Rechnungsübersicht")
titel += " (für das Amt)" if gesamtrechnung_betont else ""
story.append(Paragraph(titel, st["h"])) story.append(Paragraph(titel, st["h"]))
if angebot: if angebot:
# Ohne diesen Satz sieht das Blatt aus wie eine Rechnung. Es ist keine - # Ohne diesen Satz sieht das Blatt aus wie eine Rechnung. Es ist keine -
@ -477,7 +442,7 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
# Auf dem Angebot wird gar keine Steuer ausgewiesen - weder Umsatzsteuer # Auf dem Angebot wird gar keine Steuer ausgewiesen - weder Umsatzsteuer
# noch Beherbergungssteuer. Es steht nur, was der Gast voraussichtlich # noch Beherbergungssteuer. Es steht nur, was der Gast voraussichtlich
# zahlt; aufgeschluesselt wird erst in der Rechnung. # zahlt; aufgeschluesselt wird erst in der Rechnung.
if rechnung.aufschlaege and not gesamtrechnung_betont and not angebot: if rechnung.aufschlaege and not angebot:
summen_rows.append([ summen_rows.append([
Paragraph("Zwischensumme:", st["info_l"]), Paragraph("Zwischensumme:", st["info_l"]),
Paragraph(eur(rechnung.brutto), st["cell_r"]), Paragraph(eur(rechnung.brutto), st["cell_r"]),
@ -489,7 +454,7 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
Paragraph(eur(a.betrag), st["cell_r"]), Paragraph(eur(a.betrag), st["cell_r"]),
]) ])
endsumme = rechnung.brutto if gesamtrechnung_betont else rechnung.gesamt endsumme = rechnung.gesamt
summen_rows.append([ summen_rows.append([
Paragraph("Voraussichtlicher Gesamtbetrag:" if angebot else Paragraph("Voraussichtlicher Gesamtbetrag:" if angebot else
"Rechnungsbetrag Insgesamt:" if montage else "Gesamtbetrag:", "Rechnungsbetrag Insgesamt:" if montage else "Gesamtbetrag:",
@ -508,14 +473,13 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
summen.setStyle(TableStyle(style_cmds)) summen.setStyle(TableStyle(style_cmds))
story.append(summen) story.append(summen)
# Hinweis zur Beherbergungssteuer - nur auf der Kundenseite. Auf dem Amt-Blatt # Hinweis zur Beherbergungssteuer.
# wird die Steuer gar nicht mehr ausgewiesen, dann waere der Satz sinnlos.
if angebot: if angebot:
story.append(Spacer(1, 1.5 * mm)) story.append(Spacer(1, 1.5 * mm))
story.append(Paragraph( story.append(Paragraph(
"In diesem Betrag sind alle Steuern und Abgaben enthalten. Aufgeschlüsselt " "In diesem Betrag sind alle Steuern und Abgaben enthalten. Aufgeschlüsselt "
"werden sie erst in der Rechnung nach dem Aufenthalt.", st["dank"])) "werden sie erst in der Rechnung nach dem Aufenthalt.", st["dank"]))
elif not gesamtrechnung_betont: else:
for a in rechnung.aufschlaege: for a in rechnung.aufschlaege:
if not a.bezeichnung: if not a.bezeichnung:
continue continue
@ -534,90 +498,83 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
story.append(Spacer(1, 8 * mm)) story.append(Spacer(1, 8 * mm))
# ---- Schlusstexte ------------------------------------------------------- # ---- Schlusstexte -------------------------------------------------------
# Dank- und Zahlungstexte gehoeren nur auf die Kundenseite. Die texte = cfg.get("texte", {})
# STORNO-Erklaerung gehoert aber AUCH aufs Amtsblatt: dort steht sonst eine block = []
# Stornonummer neben einer voellig anderen Rechnungsnummer, und niemand if art == "storno":
# erfaehrt, warum. Sein Fund am 04.09.2026: *"in der pdf wird der satz nicht # Auf einem Storno sind Dank- und Zahlungstexte falsch - hier wird
# angezeigt, da ist dann ein sprung in den rechnungs nummern"* - genau das # nichts geschuldet, sondern aufgehoben.
# Blatt, das die Stadt bekommt, war der Beleg ohne Begruendung. bezug = kopf.get("storno_zu", "")
if not gesamtrechnung_betont or art == "storno": if kopf.get("storno_datum"):
texte = cfg.get("texte", {}) bezug += f" vom {kopf['storno_datum']}"
block = [] folgt = kopf.get("korrektur_nummer") or ""
if art == "storno": if kopf.get("ersatz_vorhanden") and folgt:
# Auf einem Storno sind Dank- und Zahlungstexte falsch - hier wird # Doppelt geschriebene Leistung: es kommt nichts nach, sie ist
# nichts geschuldet, sondern aufgehoben. # bereits abgerechnet. Der Satz muss das sagen - sonst wartet der
bezug = kopf.get("storno_zu", "") # Gast auf eine Rechnung, die nie kommt.
if kopf.get("storno_datum"):
bezug += f" vom {kopf['storno_datum']}"
folgt = kopf.get("korrektur_nummer") or ""
if kopf.get("ersatz_vorhanden") and folgt:
# Doppelt geschriebene Leistung: es kommt nichts nach, sie ist
# bereits abgerechnet. Der Satz muss das sagen - sonst wartet der
# Gast auf eine Rechnung, die nie kommt.
block.append(Paragraph(
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
f"Die Leistung wurde versehentlich doppelt in Rechnung gestellt und "
f"ist bereits mit der Rechnung {folgt} abgerechnet – es folgt keine "
"weitere Rechnung. Bereits gezahlte Beträge werden erstattet oder "
"verrechnet.", st["dank"]))
story.append(KeepTogether(block))
return
if kopf.get("nur_storno"):
# Nur Storno (04.09.2026): die Leistung wird gar nicht abgerechnet.
# Hier darf NICHT von einer berichtigten Rechnung die Rede sein -
# es kommt keine, und der Gast wartet sonst darauf.
block.append(Paragraph(
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
"Die Leistung wird nicht abgerechnet – es folgt keine weitere "
"Rechnung. Bereits gezahlte Beträge werden erstattet.", st["dank"]))
story.append(KeepTogether(block))
return
kette = (f" Die berichtigte Rechnung trägt die Nummer {folgt} "
f"(also {kopf.get('storno_zu','')} → {kopf.get('rechnungsnummer','')} "
f"→ {folgt}).") if folgt else ""
block.append(Paragraph( block.append(Paragraph(
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. " f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
"Bereits gezahlte Beträge werden erstattet oder mit der berichtigten " f"Die Leistung wurde versehentlich doppelt in Rechnung gestellt und "
"Rechnung verrechnet." + kette, st["dank"])) f"ist bereits mit der Rechnung {folgt} abgerechnet – es folgt keine "
elif art == "berichtigung": "weitere Rechnung. Bereits gezahlte Beträge werden erstattet oder "
# Der Satz ist kein Beiwerk: mehrere Rechnungen ueber dieselbe Leistung "verrechnet.", st["dank"]))
# ohne Kennzeichnung loesen die Steuer erneut aus (Abschn. 14c.1 Abs. 4 story.append(KeepTogether(block))
# Satz 4 UStAE). Gegenueber Endverbrauchern entsteht sie zwar nicht return
# (EuGH C-378/21, BMF v. 27.02.2024) - aber Firmengaeste und if kopf.get("nur_storno"):
# gewerbliche Dauercamper gibt es auch, und der Nachweis liegt beim # Nur Storno (04.09.2026): die Leistung wird gar nicht abgerechnet.
# Aussteller. Also bleibt der Satz drauf. # Hier darf NICHT von einer berichtigten Rechnung die Rede sein -
# es kommt keine, und der Gast wartet sonst darauf.
block.append(Paragraph( block.append(Paragraph(
f"Dieses Blatt berichtigt die Angaben zur Rechnung " f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
f"{kopf.get('berichtigt_zu','')} vom {kopf.get('datum','')} nach " "Die Leistung wird nicht abgerechnet – es folgt keine weitere "
"§ 31 Abs. 5 UStDV. Leistungen, Beträge und Umsatzsteuer sind " "Rechnung. Bereits gezahlte Beträge werden erstattet.", st["dank"]))
"unverändert – es handelt sich <b>nicht</b> um eine zusätzliche " story.append(KeepTogether(block))
"Leistung und nicht um eine zweite Rechnung. Die Rechnung behält " return
"ihre Nummer.", st["dank"])) kette = (f" Die berichtigte Rechnung trägt die Nummer {folgt} "
if texte.get("dank1"): f"(also {kopf.get('storno_zu','')} → {kopf.get('rechnungsnummer','')} "
block.append(Paragraph(texte["dank1"], st["dank"])) f"→ {folgt}).") if folgt else ""
elif angebot: block.append(Paragraph(
# Dank- und Zahlungstexte waeren hier falsch: der Aufenthalt liegt f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
# noch vor uns, und bezahlt wird nichts. Der Hinweis "bitte die "Bereits gezahlte Beträge werden erstattet oder mit der berichtigten "
# Rechnungsnummer angeben" wuerde dem Blatt sogar widersprechen. "Rechnung verrechnet." + kette, st["dank"]))
block.append(Paragraph( elif art == "berichtigung":
"Dieses Angebot ist freibleibend und unverbindlich (§ 145 BGB) und " # Der Satz ist kein Beiwerk: mehrere Rechnungen ueber dieselbe Leistung
"keine Zahlungsaufforderung. Sagen Sie uns Bescheid, dann reservieren " # ohne Kennzeichnung loesen die Steuer erneut aus (Abschn. 14c.1 Abs. 4
"wir – die Rechnung erhalten Sie nach dem Aufenthalt. Für Rückfragen " # Satz 4 UStAE). Gegenueber Endverbrauchern entsteht sie zwar nicht
"stehen wir gern zur Verfügung.", # (EuGH C-378/21, BMF v. 27.02.2024) - aber Firmengaeste und
st["dank"])) # gewerbliche Dauercamper gibt es auch, und der Nachweis liegt beim
else: # Aussteller. Also bleibt der Satz drauf.
if art == "korrektur" and kopf.get("ersetzt"): block.append(Paragraph(
hinweis = f"Diese Rechnung ersetzt die Rechnung {kopf['ersetzt']}" f"Dieses Blatt berichtigt die Angaben zur Rechnung "
if kopf.get("storno_nummer"): f"{kopf.get('berichtigt_zu','')} vom {kopf.get('datum','')} nach "
hinweis += f", die mit der Stornorechnung {kopf['storno_nummer']} aufgehoben wurde" "§ 31 Abs. 5 UStDV. Leistungen, Beträge und Umsatzsteuer sind "
block.append(Paragraph(hinweis + ".", st["dank"])) "unverändert – es handelt sich <b>nicht</b> um eine zusätzliche "
if texte.get("dank1"): "Leistung und nicht um eine zweite Rechnung. Die Rechnung behält "
block.append(Paragraph(texte["dank1"], st["dank"])) "ihre Nummer.", st["dank"]))
if texte.get("dank2"): if texte.get("dank1"):
block.append(Paragraph(texte["dank2"], st["dank"])) block.append(Paragraph(texte["dank1"], st["dank"]))
if texte.get("hinweis"): elif angebot:
block.append(Paragraph(texte["hinweis"], st["hinweis"])) # Dank- und Zahlungstexte waeren hier falsch: der Aufenthalt liegt
story.append(KeepTogether(block)) # noch vor uns, und bezahlt wird nichts. Der Hinweis "bitte die
# Rechnungsnummer angeben" wuerde dem Blatt sogar widersprechen.
block.append(Paragraph(
"Dieses Angebot ist freibleibend und unverbindlich (§ 145 BGB) und "
"keine Zahlungsaufforderung. Sagen Sie uns Bescheid, dann reservieren "
"wir – die Rechnung erhalten Sie nach dem Aufenthalt. Für Rückfragen "
"stehen wir gern zur Verfügung.",
st["dank"]))
else:
if art == "korrektur" and kopf.get("ersetzt"):
hinweis = f"Diese Rechnung ersetzt die Rechnung {kopf['ersetzt']}"
if kopf.get("storno_nummer"):
hinweis += f", die mit der Stornorechnung {kopf['storno_nummer']} aufgehoben wurde"
block.append(Paragraph(hinweis + ".", st["dank"]))
if texte.get("dank1"):
block.append(Paragraph(texte["dank1"], st["dank"]))
if texte.get("dank2"):
block.append(Paragraph(texte["dank2"], st["dank"]))
if texte.get("hinweis"):
block.append(Paragraph(texte["hinweis"], st["hinweis"]))
story.append(KeepTogether(block))
def _kenndaten(rechnung, kopf): def _kenndaten(rechnung, kopf):
@ -736,52 +693,37 @@ def render_rechnung(pfad, cfg, rechnung, kopf, vorschau=False):
rechnung : berechnung.Rechnung rechnung : berechnung.Rechnung
kopf : dict mit rechnungsnummer, datum, anreise, abreise, naechte, kunde_zeilen kopf : dict mit rechnungsnummer, datum, anreise, abreise, naechte, kunde_zeilen
EIN PDF mit zwei Blaettern (falls Beherbergungssteuer anfaellt): EIN Blatt: die Rechnung fuer den Gast.
Seite 1 = Kunden-Rechnung, vollstaendig (Steuer ausgewiesen, Bankverbindung im Fuss).
Letzte Seite = Blatt fuers Amt: KEINE Beherbergungssteuer (auch nicht im Gesamt), Bis zum 05.09.2026 hing hier ein zweites Blatt "fuer das Amt" mit derselben
keine Zwischensumme, keine Bankverbindung, keine Steuernummer. Rechnungsnummer, aber ohne Beherbergungssteuer - und damit mit einem anderen
Gesamtbetrag. Genau das ist bei einer Pruefung ein Problem: mehrere Blaetter
zum selben Umsatz mit verschiedenen Endbetraegen sehen aus, als werde ueber
verschiedene Umsaetze abgerechnet. Es ist ersatzlos entfallen.
Was die Stadt braucht, kommt aus dem Steuerjournal: es liest die Kenndaten
(subject) dieser PDF und rechnet die Bemessungsgrundlage selbst aus. Die
Anmeldung geht als Summenmeldung ans Amt, nicht als Rechnungskopie mit
Gastnamen.
""" """
_design_anwenden(cfg) # Firmenfarbe VOR den Stilen - sie lesen sie mit _design_anwenden(cfg) # Firmenfarbe VOR den Stilen - sie lesen sie mit
st = _styles() st = _styles()
def bauen(ziel, amt_seite): doc = SimpleDocTemplate(
doc = SimpleDocTemplate( pfad, pagesize=A4,
ziel, pagesize=A4, leftMargin=MARGIN_L, rightMargin=MARGIN_R,
leftMargin=MARGIN_L, rightMargin=MARGIN_R, topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT,
topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT, title=({"storno": "Stornorechnung",
title=({"storno": "Stornorechnung", "angebot": "Angebot"}.get(kopf.get("art"), "Rechnung"))
"angebot": "Angebot"}.get(kopf.get("art"), "Rechnung")) + f" {kopf.get('rechnungsnummer','')}",
+ f" {kopf.get('rechnungsnummer','')}", author=cfg["firma"].get("name", ""),
author=cfg["firma"].get("name", ""), subject=_kenndaten(rechnung, kopf), # maschinenlesbar fuers Steuerjournal
subject=_kenndaten(rechnung, kopf), # maschinenlesbar fuers Steuerjournal )
) story = []
story = [] _seite_inhalt(story, st, cfg, rechnung, kopf)
_seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=False) # Kunde
# Ein Angebot bekommt KEIN Blatt fuers Amt: gemeldet wird eine
# Uebernachtung erst, wenn sie abgerechnet ist.
if rechnung.aufschlaege and kopf.get("art") != "angebot":
story.append(PageBreak())
story.append(_AmtMarke(marke))
_seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=True) # Amt
def seite(c, d): def seite(c, d):
_kopf_fuss(c, d, cfg, _kopf_fuss(c, d, cfg, vorschau=vorschau)
amt=(amt_seite is not None and c.getPageNumber() >= amt_seite),
vorschau=vorschau)
doc.build(story, onFirstPage=seite, onLaterPages=seite) doc.build(story, onFirstPage=seite, onLaterPages=seite)
# Zwei Durchlaeufe, und zwar aus einem konkreten Grund:
#
# Kopf und Fuss werden VOR dem Seiteninhalt gezeichnet - beim Zeichnen weiss die
# Seite also noch nicht, ob auf ihr gleich die Kunden- oder die Amts-Seite landet.
# Auf "Seite 2 ist das Amt" zu wetten, waere falsch, sobald die Kundenrechnung
# laenger als eine Seite wird: dann stuende die Bankverbindung ploetzlich auf dem
# Amt-Blatt und fehlte beim Kunden.
#
# Darum: erster Lauf ins Nichts, nur um zu erfahren, auf welcher Seite das
# Amt-Blatt beginnt. Zweiter Lauf schreibt die echte Datei.
marke = {}
bauen(io.BytesIO(), None)
bauen(pfad, marke.get("seite"))
return pfad return pfad

168
pruef_api.py Normal file
View file

@ -0,0 +1,168 @@
# -*- coding: utf-8 -*-
"""Pruefstand: das Fenster ueber die API bedienen - mit Schluessel.
Warum es das gibt (seine Ansage): *"bau dir steuer logik per api rein, dann
kannst damit die sachen triggern, brauchst meine maus nicht"* und *"mach pw
rein, also schluessel wie bei ssh key, das nur du und das beherbergungssteuer
ding rein kann, weil muss ja sprechen"*.
Geprueft wird:
1. Ohne Schluessel geht NICHTS - auch nicht "nur nachsehen".
2. Mit falschem Schluessel ebenfalls nicht.
3. Mit dem Schluessel aus dem gemeinsamen Ordner laesst sich das Fenster
fuellen, nachlesen und eine Vorschau erzeugen.
4. Was einen BELEG schreibt, geht nur im Testlauf - und schreibt dann
tatsaechlich die Rechnung.
5. ping und schau_nach bleiben schluessellos, sonst kann ein aelteres
Steuerjournal nicht mehr anklopfen.
"""
import json
import os
import shutil
import sys
import tempfile
import threading
QUELLE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, QUELLE)
tmp = tempfile.mkdtemp(prefix="api_pruef_")
ordner = os.path.join(tmp, "rechnungen")
gemeinsam_ordner = os.path.join(tmp, "install")
os.makedirs(ordner)
os.makedirs(gemeinsam_ordner)
import config # noqa: E402
shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"))
config.BASE_DIR = tmp
config.CONFIG_PATH = os.path.join(tmp, "config.json")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0
roh["output_dir"] = ordner
roh["gemeinsam_ordner"] = gemeinsam_ordner
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
os.environ["CAMPINGHOF_TEST"] = "1"
import api # noqa: E402
import app as A # noqa: E402
import gemeinsam # noqa: E402
from tkinter import messagebox # noqa: E402
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
os._exit(2)
_wecker = threading.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
k = sys.stdout.encoding or "utf-8"
print(zeile.encode(k, "replace").decode(k))
if not bedingung:
fehler.append(text)
messagebox.showinfo = messagebox.showwarning = messagebox.showerror = lambda *a, **k: None
messagebox.askyesno = lambda *a, **k: True
f = A.RechnungsApp()
f.cfg["output_dir"] = ordner
f.buch = gemeinsam.Nummernbuch(gemeinsam_ordner)
f.api.starten(f.buch, ordner=gemeinsam_ordner)
f.update()
port = f.api.port
pruefe(bool(port), f"die API laeuft ({port})")
schluessel = api.schluessel_holen(gemeinsam_ordner)
pruefe(len(schluessel) == 64, f"ein Schluessel liegt im gemeinsamen Ordner ({len(schluessel)} Zeichen)")
pruefe(os.path.exists(os.path.join(gemeinsam_ordner, api.SCHLUESSEL_DATEI)),
"und zwar als Datei neben dem Nummernbuch")
pruefe(api.schluessel_holen(gemeinsam_ordner) == schluessel,
"beim zweiten Lesen kommt derselbe zurueck (kein neuer je Start)")
def rufen(**nachricht):
"""Direkt anrufen - der Pruefstand spielt hier das andere Programm."""
was = nachricht.pop("was", "aktion")
return api._reden(port, was, zeitgrenze=20.0, **nachricht)
# Das Fenster muss waehrend des Anrufs weiterlaufen: die Aktion wird in seinem
# Faden ausgefuehrt. Also nebenher anrufen und die Ereignisschleife drehen.
def anrufen_und_drehen(**nachricht):
ergebnis = {}
def anruf():
ergebnis["antwort"] = rufen(**nachricht)
t = threading.Thread(target=anruf, daemon=True)
t.start()
while t.is_alive():
f.update()
return ergebnis.get("antwort")
# ---- 1. + 2. Ohne (oder mit falschem) Schluessel: kein Zutritt -------------
pruefe(anrufen_und_drehen(name="zustand") == "kein zutritt",
"ohne Schluessel: kein Zutritt")
pruefe(anrufen_und_drehen(name="zustand", schluessel="0" * 64) == "kein zutritt",
"falscher Schluessel: kein Zutritt")
# ---- 5. Die alten zwei Woerter bleiben schluessellos -----------------------
pruefe(anrufen_und_drehen(was="ping") == api.ANTWORT_HALLO,
"ping antwortet weiterhin ohne Schluessel")
# ---- 3. Mit Schluessel: fuellen, nachlesen, Vorschau -----------------------
antwort = anrufen_und_drehen(
name="setze", schluessel=schluessel,
daten={"kunde": {"vorname": "Anna", "nachname": "Beispiel",
"strasse": "Musterweg 1", "plzort": "12345 Musterstadt"},
"anreise": "01.09.2026", "abreise": "04.09.2026",
"positionen": [{"nr": sorted(f.anzahl_vars)[0], "anzahl": 3}]})
pruefe(isinstance(antwort, dict) and antwort.get("kunde", {}).get("nachname") == "Beispiel",
f"mit Schluessel laesst sich das Formular fuellen ({str(antwort)[:60]}…)")
pruefe(antwort.get("gesamt", 0) > 0, f"und es ist gerechnet ({antwort.get('gesamt')})")
antwort = anrufen_und_drehen(name="vorschau", schluessel=schluessel)
pfad = (antwort or {}).get("pfad", "")
pruefe(bool(pfad) and os.path.exists(pfad), f"die Vorschau entsteht ohne Maus ({os.path.basename(pfad)})")
pruefe(not os.listdir(ordner), "und sie landet NICHT im Rechnungsordner")
# ---- 4. Beleg schreiben ---------------------------------------------------
antwort = anrufen_und_drehen(name="rechnung", schluessel=schluessel)
pdfs = [n for n in os.listdir(ordner) if n.lower().endswith(".pdf")]
pruefe(len(pdfs) == 1, f"im Testlauf schreibt die API die Rechnung ({pdfs})")
# Und im ECHTEN Betrieb nicht: dieselbe Aktion, aber ohne Testlauf-Flag.
A.TESTLAUF = False
antwort = anrufen_und_drehen(name="rechnung", schluessel=schluessel)
pruefe(isinstance(antwort, dict) and "nur im Testlauf" in str(antwort.get("fehler", "")),
f"im echten Betrieb verweigert sie den Beleg ({antwort})")
pruefe(len([n for n in os.listdir(ordner) if n.lower().endswith(".pdf")]) == 1,
"und es kam keine zweite Rechnung dazu")
A.TESTLAUF = True
# Unbekannter Name sagt, was es gibt - statt still nichts zu tun.
antwort = anrufen_und_drehen(name="tanz", schluessel=schluessel)
pruefe("kenne ich nicht" in str(antwort), f"unbekannte Aktion wird benannt ({str(antwort)[:50]}…)")
f.api.stoppen(f.buch)
f.destroy()
print()
if fehler:
print(f"FEHLGESCHLAGEN: {len(fehler)}")
for x in fehler:
print(" -", x)
print(f"(Ordner bleibt: {tmp})")
sys.exit(1)
shutil.rmtree(tmp, ignore_errors=True)
print("ALLES GRUEN")

View file

@ -98,13 +98,12 @@ def seiten_bilder_und_text(pdf):
seiten = seiten_bilder_und_text(pfad) seiten = seiten_bilder_und_text(pfad)
pruefe(len(seiten) >= 2, f"Kundenblatt + Amt-Blatt: {len(seiten)} Seiten") pruefe(len(seiten) == 1, f"genau EIN Blatt ({len(seiten)} Seiten)")
kunde_bilder, kunde_text = seiten[0] kunde_bilder, kunde_text = seiten[0]
amt_bilder, amt_text = seiten[-1] pruefe(kunde_bilder == 2, f"das Blatt traegt Kopf- UND Fussbild ({kunde_bilder})")
pruefe(kunde_bilder == 2, f"Kundenblatt traegt Kopf- UND Fussbild ({kunde_bilder})") pruefe("Testbank" in kunde_text, "Bankverbindung steht auf der Rechnung")
pruefe(amt_bilder == 1, f"Amt-Blatt traegt NUR das Kopfbild ({amt_bilder})") pruefe("Steuernummer" in kunde_text,
pruefe("Testbank" in kunde_text, "Bankverbindung steht auf der Kundenrechnung") "die Steuernummer steht auf der Rechnung (§ 14 Abs. 4 Nr. 2 UStG)")
pruefe("Testbank" not in amt_text, "Bankverbindung steht NICHT auf dem Amt-Blatt")
# ---- 3. Die Firmenfarbe faerbt das Blatt ---------------------------------- # ---- 3. Die Firmenfarbe faerbt das Blatt ----------------------------------
# Jede Firma faerbt ihre Rechnung selbst. Geprueft wird, dass die gewaehlte # Jede Firma faerbt ihre Rechnung selbst. Geprueft wird, dass die gewaehlte

View file

@ -155,7 +155,10 @@ pruefe(len(pos) == 1 and float(pos[0]["anzahl"]) == 1.0, f"eine Position, ein Mo
from pypdf import PdfReader # noqa: E402 from pypdf import PdfReader # noqa: E402
seiten = [s.extract_text() or "" for s in PdfReader(pdf).pages] seiten = [s.extract_text() or "" for s in PdfReader(pdf).pages]
pruefe(len(seiten) == 2, f"zwei Seiten - Kunde und Amt ({len(seiten)})") # Seit 05.09.2026 gibt es nur noch EIN Blatt. Ein zweites mit derselben Nummer,
# aber anderem Gesamtbetrag ("fuer das Amt") sieht bei einer Pruefung aus, als
# werde ueber zwei verschiedene Umsaetze abgerechnet.
pruefe(len(seiten) == 1, f"genau EIN Blatt - kein zweites fuers Amt ({len(seiten)})")
kunde_seite = seiten[0].replace("\n", " ") kunde_seite = seiten[0].replace("\n", " ")
pruefe("Miete für Montagearbeiter" in kunde_seite, "die Miet-Zeile steht auf dem Blatt") pruefe("Miete für Montagearbeiter" in kunde_seite, "die Miet-Zeile steht auf dem Blatt")
pruefe("Zeitraum 01.08.2026" in kunde_seite, "mit dem Zeitraum") pruefe("Zeitraum 01.08.2026" in kunde_seite, "mit dem Zeitraum")
@ -166,9 +169,8 @@ pruefe("Tage/Menge" not in kunde_seite and "Pos." not in kunde_seite,
pruefe("525,00" in kunde_seite and "500,00" in kunde_seite, pruefe("525,00" in kunde_seite and "500,00" in kunde_seite,
"500,00 und 525,00 stehen drauf") "500,00 und 525,00 stehen drauf")
pruefe("Beherbergungssteuer" in kunde_seite, "die Steuer ist auf der Kundenseite ausgewiesen") pruefe("Beherbergungssteuer" in kunde_seite, "die Steuer ist auf der Kundenseite ausgewiesen")
amt_seite = seiten[1].replace("\n", " ") pruefe(kunde_seite.count("Rechnungsbetrag Insgesamt") == 1,
pruefe("Beherbergungssteuer" not in amt_seite, "der Gesamtbetrag steht genau einmal in der Datei")
"auf dem Amtsblatt steht sie nicht - wie bei jeder anderen Rechnung")
# ---- 4. Das Steuerjournal liest die Datei --------------------------------- # ---- 4. Das Steuerjournal liest die Datei ---------------------------------
sys.path.append(JOURNAL_PROG) sys.path.append(JOURNAL_PROG)

View file

@ -228,6 +228,17 @@ def erststart(zahl, erwartet_ja=True):
for k in knoepfe_von(top): for k in knoepfe_von(top):
if "bernehmen" in str(k.cget("text")): if "bernehmen" in str(k.cget("text")):
k.invoke() k.invoke()
# Lehnt der Dialog die Zahl ab, BLEIBT er offen - das ist
# gewollt und wird weiter unten geprueft. Dann muss hier
# aber, wie ein Benutzer, "Spaeter" gedrueckt werden:
# sonst wartet das Programm in wait_window() ewig auf
# einen Klick, den im Pruefstand niemand macht.
top.update()
if top.winfo_exists():
for spaeter in knoepfe_von(top):
if "ter" in str(spaeter.cget("text")):
spaeter.invoke()
break
return return
if rest: if rest:
f.after(100, lambda: klick(rest - 1)) f.after(100, lambda: klick(rest - 1))

103
pruefsteuerung.py Normal file
View file

@ -0,0 +1,103 @@
# -*- coding: utf-8 -*-
"""Steuerung fuer Pruefstaende: keine Fenster, keine Popups, keine Maus.
WARUM
-----
Ein Pruefstand baut das Programm auf, um es zu BEDIENEN - nicht, um es zu
zeigen. Solange er wie ein Benutzer arbeitet, reisst jeder Lauf dem Menschen am
Rechner Fokus und Maus weg, und jeder Dialog bleibt stehen und wartet auf einen
Klick, den niemand macht.
Deshalb zwei Dinge, beide nur im Testlauf (`--test` / `CAMPINGHOF_TEST=1`):
* Das Fenster wird gar nicht erst gezeigt (`withdraw()` in app.py).
* Alle Dialoge laufen hierueber statt ueber echte Fenster.
WAS EIN DIALOG HIER TUT
-----------------------
`showinfo/showwarning/showerror` werden ins PROTOKOLL geschrieben und
verschwinden. `askyesno` und Verwandte schlagen im Fahrplan nach:
pruefsteuerung.antwortet("Hausnummer", True)
Passt kein Eintrag, bricht der Lauf mit einer Meldung ab - ABSICHTLICH. Ein
unbekannter Dialog heisst: das Programm fragt etwas Neues, und niemand hat
entschieden, was der Pruefstand darauf antworten soll. Stillschweigend "ja" zu
sagen waere die faule Loesung und wuerde genau die Faelle uebergehen, wegen
denen es die Pruefung gibt.
BENUTZUNG IM PRUEFSTAND
-----------------------
import pruefsteuerung as PS
PS.antwortet("Storno wirklich", True)
...
PS.gemeldet("Kundenangaben fehlen") -> True, wenn das Programm es sagte
"""
from tkinter import messagebox
# Was das Programm gemeldet haette: Liste aus (art, titel, text).
PROTOKOLL = []
# Fahrplan fuer Ja/Nein-Fragen: Textstueck im Titel -> Antwort.
ANTWORTEN = {}
_aktiv = False
def antwortet(titelstueck, wert):
"""Legt fest, wie eine Frage beantwortet wird, deren Titel das Stueck enthaelt."""
ANTWORTEN[titelstueck] = wert
def zuruecksetzen():
PROTOKOLL.clear()
ANTWORTEN.clear()
def meldungen(stueck=""):
"""Alle Meldungen (oder die, in denen das Stueck vorkommt)."""
return [m for m in PROTOKOLL if stueck in m[1] or stueck in m[2]]
def gemeldet(stueck):
"""Hat das Programm etwas gemeldet, in dem das Stueck vorkommt?"""
return bool(meldungen(stueck))
def _melden(art):
def gemeldet_(titel="", text="", **_kw):
PROTOKOLL.append((art, str(titel), str(text)))
print(f"[dialog] {art}: {titel}")
return "ok"
return gemeldet_
def _fragen(vorgabe=None):
def gefragt(titel="", text="", **_kw):
PROTOKOLL.append(("frage", str(titel), str(text)))
for stueck, wert in ANTWORTEN.items():
if stueck in str(titel) or stueck in str(text):
print(f"[dialog] frage: {titel} -> {wert}")
return wert
if vorgabe is not None:
return vorgabe
raise AssertionError(
f"unangemeldeter Dialog: {titel!r}\n"
f" {text!r}\n"
" -> im Pruefstand mit pruefsteuerung.antwortet(<Stueck>, True/False) "
"beantworten")
return gefragt
def einschalten():
"""Dialoge umleiten. Mehrfaches Aufrufen schadet nicht."""
global _aktiv
if _aktiv:
return
messagebox.showinfo = _melden("info")
messagebox.showwarning = _melden("warnung")
messagebox.showerror = _melden("fehler")
messagebox.askyesno = _fragen()
messagebox.askokcancel = _fragen()
messagebox.askretrycancel = _fragen(False)
messagebox.askquestion = _fragen()
_aktiv = True
print("[pruefsteuerung] Dialoge laufen ohne Fenster")

View file

@ -9,6 +9,8 @@ import sys
import tkinter as tk import tkinter as tk
from tkinter import ttk from tkinter import ttk
import config
def windows_dark_mode(): def windows_dark_mode():
"""True, wenn Windows auf das dunkle App-Theme eingestellt ist.""" """True, wenn Windows auf das dunkle App-Theme eingestellt ist."""
@ -113,7 +115,12 @@ class ThemeMixin:
self.option_add("*foreground", fg) self.option_add("*foreground", fg)
def _style_dialog(self, d): def _style_dialog(self, d):
"""Toplevel-Dialog an das Theme angleichen.""" """Toplevel-Dialog an das Theme angleichen.
Im Pruefstand wird derselbe Dialog gebaut und bedient, aber nicht
gezeigt - und sein grab_set faellt weg. Sonst haelt jeder Dialog Maus
und Tastatur fest, waehrend nebenan jemand schreibt.
"""
d.configure(bg=self.col_bg) d.configure(bg=self.col_bg)
# erst nach dem Anzeigen setzen, sonst greift DWM noch nicht # erst nach dem Anzeigen setzen, sonst greift DWM noch nicht
d.after(20, lambda: self._dark_titlebar(d)) d.after(20, lambda: self._dark_titlebar(d))