Funde der drei Agenten: zwei kaputte Dialoge, haengende Flags, scharfe Pruefstaende
REGRESSION aus der Modulaufteilung (Code-Review-Agent): - einstellungen.py fehlten "berechnung" und "datetime". Menue > Leistungskatalog und > Rechnungsnummer stuerzten mit NameError ab und liessen ein leeres, modales Fenster stehen. Preise und Nummernkreis waren nicht mehr einstellbar. KEIN Pruefstand hat das gemerkt - keiner oeffnete je einen Dialog. Neu: pruef_dialoge.py oeffnet ALLE Dialoge und schliesst sie wieder. HAENGENDE ZWISCHENSPEICHER (derselbe Agent, gemessen): - "Zuruecksetzen" liess storno_stand/berichtigung_stand stehen. Danach gehoerten die Daten eines FREMDEN Gastes zum Storno der alten Rechnung, und die Berichtigung schrieb ein Blatt mit Nummer A im Kenndatensatz und Nummer B auf dem Blatt. reset_formular fragt jetzt nach und verwirft sauber. - _neue_nummer_vorschlagen fasst die Nummer nicht mehr an, solange ein Vorgang laeuft (traf auch dlg_nummer). - Fenster schliessen mit offenem Storno fragt jetzt nach (WM_DELETE_WINDOW). - _storno_verwerfen bricht ab, wenn das PDF nicht geloescht werden kann, statt den Zaehler trotzdem zurueckzudrehen. - Der Storno prueft die Kundenangaben und fragt, wenn die alte Rechnung sie nicht hergibt. - Zaehler nicht speicherbar -> sichtbare Meldung statt Traceback ins Nichts. - storno.py: v1-Rueckfall nimmt den ersten Satz OHNE extra_blatt. - EINE Quelle fuer die naechste Nummer: _neue_nummer_vorschlagen benutzt jetzt naechste_freie_nummer, der Jahreswechsel steht nur noch dort. Vorher schlug das Formular 2026-001 vor, waehrend der Storno-Weg 2025-088 nahm. PRUEFSTAENDE (Audit-Agent: 16 von 27 eingebauten Fehlern blieben unbemerkt): - Neu pruef_kernregeln.py (27 Pruefungen) fuer die zwei Regeln, um die es geht: Nummer von HAND auf eine vergebene setzen, Zaehler hinter dem Ordner, Jahreswechsel, Zaehler nur vorwaerts, Storno ueber den DIALOGKNOPF statt der internen Methode, zweite Berichtigung am selben Tag. Der Bestand wird ueber SHA256 verglichen - "Datei ist noch da" heisst nicht "unveraendert". - Der Text des Storno-PDFs wird gelesen: Positionszeile negativ, nicht nur der Summenblock. - Tautologien raus: Selbstvergleich beim storno_datum, all() ueber eine leere Liste, "nicht" in einem deutschen Text. - Unangemeldeter Dialog laesst den Lauf scheitern, statt still "Nein" zu sagen. - pruef_nummern.py: .PDF gross geschrieben, Rechnung_*.txt, echte Praefix-Falle. - Gegenprobe: sechs Mutationen eingebaut, die vorher gruen blieben - alle sechs werden jetzt rot. RECHT (Gegenpruefungs-Agent): Zitate praezisiert. § 146 Abs. 4 AO verbietet nur Aenderungen, bei denen der urspruengliche Inhalt nicht mehr feststellbar ist - ein protokollierter Storno-Vermerk waere erlaubt, wir verzichten trotzdem bewusst darauf. Der § 14c-Hinweis bleibt, gilt aber gegenueber Endverbrauchern nicht (EuGH C-378/21, BMF v. 27.02.2024) - er zielt auf Firmengaeste. 142 Pruefungen gruen (100 + 27 + 8 + 7). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
This commit is contained in:
parent
2277a12e0f
commit
d67448c58f
11 changed files with 538 additions and 29 deletions
67
app.py
67
app.py
|
|
@ -66,6 +66,10 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
self._neue_nummer_vorschlagen()
|
self._neue_nummer_vorschlagen()
|
||||||
self.recalc()
|
self.recalc()
|
||||||
|
|
||||||
|
# Beim Schliessen nachfragen, wenn ein Storno noch offen ist - sonst liegt
|
||||||
|
# es morgen ohne berichtigte Rechnung im Ordner und keiner weiss warum.
|
||||||
|
self.protocol("WM_DELETE_WINDOW", self._beenden)
|
||||||
|
|
||||||
# Titelleiste dunkel (nach dem Mappen des Fensters)
|
# Titelleiste dunkel (nach dem Mappen des Fensters)
|
||||||
self.after(60, lambda: self._dark_titlebar(self))
|
self.after(60, lambda: self._dark_titlebar(self))
|
||||||
# Beim ersten Start nach dem Speicherort fragen (wenn noch keiner gesetzt ist)
|
# Beim ersten Start nach dem Speicherort fragen (wenn noch keiner gesetzt ist)
|
||||||
|
|
@ -83,7 +87,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
("Formular zurücksetzen", self.reset_formular),
|
("Formular zurücksetzen", self.reset_formular),
|
||||||
("Ausgabeordner öffnen", self._ordner_oeffnen),
|
("Ausgabeordner öffnen", self._ordner_oeffnen),
|
||||||
("-", None),
|
("-", None),
|
||||||
("Beenden", self.destroy),
|
("Beenden", self._beenden), # fragt nach, wenn ein Storno offen ist
|
||||||
],
|
],
|
||||||
"Einstellungen": [
|
"Einstellungen": [
|
||||||
("Steuersätze…", self.dlg_steuern),
|
("Steuersätze…", self.dlg_steuern),
|
||||||
|
|
@ -403,12 +407,16 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
|
|
||||||
# ----------------------------------------------------------- Aktionen
|
# ----------------------------------------------------------- Aktionen
|
||||||
def _neue_nummer_vorschlagen(self):
|
def _neue_nummer_vorschlagen(self):
|
||||||
jahr = self.cfg.get("rechnung_jahr", datetime.date.today().year)
|
"""Nächste freie Nummer ins Formular - EINE Quelle, sonst laufen sie auseinander.
|
||||||
zaehler = self.cfg.get("rechnung_zaehler", 0)
|
|
||||||
heute_jahr = datetime.date.today().year
|
Läuft gerade ein Storno oder eine Berichtigung, gehört die Nummer im Feld
|
||||||
if heute_jahr != jahr:
|
zu diesem Vorgang und wird nicht angefasst.
|
||||||
jahr, zaehler = heute_jahr, 0
|
"""
|
||||||
self.r_nummer.set(config.format_rechnungsnummer(jahr, zaehler + 1))
|
if self.storno_stand or self.berichtigung_stand:
|
||||||
|
return
|
||||||
|
frei = naechste_freie_nummer(config.ensure_output_dir(self.cfg), self.cfg)
|
||||||
|
if frei:
|
||||||
|
self.r_nummer.set(frei)
|
||||||
|
|
||||||
|
|
||||||
def kunde_pruefen(self):
|
def kunde_pruefen(self):
|
||||||
|
|
@ -548,7 +556,9 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Nummer schon vergeben? Dann NICHT drueberschreiben: eine ausgestellte
|
# Nummer schon vergeben? Dann NICHT drueberschreiben: eine ausgestellte
|
||||||
# Rechnung muss unveraendert bestehen bleiben (§ 146 Abs. 4 AO), und jede
|
# Rechnung bleibt liegen - § 146 Abs. 4 AO verbietet Aenderungen, bei denen
|
||||||
|
# der urspruengliche Inhalt nicht mehr feststellbar ist; ein unangetasteter
|
||||||
|
# Beleg ist im Pruefungsfall die einfachere Geschichte. Und jede
|
||||||
# Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG). Also neue Nummer.
|
# Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG). Also neue Nummer.
|
||||||
vorhanden = vorhandene_rechnungen(out_dir, nummer)
|
vorhanden = vorhandene_rechnungen(out_dir, nummer)
|
||||||
if vorhanden:
|
if vorhanden:
|
||||||
|
|
@ -619,10 +629,49 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
return
|
return
|
||||||
self.cfg["rechnung_jahr"] = jahr
|
self.cfg["rechnung_jahr"] = jahr
|
||||||
self.cfg["rechnung_zaehler"] = z
|
self.cfg["rechnung_zaehler"] = z
|
||||||
config.save_config(self.cfg)
|
try:
|
||||||
|
config.save_config(self.cfg)
|
||||||
|
except OSError as e: # noqa: BLE001 - die PDF ist schon geschrieben
|
||||||
|
# Ohne diese Meldung merkt niemand, dass der Zaehler stehengeblieben
|
||||||
|
# ist: die Ausgabe geht in eine Konsole, die es in der EXE nicht gibt.
|
||||||
|
messagebox.showwarning(
|
||||||
|
"Rechnungsnummer nicht gespeichert",
|
||||||
|
f"Die Rechnung ist geschrieben, der Zählerstand aber nicht:\n{e}\n\n"
|
||||||
|
f"Bitte im Menü unter „Rechnungsnummer…“ den Zähler auf {z} setzen, "
|
||||||
|
"sonst wird die nächste Nummer noch einmal vergeben.")
|
||||||
self._neue_nummer_vorschlagen()
|
self._neue_nummer_vorschlagen()
|
||||||
|
|
||||||
|
def _beenden(self):
|
||||||
|
"""Schliessen - mit Rückfrage, wenn ein Storno noch offen ist."""
|
||||||
|
if self.storno_stand:
|
||||||
|
if not messagebox.askyesno(
|
||||||
|
"Storno ist noch offen",
|
||||||
|
f"Der Storno {self.storno_stand['nummer']} zu Rechnung "
|
||||||
|
f"{self.storno_stand['zu']} liegt im Ordner, die berichtigte "
|
||||||
|
"Rechnung fehlt noch.\n\n"
|
||||||
|
"Beim Schließen bleibt er so liegen – morgen weiß niemand mehr, "
|
||||||
|
"warum.\n\nTrotzdem beenden?", icon="warning", default="no"):
|
||||||
|
return
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
def reset_formular(self):
|
def reset_formular(self):
|
||||||
|
# Ein laufender Vorgang darf nicht stumm liegenbleiben: sonst gehören die
|
||||||
|
# neu eingetippten Daten plötzlich zum Storno der fremden Rechnung.
|
||||||
|
if self.storno_stand:
|
||||||
|
if not messagebox.askyesno(
|
||||||
|
"Storno verwerfen?",
|
||||||
|
f"Der Storno {self.storno_stand['nummer']} zu Rechnung "
|
||||||
|
f"{self.storno_stand['zu']} liegt noch bereit.\n\n"
|
||||||
|
"Zurücksetzen verwirft ihn – das Storno-PDF wird gelöscht und "
|
||||||
|
f"es bleibt allein die Rechnung {self.storno_stand['zu']}.\n\n"
|
||||||
|
"Wirklich zurücksetzen?", icon="warning", default="no"):
|
||||||
|
return
|
||||||
|
self._storno_verwerfen("beim Zurücksetzen verworfen")
|
||||||
|
elif self.berichtigung_stand:
|
||||||
|
# Da ist noch nichts geschrieben - einfach fallenlassen.
|
||||||
|
self.berichtigung_stand = None
|
||||||
|
self._storno_anzeigen()
|
||||||
|
|
||||||
for v in (self.k_anrede, self.k_vorname, self.k_name, self.k_zusatz,
|
for v in (self.k_anrede, self.k_vorname, self.k_name, self.k_zusatz,
|
||||||
self.k_strasse, self.k_plzort, self.r_anreise, self.r_abreise):
|
self.k_strasse, self.k_plzort, self.r_anreise, self.r_abreise):
|
||||||
v.set("")
|
v.set("")
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,11 @@ def naechste_freie_nummer(ordner, cfg):
|
||||||
"""
|
"""
|
||||||
jahr = cfg.get("rechnung_jahr", datetime.date.today().year)
|
jahr = cfg.get("rechnung_jahr", datetime.date.today().year)
|
||||||
z = cfg.get("rechnung_zaehler", 0)
|
z = cfg.get("rechnung_zaehler", 0)
|
||||||
|
# Jahreswechsel: neues Jahr faengt bei 001 an. Muss HIER stehen und nirgends
|
||||||
|
# sonst - sonst schlaegt das Formular 2026-001 vor und der Storno nimmt
|
||||||
|
# gleichzeitig noch 2025-088.
|
||||||
|
if datetime.date.today().year != jahr:
|
||||||
|
jahr, z = datetime.date.today().year, 0
|
||||||
for _ in range(1000):
|
for _ in range(1000):
|
||||||
z += 1
|
z += 1
|
||||||
kandidat = config.format_rechnungsnummer(jahr, z)
|
kandidat = config.format_rechnungsnummer(jahr, z)
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,12 @@ Alles, was hier eingestellt wird, landet in der config.json. Nichts davon steht
|
||||||
hart im Code: Steuersaetze, Preise, Firmendaten, Rechnungsnummernkreis.
|
hart im Code: Steuersaetze, Preise, Firmendaten, Rechnungsnummernkreis.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
import os
|
import os
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import ttk, messagebox, filedialog
|
from tkinter import ttk, messagebox, filedialog
|
||||||
|
|
||||||
|
import berechnung
|
||||||
import config
|
import config
|
||||||
from helfer import parse_num
|
from helfer import parse_num
|
||||||
|
|
||||||
|
|
@ -29,6 +31,7 @@ class EinstellungenMixin:
|
||||||
self.cfg["output_dir"] = d if d else vorschlag
|
self.cfg["output_dir"] = d if d else vorschlag
|
||||||
config.save_config(self.cfg)
|
config.save_config(self.cfg)
|
||||||
config.ensure_output_dir(self.cfg)
|
config.ensure_output_dir(self.cfg)
|
||||||
|
|
||||||
def dlg_speicherort(self):
|
def dlg_speicherort(self):
|
||||||
aktuell = config.ensure_output_dir(self.cfg)
|
aktuell = config.ensure_output_dir(self.cfg)
|
||||||
d = filedialog.askdirectory(title="Ordner für die Rechnungen wählen", initialdir=aktuell)
|
d = filedialog.askdirectory(title="Ordner für die Rechnungen wählen", initialdir=aktuell)
|
||||||
|
|
@ -37,6 +40,7 @@ class EinstellungenMixin:
|
||||||
config.save_config(self.cfg)
|
config.save_config(self.cfg)
|
||||||
config.ensure_output_dir(self.cfg)
|
config.ensure_output_dir(self.cfg)
|
||||||
messagebox.showinfo("Speicherort", f"Rechnungen werden gespeichert in:\n{d}")
|
messagebox.showinfo("Speicherort", f"Rechnungen werden gespeichert in:\n{d}")
|
||||||
|
|
||||||
def _info(self):
|
def _info(self):
|
||||||
d = tk.Toplevel(self)
|
d = tk.Toplevel(self)
|
||||||
d.title("Info")
|
d.title("Info")
|
||||||
|
|
@ -57,6 +61,7 @@ class EinstellungenMixin:
|
||||||
d.bind("<Return>", lambda e: d.destroy())
|
d.bind("<Return>", lambda e: d.destroy())
|
||||||
d.grab_set()
|
d.grab_set()
|
||||||
# ====================================================== Einstellungen
|
# ====================================================== Einstellungen
|
||||||
|
|
||||||
def dlg_steuern(self):
|
def dlg_steuern(self):
|
||||||
d = tk.Toplevel(self)
|
d = tk.Toplevel(self)
|
||||||
d.title("Steuersätze")
|
d.title("Steuersätze")
|
||||||
|
|
@ -126,6 +131,7 @@ class EinstellungenMixin:
|
||||||
btns.pack(side="bottom", fill="x", pady=(12, 0))
|
btns.pack(side="bottom", fill="x", pady=(12, 0))
|
||||||
ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4)
|
ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4)
|
||||||
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
|
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
|
||||||
|
|
||||||
def dlg_katalog(self):
|
def dlg_katalog(self):
|
||||||
d = tk.Toplevel(self)
|
d = tk.Toplevel(self)
|
||||||
d.title("Leistungskatalog / Preise")
|
d.title("Leistungskatalog / Preise")
|
||||||
|
|
@ -256,6 +262,7 @@ class EinstellungenMixin:
|
||||||
d.geometry(f"{win_w}x{int(win_h)}")
|
d.geometry(f"{win_w}x{int(win_h)}")
|
||||||
d.minsize(min(win_w, 820), 420)
|
d.minsize(min(win_w, 820), 420)
|
||||||
info_lbl.configure(wraplength=win_w - 24)
|
info_lbl.configure(wraplength=win_w - 24)
|
||||||
|
|
||||||
def dlg_firma(self):
|
def dlg_firma(self):
|
||||||
d = tk.Toplevel(self)
|
d = tk.Toplevel(self)
|
||||||
d.title("Firmendaten & Texte")
|
d.title("Firmendaten & Texte")
|
||||||
|
|
@ -326,6 +333,7 @@ class EinstellungenMixin:
|
||||||
btns.grid(row=base + 4, column=0, columnspan=2, pady=(12, 0), sticky="e")
|
btns.grid(row=base + 4, column=0, columnspan=2, pady=(12, 0), sticky="e")
|
||||||
ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4)
|
ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4)
|
||||||
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
|
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
|
||||||
|
|
||||||
def dlg_nummer(self):
|
def dlg_nummer(self):
|
||||||
d = tk.Toplevel(self)
|
d = tk.Toplevel(self)
|
||||||
d.title("Rechnungsnummer")
|
d.title("Rechnungsnummer")
|
||||||
|
|
|
||||||
52
korrektur.py
52
korrektur.py
|
|
@ -8,7 +8,13 @@ Zwei Wege, ein Popup-Bauplan:
|
||||||
Nummer, Datum und Betraege (§ 31 Abs. 5 UStDV), es wird nur
|
Nummer, Datum und Betraege (§ 31 Abs. 5 UStDV), es wird nur
|
||||||
ein berichtigtes Blatt nachgereicht. Keine Nummer verbraucht.
|
ein berichtigtes Blatt nachgereicht. Keine Nummer verbraucht.
|
||||||
|
|
||||||
Die alte Rechnung wird in beiden Faellen NIE angefasst (§ 146 Abs. 4 AO).
|
Die alte Rechnung wird in beiden Faellen NIE angefasst. § 146 Abs. 4 AO verbietet
|
||||||
|
genau genommen nur Aenderungen, bei denen der urspruengliche Inhalt nicht mehr
|
||||||
|
feststellbar ist - ein protokollierter Storno-Vermerk waere also erlaubt. Dieses
|
||||||
|
Programm verzichtet trotzdem darauf: ein unberuehrter Ursprungsbeleg ist im
|
||||||
|
Pruefungsfall die einfachere Geschichte. Korrigiert wird ueber ein eigenes
|
||||||
|
Dokument - so sieht es das Gesetz selbst vor (§ 14 Abs. 6 Nr. 5 UStG,
|
||||||
|
§ 31 Abs. 5 UStDV).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
@ -20,7 +26,7 @@ import berechnung
|
||||||
import config
|
import config
|
||||||
import pdf_renderer
|
import pdf_renderer
|
||||||
from bestand import kundendaten_ermitteln, naechste_freie_nummer, rechnungen_im_ordner
|
from bestand import kundendaten_ermitteln, naechste_freie_nummer, rechnungen_im_ordner
|
||||||
from helfer import nummer_normalisieren, safe_filename
|
from helfer import kunde_maengel, nummer_normalisieren, safe_filename
|
||||||
from storno import storno_rechnung_bauen
|
from storno import storno_rechnung_bauen
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -28,6 +34,7 @@ class KorrekturMixin:
|
||||||
"""Storno und Berichtigung. Erwartet die Felder der RechnungsApp."""
|
"""Storno und Berichtigung. Erwartet die Felder der RechnungsApp."""
|
||||||
|
|
||||||
# ------------------------------------------------------------- Storno
|
# ------------------------------------------------------------- Storno
|
||||||
|
|
||||||
def _storno_anzeigen(self):
|
def _storno_anzeigen(self):
|
||||||
"""Hinweiszeile und Sperre der Rechnungsnummer an den Zwischenspeicher anpassen."""
|
"""Hinweiszeile und Sperre der Rechnungsnummer an den Zwischenspeicher anpassen."""
|
||||||
stand = self.storno_stand
|
stand = self.storno_stand
|
||||||
|
|
@ -44,6 +51,7 @@ class KorrekturMixin:
|
||||||
else:
|
else:
|
||||||
self.storno_hinweis.set("")
|
self.storno_hinweis.set("")
|
||||||
self.nummer_entry.configure(state="normal")
|
self.nummer_entry.configure(state="normal")
|
||||||
|
|
||||||
def _berichtigung_starten(self, meta, out_dir):
|
def _berichtigung_starten(self, meta, out_dir):
|
||||||
"""Rechnung ins Formular holen; "PDF erstellen" schreibt das berichtigte Blatt."""
|
"""Rechnung ins Formular holen; "PDF erstellen" schreibt das berichtigte Blatt."""
|
||||||
nummer = str(meta.get("rechnungsnummer", ""))
|
nummer = str(meta.get("rechnungsnummer", ""))
|
||||||
|
|
@ -61,6 +69,7 @@ class KorrekturMixin:
|
||||||
"Jetzt die Kundenangaben richtigstellen und \"PDF erstellen\" drücken – "
|
"Jetzt die Kundenangaben richtigstellen und \"PDF erstellen\" drücken – "
|
||||||
"es entsteht ein berichtigtes Blatt zu dieser Rechnung. Die alte Rechnung "
|
"es entsteht ein berichtigtes Blatt zu dieser Rechnung. Die alte Rechnung "
|
||||||
f"bleibt liegen.{hinweis}")
|
f"bleibt liegen.{hinweis}")
|
||||||
|
|
||||||
def _storno_verwerfen(self, grund=""):
|
def _storno_verwerfen(self, grund=""):
|
||||||
"""Storno rückgängig: PDF löschen, Zähler zurück, Zwischenspeicher leeren.
|
"""Storno rückgängig: PDF löschen, Zähler zurück, Zwischenspeicher leeren.
|
||||||
|
|
||||||
|
|
@ -75,22 +84,38 @@ class KorrekturMixin:
|
||||||
os.remove(stand["pfad"])
|
os.remove(stand["pfad"])
|
||||||
print(f"[storno] verworfen, PDF gelöscht -> {stand['pfad']}")
|
print(f"[storno] verworfen, PDF gelöscht -> {stand['pfad']}")
|
||||||
except OSError as e: # noqa: BLE001
|
except OSError as e: # noqa: BLE001
|
||||||
messagebox.showwarning("Storno-PDF blieb liegen",
|
# Abbrechen, NICHT weiterlaufen: sonst liegt das Storno noch im Ordner,
|
||||||
f"Die Storno-PDF konnte nicht gelöscht werden:\n{e}")
|
# der Zähler ist zurückgedreht und die Rechnung gilt als storniert,
|
||||||
|
# obwohl der Nutzer sie behalten wollte.
|
||||||
|
messagebox.showerror(
|
||||||
|
"Storno konnte nicht gelöscht werden",
|
||||||
|
f"Die Datei\n{stand['pfad']}\nließ sich nicht löschen:\n{e}\n\n"
|
||||||
|
"Ist sie noch in einem PDF-Betrachter offen? Bitte dort schließen "
|
||||||
|
"und noch einmal versuchen. Es wurde nichts verändert.")
|
||||||
|
return
|
||||||
self.cfg["rechnung_jahr"] = stand["jahr_vorher"]
|
self.cfg["rechnung_jahr"] = stand["jahr_vorher"]
|
||||||
self.cfg["rechnung_zaehler"] = stand["zaehler_vorher"]
|
self.cfg["rechnung_zaehler"] = stand["zaehler_vorher"]
|
||||||
config.save_config(self.cfg)
|
try:
|
||||||
|
config.save_config(self.cfg)
|
||||||
|
except OSError as e: # noqa: BLE001
|
||||||
|
messagebox.showwarning(
|
||||||
|
"Zähler nicht gespeichert",
|
||||||
|
f"Die Einstellungen ließen sich nicht schreiben:\n{e}\n\n"
|
||||||
|
"Bitte im Menü unter „Rechnungsnummer…“ prüfen.")
|
||||||
self.storno_stand = None
|
self.storno_stand = None
|
||||||
self._storno_anzeigen()
|
self._storno_anzeigen()
|
||||||
self._neue_nummer_vorschlagen()
|
self._neue_nummer_vorschlagen()
|
||||||
if grund:
|
if grund:
|
||||||
print(f"[storno] {grund}")
|
print(f"[storno] {grund}")
|
||||||
|
|
||||||
def dlg_storno(self):
|
def dlg_storno(self):
|
||||||
"""Knopf „Storno“ – setzt den Weg auf Storno und öffnet das Popup."""
|
"""Knopf „Storno“ – setzt den Weg auf Storno und öffnet das Popup."""
|
||||||
self._dlg_korrektur("storno")
|
self._dlg_korrektur("storno")
|
||||||
|
|
||||||
def dlg_berichtigen(self):
|
def dlg_berichtigen(self):
|
||||||
"""Knopf „Berichtigen“ – setzt den Weg auf Berichtigung und öffnet das Popup."""
|
"""Knopf „Berichtigen“ – setzt den Weg auf Berichtigung und öffnet das Popup."""
|
||||||
self._dlg_korrektur("berichtigung")
|
self._dlg_korrektur("berichtigung")
|
||||||
|
|
||||||
def _dlg_korrektur(self, modus):
|
def _dlg_korrektur(self, modus):
|
||||||
"""Popup für beide Wege. Welcher gilt, steht schon beim Öffnen fest.
|
"""Popup für beide Wege. Welcher gilt, steht schon beim Öffnen fest.
|
||||||
|
|
||||||
|
|
@ -201,6 +226,7 @@ class KorrekturMixin:
|
||||||
command=anlegen).pack(side="right", padx=4)
|
command=anlegen).pack(side="right", padx=4)
|
||||||
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
|
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
|
||||||
frm.columnconfigure(1, weight=1)
|
frm.columnconfigure(1, weight=1)
|
||||||
|
|
||||||
def _storno_anlegen(self, meta, out_dir):
|
def _storno_anlegen(self, meta, out_dir):
|
||||||
"""Schreibt die Storno-PDF und füllt das Formular für die berichtigte Rechnung."""
|
"""Schreibt die Storno-PDF und füllt das Formular für die berichtigte Rechnung."""
|
||||||
alt_nummer = str(meta.get("rechnungsnummer", ""))
|
alt_nummer = str(meta.get("rechnungsnummer", ""))
|
||||||
|
|
@ -219,6 +245,21 @@ class KorrekturMixin:
|
||||||
kunde.get("nachname", "")) if t)
|
kunde.get("nachname", "")) if t)
|
||||||
kunde_zeilen = [kunde.get("anrede", ""), namenszeile, kunde.get("zusatz", ""),
|
kunde_zeilen = [kunde.get("anrede", ""), namenszeile, kunde.get("zusatz", ""),
|
||||||
kunde.get("strasse", ""), kunde.get("plzort", "")]
|
kunde.get("strasse", ""), kunde.get("plzort", "")]
|
||||||
|
|
||||||
|
# Der Storno ist der Beleg, der die Rechnung aufhebt - er geht an den Gast
|
||||||
|
# und braucht dieselben Angaben wie sie. Steht in der alten Rechnung nichts,
|
||||||
|
# kann das Programm nichts erfinden; dann muss der Nutzer entscheiden.
|
||||||
|
maengel = kunde_maengel(kunde)
|
||||||
|
if maengel:
|
||||||
|
if not messagebox.askyesno(
|
||||||
|
"Angaben zum Kunden unvollständig",
|
||||||
|
f"In Rechnung {alt_nummer} steht nicht alles:\n\n- "
|
||||||
|
+ "\n- ".join(maengel) +
|
||||||
|
"\n\nDas Storno geht an den Gast und trägt dieselben Angaben. "
|
||||||
|
"Was fehlt, fehlt auch dort.\n\nTrotzdem erstellen?",
|
||||||
|
icon="warning", default="no"):
|
||||||
|
return
|
||||||
|
|
||||||
kopf = {
|
kopf = {
|
||||||
"rechnungsnummer": nummer,
|
"rechnungsnummer": nummer,
|
||||||
"datum": heute,
|
"datum": heute,
|
||||||
|
|
@ -265,6 +306,7 @@ class KorrekturMixin:
|
||||||
f"Stornorechnung {nummer} zu Rechnung {alt_nummer} liegt hier:\n{pfad}\n\n"
|
f"Stornorechnung {nummer} zu Rechnung {alt_nummer} liegt hier:\n{pfad}\n\n"
|
||||||
f"Jetzt die berichtigte Rechnung {self.r_nummer.get()} anpassen und "
|
f"Jetzt die berichtigte Rechnung {self.r_nummer.get()} anpassen und "
|
||||||
f"\"PDF erstellen\" drücken.{hinweis}")
|
f"\"PDF erstellen\" drücken.{hinweis}")
|
||||||
|
|
||||||
def _formular_aus_meta(self, meta):
|
def _formular_aus_meta(self, meta):
|
||||||
"""Formular mit den Daten der alten Rechnung füllen.
|
"""Formular mit den Daten der alten Rechnung füllen.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -389,9 +389,12 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
||||||
"Bereits gezahlte Beträge werden erstattet oder mit der berichtigten "
|
"Bereits gezahlte Beträge werden erstattet oder mit der berichtigten "
|
||||||
"Rechnung verrechnet.", st["dank"]))
|
"Rechnung verrechnet.", st["dank"]))
|
||||||
elif art == "berichtigung":
|
elif art == "berichtigung":
|
||||||
# Der Satz ist kein Beiwerk: ein zweites Blatt mit ausgewiesener USt,
|
# Der Satz ist kein Beiwerk: mehrere Rechnungen ueber dieselbe Leistung
|
||||||
# das nicht klar als Berichtigung erkennbar ist, kann die Steuer ein
|
# ohne Kennzeichnung loesen die Steuer erneut aus (Abschn. 14c.1 Abs. 4
|
||||||
# zweites Mal ausloesen (§ 14c UStG).
|
# Satz 4 UStAE). Gegenueber Endverbrauchern entsteht sie zwar nicht
|
||||||
|
# (EuGH C-378/21, BMF v. 27.02.2024) - aber Firmengaeste und
|
||||||
|
# gewerbliche Dauercamper gibt es auch, und der Nachweis liegt beim
|
||||||
|
# Aussteller. Also bleibt der Satz drauf.
|
||||||
block.append(Paragraph(
|
block.append(Paragraph(
|
||||||
f"Dieses Blatt berichtigt die Angaben zur Rechnung "
|
f"Dieses Blatt berichtigt die Angaben zur Rechnung "
|
||||||
f"{kopf.get('berichtigt_zu','')} vom {kopf.get('datum','')} nach "
|
f"{kopf.get('berichtigt_zu','')} vom {kopf.get('datum','')} nach "
|
||||||
|
|
|
||||||
91
pruef_dialoge.py
Normal file
91
pruef_dialoge.py
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Pruefstand: geht jeder Dialog ueberhaupt auf?
|
||||||
|
|
||||||
|
Klingt banal, ist es nicht: beim Aufteilen von app.py in Module sind zwei
|
||||||
|
Dialoge stumm kaputtgegangen (fehlende Importe), und kein anderer Pruefstand
|
||||||
|
hat es gemerkt, weil keiner sie oeffnet. Dieser hier oeffnet ALLE.
|
||||||
|
|
||||||
|
Geprueft wird ausserdem, dass jeder Dialog auch wieder zugeht - ein modales
|
||||||
|
Fenster, das haengenbleibt, sperrt das Programm.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import tkinter as tk
|
||||||
|
|
||||||
|
QUELLE = r"C:\claude\rechnungstool"
|
||||||
|
sys.path.insert(0, QUELLE)
|
||||||
|
|
||||||
|
tmp = tempfile.mkdtemp(prefix="dialog_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["output_dir"] = ordner
|
||||||
|
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False)
|
||||||
|
|
||||||
|
import app as A # noqa: E402
|
||||||
|
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
|
||||||
|
|
||||||
|
fehler = 0
|
||||||
|
f = A.RechnungsApp()
|
||||||
|
f.update()
|
||||||
|
|
||||||
|
# Damit "Storno"/"Berichtigen" nicht an "nichts zu korrigieren" scheitern:
|
||||||
|
# eine echte Rechnung in den Wegwerf-Ordner legen.
|
||||||
|
f.k_vorname.set("Anna")
|
||||||
|
f.k_name.set("Beispiel")
|
||||||
|
f.k_strasse.set("Musterweg 1")
|
||||||
|
f.k_plzort.set("12345 Musterstadt")
|
||||||
|
f.anzahl_vars[sorted(f.anzahl_vars)[0]].set("2")
|
||||||
|
f.update()
|
||||||
|
f.pdf_erstellen()
|
||||||
|
|
||||||
|
DIALOGE = ["dlg_steuern", "dlg_katalog", "dlg_firma", "dlg_nummer",
|
||||||
|
"dlg_storno", "dlg_berichtigen"]
|
||||||
|
|
||||||
|
for name in DIALOGE:
|
||||||
|
offen_vorher = [w for w in f.winfo_children() if isinstance(w, tk.Toplevel)]
|
||||||
|
try:
|
||||||
|
getattr(f, name)()
|
||||||
|
f.update()
|
||||||
|
except Exception as e: # noqa: BLE001 - genau das wollen wir sehen
|
||||||
|
print(f"ABSTURZ | {name}: {type(e).__name__}: {e}")
|
||||||
|
fehler += 1
|
||||||
|
continue
|
||||||
|
neu = [w for w in f.winfo_children()
|
||||||
|
if isinstance(w, tk.Toplevel) and w not in offen_vorher]
|
||||||
|
if not neu:
|
||||||
|
print(f"FEHLER | {name}: kein Fenster aufgegangen")
|
||||||
|
fehler += 1
|
||||||
|
continue
|
||||||
|
titel = neu[-1].title()
|
||||||
|
for w in neu:
|
||||||
|
w.grab_release()
|
||||||
|
w.destroy()
|
||||||
|
f.update()
|
||||||
|
print(f" OK | {name} -> „{titel}“")
|
||||||
|
|
||||||
|
# Der Speicherort-Dialog oeffnet einen Systemdialog - nur die Vorstufe pruefen
|
||||||
|
try:
|
||||||
|
f._frage_speicherort_falls_noetig()
|
||||||
|
f.update()
|
||||||
|
print(" OK | _frage_speicherort_falls_noetig")
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f"ABSTURZ | _frage_speicherort_falls_noetig: {type(e).__name__}: {e}")
|
||||||
|
fehler += 1
|
||||||
|
|
||||||
|
f.destroy()
|
||||||
|
print("---")
|
||||||
|
print("alles gruen" if not fehler else f"{fehler} Fehler")
|
||||||
|
sys.exit(1 if fehler else 0)
|
||||||
291
pruef_kernregeln.py
Normal file
291
pruef_kernregeln.py
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Pruefstand fuer die ZWEI Regeln, um die es dem Programm eigentlich geht:
|
||||||
|
|
||||||
|
1. Eine Rechnungsnummer wird nie zweimal vergeben.
|
||||||
|
2. Eine ausgestellte Rechnung wird nie veraendert und nie geloescht.
|
||||||
|
|
||||||
|
Warum es diesen Pruefstand gibt: pruef_storno.py lief 100x gruen und pruefte
|
||||||
|
trotzdem keine der beiden Regeln - er ging immer nur den gluecklichen Weg mit
|
||||||
|
sauber hochgezaehltem Zaehler. Ein Mutationstest hat gezeigt, dass man BEIDE
|
||||||
|
Sperren gegen die doppelte Nummer ausbauen kann, ohne dass er rot wird.
|
||||||
|
|
||||||
|
Darum hier:
|
||||||
|
- Der Bestand wird ueber PRUEFSUMMEN verglichen, nicht ueber os.path.exists.
|
||||||
|
Eine Datei, die noch da ist, kann trotzdem veraendert worden sein.
|
||||||
|
- Die Nummer wird von HAND ins Feld getippt, so wie es der Nutzer tut.
|
||||||
|
- Unbekannte Dialoge lassen den Lauf scheitern, statt still "Nein" zu sagen.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
QUELLE = r"C:\claude\rechnungstool"
|
||||||
|
sys.path.insert(0, QUELLE)
|
||||||
|
|
||||||
|
tmp = tempfile.mkdtemp(prefix="kern_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)
|
||||||
|
|
||||||
|
import app as A # noqa: E402
|
||||||
|
import bestand as BE # noqa: E402
|
||||||
|
from tkinter import messagebox # noqa: E402
|
||||||
|
|
||||||
|
fehler = []
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe(bedingung, text):
|
||||||
|
print((" OK | " if bedingung else "FEHLER | ") + text)
|
||||||
|
if not bedingung:
|
||||||
|
fehler.append(text)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Dialoge: jede Frage muss angemeldet sein --------------------------------
|
||||||
|
antworten = {}
|
||||||
|
gefragt = []
|
||||||
|
meldungen = []
|
||||||
|
|
||||||
|
|
||||||
|
def askyesno(titel, text="", **kw):
|
||||||
|
gefragt.append(titel)
|
||||||
|
for schluessel, wert in antworten.items():
|
||||||
|
if schluessel in titel:
|
||||||
|
return wert
|
||||||
|
# Kein stilles "Nein": sonst haelt der Pruefstand einen abgebrochenen
|
||||||
|
# Ablauf faelschlich fuer ein Ergebnis.
|
||||||
|
raise AssertionError(f"unangemeldeter Dialog: {titel!r} — {text[:80]!r}")
|
||||||
|
|
||||||
|
|
||||||
|
messagebox.askyesno = askyesno
|
||||||
|
messagebox.showinfo = lambda *a, **k: meldungen.append(("info", a[0] if a else ""))
|
||||||
|
messagebox.showwarning = lambda *a, **k: meldungen.append(("warnung", a[0] if a else ""))
|
||||||
|
messagebox.showerror = lambda *a, **k: meldungen.append(("fehler", a[0] if a else ""))
|
||||||
|
|
||||||
|
|
||||||
|
def bestand_lesen():
|
||||||
|
"""Pruefsumme je PDF im Ordner - erkennt auch stille Aenderungen am Inhalt."""
|
||||||
|
stand = {}
|
||||||
|
for name in sorted(os.listdir(ordner)):
|
||||||
|
if name.lower().endswith(".pdf"):
|
||||||
|
with open(os.path.join(ordner, name), "rb") as f:
|
||||||
|
stand[name] = hashlib.sha256(f.read()).hexdigest()
|
||||||
|
return stand
|
||||||
|
|
||||||
|
|
||||||
|
def unveraendert(vorher, ausser=()):
|
||||||
|
"""Alle Dateien aus <vorher> sind byteweise unveraendert (bis auf <ausser>)."""
|
||||||
|
jetzt = bestand_lesen()
|
||||||
|
for name, summe in vorher.items():
|
||||||
|
if name in ausser:
|
||||||
|
continue
|
||||||
|
if jetzt.get(name) != summe:
|
||||||
|
return False, (f"{name} " + ("ist weg" if name not in jetzt else "wurde verändert"))
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def kunde_setzen(vor, nach, tage="2"):
|
||||||
|
f.k_vorname.set(vor)
|
||||||
|
f.k_name.set(nach)
|
||||||
|
f.k_strasse.set("Musterweg 1")
|
||||||
|
f.k_plzort.set("12345 Musterstadt")
|
||||||
|
f.anzahl_vars[erste].set(tage)
|
||||||
|
f.update()
|
||||||
|
|
||||||
|
|
||||||
|
f = A.RechnungsApp()
|
||||||
|
f.update()
|
||||||
|
f.cfg["output_dir"] = ordner
|
||||||
|
erste = sorted(f.anzahl_vars)[0]
|
||||||
|
antworten["Fertig"] = False # "jetzt oeffnen?" immer nein
|
||||||
|
|
||||||
|
# ---- Ausgangslage: zwei echte Rechnungen ------------------------------------
|
||||||
|
kunde_setzen("Anna", "Beispiel", "4")
|
||||||
|
f.pdf_erstellen()
|
||||||
|
kunde_setzen("Bernd", "Zweit", "3")
|
||||||
|
f.pdf_erstellen()
|
||||||
|
pruefe(sorted(bestand_lesen()) == ["Rechnung_2026-001_Beispiel.pdf",
|
||||||
|
"Rechnung_2026-002_Zweit.pdf"],
|
||||||
|
f"zwei Rechnungen angelegt ({sorted(bestand_lesen())})")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# REGEL 1: eine Nummer nie zweimal - auch nicht, wenn sie von Hand kommt
|
||||||
|
# =============================================================================
|
||||||
|
print("--- Regel 1: Nummer von Hand auf eine vergebene setzen ---")
|
||||||
|
vorher = bestand_lesen()
|
||||||
|
gefragt.clear()
|
||||||
|
antworten["Rechnungsnummer schon vergeben"] = True # "naechste freie nehmen?"
|
||||||
|
kunde_setzen("Carla", "Dritt", "1")
|
||||||
|
f.r_nummer.set("2026-001") # genau das tut der Nutzer von Hand
|
||||||
|
f.update()
|
||||||
|
f.pdf_erstellen()
|
||||||
|
|
||||||
|
pruefe(any("schon vergeben" in t for t in gefragt),
|
||||||
|
f"Programm meldet die vergebene Nummer ({gefragt})")
|
||||||
|
ok, warum = unveraendert(vorher)
|
||||||
|
pruefe(ok, f"die beiden alten Rechnungen sind byteweise unverändert ({warum})")
|
||||||
|
pruefe(os.path.exists(os.path.join(ordner, "Rechnung_2026-003_Dritt.pdf")),
|
||||||
|
"die neue Rechnung bekam die nächste FREIE Nummer 2026-003")
|
||||||
|
pruefe(f.r_nummer.get() != "2026-001", f"Feld zeigt nicht mehr die alte Nummer ({f.r_nummer.get()})")
|
||||||
|
|
||||||
|
print("--- Regel 1: Nutzer lehnt die Ausweichnummer ab ---")
|
||||||
|
vorher = bestand_lesen()
|
||||||
|
antworten["Rechnungsnummer schon vergeben"] = False
|
||||||
|
kunde_setzen("Doris", "Vier", "1")
|
||||||
|
f.r_nummer.set("2026-002")
|
||||||
|
f.update()
|
||||||
|
f.pdf_erstellen()
|
||||||
|
ok, warum = unveraendert(vorher)
|
||||||
|
pruefe(ok and bestand_lesen() == vorher,
|
||||||
|
f"bei Nein wird gar nichts geschrieben und nichts verändert ({warum})")
|
||||||
|
|
||||||
|
print("--- Regel 1: Zähler steht HINTER dem Ordner (config aus dem Backup) ---")
|
||||||
|
f.cfg["rechnung_zaehler"] = 0 # so sieht eine zurückgespielte config aus
|
||||||
|
f.berichtigung_stand = None
|
||||||
|
f.storno_stand = None
|
||||||
|
f._neue_nummer_vorschlagen()
|
||||||
|
pruefe(f.r_nummer.get() == "2026-004",
|
||||||
|
f"Vorschlag überspringt die belegten Nummern ({f.r_nummer.get()})")
|
||||||
|
|
||||||
|
print("--- Regel 1: der Zähler darf nur vorwärts ---")
|
||||||
|
# Zweite Verteidigungslinie hinter dem Ordner-Abgleich: wer von Hand eine
|
||||||
|
# niedrigere freie Nummer nimmt, darf den Zähler nicht zurückdrehen - sonst
|
||||||
|
# schlägt das Programm die dazwischenliegenden Nummern ein zweites Mal vor.
|
||||||
|
kunde_setzen("Emil", "Hoch", "1")
|
||||||
|
f.r_nummer.set("2026-009")
|
||||||
|
f.update()
|
||||||
|
f.pdf_erstellen()
|
||||||
|
pruefe(f.cfg["rechnung_zaehler"] == 9, f"Zähler steht auf 9 ({f.cfg['rechnung_zaehler']})")
|
||||||
|
kunde_setzen("Frida", "Tief", "1")
|
||||||
|
f.r_nummer.set("2026-007")
|
||||||
|
f.update()
|
||||||
|
f.pdf_erstellen()
|
||||||
|
pruefe(os.path.exists(os.path.join(ordner, "Rechnung_2026-007_Tief.pdf")),
|
||||||
|
"die niedrigere freie Nummer 2026-007 wurde geschrieben")
|
||||||
|
pruefe(f.cfg["rechnung_zaehler"] == 9,
|
||||||
|
f"Zähler bleibt auf 9, fällt NICHT auf 7 zurück ({f.cfg['rechnung_zaehler']})")
|
||||||
|
f._neue_nummer_vorschlagen()
|
||||||
|
pruefe(f.r_nummer.get() == "2026-010",
|
||||||
|
f"nächster Vorschlag ist 2026-010, keine schon vergebene ({f.r_nummer.get()})")
|
||||||
|
|
||||||
|
print("--- Regel 1: Jahreswechsel - beide Quellen müssen dasselbe sagen ---")
|
||||||
|
f.cfg["rechnung_jahr"] = 2025
|
||||||
|
f.cfg["rechnung_zaehler"] = 87
|
||||||
|
f._neue_nummer_vorschlagen()
|
||||||
|
vom_formular = f.r_nummer.get()
|
||||||
|
vom_storno = BE.naechste_freie_nummer(ordner, f.cfg)
|
||||||
|
pruefe(vom_formular == vom_storno,
|
||||||
|
f"Formular und Storno-Weg schlagen dieselbe Nummer vor ({vom_formular} / {vom_storno})")
|
||||||
|
pruefe(vom_formular.startswith("2026-"),
|
||||||
|
f"neues Jahr fängt im aktuellen Jahr an ({vom_formular})")
|
||||||
|
f.cfg["rechnung_jahr"], f.cfg["rechnung_zaehler"] = 2026, 3
|
||||||
|
config.save_config(f.cfg)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# REGEL 2: ausgestellte Rechnung nie anfassen
|
||||||
|
# =============================================================================
|
||||||
|
print("--- Regel 2: Storno über den Dialogknopf, nicht über die interne Methode ---")
|
||||||
|
vorher = bestand_lesen()
|
||||||
|
gefragt.clear()
|
||||||
|
f.dlg_storno()
|
||||||
|
f.update()
|
||||||
|
import tkinter as tk # noqa: E402
|
||||||
|
from tkinter import ttk # noqa: E402
|
||||||
|
dlg = [w for w in f.winfo_children() if isinstance(w, tk.Toplevel)][-1]
|
||||||
|
feld = knopf = None
|
||||||
|
for rahmen in dlg.winfo_children():
|
||||||
|
for w in rahmen.winfo_children():
|
||||||
|
if isinstance(w, ttk.Entry):
|
||||||
|
feld = w
|
||||||
|
elif isinstance(w, ttk.Frame):
|
||||||
|
for b in w.winfo_children():
|
||||||
|
if isinstance(b, ttk.Button) and "Storno erstellen" in str(b.cget("text")):
|
||||||
|
knopf = b
|
||||||
|
pruefe(feld is not None and knopf is not None, "Storno-Dialog hat Feld und Knopf")
|
||||||
|
feld.delete(0, "end")
|
||||||
|
feld.insert(0, "2026-001")
|
||||||
|
knopf.invoke() # der ECHTE Weg, nicht _storno_anlegen()
|
||||||
|
f.update()
|
||||||
|
|
||||||
|
pruefe(f.storno_stand is not None and f.storno_stand["zu"] == "2026-001",
|
||||||
|
f"der Knopf hat wirklich ein Storno gestartet ({f.storno_stand})")
|
||||||
|
storno_datei = os.path.basename(f.storno_stand["pfad"]) if f.storno_stand else ""
|
||||||
|
ok, warum = unveraendert(vorher)
|
||||||
|
pruefe(ok, f"alle bisherigen Rechnungen unverändert ({warum})")
|
||||||
|
|
||||||
|
print("--- Regel 2: was steht auf dem Storno-Blatt? ---")
|
||||||
|
from pypdf import PdfReader # noqa: E402
|
||||||
|
text = PdfReader(f.storno_stand["pfad"]).pages[0].extract_text()
|
||||||
|
pruefe("Stornorechnung" in text, "Überschrift „Stornorechnung“ steht drauf")
|
||||||
|
pruefe("2026-001" in text, "Bezug auf die alte Nummer steht drauf")
|
||||||
|
# pypdf legt jeden Tabellenwert in eine eigene Zeile - die Betraege stehen also
|
||||||
|
# NACH ihrer Beschriftung, nicht daneben.
|
||||||
|
zeilen = text.splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
def betraege_nach(label, wieviele=6):
|
||||||
|
"""Die Zahlenwerte, die auf die Zeile mit <label> folgen."""
|
||||||
|
for i, z in enumerate(zeilen):
|
||||||
|
if label in z:
|
||||||
|
return [w.strip() for w in zeilen[i + 1:i + 1 + wieviele]
|
||||||
|
if "," in w and any(c.isdigit() for c in w)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
pos = betraege_nach("Storno der Rechnung")
|
||||||
|
pruefe(bool(pos), f"Positionszeile hat Beträge ({pos})")
|
||||||
|
pruefe(all(w.startswith("-") for w in pos),
|
||||||
|
f"Positionszeile ist NEGATIV, nicht nur der Summenblock ({pos})")
|
||||||
|
gesamt = betraege_nach("Gesamtbetrag", 1)
|
||||||
|
pruefe(gesamt and gesamt[0].startswith("-"), f"Gesamtbetrag negativ ({gesamt})")
|
||||||
|
netto = betraege_nach("Nettobetrag", 1)
|
||||||
|
pruefe(netto and netto[0].startswith("-"), f"Nettobetrag negativ ({netto})")
|
||||||
|
|
||||||
|
print("--- Regel 2: berichtigte Rechnung schreiben, alter Bestand bleibt ---")
|
||||||
|
vorher = bestand_lesen()
|
||||||
|
antworten["Storno wirklich"] = True
|
||||||
|
kunde_setzen("Anna", "Beispiel", "3")
|
||||||
|
f.pdf_erstellen()
|
||||||
|
ok, warum = unveraendert(vorher)
|
||||||
|
pruefe(ok, f"Storno und alle Rechnungen unverändert ({warum})")
|
||||||
|
pruefe(len(bestand_lesen()) == len(vorher) + 1, "genau eine Datei kam dazu")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Berichtigung: zweimal am selben Tag darf nicht stillschweigend überschreiben
|
||||||
|
# =============================================================================
|
||||||
|
print("--- Berichtigung zweimal am selben Tag ---")
|
||||||
|
antworten["Berichtigte Rechnung erstellen"] = True
|
||||||
|
meta = next(x for x in BE.rechnungen_im_ordner(ordner)
|
||||||
|
if x.get("rechnungsnummer") == "2026-002")
|
||||||
|
f._berichtigung_starten(meta, ordner)
|
||||||
|
f.k_strasse.set("Neue Straße 5")
|
||||||
|
f.update()
|
||||||
|
f.pdf_erstellen()
|
||||||
|
erste_fassung = bestand_lesen()
|
||||||
|
ziel = [n for n in erste_fassung if n.startswith("Berichtigte Rechnung")]
|
||||||
|
pruefe(len(ziel) == 1, f"Berichtigungsblatt geschrieben ({ziel})")
|
||||||
|
|
||||||
|
antworten["Berichtigung von heute ersetzen"] = False # NEIN, nicht ersetzen
|
||||||
|
f._berichtigung_starten(meta, ordner)
|
||||||
|
f.k_strasse.set("Ganz andere Straße 9")
|
||||||
|
f.update()
|
||||||
|
gefragt.clear()
|
||||||
|
f.pdf_erstellen()
|
||||||
|
pruefe(any("ersetzen" in t for t in gefragt), f"vor dem Ersetzen wird gefragt ({gefragt})")
|
||||||
|
ok, warum = unveraendert(erste_fassung)
|
||||||
|
pruefe(ok, f"bei Nein bleibt die erste Fassung unangetastet ({warum})")
|
||||||
|
|
||||||
|
f.destroy()
|
||||||
|
print("---")
|
||||||
|
print(f"Ordner: {ordner}")
|
||||||
|
print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")
|
||||||
|
sys.exit(1 if fehler else 0)
|
||||||
|
|
@ -1,20 +1,23 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""Pruefstand: findet vorhandene_rechnungen() eine schon vergebene Nummer?
|
"""Pruefstand: welche Nummern gelten als vergeben, welche als frei?
|
||||||
|
|
||||||
Kein Rateraum: legt echte Dateien in einem Wegwerf-Ordner an und fragt die
|
Kein Rateraum: legt echte Dateien in einem Wegwerf-Ordner an und fragt
|
||||||
Funktion aus app.py direkt.
|
bestand.py direkt. Geprueft wird beides - vorhandene_rechnungen() und
|
||||||
|
naechste_freie_nummer(), zusammen sind sie die Nummernvergabe.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
sys.path.insert(0, r"C:\claude\rechnungstool")
|
sys.path.insert(0, r"C:\claude\rechnungstool")
|
||||||
from bestand import vorhandene_rechnungen # noqa: E402
|
from bestand import naechste_freie_nummer, vorhandene_rechnungen # noqa: E402
|
||||||
|
|
||||||
ordner = tempfile.mkdtemp(prefix="rechnungspruef_")
|
ordner = tempfile.mkdtemp(prefix="rechnungspruef_")
|
||||||
dateien = [
|
dateien = [
|
||||||
"Rechnung_2026-013_Meier.pdf", # dieselbe Nummer, Kunde Meier
|
"Rechnung_2026-013_Meier.pdf", # dieselbe Nummer, Kunde Meier
|
||||||
"Rechnung_2026-130_Schmidt.pdf", # laengere Nummer - darf NICHT treffen
|
"Rechnung_2026-0135_Schmidt.pdf", # laengere Nummer - darf NICHT treffen
|
||||||
|
"Rechnung_2026-777_Gross.PDF", # Endung GROSS geschrieben - zaehlt trotzdem
|
||||||
|
"Rechnung_2026-013_Meier.txt", # richtiger Name, aber kein PDF
|
||||||
"Rechnung_2026-014_Meier.pdf", # andere Nummer
|
"Rechnung_2026-014_Meier.pdf", # andere Nummer
|
||||||
"Rechnung_2026-020.pdf", # ohne Kundenname
|
"Rechnung_2026-020.pdf", # ohne Kundenname
|
||||||
"Storno_2026-021_Meier.pdf", # Storno verbraucht die Nummer auch
|
"Storno_2026-021_Meier.pdf", # Storno verbraucht die Nummer auch
|
||||||
|
|
@ -26,7 +29,8 @@ for d in dateien:
|
||||||
|
|
||||||
faelle = [
|
faelle = [
|
||||||
("2026-013", ["Rechnung_2026-013_Meier.pdf"], "Nummer vergeben, anderer Kundenname"),
|
("2026-013", ["Rechnung_2026-013_Meier.pdf"], "Nummer vergeben, anderer Kundenname"),
|
||||||
("2026-130", ["Rechnung_2026-130_Schmidt.pdf"], "laengere Nummer trifft sich selbst"),
|
("2026-0135", ["Rechnung_2026-0135_Schmidt.pdf"], "laengere Nummer trifft nur sich selbst"),
|
||||||
|
("2026-777", ["Rechnung_2026-777_Gross.PDF"], "Endung GROSS geschrieben wird gefunden"),
|
||||||
("2026-020", ["Rechnung_2026-020.pdf"], "ohne Kundenname im Dateinamen"),
|
("2026-020", ["Rechnung_2026-020.pdf"], "ohne Kundenname im Dateinamen"),
|
||||||
("2026-021", ["Storno_2026-021_Meier.pdf"], "Nummer ist von einem Storno belegt"),
|
("2026-021", ["Storno_2026-021_Meier.pdf"], "Nummer ist von einem Storno belegt"),
|
||||||
("2026-099", [], "freie Nummer"),
|
("2026-099", [], "freie Nummer"),
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,13 @@ def askyesno(titel, text, **kw):
|
||||||
for schlue, wert in antworten.items():
|
for schlue, wert in antworten.items():
|
||||||
if schlue in titel:
|
if schlue in titel:
|
||||||
return wert
|
return wert
|
||||||
return False
|
# Kein stilles "Nein": ein nicht angemeldeter Dialog wuerde den Ablauf
|
||||||
|
# abbrechen und der Pruefstand haelt das Ergebnis faelschlich fuer gewollt.
|
||||||
|
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
|
||||||
|
|
||||||
|
|
||||||
|
antworten["Fertig"] = False # "jetzt oeffnen?" - nie oeffnen im Pruefstand
|
||||||
|
antworten["Hausnummer"] = True # "Musterweg 1" hat eine, greift nur im Sonderfall
|
||||||
messagebox.askyesno = askyesno
|
messagebox.askyesno = askyesno
|
||||||
messagebox.showinfo = lambda *a, **k: None
|
messagebox.showinfo = lambda *a, **k: None
|
||||||
warnungen = []
|
warnungen = []
|
||||||
|
|
@ -103,11 +107,15 @@ pruefe(os.path.exists(storno_pdf), f"Storno-PDF geschrieben ({os.path.basename(s
|
||||||
meta2 = BE.kenndaten_lesen(storno_pdf)
|
meta2 = BE.kenndaten_lesen(storno_pdf)
|
||||||
pruefe(meta2.get("art") == "storno", "art = storno")
|
pruefe(meta2.get("art") == "storno", "art = storno")
|
||||||
pruefe(meta2.get("storno_zu") == "2026-001", "storno_zu zeigt auf 2026-001")
|
pruefe(meta2.get("storno_zu") == "2026-001", "storno_zu zeigt auf 2026-001")
|
||||||
pruefe(meta2.get("storno_datum") == meta1.get("datum"), "Datum der alten Rechnung steht drin")
|
import re as _re
|
||||||
|
pruefe(bool(_re.fullmatch(r"\d{2}\.\d{2}\.\d{4}", str(meta2.get("storno_datum") or "")))
|
||||||
|
and meta2.get("storno_datum") == meta1.get("datum"),
|
||||||
|
f"Datum der alten Rechnung steht drin ({meta2.get('storno_datum')!r})")
|
||||||
pruefe(float(meta2["zwischensumme"]) == -brutto1, f"Zwischensumme negativ ({meta2['zwischensumme']})")
|
pruefe(float(meta2["zwischensumme"]) == -brutto1, f"Zwischensumme negativ ({meta2['zwischensumme']})")
|
||||||
pruefe(float(meta2["gesamt"]) == -gesamt1, f"Gesamt negativ ({meta2['gesamt']})")
|
pruefe(float(meta2["gesamt"]) == -gesamt1, f"Gesamt negativ ({meta2['gesamt']})")
|
||||||
pruefe(all(b["netto"] <= 0 and b["ust"] <= 0 for b in meta2["ust_bloecke"]),
|
pruefe(bool(meta2["ust_bloecke"])
|
||||||
"alle USt-Bloecke negativ")
|
and all(b["netto"] < 0 and b["ust"] < 0 for b in meta2["ust_bloecke"]),
|
||||||
|
f"alle USt-Bloecke echt negativ ({meta2['ust_bloecke']})")
|
||||||
pruefe(f.r_nummer.get() == "2026-003", f"Formular steht auf 2026-003 (ist {f.r_nummer.get()})")
|
pruefe(f.r_nummer.get() == "2026-003", f"Formular steht auf 2026-003 (ist {f.r_nummer.get()})")
|
||||||
pruefe(str(f.nummer_entry.cget("state")) == "readonly", "Nummernfeld gesperrt")
|
pruefe(str(f.nummer_entry.cget("state")) == "readonly", "Nummernfeld gesperrt")
|
||||||
pruefe(f.k_plzort.get() == "12345 Musterstadt", "Anschrift ins Formular uebernommen")
|
pruefe(f.k_plzort.get() == "12345 Musterstadt", "Anschrift ins Formular uebernommen")
|
||||||
|
|
@ -378,7 +386,8 @@ from pypdf import PdfReader as _PR # noqa: E402
|
||||||
text = _PR(ziel).pages[0].extract_text()
|
text = _PR(ziel).pages[0].extract_text()
|
||||||
pruefe("Berichtigte Rechnung" in text, "Ueberschrift steht auf dem Blatt")
|
pruefe("Berichtigte Rechnung" in text, "Ueberschrift steht auf dem Blatt")
|
||||||
pruefe("2026-910" in text, "Rechnungsnummer steht drauf")
|
pruefe("2026-910" in text, "Rechnungsnummer steht drauf")
|
||||||
pruefe("nicht" in text and "31 Abs. 5 UStDV" in text, "der Schutzsatz steht drauf")
|
pruefe("31 Abs. 5 UStDV" in text and "zus" in text and "tzliche Leistung" in text,
|
||||||
|
"der Schutzsatz steht drauf (keine zusaetzliche Leistung, § 31 Abs. 5)")
|
||||||
pruefe("04416 Markkleeberg" in text, "berichtigte Anschrift steht drauf")
|
pruefe("04416 Markkleeberg" in text, "berichtigte Anschrift steht drauf")
|
||||||
|
|
||||||
liste3, kaputt3 = einnahmen.scanne(ordner, {"ust_saetze": [7, 19]})
|
liste3, kaputt3 = einnahmen.scanne(ordner, {"ust_saetze": [7, 19]})
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,11 @@ def storno_rechnung_bauen(meta, cfg):
|
||||||
# v1-PDF ohne Aufteilung: alles auf den ermaessigten Satz, sonst faellt
|
# v1-PDF ohne Aufteilung: alles auf den ermaessigten Satz, sonst faellt
|
||||||
# die USt unter den Tisch.
|
# die USt unter den Tisch.
|
||||||
brutto = float(meta.get("zwischensumme") or 0)
|
brutto = float(meta.get("zwischensumme") or 0)
|
||||||
satz = int((cfg.get("steuersaetze") or [{"satz": 7}])[0].get("satz", 7))
|
# Der erste Satz OHNE "extra_blatt" - also ein echter USt-Satz. Nimmt man
|
||||||
|
# blind den ersten aus der Liste, rechnet ein umsortierter Katalog die
|
||||||
|
# Steuer mit 19 % oder der Beherbergungssteuer heraus.
|
||||||
|
echte = [s for s in cfg.get("steuersaetze", []) if not s.get("extra_blatt")]
|
||||||
|
satz = int((echte or [{"satz": 7}])[0].get("satz", 7))
|
||||||
netto = berechnung.runde(brutto * 100 / (100 + satz)) if satz else brutto
|
netto = berechnung.runde(brutto * 100 / (100 + satz)) if satz else brutto
|
||||||
bloecke = [{"satz": satz, "netto": netto, "ust": berechnung.runde(brutto - netto),
|
bloecke = [{"satz": satz, "netto": netto, "ust": berechnung.runde(brutto - netto),
|
||||||
"brutto": brutto}]
|
"brutto": brutto}]
|
||||||
|
|
|
||||||
3
theme.py
3
theme.py
|
|
@ -27,6 +27,7 @@ class ThemeMixin:
|
||||||
"""Farben setzen. Erwartet ein tk.Tk als Basis."""
|
"""Farben setzen. Erwartet ein tk.Tk als Basis."""
|
||||||
|
|
||||||
# ----------------------------------------------------------------- Theme
|
# ----------------------------------------------------------------- Theme
|
||||||
|
|
||||||
def _apply_theme(self):
|
def _apply_theme(self):
|
||||||
"""Farben passend zum Windows-Theme (hell/dunkel) setzen."""
|
"""Farben passend zum Windows-Theme (hell/dunkel) setzen."""
|
||||||
dark = windows_dark_mode()
|
dark = windows_dark_mode()
|
||||||
|
|
@ -96,11 +97,13 @@ class ThemeMixin:
|
||||||
# Klassische tk-Widgets (Canvas, Toplevel, Text) erben diese Defaults
|
# Klassische tk-Widgets (Canvas, Toplevel, Text) erben diese Defaults
|
||||||
self.option_add("*background", bg)
|
self.option_add("*background", bg)
|
||||||
self.option_add("*foreground", fg)
|
self.option_add("*foreground", fg)
|
||||||
|
|
||||||
def _style_dialog(self, d):
|
def _style_dialog(self, d):
|
||||||
"""Toplevel-Dialog an das Theme angleichen."""
|
"""Toplevel-Dialog an das Theme angleichen."""
|
||||||
d.configure(bg=self.col_bg)
|
d.configure(bg=self.col_bg)
|
||||||
# erst nach dem Anzeigen setzen, sonst greift DWM noch nicht
|
# erst nach dem Anzeigen setzen, sonst greift DWM noch nicht
|
||||||
d.after(20, lambda: self._dark_titlebar(d))
|
d.after(20, lambda: self._dark_titlebar(d))
|
||||||
|
|
||||||
def _dark_titlebar(self, win):
|
def _dark_titlebar(self, win):
|
||||||
"""Windows-Titelleiste des Fensters dunkel machen (nur im Dark-Mode)."""
|
"""Windows-Titelleiste des Fensters dunkel machen (nur im Dark-Mode)."""
|
||||||
if not (sys.platform.startswith("win") and getattr(self, "_dark_mode", False)):
|
if not (sys.platform.startswith("win") and getattr(self, "_dark_mode", False)):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue