diff --git a/AUFGABE_RECHTSSICHER_BILDER_VORSCHAU.md b/AUFGABE_RECHTSSICHER_BILDER_VORSCHAU.md
new file mode 100644
index 0000000..38903bb
--- /dev/null
+++ b/AUFGABE_RECHTSSICHER_BILDER_VORSCHAU.md
@@ -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).
diff --git a/api.py b/api.py
index 0a2a2a8..98eff1e 100644
--- a/api.py
+++ b/api.py
@@ -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
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": "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
Meldung ohnehin. Damit gibt es genau EINE Wahrheit, und wenn der Anruf nicht
durchgeht (Programm zu, Port belegt), findet das andere die Meldung beim
@@ -42,6 +69,8 @@ hinueber.
import json
import logging
import os
+import queue
+import secrets
import socket
import socketserver
import sys
@@ -50,6 +79,33 @@ import threading
log = logging.getLogger("api")
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):
@@ -66,14 +122,17 @@ class _Handler(socketserver.StreamRequestHandler):
if not zeile:
return
try:
- was = str(json.loads(zeile).get("was", ""))
+ nachricht = json.loads(zeile)
+ was = str(nachricht.get("was", ""))
except (ValueError, AttributeError):
- was = ""
+ nachricht, was = {}, ""
if was == "ping":
antwort = ANTWORT_HALLO
elif was == "schau_nach":
antwort = "ok"
self.server.melden() # Fenster benachrichtigen (threadsicher)
+ elif was == "aktion":
+ antwort = self._aktion(nachricht)
else:
antwort = "haeh?"
try:
@@ -82,12 +141,35 @@ class _Handler(socketserver.StreamRequestHandler):
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):
allow_reuse_address = True
daemon_threads = True
- def __init__(self, adresse, bei_push):
+ def __init__(self, adresse, bei_push, aktion=None, schluessel=""):
self.melden = bei_push
+ self.aktion = aktion
+ self.schluessel = schluessel
super().__init__(adresse, _Handler)
@@ -99,19 +181,30 @@ class MiniApi:
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.bei_push = bei_push
+ self.aktion = aktion # (name, daten) -> Antwort; None = keine Bedienung
self.server = None
self.faden = None
self.port = 0
+ self.schluessel = ""
- def starten(self, buch):
- """Server aufmachen und im Telefonbuch (SQLite) eintragen."""
+ def starten(self, buch, ordner=None):
+ """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:
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:
- 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:
log.warning("API konnte nicht starten: %s", e)
self.server = None
@@ -150,12 +243,14 @@ class MiniApi:
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."""
+ nachricht = {"was": was}
+ nachricht.update(rest)
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"))
+ s.sendall((json.dumps(nachricht) + chr(10)).encode("utf-8"))
roh = s.makefile("r", encoding="utf-8").readline()
return json.loads(roh).get("antwort", "")
except (OSError, ValueError):
@@ -192,3 +287,69 @@ def anklopfen(buch, programm):
if not eintrag or not eintrag.get("port"):
return False
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()
diff --git a/app.py b/app.py
index 534c3e0..cf23915 100644
--- a/app.py
+++ b/app.py
@@ -70,7 +70,13 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
# Das gemeinsame Nummernbuch (siehe gemeinsam.py). None = noch keiner
# gesetzt; das Programm laeuft dann weiter, warnt aber sichtbar.
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()
# Zwischenspeicher fuer eine reine Berichtigung (§ 31 Abs. 5 UStDV):
# 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?",
font=("Segoe UI", 13, "bold")).grid(row=0, column=0, columnspan=2, sticky="w")
ttk.Label(rahmen, wraplength=620, justify="left", text=(
- "Trag die zuletzt vergebene Nummer ein – die aus deiner bisherigen Liste. "
- "Die nächste Rechnung bekommt dann die Nummer danach.\n\n"
+ "Zwei Wege - such dir einen aus." + chr(10) + chr(10) +
"Das wird EINMAL eingerichtet. Danach steht der Zähler fest: eine "
"ausgestellte Rechnung wird nur noch über Storno oder Berichtigung "
"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(
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")
- ttk.Entry(rahmen, textvariable=wert, width=10, justify="center").grid(
- row=3, column=1, sticky="w", padx=(8, 0))
+ # Zwei Wege statt einer Zahl: neu anfangen oder die eigene Zaehlung
+ # weiterfuehren. Die Frage "trag die zuletzt vergebene Nummer ein" holt
+ # 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",
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():
- try:
- zahl = int(parse_num(wert.get()))
- except (TypeError, ValueError):
- zahl = -1
- if zahl < gefunden:
- hinweis.configure(text=f"Mindestens {gefunden} – so weit ist schon vergeben.")
- return
+ if weg.get() == "null":
+ zahl = 0
+ else:
+ try:
+ zahl = int(parse_num(wert.get()))
+ except (TypeError, ValueError):
+ zahl = -1
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
if not messagebox.askyesno(
"Sind Sie sicher?",
@@ -680,7 +712,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
d.destroy()
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="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, 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):
"""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],
}
- def vorschau_zeigen(self):
+ def vorschau_zeigen(self, oeffnen=True):
"""Zeigt das Blatt, BEVOR es eine Rechnung wird.
Eine ausgestellte Rechnung laesst sich nur noch ueber einen Storno
@@ -1142,10 +1255,13 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
try:
pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf, vorschau=True)
print(f"[vorschau] geschrieben -> {pfad}")
- os.startfile(pfad) # noqa: S606
+ if oeffnen:
+ os.startfile(pfad) # noqa: S606
+ return pfad
except OSError as e:
messagebox.showerror("Vorschau nicht möglich",
f"Die Vorschau konnte nicht geöffnet werden:\n{e}")
+ return ""
def pdf_erstellen(self, angebot=False):
montage = self.ist_montage()
diff --git a/config.py b/config.py
index e3058b1..6b4d98e 100644
--- a/config.py
+++ b/config.py
@@ -71,6 +71,13 @@ def _daten_ordner():
BASE_DIR = _daten_ordner()
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).
# satz = Umsatzsteuersatz in Prozent (7 = Beherbergung, 19 = Zusatzleistung)
# preis = Brutto-Einzelpreis in Euro (Endpreis inkl. USt)
diff --git a/pdf_renderer.py b/pdf_renderer.py
index 28daf93..b421865 100644
--- a/pdf_renderer.py
+++ b/pdf_renderer.py
@@ -10,7 +10,6 @@ im Layout der Excel-Vorlage:
"""
import os
-import io
import json
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.styles import ParagraphStyle
from reportlab.platypus import (
- SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, KeepTogether, PageBreak,
- Flowable,
+ SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, KeepTogether,
)
import bilder
@@ -28,23 +26,6 @@ import config
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 ---------------------------------------------------------
# Die Akzentfarbe gehoert der Firma, nicht dem Programm: Ueberschriften, die
# Linie unter dem Kopf und der Tabellenkopf tragen sie. Ein Handwerker will kein
@@ -157,8 +138,8 @@ def _vorschau_marke(canvas):
canvas.restoreState()
-def _kopf_fuss(canvas, doc, cfg, amt=False, vorschau=False):
- """Kopf und Fuss einer Seite. amt=True -> Blatt fuer das Amt (siehe Fusszeile)."""
+def _kopf_fuss(canvas, doc, cfg, vorschau=False):
+ """Kopf und Fuss einer Seite."""
firma = cfg["firma"]
canvas.saveState()
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)
# --- Fusszeile ---
- #
- # STRIKTE TRENNUNG Kunde / Amt:
- # 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.
+ # Bankverbindung und Steuernummer MUESSEN drauf: das ist die Rechnung, die der
+ # Gast bezahlt, und die Steuernummer ist Pflichtangabe (§ 14 Abs. 4 Nr. 2 UStG).
fy = MARGIN_BOT - 4 * mm
canvas.setStrokeColor(LINE)
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_r = PAGE_W - MARGIN_R
- # Das Fusszeilen-Bild steht auf dem AMT-BLATT NICHT. Ein fertiges
- # 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"))
+ fuss_img, fiw, fih = bilder.laden(firma.get("fussbild"))
if fuss_img:
# Schmaler Streifen UNTER den Fusstexten. Die Texte bleiben, wo sie sind -
# 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,
width=dw, height=dh, preserveAspectRatio=True, mask="auto")
- rows_l = [] if amt else [firma.get("bank", ""), firma.get("iban", ""), firma.get("bic", "")]
- rows_m = [firma.get("inhaber", "")] if amt else [firma.get("inhaber", ""),
- firma.get("steuernummer", "")]
+ rows_l = [firma.get("bank", ""), firma.get("iban", ""), firma.get("bic", "")]
+ rows_m = [firma.get("inhaber", ""), firma.get("steuernummer", "")]
rows_r = [firma.get("homepage", ""), firma.get("email", ""), firma.get("telefon", "")]
for i, t in enumerate(rows_l):
@@ -327,12 +297,8 @@ def _montage_zeile(story, st, rechnung, kopf):
story.append(dreier)
-def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
- """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.
- """
+def _seite_inhalt(story, st, cfg, rechnung, kopf):
+ """Baut den kompletten Seiteninhalt (Absender -> Positionen -> Summen -> Danktexte)."""
# Absenderzeile (klein, unterstrichen)
story.append(Paragraph(
f"{cfg['firma'].get('absender','')}",
@@ -428,7 +394,6 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
titel = {"storno": "Stornorechnung",
"berichtigung": "Berichtigte Rechnung",
"angebot": "Unverbindliches Angebot"}.get(art, "Rechnungsübersicht")
- titel += " (für das Amt)" if gesamtrechnung_betont else ""
story.append(Paragraph(titel, st["h"]))
if angebot:
# 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
# noch Beherbergungssteuer. Es steht nur, was der Gast voraussichtlich
# 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([
Paragraph("Zwischensumme:", st["info_l"]),
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"]),
])
- endsumme = rechnung.brutto if gesamtrechnung_betont else rechnung.gesamt
+ endsumme = rechnung.gesamt
summen_rows.append([
Paragraph("Voraussichtlicher Gesamtbetrag:" if angebot else
"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))
story.append(summen)
- # Hinweis zur Beherbergungssteuer - nur auf der Kundenseite. Auf dem Amt-Blatt
- # wird die Steuer gar nicht mehr ausgewiesen, dann waere der Satz sinnlos.
+ # Hinweis zur Beherbergungssteuer.
if angebot:
story.append(Spacer(1, 1.5 * mm))
story.append(Paragraph(
"In diesem Betrag sind alle Steuern und Abgaben enthalten. Aufgeschlüsselt "
"werden sie erst in der Rechnung nach dem Aufenthalt.", st["dank"]))
- elif not gesamtrechnung_betont:
+ else:
for a in rechnung.aufschlaege:
if not a.bezeichnung:
continue
@@ -534,90 +498,83 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
story.append(Spacer(1, 8 * mm))
# ---- Schlusstexte -------------------------------------------------------
- # Dank- und Zahlungstexte gehoeren nur auf die Kundenseite. Die
- # STORNO-Erklaerung gehoert aber AUCH aufs Amtsblatt: dort steht sonst eine
- # Stornonummer neben einer voellig anderen Rechnungsnummer, und niemand
- # erfaehrt, warum. Sein Fund am 04.09.2026: *"in der pdf wird der satz nicht
- # angezeigt, da ist dann ein sprung in den rechnungs nummern"* - genau das
- # Blatt, das die Stadt bekommt, war der Beleg ohne Begruendung.
- if not gesamtrechnung_betont or art == "storno":
- texte = cfg.get("texte", {})
- block = []
- if art == "storno":
- # Auf einem Storno sind Dank- und Zahlungstexte falsch - hier wird
- # nichts geschuldet, sondern aufgehoben.
- bezug = kopf.get("storno_zu", "")
- 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 ""
+ texte = cfg.get("texte", {})
+ block = []
+ if art == "storno":
+ # Auf einem Storno sind Dank- und Zahlungstexte falsch - hier wird
+ # nichts geschuldet, sondern aufgehoben.
+ bezug = kopf.get("storno_zu", "")
+ 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. "
- "Bereits gezahlte Beträge werden erstattet oder mit der berichtigten "
- "Rechnung verrechnet." + kette, st["dank"]))
- elif art == "berichtigung":
- # Der Satz ist kein Beiwerk: mehrere Rechnungen ueber dieselbe Leistung
- # ohne Kennzeichnung loesen die Steuer erneut aus (Abschn. 14c.1 Abs. 4
- # Satz 4 UStAE). Gegenueber Endverbrauchern entsteht sie zwar nicht
- # (EuGH C-378/21, BMF v. 27.02.2024) - aber Firmengaeste und
- # gewerbliche Dauercamper gibt es auch, und der Nachweis liegt beim
- # Aussteller. Also bleibt der Satz drauf.
+ 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"Dieses Blatt berichtigt die Angaben zur Rechnung "
- f"{kopf.get('berichtigt_zu','')} vom {kopf.get('datum','')} nach "
- "§ 31 Abs. 5 UStDV. Leistungen, Beträge und Umsatzsteuer sind "
- "unverändert – es handelt sich nicht um eine zusätzliche "
- "Leistung und nicht um eine zweite Rechnung. Die Rechnung behält "
- "ihre Nummer.", st["dank"]))
- if texte.get("dank1"):
- block.append(Paragraph(texte["dank1"], st["dank"]))
- elif angebot:
- # Dank- und Zahlungstexte waeren hier falsch: der Aufenthalt liegt
- # 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))
+ 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(
+ f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
+ "Bereits gezahlte Beträge werden erstattet oder mit der berichtigten "
+ "Rechnung verrechnet." + kette, st["dank"]))
+ elif art == "berichtigung":
+ # Der Satz ist kein Beiwerk: mehrere Rechnungen ueber dieselbe Leistung
+ # ohne Kennzeichnung loesen die Steuer erneut aus (Abschn. 14c.1 Abs. 4
+ # Satz 4 UStAE). Gegenueber Endverbrauchern entsteht sie zwar nicht
+ # (EuGH C-378/21, BMF v. 27.02.2024) - aber Firmengaeste und
+ # gewerbliche Dauercamper gibt es auch, und der Nachweis liegt beim
+ # Aussteller. Also bleibt der Satz drauf.
+ block.append(Paragraph(
+ f"Dieses Blatt berichtigt die Angaben zur Rechnung "
+ f"{kopf.get('berichtigt_zu','')} vom {kopf.get('datum','')} nach "
+ "§ 31 Abs. 5 UStDV. Leistungen, Beträge und Umsatzsteuer sind "
+ "unverändert – es handelt sich nicht um eine zusätzliche "
+ "Leistung und nicht um eine zweite Rechnung. Die Rechnung behält "
+ "ihre Nummer.", st["dank"]))
+ if texte.get("dank1"):
+ block.append(Paragraph(texte["dank1"], st["dank"]))
+ elif angebot:
+ # Dank- und Zahlungstexte waeren hier falsch: der Aufenthalt liegt
+ # 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):
@@ -736,52 +693,37 @@ def render_rechnung(pfad, cfg, rechnung, kopf, vorschau=False):
rechnung : berechnung.Rechnung
kopf : dict mit rechnungsnummer, datum, anreise, abreise, naechte, kunde_zeilen
- EIN PDF mit zwei Blaettern (falls Beherbergungssteuer anfaellt):
- Seite 1 = Kunden-Rechnung, vollstaendig (Steuer ausgewiesen, Bankverbindung im Fuss).
- Letzte Seite = Blatt fuers Amt: KEINE Beherbergungssteuer (auch nicht im Gesamt),
- keine Zwischensumme, keine Bankverbindung, keine Steuernummer.
+ EIN Blatt: die Rechnung fuer den Gast.
+
+ Bis zum 05.09.2026 hing hier ein zweites Blatt "fuer das Amt" mit derselben
+ 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
st = _styles()
- def bauen(ziel, amt_seite):
- doc = SimpleDocTemplate(
- ziel, pagesize=A4,
- leftMargin=MARGIN_L, rightMargin=MARGIN_R,
- topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT,
- title=({"storno": "Stornorechnung",
- "angebot": "Angebot"}.get(kopf.get("art"), "Rechnung"))
- + f" {kopf.get('rechnungsnummer','')}",
- author=cfg["firma"].get("name", ""),
- subject=_kenndaten(rechnung, kopf), # maschinenlesbar fuers Steuerjournal
- )
- story = []
- _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
+ doc = SimpleDocTemplate(
+ pfad, pagesize=A4,
+ leftMargin=MARGIN_L, rightMargin=MARGIN_R,
+ topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT,
+ title=({"storno": "Stornorechnung",
+ "angebot": "Angebot"}.get(kopf.get("art"), "Rechnung"))
+ + f" {kopf.get('rechnungsnummer','')}",
+ author=cfg["firma"].get("name", ""),
+ subject=_kenndaten(rechnung, kopf), # maschinenlesbar fuers Steuerjournal
+ )
+ story = []
+ _seite_inhalt(story, st, cfg, rechnung, kopf)
- def seite(c, d):
- _kopf_fuss(c, d, cfg,
- amt=(amt_seite is not None and c.getPageNumber() >= amt_seite),
- vorschau=vorschau)
+ def seite(c, d):
+ _kopf_fuss(c, d, cfg, vorschau=vorschau)
- 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"))
+ doc.build(story, onFirstPage=seite, onLaterPages=seite)
return pfad
diff --git a/pruef_api.py b/pruef_api.py
new file mode 100644
index 0000000..ac9d217
--- /dev/null
+++ b/pruef_api.py
@@ -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")
diff --git a/pruef_bilder.py b/pruef_bilder.py
index 16bbe75..05b1c1f 100644
--- a/pruef_bilder.py
+++ b/pruef_bilder.py
@@ -98,13 +98,12 @@ def seiten_bilder_und_text(pdf):
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]
-amt_bilder, amt_text = seiten[-1]
-pruefe(kunde_bilder == 2, f"Kundenblatt traegt Kopf- UND Fussbild ({kunde_bilder})")
-pruefe(amt_bilder == 1, f"Amt-Blatt traegt NUR das Kopfbild ({amt_bilder})")
-pruefe("Testbank" in kunde_text, "Bankverbindung steht auf der Kundenrechnung")
-pruefe("Testbank" not in amt_text, "Bankverbindung steht NICHT auf dem Amt-Blatt")
+pruefe(kunde_bilder == 2, f"das Blatt traegt Kopf- UND Fussbild ({kunde_bilder})")
+pruefe("Testbank" in kunde_text, "Bankverbindung steht auf der Rechnung")
+pruefe("Steuernummer" in kunde_text,
+ "die Steuernummer steht auf der Rechnung (§ 14 Abs. 4 Nr. 2 UStG)")
# ---- 3. Die Firmenfarbe faerbt das Blatt ----------------------------------
# Jede Firma faerbt ihre Rechnung selbst. Geprueft wird, dass die gewaehlte
diff --git a/pruef_montage.py b/pruef_montage.py
index cea63cb..86581c1 100644
--- a/pruef_montage.py
+++ b/pruef_montage.py
@@ -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
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", " ")
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")
@@ -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,
"500,00 und 525,00 stehen drauf")
pruefe("Beherbergungssteuer" in kunde_seite, "die Steuer ist auf der Kundenseite ausgewiesen")
-amt_seite = seiten[1].replace("\n", " ")
-pruefe("Beherbergungssteuer" not in amt_seite,
- "auf dem Amtsblatt steht sie nicht - wie bei jeder anderen Rechnung")
+pruefe(kunde_seite.count("Rechnungsbetrag Insgesamt") == 1,
+ "der Gesamtbetrag steht genau einmal in der Datei")
# ---- 4. Das Steuerjournal liest die Datei ---------------------------------
sys.path.append(JOURNAL_PROG)
diff --git a/pruef_nummernsperre.py b/pruef_nummernsperre.py
index 6893504..5470554 100644
--- a/pruef_nummernsperre.py
+++ b/pruef_nummernsperre.py
@@ -228,6 +228,17 @@ def erststart(zahl, erwartet_ja=True):
for k in knoepfe_von(top):
if "bernehmen" in str(k.cget("text")):
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
if rest:
f.after(100, lambda: klick(rest - 1))
diff --git a/pruefsteuerung.py b/pruefsteuerung.py
new file mode 100644
index 0000000..ed320de
--- /dev/null
+++ b/pruefsteuerung.py
@@ -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(, 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")
diff --git a/theme.py b/theme.py
index 8c53c46..3070c3e 100644
--- a/theme.py
+++ b/theme.py
@@ -9,6 +9,8 @@ import sys
import tkinter as tk
from tkinter import ttk
+import config
+
def windows_dark_mode():
"""True, wenn Windows auf das dunkle App-Theme eingestellt ist."""
@@ -113,7 +115,12 @@ class ThemeMixin:
self.option_add("*foreground", fg)
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)
# erst nach dem Anzeigen setzen, sonst greift DWM noch nicht
d.after(20, lambda: self._dark_titlebar(d))