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
306 lines
14 KiB
Python
306 lines
14 KiB
Python
# -*- 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)
|