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
211 lines
8.9 KiB
Python
211 lines
8.9 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"),
|
||
("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))
|