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>
187 lines
7.5 KiB
Python
187 lines
7.5 KiB
Python
# -*- 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))
|