Amtsbericht: eigene Spalte "Grund", und die stornierte Zeile nennt ihren Storno

Zwei Ansagen von ihm, beide am selben Blatt:

1. "weil die nummer hast nur auf der storno selber aber nicht am
   stornierten nummer um die es geht" - die aufgehobene Rechnung stand
   nur als "storniert" da. Jetzt steht dort "storniert mit 2026-901".
   Damit ist das Paar in BEIDE Richtungen lesbar, und das zaehlt vor
   allem dann, wenn der Storno in einem anderen Monat liegt und auf
   diesem Blatt gar nicht auftaucht.

2. "mach eine neue spalte mit grund oder so was" - der Vermerk stand als
   zweite Zeile unter der Rechnungsnummer. Jetzt ist es eine eigene
   Spalte "Grund" zwischen Rechnung und Name, eine Zeile je Buchung.
   Feste Breiten neu verteilt (20/20/34 mm + Rest fuer den Namen), damit
   nichts umbricht.

Dafuer neu: modell.aufhebungen() gibt {id der Rechnung: Storno-Buchung}
statt nur der ids - die Anzeige will auch sagen, WOMIT storniert wurde,
nicht nur DASS. aufgehobene() bleibt als duenne Huelle darueber, damit
zeig_stornos und doppelte_nummern unveraendert weiterlaufen.

Fenster und Blatt zeigen wieder dieselben Worte.

pruef_storno_verrechnet prueft beide Richtungen mit, auch ueber die
Monatsgrenze ("storniert mit 2026-911" im August, der Storno selbst steht
im September). Angesehen: Tabelle als Bild gerendert, alles einzeilig,
auch mit langem Namen. doppelte und berichtigung 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 01:38:36 +02:00
parent 3a9f1278c8
commit 87d92d15b6
6 changed files with 53 additions and 26 deletions

Binary file not shown.

6
app.py
View file

@ -52,7 +52,7 @@ import firma as firma_lib
import bericht_pdf import bericht_pdf
import ablage import ablage
from modell import (MONATE, STANDARD_SATZ, summiere, eur, eur_kurz, from modell import (MONATE, STANDARD_SATZ, summiere, eur, eur_kurz,
aufgehobene, doppelte_nummern) aufhebungen, doppelte_nummern)
from gui_monat import MonatsTab, SummenLeiste, WARNUNG from gui_monat import MonatsTab, SummenLeiste, WARNUNG
# Als EXE liegt alles in einem eigenen Datenordner - siehe ablage.py. # Als EXE liegt alles in einem eigenen Datenordner - siehe ablage.py.
@ -235,7 +235,9 @@ class App(tk.Tk):
# Was ein Storno aufgehoben hat, steht weiter da - in normaler Schrift, # Was ein Storno aufgehoben hat, steht weiter da - in normaler Schrift,
# gekennzeichnet nur ueber die Spalte "Art", und es zaehlt nicht mehr # gekennzeichnet nur ueber die Spalte "Art", und es zaehlt nicht mehr
# als offene Rechnung. # als offene Rechnung.
self._aufgehoben = aufgehobene(alle) # dict {id der Rechnung: Storno-Buchung} - die Anzeige will auch sagen,
# WOMIT storniert wurde, nicht nur DASS.
self._aufgehoben = aufhebungen(alle)
if self._doppelte: if self._doppelte:
# Gehoert ins Log: der Amtsbericht wird aus diesen Zeilen gebaut, und # Gehoert ins Log: der Amtsbericht wird aus diesen Zeilen gebaut, und
# spaeter soll nachvollziehbar sein, wann der Fall zuerst dastand. # spaeter soll nachvollziehbar sein, wann der Fall zuerst dastand.

View file

