Sicherheitsfrage vor dem Erstellen, Datenordner, toter Aufruf raus

- "Rechnung endgueltig erstellen?" nennt Nummer, Kunde und Betrag und weist
  darauf hin, dass danach nur noch Storno oder Berichtigung bleibt
  (§ 14 Abs. 4 Nr. 4 UStG, § 31 Abs. 5 UStDV). Geprueft ist auch, dass "Nein"
  wirklich nichts schreibt - sonst waere die Frage eine Attrappe.
- Als EXE legt das Programm sich beim ersten Start einen Ordner "Daten" neben
  die EXE (config.json). Ist dort nicht schreibbar (EXE unter "Programme"),
  weicht es nach LOCALAPPDATA aus. Lag die config.json noch direkt neben der
  EXE, zieht sie um - sonst faengt das Programm ohne Firmendaten neu an.
- Aufruf von _storno_fortsetzen entfernt: die Methode gibt es nicht mehr, seit
  die Kette auf dem Storno-Blatt steht. War unerreichbar, waere aber beim
  naechsten Anfassen ein AttributeError geworden.
- Pruefstaende raeumen ihren Wegwerf-Ordner weg und haben eine Reissleine
  (Abbruch nach zwei Minuten), damit kein Dialog mehr ewig offen steht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
This commit is contained in:
TheMockTv 2026-09-02 21:20:11 +02:00
parent 3ea31b3421
commit 5c68278191
7 changed files with 165 additions and 9 deletions

22
app.py
View file

@ -463,6 +463,28 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
"Trotzdem so auf die Rechnung schreiben?"):
return
# Letzte Gelegenheit, bevor der Beleg entsteht. Danach ist Ändern nicht
# mehr: eine ausgestellte Rechnung wird nur noch über einen Storno oder
# 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.
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"):
return
# Auf der Rechnung stehen Vor- und Nachname wie gewohnt in einer Zeile;
# der Nachname geht zusätzlich getrennt mit, damit das Steuerjournal ihn
# nicht aus dem Adressblock raten muss.

View file

@ -16,11 +16,59 @@ import sys
# - eingefroren (.exe): config.json/rechnungen NEBEN der .exe (beschreibbar),
# gebuendelte Ressourcen (Logo) liegen in sys._MEIPASS
if getattr(sys, "frozen", False):
BASE_DIR = os.path.dirname(sys.executable)
RESOURCE_DIR = getattr(sys, "_MEIPASS", BASE_DIR)
EXE_DIR = os.path.dirname(sys.executable)
RESOURCE_DIR = getattr(sys, "_MEIPASS", EXE_DIR)
else:
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
RESOURCE_DIR = BASE_DIR
EXE_DIR = os.path.dirname(os.path.abspath(__file__))
RESOURCE_DIR = EXE_DIR
def _beschreibbar(ordner):
"""Kann in diesen Ordner geschrieben werden? Wird ausprobiert, nicht geraten."""
try:
os.makedirs(ordner, exist_ok=True)
probe = os.path.join(ordner, ".schreibprobe")
with open(probe, "w") as f:
f.write("x")
os.remove(probe)
return True
except OSError:
return False
def _daten_ordner():
"""Der Ordner, den das Programm sich beim ersten Start selbst anlegt.
Alles, was das Programm schreibt, liegt darin beieinander - man sieht auf
einen Blick, was von ihm stammt, und kann es mitnehmen oder sichern.
1. neben der EXE ein Unterordner "Daten"
2. geht das nicht (EXE liegt z.B. unter "Programme"), dann
der Ordner Rechnungstool unter LOCALAPPDATA
Was schon da ist, wird benutzt; was fehlt, wird angelegt. Lagen die Dateien
noch direkt neben der EXE - so war es frueher -, ziehen sie mit um, statt
dass das Programm ohne Firmendaten und Zaehler neu anfaengt.
"""
if not getattr(sys, "frozen", False):
return EXE_DIR # aus der Quelle heraus bleibt alles, wo es ist
kandidat = os.path.join(EXE_DIR, "Daten")
if not _beschreibbar(kandidat):
kandidat = os.path.join(os.environ.get("LOCALAPPDATA", EXE_DIR), "Rechnungstool")
if not _beschreibbar(kandidat):
return EXE_DIR # letzter Ausweg: wie frueher
for name in ("config.json",):
alt, neu_ = os.path.join(EXE_DIR, name), os.path.join(kandidat, name)
if os.path.exists(alt) and not os.path.exists(neu_):
try:
os.replace(alt, neu_)
print(f"[config] {name} in den Datenordner übernommen")
except OSError:
pass
return kandidat
BASE_DIR = _daten_ordner()
CONFIG_PATH = os.path.join(BASE_DIR, "config.json")
# Leistungskatalog 1:1 aus der Excel-Vorlage (Rechnung Campinghof Bartl).

