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))