Steuerrechnungstool/gui_monat.py
TheMockTv 59d2db75af Storno und Berichtigung kommen an, gemeinsames Nummernbuch, kein Bearbeiten mehr
Storno: das Journal zaehlte die Uebernachtungen der aufgehobenen Rechnung
weiter mit (4 - 0 + 3 = 7 statt 3 gemeldeter Naechte). Die Betraege kuerzten
sich, die Naechte nicht. Der Storno traegt jetzt die negative Zahl, und das
Journal kennt Art, Bezug und Datum des Belegs (vier neue Spalten, alte
Journale bauen sich beim Oeffnen um).

Berichtigung: das Blatt wurde nur uebersprungen. Der berichtigte NAME kommt
jetzt in der Buchung an - im Amtsbericht stand sonst weiter der falsche.
Angewandt wird erst am Ende des Scans, das Blatt kann vor seiner Rechnung im
Ordner liegen.

Doppelte Nummern: eine stornierte Rechnung ist keine offene mehr und faellt aus
der Meldung. Zugeordnet wird ueber Nummer + Datum + Empfaenger, nicht ueber die
Nummer allein - bei einer doppelt vergebenen Nummer waere die kein eindeutiger
Bezug, und genau den verlangt § 31 Abs. 5 UStDV. Die Texte nennen jetzt den
Weg: eine der beiden im Rechnungstool stornieren und mit freier Nummer neu
ausstellen (§ 14 Abs. 4 Nr. 4 UStG - jede Nummer nur einmal).

Neue Spalte "Art": Storno zu ..., storniert, Neuausstellung. Aufgehobene Zeilen
stehen grau - geloescht wird nichts.

Gemeinsames Nummernbuch (gemeinsam.py, ordnerwahl.py - in beiden Programmen
dieselbe Datei): eine SQLite in einem Ordner, den beide kennen. Das Journal
traegt seine Nummern ein, auch die aus der Excel-Mappe ohne PDF - genau die
kannte das Rechnungstool nicht und vergab sie ein zweites Mal. Beim ersten
Start wird nach dem Ordner gefragt (auswaehlen, OK, Sicherheitsfrage, bei Nein
zurueck ins Feld); wer ihn nicht hat, kommt mit "Spaeter einrichten" weiter.

Kein Doppelklick zum Bearbeiten mehr (gui_dialoge.py geloescht): wer hier Betrag
oder Nummer verstellt, meldet dem Amt etwas anderes, als auf dem Beleg steht.
Loeschen bleibt.

--test / CAMPINGHOF_TEST=1 unterdrueckt den Erststart-Dialog (Pruefstaende).

Pruefstaende: pruef_storno_journal.py (neu, ueber BEIDE Programme: Storno,
Berichtigung, doppelte Nummer aufloesen), pruef_doppelte, pruef_berichtigung,
pruef_einzelinstanz - alle gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx
2026-09-03 20:38:38 +02:00

