rechnungstool/korrektur.py
TheMockTv 2277a12e0f Zwei getrennte Knoepfe und app.py in Module aufgeteilt
Knoepfe: "Storno" und "Berichtigen" setzen den Weg schon beim Oeffnen fest,
das Popup fragt dann nur noch die Nummer. Vorher entschied ein zweiter Knopf
im Popup - der ist raus.

app.py war 1802 Zeilen, jetzt 672. Neu daneben:
  helfer.py        Zahlen, Datum, Namen, Pruefungen        (87)
  bestand.py       was im Rechnungsordner liegt, nur lesen (171)
  storno.py        den spiegelbildlichen Storno rechnen     (57)
  einzelinstanz.py nur ein Fenster zulassen                 (90)
  theme.py         Farben nach Windows hell/dunkel         (120)
  korrektur.py     Storno- und Berichtigungsweg            (306)
  einstellungen.py die Dialoge aus dem Menue               (358)

Die Oberflaeche bleibt eine Klasse - die Teile sind Mixins, damit kein
einziger self-Zugriff umgeschrieben werden musste. Kein Modul importiert
app.py zurueck, keine ungenutzten Importe mehr. Die Pruefstaende holen die
Funktionen jetzt aus dem Modul, dem sie gehoeren.

pruef_storno.py 100 gruen, pruef_nummern.py 7 gruen, Fenster nach der
Aufteilung gestartet und angesehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
2026-09-02 19:41:00 +02:00

