rechnungstool/korrektur.py
TheMockTv 2b09d15cd0 Aufraeumen nach der Pruefkette: doppelter Code an eine Stelle
Die Runde vom 07.09.2026 hat neben den zwei echten Fehlern eine Reihe
Wiederholungen und stumme Stellen gemeldet. Abgearbeitet in Etappen, jede fuer
sich pruefbar - nach jeder liefen beide Pruefketten gruen.

* Menueleiste: der Wechsel von einem Titel zum anderen brauchte ZWEI Klicks.
  Der grab_set der offenen Liste schluckt den ersten, die <Button-1>-Bindung
  des Titels feuert gar nicht. Das native tk.Menu, das diese Leiste ersetzt
  hat, wechselte schon beim blossen Ueberfahren - zwei Klicks waren also eine
  Verschlechterung gegenueber dem, was vorher da war. Die Klappliste wird
  ausserdem jetzt wie jede andere Position ueber theme.auf_bildschirm()
  begrenzt; am unteren Fensterrand lief sie vorher hinaus.
  Neu: pruef_menueleiste.py misst beides ueber echte Klickereignisse.

* %LOCALAPPDATA%/ravokk wurde an drei Stellen einzeln ausgerechnet. Jetzt
  fragen alle gemeinsam.standard_ordner(). Laufen die je auseinander, fuehren
  die beiden Programme zwei Nummernbuecher - und das faellt erst auf, wenn
  eine Rechnungsnummer zum zweiten Mal vergeben ist (§ 14 Abs. 4 Nr. 4 UStG).

* pruefe() stand 35-mal im Quelltext, in sechs Fassungen -> pruefhelfer.py.
  Die zwei Ausreisser sind mitgezogen: pruef_bilder.py schrieb ein eigenes
  Format, pruef_storno_verrechnet.py zaehlte in einer Zahl und konnte am Ende
  nicht sagen, WAS fehlschlug.

* dlg_darstellung() und die drei Handgriffe des Umschaltens standen in beiden
  app.py fast wortgleich -> ThemeMixin in theme.py. Beide trugen inzwischen
  denselben langen Kommentar zu demselben Fehler; das war der Beweis, dass es
  eine Sache ist. Programmspezifisch bleiben der Hinweistext im Dialog (R31)
  und die Frage, ob die Einstellungen ausdruecklich gespeichert werden muessen.

* pruef_gemeinsam_automatisch.py sagte im Text "genau ein Buch" und prueft
  ">= 1" mit einem any() - eine Pruefung, die nicht rot werden kann. Jetzt
  == 1, und der Dateiname wird mitgeprueft.
  Dazu neu: die neun Dateien, die im Kopf zusagen, sie laegen in beiden
  Programmen gleich, werden byteweise verglichen. Bei hinweis.py stimmte die
  Zusage seit dem 06.09. nicht mehr - gleicher Inhalt, CRLF gegen LF.

* .gitattributes, damit die Zeilenenden nicht von der Maschine abhaengen.
  Ohne das meldet genau diese Pruefung nach einem frischen Checkout einen
  Unterschied, den es im Repository gar nicht gibt.

* Sechs stumme "except OSError" sagen jetzt, warum sie schweigen duerfen, und
  einer meldet statt zu schweigen: schlaegt die Uebernahme einer alten
  config.json fehl, faengt das Programm ohne Firmendaten, Katalog und Zaehler
  neu an - und der Erfolgsfall schrieb eine Zeile, der Fehlerfall nicht. Die
  24 stummen tk.TclError bleiben: dort wird ein Widget angefasst, das gerade
  zerstoert wurde, und eine Meldung waere Rauschen.

* Tote Parameter und Variablen aus dem Umbau des Vortags: symbol_setzen(dunkel=),
  kachel(grund=, radius=), mit_trennlinie, sechs x/y-Berechnungen, die von
  theme.mittig() sofort ueberschrieben wurden, vier lokale "import ctypes".

39 Pruefstaende, 0 rot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 18:39:03 +02:00

