Storno statt Ueberschreiben: Rechnungskette 005 -> Storno -> Korrektur
Eine ausgestellte Rechnung darf nicht geaendert und nicht geloescht werden (§ 146 Abs. 4 AO), und jede Nummer gibt es nur einmal (§ 14 Abs. 4 Nr. 4 UStG). Korrigiert wird deshalb ueber ein eigenes Dokument mit neuer Nummer, das sich auf die alte bezieht (§ 31 Abs. 5 UStDV). - Knopf "Rechnung stornieren": alte Nummer waehlen, Programm liest deren PDF und schreibt eine Stornorechnung mit eigener neuer Nummer, Betraege negativ, mit Bezug auf Nummer und Datum der alten Rechnung. - Danach steht das Formular auf der naechsten Nummer, gefuellt aus der alten Rechnung; die Nummer ist gesperrt. "PDF erstellen" fragt noch einmal nach und legt bei Nein das Storno-PDF wieder weg - dann bleibt allein das Original stehen. - Die alte Rechnung wird nie angefasst. - Metadaten v3: art / storno_zu / ersetzt / storno_nummer, dazu Anschrift und Positionen, damit eine Rechnung wieder ins Formular geladen werden kann. Alle Felder aus v1/v2 bleiben unveraendert - daran haengt das Steuerjournal. - Zaehler laeuft nur noch vorwaerts; eine vergebene Nummer wird nicht mehr ueberschrieben, sondern die naechste freie angeboten. - Combobox im Storno-Dialog blieb leer: die StringVar hing nur an einer lokalen Variable und wurde weggeraeumt. Jetzt ohne textvariable. Prueflauf (pruef_storno.py, 36 Pruefungen gruen): echte PDFs in einem Wegwerf-Ordner, danach liest der Steuerrechner den Ordner - 001 + Storno + Korrektur ergeben genau die berichtigte Rechnung, kein Beleg unsicher. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
This commit is contained in:
parent
1564d769c9
commit
790ac4c0f6
4 changed files with 739 additions and 16 deletions
451
app.py
451
app.py
|
|
@ -10,6 +10,7 @@ Bedienung:
|
|||
Steuersaetze, Bettensteuer, Preise und Firmendaten -> Menue "Einstellungen".
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import datetime
|
||||
|
|
@ -48,6 +49,140 @@ def safe_filename(s):
|
|||
return "".join(c for c in s if c.isalnum() or c in keep).strip() or "Rechnung"
|
||||
|
||||
|
||||
def vorhandene_rechnungen(ordner, nummer):
|
||||
"""Alle PDFs im Ordner, die schon zu dieser Rechnungsnummer gehoeren.
|
||||
|
||||
Der Kundenname haengt mit im Dateinamen ("Rechnung_2026-013_Meier.pdf"),
|
||||
darum wird nur der Nummernteil verglichen - sonst rutscht dieselbe Nummer
|
||||
unter einem anderen Namen als zweite Datei durch.
|
||||
"""
|
||||
kopf = "Rechnung_" + safe_filename(nummer)
|
||||
try:
|
||||
namen = os.listdir(ordner)
|
||||
except OSError:
|
||||
return []
|
||||
treffer = []
|
||||
for name in namen:
|
||||
if not name.lower().endswith(".pdf"):
|
||||
continue
|
||||
stamm = name[:-4]
|
||||
# exakt die Nummer oder Nummer + "_Kundenname" - "2026-130" faellt raus
|
||||
if stamm == kopf or stamm.startswith(kopf + "_"):
|
||||
treffer.append(os.path.join(ordner, name))
|
||||
return sorted(treffer)
|
||||
|
||||
|
||||
def kenndaten_lesen(pfad):
|
||||
"""Kenndaten (Feld /Subject) aus einer vom Tool erzeugten PDF; None wenn keine.
|
||||
|
||||
Gelesen wird der Bestand, nicht das Formular - ein Storno muss genau das
|
||||
spiegeln, was rausgegangen ist.
|
||||
"""
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
roh = (PdfReader(pfad).metadata or {}).get("/Subject") or ""
|
||||
if not str(roh).strip().startswith("{"):
|
||||
return None
|
||||
daten = json.loads(str(roh))
|
||||
except Exception as e: # noqa: BLE001 - fremde oder kaputte PDF
|
||||
print(f"[storno] {os.path.basename(pfad)}: keine lesbaren Kenndaten ({e})")
|
||||
return None
|
||||
if daten.get("quelle") != "rechnungstool" or not daten.get("rechnungsnummer"):
|
||||
return None
|
||||
daten["_pfad"] = pfad
|
||||
return daten
|
||||
|
||||
|
||||
def rechnungen_im_ordner(ordner):
|
||||
"""Alle lesbaren Rechnungen des Ordners, neueste Nummer zuerst.
|
||||
|
||||
Stornos und schon stornierte Rechnungen sind mit drin - welche davon
|
||||
stornierbar ist, entscheidet der Aufrufer.
|
||||
"""
|
||||
try:
|
||||
namen = sorted(os.listdir(ordner))
|
||||
except OSError:
|
||||
return []
|
||||
gefunden = []
|
||||
for name in namen:
|
||||
if not name.lower().endswith(".pdf") or not name.startswith("Rechnung_"):
|
||||
continue
|
||||
daten = kenndaten_lesen(os.path.join(ordner, name))
|
||||
if daten:
|
||||
gefunden.append(daten)
|
||||
gefunden.sort(key=lambda d: str(d.get("rechnungsnummer", "")), reverse=True)
|
||||
return gefunden
|
||||
|
||||
|
||||
def storno_rechnung_bauen(meta, cfg):
|
||||
"""Baut aus den Kenndaten der alten Rechnung den spiegelbildlichen Storno.
|
||||
|
||||
Alle Betraege negativ. Je USt-Satz eine Zeile - die einzelnen Leistungen der
|
||||
alten Rechnung werden NICHT wiederholt: aufgehoben wird der Beleg als Ganzes,
|
||||
und die Positionen stehen in aelteren PDFs gar nicht in den Kenndaten.
|
||||
"""
|
||||
r = berechnung.Rechnung()
|
||||
nummer = meta.get("rechnungsnummer", "")
|
||||
bloecke = meta.get("ust_bloecke") or []
|
||||
if not bloecke:
|
||||
# v1-PDF ohne Aufteilung: alles auf den ermaessigten Satz, sonst faellt
|
||||
# die USt unter den Tisch.
|
||||
brutto = float(meta.get("zwischensumme") or 0)
|
||||
satz = int((cfg.get("steuersaetze") or [{"satz": 7}])[0].get("satz", 7))
|
||||
netto = berechnung.runde(brutto * 100 / (100 + satz)) if satz else brutto
|
||||
bloecke = [{"satz": satz, "netto": netto, "ust": berechnung.runde(brutto - netto),
|
||||
"brutto": brutto}]
|
||||
|
||||
saetze = {int(s.get("satz", -1)): s for s in cfg.get("steuersaetze", [])}
|
||||
for i, b in enumerate(bloecke, start=1):
|
||||
satz = int(float(b.get("satz", 0) or 0))
|
||||
brutto = -abs(berechnung.runde(b.get("brutto") or 0))
|
||||
netto = -abs(berechnung.runde(b.get("netto") or 0))
|
||||
ust = -abs(berechnung.runde(b.get("ust") or 0))
|
||||
r.zeilen.append(berechnung.PositionsZeile(
|
||||
nr=i, leistung=f"Storno der Rechnung {nummer} ({satz} % USt)",
|
||||
satz=satz, preis=brutto, anzahl=1, personen=0, pro_person=False,
|
||||
brutto=brutto, netto=netto))
|
||||
info = saetze.get(satz, {})
|
||||
r.bloecke.append(berechnung.Steuerblock(
|
||||
satz=satz, netto=netto, ust=ust,
|
||||
bezeichnung=str(info.get("bezeichnung", "") or "Mehrwertsteuer"),
|
||||
extra_blatt=bool(info.get("extra_blatt", False))))
|
||||
|
||||
r.netto_gesamt = berechnung.runde(sum(b.netto for b in r.bloecke))
|
||||
r.ust_gesamt = berechnung.runde(sum(b.ust for b in r.bloecke))
|
||||
r.brutto = berechnung.runde(r.netto_gesamt + r.ust_gesamt)
|
||||
|
||||
# Beherbergungssteuer wird mitstorniert, sonst stimmt die Meldung ans Amt nicht.
|
||||
# Die Bezeichnung kommt aus dem Satz mit "extra_blatt" - genau dem, aus dem
|
||||
# berechne() den Aufschlag baut.
|
||||
steuer = berechnung.runde(meta.get("steuer_betrag") or 0)
|
||||
if steuer:
|
||||
extra = next((s for s in cfg.get("steuersaetze", []) if s.get("extra_blatt")), {})
|
||||
r.aufschlaege.append(berechnung.Aufschlag(
|
||||
satz=float(meta.get("steuer_satz") or extra.get("satz", 0) or 0),
|
||||
bezeichnung=str(extra.get("bezeichnung", "") or "Beherbergungssteuer"),
|
||||
basis=r.brutto, betrag=-abs(steuer), extra_blatt=True))
|
||||
r.gesamt = berechnung.runde(r.brutto + sum(a.betrag for a in r.aufschlaege))
|
||||
return r
|
||||
|
||||
|
||||
def naechste_freie_nummer(ordner, cfg):
|
||||
"""Erste Nummer JJJJ-NNN, zu der im Ordner noch kein PDF liegt.
|
||||
|
||||
Gezaehlt wird ab dem zuletzt vergebenen Stand aus der config; im Ordner
|
||||
hoeher liegende Nummern werden dabei uebersprungen, weil sie nicht frei sind.
|
||||
"""
|
||||
jahr = cfg.get("rechnung_jahr", datetime.date.today().year)
|
||||
z = cfg.get("rechnung_zaehler", 0)
|
||||
for _ in range(1000):
|
||||
z += 1
|
||||
kandidat = config.format_rechnungsnummer(jahr, z)
|
||||
if not vorhandene_rechnungen(ordner, kandidat):
|
||||
return kandidat
|
||||
return None
|
||||
|
||||
|
||||
def einzelinstanz_pruefen():
|
||||
"""Sperrt das Programm gegen ein zweites Fenster.
|
||||
|
||||
|
|
@ -162,6 +297,9 @@ class RechnungsApp(tk.Tk):
|
|||
self.anzahl_vars = {} # nr -> StringVar
|
||||
self.personen_vars = {} # nr -> StringVar
|
||||
self.gesamt_lbls = {} # nr -> Label
|
||||
# Zwischenspeicher: geschriebenes Storno, das noch bestaetigt werden muss.
|
||||
# Solange es hier liegt, gehoert zur naechsten PDF die Storno-Kette.
|
||||
self.storno_stand = None
|
||||
|
||||
self._apply_theme() # Farben nach Windows hell/dunkel
|
||||
self._build_menu()
|
||||
|
|
@ -223,10 +361,20 @@ class RechnungsApp(tk.Tk):
|
|||
style.configure("TCheckbutton", background=bg, foreground=fg)
|
||||
style.map("TCheckbutton", background=[("active", bg)], foreground=[("active", fg)])
|
||||
style.configure("TEntry", fieldbackground=field, foreground=fg, insertcolor=fg)
|
||||
# Bei state="readonly" zeichnet die Combobox ihren Text als MARKIERUNG,
|
||||
# sobald sie den Fokus hat - ohne diese beiden Farben steht der Wert
|
||||
# unsichtbar im Feld.
|
||||
style.configure("TCombobox", fieldbackground=field, foreground=fg,
|
||||
background=field, arrowcolor=fg)
|
||||
background=field, arrowcolor=fg,
|
||||
selectbackground=field, selectforeground=fg)
|
||||
style.map("TCombobox", fieldbackground=[("readonly", field)],
|
||||
foreground=[("readonly", fg)], background=[("readonly", field)])
|
||||
foreground=[("readonly", fg)], background=[("readonly", field)],
|
||||
selectbackground=[("readonly", field)], selectforeground=[("readonly", fg)])
|
||||
# Die aufgeklappte Liste ist ein klassisches tk-Listbox-Widget
|
||||
self.option_add("*TCombobox*Listbox.background", field)
|
||||
self.option_add("*TCombobox*Listbox.foreground", fg)
|
||||
self.option_add("*TCombobox*Listbox.selectBackground", sel)
|
||||
self.option_add("*TCombobox*Listbox.selectForeground", fg)
|
||||
for sbs in ("Vertical.TScrollbar", "Horizontal.TScrollbar", "TScrollbar"):
|
||||
style.configure(sbs, troughcolor=bg, background=self.col_scroll,
|
||||
bordercolor=bg, lightcolor=bg, darkcolor=bg,
|
||||
|
|
@ -390,11 +538,20 @@ class RechnungsApp(tk.Tk):
|
|||
ttk.Label(info, text=lbl).grid(row=i, column=0, sticky="w", pady=2)
|
||||
ent = ttk.Entry(info, textvariable=var, width=18)
|
||||
ent.grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||
if var is self.r_nummer:
|
||||
# Waehrend eines Stornos wird das Feld gesperrt - die Nummern
|
||||
# vergibt dann das Programm, damit keine doppelt sein kann.
|
||||
self.nummer_entry = ent
|
||||
if var in date_vars: # Datum: Punkte automatisch, Cursor ans Ende
|
||||
ent.bind("<KeyRelease>", self._auto_datum_evt)
|
||||
ttk.Label(info, text="Nächte").grid(row=4, column=0, sticky="w", pady=2)
|
||||
ttk.Label(info, textvariable=self.r_naechte, font=("Segoe UI", 10, "bold")).grid(
|
||||
row=4, column=1, sticky="w", padx=(8, 0))
|
||||
# Storno-Hinweis: steht nur da, solange ein Storno im Zwischenspeicher liegt.
|
||||
self.storno_hinweis = tk.StringVar(value="")
|
||||
ttk.Label(info, textvariable=self.storno_hinweis, foreground="#b00020",
|
||||
font=("Segoe UI", 9, "bold"), wraplength=200, justify="left").grid(
|
||||
row=5, column=0, columnspan=2, sticky="w", pady=(6, 0))
|
||||
info.columnconfigure(1, weight=1)
|
||||
self.r_anreise.trace_add("write", lambda *a: self._update_naechte())
|
||||
self.r_abreise.trace_add("write", lambda *a: self._update_naechte())
|
||||
|
|
@ -527,6 +684,7 @@ class RechnungsApp(tk.Tk):
|
|||
links = ttk.Frame(f)
|
||||
links.pack(side="left")
|
||||
ttk.Button(links, text="PDF erstellen", command=self.pdf_erstellen, width=18).pack(side="left", padx=4)
|
||||
ttk.Button(links, text="Rechnung stornieren", command=self.dlg_storno, width=20).pack(side="left", padx=4)
|
||||
ttk.Button(links, text="Zurücksetzen", command=self.reset_formular, width=14).pack(side="left", padx=4)
|
||||
|
||||
# --------------------------------------------------------- Berechnung
|
||||
|
|
@ -578,6 +736,219 @@ class RechnungsApp(tk.Tk):
|
|||
jahr, zaehler = heute_jahr, 0
|
||||
self.r_nummer.set(config.format_rechnungsnummer(jahr, zaehler + 1))
|
||||
|
||||
# ------------------------------------------------------------- Storno
|
||||
def _storno_anzeigen(self):
|
||||
"""Hinweiszeile und Sperre der Rechnungsnummer an den Zwischenspeicher anpassen."""
|
||||
stand = self.storno_stand
|
||||
if stand:
|
||||
self.storno_hinweis.set(
|
||||
f"Storno {stand['nummer']} zu Rechnung {stand['zu']} liegt bereit.\n"
|
||||
f"Beim Erstellen wird noch einmal gefragt.")
|
||||
self.nummer_entry.configure(state="readonly")
|
||||
else:
|
||||
self.storno_hinweis.set("")
|
||||
self.nummer_entry.configure(state="normal")
|
||||
|
||||
def _storno_verwerfen(self, grund=""):
|
||||
"""Storno rückgängig: PDF löschen, Zähler zurück, Zwischenspeicher leeren.
|
||||
|
||||
Zulaessig ist das nur hier: die Storno-PDF ist Sekunden alt und hat das
|
||||
Haus nie verlassen. Die stornierte Rechnung selbst wird nie angefasst.
|
||||
"""
|
||||
stand = self.storno_stand
|
||||
if not stand:
|
||||
return
|
||||
try:
|
||||
if os.path.exists(stand["pfad"]):
|
||||
os.remove(stand["pfad"])
|
||||
print(f"[storno] verworfen, PDF gelöscht -> {stand['pfad']}")
|
||||
except OSError as e: # noqa: BLE001
|
||||
messagebox.showwarning("Storno-PDF blieb liegen",
|
||||
f"Die Storno-PDF konnte nicht gelöscht werden:\n{e}")
|
||||
self.cfg["rechnung_jahr"] = stand["jahr_vorher"]
|
||||
self.cfg["rechnung_zaehler"] = stand["zaehler_vorher"]
|
||||
config.save_config(self.cfg)
|
||||
self.storno_stand = None
|
||||
self._storno_anzeigen()
|
||||
self._neue_nummer_vorschlagen()
|
||||
if grund:
|
||||
print(f"[storno] {grund}")
|
||||
|
||||
def dlg_storno(self):
|
||||
"""Storno-Dialog: alte Nummer wählen -> Storno-PDF schreiben -> Formular füllen."""
|
||||
if self.storno_stand:
|
||||
messagebox.showinfo(
|
||||
"Storno liegt schon bereit",
|
||||
f"Der Storno {self.storno_stand['nummer']} zu Rechnung "
|
||||
f"{self.storno_stand['zu']} ist noch offen.\n\n"
|
||||
"Erst die berichtigte Rechnung erstellen oder den Storno dort verwerfen.")
|
||||
return
|
||||
|
||||
out_dir = config.ensure_output_dir(self.cfg)
|
||||
alle = rechnungen_im_ordner(out_dir)
|
||||
stornierte = {str(d.get("storno_zu") or "") for d in alle if d.get("art") == "storno"}
|
||||
offen = [d for d in alle
|
||||
if d.get("art") != "storno" and d.get("rechnungsnummer") not in stornierte]
|
||||
if not offen:
|
||||
messagebox.showwarning(
|
||||
"Nichts zu stornieren",
|
||||
f"Im Ordner\n{out_dir}\nliegt keine stornierbare Rechnung.")
|
||||
return
|
||||
|
||||
d = tk.Toplevel(self)
|
||||
d.title("Rechnung stornieren")
|
||||
d.transient(self)
|
||||
d.grab_set()
|
||||
self._style_dialog(d)
|
||||
frm = ttk.Frame(d, padding=12)
|
||||
frm.pack(fill="both", expand=True)
|
||||
|
||||
ttk.Label(frm, justify="left", text=
|
||||
"Die alte Rechnung bleibt unverändert liegen – sie darf weder geändert\n"
|
||||
"noch gelöscht werden. Das Programm schreibt eine Stornorechnung mit\n"
|
||||
"eigener neuer Nummer, die sich auf sie bezieht.").grid(
|
||||
row=0, column=0, columnspan=2, sticky="w", pady=(0, 10))
|
||||
|
||||
ttk.Label(frm, text="Alte Rechnungsnummer").grid(row=1, column=0, sticky="w", pady=3)
|
||||
# Bewusst OHNE textvariable: eine StringVar, die nur an einer lokalen
|
||||
# Variable haengt, raeumt Python nach dem Dialogaufbau weg - Tk loescht
|
||||
# dann die Tcl-Variable und das Feld steht leer da.
|
||||
werte = [f"{x['rechnungsnummer']} {x.get('datum','')} {x.get('nachname','')}".strip()
|
||||
for x in offen]
|
||||
box = ttk.Combobox(frm, values=werte, width=42, state="readonly")
|
||||
box.grid(row=1, column=1, sticky="ew", pady=3, padx=(8, 0))
|
||||
box.current(0)
|
||||
|
||||
v_info = tk.StringVar(value="")
|
||||
ttk.Label(frm, textvariable=v_info, justify="left", foreground=self.col_muted).grid(
|
||||
row=2, column=0, columnspan=2, sticky="w", pady=(8, 0))
|
||||
|
||||
def gewaehlt():
|
||||
i = box.current()
|
||||
return offen[i] if 0 <= i < len(offen) else None
|
||||
|
||||
def vorschau(*_a):
|
||||
m = gewaehlt()
|
||||
if not m:
|
||||
return
|
||||
v_info.set(f"Rechnungsbetrag {berechnung.eur(m.get('zwischensumme') or 0)}, "
|
||||
f"Gesamt {berechnung.eur(m.get('gesamt') or 0)}\n"
|
||||
f"Der Storno lautet über {berechnung.eur(-abs(float(m.get('gesamt') or 0)))}.")
|
||||
box.bind("<<ComboboxSelected>>", vorschau)
|
||||
vorschau()
|
||||
|
||||
def anlegen():
|
||||
meta = gewaehlt()
|
||||
if not meta:
|
||||
return
|
||||
d.destroy()
|
||||
self._storno_anlegen(meta, out_dir)
|
||||
|
||||
btns = ttk.Frame(frm)
|
||||
btns.grid(row=3, column=0, columnspan=2, pady=(14, 0), sticky="e")
|
||||
ttk.Button(btns, text="Storno erstellen", command=anlegen).pack(side="right", padx=4)
|
||||
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
|
||||
frm.columnconfigure(1, weight=1)
|
||||
|
||||
def _storno_anlegen(self, meta, out_dir):
|
||||
"""Schreibt die Storno-PDF und füllt das Formular für die berichtigte Rechnung."""
|
||||
alt_nummer = str(meta.get("rechnungsnummer", ""))
|
||||
nummer = naechste_freie_nummer(out_dir, self.cfg)
|
||||
if not nummer:
|
||||
messagebox.showerror("Keine freie Nummer",
|
||||
"Es wurde keine freie Rechnungsnummer gefunden.")
|
||||
return
|
||||
|
||||
r = storno_rechnung_bauen(meta, self.cfg)
|
||||
heute = datetime.date.today().strftime("%d.%m.%Y")
|
||||
namenszeile = " ".join(t for t in (str(meta.get("vorname") or ""),
|
||||
str(meta.get("nachname") or "")) if t)
|
||||
kunde = meta.get("kunde") or {}
|
||||
kunde_zeilen = ([kunde.get("anrede", ""), namenszeile, kunde.get("zusatz", ""),
|
||||
kunde.get("strasse", ""), kunde.get("plzort", "")]
|
||||
if kunde else ["", namenszeile, "", "", ""])
|
||||
kopf = {
|
||||
"rechnungsnummer": nummer,
|
||||
"datum": heute,
|
||||
"anreise": "", "abreise": "", "naechte": "",
|
||||
"kunde_zeilen": kunde_zeilen,
|
||||
"nachname": str(meta.get("nachname") or ""),
|
||||
"vorname": str(meta.get("vorname") or ""),
|
||||
"art": "storno",
|
||||
"storno_zu": alt_nummer,
|
||||
"storno_datum": str(meta.get("datum") or ""),
|
||||
"kunde": kunde,
|
||||
}
|
||||
name = f"Storno_{safe_filename(nummer)}"
|
||||
if kopf["nachname"]:
|
||||
name += "_" + safe_filename(kopf["nachname"])
|
||||
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"Storno-PDF konnte nicht erstellt werden:\n{e}")
|
||||
print("[storno] FEHLER:", e)
|
||||
return
|
||||
|
||||
self.storno_stand = {
|
||||
"pfad": pfad, "nummer": nummer, "zu": alt_nummer,
|
||||
"zu_datum": str(meta.get("datum") or ""),
|
||||
"jahr_vorher": self.cfg.get("rechnung_jahr", datetime.date.today().year),
|
||||
"zaehler_vorher": self.cfg.get("rechnung_zaehler", 0),
|
||||
}
|
||||
self._zaehler_aus_nummer_speichern(nummer)
|
||||
print(f"[storno] erstellt -> {pfad}")
|
||||
|
||||
fehlend = self._formular_aus_meta(meta)
|
||||
self.r_nummer.set(naechste_freie_nummer(out_dir, self.cfg) or "")
|
||||
self._storno_anzeigen()
|
||||
|
||||
hinweis = ""
|
||||
if fehlend:
|
||||
hinweis = ("\n\nAus der alten PDF ließen sich nicht alle Felder übernehmen "
|
||||
f"({fehlend}) – die stammt noch aus einer älteren Fassung. "
|
||||
"Bitte vor dem Erstellen prüfen.")
|
||||
messagebox.showinfo(
|
||||
"Storno erstellt",
|
||||
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"\"PDF erstellen\" drücken.{hinweis}")
|
||||
|
||||
def _formular_aus_meta(self, meta):
|
||||
"""Formular mit den Daten der alten Rechnung füllen.
|
||||
|
||||
Gibt zurück, was NICHT übernommen werden konnte - aeltere PDFs (v1/v2)
|
||||
tragen weder Anschrift noch Positionen in den Kenndaten.
|
||||
"""
|
||||
self.k_vorname.set(str(meta.get("vorname") or ""))
|
||||
self.k_name.set(str(meta.get("nachname") or ""))
|
||||
kunde = meta.get("kunde") or {}
|
||||
self.k_anrede.set(str(kunde.get("anrede", "") or ""))
|
||||
self.k_zusatz.set(str(kunde.get("zusatz", "") or ""))
|
||||
self.k_strasse.set(str(kunde.get("strasse", "") or ""))
|
||||
self.k_plzort.set(str(kunde.get("plzort", "") or ""))
|
||||
self.r_anreise.set(str(meta.get("anreise", "") or ""))
|
||||
self.r_abreise.set(str(meta.get("abreise", "") or ""))
|
||||
self.r_datum.set(datetime.date.today().strftime("%d.%m.%Y"))
|
||||
|
||||
for nr, var in self.anzahl_vars.items():
|
||||
var.set("")
|
||||
self.personen_vars[nr].set("")
|
||||
for p in meta.get("positionen") or []:
|
||||
nr = p.get("nr")
|
||||
if nr in self.anzahl_vars:
|
||||
self.anzahl_vars[nr].set(str(p.get("anzahl", "") or ""))
|
||||
self.personen_vars[nr].set(str(p.get("personen", "") or ""))
|
||||
self.recalc()
|
||||
|
||||
fehlt = []
|
||||
if not kunde:
|
||||
fehlt.append("Anschrift")
|
||||
if not (meta.get("positionen") or []):
|
||||
fehlt.append("Leistungen")
|
||||
return ", ".join(fehlt)
|
||||
|
||||
def pdf_erstellen(self):
|
||||
r = self.recalc()
|
||||
if not r.zeilen:
|
||||
|
|
@ -601,6 +972,26 @@ class RechnungsApp(tk.Tk):
|
|||
namenszeile = " ".join(t for t in (vorname, nachname) if t)
|
||||
kunde_zeilen = [self.k_anrede.get(), namenszeile, self.k_zusatz.get(),
|
||||
self.k_strasse.get(), self.k_plzort.get()]
|
||||
# Liegt ein Storno im Zwischenspeicher, ist das hier die berichtigte Rechnung.
|
||||
# Letzte Gelegenheit umzukehren: sagt er Nein, wird das Storno-PDF wieder
|
||||
# geloescht und es bleibt allein die urspruengliche Rechnung stehen.
|
||||
stand = self.storno_stand
|
||||
if stand:
|
||||
if not messagebox.askyesno(
|
||||
"Storno wirklich durchführen?",
|
||||
f"Storno {stand['nummer']} hebt die Rechnung {stand['zu']} auf, "
|
||||
f"und {nummer} wird die berichtigte Rechnung.\n\n"
|
||||
"Beide gehen an den Gast – die Berichtigung wirkt erst, wenn sie "
|
||||
"ihm zugegangen ist.\n\n"
|
||||
"Ja = Storno behalten und berichtigte Rechnung erstellen\n"
|
||||
f"Nein = alles verwerfen, es bleibt allein Rechnung {stand['zu']}",
|
||||
icon="warning", default="yes"):
|
||||
self._storno_verwerfen("vom Benutzer verworfen")
|
||||
messagebox.showinfo(
|
||||
"Storno verworfen",
|
||||
f"Der Storno wurde gelöscht. Es bleibt allein die Rechnung {stand['zu']}.")
|
||||
return
|
||||
|
||||
kopf = {
|
||||
"rechnungsnummer": nummer,
|
||||
"datum": self.r_datum.get().strip() or datetime.date.today().strftime("%d.%m.%Y"),
|
||||
|
|
@ -610,8 +1001,46 @@ class RechnungsApp(tk.Tk):
|
|||
"kunde_zeilen": kunde_zeilen,
|
||||
"nachname": nachname,
|
||||
"vorname": vorname,
|
||||
# Fuer Version 3: kompletter Formularstand, damit die Rechnung spaeter
|
||||
# wieder ins Formular geladen werden kann statt abgetippt zu werden.
|
||||
"kunde": {"anrede": self.k_anrede.get().strip(), "vorname": vorname,
|
||||
"nachname": nachname, "zusatz": self.k_zusatz.get().strip(),
|
||||
"strasse": self.k_strasse.get().strip(),
|
||||
"plzort": self.k_plzort.get().strip()},
|
||||
"positionen": [{"nr": z.nr, "anzahl": z.anzahl, "personen": z.personen}
|
||||
for z in r.zeilen],
|
||||
}
|
||||
if stand:
|
||||
kopf["art"] = "korrektur"
|
||||
kopf["ersetzt"] = stand["zu"]
|
||||
kopf["storno_nummer"] = stand["nummer"]
|
||||
out_dir = config.ensure_output_dir(self.cfg)
|
||||
|
||||
# Nummer schon vergeben? Dann NICHT drueberschreiben: eine ausgestellte
|
||||
# Rechnung muss unveraendert bestehen bleiben (§ 146 Abs. 4 AO), und jede
|
||||
# Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG). Also neue Nummer.
|
||||
vorhanden = vorhandene_rechnungen(out_dir, nummer)
|
||||
if vorhanden:
|
||||
liste = "\n".join("- " + os.path.basename(x) for x in vorhanden)
|
||||
frei = naechste_freie_nummer(out_dir, self.cfg)
|
||||
if not frei:
|
||||
messagebox.showerror(
|
||||
"Rechnungsnummer schon vergeben",
|
||||
f"Die Rechnungsnummer {nummer} ist schon vergeben:\n\n{liste}\n\n"
|
||||
"Es wurde keine freie Nummer gefunden - bitte die Nummer von Hand setzen.")
|
||||
return
|
||||
if not messagebox.askyesno(
|
||||
"Rechnungsnummer schon vergeben",
|
||||
f"Die Rechnungsnummer {nummer} ist schon vergeben:\n\n{liste}\n\n"
|
||||
"Die vorhandene Rechnung bleibt bestehen - sie darf weder überschrieben "
|
||||
"noch gelöscht werden (§ 14 Abs. 4 Nr. 4 UStG, § 146 Abs. 4 AO).\n\n"
|
||||
f"Stattdessen die nächste freie Nummer {frei} verwenden?",
|
||||
icon="warning", default="yes"):
|
||||
return
|
||||
nummer = frei
|
||||
self.r_nummer.set(nummer)
|
||||
kopf["rechnungsnummer"] = nummer
|
||||
|
||||
fname = f"Rechnung_{safe_filename(nummer)}"
|
||||
if self.k_name.get().strip():
|
||||
fname += "_" + safe_filename(self.k_name.get().strip())
|
||||
|
|
@ -630,8 +1059,19 @@ class RechnungsApp(tk.Tk):
|
|||
|
||||
print(f"[pdf] erstellt -> {pfad}")
|
||||
hinweis = "\n\n(Seite 1 = Kunde, Seite 2 = Beherbergungssteuer fürs Amt)" if r.aufschlaege else ""
|
||||
storno_pfad = None
|
||||
if stand:
|
||||
# Storno ist jetzt bestaetigt - Zwischenspeicher leeren, Sperre loesen.
|
||||
storno_pfad = stand["pfad"]
|
||||
self.storno_stand = None
|
||||
self._storno_anzeigen()
|
||||
hinweis = (f"\n\nDazu gehört die Stornorechnung {stand['nummer']} "
|
||||
f"({os.path.basename(storno_pfad)}).\n"
|
||||
"Beide Blätter gehören zum Gast.") + hinweis
|
||||
if messagebox.askyesno("Fertig", f"Rechnung erstellt:\n{pfad}{hinweis}\n\nJetzt öffnen (zum Drucken)?"):
|
||||
self._oeffne_datei(pfad)
|
||||
if storno_pfad:
|
||||
self._oeffne_datei(storno_pfad)
|
||||
|
||||
def _zaehler_aus_nummer_speichern(self, nummer):
|
||||
try:
|
||||
|
|
@ -639,6 +1079,13 @@ class RechnungsApp(tk.Tk):
|
|||
jahr, z = int(jahr_s), int(z_s)
|
||||
except (ValueError, AttributeError):
|
||||
return
|
||||
# Der Zaehler darf nur vorwaerts. Wird eine alte Rechnung ueberschrieben,
|
||||
# wuerde er sonst zurueckfallen und die naechsten Nummern noch einmal
|
||||
# vergeben - genau der Fehler, den wir loswerden wollen.
|
||||
alt_jahr = self.cfg.get("rechnung_jahr", 0)
|
||||
alt_z = self.cfg.get("rechnung_zaehler", 0)
|
||||
if jahr < alt_jahr or (jahr == alt_jahr and z <= alt_z):
|
||||
return
|
||||
self.cfg["rechnung_jahr"] = jahr
|
||||
self.cfg["rechnung_zaehler"] = z
|
||||
config.save_config(self.cfg)
|
||||
|
|
|
|||
|
|
@ -208,10 +208,21 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
|||
if len(kunde_flow) == 1:
|
||||
kunde_flow.append(Paragraph(" ", st["addr"]))
|
||||
|
||||
art = kopf.get("art", "rechnung")
|
||||
info_rows = [
|
||||
("Rechnungsnummer:", kopf.get("rechnungsnummer", "")),
|
||||
("Stornonummer:" if art == "storno" else "Rechnungsnummer:",
|
||||
kopf.get("rechnungsnummer", "")),
|
||||
("Rechnungsdatum:", kopf.get("datum", "")),
|
||||
]
|
||||
# Der Bezug auf die alte Rechnung ist Pflicht (§ 31 Abs. 5 UStDV): Nummer UND Datum.
|
||||
if art == "storno" and kopf.get("storno_zu"):
|
||||
# Nummer und Datum in zwei Zeilen - zusammen brechen sie in der schmalen
|
||||
# Spalte um und der Bezug sieht aus wie zwei halbe Angaben.
|
||||
info_rows.append(("Storno zu Rechnung:", kopf.get("storno_zu", "")))
|
||||
if kopf.get("storno_datum"):
|
||||
info_rows.append(("Rechnung vom:", kopf["storno_datum"]))
|
||||
elif art == "korrektur" and kopf.get("ersetzt"):
|
||||
info_rows.append(("Ersetzt Rechnung:", kopf.get("ersetzt", "")))
|
||||
if kopf.get("anreise"):
|
||||
info_rows.append(("Anreise:", kopf.get("anreise", "")))
|
||||
if kopf.get("abreise"):
|
||||
|
|
@ -239,7 +250,10 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
|||
# Ueberschrift (auf dem Amt-Blatt mit Zusatz)
|
||||
# Das Wort "Beherbergungssteuer" steht auf dem Amt-Blatt nirgends mehr - auch nicht
|
||||
# in der Ueberschrift. Der Zusatz sagt nur noch, fuer wen das Blatt ist.
|
||||
titel = "Rechnungsübersicht" + (" (für das Amt)" if gesamtrechnung_betont else "")
|
||||
# Das Wort "Stornorechnung" muss auf dem Blatt stehen, damit der Beleg
|
||||
# eindeutig als Aufhebung erkennbar ist.
|
||||
titel = "Stornorechnung" if art == "storno" else "Rechnungsübersicht"
|
||||
titel += " (für das Amt)" if gesamtrechnung_betont else ""
|
||||
story.append(Paragraph(titel, st["h"]))
|
||||
|
||||
# Positionstabelle
|
||||
|
|
@ -342,16 +356,38 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
|||
continue
|
||||
satz_txt = eur_kurz(a.satz).replace(",00", "")
|
||||
story.append(Spacer(1, 1.5 * mm))
|
||||
if art == "storno":
|
||||
# Auf dem Storno wird nichts erhoben, sondern zurueckgenommen.
|
||||
story.append(Paragraph(
|
||||
f"Die mit der stornierten Rechnung erhobene {a.bezeichnung} "
|
||||
f"({satz_txt} %) wird mit aufgehoben.", st["dank"]))
|
||||
else:
|
||||
story.append(Paragraph(
|
||||
f"Die {a.bezeichnung} ({satz_txt} %) wird gemäß kommunaler Satzung auf die "
|
||||
f"Zwischensumme erhoben und ist im Gesamtbetrag enthalten.", st["dank"]))
|
||||
|
||||
story.append(Spacer(1, 8 * mm))
|
||||
|
||||
# ---- Danktexte + Hinweis (nur Kundenseite; auf dem Amt-Blatt unnoetig) ----
|
||||
# ---- Schlusstexte (nur Kundenseite; auf dem Amt-Blatt unnoetig) ----
|
||||
if not gesamtrechnung_betont:
|
||||
texte = cfg.get("texte", {})
|
||||
block = []
|
||||
if art == "storno":
|
||||
# Auf einem Storno sind Dank- und Zahlungstexte falsch - hier wird
|
||||
# nichts geschuldet, sondern aufgehoben.
|
||||
bezug = kopf.get("storno_zu", "")
|
||||
if kopf.get("storno_datum"):
|
||||
bezug += f" vom {kopf['storno_datum']}"
|
||||
block.append(Paragraph(
|
||||
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
|
||||
"Bereits gezahlte Beträge werden erstattet oder mit der berichtigten "
|
||||
"Rechnung verrechnet.", st["dank"]))
|
||||
else:
|
||||
if art == "korrektur" and kopf.get("ersetzt"):
|
||||
hinweis = f"Diese Rechnung ersetzt die Rechnung {kopf['ersetzt']}"
|
||||
if kopf.get("storno_nummer"):
|
||||
hinweis += f", die mit der Stornorechnung {kopf['storno_nummer']} aufgehoben wurde"
|
||||
block.append(Paragraph(hinweis + ".", st["dank"]))
|
||||
if texte.get("dank1"):
|
||||
block.append(Paragraph(texte["dank1"], st["dank"]))
|
||||
if texte.get("dank2"):
|
||||
|
|
@ -374,11 +410,21 @@ def _kenndaten(rechnung, kopf):
|
|||
stecken auf einer Rechnung gemischt drin. Alle Felder der Version 1 bleiben
|
||||
unveraendert stehen - am "zwischensumme"/"steuer_satz"/"steuer_betrag" haengt
|
||||
der Import des Steuerjournals, das darf nicht brechen.
|
||||
|
||||
Version 3 kommt mit dem Storno dazu:
|
||||
"art" - "rechnung", "storno" oder "korrektur"
|
||||
"storno_zu" - beim Storno die Nummer der aufgehobenen Rechnung
|
||||
"ersetzt" - bei der Korrektur die Nummer der falschen Rechnung
|
||||
"kunde" / "positionen" / "anreise" / "abreise" - der komplette Formularstand,
|
||||
damit eine Rechnung spaeter wieder ins Formular geladen werden kann, statt
|
||||
sie abzutippen. Aeltere PDFs (v1/v2) haben das nicht.
|
||||
Beim Storno sind alle Betraege NEGATIV - das Steuerjournal und der
|
||||
Steuerrechner summieren dadurch von allein richtig (+100 -100 +120 = 120).
|
||||
"""
|
||||
aufschlag = rechnung.aufschlaege[0] if rechnung.aufschlaege else None
|
||||
daten = {
|
||||
"quelle": "rechnungstool",
|
||||
"version": 2,
|
||||
"version": 3,
|
||||
"rechnungsnummer": kopf.get("rechnungsnummer", ""),
|
||||
"datum": kopf.get("datum", ""),
|
||||
"nachname": kopf.get("nachname", ""),
|
||||
|
|
@ -396,6 +442,16 @@ def _kenndaten(rechnung, kopf):
|
|||
"brutto": runde(b.brutto)}
|
||||
for b in rechnung.bloecke
|
||||
],
|
||||
# neu in Version 3
|
||||
"art": kopf.get("art", "rechnung"),
|
||||
"storno_zu": kopf.get("storno_zu", ""),
|
||||
"storno_datum": kopf.get("storno_datum", ""),
|
||||
"ersetzt": kopf.get("ersetzt", ""),
|
||||
"storno_nummer": kopf.get("storno_nummer", ""),
|
||||
"anreise": kopf.get("anreise", ""),
|
||||
"abreise": kopf.get("abreise", ""),
|
||||
"kunde": kopf.get("kunde", {}),
|
||||
"positionen": kopf.get("positionen", []),
|
||||
}
|
||||
return json.dumps(daten, ensure_ascii=False)
|
||||
|
||||
|
|
@ -419,7 +475,8 @@ def render_rechnung(pfad, cfg, rechnung, kopf):
|
|||
ziel, pagesize=A4,
|
||||
leftMargin=MARGIN_L, rightMargin=MARGIN_R,
|
||||
topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT,
|
||||
title=f"Rechnung {kopf.get('rechnungsnummer','')}",
|
||||
title=("Stornorechnung" if kopf.get("art") == "storno" else "Rechnung")
|
||||
+ f" {kopf.get('rechnungsnummer','')}",
|
||||
author=cfg["firma"].get("name", ""),
|
||||
subject=_kenndaten(rechnung, kopf), # maschinenlesbar fuers Steuerjournal
|
||||
)
|
||||
|
|
|
|||
50
pruef_nummern.py
Normal file
50
pruef_nummern.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Pruefstand: findet vorhandene_rechnungen() eine schon vergebene Nummer?
|
||||
|
||||
Kein Rateraum: legt echte Dateien in einem Wegwerf-Ordner an und fragt die
|
||||
Funktion aus app.py direkt.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, r"C:\claude\rechnungstool")
|
||||
from app import vorhandene_rechnungen # noqa: E402
|
||||
|
||||
ordner = tempfile.mkdtemp(prefix="rechnungspruef_")
|
||||
dateien = [
|
||||
"Rechnung_2026-013_Meier.pdf", # dieselbe Nummer, Kunde Meier
|
||||
"Rechnung_2026-130_Schmidt.pdf", # laengere Nummer - darf NICHT treffen
|
||||
"Rechnung_2026-014_Meier.pdf", # andere Nummer
|
||||
"Rechnung_2026-020.pdf", # ohne Kundenname
|
||||
"Notiz_2026-013.txt", # kein PDF
|
||||
]
|
||||
for d in dateien:
|
||||
with open(os.path.join(ordner, d), "w") as f:
|
||||
f.write("x")
|
||||
|
||||
faelle = [
|
||||
("2026-013", ["Rechnung_2026-013_Meier.pdf"], "Nummer vergeben, anderer Kundenname"),
|
||||
("2026-130", ["Rechnung_2026-130_Schmidt.pdf"], "laengere Nummer trifft sich selbst"),
|
||||
("2026-020", ["Rechnung_2026-020.pdf"], "ohne Kundenname im Dateinamen"),
|
||||
("2026-099", [], "freie Nummer"),
|
||||
("2026-01", [], "Teilnummer darf nicht auf 2026-013 anspringen"),
|
||||
]
|
||||
|
||||
fehler = 0
|
||||
for nummer, erwartet, was in faelle:
|
||||
ist = [os.path.basename(p) for p in vorhandene_rechnungen(ordner, nummer)]
|
||||
ok = ist == erwartet
|
||||
fehler += 0 if ok else 1
|
||||
print((" OK " if ok else "FEHLER") + f" | {nummer} -> {ist} ({was})")
|
||||
|
||||
# leerer / nicht vorhandener Ordner darf nicht knallen
|
||||
if vorhandene_rechnungen(os.path.join(ordner, "gibtsnicht"), "2026-013") != []:
|
||||
print("FEHLER | fehlender Ordner")
|
||||
fehler += 1
|
||||
else:
|
||||
print(" OK | fehlender Ordner gibt leere Liste")
|
||||
|
||||
print("---")
|
||||
print("alles gruen" if fehler == 0 else f"{fehler} Fehler")
|
||||
sys.exit(1 if fehler else 0)
|
||||
169
pruef_storno.py
Normal file
169
pruef_storno.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Pruefstand fuer die Storno-Kette - kompletter Durchlauf in einem Wegwerf-Ordner.
|
||||
|
||||
Geprueft wird an ECHTEN PDFs, die das Programm selbst schreibt:
|
||||
1. Rechnung 2026-001 erstellen
|
||||
2. sie stornieren -> Storno 2026-002 mit negativen Betraegen
|
||||
3. berichtigte Rechnung 2026-003 mit geaenderter Menge
|
||||
4. der Steuerrechner liest den Ordner: 001 + 002 + 003 muss 003 ergeben
|
||||
5. Rueckweg: Storno anlegen und verwerfen -> PDF weg, Zaehler zurueck, Original da
|
||||
|
||||
Die echte config.json wird NICHT angefasst - es wird mit einer Kopie im
|
||||
Wegwerf-Ordner gearbeitet.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
QUELLE = r"C:\claude\rechnungstool"
|
||||
sys.path.insert(0, QUELLE)
|
||||
|
||||
tmp = tempfile.mkdtemp(prefix="storno_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
|
||||
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 automatisch beantworten; jede Frage wird protokolliert.
|
||||
antworten = {}
|
||||
gefragt = []
|
||||
|
||||
|
||||
def askyesno(titel, text, **kw):
|
||||
gefragt.append(titel)
|
||||
for schlue, wert in antworten.items():
|
||||
if schlue in titel:
|
||||
return wert
|
||||
return False
|
||||
|
||||
|
||||
messagebox.askyesno = askyesno
|
||||
messagebox.showinfo = lambda *a, **k: None
|
||||
messagebox.showwarning = lambda *a, **k: print("[warnung]", a[0] if a else "")
|
||||
messagebox.showerror = lambda *a, **k: print("[fehler]", a[0] if a else "", a[1] if len(a) > 1 else "")
|
||||
|
||||
f = A.RechnungsApp()
|
||||
f.update()
|
||||
f.cfg["output_dir"] = ordner
|
||||
|
||||
# ---- 1. normale Rechnung ------------------------------------------------
|
||||
f.k_vorname.set("Anna")
|
||||
f.k_name.set("Beispiel")
|
||||
f.k_strasse.set("Musterweg 1")
|
||||
f.k_plzort.set("12345 Musterstadt")
|
||||
f.r_anreise.set("01.08.2026")
|
||||
f.r_abreise.set("05.08.2026")
|
||||
erste = sorted(f.anzahl_vars)[0]
|
||||
f.anzahl_vars[erste].set("4")
|
||||
f.update()
|
||||
f.pdf_erstellen()
|
||||
erste_pdf = os.path.join(ordner, "Rechnung_2026-001_Beispiel.pdf")
|
||||
pruefe(os.path.exists(erste_pdf), "Rechnung 2026-001 geschrieben")
|
||||
|
||||
meta1 = A.kenndaten_lesen(erste_pdf)
|
||||
pruefe(bool(meta1), "Kenndaten der Rechnung lesbar")
|
||||
pruefe(meta1.get("version") == 3, f"Metadaten Version 3 (ist {meta1.get('version')})")
|
||||
pruefe(meta1.get("art") == "rechnung", "art = rechnung")
|
||||
pruefe(len(meta1.get("positionen") or []) == 1, "Positionen stehen in den Kenndaten")
|
||||
pruefe(bool(meta1.get("kunde", {}).get("plzort")), "Anschrift steht in den Kenndaten")
|
||||
brutto1 = float(meta1["zwischensumme"])
|
||||
gesamt1 = float(meta1["gesamt"])
|
||||
print(f" Rechnung 001: brutto {brutto1:.2f}, gesamt {gesamt1:.2f}")
|
||||
|
||||
# ---- 2. stornieren ------------------------------------------------------
|
||||
offen = [d for d in A.rechnungen_im_ordner(ordner) if d.get("art") != "storno"]
|
||||
pruefe(len(offen) == 1, "eine stornierbare Rechnung gefunden")
|
||||
f._storno_anlegen(offen[0], ordner)
|
||||
pruefe(f.storno_stand is not None, "Storno liegt im Zwischenspeicher")
|
||||
storno_pdf = f.storno_stand["pfad"] if f.storno_stand else ""
|
||||
pruefe(os.path.exists(storno_pdf), f"Storno-PDF geschrieben ({os.path.basename(storno_pdf)})")
|
||||
|
||||
meta2 = A.kenndaten_lesen(storno_pdf)
|
||||
pruefe(meta2.get("art") == "storno", "art = storno")
|
||||
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")
|
||||
pruefe(float(meta2["zwischensumme"]) == -brutto1, f"Zwischensumme negativ ({meta2['zwischensumme']})")
|
||||
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"]),
|
||||
"alle USt-Bloecke negativ")
|
||||
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(f.k_plzort.get() == "12345 Musterstadt", "Anschrift ins Formular uebernommen")
|
||||
pruefe(f.anzahl_vars[erste].get() == "4.0", f"Menge uebernommen (ist {f.anzahl_vars[erste].get()})")
|
||||
|
||||
# ---- 3. berichtigte Rechnung -------------------------------------------
|
||||
antworten["Storno wirklich"] = True
|
||||
f.anzahl_vars[erste].set("3") # das war der Fehler: 4 statt 3 Naechte
|
||||
f.update()
|
||||
f.pdf_erstellen()
|
||||
dritte = os.path.join(ordner, "Rechnung_2026-003_Beispiel.pdf")
|
||||
pruefe(os.path.exists(dritte), "berichtigte Rechnung 2026-003 geschrieben")
|
||||
pruefe(f.storno_stand is None, "Zwischenspeicher nach dem Erstellen leer")
|
||||
pruefe(str(f.nummer_entry.cget("state")) == "normal", "Nummernfeld wieder frei")
|
||||
meta3 = A.kenndaten_lesen(dritte)
|
||||
pruefe(meta3.get("art") == "korrektur", "art = korrektur")
|
||||
pruefe(meta3.get("ersetzt") == "2026-001", "ersetzt zeigt auf 2026-001")
|
||||
pruefe(meta3.get("storno_nummer") == "2026-002", "storno_nummer zeigt auf 2026-002")
|
||||
pruefe(os.path.exists(erste_pdf), "die urspruengliche Rechnung liegt unangetastet da")
|
||||
gesamt3 = float(meta3["gesamt"])
|
||||
print(f" Rechnung 003: gesamt {gesamt3:.2f}")
|
||||
|
||||
# ---- 4. was sieht der Steuerrechner? ------------------------------------
|
||||
sys.path.insert(0, r"C:\claude\steuerrechner")
|
||||
import einnahmen # noqa: E402
|
||||
liste, kaputt = einnahmen.scanne(ordner, {"ust_saetze": [7, 19]})
|
||||
pruefe(len(liste) == 3, f"Steuerrechner liest 3 Belege (liest {len(liste)})")
|
||||
pruefe(not kaputt, f"kein Beleg uebersprungen ({kaputt})")
|
||||
pruefe(not any(e.unsicher for e in liste),
|
||||
"kein Beleg als unsicher markiert")
|
||||
summe = round(sum(e.gesamt for e in liste), 2)
|
||||
netto = round(sum(e.netto for e in liste), 2)
|
||||
ust = round(sum(e.ust for e in liste), 2)
|
||||
pruefe(abs(summe - gesamt3) < 0.005,
|
||||
f"Jahressumme = nur die berichtigte Rechnung ({summe:.2f} vs {gesamt3:.2f})")
|
||||
print(f" Summe {summe:.2f} | Netto {netto:.2f} | USt {ust:.2f}")
|
||||
|
||||
# ---- 5. Rueckweg: Storno anlegen und verwerfen --------------------------
|
||||
antworten["Storno wirklich"] = False
|
||||
offen = [d for d in A.rechnungen_im_ordner(ordner)
|
||||
if d.get("art") == "korrektur"]
|
||||
f._storno_anlegen(offen[0], ordner)
|
||||
storno2 = f.storno_stand["pfad"]
|
||||
zaehler_mit_storno = f.cfg["rechnung_zaehler"]
|
||||
pruefe(os.path.exists(storno2), "zweites Storno geschrieben")
|
||||
f.anzahl_vars[erste].set("2")
|
||||
f.update()
|
||||
f.pdf_erstellen() # -> Frage wird mit Nein beantwortet
|
||||
pruefe(not os.path.exists(storno2), "verworfenes Storno-PDF ist geloescht")
|
||||
pruefe(f.storno_stand is None, "Zwischenspeicher geleert")
|
||||
pruefe(f.cfg["rechnung_zaehler"] == zaehler_mit_storno - 1,
|
||||
f"Zaehler zurueckgesetzt ({f.cfg['rechnung_zaehler']} statt {zaehler_mit_storno})")
|
||||
pruefe(os.path.exists(dritte), "die Rechnung 2026-003 steht unveraendert da")
|
||||
pruefe(len([n for n in os.listdir(ordner) if n.endswith('.pdf')]) == 3,
|
||||
"es liegen wieder genau 3 PDFs im Ordner")
|
||||
|
||||
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue