Storno wird nicht mehr mitgerechnet, und nicht mehr ausgegraut

Seine Ansage: "wenn es als storno getagt ist darf der rechner das nicht
mehr rechnen weil es minus ist, die rechnung gibt es nicht mehr" - und
"ausgrauen wuerde ich es nicht, ich wuerde es nur als storno taggen".

Bisher standen Rechnung (+) und Storno (-) beide in der Summe und hoben
sich gegenseitig auf. Naechte, Entgelt und Steuer kamen dabei zwar
richtig heraus, aber:

* die Zahl "Rechnungen" war je Stornofall um ZWEI zu hoch - im Fenster
  wie im Amtsbericht, und
* lag der Storno in einem anderen MONAT als seine Rechnung, stand in
  jedem der beiden Monate die Haelfte der Verrechnung allein da: der
  August meldete eine Uebernachtung zu viel, der September eine zu wenig.
  Erst im Jahr hob sich das wieder auf.

Neu ist modell.zaehlbar(): Storno und die von ihm aufgehobene Rechnung
zaehlen gar nicht mehr mit. summiere() geht durch diesen Filter, das Set
der aufgehobenen Rechnungen wird ueber das ganze JAHR bestimmt und an
Monatsleiste, Jahresleiste und Amtsbericht durchgereicht.

Der Amtsbericht zeigt die beiden Zeilen auch nicht mehr an - die
Rechnung gibt es nicht mehr, und Minuswerte auf dem Blatt fuers Amt
waeren ohne die Gegenzeile nicht erklaerbar.

Im Fenster bleibt alles sichtbar, nur nicht mehr grau: der Hinweis
steht in der Spalte "Art" (Storno zu .../storniert/Neuausstellung).

Pruefstand pruef_storno_rechnet_nicht.py (neu, 22 Pruefungen) legt die
Faelle als Fake-Rechnungen in einer echten SQLite an - auch den Fall
ueber die Monatsgrenze, den echte Daten nicht hergeben - und prueft
Rechenkern, Amtsbericht-PDF und das echte Fenster. Die uebrige
Pruefkette (storno_journal, doppelte, berichtigung, einzelinstanz,
wache) laeuft unveraendert gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bEsoFUk16DfnNA7MjY36H
This commit is contained in:
TheMockTv 2026-09-04 00:10:19 +02:00
parent 0218abaebe
commit 448afb3c62
5 changed files with 252 additions and 21 deletions

17
app.py
View file

