beherbergungssteuer/gui_monat.py
TheMockTv 80967de26f Beherbergungssteuer: Steuerjournal aus den Rechnungs-PDFs
Ersetzt die Excel-Mappe des Campinghofs. Die Rechnungs-PDFs des
Rechnungstools sind die Quelle; SQLite ist nur Cache, Excel nur noch
Import des Altbestands. Bericht fuers Amt als PDF im Briefkopf der
Rechnungen (Firmendaten aus der config.json des Rechnungstools).

- pdf_parser: liest Datum, Nummer, Name, Naechte, Zwischensumme, Satz
- db: Journal + Scan mit Cache (Pfad/mtime/Groesse)
- xlsx_io: Import der alten Tabelle, vereinheitlicht Rechnungsnummern
- bericht_pdf: Monats- und Jahresbericht (reportlab)
- GUI: Monatsreiter, Suche, Anhaken + Papierkorb, Kennzahlen-Leiste

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:15:44 +02:00

187 lines
7.5 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"),
("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
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_doppelklick=None, bei_haken=None):
super().__init__(master, padding=(8, 8, 8, 6))
self.monat = monat
self.bei_doppelklick = bei_doppelklick
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")
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)
if bei_doppelklick:
self.baum.bind("<Double-1>", lambda _e: self._doppelklick())
# ---- 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 _doppelklick(self):
b = self.markierte()
if b:
self.bei_doppelklick(b)
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):
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):
self.baum.insert("", "end", iid=str(i), tags=(b.quelle,), values=(
HAKEN_AN if b.id in self._angehakt else HAKEN_AUS,
b.datum.strftime("%d.%m.%Y"),
b.voll_nummer,
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))