View file

@ -246,7 +246,6 @@ class KorrekturMixin:
if treffer.get("art") == "storno":
return None, f"{nr} ist selbst eine Stornorechnung."
nr_norm = nummer_normalisieren(treffer.get("rechnungsnummer"))
self._offener_storno = None
if nr_norm in stornierte:
st = stornos.get(nr_norm) or {}
folgt = st.get("korrektur_nummer") or ""
@ -282,9 +281,7 @@ class KorrekturMixin:
v_info.set(meldung)
return
d.destroy()
if storno and getattr(self, "_offener_storno", None):
self._storno_fortsetzen(self._offener_storno, meta, out_dir)
elif storno:
if storno:
self._storno_anlegen(meta, out_dir)
else:
self._berichtigung_starten(meta, out_dir)

View file

@ -35,7 +35,26 @@ from tkinter import messagebox # noqa: E402
messagebox.showinfo = lambda *a, **k: None
messagebox.showwarning = lambda *a, **k: None
messagebox.showerror = lambda *a, **k: None
messagebox.askyesno = lambda *a, **k: False
messagebox.askyesno = lambda *a, **k: True # Sicherheitsfrage bejahen
# --- Reissleine ---------------------------------------------------------
# Ein Pruefstand darf NIE haengen. Bleibt er in einem Dialog stehen (etwa weil
# eine Aenderung eine neue Frage eingefuehrt hat), bricht er nach zwei Minuten
# von selbst ab, statt ein Fenster offen zu lassen, auf das niemand klickt.
import threading as _th
def _reissleine():
import os as _os
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog, "
"der nicht beantwortet wird")
_os._exit(2)
_wecker = _th.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
fehler = 0
f = A.RechnungsApp()

View file

@ -41,6 +41,25 @@ import app as A # noqa: E402
import bestand as BE # noqa: E402
from tkinter import messagebox # noqa: E402
# --- Reissleine ---------------------------------------------------------
# Ein Pruefstand darf NIE haengen. Bleibt er in einem Dialog stehen (etwa weil
# eine Aenderung eine neue Frage eingefuehrt hat), bricht er nach zwei Minuten
# von selbst ab, statt ein Fenster offen zu lassen, auf das niemand klickt.
import threading as _th
def _reissleine():
import os as _os
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog, "
"der nicht beantwortet wird")
_os._exit(2)
_wecker = _th.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
fehler = []
@ -111,6 +130,17 @@ f.update()
f.cfg["output_dir"] = ordner
erste = sorted(f.anzahl_vars)[0]
antworten["Fertig"] = False # "jetzt oeffnen?" immer nein
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
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
# ---- Ausgangslage: zwei echte Rechnungen ------------------------------------
kunde_setzen("Anna", "Beispiel", "4")

View file

@ -40,6 +40,25 @@ import app as A # noqa: E402
import bestand as BE # noqa: E402
from tkinter import messagebox # noqa: E402
# --- Reissleine ---------------------------------------------------------
# Ein Pruefstand darf NIE haengen. Bleibt er in einem Dialog stehen (etwa weil
# eine Aenderung eine neue Frage eingefuehrt hat), bricht er nach zwei Minuten
# von selbst ab, statt ein Fenster offen zu lassen, auf das niemand klickt.
import threading as _th
def _reissleine():
import os as _os
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog, "
"der nicht beantwortet wird")
_os._exit(2)
_wecker = _th.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
fehler = []
@ -54,6 +73,7 @@ def pruefe(bedingung, text):
antworten = {"Fertig": False, "Storno wirklich": True,
"endgültig erstellen": True,
"Berichtigte Rechnung erstellen": True, "Hausnummer": True}

View file

@ -36,6 +36,25 @@ import app as A # noqa: E402
import bestand as BE # noqa: E402
from tkinter import messagebox # noqa: E402
# --- Reissleine ---------------------------------------------------------
# Ein Pruefstand darf NIE haengen. Bleibt er in einem Dialog stehen (etwa weil
# eine Aenderung eine neue Frage eingefuehrt hat), bricht er nach zwei Minuten
# von selbst ab, statt ein Fenster offen zu lassen, auf das niemand klickt.
import threading as _th
def _reissleine():
import os as _os
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog, "
"der nicht beantwortet wird")
_os._exit(2)
_wecker = _th.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
fehler = []
@ -65,6 +84,7 @@ def askyesno(titel, text, **kw):
antworten["Fertig"] = False # "jetzt oeffnen?" - nie oeffnen im Pruefstand
antworten["endgültig erstellen"] = True # die Sicherheitsfrage bejahen
antworten["Hausnummer"] = True # "Musterweg 1" hat eine, greift nur im Sonderfall
messagebox.askyesno = askyesno
messagebox.showinfo = lambda *a, **k: None