From 81632139608ce909c60164da714509c9ae15072c Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Thu, 3 Sep 2026 21:02:11 +0200 Subject: [PATCH 1/2] Das Rechnungstool meldet dem Steuerjournal, was es geschrieben hat Beide Programme stehen nebeneinander offen. Bisher haette das Journal die neue Rechnung erst beim naechsten Einlesen gesehen. Kein Absuchen des Rechnungsordners und kein Server: nach jeder Rechnung und jedem Storno schaut das Rechnungstool nach, ob das Journal ueberhaupt laeuft - ueber dessen PID, nicht ueber ein Fenster und nicht ueber eine Uhrzeit. Windows beantwortet das genau; PIDs werden wiederverwendet, deshalb wird zusaetzlich der Name der EXE verglichen. Laeuft es, kommt eine Zeile in den Briefkasten. Der Briefkasten IST die gemeinsame SQLite (seine Ansage): zwei Tabellen mehr - "laeuft" (wer ist offen, mit PID und EXE) und "nachrichten". Damit liegt im gemeinsamen Ordner genau EINE Datei. Karteileichen (abgestuerztes Programm) werden beim Nachsehen weggeraeumt, alte Meldungen beim Melden. Ist das Journal zu, passiert nichts - es liest den Ordner beim naechsten Start ohnehin. Die Meldung ist eine Anklopfhilfe, keine Datenquelle. Pruefstand pruef_wache.py (im Steuerjournal, faehrt BEIDE Programme): PID-Pruefung mit toter Nummer und fremdem EXE-Namen, genau eine Datei im Ordner, Meldung nach Rechnung und nach Storno, Einlesen samt Anzeige, Ruhe ohne Meldung, kein Melden an ein geschlossenes Journal. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx --- app.py | 24 ++++++++ gemeinsam.py | 155 +++++++++++++++++++++++++++++++++++++++++++++++++++ korrektur.py | 1 + 3 files changed, 180 insertions(+) diff --git a/app.py b/app.py index 60c0169..29401da 100644 --- a/app.py +++ b/app.py @@ -635,6 +635,29 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): f"Nummernbuch eingetragen werden:\n{e}\n\n" "Das Steuerjournal sieht sie trotzdem - es liest die PDFs.") + def _journal_benachrichtigen(self, text): + """Dem laufenden Steuerjournal Bescheid sagen. + + Erst nachsehen, ob es ueberhaupt offen ist (es legt einen Herzschlag im + gemeinsamen Ordner ab), dann eine Zeile in den Briefkasten. Das Journal + liest die Rechnung daraufhin von selbst ein. + + Ist es zu, passiert nichts - es liest den Ordner beim naechsten Start + ohnehin. Die Nachricht ist eine Anklopfhilfe, keine Datenquelle. + """ + if self.buch is None: + return + try: + if not self.buch.laeuft("journal"): + print("[melden] Steuerjournal laeuft nicht - es liest die Rechnung " + "beim naechsten Start") + return + self.buch.melden("rechnungstool", text) + self.buch.aufraeumen() + print(f"[melden] ans Steuerjournal: {text}") + except Exception as e: # noqa: BLE001 - die Rechnung ist schon geschrieben + print("[melden] fehlgeschlagen:", e) + def _nachtragen(self): """Alles, was schon im Rechnungsordner liegt, ins Buch schreiben. @@ -1020,6 +1043,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): # auch wenn es die PDF (noch) nicht eingelesen hat. self._ins_buch(nummer, kopf, r, pfad) self._buch_anzeigen() + self._journal_benachrichtigen(f"Rechnung {nummer} erstellt") # Rechnungsnummer-Zaehler hochsetzen, falls Format JJJJ-NNN self._zaehler_aus_nummer_speichern(nummer) diff --git a/gemeinsam.py b/gemeinsam.py index 1bf3a19..304da0f 100644 --- a/gemeinsam.py +++ b/gemeinsam.py @@ -27,6 +27,8 @@ hinueber - sonst schreiben zwei Fassungen in dieselbe Datei. import logging import os import sqlite3 +import sys +import time log = logging.getLogger("gemeinsam") @@ -56,6 +58,26 @@ CREATE TABLE IF NOT EXISTS nummern ( geaendert TEXT NOT NULL DEFAULT '' -- ISO-Zeitstempel ); CREATE INDEX IF NOT EXISTS idx_nummern_jahr ON nummern (jahr); + +-- Wer gerade laeuft. Eine Zeile je Programm, mit PID und EXE-Namen: ob es +-- wirklich noch laeuft, beantwortet Windows (prozess_laeuft), nicht ein +-- Zeitstempel. Ein abgestuerztes Programm hinterlaesst hier nur eine +-- Karteileiche, die beim naechsten Nachsehen weggeraeumt wird. +CREATE TABLE IF NOT EXISTS laeuft ( + programm TEXT PRIMARY KEY, -- 'journal' | 'rechnungstool' + pid INTEGER NOT NULL DEFAULT 0, + exe TEXT NOT NULL DEFAULT '', + seit TEXT NOT NULL DEFAULT '' +); + +-- Der Briefkasten. Das Rechnungstool legt hier ab, dass es eine Rechnung +-- geschrieben hat; das Journal liest die neuen Zeilen und liest die PDFs nach. +CREATE TABLE IF NOT EXISTS nachrichten ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + zeit TEXT NOT NULL DEFAULT '', + absender TEXT NOT NULL DEFAULT '', + text TEXT NOT NULL DEFAULT '' +); """ FELDER = ("jahr", "datum", "nachname", "vorname", "naechte", "zwischensumme", @@ -84,6 +106,78 @@ def ordner_taugt(ordner): return True, "" +# --------------------------------------------------------------------------- +# Briefkasten und Herzschlag: das Rechnungstool sagt dem Steuerjournal Bescheid +# +# Kein Programm durchsucht dauernd den Rechnungsordner. Stattdessen legt das +# LAUFENDE Steuerjournal einen Herzschlag im gemeinsamen Ordner ab, und das +# Rechnungstool schaut nach dem Schreiben einer Rechnung nur kurz hinein: laeuft +# es, wirft es eine Nachricht in den Briefkasten. Das Journal sieht genau EINE +# Datei nach (os.stat) und liest die Rechnung dann von selbst ein. +# +# Ist das Journal zu, passiert nichts - es liest den Ordner beim naechsten Start +# ohnehin komplett. Die Nachricht ist eine Anklopfhilfe, keine Datenquelle. +def prozess_laeuft(pid, name=""): + """Laeuft unter dieser Prozessnummer wirklich noch etwas? + + Die Antwort kommt von Windows, nicht aus einer Uhrzeit und nicht aus einer + Fenstersuche. PIDs werden wiederverwendet - deshalb wird zusaetzlich der + Name der EXE verglichen, wenn einer eingetragen wurde. Sonst haette ein + fremder Prozess mit derselben Nummer das andere Programm "am Leben" + gehalten. + """ + if not pid: + return False + if not sys.platform.startswith("win"): + try: + os.kill(int(pid), 0) + return True + except (OSError, ValueError): + return False + import ctypes + from ctypes import wintypes + PROCESS_QUERY_LIMITED = 0x1000 + k32 = ctypes.WinDLL("kernel32", use_last_error=True) + k32.OpenProcess.restype = wintypes.HANDLE + griff = k32.OpenProcess(PROCESS_QUERY_LIMITED, False, int(pid)) + if not griff: + return False + try: + if not name: + return True + puffer = ctypes.create_unicode_buffer(4096) + laenge = wintypes.DWORD(len(puffer)) + if not k32.QueryFullProcessImageNameW(griff, 0, puffer, ctypes.byref(laenge)): + return False + return os.path.basename(puffer.value).lower() == str(name).lower() + finally: + k32.CloseHandle(griff) + + +def datei_stand(ordner): + """(Aenderungszeit, Groesse) der gemeinsamen Datei - oder None. + + Damit sieht ein Programm mit EINEM os.stat, ob sich im Buch etwas getan hat, + ohne dafuer die Datenbank zu oeffnen. + """ + try: + st = os.stat(pfad_zur_datei(ordner)) + except OSError: + return None + return st.st_mtime, st.st_size + + +def journal_laeuft(ordner): + """Ist das Steuerjournal gerade offen?""" + if not ordner or not os.path.exists(pfad_zur_datei(ordner)): + return False + try: + return Nummernbuch(ordner).laeuft("journal") + except Exception as e: # noqa: BLE001 - eine Auskunft darf nichts umwerfen + log.debug("Anmeldung nicht lesbar: %s", e) + return False + + class Nummernbuch: """Das gemeinsame Register. Wird zum Schreiben kurz geoeffnet und wieder geschlossen - zwei Programme haben die Datei sonst dauerhaft in der Hand, @@ -153,6 +247,67 @@ class Nummernbuch: return {z["storno_zu"] for z in con.execute("SELECT storno_zu FROM nummern WHERE storno_zu <> ''")} + # ---- Wer laeuft, und der Briefkasten ----------------------------------- + def anmelden(self, programm, pid, exe): + """Eintragen: dieses Programm ist offen.""" + import datetime + with self._verbindung() as con: + con.execute( + "INSERT INTO laeuft (programm, pid, exe, seit) VALUES (?,?,?,?) " + "ON CONFLICT(programm) DO UPDATE SET pid=excluded.pid, exe=excluded.exe, " + "seit=excluded.seit", + (str(programm), int(pid), str(exe), + datetime.datetime.now().isoformat(timespec="seconds"))) + log.info("angemeldet: %s (PID %s)", programm, pid) + + def abmelden(self, programm): + with self._verbindung() as con: + con.execute("DELETE FROM laeuft WHERE programm=?", (str(programm),)) + + def laeuft(self, programm): + """Laeuft dieses Programm noch? Karteileichen werden weggeraeumt.""" + with self._verbindung() as con: + z = con.execute("SELECT pid, exe FROM laeuft WHERE programm=?", + (str(programm),)).fetchone() + if not z: + return False + if prozess_laeuft(z["pid"], z["exe"]): + return True + self.abmelden(programm) + return False + + def melden(self, absender, text): + """Eine Zeile in den Briefkasten legen.""" + import datetime + with self._verbindung() as con: + con.execute("INSERT INTO nachrichten (zeit, absender, text) VALUES (?,?,?)", + (datetime.datetime.now().isoformat(timespec="seconds"), + str(absender), str(text))) + log.info("Nachricht von %s: %s", absender, text) + return True + + def letzte_nachricht(self): + """Hoechste Nachrichten-Nummer (0, wenn der Kasten leer ist).""" + with self._verbindung() as con: + z = con.execute("SELECT MAX(id) AS m FROM nachrichten").fetchone() + return int(z["m"] or 0) + + def nachrichten_ab(self, seit_id): + """Alle Nachrichten NACH dieser Nummer: (Liste von Texten, neue Nummer).""" + with self._verbindung() as con: + zeilen = list(con.execute( + "SELECT id, absender, text FROM nachrichten WHERE id > ? ORDER BY id", + (int(seit_id or 0),))) + if not zeilen: + return [], int(seit_id or 0) + return [z["text"] for z in zeilen], int(zeilen[-1]["id"]) + + def aufraeumen(self, behalten=200): + """Alte Nachrichten wegwerfen - der Briefkasten ist kein Archiv.""" + with self._verbindung() as con: + con.execute("DELETE FROM nachrichten WHERE id <= " + "(SELECT MAX(id) FROM nachrichten) - ?", (int(behalten),)) + def entfernen(self, nummer): """Nur fuer den Rueckweg: ein verworfener Storno gibt seine Nummer zurueck.""" with self._verbindung() as con: diff --git a/korrektur.py b/korrektur.py index 7c7cdcd..c1ab058 100644 --- a/korrektur.py +++ b/korrektur.py @@ -392,6 +392,7 @@ class KorrekturMixin: # erfaehrt nicht, dass die Rechnung aufgehoben ist. self._ins_buch(nummer, kopf, r, pfad) self._buch_anzeigen() + self._journal_benachrichtigen(f"Storno {nummer} zu Rechnung {alt_nummer} erstellt") fehlend = self._formular_aus_meta(meta) self.r_nummer.set(folge_nummer or "") From 94c2c7881a10444f108ced13d80adf9feb6e801c Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Thu, 3 Sep 2026 21:22:48 +0200 Subject: [PATCH 2/2] Mini-API zwischen beiden Programmen, Proforma-Rechnung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MINI-API (seine Ansage: "die muessen ja sehen das die beiden auf sind"): Jedes Programm macht beim Start einen winzigen Server auf 127.0.0.1 mit einem Port, den das Betriebssystem aussucht, und traegt Port und PID in die gemeinsame SQLite ein - die ist das Telefonbuch. Zwei Nachrichten reichen: "ping" wird mit "hallo, bin noch da" beantwortet, "schau_nach" ist der Push. Der Push traegt KEINE Daten. Er sagt nur "schau in die sqlite" - dort steht die Meldung ohnehin. Eine Wahrheit, und wenn der Anruf nicht durchgeht, findet das andere Programm die Meldung beim naechsten Nachsehen. Gebunden wird nur auf Loopback: von aussen ist nichts erreichbar, Windows fragt bei 127.0.0.1 nicht nach der Firewall, und die API fuehrt keine Befehle aus. Kein Netzwerk-Server mit MySQL - dafuer braeuchte er Infrastruktur, die es nicht gibt; auf verschiedenen Rechnern waere ohnehin ein anderer Bau noetig. PROFORMA-RECHNUNG: ein Knopf, gewaehlt wird im Popup - die fruehere Sicherheitsfrage IST diese Auswahl geworden (eine Frage statt zwei). Seine Bedingung: "musst nur auf passen das der rechner dann die rechnungs nummer nicht aendert". Die Proforma laeuft in einer EIGENEN Reihe (2026-P001); Nummernfeld, Rechnungszaehler, Nummernbuch und die Meldung ans Journal bleiben unberuehrt. Genau das prueft pruef_proforma.py zuerst. An den Quellen geprueft, nicht an Ratgeberseiten: * § 14 Abs. 1 S. 1 UStG / UStAE 14.1 - Rechnung ist jedes Dokument, mit dem abgerechnet wird, "gleichgueltig, wie dieses Dokument im Geschaeftsverkehr bezeichnet wird". Das Wort "Proforma" allein schuetzt also nicht. * § 14c Abs. 2 UStG - wer Steuer gesondert ausweist, ohne berechtigt zu sein, schuldet den Betrag; Korrektur nur mit Antrag und Zustimmung des Finanzamts. * § 14 Abs. 4 Nr. 4 UStG - die fortlaufende, einmalig vergebene Nummer gilt fuer Rechnungen. Deshalb: Ueberschrift "Proforma-Rechnung", der Satz "keine Rechnung im Sinne des § 14 UStG ... nicht zu bezahlen", "Proforma an:" statt "Rechnung an:", die Nummer ausdruecklich als KEINE Rechnungsnummer, KEIN Steuersatz und KEIN Steuerbetrag (auch die Beherbergungssteuer nicht - sonst waere das Blatt halb ausgewiesen, halb nicht), dafuer "Voraussichtlicher Gesamtbetrag" mit dem Hinweis, dass alles enthalten ist. Kein Blatt fuers Amt, kein Zahlungstext. TESTLAUF: _testwahl sagt, was im Auswahlfenster geklickt worden waere; pruef_proforma setzt _dialog_zeigen und drueckt die echten Knoepfe. Pruefstaende: pruef_proforma.py (neu, mit dem echten Popup), pruef_montage, pruef_storno, pruef_kette, pruef_kernregeln, pruef_nummernbuch, pruef_nummern, pruef_dialoge - alle gruen. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx --- api.py | 194 +++++++++++++++++++++++++++++++++++++ app.py | 178 +++++++++++++++++++++++++++++++--- config.py | 4 + gemeinsam.py | 48 +++++++--- pdf_renderer.py | 92 +++++++++++++++--- pruef_kernregeln.py | 6 +- pruef_proforma.py | 227 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 703 insertions(+), 46 deletions(-) create mode 100644 api.py create mode 100644 pruef_proforma.py diff --git a/api.py b/api.py new file mode 100644 index 0000000..0a2a2a8 --- /dev/null +++ b/api.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +"""Mini-API zwischen Rechnungstool und Steuerjournal. + +WARUM +----- +Beide Programme stehen nebeneinander offen. Schreibt das Rechnungstool eine +Rechnung, soll sie im Journal SOFORT auftauchen - nicht erst beim naechsten +Einlesen und nicht nach einer Wartezeit. + +Seine Ansage dazu: *"machst ein kleinen py server der hin und her pingt mit +einem push vom rechnungstool: eh schau mal in die sqlite"* und *"die muessen ja +sehen das die beiden auf sind"*. + +WIE +--- +Jedes Programm macht beim Start einen winzigen Server auf **127.0.0.1** mit +einem Port, den das Betriebssystem aussucht (Port 0). Wo es lauscht, traegt es +in die gemeinsame SQLite ein - die ist das Telefonbuch. Wer jemanden erreichen +will, schlaegt dort Port und PID nach, prueft die PID und ruft an. + +Zwei Nachrichten reichen: + + {"was": "ping"} -> {"antwort": "hallo, bin noch da"} + {"was": "schau_nach"} -> {"antwort": "ok"} (und das Fenster liest nach) + +Der Push traegt keine Daten. Er sagt nur "schau in die SQLite" - dort steht die +Meldung ohnehin. Damit gibt es genau EINE Wahrheit, und wenn der Anruf nicht +durchgeht (Programm zu, Port belegt), findet das andere die Meldung beim +naechsten Nachsehen trotzdem. + +SICHERHEIT +---------- +Gebunden wird ausschliesslich auf 127.0.0.1 - von aussen ist da nichts zu +erreichen, und Windows fragt bei Loopback nicht nach der Firewall. Angenommen +werden nur die zwei Woerter oben; alles andere wird verworfen. Die API fuehrt +keine Befehle aus und uebertraegt keine Daten. + +Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie +hinueber. +""" + +import json +import logging +import os +import socket +import socketserver +import sys +import threading + +log = logging.getLogger("api") + +ANTWORT_HALLO = "hallo, bin noch da" + + +class _Handler(socketserver.StreamRequestHandler): + timeout = 3 + + def handle(self): + # Nur der eigene Rechner darf reden - gebunden ist ohnehin nur Loopback. + if self.client_address[0] not in ("127.0.0.1", "::1"): + return + try: + zeile = self.rfile.readline(4096).decode("utf-8", "replace").strip() + except OSError: + return + if not zeile: + return + try: + was = str(json.loads(zeile).get("was", "")) + except (ValueError, AttributeError): + was = "" + if was == "ping": + antwort = ANTWORT_HALLO + elif was == "schau_nach": + antwort = "ok" + self.server.melden() # Fenster benachrichtigen (threadsicher) + else: + antwort = "haeh?" + try: + self.wfile.write((json.dumps({"antwort": antwort}) + "\n").encode("utf-8")) + except OSError: + pass + + +class _Server(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + + def __init__(self, adresse, bei_push): + self.melden = bei_push + super().__init__(adresse, _Handler) + + +class MiniApi: + """Der kleine Server eines Programms. + + `bei_push` wird aus einem FREMDEN Faden gerufen - Tkinter darf man von dort + nicht anfassen. Der Aufrufer legt die Meldung deshalb in eine Warteschlange + und holt sie im Fenster mit after() ab. + """ + + def __init__(self, programm, bei_push): + self.programm = programm + self.bei_push = bei_push + self.server = None + self.faden = None + self.port = 0 + + def starten(self, buch): + """Server aufmachen und im Telefonbuch (SQLite) eintragen.""" + if self.server is not None: + return self.port + try: + self.server = _Server(("127.0.0.1", 0), self.bei_push) + except OSError as e: + log.warning("API konnte nicht starten: %s", e) + self.server = None + return 0 + self.port = self.server.server_address[1] + self.faden = threading.Thread(target=self.server.serve_forever, + name=f"api-{self.programm}", daemon=True) + self.faden.start() + log.info("API von %s laeuft auf 127.0.0.1:%s", self.programm, self.port) + self.eintragen(buch) + return self.port + + def eintragen(self, buch): + """Port und PID ins Telefonbuch. Ohne Buch gibt es keine API-Auskunft.""" + if buch is None or not self.port: + return + try: + buch.anmelden(self.programm, os.getpid(), + os.path.basename(sys.executable), self.port) + except Exception as e: # noqa: BLE001 + log.warning("Eintrag ins Telefonbuch fehlgeschlagen: %s", e) + + def stoppen(self, buch=None): + if buch is not None: + try: + buch.abmelden(self.programm) + except Exception: # noqa: BLE001 - beim Schliessen nichts erzwingen + pass + if self.server is not None: + try: + self.server.shutdown() + self.server.server_close() + except Exception: # noqa: BLE001 + pass + self.server = None + self.port = 0 + + +def _reden(port, was, zeitgrenze=1.5): + """Eine Zeile hinschicken und die Antwort lesen. None = niemand da.""" + try: + with socket.create_connection(("127.0.0.1", int(port)), timeout=zeitgrenze) as s: + s.settimeout(zeitgrenze) + s.sendall((json.dumps({"was": was}) + "\n").encode("utf-8")) + roh = s.makefile("r", encoding="utf-8").readline() + return json.loads(roh).get("antwort", "") + except (OSError, ValueError): + return None + + +def erreichbar(buch, programm): + """Meldet sich das andere Programm? ("hallo, bin noch da") + + Zuerst die PID - die beantwortet Windows und kostet nichts. Erst wenn die + stimmt, wird ueberhaupt angerufen. + """ + if buch is None: + return False + try: + eintrag = buch.wer_laeuft(programm) + except Exception as e: # noqa: BLE001 + log.debug("Telefonbuch nicht lesbar: %s", e) + return False + if not eintrag or not eintrag.get("port"): + return False + return _reden(eintrag["port"], "ping") == ANTWORT_HALLO + + +def anklopfen(buch, programm): + """Push: "schau mal in die sqlite". True, wenn es angekommen ist.""" + if buch is None: + return False + try: + eintrag = buch.wer_laeuft(programm) + except Exception as e: # noqa: BLE001 + log.debug("Telefonbuch nicht lesbar: %s", e) + return False + if not eintrag or not eintrag.get("port"): + return False + return _reden(eintrag["port"], "schau_nach") == "ok" diff --git a/app.py b/app.py index 29401da..39fe703 100644 --- a/app.py +++ b/app.py @@ -22,9 +22,11 @@ Aufgeteilt in: import os import sys import datetime +import queue import tkinter as tk from tkinter import ttk, messagebox +import api import config import berechnung import gemeinsam @@ -66,6 +68,8 @@ 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) + 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. self.berichtigung_stand = None @@ -89,6 +93,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): self.after(250, self._frage_speicherort_falls_noetig) # ... und danach nach dem gemeinsamen Ordner fuer das Nummernbuch. self.after(400, self._frage_gemeinsam_falls_noetig) + self.after(500, self._push_abholen) # ---------------------------------------------------------------- Menue @@ -536,6 +541,9 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): print("[nummernbuch] nicht erreichbar:", grund) return self.buch = gemeinsam.Nummernbuch(ordner) + # Auch das Rechnungstool macht seinen kleinen Server auf: die beiden + # sollen sich GEGENSEITIG sehen, nicht nur einer den anderen. + self.api.starten(self.buch) def _frage_gemeinsam_falls_noetig(self): """Beim ersten Start nach dem gemeinsamen Ordner fragen. @@ -652,12 +660,39 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): print("[melden] Steuerjournal laeuft nicht - es liest die Rechnung " "beim naechsten Start") return + # Erst die Meldung in die SQLite (das ist die Wahrheit), dann + # anklopfen. Geht der Anruf nicht durch, findet das Journal sie + # beim naechsten Nachsehen trotzdem. self.buch.melden("rechnungstool", text) self.buch.aufraeumen() - print(f"[melden] ans Steuerjournal: {text}") + gehoert = api.anklopfen(self.buch, "journal") + print(f"[melden] ans Steuerjournal: {text}" + + (" (angeklopft)" if gehoert else " (in der SQLite, nicht erreicht)")) except Exception as e: # noqa: BLE001 - die Rechnung ist schon geschrieben print("[melden] fehlgeschlagen:", e) + def _push_empfangen(self): + """Aus dem API-Faden - hier nur einen Zettel hinterlegen.""" + self._push_kasten.put(1) + + def _push_abholen(self): + """Im Fenster-Faden: hat das Journal angeklopft, Nummernvorschlag frisch holen.""" + try: + geklopft = False + while True: + try: + self._push_kasten.get_nowait() + geklopft = True + except queue.Empty: + break + if geklopft: + self._buch_anzeigen() + self._neue_nummer_vorschlagen() + except Exception as e: # noqa: BLE001 + print("[push]", e) + finally: + self.after(250, self._push_abholen) + def _nachtragen(self): """Alles, was schon im Rechnungsordner liegt, ins Buch schreiben. @@ -803,7 +838,91 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): return [f"{name}: „{wert}“ ist kein Datum" for name, wert in felder if wert and not parse_date(wert)] - def pdf_erstellen(self): + def _art_waehlen(self, nummer, betrag, wer): + """Popup: Rechnung oder Proforma? Gibt "rechnung", "proforma" oder None. + + Ersetzt die fruehere Sicherheitsfrage - EIN Fenster statt zwei. Die + Warnung, dass eine ausgestellte Rechnung nicht mehr zu aendern ist, + steht weiter drin: sie gilt genau fuer diesen einen Knopf. + """ + # Im Testlauf gibt es niemanden, der klickt: dann gilt der uebliche Weg + # (Rechnung). Der Pruefstand, der GENAU DIESES Fenster pruefen will, + # setzt _dialog_zeigen und drueckt die Knoepfe wirklich. + if TESTLAUF and not getattr(self, "_dialog_zeigen", False): + # _testwahl: was der Pruefstand im Fenster geklickt haette. + # "rechnung" (Vorgabe), "proforma" oder None fuer Abbrechen. + return getattr(self, "_testwahl", "rechnung") + d = tk.Toplevel(self) + d.title("Was soll erstellt werden?") + d.transient(self) + d.resizable(False, False) + self._style_dialog(d) + wahl = {"art": None} + + rahmen = ttk.Frame(d, padding=18) + rahmen.pack(fill="both", expand=True) + ttk.Label(rahmen, text=f"{wer or 'ohne Namen'} – {betrag}", + font=("Segoe UI", 12, "bold")).grid(row=0, column=0, sticky="w") + + ttk.Label(rahmen, wraplength=560, justify="left", text=( + f"RECHNUNG {nummer} – der Beleg. Danach lässt sie sich nicht mehr " + "ändern: korrigiert wird nur über Storno oder Berichtigung, und jede " + "Nummer gibt es nur einmal (§ 14 Abs. 4 Nr. 4 UStG). Das Steuerjournal " + "bucht sie und meldet die Übernachtungen ans Amt.")).grid( + row=1, column=0, sticky="w", pady=(10, 8)) + ttk.Label(rahmen, wraplength=560, justify="left", text=( + "PROFORMA-RECHNUNG – unverbindliche Kostenaufstellung vorab. Keine " + "Rechnung im Sinne des § 14 UStG, kein Vorsteuerabzug, nicht zu zahlen. " + "Sie verbraucht KEINE Rechnungsnummer (eigene Reihe) und wird nirgends " + f"gebucht – die nächste Rechnung bleibt {nummer}.")).grid( + row=2, column=0, sticky="w", pady=(0, 14)) + + def nimm(art): + wahl["art"] = art + d.destroy() + + knoepfe = ttk.Frame(rahmen) + knoepfe.grid(row=3, column=0, sticky="ew") + ttk.Button(knoepfe, text=f"Rechnung {nummer} erstellen", width=28, + command=lambda: nimm("rechnung")).pack(side="left") + ttk.Button(knoepfe, text="Proforma-Rechnung", width=20, + command=lambda: nimm("proforma")).pack(side="left", padx=8) + ttk.Button(knoepfe, text="Abbrechen", width=14, + command=d.destroy).pack(side="right") + + d.protocol("WM_DELETE_WINDOW", d.destroy) + d.bind("", lambda _e: d.destroy()) + d.update_idletasks() + x = self.winfo_rootx() + max(0, (self.winfo_width() - d.winfo_reqwidth()) // 2) + d.geometry(f"+{max(0, x)}+{self.winfo_rooty() + 130}") + d.grab_set() + self.wait_window(d) + return wahl["art"] + + def _naechste_proforma_nummer(self): + """JJJJ-Pnnn aus der EIGENEN Reihe. Der Jahreswechsel faengt bei P001 an. + + Wichtig: hier wird der Rechnungszaehler nicht angefasst. Eine Proforma + darf keine Rechnungsnummer verbrauchen - sonst muesste man die Nummern + wieder von Hand aendern koennen, und genau daher kamen die doppelten. + """ + jahr = datetime.date.today().year + zaehler = int(self.cfg.get("proforma_zaehler", 0) or 0) + if int(self.cfg.get("proforma_jahr", 0) or 0) != jahr: + zaehler = 0 + out_dir = config.ensure_output_dir(self.cfg) + try: + vorhanden = os.listdir(out_dir) + except OSError: + vorhanden = [] + for _ in range(1000): + zaehler += 1 + nummer = f"{jahr}-P{zaehler:03d}" + if not any(d.startswith(f"Proforma_{nummer}") for d in vorhanden): + return jahr, zaehler, nummer + return jahr, zaehler, f"{jahr}-P{zaehler:03d}" + + def pdf_erstellen(self, proforma=False): montage = self.ist_montage() r = self.recalc() if not r.zeilen: @@ -860,22 +979,19 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): # eine Berichtigung korrigiert - beides kostet Papier und einen Weg zum # Gast. Beim Storno- und Berichtigungsweg fragt das Programm ohnehin # schon, darum hier nur bei einer ganz normalen Rechnung. + # EIN Knopf - gewaehlt wird hier: Rechnung oder Proforma. Waehrend + # eines Stornos oder einer Berichtigung gibt es nichts zu waehlen. if not self.storno_stand and not self.berichtigung_stand: wer = " ".join(t for t in (self.k_vorname.get().strip(), self.k_name.get().strip()) if t) - if not messagebox.askyesno( - "Rechnung endgültig erstellen?", - f"Rechnung {nummer} für {wer or 'ohne Namen'} über " - f"{berechnung.eur(r.gesamt)}.\n\n" - "Sind alle Angaben richtig? Danach lässt sich die Rechnung " - "nicht mehr ändern.\n\n" - "Eine ausgestellte Rechnung darf weder überschrieben noch " - "gelöscht werden. Korrigiert wird sie nur noch über " - "„Storno“ (falsche Beträge) oder „Berichtigen“ (falsche " - "Kundenangaben, § 31 Abs. 5 UStDV) – und jede Nummer gibt " - "es nur einmal (§ 14 Abs. 4 Nr. 4 UStG).", - icon="question", default="yes"): + wahl = self._art_waehlen(nummer, berechnung.eur(r.gesamt), wer) + if wahl is None: return + proforma = wahl == "proforma" + if proforma: + # EIGENE Reihe: Nummernfeld und Rechnungszaehler bleiben + # unangetastet, die naechste Rechnung behaelt ihre Nummer. + p_jahr, p_zaehler, nummer = self._naechste_proforma_nummer() # Auf der Rechnung stehen Vor- und Nachname wie gewohnt in einer Zeile; # der Nachname geht zusätzlich getrennt mit, damit das Steuerjournal ihn @@ -928,6 +1044,9 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): # dahinter ist eine ganz normale: Zwischensumme + Beherbergungssteuer. kopf["vorlage"] = "montage" kopf["montage_text"] = self._montage_text() + if proforma: + kopf["art"] = "proforma" + kopf["rechnungsnummer"] = nummer if stand: kopf["art"] = "korrektur" kopf["vorgang"] = stand.get("vorgang") or stand["zu"] @@ -1021,6 +1140,36 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): self.r_nummer.set(nummer) kopf["rechnungsnummer"] = nummer + if proforma: + name = f"Proforma_{safe_filename(nummer)}" + if self.k_name.get().strip(): + name += "_" + safe_filename(self.k_name.get().strip()) + pfad = os.path.join(out_dir, name + ".pdf") + try: + pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf) + except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen + messagebox.showerror( + "Fehler beim Erstellen", + f"Proforma konnte nicht erstellt werden:\n{e}") + print("[proforma] FEHLER:", e) + return + # NUR der Proforma-Zaehler wandert weiter. Rechnungsnummer, deren + # Zaehler, das gemeinsame Nummernbuch und die Meldung ans + # Steuerjournal bleiben unberuehrt - gebucht wird eine Proforma + # nirgends. + self.cfg["proforma_jahr"] = p_jahr + self.cfg["proforma_zaehler"] = p_zaehler + try: + config.save_config(self.cfg) + except OSError as e: # noqa: BLE001 + print("[proforma] Zaehler nicht gespeichert:", e) + print(f"[proforma] erstellt -> {pfad}") + if messagebox.askyesno( + "Fertig", f"Proforma-Rechnung erstellt:\n{pfad}\n\n" + "Jetzt öffnen (zum Drucken)?"): + self._oeffne_datei(pfad) + return + # Der Dateiname faengt weiter mit "Rechnung_" an - daran erkennt # bestand.vorhandene_rechnungen() eine schon vergebene Nummer. Das # "_Montage" haengt nur hinten dran, damit man die Sorte im Ordner sieht. @@ -1102,6 +1251,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): "Beim Schließen bleibt er so liegen – morgen weiß niemand mehr, " "warum.\n\nTrotzdem beenden?", icon="warning", default="no"): return + self.api.stoppen(self.buch) self.destroy() def reset_formular(self): diff --git a/config.py b/config.py index f36fc11..a9f4670 100644 --- a/config.py +++ b/config.py @@ -140,6 +140,10 @@ DEFAULT_CONFIG = { "texte": DEFAULT_TEXTE, "katalog": DEFAULT_KATALOG, "montage": DEFAULT_MONTAGE, + # Die Proforma hat eine EIGENE Reihe (2026-P001): sie darf keine + # Rechnungsnummer verbrauchen - die gehoert zu einer echten Rechnung. + "proforma_jahr": 0, + "proforma_zaehler": 0, # Alle Steuern als EINE benannte Liste. # - extra_blatt = False: USt-Satz, wird auf das Netto der zugeordneten Leistungen gerechnet. # - extra_blatt = True : Aufschlag auf die Endsumme (z.B. Beherbergungssteuer), eigenes Blatt. diff --git a/gemeinsam.py b/gemeinsam.py index 304da0f..fced977 100644 --- a/gemeinsam.py +++ b/gemeinsam.py @@ -67,6 +67,7 @@ CREATE TABLE IF NOT EXISTS laeuft ( programm TEXT PRIMARY KEY, -- 'journal' | 'rechnungstool' pid INTEGER NOT NULL DEFAULT 0, exe TEXT NOT NULL DEFAULT '', + port INTEGER NOT NULL DEFAULT 0, -- wo seine Mini-API lauscht (127.0.0.1) seit TEXT NOT NULL DEFAULT '' ); @@ -191,6 +192,11 @@ class Nummernbuch: con = sqlite3.connect(self.pfad, timeout=10) con.row_factory = sqlite3.Row con.executescript(SCHEMA) + # Spalte nachziehen: CREATE TABLE IF NOT EXISTS aendert eine bestehende + # Tabelle nicht, und ein Buch aus der Fassung ohne API hat sie noch nicht. + spalten = {z["name"] for z in con.execute("PRAGMA table_info('laeuft')")} + if spalten and "port" not in spalten: + con.execute("ALTER TABLE laeuft ADD COLUMN port INTEGER NOT NULL DEFAULT 0") return con # ---- Lesen ------------------------------------------------------------- @@ -248,17 +254,37 @@ class Nummernbuch: con.execute("SELECT storno_zu FROM nummern WHERE storno_zu <> ''")} # ---- Wer laeuft, und der Briefkasten ----------------------------------- - def anmelden(self, programm, pid, exe): - """Eintragen: dieses Programm ist offen.""" + def anmelden(self, programm, pid, exe, port=0): + """Eintragen: dieses Programm ist offen - und wo seine API lauscht. + + Die Tabelle ist das Telefonbuch der beiden Programme: PID (laeuft es + ueberhaupt?) und Port (wo erreiche ich es?). + """ import datetime with self._verbindung() as con: con.execute( - "INSERT INTO laeuft (programm, pid, exe, seit) VALUES (?,?,?,?) " + "INSERT INTO laeuft (programm, pid, exe, port, seit) VALUES (?,?,?,?,?) " "ON CONFLICT(programm) DO UPDATE SET pid=excluded.pid, exe=excluded.exe, " - "seit=excluded.seit", - (str(programm), int(pid), str(exe), + "port=excluded.port, seit=excluded.seit", + (str(programm), int(pid), str(exe), int(port or 0), datetime.datetime.now().isoformat(timespec="seconds"))) - log.info("angemeldet: %s (PID %s)", programm, pid) + log.info("angemeldet: %s (PID %s, Port %s)", programm, pid, port) + + def wer_laeuft(self, programm): + """Telefonbuch-Eintrag eines laufenden Programms - oder None. + + Geprueft wird die PID (Windows antwortet), nicht ein Zeitstempel. Eine + Karteileiche wird gleich weggeraeumt. + """ + with self._verbindung() as con: + z = con.execute("SELECT programm, pid, exe, port FROM laeuft WHERE programm=?", + (str(programm),)).fetchone() + if not z: + return None + if not prozess_laeuft(z["pid"], z["exe"]): + self.abmelden(programm) + return None + return dict(z) def abmelden(self, programm): with self._verbindung() as con: @@ -266,15 +292,7 @@ class Nummernbuch: def laeuft(self, programm): """Laeuft dieses Programm noch? Karteileichen werden weggeraeumt.""" - with self._verbindung() as con: - z = con.execute("SELECT pid, exe FROM laeuft WHERE programm=?", - (str(programm),)).fetchone() - if not z: - return False - if prozess_laeuft(z["pid"], z["exe"]): - return True - self.abmelden(programm) - return False + return self.wer_laeuft(programm) is not None def melden(self, absender, text): """Eine Zeile in den Briefkasten legen.""" diff --git a/pdf_renderer.py b/pdf_renderer.py index a95b013..70e48aa 100644 --- a/pdf_renderer.py +++ b/pdf_renderer.py @@ -188,13 +188,17 @@ def _kopf_fuss(canvas, doc, cfg, amt=False): # ---- Hauptfunktion ---------------------------------------------------------- -def _positionstabelle(story, st, rechnung): - """Die gewohnte Positionstabelle der Campingrechnung.""" +def _positionstabelle(story, st, rechnung, ohne_ust=False): + """Die gewohnte Positionstabelle der Campingrechnung. + + ohne_ust=True laesst die Spalte mit dem Steuersatz weg - auf einer + Proforma steht bewusst kein Steuersatz und kein Steuerbetrag + (§ 14c UStG: wer Steuer ausweist, schuldet sie).""" col_w = [10 * mm, 70 * mm, 12 * mm, 21 * mm, 13 * mm, 22 * mm, 26 * mm] data = [[ Paragraph("Pos.", st["th"]), Paragraph("Leistung", st["th_l"]), - Paragraph("USt %", st["th"]), + Paragraph("" if ohne_ust else "USt %", st["th"]), Paragraph("Anzahl
Tage/Menge", st["th"]), Paragraph("Pers.", st["th"]), Paragraph("E.-Preis", st["th"]), @@ -205,7 +209,7 @@ def _positionstabelle(story, st, rechnung): data.append([ Paragraph(str(z.nr), st["cell_c"]), Paragraph(z.leistung, st["leistung"]), - Paragraph(str(z.satz), st["cell_c"]), + Paragraph("" if ohne_ust else str(z.satz), st["cell_c"]), Paragraph(eur_kurz(z.anzahl).replace(",00", ""), st["cell_c"]), Paragraph(pers, st["cell_c"]), Paragraph(eur_kurz(z.preis), st["cell_r"]), @@ -274,7 +278,8 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): story.append(Spacer(1, 5 * mm)) # Kundenadresse (links) | Rechnungsinfos (rechts) - kunde_flow = [Paragraph("Rechnung an:", st["addr_label"])] + kunde_flow = [Paragraph("Proforma an:" if kopf.get("art") == "proforma" + else "Rechnung an:", st["addr_label"])] for z in kopf.get("kunde_zeilen", []): if str(z).strip(): kunde_flow.append(Paragraph(str(z), st["addr"])) @@ -286,8 +291,10 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): # Keine Positionstabelle, keine Naechte im Kopf - der Zeitraum steht in der # einen Textzeile, so wie Bartl das Blatt bisher von Hand geschrieben hat. montage = kopf.get("vorlage") == "montage" + proforma = art == "proforma" info_rows = [ - ("Stornonummer:" if art == "storno" else "Rechnungsnummer:", + ("Stornonummer:" if art == "storno" else + "Proforma-Nr.:" if proforma else "Rechnungsnummer:", kopf.get("rechnungsnummer", "")), ("Rechnungsdatum:", kopf.get("datum", "")), ] @@ -352,23 +359,40 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): # Das Wort "Stornorechnung" muss auf dem Blatt stehen, damit der Beleg # eindeutig als Aufhebung erkennbar ist. titel = {"storno": "Stornorechnung", - "berichtigung": "Berichtigte Rechnung"}.get(art, "Rechnungsübersicht") + "berichtigung": "Berichtigte Rechnung", + "proforma": "Proforma-Rechnung"}.get(art, "Rechnungsübersicht") titel += " (für das Amt)" if gesamtrechnung_betont else "" story.append(Paragraph(titel, st["h"])) + if proforma: + # Ohne diesen Satz sieht das Blatt aus wie eine Rechnung. Es ist keine - + # und ohne die deutliche Kennzeichnung kann das Finanzamt es als eine + # werten. Ein Steuersatz oder Steuerbetrag steht bewusst NIRGENDS auf + # dem Blatt: wer Steuer ausweist, schuldet sie (§ 14c UStG). + story.append(Paragraph( + "Dies ist eine Proforma-Rechnung – eine unverbindliche Vorab-" + "Aufstellung der Kosten. Sie ist keine Rechnung im Sinne des § 14 " + "UStG, berechtigt nicht zum Vorsteuerabzug und ist nicht zu bezahlen. " + "Die Nummer oben ist keine Rechnungsnummer. Alle Beträge in Euro " + "inkl. gesetzlicher Umsatzsteuer; ausgewiesen wird sie erst in der " + "Rechnung nach dem Aufenthalt.", st["dank"])) + story.append(Spacer(1, 3 * mm)) if montage: _montage_zeile(story, st, rechnung, kopf) else: - _positionstabelle(story, st, rechnung) + _positionstabelle(story, st, rechnung, ohne_ust=proforma) story.append(Spacer(1, 3 * mm)) # ---- Summenblock: Netto -> USt je Satz -> Zwischensumme (brutto) # -> zzgl. Beherbergungssteuer -> Gesamtbetrag ---- - summen_rows = [[ + # Auf der Proforma steht weder Netto noch Umsatzsteuer: ausgewiesen wird + # sie erst mit der Rechnung. Die Beherbergungssteuer ist keine Umsatzsteuer + # und darf bleiben - der Gast soll sehen, was er zahlen wird. + summen_rows = [] if proforma else [[ Paragraph("Nettobetrag:", st["info_l"]), Paragraph(eur(rechnung.netto_gesamt), st["cell_r"]), ]] - for b in rechnung.bloecke: + for b in ([] if proforma else rechnung.bloecke): name = b.bezeichnung or "USt" summen_rows.append([ Paragraph(f"{name} {b.satz} %:", st["info_l"]), @@ -384,7 +408,10 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): # # Auf der Kundenseite bleibt alles wie gehabt - dort MUSS die Steuer ausgewiesen # sein und im Gesamtbetrag stecken, das ist der Betrag, den der Gast zahlt. - if rechnung.aufschlaege and not gesamtrechnung_betont: + # Auf der Proforma 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 proforma: summen_rows.append([ Paragraph("Zwischensumme:", st["info_l"]), Paragraph(eur(rechnung.brutto), st["cell_r"]), @@ -398,7 +425,9 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): endsumme = rechnung.brutto if gesamtrechnung_betont else rechnung.gesamt summen_rows.append([ - Paragraph("Rechnungsbetrag Insgesamt:" if montage else "Gesamtbetrag:", st["total_l"]), + Paragraph("Voraussichtlicher Gesamtbetrag:" if proforma else + "Rechnungsbetrag Insgesamt:" if montage else "Gesamtbetrag:", + st["total_l"]), Paragraph(eur(endsumme), st["total_v"]), ]) style_cmds = [ @@ -415,7 +444,12 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): # Hinweis zur Beherbergungssteuer - nur auf der Kundenseite. Auf dem Amt-Blatt # wird die Steuer gar nicht mehr ausgewiesen, dann waere der Satz sinnlos. - if not gesamtrechnung_betont: + if proforma: + 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: for a in rechnung.aufschlaege: if not a.bezeichnung: continue @@ -467,6 +501,15 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): "ihre Nummer.", st["dank"])) if texte.get("dank1"): block.append(Paragraph(texte["dank1"], st["dank"])) + elif proforma: + # 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( + "Diese Aufstellung ist unverbindlich und dient nur zur Information. " + "Sie ist keine Zahlungsaufforderung – 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']}" @@ -507,6 +550,22 @@ def _kenndaten(rechnung, kopf): Steuerrechner summieren dadurch von allein richtig (+100 -100 +120 = 120). """ aufschlag = rechnung.aufschlaege[0] if rechnung.aufschlaege else None + if kopf.get("art") == "proforma": + # Eine Proforma ist kein Umsatz - sie traegt Nullen. Ein Programm, das + # "art" nicht kennt, zaehlt dann 0,00 statt einer Uebernachtung, die nie + # abgerechnet wurde. + return json.dumps({ + "quelle": "rechnungstool", "version": 3, "art": "proforma", + "rechnungsnummer": kopf.get("rechnungsnummer", ""), + "datum": kopf.get("datum", ""), + "nachname": kopf.get("nachname", ""), "vorname": kopf.get("vorname", ""), + "naechte": kopf.get("naechte", ""), + "zwischensumme": 0.0, "gesamt": 0.0, + "steuer_satz": 0.0, "steuer_betrag": 0.0, + "netto_gesamt": 0.0, "ust_gesamt": 0.0, "ust_bloecke": [], + "kunde": kopf.get("kunde", {}), + "anreise": kopf.get("anreise", ""), "abreise": kopf.get("abreise", ""), + }, ensure_ascii=False) if kopf.get("art") == "berichtigung": # Das berichtigte Blatt zeigt dieselben Betraege wie die Rechnung, ist aber # KEINE zweite Einnahme. Es traegt deshalb Nullen: ein Programm, das "art" @@ -592,14 +651,17 @@ def render_rechnung(pfad, cfg, rechnung, kopf): ziel, pagesize=A4, leftMargin=MARGIN_L, rightMargin=MARGIN_R, topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT, - title=("Stornorechnung" if kopf.get("art") == "storno" else "Rechnung") + title=({"storno": "Stornorechnung", + "proforma": "Proforma-Rechnung"}.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 - if rechnung.aufschlaege: + # Eine Proforma bekommt KEIN Blatt fuers Amt: gemeldet wird eine + # Uebernachtung erst, wenn sie abgerechnet ist. + if rechnung.aufschlaege and kopf.get("art") != "proforma": story.append(PageBreak()) story.append(_AmtMarke(marke)) _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=True) # Amt diff --git a/pruef_kernregeln.py b/pruef_kernregeln.py index e5369a5..5fc8eda 100644 --- a/pruef_kernregeln.py +++ b/pruef_kernregeln.py @@ -137,12 +137,14 @@ antworten["endgültig erstellen"] = True # die Sicherheitsfrage bejahen # ---- Die Sicherheitsfrage muss auch wirklich abbrechen ---------------------- # "Sind alle Angaben richtig?" ist die letzte Gelegenheit vor dem Beleg. Sagt # der Nutzer Nein, darf NICHTS entstehen - sonst waere die Frage eine Attrappe. -antworten["endgültig erstellen"] = False +# Aus der Sicherheitsfrage ist das Auswahlfenster geworden (Rechnung oder +# Proforma). "Abbrechen" heisst dort: gar nichts schreiben. +f._testwahl = None kunde_setzen("Test", "Abbruch", "1") f.pdf_erstellen() pruefe(not [n for n in os.listdir(ordner) if n.lower().endswith(".pdf")], "bei „Nein“ auf die Sicherheitsfrage entsteht keine PDF") -antworten["endgültig erstellen"] = True +f._testwahl = "rechnung" # ---- Ausgangslage: zwei echte Rechnungen ------------------------------------ kunde_setzen("Anna", "Beispiel", "4") diff --git a/pruef_proforma.py b/pruef_proforma.py new file mode 100644 index 0000000..64265ee --- /dev/null +++ b/pruef_proforma.py @@ -0,0 +1,227 @@ +# -*- coding: utf-8 -*- +"""Pruefstand: die Proforma-Rechnung. + +Sein Auftrag: ein Knopf, im Popup waehlt man Rechnung oder Proforma - *"musst +nur auf passen das der rechner dann die rechnungs nummer nicht aendert, sonst +muessen wir wieder die nummern aenderbar machen und dann macht er wieder +fehler"*. + +Genau das ist hier die Hauptpruefung: die Proforma laeuft in einer EIGENEN +Reihe (2026-P001) und laesst Nummernfeld, Zaehler und Nummernbuch unberuehrt. + +Dazu die Rechtslage (nachgeschlagen am 03.09.2026): + * keine Nummer aus dem regulaeren Kreis - die fortlaufende Nummerierung nach + § 14 Abs. 4 Nr. 4 UStG gilt nur fuer echte Rechnungen + * kein Steuersatz und kein Steuerbetrag auf dem Blatt (§ 14c UStG: wer Steuer + ausweist, schuldet sie) + * deutlich als "Proforma-Rechnung" kennzeichnen, sonst kann das Finanzamt das + Blatt als echte Rechnung werten + +Gefahren wird das ECHTE Popup - die Knoepfe werden angeklickt, nicht umgangen. +""" +import json +import os +import shutil +import sys +import tempfile +import threading + +QUELLE = r"C:\claude\rechnungstool" +JOURNAL = r"C:\claude\beherbergungssteuer" +sys.path.insert(0, QUELLE) + +fehler = [] + + +def pruefe(bedingung, text): + zeile = (" OK | " if bedingung else "FEHLER | ") + text + kodierung = sys.stdout.encoding or "utf-8" + print(zeile.encode(kodierung, "replace").decode(kodierung)) + if not bedingung: + fehler.append(text) + + +def _reissleine(): + print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog") + os._exit(2) + + +_wecker = threading.Timer(150, _reissleine) +_wecker.daemon = True +_wecker.start() + +os.environ["CAMPINGHOF_TEST"] = "1" +tmp = tempfile.mkdtemp(prefix="proforma_pruef_") +ordner = os.path.join(tmp, "rechnungen") +os.makedirs(ordner) + +import config # noqa: E402 + +shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json")) +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 +json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2) + +import app as A # noqa: E402 +import bestand as BE # noqa: E402 +from tkinter import messagebox # noqa: E402 + +antworten = {"Fertig": False, "Hausnummer": True} + + +def askyesno(titel, text, **kw): + for schlue, wert in antworten.items(): + if schlue in titel: + return wert + raise AssertionError(f"unangemeldeter Dialog: {titel!r}") + + +messagebox.askyesno = askyesno +messagebox.showinfo = lambda *a, **k: None +warnungen = [] +messagebox.showwarning = lambda *a, **k: warnungen.append(a[0] if a else "") +messagebox.showerror = lambda *a, **k: print("[fehler]", a[0] if a else "") + +f = A.RechnungsApp() +f.cfg["output_dir"] = ordner +# Dieser Pruefstand will das ECHTE Popup sehen und seine Knoepfe druecken. +f._dialog_zeigen = True + + +def knoepfe_von(fenster): + """Alle Knopf-Widgets eines Fensters, egal wie tief.""" + aus = [] + + def gehe(w): + for k in w.winfo_children(): + if k.winfo_class() in ("TButton", "Button"): + aus.append(k) + gehe(k) + + gehe(fenster) + return aus + + +def klicke_im_popup(teil): + """Wartet auf das Popup und drueckt den Knopf, dessen Text `teil` enthaelt.""" + def versuch(rest=40): + tops = [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"] + if tops: + for k in knoepfe_von(tops[-1]): + if teil.lower() in str(k.cget("text")).lower(): + k.invoke() + return + if rest: + f.after(100, lambda: versuch(rest - 1)) + f.after(200, versuch) + + +def kunde(vorname, nachname): + f.k_vorname.set(vorname) + f.k_name.set(nachname) + f.k_strasse.set("Musterweg 1") + f.k_plzort.set("12345 Musterstadt") + f.r_anreise.set("01.08.2026") + f.r_abreise.set("04.08.2026") + f.anzahl_vars[sorted(f.anzahl_vars)[0]].set("3") + f.update() + + +# ---- 1. Der eine Knopf und das Popup -------------------------------------- +knopf_texte = [str(k.cget("text")) for k in knoepfe_von(f)] +pruefe("PDF erstellen" in knopf_texte, f"unten steht EIN Knopf zum Erstellen ({knopf_texte})") +pruefe(not any("Proforma" in t for t in knopf_texte), + "und kein zweiter Knopf fuer die Proforma - gewaehlt wird im Popup") + +kunde("Anna", "Vorab") +nummer_vorher = f.r_nummer.get() +zaehler_vorher = f.cfg.get("rechnung_zaehler") +pruefe(nummer_vorher == "2026-001", f"vorgeschlagen ist 2026-001 ({nummer_vorher})") + +# ---- 2. Abbrechen schreibt nichts ---------------------------------------- +klicke_im_popup("Abbrechen") +f.pdf_erstellen() +pruefe(not os.listdir(ordner), f"Abbrechen schreibt nichts ({os.listdir(ordner)})") + +# ---- 3. Proforma waehlen ------------------------------------------------- +klicke_im_popup("Proforma") +f.pdf_erstellen() +dateien = sorted(os.listdir(ordner)) +pruefe(dateien == ["Proforma_2026-P001_Vorab.pdf"], f"die Proforma liegt da ({dateien})") + +# ---- 4. DIE Hauptsache: die Rechnungsnummer ist unberuehrt -------------- +pruefe(f.r_nummer.get() == nummer_vorher, + f"das Nummernfeld steht unveraendert auf {nummer_vorher} ({f.r_nummer.get()})") +pruefe(f.cfg.get("rechnung_zaehler") == zaehler_vorher, + f"der Rechnungszaehler ist unveraendert ({f.cfg.get('rechnung_zaehler')})") +pruefe(f.cfg.get("proforma_zaehler") == 1, + f"nur der Proforma-Zaehler ist weitergelaufen ({f.cfg.get('proforma_zaehler')})") + +# ---- 5. Was auf dem Blatt steht ----------------------------------------- +from pypdf import PdfReader # noqa: E402 + +pfad = os.path.join(ordner, "Proforma_2026-P001_Vorab.pdf") +seiten = PdfReader(pfad).pages +pruefe(len(seiten) == 1, f"eine Seite - kein Blatt fuers Amt ({len(seiten)})") +text = (seiten[0].extract_text() or "").replace("\n", " ") +pruefe("Proforma-Rechnung" in text, "es steht deutlich Proforma-Rechnung drauf") +pruefe("keine Rechnung im Sinne des § 14 UStG" in text, + "mit dem Satz, dass es keine Rechnung nach § 14 UStG ist") +pruefe("keine Rechnungsnummer" in text, "und dass die Nummer keine Rechnungsnummer ist") +pruefe("2026-P001" in text and "2026-001" not in text, + "nur die Proforma-Nummer steht drauf, keine Rechnungsnummer") +pruefe("Mehrwertsteuer" not in text and "Nettobetrag" not in text, + "kein Steuerbetrag und kein Nettoausweis (§ 14c UStG)") +pruefe("Beherbergungssteuer" not in text and "Zwischensumme" not in text, + "auch die Beherbergungssteuer steht nicht drauf - gar keine Steuer wird ausgewiesen") +pruefe("alle Steuern und Abgaben enthalten" in text, + "dafuer der Hinweis, dass alles im Betrag enthalten ist") +pruefe("Voraussichtlicher Gesamtbetrag" in text, + "der Betrag heisst voraussichtlich, nicht Rechnungsbetrag") + +meta = BE.kenndaten_lesen(pfad) or {} +pruefe(meta.get("art") == "proforma", f"art = proforma ({meta.get('art')})") +pruefe(float(meta.get("zwischensumme") or 0) == 0.0 and float(meta.get("gesamt") or 0) == 0.0, + f"die Kenndaten tragen Nullen ({meta.get('zwischensumme')}, {meta.get('gesamt')})") + +# ---- 6. Danach die echte Rechnung: sie bekommt 2026-001 ---------------- +antworten["Storno"] = True +klicke_im_popup("Rechnung 2026-001 erstellen") +f.pdf_erstellen() +pruefe(os.path.exists(os.path.join(ordner, "Rechnung_2026-001_Vorab.pdf")), + f"die Rechnung 2026-001 ist geschrieben ({sorted(os.listdir(ordner))})") +pruefe(f.cfg.get("rechnung_zaehler") == 1, + f"jetzt erst laeuft der Rechnungszaehler weiter ({f.cfg.get('rechnung_zaehler')})") + +# ---- 7. Die zweite Proforma zaehlt in ihrer Reihe weiter --------------- +f.reset_formular() +kunde("Bernd", "Zweitvorab") +klicke_im_popup("Proforma") +f.pdf_erstellen() +pruefe(os.path.exists(os.path.join(ordner, "Proforma_2026-P002_Zweitvorab.pdf")), + f"die zweite Proforma heisst P002 ({sorted(os.listdir(ordner))})") +f.destroy() + +# ---- 8. Das Steuerjournal bucht die Proforma NICHT -------------------- +sys.path.append(JOURNAL) +import db as BST # noqa: E402 + +journal = BST.Journal(os.path.join(tmp, "journal.sqlite3")) +journal.scanne(ordner, 5.0, voll=True) +buchungen = journal.buchungen(jahr=2026) +pruefe(len(buchungen) == 1, f"nur die echte Rechnung ist gebucht ({len(buchungen)})") +pruefe(buchungen and buchungen[0].voll_nummer == "2026-001", + f"und zwar 2026-001 ({[b.voll_nummer for b in buchungen]})") +pruefe(not any("P0" in b.voll_nummer for b in buchungen), + "keine Proforma-Nummer im Journal") +journal.schliessen() + +if not fehler: + shutil.rmtree(tmp, ignore_errors=True) +else: + print(f" (Wegwerf-Ordner bleibt: {tmp})") +print("---") +print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}") +sys.exit(1 if fehler else 0)