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
723 lines
36 KiB
Python
723 lines
36 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 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)
|