723 lines
36 KiB
Python
Raw Permalink 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 verbietet
genau genommen nur Aenderungen, bei denen der urspruengliche Inhalt nicht mehr
feststellbar ist - ein protokollierter Storno-Vermerk waere also erlaubt. Dieses
Programm verzichtet trotzdem darauf: ein unberuehrter Ursprungsbeleg ist im
Pruefungsfall die einfachere Geschichte. Korrigiert wird ueber ein eigenes
Dokument - so sieht es das Gesetz selbst vor (§ 14 Abs. 6 Nr. 5 UStG,
§ 31 Abs. 5 UStDV).
"""
import datetime
import os
import tkinter as tk
from tkinter import ttk, messagebox
import berechnung
import theme
import config
import pdf_renderer
import berechnung
from bestand import (kundendaten_ermitteln, naechste_freie_nummer,
naechste_freie_nummer_nach, rechnungen_im_ordner)
from helfer import kunde_maengel, nummer_normalisieren, parse_num, 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 ""),
"vorgang": str(meta.get("vorgang") or nummer),
"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 ""
self.melden_info(
"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
# Abbrechen, NICHT weiterlaufen: sonst liegt das Storno noch im Ordner,
# der Zähler ist zurückgedreht und die Rechnung gilt als storniert,
# obwohl der Nutzer sie behalten wollte.
self.melden_fehler(
"Storno konnte nicht gelöscht werden",
f"Die Datei\n{stand['pfad']}\nließ sich nicht löschen:\n{e}\n\n"
"Ist sie noch in einem PDF-Betrachter offen? Bitte dort schließen "
"und noch einmal versuchen. Es wurde nichts verändert.")
return
self.cfg["rechnung_jahr"] = stand["jahr_vorher"]
self.cfg["rechnung_zaehler"] = stand["zaehler_vorher"]
try:
config.save_config(self.cfg)
except OSError as e: # noqa: BLE001
self.melden_warnung(
"Zähler nicht gespeichert",
f"Die Einstellungen ließen sich nicht schreiben:\n{e}\n\n"
"Bitte im Menü unter „Rechnungsnummer…“ prüfen.")
# Die Nummer ist wieder frei - auch im gemeinsamen Buch, sonst
# ueberspringt das Rechnungstool sie fuer immer.
if self.buch is not None and stand.get("nummer"):
try:
self.buch.entfernen(stand["nummer"])
except Exception as e: # noqa: BLE001
print("[nummernbuch] Storno nicht ausgetragen:", e)
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:
self.melden_info(
"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:
self.melden_info(
"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)
stornos = {nummer_normalisieren(d.get("storno_zu")): d
for d in alle if d.get("art") == "storno"}
stornierte = set(stornos)
# Zu welchen Rechnungen gibt es schon eine berichtigte Rechnung?
ersetzte = {nummer_normalisieren(d.get("ersetzt"))
for d in alle if d.get("art") == "korrektur"}
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:
self.melden_warnung(
"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))
# Wird nur sichtbar, wenn eine Nummer MEHRFACH im Ordner liegt. Genau das
# ist der Altbestand: solange zwei Fenster gleichzeitig laufen konnten,
# wurde dieselbe Nummer zweimal vergeben, teils als Windows-Kopie mit
# "(1)" im Namen. Dann darf das Programm nicht raten, welche gemeint ist.
self._auswahl = tk.Listbox(frm, height=4, exportselection=False,
selectbackground=self.col_sel,
selectforeground=self.col_fg,
font=("Consolas", 9), **self.feld_optionen(einfuegemarke=False))
self._auswahl.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(8, 0))
self._auswahl.grid_remove()
self._kandidaten = []
def kandidaten_zur_nummer(nr):
return [x for x in alle
if nummer_normalisieren(x.get("rechnungsnummer")) == nr
and x.get("art") != "berichtigung"]
def suche():
"""(Rechnung, Meldung) zur eingetippten Nummer. Rechnung=None -> Meldung sagt warum."""
nr = nummer_normalisieren(ent.get())
if not nr:
self._kandidaten = []
self._auswahl.grid_remove()
return None, "Nummer eintragen – das Programm sucht die Rechnung."
gefunden = kandidaten_zur_nummer(nr)
if not gefunden:
self._kandidaten = []
self._auswahl.grid_remove()
return None, f"Zu {nr} liegt in\n{out_dir}\nkeine Rechnung."
# Mehrere Belege unter derselben Nummer -> auswählen lassen.
if len(gefunden) > 1:
if gefunden != self._kandidaten:
self._kandidaten = gefunden
self._auswahl.delete(0, "end")
for x in gefunden:
self._auswahl.insert("end",
f"{x.get('datum',''):11s} {str(x.get('nachname') or '?'):16s} "
f"{berechnung.eur(x.get('gesamt') or 0):>11s} "
f"{os.path.basename(x.get('_pfad',''))}")
self._auswahl.grid()
self._auswahl.selection_clear(0, "end")
auswahl = self._auswahl.curselection()
if not auswahl:
return None, (f"⚠ Die Nummer {nr} liegt {len(gefunden)}× im Ordner – "
"das darf es nicht geben.\nBitte unten auswählen, "
"welcher Beleg gemeint ist.")
treffer = gefunden[auswahl[0]]
else:
self._kandidaten = []
self._auswahl.grid_remove()
treffer = gefunden[0]
if treffer.get("art") == "storno":
return None, f"{nr} ist selbst eine Stornorechnung."
nr_norm = nummer_normalisieren(treffer.get("rechnungsnummer"))
if nr_norm in stornierte:
st = stornos.get(nr_norm) or {}
folgt = st.get("korrektur_nummer") or ""
if nr_norm in ersetzte or not folgt:
return None, (f"{nr} wurde bereits mit {st.get('rechnungsnummer','?')} "
"storniert – ein zweites Mal geht nicht.")
# Auf dem Storno steht, welche Nummer die berichtigte Rechnung
# bekommt. Damit braucht es keinen gemerkten Zustand: fehlt sie
# noch, wird sie einfach unter dieser Nummer geschrieben.
return None, (f"{nr} ist mit {st.get('rechnungsnummer','?')} storniert. "
f"Die berichtigte Rechnung gehört unter die Nummer {folgt} – "
"die steht auch auf dem Storno-Blatt.")
gesamt = float(treffer.get("gesamt") or 0)
mehrfach = (f"⚠ Achtung: die Nummer liegt {len(gefunden)}× im Ordner.\n"
if len(gefunden) > 1 else "")
return treffer, (
mehrfach +
f"Rechnung {treffer.get('rechnungsnummer')} vom {treffer.get('datum','')}"
f" {treffer.get('nachname','')}"
+ (f" ({os.path.basename(treffer.get('_pfad',''))})" if mehrfach else "") + "\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)
self._auswahl.bind("<<ListboxSelect>>", 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=4, 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)
@staticmethod
def _passt_zusammen(alt, neu):
"""Passt die bleibende Rechnung inhaltlich zu der, die storniert wird?
Die Nummer allein sagt nichts - sie koennte auf einen ganz anderen Gast
zeigen. Verglichen werden Name, Zeitraum und Betrag, und zwar getrennt,
damit die Meldung sagt, WAS abweicht.
Gibt (schwer, leicht) zurueck: schwer = darf nicht durchgehen (anderer
Gast), leicht = Hinweise, die der Nutzer bestaetigen muss.
"""
def wort(x):
return " ".join(str(x or "").lower().split())
schwer, leicht = [], []
if wort(alt.get("nachname")) != wort(neu.get("nachname")):
schwer.append(f"anderer Gast: {alt.get('nachname') or '?'} / "
f"{neu.get('nachname') or '?'}")
for feld, name in (("anreise", "Anreise"), ("abreise", "Abreise")):
a, b = wort(alt.get(feld)), wort(neu.get(feld))
if a and b and a != b:
leicht.append(f"{name} {alt.get(feld)} / {neu.get(feld)}")
try:
ga, gb = float(alt.get("gesamt") or 0), float(neu.get("gesamt") or 0)
except (TypeError, ValueError):
ga = gb = 0.0
if abs(ga - gb) > 0.005:
leicht.append(f"Betrag {berechnung.eur(ga)} / {berechnung.eur(gb)}")
return schwer, leicht
def _storno_folge_waehlen(self, out_dir, alt_nummer, alt_meta=None):
"""Nach der Frage WELCHE Rechnung kommt die Frage, wie es weitergeht.
Zwei Schritte in einem Fenster, so wie er es beschrieben hat: erst A
oder B, und erst wenn B gedrueckt ist, kommt das Nummernfeld.
A - es folgt eine neue Rechnung (der bisherige Weg)
B - die Leistung ist schon abgerechnet -> Nummer der BLEIBENDEN
C - nur Storno: es folgt nichts und es gibt keine andere Rechnung
Sein Fall: mit dem alten Programm wurde bei einem Fehler einfach eine
NEUE Rechnung geschrieben. Jetzt liegt dieselbe Leistung mehrfach im
Ordner, und die Ueberzaehligen muessen auf die Rechnung verweisen, die
BLEIBT - sonst wartet der Gast auf eine Rechnung, die nie kommt.
C kam am 04.09.2026 dazu (*"des wegen sollte man auch eine version c mit
only storno machen weil das gibt es auch"*): die Leistung wird gar nicht
abgerechnet - Buchung geplatzt, Rechnung komplett irrtuemlich. Ohne C
muesste man A waehlen und die versprochene neue Rechnung bliebe ewig
offen, oder B und muesste auf eine Rechnung verweisen, die es nicht gibt.
Rueckgabe: ("neu", None) | ("vorhanden", "2026-338") | ("nur", None)
| None (Abbruch).
"""
# Im Testlauf klickt niemand: dann gilt der uebliche Weg. Der Pruefstand,
# der GENAU DIESES Fenster prueft, setzt _storno_dialog_zeigen.
from app import TESTLAUF
if TESTLAUF and not getattr(self, "_storno_dialog_zeigen", False):
return getattr(self, "_folgewahl", ("neu", None))
d = tk.Toplevel(self)
d.title("Wie geht es nach dem Storno weiter?")
d.transient(self)
d.resizable(False, False)
self._style_dialog(d)
wahl = {"art": None, "nummer": ""}
gefunden = {"meta": None, "schwer": [], "leicht": []}
eingabe = tk.StringVar()
rahmen = ttk.Frame(d, padding=18)
rahmen.pack(fill="both", expand=True)
ttk.Label(rahmen, text=f"Storno zu Rechnung {alt_nummer}",
font=(theme.SCHRIFT_NAME, 13, "bold")).pack(anchor="w")
# ---- Schritt 1: A oder B ------------------------------------------
schritt1 = ttk.Frame(rahmen)
schritt1.pack(fill="x", pady=(8, 0))
ttk.Label(schritt1, wraplength=620, justify="left", text=(
"Auf dem Storno-Blatt muss stehen, wie die Leistung stattdessen abgerechnet "
"wird – sonst weiß der Gast nicht, woran er ist.")).pack(anchor="w", pady=(0, 12))
ttk.Label(schritt1, text="A – es folgt eine neue Rechnung",
font=(theme.SCHRIFT_NAME, 10, "bold")).pack(anchor="w")
ttk.Label(schritt1, wraplength=620, justify="left", text=(
"Der übliche Weg: die Rechnung war falsch und wird nach dem Storno richtig neu "
"geschrieben. Das Formular bleibt danach offen.")).pack(anchor="w", pady=(2, 10))
ttk.Label(schritt1, text="B – die Leistung ist schon abgerechnet",
font=(theme.SCHRIFT_NAME, 10, "bold")).pack(anchor="w")
ttk.Label(schritt1, wraplength=620, justify="left", text=(
"Für Doppelte: derselbe Aufenthalt steht mehrfach im Ordner. Im nächsten Schritt "
"trägst du die Nummer der Rechnung ein, die bleibt.")).pack(anchor="w", pady=(2, 10))
ttk.Label(schritt1, text="C – nur Storno",
font=(theme.SCHRIFT_NAME, 10, "bold")).pack(anchor="w")
ttk.Label(schritt1, wraplength=620, justify="left", text=(
"Die Leistung wird gar nicht abgerechnet: Buchung geplatzt oder die Rechnung "
"war komplett irrtümlich. Es folgt keine neue Rechnung und es gibt auch keine "
"andere, auf die verwiesen wird.")).pack(anchor="w", pady=(2, 0))
# ---- Schritt 2: die Nummer (erst nach B) --------------------------
schritt2 = ttk.Frame(rahmen)
ttk.Label(schritt2, wraplength=620, justify="left", text=(
"Welche Rechnung bleibt? Sie muss im Rechnungsordner liegen und denselben "
"Aufenthalt abrechnen – das Programm sieht nach.")).pack(anchor="w", pady=(8, 10))
zeile = ttk.Frame(schritt2)
zeile.pack(anchor="w")
ttk.Label(zeile, text="Bleibende Rechnung:").pack(side="left")
feld = ttk.Entry(zeile, textvariable=eingabe, width=16, justify="center")
feld.pack(side="left", padx=(8, 0))
stand = tk.Label(schritt2, text="", wraplength=620, justify="left",
font=(theme.SCHRIFT_NAME, 9))
stand.pack(anchor="w", pady=(8, 0))
def suchen(*_a):
"""Gibt es die Nummer - und passt der INHALT? Antwort aus dem Ordner."""
nr = nummer_normalisieren(eingabe.get())
gefunden["meta"], gefunden["schwer"], gefunden["leicht"] = None, [], []
if not nr:
stand.configure(text="", fg=self.col_muted)
return None
if nr == alt_nummer:
stand.configure(text="Das ist die Rechnung, die gerade storniert wird.",
fg="#a00000")
return None
treffer = [m for m in rechnungen_im_ordner(out_dir)
if str(m.get("rechnungsnummer")) == nr and m.get("art") != "storno"]
if not treffer:
stand.configure(text=f"Zu {nr} liegt keine Rechnung im Ordner.", fg="#a00000")
return None
m = treffer[0]
wer = " ".join(t for t in (str(m.get("vorname") or ""),
str(m.get("nachname") or "")) if t)
grund = (f"{nr} vom {m.get('datum','')} – {wer or 'ohne Namen'}, "
f"{berechnung.eur(m.get('gesamt') or 0)}")
schwer, leicht = self._passt_zusammen(alt_meta or {}, m)
gefunden["meta"], gefunden["schwer"], gefunden["leicht"] = m, schwer, leicht
if schwer:
stand.configure(text=grund + " — " + "; ".join(schwer)
+ ". So kann das Storno nicht darauf verweisen.",
fg="#a00000")
return None
if leicht:
stand.configure(text=grund + " — weicht ab: " + "; ".join(leicht),
fg="#a06000")
return nr
stand.configure(text=grund + " — gleicher Gast, gleicher Zeitraum, gleicher Betrag.",
fg="#1f5c3d")
return nr
eingabe.trace_add("write", suchen)
# ---- Knopfleiste, wechselt mit dem Schritt ------------------------
knoepfe = ttk.Frame(rahmen)
knoepfe.pack(fill="x", pady=(16, 0))
def nimm_neu():
wahl["art"] = "neu"
d.destroy()
def nimm_nur():
wahl["art"] = "nur"
d.destroy()
def uebernehmen():
nr = suchen()
if not nr:
if not eingabe.get().strip():
stand.configure(text="Bitte die Nummer der bleibenden Rechnung eintragen.",
fg="#a00000")
return
if gefunden["leicht"] and not self.frage(
"Weicht ab – trotzdem?",
f"Rechnung {nr} weicht ab: " + "; ".join(gefunden["leicht"]) +
". Das Storno würde trotzdem darauf verweisen. Ist das dieselbe "
"Leistung?", icon="warning", default="no", parent=d):
return
wahl["art"], wahl["nummer"] = "vorhanden", nr
d.destroy()
def zu_schritt2():
schritt1.pack_forget()
schritt2.pack(fill="x")
for w in knoepfe.winfo_children():
w.destroy()
ttk.Button(knoepfe, text="Übernehmen", width=16,
command=uebernehmen).pack(side="right")
ttk.Button(knoepfe, text="Zurück", width=12, command=zu_schritt1).pack(side="left")
ttk.Button(knoepfe, text="Abbrechen", width=14,
command=d.destroy).pack(side="right", padx=(0, 8))
d.update_idletasks()
feld.focus_set()
def zu_schritt1():
schritt2.pack_forget()
schritt1.pack(fill="x", pady=(8, 0))
for w in knoepfe.winfo_children():
w.destroy()
ttk.Button(knoepfe, text="A: neue Rechnung folgt", width=24,
command=nimm_neu).pack(side="left")
ttk.Button(knoepfe, text="B: schon abgerechnet", width=22,
command=zu_schritt2).pack(side="left", padx=8)
ttk.Button(knoepfe, text="C: nur Storno", width=16,
command=nimm_nur).pack(side="left")
ttk.Button(knoepfe, text="Abbrechen", width=14,
command=d.destroy).pack(side="right")
zu_schritt1()
d.protocol("WM_DELETE_WINDOW", d.destroy)
d.bind("<Escape>", lambda _e: d.destroy())
d.update_idletasks()
theme.mittig(d, self)
d.grab_set()
self.wait_window(d)
if wahl["art"] == "vorhanden":
return "vorhanden", wahl["nummer"]
if wahl["art"] in ("neu", "nur"):
return wahl["art"], None
return None
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", ""))
# Zuerst die Frage, die frueher gefehlt hat: kommt eine neue Rechnung, oder
# ist die Leistung schon unter einer anderen Nummer abgerechnet? Beim
# alten Programm wurde bei einem Fehler einfach neu geschrieben - deshalb
# liegt dieselbe Leistung mehrfach im Ordner, und die Ueberzaehligen
# muessen auf die BLEIBENDE Rechnung verweisen.
weiter = self._storno_folge_waehlen(out_dir, alt_nummer, meta)
if weiter is None:
return
art_folge, vorhandene = weiter
nummer = naechste_freie_nummer(out_dir, self.cfg, self._belegte_nummern())
if not nummer:
self.melden_fehler("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")
# Welche Nummer die berichtigte Rechnung bekommt, steht schon jetzt fest -
# die naechste freie NACH dem Storno. Sie kommt aufs Storno-Blatt, damit
# die Kette aus dem Beleg allein lesbar ist und kein Zustand gemerkt
# werden muss: alte Nummer -> Storno -> neue Nummer, alles auf einem Blatt.
if art_folge == "vorhanden":
# Es kommt keine neue Rechnung - das Storno verweist auf die,
# die bleibt. Es wird also auch keine Nummer dafuer reserviert.
folge_nummer = vorhandene
elif art_folge == "nur":
# Nur Storno: es folgt nichts, es gibt auch nichts, worauf zu
# verweisen waere. Keine Nummer reservieren - sonst reisst die
# fortlaufende Nummernfolge eine Luecke (Paragraph 14 Abs. 4 Nr. 4 UStG).
folge_nummer = ""
else:
folge_nummer = naechste_freie_nummer_nach(out_dir, self.cfg, nummer,
self._belegte_nummern())
# 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", "")]
# Der Storno ist der Beleg, der die Rechnung aufhebt - er geht an den Gast
# und braucht dieselben Angaben wie sie. Steht in der alten Rechnung nichts,
# kann das Programm nichts erfinden; dann muss der Nutzer entscheiden.
maengel = kunde_maengel(kunde)
if maengel:
if not self.frage(
"Angaben zum Kunden unvollständig",
f"In Rechnung {alt_nummer} steht nicht alles:\n\n- "
+ "\n- ".join(maengel) +
"\n\nDas Storno geht an den Gast und trägt dieselben Angaben. "
"Was fehlt, fehlt auch dort.\n\nTrotzdem erstellen?",
icon="warning", default="no"):
return
# Der Storno ist das Spiegelbild der Rechnung - auch bei den
# UEBERNACHTUNGEN. Stand hier vorher nichts, blieben die Naechte der
# aufgehobenen Rechnung im Steuerjournal stehen: die Betraege kuerzten
# sich, die Zahl der Uebernachtungen nicht, und das Amt bekam sie
# doppelt gemeldet (4 - 0 + 4 = 8 statt 4).
alt_naechte = int(parse_num(str(meta.get("naechte") or 0)) or 0)
kopf = {
"rechnungsnummer": nummer,
"datum": heute,
"anreise": "", "abreise": "",
"naechte": -abs(alt_naechte) if alt_naechte else "",
"kunde_zeilen": kunde_zeilen,
"nachname": kunde.get("nachname", ""),
"vorname": kunde.get("vorname", ""),
"art": "storno",
# Vorgang der alten Rechnung uebernehmen, nicht deren Nummer: wird eine
# schon berichtigte Rechnung noch einmal storniert, bleibt die ganze
# Kette unter EINEM Schluessel.
"vorgang": str(meta.get("vorgang") or alt_nummer),
"storno_zu": alt_nummer,
# alte Nummer -> diese Nummer. Steht auf dem Blatt, damit die Kette
# aus dem Storno allein lesbar ist.
"korrektur_nummer": folge_nummer or "",
# Sagt dem Blatt, ob die Nummer eine BLEIBENDE Rechnung meint
# ("bereits abgerechnet mit") oder eine, die noch kommt.
"ersatz_vorhanden": art_folge == "vorhanden",
# Sagt dem Blatt: es kommt NICHTS nach. Ohne das stuende dort der
# Satz von der "berichtigten Rechnung", die es nie geben wird.
"nur_storno": art_folge == "nur",
"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
self.melden_fehler("Fehler beim Erstellen",
f"Storno-PDF konnte nicht erstellt werden:\n{e}")
print("[storno] FEHLER:", e)
return
# Nur wenn eine neue Rechnung folgt, bleibt ein Vorgang offen. Bei
# "schon abgerechnet" ist mit dem Storno alles getan.
self.storno_stand = None if art_folge in ("vorhanden", "nur") else {
"pfad": pfad, "nummer": nummer, "zu": alt_nummer,
"vorgang": str(meta.get("vorgang") or 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}")
# Der Storno hat eine eigene Nummer - die gehoert ins gemeinsame Buch,
# sonst vergibt sie das andere Programm noch einmal, und das Journal
# erfaehrt nicht, dass die Rechnung aufgehoben ist.
self._ins_buch(nummer, kopf, r, pfad)
self._buch_anzeigen()
self._journal_benachrichtigen(f"Storno {nummer} zu Rechnung {alt_nummer} erstellt")
if art_folge == "vorhanden":
self._storno_anzeigen()
self._neue_nummer_vorschlagen()
if self.frage(
"Storno erstellt",
f"Storno {nummer} hebt Rechnung {alt_nummer} auf.\n\n"
f"Die Leistung bleibt mit Rechnung {folge_nummer} abgerechnet – es "
"kommt keine neue Rechnung dazu.\n\n"
"Das Blatt gehört zum Gast.\n\nJetzt öffnen (zum Drucken)?"):
self._oeffne_datei(pfad)
return
fehlend = self._formular_aus_meta(meta)
self.r_nummer.set(folge_nummer 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.")
self.melden_info(
"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"))
# Eine Montage-Rechnung gehoert in ihren Reiter: ihre Position 1 ist die
# Monatsmiete, im Campingformular waere es Position 1 des Katalogs
# (Wohnwagen pro Nacht) - der Storno haette stillschweigend etwas
# anderes berichtigt.
montage = str(meta.get("vorlage", "") or "") == "montage"
try:
self.blaetter.select(1 if montage else 0)
except (AttributeError, tk.TclError):
pass
for nr, var in self.anzahl_vars.items():
var.set("")
self.personen_vars[nr].set("")
if montage:
erste = (meta.get("positionen") or [{}])[0]
self.m_monate.set(str(erste.get("anzahl", "1") or "1"))
else:
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)