211 lines
8.9 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 -*-
"""
Ein Monatsblatt der Oberfläche: Tabelle der Buchungen + Summenzeile darunter.
Entspricht einem Monatsblatt der alten Excel-Mappe.
"""
import tkinter as tk
from tkinter import ttk, font as tkfont
from modell import summiere, eur, eur_kurz
HAKEN_AN = "☑"
HAKEN_AUS = "☐"
SPALTEN = [
("haken", HAKEN_AUS, 34, "center"), # zum Anhaken fürs Löschen
("datum", "Datum", 90, "center"),
("nummer", "Rechnung", 95, "center"),
("art", "Art", 132, "center"), # Storno zu ... / storniert / Neuausstellung
("nachname", "Nachname", 200, "w"),
("naechte", "Nächte", 65, "center"),
("gezahlt", "Gezahlt", 105, "e"),
("satz", "Satz %", 60, "center"),
("steuer", "Steuer", 105, "e"),
("quelle", "Quelle", 75, "center"),
]
QUELLE_TEXT = {"pdf": "PDF", "xlsx": "Excel", "manuell": "manuell"}
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):
"""Kennzahlen als abgesetzte Felder statt einer langen Textzeile."""
FELDER = [
("rechnungen", "Rechnungen", False),
("naechte", "Übernachtungen", False),
("gezahlt", "Gezahlt", False),
("steuer", "Beherbergungssteuer", True), # hervorgehoben
]
# Die Jahresleiste zeigt zusätzlich, was im Schnitt pro Monat hereinkam:
# einmal der Rechnungsbetrag (bleibt beim Hof) und einmal das, was der Gast
# insgesamt gezahlt hat (inkl. der Beherbergungssteuer, die ans Amt durchläuft).
FELDER_JAHR = FELDER + [
("schnitt_ohne", "Ø je Monat (ohne Steuer)", False),
("schnitt_mit", "Ø je Monat (mit Steuer)", False),
]
def __init__(self, master, mit_schnitt: bool = False):
super().__init__(master, bg=BG, highlightbackground=RAHMEN, highlightthickness=1)
self.werte = {}
self.felder = self.FELDER_JAHR if mit_schnitt else self.FELDER
for spalte, (schluessel, titel, betont) in enumerate(self.felder):
if spalte:
tk.Frame(self, bg=RAHMEN, width=1).grid(row=0, column=spalte * 2 - 1,
sticky="ns", pady=8)
zelle = tk.Frame(self, bg=BG)
zelle.grid(row=0, column=spalte * 2, sticky="ew", padx=16, pady=8)
tk.Label(zelle, text=titel.upper(), bg=BG, fg="#4a544c",
font=("Segoe UI", 7, "bold")).pack(anchor="w")
wert = tk.Label(zelle, text="–", bg=BG,
fg=GRUEN if betont else "black",
font=("Segoe UI", 13 if betont else 12,
"bold" if betont else "normal"))
wert.pack(anchor="w")
self.werte[schluessel] = wert
self.columnconfigure(spalte * 2, weight=1)
def setzen(self, s, buchungen=None):
self.werte["rechnungen"].config(text=str(s.buchungen))
self.werte["naechte"].config(text=str(s.naechte))
self.werte["gezahlt"].config(text=eur(s.gezahlt))
self.werte["steuer"].config(text=eur(s.steuer))
if "schnitt_ohne" in self.werte:
monate = len({b.monat for b in (buchungen or [])}) # nur Monate mit Buchungen
self.werte["schnitt_ohne"].config(
text=eur(s.gezahlt / monate) if monate else "–")
self.werte["schnitt_mit"].config(
text=eur(s.gesamt / monate) if monate else "–")
class MonatsTab(ttk.Frame):
"""Tabelle eines Monats. `bei_auswahl` wird mit der markierten Buchung gerufen."""
def __init__(self, master, monat: int, bei_haken=None):
super().__init__(master, padding=(8, 8, 8, 6))
self.monat = monat
self.bei_haken = bei_haken # meldet dem Fenster, dass sich die Auswahl änderte
self._buchungen = []
self._angehakt = set() # ids der angehakten Buchungen
self.baum = ttk.Treeview(self, columns=[s[0] for s in SPALTEN],
show="headings", selectmode="browse")
for schluessel, titel, breite, anker in SPALTEN:
self.baum.heading(schluessel, text=titel)
self.baum.column(schluessel, width=breite, anchor=anker, stretch=False)
rollbalken = ttk.Scrollbar(self, orient="vertical", command=self.baum.yview)
self.baum.configure(yscrollcommand=rollbalken.set)
self.baum.grid(row=0, column=0, sticky="nsew")
rollbalken.grid(row=0, column=1, sticky="ns")
# Woher eine Buchung stammt, steht in der Spalte "Quelle" – nicht in der Schriftfarbe
for tag in ("pdf", "xlsx", "manuell"):
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)
self.summen = SummenLeiste(self)
self.summen.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(8, 0))
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
self.baum.bind("<Button-1>", self._klick)
# Kein Doppelklick zum Bearbeiten: die Zahlen kommen aus der Rechnung,
# nicht aus diesem Fenster.
# ---- Anhaken -----------------------------------------------------------
def _klick(self, ereignis):
"""Klick in die Haken-Spalte schaltet die Zeile an/aus."""
if self.baum.identify_region(ereignis.x, ereignis.y) != "cell":
return
if self.baum.identify_column(ereignis.x) != "#1":
return
zeile = self.baum.identify_row(ereignis.y)
if not zeile:
return
b = self._buchungen[int(zeile)]
if b.id in self._angehakt:
self._angehakt.discard(b.id)
else:
self._angehakt.add(b.id)
self.baum.set(zeile, "haken", HAKEN_AN if b.id in self._angehakt else HAKEN_AUS)
if self.bei_haken:
self.bei_haken()
return "break" # kein Zeilen-Markieren durch den Haken-Klick
def angehakte(self):
return [b for b in self._buchungen if b.id in self._angehakt]
def haken_loesen(self):
self._angehakt.clear()
def markierte(self):
"""Aktuell markierte Buchung oder None."""
sel = self.baum.selection()
if not sel:
return None
idx = int(sel[0])
return self._buchungen[idx] if 0 <= idx < len(self._buchungen) else None
def _namensspalte_anpassen(self):
"""Namensspalte nur so breit wie der breiteste Name."""
schrift = tkfont.nametofont("TkDefaultFont")
breit = max([schrift.measure(b.nachname) for b in self._buchungen]
+ [schrift.measure("Nachname")])
self.baum.column("nachname", width=breit + 24, minwidth=90)
def fuellen(self, buchungen, doppelte=(), aufgehoben=()):
"""Zeilen des Monats. `doppelte` sind die doppelt vergebenen Nummern des
ganzen JAHRES (aus modell.doppelte_nummern) - eine Nummer, deren Zwilling
in einem anderen Monat liegt, gehört hier trotzdem rot markiert."""
doppelte = set(doppelte)
aufgehoben = set(aufgehoben)
self._buchungen = sorted(buchungen, key=lambda b: (b.datum, b.rechnungsnummer))
# Haken nur für Zeilen behalten, die es noch gibt
self._angehakt &= {b.id for b in self._buchungen}
self._namensspalte_anpassen()
self.baum.delete(*self.baum.get_children())
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:
marke = ("doppelt",)
else:
marke = (b.quelle,)
if b.storno_zu:
art_text = f"Storno zu {b.storno_zu}"
elif b.id in aufgehoben:
art_text = "storniert"
elif b.art == "korrektur":
art_text = "Neuausstellung"
else:
art_text = ""
self.baum.insert("", "end", iid=str(i), tags=marke, values=(
HAKEN_AN if b.id in self._angehakt else HAKEN_AUS,
b.datum.strftime("%d.%m.%Y"),
("⚠ " + b.voll_nummer) if zweimal else b.voll_nummer,
art_text,
b.nachname,
b.naechte,
eur_kurz(b.gezahlt),
eur_kurz(b.satz).replace(",00", ""),
eur_kurz(b.steuer),
QUELLE_TEXT.get(b.quelle, b.quelle),
))
self.summen.setzen(summiere(self._buchungen))