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 60c0169..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. @@ -635,6 +643,56 @@ 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 + # 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() + 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. @@ -780,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: @@ -837,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 @@ -905,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"] @@ -998,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. @@ -1020,6 +1192,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) @@ -1078,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 1bf3a19..fced977 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,27 @@ 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 '', + port INTEGER NOT NULL DEFAULT 0, -- wo seine Mini-API lauscht (127.0.0.1) + 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 +107,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, @@ -97,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 ------------------------------------------------------------- @@ -153,6 +253,79 @@ 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, 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, port, seit) VALUES (?,?,?,?,?) " + "ON CONFLICT(programm) DO UPDATE SET pid=excluded.pid, exe=excluded.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, 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: + con.execute("DELETE FROM laeuft WHERE programm=?", (str(programm),)) + + def laeuft(self, programm): + """Laeuft dieses Programm noch? Karteileichen werden weggeraeumt.""" + return self.wer_laeuft(programm) is not None + + 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 "") 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)