@ -23,7 +23,7 @@ from reportlab.lib.styles import ParagraphStyle
from reportlab.pdfbase.pdfmetrics import stringWidth from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
from modell import MONATE, summiere, aufgehobene, eur, eur_kurz from modell import MONATE, summiere, aufhebungen, eur, eur_kurz
log = logging.getLogger("bst.bericht") log = logging.getLogger("bst.bericht")
@ -209,7 +209,11 @@ def _art_text(b, weg) -> str:
if b.storno_zu: if b.storno_zu:
return f"Storno zu {b.storno_zu}" return f"Storno zu {b.storno_zu}"
if b.id in weg: if b.id in weg:
return "storniert" # Nicht nur "storniert": auch WOMIT. Sonst steht auf dem Blatt ein
# Vermerk ohne Gegenstueck und man muss die Zeile suchen - erst recht,
# wenn der Storno in einem anderen Monat liegt und gar nicht auf
# demselben Blatt steht. Sein Hinweis vom 04.09.2026.
return f"storniert mit {weg[b.id].voll_nummer}"
if b.art == "korrektur": if b.art == "korrektur":
return "Neuausstellung" return "Neuausstellung"
return "" return ""
@ -219,7 +223,7 @@ def render_monat(pfad, firma, jahr: int, monat: int, buchungen):
st = _styles() st = _styles()
# Ueber ALLE uebergebenen Buchungen bestimmt (das ganze Jahr): der Storno # Ueber ALLE uebergebenen Buchungen bestimmt (das ganze Jahr): der Storno
# kann in einem anderen Monat liegen als die Rechnung, die er aufhebt. # kann in einem anderen Monat liegen als die Rechnung, die er aufhebt.
weg = aufgehobene(buchungen) weg = aufhebungen(buchungen)
# Storno und aufgehobene Rechnung stehen BEIDE im Bericht, jede in ihrem # Storno und aufgehobene Rechnung stehen BEIDE im Bericht, jede in ihrem
# Monat und mit ihrem Vorzeichen - so ist die Meldung aus sich heraus # Monat und mit ihrem Vorzeichen - so ist die Meldung aus sich heraus
# nachvollziehbar (Entscheidung des Auftraggebers, siehe modell.summiere). # nachvollziehbar (Entscheidung des Auftraggebers, siehe modell.summiere).
@ -233,15 +237,16 @@ def render_monat(pfad, firma, jahr: int, monat: int, buchungen):
_titel(story, st, firma, "Beherbergungssteuer – Steueranmeldung", _titel(story, st, firma, "Beherbergungssteuer – Steueranmeldung",
f"Zeitraum: {MONATE[monat - 1]} {jahr}") f"Zeitraum: {MONATE[monat - 1]} {jahr}")
kopf = ["Datum", "Rechnung", "Name", "Nächte", "Entgelt", "Satz %", "Steuer"] # Eigene Spalte "Grund" statt einer zweiten Zeile unter der Nummer - seine
stile = ["th", "th", "th_l", "th", "th", "th", "th"] # Ansage vom 04.09.2026: "mach eine neue spalte mit grund oder so was".
kopf = ["Datum", "Rechnung", "Grund", "Name", "Nächte", "Entgelt", "Satz %", "Steuer"]
stile = ["th", "th", "th_l", "th_l", "th", "th", "th", "th"]
daten = [[Paragraph(k, st[s_]) for k, s_ in zip(kopf, stile)]] daten = [[Paragraph(k, st[s_]) for k, s_ in zip(kopf, stile)]]
for b in liste: for b in liste:
daten.append([ daten.append([
Paragraph(b.datum.strftime("%d.%m.%Y"), st["zelle_c"]), Paragraph(b.datum.strftime("%d.%m.%Y"), st["zelle_c"]),
Paragraph(b.voll_nummer + (f'<br/><font size="6.5" color="#555555">' Paragraph(b.voll_nummer, st["zelle_c"]),
f'{_art_text(b, weg)}</font>' Paragraph(f'<font size="7.5">{_art_text(b, weg)}</font>', st["zelle"]),
if _art_text(b, weg) else ""), st["zelle_c"]),
Paragraph(b.nachname, st["zelle"]), Paragraph(b.nachname, st["zelle"]),
Paragraph(str(b.naechte), st["zelle_c"]), Paragraph(str(b.naechte), st["zelle_c"]),
Paragraph(eur_kurz(b.gezahlt), st["zelle_r"]), Paragraph(eur_kurz(b.gezahlt), st["zelle_r"]),
@ -251,15 +256,18 @@ def render_monat(pfad, firma, jahr: int, monat: int, buchungen):
if len(daten) == 1: if len(daten) == 1:
daten.append([Paragraph("", st["zelle_c"]), daten.append([Paragraph("", st["zelle_c"]),
Paragraph("<i>(keine Buchungen in diesem Monat)</i>", st["zelle"]), Paragraph("<i>(keine Buchungen in diesem Monat)</i>", st["zelle"]),
"", "", "", "", ""]) "", "", "", "", "", ""])
# Namensspalte nur so breit wie der breiteste Name (Rest der Tabelle ist fest) # Namensspalte nur so breit wie der breiteste Name (Rest der Tabelle ist fest)
breiteste = max([stringWidth(b.nachname, "Helvetica", 8.5) for b in liste] breiteste = max([stringWidth(b.nachname, "Helvetica", 8.5) for b in liste]
+ [stringWidth("Name", "Helvetica-Bold", 8)]) + [stringWidth("Name", "Helvetica-Bold", 8)])
name_w = min(max(breiteste + 6 * mm, 25 * mm), CONTENT_W - 120 * mm) # Die feste Breite ist durch die Grund-Spalte gewachsen; der Name bekommt,
# was uebrig bleibt (34 mm reichen fuer die ueblichen Namen, lange brechen um).
name_w = min(max(breiteste + 6 * mm, 22 * mm), CONTENT_W - 140 * mm)
tabelle = Table(daten, repeatRows=1, hAlign="LEFT", tabelle = Table(daten, repeatRows=1, hAlign="LEFT",
colWidths=[22 * mm, 22 * mm, name_w, 15 * mm, 22 * mm, 14 * mm, 25 * mm]) colWidths=[20 * mm, 20 * mm, 34 * mm, name_w,
13 * mm, 20 * mm, 12 * mm, 21 * mm])
tabelle.setStyle(TableStyle([ tabelle.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), GREY_BG), ("BACKGROUND", (0, 0), (-1, 0), GREY_BG),
("LINEBELOW", (0, 0), (-1, 0), 0.8, ACCENT), ("LINEBELOW", (0, 0), (-1, 0), 0.8, ACCENT),

View file

@ -139,7 +139,9 @@ class MonatsTab(ttk.Frame):
ganzen JAHRES (aus modell.doppelte_nummern) - eine Nummer, deren Zwilling ganzen JAHRES (aus modell.doppelte_nummern) - eine Nummer, deren Zwilling
in einem anderen Monat liegt, gehört hier trotzdem rot markiert.""" in einem anderen Monat liegt, gehört hier trotzdem rot markiert."""
doppelte = set(doppelte) doppelte = set(doppelte)
aufgehoben = set(aufgehoben) # dict {id: Storno-Buchung}; "b.id in aufgehoben" arbeitet damit genauso,
# aber die Zeile kann jetzt auch die Storno-Nummer nennen.
aufgehoben = dict(aufgehoben or {})
self._buchungen = sorted(buchungen, key=lambda b: (b.datum, b.rechnungsnummer)) self._buchungen = sorted(buchungen, key=lambda b: (b.datum, b.rechnungsnummer))
self._namensspalte_anpassen() self._namensspalte_anpassen()
self.baum.delete(*self.baum.get_children()) self.baum.delete(*self.baum.get_children())
@ -153,7 +155,7 @@ class MonatsTab(ttk.Frame):
if b.storno_zu: if b.storno_zu:
art_text = f"Storno zu {b.storno_zu}" art_text = f"Storno zu {b.storno_zu}"
elif b.id in aufgehoben: elif b.id in aufgehoben:
art_text = "storniert" art_text = f"storniert mit {aufgehoben[b.id].voll_nummer}"
elif b.art == "korrektur": elif b.art == "korrektur":
art_text = "Neuausstellung" art_text = "Neuausstellung"
else: else:

View file

@ -77,8 +77,8 @@ class Buchung:
return f"{self.jahr}-{self.rechnungsnummer}" return f"{self.jahr}-{self.rechnungsnummer}"
def aufgehobene(buchungen) -> set: def aufhebungen(buchungen) -> dict:
"""ids der Buchungen, die durch einen Storno aufgehoben sind. """Welcher Storno hebt welche Rechnung auf: {id der Rechnung: Storno-Buchung}.
Der Bezug ist Nummer + Datum + Nachname, nicht die Nummer allein. Genau Der Bezug ist Nummer + Datum + Nachname, nicht die Nummer allein. Genau
darum geht es hier: bei einer DOPPELT vergebenen Nummer waere die Nummer darum geht es hier: bei einer DOPPELT vergebenen Nummer waere die Nummer
@ -86,14 +86,19 @@ def aufgehobene(buchungen) -> set:
berichtigendes Dokument "spezifisch und eindeutig" auf die Rechnung berichtigendes Dokument "spezifisch und eindeutig" auf die Rechnung
bezieht. Steht auf dem Storno also 2026-020 vom 05.08. fuer Zweitgast, dann bezieht. Steht auf dem Storno also 2026-020 vom 05.08. fuer Zweitgast, dann
ist die Rechnung von Erstgast NICHT aufgehoben. ist die Rechnung von Erstgast NICHT aufgehoben.
Zurueckgegeben wird der Storno selbst und nicht nur ein Haken, damit die
Anzeige auch sagen kann, WOMIT eine Rechnung aufgehoben wurde
("storniert mit 2026-901"). Sonst zeigt das Blatt nur "storniert" und man
muss die Gegenzeile suchen.
""" """
stornos = [b for b in buchungen if b.storno_zu] stornos = [b for b in buchungen if b.storno_zu]
if not stornos: if not stornos:
return set() return {}
weg = set() zu = {}
for s in stornos: for s in stornos:
for b in buchungen: for b in buchungen:
if b.id in weg or b is s or not b.id: if b.id in zu or b is s or not b.id:
continue continue
if b.voll_nummer != s.storno_zu: if b.voll_nummer != s.storno_zu:
continue continue
@ -102,9 +107,14 @@ def aufgehobene(buchungen) -> set:
continue continue
if s.nachname and b.nachname and s.nachname != b.nachname: if s.nachname and b.nachname and s.nachname != b.nachname:
continue continue
weg.add(b.id) zu[b.id] = s
break break
return weg return zu
def aufgehobene(buchungen) -> set:
"""Nur die ids - fuer alle, die den Storno selbst nicht brauchen."""
return set(aufhebungen(buchungen))
def doppelte_nummern(buchungen) -> dict: def doppelte_nummern(buchungen) -> dict:

View file

@ -136,14 +136,19 @@ pruefe({b.voll_nummer for b in alle if b.id in weg} == {"2026-900", "2026-910"},
print("--- Fall 3: Amtsbericht aus der Datenbank ---") print("--- Fall 3: Amtsbericht aus der Datenbank ---")
pfad = os.path.join(tmp, "August.pdf") pfad = os.path.join(tmp, "August.pdf")
bericht_pdf.render_monat(pfad, firma_lib.lade(), JAHR, 8, alle) bericht_pdf.render_monat(pfad, firma_lib.lade(), JAHR, 8, alle)
text = (PdfReader(pfad).pages[0].extract_text() or "").replace("\n", " ") # Der Text wird im PDF umbrochen ("storniert mit" / "2026-901") - Leerraum
# also vereinheitlichen, sonst prueft man den Zeilenumbruch statt den Inhalt.
text = " ".join((PdfReader(pfad).pages[0].extract_text() or "").split())
pruefe("2026-900" in text and "2026-901" in text, pruefe("2026-900" in text and "2026-901" in text,
"Rechnung UND Storno stehen im Bericht - die Meldung erklaert sich selbst") "Rechnung UND Storno stehen im Bericht - die Meldung erklaert sich selbst")
pruefe("-52,00" in text, "der Storno steht mit seinem Minusbetrag drin") pruefe("-52,00" in text, "der Storno steht mit seinem Minusbetrag drin")
pruefe("117,00" in text, "und die Monatssumme ist 117,00 Euro") pruefe("117,00" in text, "und die Monatssumme ist 117,00 Euro")
# Ohne Beschriftung steht im Bericht eine Stornonummer neben einer voellig # Ohne Beschriftung steht im Bericht eine Stornonummer neben einer voellig
# anderen Rechnungsnummer - sein Wort: "musst dann rein schreiben storniert". # anderen Rechnungsnummer - sein Wort: "musst dann rein schreiben storniert".
pruefe("storniert" in text, "die aufgehobene Rechnung ist im Bericht als 'storniert' beschriftet") pruefe("storniert mit 2026-901" in text,
"die aufgehobene Rechnung nennt den Storno, der sie aufhebt")
pruefe("storniert mit 2026-911" in text,
"auch dann, wenn dieser Storno in einem ANDEREN Monat liegt")
pruefe("Storno zu 2026-900" in text, "und der Storno nennt die Rechnung, die er aufhebt") pruefe("Storno zu 2026-900" in text, "und der Storno nennt die Rechnung, die er aufhebt")
pruefe("Neuausstellung" in text, "die neue Rechnung ist als Neuausstellung beschriftet") pruefe("Neuausstellung" in text, "die neue Rechnung ist als Neuausstellung beschriftet")
@ -161,8 +166,8 @@ pruefe(not any("aufgehoben" in t for _nr, _a, t in reihen),
arten = {nr: art for nr, art, _t in reihen} arten = {nr: art for nr, art, _t in reihen}
pruefe(arten.get("2026-901") == "Storno zu 2026-900", pruefe(arten.get("2026-901") == "Storno zu 2026-900",
f"der Storno ist als Storno getaggt ({arten.get('2026-901')!r})") f"der Storno ist als Storno getaggt ({arten.get('2026-901')!r})")
pruefe(arten.get("2026-900") == "storniert", pruefe(arten.get("2026-900") == "storniert mit 2026-901",
f"die aufgehobene Rechnung ist als storniert getaggt ({arten.get('2026-900')!r})") f"die aufgehobene Rechnung nennt ihren Storno ({arten.get('2026-900')!r})")
pruefe(arten.get("2026-902") == "Neuausstellung", pruefe(arten.get("2026-902") == "Neuausstellung",
f"und die neue als Neuausstellung ({arten.get('2026-902')!r})") f"und die neue als Neuausstellung ({arten.get('2026-902')!r})")