Berichtigen: zweiter Knopf im Storno-Popup, alles haengt an einem Flag

Fuer Rechnungen, bei denen nur die Kundenangaben falsch sind - falsche
Betraege bleiben der Storno-Weg.

- Kein eigener Dialog: das Storno-Popup hat jetzt zwei Knoepfe. "Nur
  berichtigen" laedt die Rechnung ins Formular, sperrt die Nummer und setzt
  ein Flag; "PDF erstellen" schreibt daraus das berichtigte Blatt.
- Die Rechnung behaelt Nummer, Datum und Betraege; dazu kommt "Berichtigt am".
  Der Zaehler bleibt stehen - das Blatt verbraucht keine Nummer.
- Ueberschrift "Berichtigte Rechnung", darunter die vollstaendige Rechnung,
  darunter der Bezug nach § 31 Abs. 5 UStDV und der Satz, dass es KEINE
  zusaetzliche Leistung und keine zweite Rechnung ist. Ohne den kann ein
  zweites Blatt mit ausgewiesener USt die Steuer nach § 14c UStG ein zweites
  Mal ausloesen.
- Kenndaten des Blattes tragen Nullen und "art": "berichtigung". Damit zaehlt
  ein Programm, das die Art nicht kennt, 0,00 € statt den Umsatz doppelt; der
  Steuerrechner ueberspringt es ganz (einnahmen.py).
- Dateiname "Berichtigte Rechnung_JJJJ-MM-TT_<Nummer>.pdf".

pruef_storno.py: 100 Pruefungen gruen, darunter der ganze Berichtigungsweg
inklusive Text auf dem Blatt und Gegenprobe mit dem Steuerrechner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
This commit is contained in:
TheMockTv 2026-09-02 19:32:24 +02:00
parent abb462725f
commit d3962ec8d1
3 changed files with 220 additions and 30 deletions

150
app.py
View file