@ -52,7 +52,7 @@ import firma as firma_lib
import bericht_pdf
import ablage
from modell import (MONATE, STANDARD_SATZ, summiere, eur, eur_kurz,
aufgehobene, doppelte_nummern)
aufgehobene, zaehlbar, doppelte_nummern)
from gui_monat import MonatsTab, SummenLeiste, WARNUNG
# Als EXE liegt alles in einem eigenen Datenordner - siehe ablage.py.
@ -232,8 +232,9 @@ class App(tk.Tk):
# Ueber ALLE Buchungen des Jahres, nicht ueber die gefilterten: sonst
# versteckt die Suche den zweiten Beleg und die Dublette waere weg.
self._doppelte = doppelte_nummern(alle)
# Was ein Storno aufgehoben hat, steht weiter da - aber grau, und es
# zaehlt nicht mehr als offene Rechnung.
# Was ein Storno aufgehoben hat, steht weiter da - in normaler Schrift,
# gekennzeichnet nur ueber die Spalte "Art", und es zaehlt nicht mehr
# als offene Rechnung.
self._aufgehoben = aufgehobene(alle)
if self._doppelte:
# Gehoert ins Log: der Amtsbericht wird aus diesen Zeilen gebaut, und
@ -260,7 +261,10 @@ class App(tk.Tk):
else:
self.jahr_titel.config(text=f"Jahr {jahr} – gesamt")
self.jahr_summe.setzen(summiere(sichtbar), sichtbar)
# Auch der Monats-Durchschnitt zaehlt nur, was zaehlt: ein Monat, in dem
# nur ein Storno steht, ist kein Monat mit Umsatz.
self.jahr_summe.setzen(summiere(sichtbar, self._aufgehoben),
zaehlbar(sichtbar, self._aufgehoben))
self.jahr_wahl.config(values=self._jahre())
self.ordner_anzeige.config(
text=f"Rechnungen: {self.pdf_ordner}" if self.pdf_ordner
@ -698,7 +702,10 @@ class App(tk.Tk):
return
if not pfad:
return
s = summiere(self.journal.buchungen(jahr=jahr, monat=monat))
# Ueber das ganze Jahr bestimmen, was aufgehoben ist: der Storno kann in
# einem anderen Monat stehen als die Rechnung, die er aufhebt.
jahr_alle = self.journal.buchungen(jahr=jahr)
s = summiere([b for b in jahr_alle if b.monat == monat], aufgehobene(jahr_alle))
self.melde(f"Bericht geschrieben: {pfad}")
if messagebox.askyesno(
"Bericht fertig",

View file

@ -23,7 +23,7 @@ from reportlab.lib.styles import ParagraphStyle
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
from modell import MONATE, summiere, eur, eur_kurz
from modell import MONATE, summiere, zaehlbar, aufgehobene, eur, eur_kurz
log = logging.getLogger("bst.bericht")
@ -200,9 +200,14 @@ def _ort(firma):
# ------------------------------------------------------------- Monat --------
def render_monat(pfad, firma, jahr: int, monat: int, buchungen):
st = _styles()
liste = sorted([b for b in buchungen if b.jahr == jahr and b.monat == monat],
# Was ein Storno aufgehoben hat, gehoert nicht in die Meldung ans Amt - die
# Rechnung gibt es nicht mehr. Bestimmt wird das ueber ALLE uebergebenen
# Buchungen (das ganze Jahr), weil der Storno in einem anderen Monat liegen
# kann als seine Rechnung.
weg = aufgehobene(buchungen)
liste = sorted(zaehlbar([b for b in buchungen if b.jahr == jahr and b.monat == monat], weg),
key=lambda b: (b.datum, b.rechnungsnummer))
s = summiere(liste)
s = summiere(liste, weg)
saetze = {b.satz for b in liste}
satz_text = f" ({eur_kurz(list(saetze)[0]).replace(',00','')} %)" if len(saetze) == 1 else ""
@ -266,8 +271,9 @@ def render_monat(pfad, firma, jahr: int, monat: int, buchungen):
# -------------------------------------------------------------- Jahr --------
def render_jahr(pfad, firma, jahr: int, buchungen):
st = _styles()
liste = [b for b in buchungen if b.jahr == jahr]
gesamt = summiere(liste)
weg = aufgehobene(buchungen)
liste = zaehlbar([b for b in buchungen if b.jahr == jahr], weg)
gesamt = summiere(liste, weg)
story = []
_titel(story, st, firma, "Beherbergungssteuer – Jahresübersicht", f"Zeitraum: Jahr {jahr}")
@ -275,7 +281,7 @@ def render_jahr(pfad, firma, jahr: int, buchungen):
kopf = ["Monat", "Rechnungen", "Übernachtungen", "Entgelt", "Steuer"]
daten = [[Paragraph(k, st["th_l"] if i == 0 else st["th"]) for i, k in enumerate(kopf)]]
for m in range(1, 13):
s = summiere([b for b in liste if b.monat == m])
s = summiere([b for b in liste if b.monat == m], weg)
daten.append([
Paragraph(MONATE[m - 1], st["zelle"]),
Paragraph(str(s.buchungen), st["zelle_c"]),

View file

@ -27,7 +27,6 @@ BG = "#eef1ee" # heller Streifen wie im PDF-Bericht
RAHMEN = "#c3ccc5"
GRUEN = "#1f5c3d" # Campinghof-Grün für den Steuerbetrag
WARNUNG = "#a00000" # doppelt vergebene Rechnungsnummer
GRAU = "#8a8a8a" # aufgehobene Buchung - steht da, zaehlt aber nicht mehr
class SummenLeiste(tk.Frame):
@ -110,9 +109,10 @@ class MonatsTab(ttk.Frame):
self.baum.tag_configure(tag, foreground="black")
# Rot ist einzig für den Fehlerfall reserviert: die Nummer gibt es zweimal.
self.baum.tag_configure("doppelt", foreground=WARNUNG)
# Storno und die von ihm aufgehobene Rechnung: beide bleiben stehen
# (geloescht wird nichts), sie sind nur zurueckgenommen.
self.baum.tag_configure("aufgehoben", foreground=GRAU)
# Storno und die von ihm aufgehobene Rechnung stehen NICHT grau da
# (Ansage des Auftraggebers 04.09.2026: "ausgrauen wuerde ich es nicht,
# ich wuerde es nur als Storno taggen"). Der Hinweis steht in der Spalte
# "Art" - Schriftfarbe bleibt schwarz wie bei jeder anderen Zeile.
self.summen = SummenLeiste(self)
self.summen.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(8, 0))
@ -146,9 +146,7 @@ class MonatsTab(ttk.Frame):
for i, b in enumerate(self._buchungen):
weg = b.id in aufgehoben or bool(b.storno_zu)
zweimal = b.voll_nummer in doppelte and not weg
if weg:
marke = ("aufgehoben",)
elif zweimal:
if zweimal:
marke = ("doppelt",)
else:
marke = (b.quelle,)
@ -172,4 +170,6 @@ class MonatsTab(ttk.Frame):
QUELLE_TEXT.get(b.quelle, b.quelle),
))
self.summen.setzen(summiere(self._buchungen))
# Das Set der aufgehobenen Rechnungen kommt aus dem ganzen JAHR (app.py) -
# Storno und Rechnung koennen in verschiedenen Monaten liegen.
self.summen.setzen(summiere(self._buchungen, aufgehoben))

View file

@ -135,6 +135,33 @@ def doppelte_nummern(buchungen) -> dict:
for nr, liste in sorted(nach_nummer.items()) if len(liste) > 1}
def zaehlbar(buchungen, aufgehoben=None) -> list:
"""Die Zeilen, die in einer Summe ueberhaupt stehen duerfen.
Ein Storno und die von ihm aufgehobene Rechnung zaehlen NICHT mit. Ansage
des Auftraggebers (04.09.2026): *"wenn es als storno getagt ist darf der
rechner das nicht mehr rechnen, weil es minus ist - die rechnung gibt es
nicht mehr."*
Vorher standen beide Zeilen mit Plus- und Minusbetrag in der Summe und hoben
sich gegenseitig auf. Naechte, Entgelt und Steuer kamen dabei zwar richtig
heraus, aber die Zahl **Rechnungen** war je Stornofall um zwei zu hoch - und
lagen Rechnung und Storno in verschiedenen Monaten, stand in jedem Monat die
Haelfte der Verrechnung alleine da.
⚠️ `aufgehoben` muss ueber ALLE Buchungen des Jahres bestimmt sein, nicht
ueber den Monat: der Storno kann in einem anderen Monat liegen als seine
Rechnung. Wer nur eine Monatsliste hat, holt sich das Set vorher mit
`aufgehobene(alle_buchungen_des_jahres)` und reicht es durch.
Geloescht wird nichts - die Zeilen bleiben im Journal und im Fenster
sichtbar, gekennzeichnet ueber die Spalte "Art".
"""
if aufgehoben is None:
aufgehoben = aufgehobene(buchungen)
return [b for b in buchungen if not b.storno_zu and b.id not in aufgehoben]
@dataclass
class Summe:
"""Aufsummierte Kennzahlen einer Menge von Buchungen (Monat oder Jahr)."""
@ -145,9 +172,11 @@ class Summe:
gesamt: float = field(default=0.0) # gezahlt + steuer (wie I2 in der Vorlage)
def summiere(buchungen) -> Summe:
def summiere(buchungen, aufgehoben=None) -> Summe:
"""Kennzahlen einer Menge von Buchungen - ohne Storno und ohne aufgehobene
Rechnung (siehe `zaehlbar`)."""
s = Summe()
for b in buchungen:
for b in zaehlbar(buchungen, aufgehoben):
s.buchungen += 1
s.naechte += int(b.naechte or 0)
s.gezahlt += float(b.gezahlt or 0)

View file

@ -0,0 +1,189 @@
"""Pruefstand: ein Storno wird NICHT mehr mitgerechnet.
Ansage des Auftraggebers (04.09.2026):
"wenn es als storno getagt ist darf der rechner das nicht mehr rechnen,
weil es minus ist - die rechnung gibt es nicht mehr."
Vorher hoben sich Rechnung (+) und Storno (-) in der Summe gegenseitig auf.
Naechte und Steuer kamen dabei richtig heraus, aber:
* die Zahl "Rechnungen" war je Stornofall um ZWEI zu hoch, und
* lag der Storno in einem anderen MONAT als seine Rechnung, stand in jedem
der beiden Monate die Haelfte der Verrechnung allein da (August meldete
eine Uebernachtung zu viel, September eine zu wenig).
Der zweite Fall ist der, den echte Daten nicht hergeben, solange alles im
selben Monat passiert - deshalb steht er hier.
Gerechnet wird auf einer ECHTEN SQLite (Ansage: "musst in der sqlite fake
rechnungen anlegen"), nicht auf zusammengesteckten Objekten: nur so ist belegt,
dass die Storno-Spalten (art, storno_zu, storno_datum) den Weg durch die
Datenbank ueberstehen und dass die ids stimmen, an denen der Bezug haengt.
Das echte Journal wird nicht angefasst - jeder Lauf legt eine eigene Datei
in %TEMP% an.
Aufruf: python -u pruef_storno_rechnet_nicht.py
"""
import os
import sys
import tempfile
import threading
from datetime import date
HIER = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HIER)
fehler = 0
def pruefe(bedingung, text):
global fehler
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kodierung = sys.stdout.encoding or "utf-8"
print(zeile.encode(kodierung, "replace").decode(kodierung))
if not bedingung:
fehler += 1
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
os._exit(2)
_wecker = threading.Timer(180, _reissleine)
_wecker.daemon = True
_wecker.start()
# ---- eigener Datenordner, damit das echte Journal unberuehrt bleibt --------
tmp = tempfile.mkdtemp(prefix="bst_storno_")
import ablage # noqa: E402
ablage.daten_ordner = lambda: tmp
from modell import Buchung, summiere, aufgehobene # noqa: E402
import bericht_pdf # noqa: E402
import firma as firma_lib # noqa: E402
from pypdf import PdfReader # noqa: E402
import app as app_mod # noqa: E402
from gui_monat import SPALTEN # noqa: E402
pruefe(app_mod.BASE_DIR == tmp, f"Pruefstand laeuft im Testordner ({app_mod.BASE_DIR})")
# ---- Fake-Rechnungen in die SQLite ----------------------------------------
# Fall A (August): Rechnung 4 Naechte -> Storno -> Neuausstellung 3 Naechte
# Fall B (Aug/Sep): Rechnung im August, Storno erst im September
# dazu eine saubere Rechnung, die nichts angeht
JAHR = 2026
FAKE = [
Buchung(date(JAHR, 8, 22), "900", JAHR, "Muster", 4, 52.0, quelle="manuell"),
Buchung(date(JAHR, 8, 23), "901", JAHR, "Muster", -4, -52.0, quelle="manuell",
art="storno", storno_zu="2026-900", storno_datum="22.08.2026"),
Buchung(date(JAHR, 8, 23), "902", JAHR, "Muster", 3, 39.0, quelle="manuell",
art="korrektur"),
Buchung(date(JAHR, 8, 22), "910", JAHR, "Spaet", 4, 52.0, quelle="manuell"),
Buchung(date(JAHR, 9, 2), "911", JAHR, "Spaet", -4, -52.0, quelle="manuell",
art="storno", storno_zu="2026-910", storno_datum="22.08.2026"),
Buchung(date(JAHR, 8, 5), "905", JAHR, "Sauber", 2, 26.0, quelle="manuell"),
]
fenster = app_mod.App()
fenster.withdraw() # kein Fenster ins Gesicht, gerechnet wird trotzdem
for b in FAKE:
fenster.journal.speichern(b)
fenster.jahr.set(JAHR)
fenster.aktualisieren()
fenster.update_idletasks()
# Ab hier wird NUR noch gelesen, was wirklich in der Datenbank steht.
alle = fenster.journal.buchungen(jahr=JAHR)
pruefe(len(alle) == 6, f"sechs Fake-Rechnungen stehen in der SQLite ({len(alle)})")
stornos = [b for b in alle if b.storno_zu]
pruefe(len(stornos) == 2 and all(b.art == "storno" for b in stornos),
"die Storno-Spalten haben den Weg durch die DB ueberstanden "
f"({[(b.voll_nummer, b.art, b.storno_zu) for b in stornos]})")
weg = aufgehobene(alle)
pruefe({b.voll_nummer for b in alle if b.id in weg} == {"2026-900", "2026-910"},
"aufgehoben sind genau die beiden stornierten Rechnungen "
f"({sorted(b.voll_nummer for b in alle if b.id in weg)})")
# --------------------------------------------------------------------------
print("--- Fall 1: Storno im selben Monat ---")
fall_a = [b for b in alle if b.nachname == "Muster"]
s = summiere(fall_a, weg)
pruefe(s.buchungen == 1, f"gezaehlt wird EINE Rechnung, nicht drei (ist {s.buchungen})")
pruefe(s.naechte == 3, f"3 Uebernachtungen (ist {s.naechte})")
pruefe(abs(s.gezahlt - 39.0) < 0.005, f"39,00 Euro Entgelt (ist {s.gezahlt})")
pruefe(abs(s.steuer - 1.95) < 0.005, f"1,95 Euro Steuer (ist {s.steuer})")
pruefe(len(fall_a) == 3, "geloescht wird nichts - alle drei Zeilen bleiben in der DB")
# --------------------------------------------------------------------------
print("--- Fall 2: Rechnung im August, Storno im September ---")
aug = summiere([b for b in alle if b.monat == 8], weg)
sep = summiere([b for b in alle if b.monat == 9], weg)
pruefe(aug.buchungen == 2 and aug.naechte == 5,
"August: 2 Rechnungen (Neuausstellung + Sauber), 5 Naechte "
f"(ist {aug.buchungen}, {aug.naechte})")
pruefe(sep.buchungen == 0 and sep.naechte == 0,
"September meldet nichts - der Storno ist keine Buchung "
f"(ist {sep.buchungen}, {sep.naechte})")
pruefe(abs(aug.steuer - 3.25) < 0.005 and sep.steuer == 0,
f"und die Steuer steht im richtigen Monat ({aug.steuer} / {sep.steuer})")
# Gegenprobe: ein Storno, der auf eine fremde Nummer zeigt, hebt nichts auf.
fenster.journal.speichern(
Buchung(date(JAHR, 9, 3), "912", JAHR, "Fremd", -3, -39.0, quelle="manuell",
art="storno", storno_zu="2026-999", storno_datum="01.01.2026"))
mit_fremd = fenster.journal.buchungen(jahr=JAHR)
weg2 = aufgehobene(mit_fremd)
pruefe({b.voll_nummer for b in mit_fremd if b.id in weg2} == {"2026-900", "2026-910"},
"ein Storno hebt nur SEINE Rechnung auf, nicht irgendeine")
pruefe(summiere([b for b in mit_fremd if b.monat == 9], weg2).buchungen == 0,
"und ein Storno ins Leere zaehlt trotzdem nicht mit")
# --------------------------------------------------------------------------
print("--- Fall 3: Amtsbericht aus der Datenbank ---")
pfad = os.path.join(tmp, "August.pdf")
bericht_pdf.render_monat(pfad, firma_lib.lade(), JAHR, 8, mit_fremd)
text = (PdfReader(pfad).pages[0].extract_text() or "").replace("\n", " ")
pruefe("2026-900" not in text, "die aufgehobene Rechnung steht nicht im Bericht")
pruefe("2026-901" not in text, "der Storno steht nicht im Bericht")
pruefe("2026-910" not in text, "auch die im September stornierte Rechnung nicht")
pruefe("2026-902" in text and "2026-905" in text, "die gueltigen Rechnungen stehen drin")
pruefe("-52" not in text, "und keine Minusbetraege mehr auf dem Blatt fuers Amt")
# --------------------------------------------------------------------------
print("--- Fall 4: das echte Fenster ---")
fenster.aktualisieren()
fenster.update_idletasks()
namen = [sp[0] for sp in SPALTEN]
i_nr, i_art = namen.index("nummer"), namen.index("art")
baum = fenster.monate[8].baum
reihen = [(baum.item(i, "values")[i_nr], baum.item(i, "values")[i_art],
tuple(baum.item(i, "tags"))) for i in baum.get_children()]
# 2026-900 (storniert), -901 (Storno), -902 (Neuausstellung), -905, -910 (storniert)
pruefe(len(reihen) == 5, f"der August zeigt weiter ALLE fuenf Zeilen ({len(reihen)})")
pruefe(not any("aufgehoben" in t for _nr, _a, t in reihen),
f"keine Zeile ist ausgegraut ({sorted({t for _n, _a, t in reihen})})")
arten = {nr: art for nr, art, _t in reihen}
pruefe(arten.get("2026-901") == "Storno zu 2026-900",
f"der Storno ist als Storno getaggt ({arten.get('2026-901')!r})")
pruefe(arten.get("2026-900") == "storniert",
f"die aufgehobene Rechnung ist als storniert getaggt ({arten.get('2026-900')!r})")
pruefe(arten.get("2026-902") == "Neuausstellung",
f"und die neue als Neuausstellung ({arten.get('2026-902')!r})")
leiste = fenster.monate[8].summen.werte
pruefe(leiste["rechnungen"].cget("text") == "2",
f"die Monatsleiste zeigt 2 Rechnungen ({leiste['rechnungen'].cget('text')})")
pruefe(leiste["naechte"].cget("text") == "5",
f"und 5 Uebernachtungen ({leiste['naechte'].cget('text')})")
pruefe(fenster.jahr_summe.werte["rechnungen"].cget("text") == "2",
f"die Jahresleiste ebenso ({fenster.jahr_summe.werte['rechnungen'].cget('text')})")
fenster.destroy()
_wecker.cancel()
print("---")
print("alles gruen" if not fehler else f"{fehler} FEHLER")
sys.exit(1 if fehler else 0)