diff --git a/api.py b/api.py deleted file mode 100644 index 0a2a2a8..0000000 --- a/api.py +++ /dev/null @@ -1,194 +0,0 @@ -# -*- 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 39fe703..60c0169 100644 --- a/app.py +++ b/app.py @@ -22,11 +22,9 @@ 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 @@ -68,8 +66,6 @@ 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 @@ -93,7 +89,6 @@ 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 @@ -541,9 +536,6 @@ 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. @@ -643,56 +635,6 @@ 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. @@ -838,91 +780,7 @@ 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 _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): + def pdf_erstellen(self): montage = self.ist_montage() r = self.recalc() if not r.zeilen: @@ -979,19 +837,22 @@ 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) - wahl = self._art_waehlen(nummer, berechnung.eur(r.gesamt), wer) - if wahl is None: + 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"): 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 @@ -1044,9 +905,6 @@ 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"] @@ -1140,36 +998,6 @@ 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. @@ -1192,7 +1020,6 @@ 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) @@ -1251,7 +1078,6 @@ 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 a9f4670..f36fc11 100644 --- a/config.py +++ b/config.py @@ -140,10 +140,6 @@ 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 fced977..1bf3a19 100644 --- a/gemeinsam.py +++ b/gemeinsam.py @@ -27,8 +27,6 @@ hinueber - sonst schreiben zwei Fassungen in dieselbe Datei. import logging import os import sqlite3 -import sys -import time log = logging.getLogger("gemeinsam") @@ -58,27 +56,6 @@ 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", @@ -107,78 +84,6 @@ 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, @@ -192,11 +97,6 @@ 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 ------------------------------------------------------------- @@ -253,79 +153,6 @@ 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 c1ab058..7c7cdcd 100644 --- a/korrektur.py +++ b/korrektur.py @@ -392,7 +392,6 @@ 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 70e48aa..a95b013 100644 --- a/pdf_renderer.py +++ b/pdf_renderer.py @@ -188,17 +188,13 @@ def _kopf_fuss(canvas, doc, cfg, amt=False): # ---- Hauptfunktion ---------------------------------------------------------- -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).""" +def _positionstabelle(story, st, rechnung): + """Die gewohnte Positionstabelle der Campingrechnung.""" 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("" if ohne_ust else "USt %", st["th"]), + Paragraph("USt %", st["th"]), Paragraph("Anzahl
Tage/Menge", st["th"]), Paragraph("Pers.", st["th"]), Paragraph("E.-Preis", st["th"]), @@ -209,7 +205,7 @@ def _positionstabelle(story, st, rechnung, ohne_ust=False): data.append([ Paragraph(str(z.nr), st["cell_c"]), Paragraph(z.leistung, st["leistung"]), - Paragraph("" if ohne_ust else str(z.satz), st["cell_c"]), + Paragraph(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"]), @@ -278,8 +274,7 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): story.append(Spacer(1, 5 * mm)) # Kundenadresse (links) | Rechnungsinfos (rechts) - kunde_flow = [Paragraph("Proforma an:" if kopf.get("art") == "proforma" - else "Rechnung an:", st["addr_label"])] + kunde_flow = [Paragraph("Rechnung an:", st["addr_label"])] for z in kopf.get("kunde_zeilen", []): if str(z).strip(): kunde_flow.append(Paragraph(str(z), st["addr"])) @@ -291,10 +286,8 @@ 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 - "Proforma-Nr.:" if proforma else "Rechnungsnummer:", + ("Stornonummer:" if art == "storno" else "Rechnungsnummer:", kopf.get("rechnungsnummer", "")), ("Rechnungsdatum:", kopf.get("datum", "")), ] @@ -359,40 +352,23 @@ 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", - "proforma": "Proforma-Rechnung"}.get(art, "Rechnungsübersicht") + "berichtigung": "Berichtigte 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, ohne_ust=proforma) + _positionstabelle(story, st, rechnung) story.append(Spacer(1, 3 * mm)) # ---- Summenblock: Netto -> USt je Satz -> Zwischensumme (brutto) # -> zzgl. Beherbergungssteuer -> Gesamtbetrag ---- - # 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 [[ + summen_rows = [[ Paragraph("Nettobetrag:", st["info_l"]), Paragraph(eur(rechnung.netto_gesamt), st["cell_r"]), ]] - for b in ([] if proforma else rechnung.bloecke): + for b in rechnung.bloecke: name = b.bezeichnung or "USt" summen_rows.append([ Paragraph(f"{name} {b.satz} %:", st["info_l"]), @@ -408,10 +384,7 @@ 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. - # 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: + if rechnung.aufschlaege and not gesamtrechnung_betont: summen_rows.append([ Paragraph("Zwischensumme:", st["info_l"]), Paragraph(eur(rechnung.brutto), st["cell_r"]), @@ -425,9 +398,7 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): endsumme = rechnung.brutto if gesamtrechnung_betont else rechnung.gesamt summen_rows.append([ - Paragraph("Voraussichtlicher Gesamtbetrag:" if proforma else - "Rechnungsbetrag Insgesamt:" if montage else "Gesamtbetrag:", - st["total_l"]), + Paragraph("Rechnungsbetrag Insgesamt:" if montage else "Gesamtbetrag:", st["total_l"]), Paragraph(eur(endsumme), st["total_v"]), ]) style_cmds = [ @@ -444,12 +415,7 @@ 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 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: + if not gesamtrechnung_betont: for a in rechnung.aufschlaege: if not a.bezeichnung: continue @@ -501,15 +467,6 @@ 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']}" @@ -550,22 +507,6 @@ 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" @@ -651,17 +592,14 @@ def render_rechnung(pfad, cfg, rechnung, kopf): ziel, pagesize=A4, leftMargin=MARGIN_L, rightMargin=MARGIN_R, topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT, - title=({"storno": "Stornorechnung", - "proforma": "Proforma-Rechnung"}.get(kopf.get("art"), "Rechnung")) + title=("Stornorechnung" if kopf.get("art") == "storno" else "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 - # Eine Proforma bekommt KEIN Blatt fuers Amt: gemeldet wird eine - # Uebernachtung erst, wenn sie abgerechnet ist. - if rechnung.aufschlaege and kopf.get("art") != "proforma": + if rechnung.aufschlaege: 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 5fc8eda..e5369a5 100644 --- a/pruef_kernregeln.py +++ b/pruef_kernregeln.py @@ -137,14 +137,12 @@ 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. -# Aus der Sicherheitsfrage ist das Auswahlfenster geworden (Rechnung oder -# Proforma). "Abbrechen" heisst dort: gar nichts schreiben. -f._testwahl = None +antworten["endgültig erstellen"] = False 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") -f._testwahl = "rechnung" +antworten["endgültig erstellen"] = True # ---- Ausgangslage: zwei echte Rechnungen ------------------------------------ kunde_setzen("Anna", "Beispiel", "4") diff --git a/pruef_proforma.py b/pruef_proforma.py deleted file mode 100644 index 64265ee..0000000 --- a/pruef_proforma.py +++ /dev/null @@ -1,227 +0,0 @@ -# -*- 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)