@ -179,6 +179,33 @@ def adresse_aus_pdf(pfad):
return daten
def kunde_maengel(kunde):
"""Beanstandungen an Name und Anschrift; leere Liste = in Ordnung.
Prueft nicht nur "irgendwas eingetippt": ein Feld aus Leerzeichen,
Bindestrichen oder einem einzelnen Zeichen ist kein Name und keine
Anschrift. Vier Ziffern reichen, damit auch oesterreichische und
niederlaendische Postleitzahlen durchgehen.
"""
maengel = []
for feld, schluessel in (("Vorname", "vorname"),
("Nachname / Firma", "nachname"),
("Straße / Nr.", "strasse")):
wert = saeubern(kunde.get(schluessel))
if not wert:
maengel.append(f"{feld}: ist leer")
elif buchstaben(wert) < 2:
maengel.append(f"{feld}: „{wert}“ ergibt keinen Namen")
ort = saeubern(kunde.get("plzort"))
if not ort:
maengel.append("PLZ / Ort: ist leer")
elif ziffern(ort) < 4 or buchstaben(ort) < 2:
maengel.append(f"PLZ / Ort: „{ort}“ – bitte Postleitzahl UND Ort, "
"z. B. 04416 Markkleeberg")
return maengel
def name_aufteilen(ganz):
"""'Andres Schäfer' -> ('Andres', 'Schäfer'); ein einzelnes Wort wird Nachname."""
teile = str(ganz or "").split()
@ -432,6 +459,9 @@ class RechnungsApp(tk.Tk):
# Zwischenspeicher: geschriebenes Storno, das noch bestaetigt werden muss.
# Solange es hier liegt, gehoert zur naechsten PDF die Storno-Kette.
self.storno_stand = None
# Zwischenspeicher fuer eine reine Berichtigung (§ 31 Abs. 5 UStDV):
# die naechste PDF ist dann ein berichtigtes Blatt zu dieser Rechnung.
self.berichtigung_stand = None
self._apply_theme() # Farben nach Windows hell/dunkel
self._build_menu()
@ -825,7 +855,8 @@ 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="Stornieren / berichtigen", command=self.dlg_storno,
width=22).pack(side="left", padx=4)
ttk.Button(links, text="Zurücksetzen", command=self.reset_formular, width=14).pack(side="left", padx=4)
# --------------------------------------------------------- Berechnung
@ -886,10 +917,33 @@ class RechnungsApp(tk.Tk):
f"Storno {stand['nummer']} zu Rechnung {stand['zu']} liegt bereit.\n"
f"Beim Erstellen wird noch einmal gefragt.")
self.nummer_entry.configure(state="readonly")
elif self.berichtigung_stand:
self.storno_hinweis.set(
f"Berichtigung der Rechnung {self.berichtigung_stand['zu']}.\n"
"Nummer und Beträge bleiben, nur die Kundenangaben ändern sich.")
self.nummer_entry.configure(state="readonly")
else:
self.storno_hinweis.set("")
self.nummer_entry.configure(state="normal")
def _berichtigung_starten(self, meta, out_dir):
"""Rechnung ins Formular holen; "PDF erstellen" schreibt das berichtigte Blatt."""
nummer = str(meta.get("rechnungsnummer", ""))
fehlend = self._formular_aus_meta(meta)
self.berichtigung_stand = {"zu": nummer, "datum": str(meta.get("datum") or ""),
"meta": meta}
# Die Rechnung behaelt ihre Nummer - genau das ist der Unterschied zum Storno.
self.r_nummer.set(nummer)
self.r_datum.set(str(meta.get("datum") or self.r_datum.get()))
self._storno_anzeigen()
hinweis = f"\n\nZum Kunden: {fehlend}." if fehlend else ""
messagebox.showinfo(
"Rechnung geladen",
f"Rechnung {nummer} vom {meta.get('datum','')} ist im Formular.\n\n"
"Jetzt die Kundenangaben richtigstellen und \"PDF erstellen\" drücken – "
"es entsteht ein berichtigtes Blatt zu dieser Rechnung. Die alte Rechnung "
f"bleibt liegen.{hinweis}")
def _storno_verwerfen(self, grund=""):
"""Storno rückgängig: PDF löschen, Zähler zurück, Zwischenspeicher leeren.
@ -930,7 +984,7 @@ class RechnungsApp(tk.Tk):
stornierte = {nummer_normalisieren(d.get("storno_zu"))
for d in alle if d.get("art") == "storno"}
offen = [d for d in alle
if d.get("art") != "storno"
if d.get("art") not in ("storno", "berichtigung")
and nummer_normalisieren(d.get("rechnungsnummer")) not in stornierte]
if not offen:
messagebox.showwarning(
@ -946,10 +1000,14 @@ class RechnungsApp(tk.Tk):
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(
ttk.Label(frm, justify="left", wraplength=470, text=
"Die alte Rechnung bleibt unverändert liegen – sie darf weder geändert noch "
"gelöscht werden.\n\n"
"Storno erstellen: falsche Beträge oder Leistungen. Es entsteht eine "
"Stornorechnung mit eigener neuer Nummer und danach die berichtigte Rechnung.\n"
"Nur berichtigen: es stimmen nur die Angaben zum Kunden nicht. Die Rechnung "
"behält Nummer und Beträge, es wird nur ein berichtigtes Blatt nachgereicht "
"(§ 31 Abs. 5 UStDV).").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)
@ -970,7 +1028,8 @@ class RechnungsApp(tk.Tk):
if not nr:
return None, "Nummer eintragen – das Programm sucht die Rechnung."
treffer = next((x for x in alle
if nummer_normalisieren(x.get("rechnungsnummer")) == nr), None)
if nummer_normalisieren(x.get("rechnungsnummer")) == nr
and x.get("art") != "berichtigung"), None)
if not treffer:
return None, f"Zu {nr} liegt in\n{out_dir}\nkeine Rechnung."
if treffer.get("art") == "storno":
@ -998,9 +1057,18 @@ class RechnungsApp(tk.Tk):
self._storno_anlegen(meta, out_dir)
ent.bind("<Return>", anlegen)
def berichtigen():
meta, meldung = suche()
if not meta:
v_info.set(meldung)
return
d.destroy()
self._berichtigung_starten(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="Nur berichtigen", command=berichtigen).pack(side="right", padx=4)
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
frm.columnconfigure(1, weight=1)
@ -1112,31 +1180,15 @@ class RechnungsApp(tk.Tk):
def kunde_pruefen(self):
"""Beanstandungen an den Kundenfeldern; leere Liste = in Ordnung.
Prueft nicht nur "irgendwas eingetippt": ein Feld aus Leerzeichen,
Bindestrichen oder einem einzelnen Zeichen ist kein Name und keine
Anschrift. Die Felder werden dabei gesaeubert.
Saeubert die Felder und prueft sie mit kunde_maengel().
"""
for var in (self.k_anrede, self.k_vorname, self.k_name, self.k_zusatz,
self.k_strasse, self.k_plzort):
var.set(saeubern(var.get()))
maengel = []
for feld, var in (("Vorname", self.k_vorname),
("Nachname / Firma", self.k_name),
("Straße / Nr.", self.k_strasse)):
wert = var.get()
if not wert:
maengel.append(f"{feld}: ist leer")
elif buchstaben(wert) < 2:
maengel.append(f"{feld}: „{wert}“ ergibt keinen Namen")
ort = self.k_plzort.get()
if not ort:
maengel.append("PLZ / Ort: ist leer")
elif ziffern(ort) < 4 or buchstaben(ort) < 2:
maengel.append(f"PLZ / Ort: „{ort}“ – bitte Postleitzahl UND Ort, "
"z. B. 04416 Markkleeberg")
return maengel
return kunde_maengel({"vorname": self.k_vorname.get(),
"nachname": self.k_name.get(),
"strasse": self.k_strasse.get(),
"plzort": self.k_plzort.get()})
def pdf_erstellen(self):
r = self.recalc()
@ -1219,6 +1271,48 @@ class RechnungsApp(tk.Tk):
kopf["storno_nummer"] = stand["nummer"]
out_dir = config.ensure_output_dir(self.cfg)
# ---- reine Berichtigung: gleiche Nummer, gleiche Betraege, neues Blatt ----
ber = self.berichtigung_stand
if ber:
kopf["art"] = "berichtigung"
kopf["berichtigt_zu"] = ber["zu"]
kopf["berichtigt_am"] = datetime.date.today().strftime("%d.%m.%Y")
if not messagebox.askyesno(
"Berichtigte Rechnung erstellen?",
f"Zu Rechnung {ber['zu']} vom {ber['datum']} wird ein berichtigtes "
"Blatt geschrieben.\n\n"
"Nummer, Leistungen und Beträge bleiben unverändert – die alte "
"Rechnung bleibt liegen. Das Blatt gehört zum Gast: erst wenn es "
"ihm zugegangen ist, ist die Rechnung berichtigt.\n\nJetzt erstellen?",
default="yes"):
return
# Dateiname: "Berichtigte Rechnung", Datum, Nummer der Rechnung. Das
# Blatt verbraucht keine eigene Nummer, das Datum haelt mehrere
# Berichtigungen zur selben Rechnung auseinander (sortierbar).
pfad = os.path.join(
out_dir, f"Berichtigte Rechnung_{datetime.date.today():%Y-%m-%d}"
f"_{safe_filename(ber['zu'])}.pdf")
if os.path.exists(pfad) and not messagebox.askyesno(
"Berichtigung von heute ersetzen?",
f"Zu Rechnung {ber['zu']} gibt es von heute schon eine Berichtigung:\n"
f"{os.path.basename(pfad)}\n\nDurch die neue ersetzen?"):
return
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"Berichtigung konnte nicht erstellt werden:\n{e}")
print("[berichtigung] FEHLER:", e)
return
print(f"[berichtigung] erstellt -> {pfad}")
self.berichtigung_stand = None
self._storno_anzeigen()
if messagebox.askyesno("Fertig",
f"Berichtigte Rechnung zu {ber['zu']}:\n{pfad}\n\n"
"Jetzt öffnen (zum Drucken)?"):
self._oeffne_datei(pfad)
return
# 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.

View file

@ -223,6 +223,11 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
info_rows.append(("Rechnung vom:", kopf["storno_datum"]))
elif art == "korrektur" and kopf.get("ersetzt"):
info_rows.append(("Ersetzt Rechnung:", kopf.get("ersetzt", "")))
elif art == "berichtigung":
# Die Rechnung behaelt Nummer und Datum - dazu kommt der Tag der
# Berichtigung, damit man die Reihenfolge sieht.
if kopf.get("berichtigt_am"):
info_rows.append(("Berichtigt am:", kopf["berichtigt_am"]))
if kopf.get("anreise"):
info_rows.append(("Anreise:", kopf.get("anreise", "")))
if kopf.get("abreise"):
@ -252,7 +257,8 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
# in der Ueberschrift. Der Zusatz sagt nur noch, fuer wen das Blatt ist.
# 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 = {"storno": "Stornorechnung",
"berichtigung": "Berichtigte Rechnung"}.get(art, "Rechnungsübersicht")
titel += " (für das Amt)" if gesamtrechnung_betont else ""
story.append(Paragraph(titel, st["h"]))
@ -382,6 +388,19 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
"Bereits gezahlte Beträge werden erstattet oder mit der berichtigten "
"Rechnung verrechnet.", st["dank"]))
elif art == "berichtigung":
# Der Satz ist kein Beiwerk: ein zweites Blatt mit ausgewiesener USt,
# das nicht klar als Berichtigung erkennbar ist, kann die Steuer ein
# zweites Mal ausloesen (§ 14c UStG).
block.append(Paragraph(
f"Dieses Blatt berichtigt die Angaben zur Rechnung "
f"{kopf.get('berichtigt_zu','')} vom {kopf.get('datum','')} nach "
"§ 31 Abs. 5 UStDV. Leistungen, Beträge und Umsatzsteuer sind "
"unverändert – es handelt sich <b>nicht</b> um eine zusätzliche "
"Leistung und nicht um eine zweite Rechnung. Die Rechnung behält "
"ihre Nummer.", st["dank"]))
if texte.get("dank1"):
block.append(Paragraph(texte["dank1"], st["dank"]))
else:
if art == "korrektur" and kopf.get("ersetzt"):
hinweis = f"Diese Rechnung ersetzt die Rechnung {kopf['ersetzt']}"
@ -422,6 +441,22 @@ def _kenndaten(rechnung, kopf):
Steuerrechner summieren dadurch von allein richtig (+100 -100 +120 = 120).
"""
aufschlag = rechnung.aufschlaege[0] if rechnung.aufschlaege else None
if kopf.get("art") == "berichtigung":
# Das berichtigte Blatt zeigt dieselben Betraege wie die Rechnung, ist aber
# KEINE zweite Einnahme. Es traegt deshalb Nullen: ein Programm, das "art"
# nicht kennt, zaehlt dann 0,00 € statt den Umsatz ein zweites Mal.
return json.dumps({
"quelle": "rechnungstool", "version": 3, "art": "berichtigung",
"rechnungsnummer": kopf.get("berichtigt_zu", ""),
"berichtigt_zu": kopf.get("berichtigt_zu", ""),
"berichtigt_am": kopf.get("berichtigt_am", ""),
"datum": kopf.get("datum", ""),
"nachname": kopf.get("nachname", ""), "vorname": kopf.get("vorname", ""),
"zwischensumme": 0.0, "gesamt": 0.0,
"steuer_satz": 0.0, "steuer_betrag": 0.0,
"netto_gesamt": 0.0, "ust_gesamt": 0.0, "ust_bloecke": [],
"kunde": kopf.get("kunde", {}),
}, ensure_ascii=False)
daten = {
"quelle": "rechnungstool",
"version": 3,

View file

@ -290,7 +290,9 @@ import pdf_renderer as P # noqa: E402
def alt_rechnung(name, dateiname, kunde_zeilen):
"""Schreibt eine Rechnung wie die alte Fassung: ohne kunde/positionen in den Kenndaten."""
rr = B.berechne(f.cfg["katalog"], {erste: {"anzahl": 3, "personen": 0}}, f.cfg)
kopf = {"rechnungsnummer": "2026-900", "datum": "12.08.2026", "anreise": "", "abreise": "",
# Nummer aus dem Dateinamen, damit Kenndaten und Datei zusammenpassen
nummer = dateiname.split("_")[1].replace(".pdf", "")
kopf = {"rechnungsnummer": nummer, "datum": "12.08.2026", "anreise": "", "abreise": "",
"naechte": "", "kunde_zeilen": kunde_zeilen, "nachname": "", "vorname": name}
ziel = os.path.join(ordner, dateiname)
P.render_rechnung(ziel, f.cfg, rr, kopf)
@ -328,6 +330,65 @@ pruefe("aus dem Rechnungstext gelesen" in meldung, f"Herkunft wird gesagt ({meld
for name in ("Rechnung_2026-900.pdf", "Rechnung_2026-901.pdf"):
os.remove(os.path.join(ordner, name))
# ---- 11. Berichtigung: gleicher Dialog, zweiter Knopf -------------------
alt3 = alt_rechnung("Andres Schäfer", "Rechnung_2026-910.pdf", ["", "Andres Schäfer", "", "", ""])
meta3 = A.kenndaten_lesen(alt3)
vorher = sorted(os.listdir(ordner))
zaehler_vor_berichtigung = f.cfg["rechnung_zaehler"]
f._berichtigung_starten(meta3, ordner)
pruefe(f.berichtigung_stand is not None, "Flag fuer die Berichtigung gesetzt")
pruefe(f.r_nummer.get() == "2026-910", f"Rechnung behaelt ihre Nummer ({f.r_nummer.get()})")
pruefe(str(f.nummer_entry.cget("state")) == "readonly", "Nummernfeld gesperrt")
pruefe(f.k_vorname.get() == "Andres" and f.k_name.get() == "Schäfer",
"Name aufgeteilt im Formular")
# ohne Anschrift wird auch hier nichts geschrieben
warnungen.clear()
f.anzahl_vars[erste].set("3")
f.update()
f.pdf_erstellen()
pruefe(warnungen and "Kundenangaben" in warnungen[0], "Pflichtpruefung gilt auch hier")
pruefe(sorted(os.listdir(ordner)) == vorher, "nichts geschrieben")
# Anschrift ergaenzen und erstellen
f.k_strasse.set("Bornaer Chaussee 36")
f.k_plzort.set("04416 Markkleeberg")
antworten["Berichtigte Rechnung erstellen"] = True
f.update()
f.pdf_erstellen()
import datetime as _dt
ziel = os.path.join(ordner, f"Berichtigte Rechnung_{_dt.date.today():%Y-%m-%d}_2026-910.pdf")
pruefe(os.path.exists(ziel), f"Blatt geschrieben ({os.path.basename(ziel)})")
pruefe(f.berichtigung_stand is None, "Flag nach dem Erstellen wieder weg")
pruefe(str(f.nummer_entry.cget("state")) == "normal", "Nummernfeld wieder frei")
pruefe(os.path.exists(alt3), "die berichtigte Rechnung liegt unangetastet da")
pruefe(f.cfg["rechnung_zaehler"] == zaehler_vor_berichtigung,
f"Zaehler unveraendert - die Berichtigung verbraucht keine Nummer "
f"({f.cfg['rechnung_zaehler']} vs {zaehler_vor_berichtigung})")
meta_b = A.kenndaten_lesen(ziel)
pruefe(meta_b.get("art") == "berichtigung", "art = berichtigung")
pruefe(meta_b.get("berichtigt_zu") == "2026-910", "Bezug auf die Rechnung")
pruefe(float(meta_b.get("gesamt") or 0) == 0.0,
f"Kenndaten tragen 0 - keine zweite Einnahme ({meta_b.get('gesamt')})")
from pypdf import PdfReader as _PR # noqa: E402
text = _PR(ziel).pages[0].extract_text()
pruefe("Berichtigte Rechnung" in text, "Ueberschrift steht auf dem Blatt")
pruefe("2026-910" in text, "Rechnungsnummer steht drauf")
pruefe("nicht" in text and "31 Abs. 5 UStDV" in text, "der Schutzsatz steht drauf")
pruefe("04416 Markkleeberg" in text, "berichtigte Anschrift steht drauf")
liste3, kaputt3 = einnahmen.scanne(ordner, {"ust_saetze": [7, 19]})
pruefe(not any("Berichtigte" in os.path.basename(e.pdf_pfad) for e in liste3),
"Steuerrechner zaehlt das Blatt nicht als Einnahme")
pruefe(not any("Berichtigte" in os.path.basename(p) for p, _ in kaputt3),
f"und meldet es nicht als Fehler ({kaputt3})")
for name in ("Rechnung_2026-910.pdf", os.path.basename(ziel)):
os.remove(os.path.join(ordner, name))
f.destroy()
print("---")
print(f"Ordner: {ordner}")