Doppelte Rechnungsnummern werden ANGEZEIGT statt still mitgeschleppt: rote Zeile mit Warnzeichen, markierter Monatsreiter, rote Zeile unter der Jahressumme und eine Liste aller Belege mit Dateinamen. Gerechnet wird ueber das ganze Jahr - liegt die zweite Rechnung im naechsten Monat, sieht kein Monat fuer sich eine Dublette, das Amt bekommt sie trotzdem doppelt. Die Betraege bleiben in allen Summen und im Amtsbericht; gemeldet wird der Fund, korrigiert wird nichts von allein. Vorgeschichte: bis v1.2 war die Rechnungsnummer der Schluessel der Tabelle - die zweite Rechnung ueberschrieb die erste still und fehlte im Amtsbericht. v1.3 hat den Schluessel auf die PDF-Datei umgestellt, seitdem bleiben beide Zeilen stehen. Erst jetzt sieht man den Fall auch. Einzelinstanz aus dem Steuerrechner uebernommen (nur Mutex- und PID-Name geaendert): zwei Fenster lesen denselben Rechnungsordner in dieselbe journal.sqlite3 - was das eine loescht, steht im anderen noch da, und wer dort "PDF fuers Amt" drueckt, meldet einen Stand, den es nicht mehr gibt. Die Sperre greift vor ablage.daten_ordner() und nur beim echten Start. "Neue Buchung" ist raus: jede Zeile gehoert zu einer Rechnung aus dem Rechnungstool. Wer eine Buchung braucht, schreibt die Rechnung. Bestehende Zeilen lassen sich weiter bearbeiten. Pruefstaende: pruef_doppelte.py (27 Pruefungen am echten Fenster), pruef_einzelinstanz.py (echte Prozesse). Beide gruen, dazu Abnahme mit den Augen an Hauptfenster, Liste und Bearbeiten-Dialog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx
587 lines
26 KiB
Python
587 lines
26 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
Beherbergungssteuer – Steuerjournal für den Campinghof Bartl.
|
||
|
||
Ersetzt die Excel-Mappe: Die Rechnungs-PDFs aus dem Rechnungstool sind die
|
||
Quelle, das Programm liest sie ein, führt das Journal (SQLite) und schreibt
|
||
auf Knopfdruck die Excel-Mappe fürs Amt.
|
||
|
||
Start: Doppelklick auf "Beherbergungssteuer starten.bat" oder `python app.py`
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import logging
|
||
from datetime import date
|
||
|
||
import tkinter as tk
|
||
from tkinter import ttk, filedialog, messagebox
|
||
|
||
# Nur EIN Fenster. Zwei Instanzen lesen denselben Rechnungsordner in dieselbe
|
||
# journal.sqlite3: was das eine loescht, steht im anderen noch da, und der
|
||
# Bericht fuers Amt kaeme aus dem veralteten Fenster. Die Sperre steht VOR
|
||
# ablage.daten_ordner() - der legt beim ersten Start den Datenordner an und
|
||
# zieht alte Dateien um; das darf nicht zweimal gleichzeitig laufen.
|
||
# Nur beim echten Start: Pruefstaende importieren app als Modul und sollen
|
||
# nicht an der Sperre des laufenden Programms scheitern.
|
||
from einzelinstanz import (eigene_pid_merken, einzelinstanz_pruefen,
|
||
fenster_nach_vorn, laufende_pid_lesen)
|
||
|
||
_SPERRE = None
|
||
if __name__ == "__main__":
|
||
_SPERRE = einzelinstanz_pruefen()
|
||
if _SPERRE is False:
|
||
if not fenster_nach_vorn(laufende_pid_lesen()):
|
||
_wurzel = tk.Tk()
|
||
_wurzel.withdraw()
|
||
_wurzel.attributes("-topmost", True)
|
||
messagebox.showerror(
|
||
"Beherbergungssteuer läuft bereits",
|
||
"Das Steuerjournal ist schon geöffnet, das Fenster ließ sich aber "
|
||
"nicht nach vorn holen – bitte über die Taskleiste zurückwechseln.")
|
||
_wurzel.destroy()
|
||
sys.exit(0)
|
||
|
||
import db
|
||
import xlsx_io
|
||
import firma as firma_lib
|
||
import bericht_pdf
|
||
import ablage
|
||
from modell import MONATE, STANDARD_SATZ, summiere, eur, eur_kurz, doppelte_nummern
|
||
from gui_monat import MonatsTab, SummenLeiste, WARNUNG
|
||
from gui_dialoge import BuchungDialog
|
||
|
||
# Als EXE liegt alles in einem eigenen Datenordner - siehe ablage.py.
|
||
BASE_DIR = ablage.daten_ordner()
|
||
JOURNAL = os.path.join(BASE_DIR, "journal.sqlite3")
|
||
LOGDATEI = os.path.join(BASE_DIR, "beherbergungssteuer.log")
|
||
BERICHTE = os.path.join(BASE_DIR, "berichte")
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s %(levelname)-7s %(name)-8s %(message)s",
|
||
handlers=[logging.FileHandler(LOGDATEI, encoding="utf-8"), logging.StreamHandler()],
|
||
)
|
||
log = logging.getLogger("bst.app")
|
||
|
||
|
||
class App(tk.Tk):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.title("Beherbergungssteuer – Campinghof Bartl")
|
||
self.geometry("1000x680")
|
||
self.minsize(820, 520)
|
||
|
||
self.journal = db.Journal(JOURNAL)
|
||
self.satz = float(self.journal.hole("satz", str(STANDARD_SATZ)))
|
||
self.pdf_ordner = self.journal.hole("pdf_ordner", "")
|
||
self.jahr = tk.IntVar(value=int(self.journal.hole("jahr", str(date.today().year))))
|
||
self.firma = firma_lib.lade(self.journal.hole("rechnungstool_config", ""))
|
||
self.suche = tk.StringVar()
|
||
self.suche.trace_add("write", lambda *_: self.aktualisieren())
|
||
os.makedirs(BERICHTE, exist_ok=True)
|
||
|
||
self._menue()
|
||
self._kopfleiste()
|
||
self._blaetter()
|
||
self._statusleiste()
|
||
|
||
self.bind("<F5>", lambda _e: self.einlesen())
|
||
self.bind("<Control-f>", lambda _e: self.suchfeld.focus_set())
|
||
self.bind("<Delete>", lambda _e: self.buchung_loeschen())
|
||
self.protocol("WM_DELETE_WINDOW", self._beenden)
|
||
|
||
self.aktualisieren()
|
||
if self.pdf_ordner and os.path.isdir(self.pdf_ordner):
|
||
self.after(200, lambda: self.einlesen(still=True))
|
||
|
||
# ------------------------------------------------------------ Aufbau ----
|
||
def _menue(self):
|
||
leiste = tk.Menu(self)
|
||
datei = tk.Menu(leiste, tearoff=0)
|
||
datei.add_command(label="Rechnungs-Ordner wählen…", command=self.ordner_waehlen)
|
||
datei.add_separator()
|
||
datei.add_command(label="Monatsbericht fürs Amt (PDF)…", command=self.bericht_monat)
|
||
datei.add_command(label="Jahresbericht fürs Amt (PDF)…", command=self.bericht_jahr)
|
||
datei.add_separator()
|
||
datei.add_command(label="Excel-Tabelle einlesen…", command=self.altbestand_importieren)
|
||
datei.add_separator()
|
||
datei.add_command(label="Beenden", command=self._beenden)
|
||
leiste.add_cascade(label="Datei", menu=datei)
|
||
|
||
daten = tk.Menu(leiste, tearoff=0)
|
||
daten.add_command(label="Rechnungen einlesen (F5)", command=self.einlesen)
|
||
daten.add_command(label="Alle Rechnungen neu einlesen", command=lambda: self.einlesen(voll=True))
|
||
daten.add_separator()
|
||
daten.add_command(label="Markierte Buchung löschen (Entf)", command=self.buchung_loeschen)
|
||
daten.add_separator()
|
||
daten.add_command(label="Steuersatz ändern…", command=self.satz_aendern)
|
||
daten.add_command(label="Firmendaten des Rechnungstools wählen…",
|
||
command=self.firma_waehlen)
|
||
leiste.add_cascade(label="Daten", menu=daten)
|
||
self.config(menu=leiste)
|
||
|
||
def _kopfleiste(self):
|
||
leiste = ttk.Frame(self, padding=(10, 8))
|
||
leiste.pack(fill="x")
|
||
|
||
ttk.Label(leiste, text="Jahr:").pack(side="left")
|
||
self.jahr_wahl = ttk.Combobox(leiste, width=6, state="readonly",
|
||
textvariable=self.jahr, values=self._jahre())
|
||
self.jahr_wahl.pack(side="left", padx=(4, 14))
|
||
self.jahr_wahl.bind("<<ComboboxSelected>>", lambda _e: self.jahr_gewechselt())
|
||
|
||
ttk.Button(leiste, text="Rechnungen einlesen", command=self.einlesen).pack(side="left")
|
||
ttk.Button(leiste, text="PDF fürs Amt", command=self.bericht_monat).pack(side="left", padx=(6, 0))
|
||
|
||
self.papierkorb = ttk.Button(leiste, text="🗑 Löschen", state="disabled",
|
||
command=self.angehakte_loeschen)
|
||
self.papierkorb.pack(side="left", padx=(6, 0))
|
||
|
||
ttk.Label(leiste, text="Suche:").pack(side="left", padx=(20, 4))
|
||
self.suchfeld = ttk.Entry(leiste, textvariable=self.suche, width=22)
|
||
self.suchfeld.pack(side="left")
|
||
self.suchfeld.bind("<Escape>", lambda _e: self.suche_leeren())
|
||
ttk.Button(leiste, text="×", width=3, command=self.suche_leeren).pack(side="left", padx=(3, 0))
|
||
|
||
def _blaetter(self):
|
||
self.blaetter = ttk.Notebook(self)
|
||
self.blaetter.pack(fill="both", expand=True, padx=10, pady=(0, 6))
|
||
self.monate = {}
|
||
for m in range(1, 13):
|
||
tab = MonatsTab(self.blaetter, m, bei_doppelklick=self.buchung_bearbeiten,
|
||
bei_haken=self.papierkorb_auffrischen)
|
||
self.blaetter.add(tab, text=MONATE[m - 1])
|
||
self.monate[m] = tab
|
||
self.blaetter.select(min(date.today().month, 12) - 1)
|
||
|
||
def _statusleiste(self):
|
||
rahmen = ttk.Frame(self, padding=(10, 2, 10, 6))
|
||
rahmen.pack(fill="x")
|
||
self.jahr_titel = ttk.Label(rahmen, text="", font=("Segoe UI", 9, "bold"))
|
||
self.jahr_titel.pack(anchor="w")
|
||
self.jahr_summe = SummenLeiste(rahmen, mit_schnitt=True)
|
||
self.jahr_summe.pack(fill="x", pady=(2, 4))
|
||
|
||
self.fuss = ttk.Frame(rahmen)
|
||
self.fuss.pack(fill="x")
|
||
self.status = ttk.Label(self.fuss, text="", foreground="black")
|
||
self.status.pack(side="left")
|
||
self.ordner_anzeige = ttk.Label(self.fuss, text="", foreground="black")
|
||
self.ordner_anzeige.pack(side="right")
|
||
|
||
# Doppelt vergebene Rechnungsnummern. Die Leiste wird erst eingehaengt,
|
||
# wenn es wirklich welche gibt - im Normalfall soll hier nichts stehen
|
||
# und auch kein leerer Streifen Platz wegnehmen.
|
||
self._doppelte = {}
|
||
self.doppelt_leiste = ttk.Frame(rahmen)
|
||
self.knopf_doppelte = ttk.Button(self.doppelt_leiste,
|
||
text="Doppelte Rechnungen anzeigen…",
|
||
command=self.doppelte_zeigen)
|
||
self.knopf_doppelte.pack(side="right", padx=(10, 0))
|
||
self.warnung = tk.Label(self.doppelt_leiste, text="", fg=WARNUNG, anchor="w",
|
||
justify="left", wraplength=760, font=("Segoe UI", 9))
|
||
self.warnung.pack(side="left", fill="x", expand=True)
|
||
|
||
# ----------------------------------------------------------- Anzeige ----
|
||
def _jahre(self):
|
||
vorhanden = set(self.journal.jahre()) | {date.today().year, self.jahr.get()}
|
||
return [str(j) for j in sorted(vorhanden)]
|
||
|
||
def _passt(self, b, begriff: str) -> bool:
|
||
"""Sucht in Nachname, Rechnungsnummer und Datum."""
|
||
return begriff in " ".join((
|
||
b.nachname.lower(),
|
||
b.voll_nummer.lower(),
|
||
b.rechnungsnummer.lower(),
|
||
b.datum.strftime("%d.%m.%Y"),
|
||
))
|
||
|
||
def aktualisieren(self):
|
||
jahr = self.jahr.get()
|
||
alle = self.journal.buchungen(jahr=jahr)
|
||
begriff = self.suche.get().strip().lower()
|
||
sichtbar = [b for b in alle if self._passt(b, begriff)] if begriff else alle
|
||
|
||
# Ueber ALLE Buchungen des Jahres, nicht ueber die gefilterten: sonst
|
||
# versteckt die Suche den zweiten Beleg und die Dublette waere weg.
|
||
self._doppelte = doppelte_nummern(alle)
|
||
|
||
for m in range(1, 13):
|
||
self.monate[m].fuellen([b for b in sichtbar if b.monat == m], self._doppelte)
|
||
# Reiter des Monats markieren, damit man die Dublette auch dann
|
||
# sieht, wenn gerade ein anderer Monat offen ist.
|
||
betroffen = any(b.voll_nummer in self._doppelte for b in alle if b.monat == m)
|
||
self.blaetter.tab(m - 1, text=("⚠ " if betroffen else "") + MONATE[m - 1])
|
||
self._doppelte_melden()
|
||
|
||
if begriff:
|
||
self.jahr_titel.config(text=f"Jahr {jahr} – Treffer für „{self.suche.get().strip()}“")
|
||
# zum ersten Monat mit Treffern springen
|
||
monate_mit_treffern = sorted({b.monat for b in sichtbar})
|
||
if monate_mit_treffern and self._monat not in monate_mit_treffern:
|
||
self.blaetter.select(monate_mit_treffern[0] - 1)
|
||
else:
|
||
self.jahr_titel.config(text=f"Jahr {jahr} – gesamt")
|
||
|
||
self.jahr_summe.setzen(summiere(sichtbar), sichtbar)
|
||
self.jahr_wahl.config(values=self._jahre())
|
||
self.papierkorb_auffrischen()
|
||
self.ordner_anzeige.config(
|
||
text=f"Rechnungen: {self.pdf_ordner}" if self.pdf_ordner
|
||
else "kein Rechnungs-Ordner gewählt (Datei → Rechnungs-Ordner wählen…)")
|
||
|
||
def suche_leeren(self):
|
||
self.suche.set("")
|
||
self.suchfeld.focus_set()
|
||
|
||
def melde(self, text: str):
|
||
self.status.config(text=text)
|
||
log.info(text)
|
||
|
||
# ---- Doppelt vergebene Rechnungsnummern --------------------------------
|
||
def _doppelte_melden(self):
|
||
"""Warnleiste zeigen, solange es Dubletten gibt - sonst verschwindet sie."""
|
||
if not self._doppelte:
|
||
self.doppelt_leiste.pack_forget()
|
||
self.warnung.config(text="")
|
||
return
|
||
teile = []
|
||
for nr, liste in list(self._doppelte.items())[:3]:
|
||
namen = ", ".join(sorted({b.nachname or "?" for b in liste}))
|
||
teile.append(f"{nr} ({len(liste)}× – {namen})")
|
||
rest = len(self._doppelte) - len(teile)
|
||
self.warnung.config(text=(
|
||
f"⚠ {len(self._doppelte)} Rechnungsnummer(n) doppelt vergeben: "
|
||
+ "; ".join(teile) + (f" … und {rest} weitere" if rest else "")
|
||
+ ". Die Beträge stehen trotzdem in allen Summen und im Amtsbericht – "
|
||
"hier wird nichts stillschweigend weggerechnet."))
|
||
self.doppelt_leiste.pack(fill="x", pady=(0, 4), before=self.fuss)
|
||
|
||
def doppelte_zeigen(self):
|
||
"""Alle Belege auflisten, die sich eine Rechnungsnummer teilen."""
|
||
fenster = tk.Toplevel(self)
|
||
fenster.title("Doppelt vergebene Rechnungsnummern")
|
||
fenster.transient(self)
|
||
fenster.geometry("940x520")
|
||
rahmen = ttk.Frame(fenster, padding=12)
|
||
rahmen.pack(fill="both", expand=True)
|
||
|
||
ttk.Label(rahmen, wraplength=900, justify="left", text=(
|
||
f"Jahr {self.jahr.get()}: diese Rechnungsnummern kommen mehrfach vor. "
|
||
"Jede Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG).\n\n"
|
||
"Alle Beträge sind in den Summen und im Bericht fürs Amt enthalten – hier wird "
|
||
"nichts weggerechnet. Zwei Zeilen zur selben Rechnung (z. B. einmal aus dem "
|
||
"Excel-Altbestand und einmal aus der PDF) melden die Übernachtung doppelt ans "
|
||
"Amt; zwei verschiedene Gäste unter einer Nummer sind ein Fehler im "
|
||
"Rechnungstool. Was zu tun ist, entscheidest du – das Programm ändert nichts "
|
||
"von allein.")).pack(anchor="w", pady=(0, 10))
|
||
|
||
innen = ttk.Frame(rahmen)
|
||
innen.pack(fill="both", expand=True)
|
||
innen.rowconfigure(0, weight=1)
|
||
innen.columnconfigure(0, weight=1)
|
||
senkrecht = ttk.Scrollbar(innen, orient="vertical")
|
||
senkrecht.grid(row=0, column=1, sticky="ns")
|
||
# Waagerecht, weil am Zeilenende der Dateiname steht - genau der sagt,
|
||
# welche der beiden Rechnungen die zweite ist.
|
||
waagerecht = ttk.Scrollbar(innen, orient="horizontal")
|
||
waagerecht.grid(row=1, column=0, sticky="ew")
|
||
text = tk.Text(innen, wrap="none", yscrollcommand=senkrecht.set,
|
||
xscrollcommand=waagerecht.set, relief="flat",
|
||
font=("Consolas", 9))
|
||
text.grid(row=0, column=0, sticky="nsew")
|
||
senkrecht.configure(command=text.yview)
|
||
waagerecht.configure(command=text.xview)
|
||
text.tag_configure("kopf", foreground=WARNUNG, font=("Consolas", 9, "bold"))
|
||
|
||
for nr, liste in self._doppelte.items():
|
||
text.insert("end", f"{nr} – {len(liste)} Belege\n", "kopf")
|
||
for b in liste:
|
||
datei = os.path.basename(b.pdf_pfad) if b.pdf_pfad else f"({b.quelle})"
|
||
text.insert("end",
|
||
f" {b.datum:%d.%m.%Y} {(b.nachname or '?'):22.22s} "
|
||
f"{b.naechte:>2} {'Nacht ' if b.naechte == 1 else 'Nächte'} "
|
||
f"{eur_kurz(b.gezahlt):>10s} "
|
||
f"Steuer {eur_kurz(b.steuer):>8s} {datei}\n")
|
||
text.insert("end", "\n")
|
||
text.configure(state="disabled")
|
||
|
||
ttk.Button(rahmen, text="Schließen", command=fenster.destroy).pack(side="right",
|
||
pady=(10, 0))
|
||
fenster.bind("<Escape>", lambda _e: fenster.destroy())
|
||
|
||
def jahr_gewechselt(self):
|
||
self.journal.setze("jahr", self.jahr.get())
|
||
self.aktualisieren()
|
||
|
||
# ---------------------------------------------------------- Aktionen ----
|
||
def ordner_waehlen(self):
|
||
ordner = filedialog.askdirectory(
|
||
title="Ordner mit den Rechnungs-PDFs wählen",
|
||
initialdir=self.pdf_ordner or os.path.expanduser("~"))
|
||
if not ordner:
|
||
return
|
||
self.pdf_ordner = os.path.abspath(ordner)
|
||
self.journal.setze("pdf_ordner", self.pdf_ordner)
|
||
self.aktualisieren()
|
||
self.einlesen()
|
||
|
||
def einlesen(self, voll: bool = False, still: bool = False):
|
||
if not self.pdf_ordner or not os.path.isdir(self.pdf_ordner):
|
||
if not still:
|
||
messagebox.showinfo("Rechnungs-Ordner",
|
||
"Bitte zuerst den Ordner mit den Rechnungs-PDFs wählen.",
|
||
parent=self)
|
||
return
|
||
|
||
self.config(cursor="watch")
|
||
self.melde("Rechnungen werden eingelesen…")
|
||
self.update_idletasks()
|
||
try:
|
||
neu, akt, unveraendert, fehler = self.journal.scanne(
|
||
self.pdf_ordner, self.satz, voll=voll)
|
||
finally:
|
||
self.config(cursor="")
|
||
|
||
self.aktualisieren()
|
||
text = f"{neu} neu, {akt} aktualisiert, {unveraendert} unverändert"
|
||
if fehler:
|
||
text += f", {len(fehler)} übersprungen"
|
||
self.melde(text)
|
||
|
||
if fehler and not still:
|
||
namen = "\n".join(f"• {os.path.basename(p)} – {g}" for p, g in fehler[:12])
|
||
if len(fehler) > 12:
|
||
namen += f"\n… und {len(fehler) - 12} weitere"
|
||
messagebox.showwarning(
|
||
"Nicht eingelesen",
|
||
f"{len(fehler)} Datei(en) sind keine lesbare Rechnung:\n\n{namen}",
|
||
parent=self)
|
||
|
||
# Es gibt bewusst KEIN "Buchung von Hand anlegen" mehr: jede Zeile im Journal
|
||
# gehoert zu einer Rechnung aus dem Rechnungstool. Wer eine Buchung braucht,
|
||
# schreibt die Rechnung - sonst meldet das Journal ans Amt, was in keinem
|
||
# Rechnungsordner steht. Bestehende Zeilen lassen sich weiter bearbeiten.
|
||
def buchung_bearbeiten(self, buchung):
|
||
d = BuchungDialog(self, buchung=buchung, satz=self.satz)
|
||
self.wait_window(d)
|
||
if d.loeschen:
|
||
self.journal.loeschen(buchung.id)
|
||
self.aktualisieren()
|
||
self.melde(f"Buchung {buchung.voll_nummer} gelöscht")
|
||
return
|
||
if d.ergebnis:
|
||
# Nummer/Jahr können geändert worden sein -> alte Zeile weicht der neuen
|
||
if d.ergebnis.schluessel != buchung.schluessel and buchung.id:
|
||
self.journal.loeschen(buchung.id)
|
||
self.journal.speichern(d.ergebnis)
|
||
self.aktualisieren()
|
||
self.melde(f"Buchung {d.ergebnis.voll_nummer} geändert")
|
||
|
||
def _markierte(self):
|
||
return self.monate[self._monat].markierte()
|
||
|
||
def buchung_loeschen(self):
|
||
b = self._markierte()
|
||
if not b:
|
||
return
|
||
if not messagebox.askyesno(
|
||
"Buchung löschen",
|
||
f"Rechnung {b.voll_nummer} ({b.nachname}) aus dem Journal löschen?\n\n"
|
||
"Die PDF-Datei bleibt erhalten – beim nächsten Einlesen "
|
||
"taucht die Buchung wieder auf.", parent=self):
|
||
return
|
||
self.journal.loeschen(b.id)
|
||
self.aktualisieren()
|
||
self.melde(f"Buchung {b.voll_nummer} gelöscht")
|
||
|
||
# ---- Anhaken + Papierkorb ----------------------------------------------
|
||
def _angehakte(self):
|
||
"""Alle angehakten Buchungen – über alle Monate hinweg."""
|
||
return [b for m in range(1, 13) for b in self.monate[m].angehakte()]
|
||
|
||
def papierkorb_auffrischen(self):
|
||
anzahl = len(self._angehakte())
|
||
self.papierkorb.config(
|
||
text=f"🗑 Löschen ({anzahl})" if anzahl else "🗑 Löschen",
|
||
state="normal" if anzahl else "disabled")
|
||
|
||
def angehakte_loeschen(self):
|
||
angehakt = self._angehakte()
|
||
if not angehakt:
|
||
return
|
||
|
||
aus_pdf = [b for b in angehakt if b.quelle == "pdf"]
|
||
namen = "\n".join(f"• {b.datum.strftime('%d.%m.%Y')} {b.voll_nummer} "
|
||
f"{b.nachname or 'ohne Namen'}" for b in angehakt[:15])
|
||
if len(angehakt) > 15:
|
||
namen += f"\n… und {len(angehakt) - 15} weitere"
|
||
hinweis = ""
|
||
if aus_pdf:
|
||
hinweis = (f"\n\n{len(aus_pdf)} davon stammen aus einer Rechnungs-PDF – die Dateien "
|
||
"bleiben liegen und tauchen beim nächsten Einlesen wieder auf.")
|
||
|
||
if not messagebox.askyesno(
|
||
"Angehakte Buchungen löschen",
|
||
f"{len(angehakt)} Buchung(en) aus dem Journal löschen?\n\n{namen}{hinweis}",
|
||
parent=self):
|
||
return
|
||
|
||
for b in angehakt:
|
||
self.journal.loeschen(b.id)
|
||
for m in range(1, 13):
|
||
self.monate[m].haken_loesen()
|
||
self.aktualisieren()
|
||
self.papierkorb_auffrischen()
|
||
self.melde(f"{len(angehakt)} Buchung(en) gelöscht")
|
||
|
||
def satz_aendern(self):
|
||
fenster = tk.Toplevel(self)
|
||
fenster.title("Steuersatz")
|
||
fenster.resizable(False, False)
|
||
fenster.transient(self)
|
||
fenster.grab_set()
|
||
rahmen = ttk.Frame(fenster, padding=14)
|
||
rahmen.pack()
|
||
ttk.Label(rahmen, text="Beherbergungssteuer in Prozent:").grid(row=0, column=0, padx=(0, 8))
|
||
eingabe = ttk.Entry(rahmen, width=8)
|
||
eingabe.insert(0, f"{self.satz:g}".replace(".", ","))
|
||
eingabe.grid(row=0, column=1)
|
||
ttk.Label(rahmen, text="Gilt für neu eingelesene und neue Buchungen.\n"
|
||
"Bereits erfasste Zeilen behalten ihren Satz.",
|
||
foreground="black").grid(row=1, column=0, columnspan=2, pady=(8, 10))
|
||
|
||
def speichern():
|
||
from modell import zahl_aus_text
|
||
wert = zahl_aus_text(eingabe.get())
|
||
if wert <= 0:
|
||
messagebox.showerror("Steuersatz", "Bitte einen Satz größer 0 eintragen.",
|
||
parent=fenster)
|
||
return
|
||
self.satz = wert
|
||
self.journal.setze("satz", wert)
|
||
self.melde(f"Steuersatz auf {wert:g} % gesetzt")
|
||
fenster.destroy()
|
||
|
||
ttk.Button(rahmen, text="Speichern", command=speichern) \
|
||
.grid(row=2, column=0, columnspan=2, sticky="ew")
|
||
fenster.bind("<Escape>", lambda _e: fenster.destroy())
|
||
fenster.bind("<Return>", lambda _e: speichern())
|
||
eingabe.focus_set()
|
||
|
||
def firma_waehlen(self):
|
||
"""config.json des Rechnungstools zeigen – sie liefert den Briefkopf des Berichts."""
|
||
pfad = filedialog.askopenfilename(
|
||
title="config.json des Rechnungstools wählen",
|
||
initialfile="config.json",
|
||
filetypes=[("Einstellungsdatei", "config.json"), ("Alle Dateien", "*.*")])
|
||
if not pfad:
|
||
return
|
||
self.journal.setze("rechnungstool_config", os.path.abspath(pfad))
|
||
self.firma = firma_lib.lade(os.path.abspath(pfad))
|
||
name = self.firma.get("name", "")
|
||
self.melde(f"Briefkopf: {name} (aus {os.path.basename(pfad)})")
|
||
messagebox.showinfo("Firmendaten",
|
||
f"Der Bericht trägt jetzt den Briefkopf von:\n\n{name}", parent=self)
|
||
|
||
def altbestand_importieren(self):
|
||
pfad = filedialog.askopenfilename(
|
||
title="Alte Beherbergungssteuer-Tabelle wählen",
|
||
filetypes=[("Excel-Mappe", "*.xlsx"), ("Alle Dateien", "*.*")])
|
||
if not pfad:
|
||
return
|
||
buchungen, fehler = xlsx_io.importiere(pfad, self.satz)
|
||
if not buchungen:
|
||
messagebox.showwarning("Import", "In der Tabelle wurden keine Buchungen gefunden.",
|
||
parent=self)
|
||
return
|
||
s = summiere(buchungen)
|
||
if not messagebox.askyesno(
|
||
"Alte Tabelle importieren",
|
||
f"{s.buchungen} Buchungen gefunden "
|
||
f"({s.naechte} Übernachtungen, Steuer {eur(s.steuer)}).\n\n"
|
||
"Bestehende Zeilen mit gleicher Rechnungsnummer werden überschrieben.\n"
|
||
"Importieren?", parent=self):
|
||
return
|
||
for b in buchungen:
|
||
self.journal.speichern(b)
|
||
self.aktualisieren()
|
||
self.melde(f"{len(buchungen)} Buchungen aus {os.path.basename(pfad)} importiert"
|
||
+ (f", {len(fehler)} Zeilen übersprungen" if fehler else ""))
|
||
|
||
@property
|
||
def _monat(self) -> int:
|
||
"""Der Monat des gerade offenen Reiters."""
|
||
return self.blaetter.index("current") + 1
|
||
|
||
def _bericht_bauen(self, jahr: int, monat: int = None) -> str:
|
||
"""Schreibt den Bericht nach berichte/ und gibt den Pfad zurück ('' = keine Buchungen)."""
|
||
buchungen = self.journal.buchungen(jahr=jahr)
|
||
if monat:
|
||
passend = [b for b in buchungen if b.monat == monat]
|
||
if not passend:
|
||
messagebox.showinfo(
|
||
"Bericht", f"Für {MONATE[monat - 1]} {jahr} sind keine Buchungen erfasst.",
|
||
parent=self)
|
||
return ""
|
||
pfad = os.path.join(BERICHTE, f"Beherbergungssteuer_{jahr}_{monat:02d}_"
|
||
f"{MONATE[monat - 1]}.pdf")
|
||
bericht_pdf.render_monat(pfad, self.firma, jahr, monat, buchungen)
|
||
else:
|
||
if not buchungen:
|
||
messagebox.showinfo("Bericht", f"Für {jahr} sind keine Buchungen erfasst.",
|
||
parent=self)
|
||
return ""
|
||
pfad = os.path.join(BERICHTE, f"Beherbergungssteuer_{jahr}_Jahr.pdf")
|
||
bericht_pdf.render_jahr(pfad, self.firma, jahr, buchungen)
|
||
return pfad
|
||
|
||
def bericht_monat(self):
|
||
jahr, monat = self.jahr.get(), self._monat
|
||
try:
|
||
pfad = self._bericht_bauen(jahr, monat)
|
||
except PermissionError:
|
||
messagebox.showerror("Bericht", "Die PDF ist geöffnet und konnte nicht "
|
||
"überschrieben werden. Bitte schließen.", parent=self)
|
||
return
|
||
if not pfad:
|
||
return
|
||
s = summiere(self.journal.buchungen(jahr=jahr, monat=monat))
|
||
self.melde(f"Bericht geschrieben: {pfad}")
|
||
if messagebox.askyesno(
|
||
"Bericht fertig",
|
||
f"{MONATE[monat - 1]} {jahr}: {s.buchungen} Rechnungen, "
|
||
f"{s.naechte} Übernachtungen,\nSteuer {eur(s.steuer)}\n\n{pfad}\n\n"
|
||
"PDF jetzt öffnen?", parent=self):
|
||
os.startfile(pfad) # noqa: S606 - Windows-Standardprogramm
|
||
|
||
def bericht_jahr(self):
|
||
jahr = self.jahr.get()
|
||
try:
|
||
pfad = self._bericht_bauen(jahr)
|
||
except PermissionError:
|
||
messagebox.showerror("Bericht", "Die PDF ist geöffnet und konnte nicht "
|
||
"überschrieben werden. Bitte schließen.", parent=self)
|
||
return
|
||
if not pfad:
|
||
return
|
||
s = summiere(self.journal.buchungen(jahr=jahr))
|
||
self.melde(f"Jahresbericht geschrieben: {pfad}")
|
||
if messagebox.askyesno(
|
||
"Bericht fertig",
|
||
f"Jahr {jahr}: {s.buchungen} Rechnungen, {s.naechte} Übernachtungen,\n"
|
||
f"Steuer {eur(s.steuer)}\n\n{pfad}\n\nPDF jetzt öffnen?", parent=self):
|
||
os.startfile(pfad) # noqa: S606
|
||
|
||
def _beenden(self):
|
||
self.journal.setze("jahr", self.jahr.get())
|
||
self.journal.schliessen()
|
||
self.destroy()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
log.info("Beherbergungssteuer startet (Python %s)", sys.version.split()[0])
|
||
# Die Sperre selbst greift schon ganz oben, vor dem Datenordner.
|
||
eigene_pid_merken()
|
||
App().mainloop()
|