From d3962ec8d1a9ca2b798f8ea6a95620f015649fff Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Wed, 2 Sep 2026 19:32:24 +0200 Subject: [PATCH] Berichtigen: zweiter Knopf im Storno-Popup, alles haengt an einem Flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_.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 Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X --- app.py | 150 +++++++++++++++++++++++++++++++++++++++--------- pdf_renderer.py | 37 +++++++++++- pruef_storno.py | 63 +++++++++++++++++++- 3 files changed, 220 insertions(+), 30 deletions(-) diff --git a/app.py b/app.py index e9976db..8fc8ffe 100644 --- a/app.py +++ b/app.py @@ -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("", 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. diff --git a/pdf_renderer.py b/pdf_renderer.py index 65c4db3..ec5ea58 100644 --- a/pdf_renderer.py +++ b/pdf_renderer.py @@ -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 nicht 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, diff --git a/pruef_storno.py b/pruef_storno.py index f075e73..eef1327 100644 --- a/pruef_storno.py +++ b/pruef_storno.py @@ -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}")