306 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""Storno- und Berichtigungsweg.
Zwei Wege, ein Popup-Bauplan:
Storno - falsche Betraege. Stornorechnung mit eigener neuer Nummer,
danach die berichtigte Rechnung. Zwei Nummern verbraucht.
Berichtigung - nur die Kundenangaben stimmen nicht. Die Rechnung behaelt
Nummer, Datum und Betraege (§ 31 Abs. 5 UStDV), es wird nur
ein berichtigtes Blatt nachgereicht. Keine Nummer verbraucht.
Die alte Rechnung wird in beiden Faellen NIE angefasst (§ 146 Abs. 4 AO).
"""
import datetime
import os
import tkinter as tk
from tkinter import ttk, messagebox
import berechnung
import config
import pdf_renderer
from bestand import kundendaten_ermitteln, naechste_freie_nummer, rechnungen_im_ordner
from helfer import nummer_normalisieren, safe_filename
from storno import storno_rechnung_bauen
class KorrekturMixin:
"""Storno und Berichtigung. Erwartet die Felder der RechnungsApp."""
# ------------------------------------------------------------- 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")
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.
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):
"""Knopf „Storno“ – setzt den Weg auf Storno und öffnet das Popup."""
self._dlg_korrektur("storno")
def dlg_berichtigen(self):
"""Knopf „Berichtigen“ – setzt den Weg auf Berichtigung und öffnet das Popup."""
self._dlg_korrektur("berichtigung")
def _dlg_korrektur(self, modus):
"""Popup für beide Wege. Welcher gilt, steht schon beim Öffnen fest.
"storno" -> Storno-PDF schreiben, danach die berichtigte Rechnung
"berichtigung" -> nur die Kundenangaben, Rechnung behält ihre Nummer
"""
storno = modus == "storno"
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
if self.berichtigung_stand:
messagebox.showinfo(
"Berichtigung läuft schon",
f"Die Rechnung {self.berichtigung_stand['zu']} liegt gerade zum Berichtigen "
"im Formular.\n\nErst „PDF erstellen“ drücken oder das Formular zurücksetzen.")
return
out_dir = config.ensure_output_dir(self.cfg)
alle = rechnungen_im_ordner(out_dir)
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") not in ("storno", "berichtigung")
and nummer_normalisieren(d.get("rechnungsnummer")) not in stornierte]
if not offen:
messagebox.showwarning(
"Nichts zu korrigieren",
f"Im Ordner\n{out_dir}\nliegt keine Rechnung, die dafür in Frage kommt.")
return
d = tk.Toplevel(self)
d.title("Rechnung stornieren" if storno else "Rechnung berichtigen")
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", wraplength=470, text=(
"Für falsche Beträge oder Leistungen. Die alte Rechnung bleibt unverändert "
"liegen – sie darf weder geändert noch gelöscht werden. Es entsteht eine "
"Stornorechnung mit eigener neuer Nummer, die sich auf sie bezieht, und danach "
"die berichtigte Rechnung."
if storno else
"Wenn nur die Angaben zum Kunden nicht stimmen. Die Rechnung behält Nummer, "
"Datum und Beträge und bleibt gültig – es wird nur ein berichtigtes Blatt "
"nachgereicht (§ 31 Abs. 5 UStDV). Für falsche Beträge ist das der falsche Weg, "
"dafür gibt es den Storno.")).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.
ent = ttk.Entry(frm, width=20)
ent.grid(row=1, column=1, sticky="w", pady=3, padx=(8, 0))
ent.focus_set()
v_info = tk.StringVar(value="Nummer eintragen – das Programm sucht die Rechnung.")
ttk.Label(frm, textvariable=v_info, justify="left", wraplength=430).grid(
row=2, column=0, columnspan=2, sticky="w", pady=(10, 0))
def suche():
"""(Rechnung, Meldung) zur eingetippten Nummer. Rechnung=None -> Meldung sagt warum."""
nr = nummer_normalisieren(ent.get())
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
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":
return None, f"{nr} ist selbst eine Stornorechnung."
if nummer_normalisieren(treffer.get("rechnungsnummer")) in stornierte:
return None, f"{nr} wurde bereits storniert – ein zweites Mal geht nicht."
gesamt = float(treffer.get("gesamt") or 0)
return treffer, (
f"Rechnung {treffer.get('rechnungsnummer')} vom {treffer.get('datum','')}"
f" {treffer.get('nachname','')}\n"
f"Rechnungsbetrag {berechnung.eur(treffer.get('zwischensumme') or 0)}, "
f"Gesamt {berechnung.eur(gesamt)}\n"
f"Der Storno lautet über {berechnung.eur(-abs(gesamt))}.")
def vorschau(*_a):
v_info.set(suche()[1])
ent.bind("<KeyRelease>", vorschau)
def anlegen(*_a):
meta, meldung = suche()
if not meta:
v_info.set(meldung)
return
d.destroy()
if storno:
self._storno_anlegen(meta, out_dir)
else:
self._berichtigung_starten(meta, out_dir)
ent.bind("<Return>", anlegen)
btns = ttk.Frame(frm)
btns.grid(row=3, column=0, columnspan=2, pady=(14, 0), sticky="e")
ttk.Button(btns, text="Storno erstellen" if storno else "Rechnung laden",
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")
# Der Storno geht an denselben Empfaenger wie die alte Rechnung - was in
# deren Kenndaten fehlt, wird aus ihrem Adressblock geholt.
kunde, woher = kundendaten_ermitteln(meta)
namenszeile = " ".join(t for t in (kunde.get("vorname", ""),
kunde.get("nachname", "")) if t)
kunde_zeilen = [kunde.get("anrede", ""), namenszeile, kunde.get("zusatz", ""),
kunde.get("strasse", ""), kunde.get("plzort", "")]
kopf = {
"rechnungsnummer": nummer,
"datum": heute,
"anreise": "", "abreise": "", "naechte": "",
"kunde_zeilen": kunde_zeilen,
"nachname": kunde.get("nachname", ""),
"vorname": kunde.get("vorname", ""),
"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 = (f"\n\nZum Kunden: {fehlend}.\nBitte oben prüfen und ergänzen – "
"ohne vollständige Anschrift wird die Rechnung nicht geschrieben.")
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.
"""
kunde, woher = kundendaten_ermitteln(meta)
self.k_vorname.set(kunde.get("vorname", ""))
self.k_name.set(kunde.get("nachname", ""))
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()
hinweise = []
if woher:
hinweise.append("aus dem Rechnungstext gelesen: " + ", ".join(woher))
fehlt = [feld for feld, wert in (("Straße", self.k_strasse.get()),
("PLZ / Ort", self.k_plzort.get()),
("Nachname", self.k_name.get()))
if not wert]
if fehlt:
hinweise.append("steht in der alten Rechnung nicht drin: " + ", ".join(fehlt))
if not (meta.get("positionen") or []):
hinweise.append("Leistungen konnten nicht übernommen werden")
return "; ".join(hinweise)