Mini-API zwischen beiden Programmen, Proforma-Rechnung
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx
This commit is contained in:
parent
8163213960
commit
94c2c7881a
7 changed files with 703 additions and 46 deletions
194
api.py
Normal file
194
api.py
Normal file
|
|
@ -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"
|
||||
178
app.py
178
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("<Escape>", 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_<nummer>" 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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
48
gemeinsam.py
48
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."""
|
||||
|
|
|
|||
|
|
@ -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<br/>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(
|
||||
"<b>Dies ist eine Proforma-Rechnung</b> – eine unverbindliche Vorab-"
|
||||
"Aufstellung der Kosten. Sie ist <b>keine Rechnung im Sinne des § 14 "
|
||||
"UStG</b>, berechtigt nicht zum Vorsteuerabzug und ist nicht zu bezahlen. "
|
||||
"Die Nummer oben ist <b>keine Rechnungsnummer</b>. 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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
227
pruef_proforma.py
Normal file
227
pruef_proforma.py
Normal file
|
|
@ -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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue