Compare commits
2 commits
ecad4b00ea
...
94c2c7881a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94c2c7881a | ||
|
|
8163213960 |
8 changed files with 867 additions and 30 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"
|
||||||
200
app.py
200
app.py
|
|
@ -22,9 +22,11 @@ Aufgeteilt in:
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import datetime
|
import datetime
|
||||||
|
import queue
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import ttk, messagebox
|
from tkinter import ttk, messagebox
|
||||||
|
|
||||||
|
import api
|
||||||
import config
|
import config
|
||||||
import berechnung
|
import berechnung
|
||||||
import gemeinsam
|
import gemeinsam
|
||||||
|
|
@ -66,6 +68,8 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
# Das gemeinsame Nummernbuch (siehe gemeinsam.py). None = noch keiner
|
# Das gemeinsame Nummernbuch (siehe gemeinsam.py). None = noch keiner
|
||||||
# gesetzt; das Programm laeuft dann weiter, warnt aber sichtbar.
|
# gesetzt; das Programm laeuft dann weiter, warnt aber sichtbar.
|
||||||
self.buch = None
|
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):
|
# Zwischenspeicher fuer eine reine Berichtigung (§ 31 Abs. 5 UStDV):
|
||||||
# die naechste PDF ist dann ein berichtigtes Blatt zu dieser Rechnung.
|
# die naechste PDF ist dann ein berichtigtes Blatt zu dieser Rechnung.
|
||||||
self.berichtigung_stand = None
|
self.berichtigung_stand = None
|
||||||
|
|
@ -89,6 +93,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
self.after(250, self._frage_speicherort_falls_noetig)
|
self.after(250, self._frage_speicherort_falls_noetig)
|
||||||
# ... und danach nach dem gemeinsamen Ordner fuer das Nummernbuch.
|
# ... und danach nach dem gemeinsamen Ordner fuer das Nummernbuch.
|
||||||
self.after(400, self._frage_gemeinsam_falls_noetig)
|
self.after(400, self._frage_gemeinsam_falls_noetig)
|
||||||
|
self.after(500, self._push_abholen)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- Menue
|
# ---------------------------------------------------------------- Menue
|
||||||
|
|
@ -536,6 +541,9 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
print("[nummernbuch] nicht erreichbar:", grund)
|
print("[nummernbuch] nicht erreichbar:", grund)
|
||||||
return
|
return
|
||||||
self.buch = gemeinsam.Nummernbuch(ordner)
|
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):
|
def _frage_gemeinsam_falls_noetig(self):
|
||||||
"""Beim ersten Start nach dem gemeinsamen Ordner fragen.
|
"""Beim ersten Start nach dem gemeinsamen Ordner fragen.
|
||||||
|
|
@ -635,6 +643,56 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
f"Nummernbuch eingetragen werden:\n{e}\n\n"
|
f"Nummernbuch eingetragen werden:\n{e}\n\n"
|
||||||
"Das Steuerjournal sieht sie trotzdem - es liest die PDFs.")
|
"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):
|
def _nachtragen(self):
|
||||||
"""Alles, was schon im Rechnungsordner liegt, ins Buch schreiben.
|
"""Alles, was schon im Rechnungsordner liegt, ins Buch schreiben.
|
||||||
|
|
||||||
|
|
@ -780,7 +838,91 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
return [f"{name}: „{wert}“ ist kein Datum"
|
return [f"{name}: „{wert}“ ist kein Datum"
|
||||||
for name, wert in felder if wert and not parse_date(wert)]
|
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()
|
montage = self.ist_montage()
|
||||||
r = self.recalc()
|
r = self.recalc()
|
||||||
if not r.zeilen:
|
if not r.zeilen:
|
||||||
|
|
@ -837,22 +979,19 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
# eine Berichtigung korrigiert - beides kostet Papier und einen Weg zum
|
# eine Berichtigung korrigiert - beides kostet Papier und einen Weg zum
|
||||||
# Gast. Beim Storno- und Berichtigungsweg fragt das Programm ohnehin
|
# Gast. Beim Storno- und Berichtigungsweg fragt das Programm ohnehin
|
||||||
# schon, darum hier nur bei einer ganz normalen Rechnung.
|
# 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:
|
if not self.storno_stand and not self.berichtigung_stand:
|
||||||
wer = " ".join(t for t in (self.k_vorname.get().strip(),
|
wer = " ".join(t for t in (self.k_vorname.get().strip(),
|
||||||
self.k_name.get().strip()) if t)
|
self.k_name.get().strip()) if t)
|
||||||
if not messagebox.askyesno(
|
wahl = self._art_waehlen(nummer, berechnung.eur(r.gesamt), wer)
|
||||||
"Rechnung endgültig erstellen?",
|
if wahl is None:
|
||||||
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
|
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;
|
# Auf der Rechnung stehen Vor- und Nachname wie gewohnt in einer Zeile;
|
||||||
# der Nachname geht zusätzlich getrennt mit, damit das Steuerjournal ihn
|
# der Nachname geht zusätzlich getrennt mit, damit das Steuerjournal ihn
|
||||||
|
|
@ -905,6 +1044,9 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
# dahinter ist eine ganz normale: Zwischensumme + Beherbergungssteuer.
|
# dahinter ist eine ganz normale: Zwischensumme + Beherbergungssteuer.
|
||||||
kopf["vorlage"] = "montage"
|
kopf["vorlage"] = "montage"
|
||||||
kopf["montage_text"] = self._montage_text()
|
kopf["montage_text"] = self._montage_text()
|
||||||
|
if proforma:
|
||||||
|
kopf["art"] = "proforma"
|
||||||
|
kopf["rechnungsnummer"] = nummer
|
||||||
if stand:
|
if stand:
|
||||||
kopf["art"] = "korrektur"
|
kopf["art"] = "korrektur"
|
||||||
kopf["vorgang"] = stand.get("vorgang") or stand["zu"]
|
kopf["vorgang"] = stand.get("vorgang") or stand["zu"]
|
||||||
|
|
@ -998,6 +1140,36 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
self.r_nummer.set(nummer)
|
self.r_nummer.set(nummer)
|
||||||
kopf["rechnungsnummer"] = 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
|
# Der Dateiname faengt weiter mit "Rechnung_<nummer>" an - daran erkennt
|
||||||
# bestand.vorhandene_rechnungen() eine schon vergebene Nummer. Das
|
# bestand.vorhandene_rechnungen() eine schon vergebene Nummer. Das
|
||||||
# "_Montage" haengt nur hinten dran, damit man die Sorte im Ordner sieht.
|
# "_Montage" haengt nur hinten dran, damit man die Sorte im Ordner sieht.
|
||||||
|
|
@ -1020,6 +1192,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
# auch wenn es die PDF (noch) nicht eingelesen hat.
|
# auch wenn es die PDF (noch) nicht eingelesen hat.
|
||||||
self._ins_buch(nummer, kopf, r, pfad)
|
self._ins_buch(nummer, kopf, r, pfad)
|
||||||
self._buch_anzeigen()
|
self._buch_anzeigen()
|
||||||
|
self._journal_benachrichtigen(f"Rechnung {nummer} erstellt")
|
||||||
|
|
||||||
# Rechnungsnummer-Zaehler hochsetzen, falls Format JJJJ-NNN
|
# Rechnungsnummer-Zaehler hochsetzen, falls Format JJJJ-NNN
|
||||||
self._zaehler_aus_nummer_speichern(nummer)
|
self._zaehler_aus_nummer_speichern(nummer)
|
||||||
|
|
@ -1078,6 +1251,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
"Beim Schließen bleibt er so liegen – morgen weiß niemand mehr, "
|
"Beim Schließen bleibt er so liegen – morgen weiß niemand mehr, "
|
||||||
"warum.\n\nTrotzdem beenden?", icon="warning", default="no"):
|
"warum.\n\nTrotzdem beenden?", icon="warning", default="no"):
|
||||||
return
|
return
|
||||||
|
self.api.stoppen(self.buch)
|
||||||
self.destroy()
|
self.destroy()
|
||||||
|
|
||||||
def reset_formular(self):
|
def reset_formular(self):
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,10 @@ DEFAULT_CONFIG = {
|
||||||
"texte": DEFAULT_TEXTE,
|
"texte": DEFAULT_TEXTE,
|
||||||
"katalog": DEFAULT_KATALOG,
|
"katalog": DEFAULT_KATALOG,
|
||||||
"montage": DEFAULT_MONTAGE,
|
"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.
|
# Alle Steuern als EINE benannte Liste.
|
||||||
# - extra_blatt = False: USt-Satz, wird auf das Netto der zugeordneten Leistungen gerechnet.
|
# - 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.
|
# - extra_blatt = True : Aufschlag auf die Endsumme (z.B. Beherbergungssteuer), eigenes Blatt.
|
||||||
|
|
|
||||||
173
gemeinsam.py
173
gemeinsam.py
|
|
@ -27,6 +27,8 @@ hinueber - sonst schreiben zwei Fassungen in dieselbe Datei.
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
log = logging.getLogger("gemeinsam")
|
log = logging.getLogger("gemeinsam")
|
||||||
|
|
||||||
|
|
@ -56,6 +58,27 @@ CREATE TABLE IF NOT EXISTS nummern (
|
||||||
geaendert TEXT NOT NULL DEFAULT '' -- ISO-Zeitstempel
|
geaendert TEXT NOT NULL DEFAULT '' -- ISO-Zeitstempel
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_nummern_jahr ON nummern (jahr);
|
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",
|
FELDER = ("jahr", "datum", "nachname", "vorname", "naechte", "zwischensumme",
|
||||||
|
|
@ -84,6 +107,78 @@ def ordner_taugt(ordner):
|
||||||
return True, ""
|
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:
|
class Nummernbuch:
|
||||||
"""Das gemeinsame Register. Wird zum Schreiben kurz geoeffnet und wieder
|
"""Das gemeinsame Register. Wird zum Schreiben kurz geoeffnet und wieder
|
||||||
geschlossen - zwei Programme haben die Datei sonst dauerhaft in der Hand,
|
geschlossen - zwei Programme haben die Datei sonst dauerhaft in der Hand,
|
||||||
|
|
@ -97,6 +192,11 @@ class Nummernbuch:
|
||||||
con = sqlite3.connect(self.pfad, timeout=10)
|
con = sqlite3.connect(self.pfad, timeout=10)
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
con.executescript(SCHEMA)
|
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
|
return con
|
||||||
|
|
||||||
# ---- Lesen -------------------------------------------------------------
|
# ---- Lesen -------------------------------------------------------------
|
||||||
|
|
@ -153,6 +253,79 @@ class Nummernbuch:
|
||||||
return {z["storno_zu"] for z in
|
return {z["storno_zu"] for z in
|
||||||
con.execute("SELECT storno_zu FROM nummern WHERE storno_zu <> ''")}
|
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):
|
def entfernen(self, nummer):
|
||||||
"""Nur fuer den Rueckweg: ein verworfener Storno gibt seine Nummer zurueck."""
|
"""Nur fuer den Rueckweg: ein verworfener Storno gibt seine Nummer zurueck."""
|
||||||
with self._verbindung() as con:
|
with self._verbindung() as con:
|
||||||
|
|
|
||||||
|
|
@ -392,6 +392,7 @@ class KorrekturMixin:
|
||||||
# erfaehrt nicht, dass die Rechnung aufgehoben ist.
|
# erfaehrt nicht, dass die Rechnung aufgehoben ist.
|
||||||
self._ins_buch(nummer, kopf, r, pfad)
|
self._ins_buch(nummer, kopf, r, pfad)
|
||||||
self._buch_anzeigen()
|
self._buch_anzeigen()
|
||||||
|
self._journal_benachrichtigen(f"Storno {nummer} zu Rechnung {alt_nummer} erstellt")
|
||||||
|
|
||||||
fehlend = self._formular_aus_meta(meta)
|
fehlend = self._formular_aus_meta(meta)
|
||||||
self.r_nummer.set(folge_nummer or "")
|
self.r_nummer.set(folge_nummer or "")
|
||||||
|
|
|
||||||
|
|
@ -188,13 +188,17 @@ def _kopf_fuss(canvas, doc, cfg, amt=False):
|
||||||
|
|
||||||
|
|
||||||
# ---- Hauptfunktion ----------------------------------------------------------
|
# ---- Hauptfunktion ----------------------------------------------------------
|
||||||
def _positionstabelle(story, st, rechnung):
|
def _positionstabelle(story, st, rechnung, ohne_ust=False):
|
||||||
"""Die gewohnte Positionstabelle der Campingrechnung."""
|
"""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]
|
col_w = [10 * mm, 70 * mm, 12 * mm, 21 * mm, 13 * mm, 22 * mm, 26 * mm]
|
||||||
data = [[
|
data = [[
|
||||||
Paragraph("Pos.", st["th"]),
|
Paragraph("Pos.", st["th"]),
|
||||||
Paragraph("Leistung", st["th_l"]),
|
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("Anzahl<br/>Tage/Menge", st["th"]),
|
||||||
Paragraph("Pers.", st["th"]),
|
Paragraph("Pers.", st["th"]),
|
||||||
Paragraph("E.-Preis", st["th"]),
|
Paragraph("E.-Preis", st["th"]),
|
||||||
|
|
@ -205,7 +209,7 @@ def _positionstabelle(story, st, rechnung):
|
||||||
data.append([
|
data.append([
|
||||||
Paragraph(str(z.nr), st["cell_c"]),
|
Paragraph(str(z.nr), st["cell_c"]),
|
||||||
Paragraph(z.leistung, st["leistung"]),
|
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(eur_kurz(z.anzahl).replace(",00", ""), st["cell_c"]),
|
||||||
Paragraph(pers, st["cell_c"]),
|
Paragraph(pers, st["cell_c"]),
|
||||||
Paragraph(eur_kurz(z.preis), st["cell_r"]),
|
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))
|
story.append(Spacer(1, 5 * mm))
|
||||||
|
|
||||||
# Kundenadresse (links) | Rechnungsinfos (rechts)
|
# 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", []):
|
for z in kopf.get("kunde_zeilen", []):
|
||||||
if str(z).strip():
|
if str(z).strip():
|
||||||
kunde_flow.append(Paragraph(str(z), st["addr"]))
|
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
|
# Keine Positionstabelle, keine Naechte im Kopf - der Zeitraum steht in der
|
||||||
# einen Textzeile, so wie Bartl das Blatt bisher von Hand geschrieben hat.
|
# einen Textzeile, so wie Bartl das Blatt bisher von Hand geschrieben hat.
|
||||||
montage = kopf.get("vorlage") == "montage"
|
montage = kopf.get("vorlage") == "montage"
|
||||||
|
proforma = art == "proforma"
|
||||||
info_rows = [
|
info_rows = [
|
||||||
("Stornonummer:" if art == "storno" else "Rechnungsnummer:",
|
("Stornonummer:" if art == "storno" else
|
||||||
|
"Proforma-Nr.:" if proforma else "Rechnungsnummer:",
|
||||||
kopf.get("rechnungsnummer", "")),
|
kopf.get("rechnungsnummer", "")),
|
||||||
("Rechnungsdatum:", kopf.get("datum", "")),
|
("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
|
# Das Wort "Stornorechnung" muss auf dem Blatt stehen, damit der Beleg
|
||||||
# eindeutig als Aufhebung erkennbar ist.
|
# eindeutig als Aufhebung erkennbar ist.
|
||||||
titel = {"storno": "Stornorechnung",
|
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 ""
|
titel += " (für das Amt)" if gesamtrechnung_betont else ""
|
||||||
story.append(Paragraph(titel, st["h"]))
|
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:
|
if montage:
|
||||||
_montage_zeile(story, st, rechnung, kopf)
|
_montage_zeile(story, st, rechnung, kopf)
|
||||||
else:
|
else:
|
||||||
_positionstabelle(story, st, rechnung)
|
_positionstabelle(story, st, rechnung, ohne_ust=proforma)
|
||||||
story.append(Spacer(1, 3 * mm))
|
story.append(Spacer(1, 3 * mm))
|
||||||
|
|
||||||
# ---- Summenblock: Netto -> USt je Satz -> Zwischensumme (brutto)
|
# ---- Summenblock: Netto -> USt je Satz -> Zwischensumme (brutto)
|
||||||
# -> zzgl. Beherbergungssteuer -> Gesamtbetrag ----
|
# -> 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("Nettobetrag:", st["info_l"]),
|
||||||
Paragraph(eur(rechnung.netto_gesamt), st["cell_r"]),
|
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"
|
name = b.bezeichnung or "USt"
|
||||||
summen_rows.append([
|
summen_rows.append([
|
||||||
Paragraph(f"{name} {b.satz} %:", st["info_l"]),
|
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
|
# 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.
|
# 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([
|
summen_rows.append([
|
||||||
Paragraph("Zwischensumme:", st["info_l"]),
|
Paragraph("Zwischensumme:", st["info_l"]),
|
||||||
Paragraph(eur(rechnung.brutto), st["cell_r"]),
|
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
|
endsumme = rechnung.brutto if gesamtrechnung_betont else rechnung.gesamt
|
||||||
summen_rows.append([
|
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"]),
|
Paragraph(eur(endsumme), st["total_v"]),
|
||||||
])
|
])
|
||||||
style_cmds = [
|
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
|
# Hinweis zur Beherbergungssteuer - nur auf der Kundenseite. Auf dem Amt-Blatt
|
||||||
# wird die Steuer gar nicht mehr ausgewiesen, dann waere der Satz sinnlos.
|
# 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:
|
for a in rechnung.aufschlaege:
|
||||||
if not a.bezeichnung:
|
if not a.bezeichnung:
|
||||||
continue
|
continue
|
||||||
|
|
@ -467,6 +501,15 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
||||||
"ihre Nummer.", st["dank"]))
|
"ihre Nummer.", st["dank"]))
|
||||||
if texte.get("dank1"):
|
if texte.get("dank1"):
|
||||||
block.append(Paragraph(texte["dank1"], st["dank"]))
|
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:
|
else:
|
||||||
if art == "korrektur" and kopf.get("ersetzt"):
|
if art == "korrektur" and kopf.get("ersetzt"):
|
||||||
hinweis = f"Diese Rechnung ersetzt die Rechnung {kopf['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).
|
Steuerrechner summieren dadurch von allein richtig (+100 -100 +120 = 120).
|
||||||
"""
|
"""
|
||||||
aufschlag = rechnung.aufschlaege[0] if rechnung.aufschlaege else None
|
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":
|
if kopf.get("art") == "berichtigung":
|
||||||
# Das berichtigte Blatt zeigt dieselben Betraege wie die Rechnung, ist aber
|
# Das berichtigte Blatt zeigt dieselben Betraege wie die Rechnung, ist aber
|
||||||
# KEINE zweite Einnahme. Es traegt deshalb Nullen: ein Programm, das "art"
|
# 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,
|
ziel, pagesize=A4,
|
||||||
leftMargin=MARGIN_L, rightMargin=MARGIN_R,
|
leftMargin=MARGIN_L, rightMargin=MARGIN_R,
|
||||||
topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT,
|
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','')}",
|
+ f" {kopf.get('rechnungsnummer','')}",
|
||||||
author=cfg["firma"].get("name", ""),
|
author=cfg["firma"].get("name", ""),
|
||||||
subject=_kenndaten(rechnung, kopf), # maschinenlesbar fuers Steuerjournal
|
subject=_kenndaten(rechnung, kopf), # maschinenlesbar fuers Steuerjournal
|
||||||
)
|
)
|
||||||
story = []
|
story = []
|
||||||
_seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=False) # Kunde
|
_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(PageBreak())
|
||||||
story.append(_AmtMarke(marke))
|
story.append(_AmtMarke(marke))
|
||||||
_seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=True) # Amt
|
_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 ----------------------
|
# ---- Die Sicherheitsfrage muss auch wirklich abbrechen ----------------------
|
||||||
# "Sind alle Angaben richtig?" ist die letzte Gelegenheit vor dem Beleg. Sagt
|
# "Sind alle Angaben richtig?" ist die letzte Gelegenheit vor dem Beleg. Sagt
|
||||||
# der Nutzer Nein, darf NICHTS entstehen - sonst waere die Frage eine Attrappe.
|
# 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")
|
kunde_setzen("Test", "Abbruch", "1")
|
||||||
f.pdf_erstellen()
|
f.pdf_erstellen()
|
||||||
pruefe(not [n for n in os.listdir(ordner) if n.lower().endswith(".pdf")],
|
pruefe(not [n for n in os.listdir(ordner) if n.lower().endswith(".pdf")],
|
||||||
"bei „Nein“ auf die Sicherheitsfrage entsteht keine PDF")
|
"bei „Nein“ auf die Sicherheitsfrage entsteht keine PDF")
|
||||||
antworten["endgültig erstellen"] = True
|
f._testwahl = "rechnung"
|
||||||
|
|
||||||
# ---- Ausgangslage: zwei echte Rechnungen ------------------------------------
|
# ---- Ausgangslage: zwei echte Rechnungen ------------------------------------
|
||||||
kunde_setzen("Anna", "Beispiel", "4")
|
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