Steuerrechnungstool/app.py
TheMockTv 87d92d15b6 Amtsbericht: eigene Spalte "Grund", und die stornierte Zeile nennt ihren Storno
Zwei Ansagen von ihm, beide am selben Blatt:

1. "weil die nummer hast nur auf der storno selber aber nicht am
   stornierten nummer um die es geht" - die aufgehobene Rechnung stand
   nur als "storniert" da. Jetzt steht dort "storniert mit 2026-901".
   Damit ist das Paar in BEIDE Richtungen lesbar, und das zaehlt vor
   allem dann, wenn der Storno in einem anderen Monat liegt und auf
   diesem Blatt gar nicht auftaucht.

2. "mach eine neue spalte mit grund oder so was" - der Vermerk stand als
   zweite Zeile unter der Rechnungsnummer. Jetzt ist es eine eigene
   Spalte "Grund" zwischen Rechnung und Name, eine Zeile je Buchung.
   Feste Breiten neu verteilt (20/20/34 mm + Rest fuer den Namen), damit
   nichts umbricht.

Dafuer neu: modell.aufhebungen() gibt {id der Rechnung: Storno-Buchung}
statt nur der ids - die Anzeige will auch sagen, WOMIT storniert wurde,
nicht nur DASS. aufgehobene() bleibt als duenne Huelle darueber, damit
zeig_stornos und doppelte_nummern unveraendert weiterlaufen.

Fenster und Blatt zeigen wieder dieselben Worte.

pruef_storno_verrechnet prueft beide Richtungen mit, auch ueber die
Monatsgrenze ("storniert mit 2026-911" im August, der Storno selbst steht
im September). Angesehen: Tabelle als Bild gerendert, alles einzeilig,
auch mit langem Namen. doppelte und berichtigung gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bEsoFUk16DfnNA7MjY36H
2026-09-04 01:38:36 +02:00

743 lines
34 KiB
Python
Raw 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 -*-
"""
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 queue
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 api
import db
import gemeinsam
import ordnerwahl
import xlsx_io
import firma as firma_lib
import bericht_pdf
import ablage
from modell import (MONATE, STANDARD_SATZ, summiere, eur, eur_kurz,
aufhebungen, doppelte_nummern)
from gui_monat import MonatsTab, SummenLeiste, WARNUNG
# 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")
# Testlauf: kein Erststart-Dialog. Pruefstaende fahren das Fenster fern, ein
# modaler Dialog wuerde sie haengen lassen - und in der EXE kommt man an den
# Schalter ohnehin nicht heran (Doppelklick uebergibt keine Argumente).
# python app.py --test oder CAMPINGHOF_TEST=1
TESTLAUF = ("--test" in sys.argv) or os.environ.get("CAMPINGHOF_TEST") == "1"
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.buch = None # gemeinsames Nummernbuch, siehe gemeinsam.py
# Der eigene kleine Server. Klopft das Rechnungstool an, wird sofort
# nachgesehen - ohne auf den naechsten Durchgang der Wache zu warten.
self.api = api.MiniApi("journal", self._push_empfangen)
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.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))
self.after(400, self._frage_gemeinsam_falls_noetig)
# Beide Programme laufen nebeneinander: schreibt das Rechnungstool eine
# Rechnung, meldet es das hier herein - siehe _wache_jetzt().
self._briefkasten_stand = "?" # "?" = noch nicht nachgesehen
self._push_kasten = queue.Queue() # Zettel aus dem API-Faden
self.after(250, self._push_abholen)
self._nachricht_stand = 0 # bis hierher sind Meldungen gelesen
self.after(600, self._wache)
# Beim Zurueckklicken sofort nachsehen statt zwei Sekunden zu warten.
self.bind("<FocusIn>", lambda _e: self._wache_jetzt())
# ------------------------------------------------------------ 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_separator()
daten.add_command(label="Steuersatz ändern…", command=self.satz_aendern)
daten.add_command(label="Firmendaten des Rechnungstools wählen…",
command=self.firma_waehlen)
daten.add_command(label="Gemeinsamer Ordner (Nummernbuch)…",
command=self.dlg_gemeinsam)
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))
# Kein Papierkorb mehr: im Journal wird nichts von Hand geloescht. Eine
# Zeile aus einer PDF kaeme beim naechsten Einlesen ohnehin zurueck, eine
# Zeile aus dem Excel-Altbestand waere fuer immer weg - samt Uebernachtung
# im Amtsbericht (§ 146 Abs. 4 AO). Soll eine Buchung raus, wandert die PDF
# aus dem Rechnungsordner; den Rest macht der Scan von selbst.
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)
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._aufgehoben = set()
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)
# Was ein Storno aufgehoben hat, steht weiter da - in normaler Schrift,
# gekennzeichnet nur ueber die Spalte "Art", und es zaehlt nicht mehr
# als offene Rechnung.
# dict {id der Rechnung: Storno-Buchung} - die Anzeige will auch sagen,
# WOMIT storniert wurde, nicht nur DASS.
self._aufgehoben = aufhebungen(alle)
if self._doppelte:
# Gehoert ins Log: der Amtsbericht wird aus diesen Zeilen gebaut, und
# spaeter soll nachvollziehbar sein, wann der Fall zuerst dastand.
log.warning("Jahr %s: %d doppelt vergebene Rechnungsnummer(n): %s",
jahr, len(self._doppelte),
", ".join(f"{nr} ({len(v)}x)" for nr, v in self._doppelte.items()))
for m in range(1, 13):
self.monate[m].fuellen([b for b in sichtbar if b.monat == m],
self._doppelte, self._aufgehoben)
# 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.ordner_anzeige.config(
text=f"Rechnungen: {self.pdf_ordner}" if self.pdf_ordner
else "kein Rechnungs-Ordner gewählt (Datei → Rechnungs-Ordner wählen…)")
# ------------------------------------------------------------- Wache
# Das Rechnungstool MELDET, wenn es eine Rechnung geschrieben hat - es legt
# eine Zeile in den Briefkasten im gemeinsamen Ordner. Hier wird nur diese
# eine Datei angesehen (ein os.stat), nicht der ganze Rechnungsordner.
# Damit das Rechnungstool weiss, dass jemand zuhoert, traegt sich dieses
# Fenster beim Start mit seiner PID ein (siehe _anmelden).
WACHE_SEKUNDEN = 2
def _wache_jetzt(self):
"""Einmal nachsehen. True, wenn eine Meldung da war und eingelesen wurde.
Billig gehalten: erst ein os.stat auf die gemeinsame Datei. Nur wenn
sich dort etwas geruehrt hat, wird sie ueberhaupt geoeffnet.
"""
if self.buch is None:
return False
stand = gemeinsam.datei_stand(self.buch.ordner)
if self._briefkasten_stand == "?":
# Erster Blick nach dem Start: nur merken, auch wenn es die Datei
# noch gar nicht gibt - sonst gilt die erste Meldung danach als
# "schon gesehen" und wird verschluckt.
self._briefkasten_stand = stand
self._nachricht_stand = self._letzte_nachricht()
return False
if stand is None or stand == self._briefkasten_stand:
return False
self._briefkasten_stand = stand
try:
texte, neu = self.buch.nachrichten_ab(self._nachricht_stand)
except Exception as e: # noqa: BLE001
log.warning("Briefkasten nicht lesbar: %s", e)
return False
if not texte:
return False # das Buch hat sich geaendert, aber ohne Meldung
self._nachricht_stand = neu
text = texte[-1] if len(texte) == 1 else f"{len(texte)} Meldungen, zuletzt: {texte[-1]}"
log.info("Rechnungstool meldet: %s", text)
self.einlesen(still=True)
self.melde(f"Rechnungstool meldet: {text}")
return True
def _letzte_nachricht(self):
try:
return self.buch.letzte_nachricht() if self.buch else 0
except Exception: # noqa: BLE001
return 0
def _wache(self):
"""Der Wecker. Laeuft weiter, auch wenn ein Durchgang schiefgeht."""
try:
self._wache_jetzt()
except Exception as e: # noqa: BLE001 - die Wache darf nie sterben
log.warning("Wache: %s", e)
finally:
self.after(self.WACHE_SEKUNDEN * 1000, self._wache)
def suche_leeren(self):
self.suche.set("")
self.suchfeld.focus_set()
def melde(self, text: str):
self.status.config(text=text)
log.info(text)
# ---- Gemeinsames Nummernbuch -------------------------------------------
def _anmelden(self):
"""Mit der eigenen PID im gemeinsamen Ordner eintragen.
Daran sieht das Rechnungstool, dass dieses Fenster offen ist - es fragt
Windows nach der Prozessnummer, nicht nach einem Fenster.
"""
ordner = self.journal.hole("gemeinsam_ordner", "")
if not ordner:
return
if self.buch is None:
self._buch_oeffnen()
if self.buch is None:
return
# Die Mini-API aufmachen und mit Port und PID ins Telefonbuch: daran
# sieht das Rechnungstool, dass jemand zuhoert, und kann anklopfen.
self.api.starten(self.buch)
if not self.api.port:
try:
self.buch.anmelden("journal", os.getpid(),
os.path.basename(sys.executable))
except Exception as e: # noqa: BLE001
log.warning("Anmeldung fehlgeschlagen: %s", e)
def _abmelden(self):
self.api.stoppen(self.buch)
def _push_empfangen(self):
"""Ruft die API aus IHREM Faden - Tkinter darf man von dort nicht anfassen.
Also nur einen Zettel in die Warteschlange legen; das Fenster holt ihn
gleich ab (_push_abholen).
"""
self._push_kasten.put(1)
def _push_abholen(self):
"""Im Fenster-Faden: liegt ein Zettel da, sofort in der SQLite nachsehen."""
try:
geklopft = False
while True:
try:
self._push_kasten.get_nowait()
geklopft = True
except queue.Empty:
break
if geklopft:
self._wache_jetzt()
except Exception as e: # noqa: BLE001 - darf das Fenster nie umwerfen
log.warning("Push: %s", e)
finally:
self.after(250, self._push_abholen)
def _buch_oeffnen(self):
ordner = self.journal.hole("gemeinsam_ordner", "")
if not ordner:
self.buch = None
return
taugt, grund = gemeinsam.ordner_taugt(ordner)
if not taugt:
self.buch = None
log.warning("Nummernbuch nicht erreichbar: %s", grund)
return
self.buch = gemeinsam.Nummernbuch(ordner)
def _frage_gemeinsam_falls_noetig(self):
"""Beim ersten Start nach dem gemeinsamen Ordner fragen.
Das Buch ist der Weg, auf dem das Rechnungstool die Nummern aus dem
Altbestand erfaehrt - zu denen es keine PDF gibt und die es sonst ein
zweites Mal vergeben wuerde.
"""
self._buch_oeffnen()
if self.buch is None and not TESTLAUF:
self.dlg_gemeinsam(erststart=True)
self._anmelden()
self._buch_fuellen()
def dlg_gemeinsam(self, erststart=False):
ordner = ordnerwahl.ordner_abfragen(self, "Steuerjournal",
self.journal.hole("gemeinsam_ordner", ""))
if not ordner:
if erststart:
messagebox.showwarning(
"Ohne gemeinsamen Ordner",
"Es wurde kein gemeinsamer Ordner gewählt.\n\n"
"Das Rechnungstool erfährt dann nicht, welche Nummern hier schon "
"vergeben sind - auch nicht die aus der alten Excel-Tabelle.\n\n"
"Nachholen: Daten → Gemeinsamer Ordner (Nummernbuch).", parent=self)
return
self.journal.setze("gemeinsam_ordner", ordner)
self._buch_oeffnen()
self._anmelden()
self._buch_fuellen(melden=True)
def _buch_fuellen(self, melden=False):
"""Alle Buchungen ins gemeinsame Buch schreiben.
Auch die aus der Excel-Mappe und die von Hand angelegten - genau die
kennt das Rechnungstool sonst nicht. Faellt das Buch aus (Netzlaufwerk
weg), passiert hier nichts weiter: das Journal arbeitet ohne es.
"""
if self.buch is None:
return
anzahl = 0
try:
for b in self.journal.buchungen():
self.buch.eintragen(
b.voll_nummer, jahr=b.jahr, datum=b.datum.isoformat(),
nachname=b.nachname, naechte=int(b.naechte or 0),
zwischensumme=float(b.gezahlt or 0), steuer_satz=float(b.satz or 0),
steuer_betrag=float(b.steuer or 0),
gesamt=float(b.gezahlt or 0) + float(b.steuer or 0),
art=b.art or "rechnung", storno_zu=b.storno_zu or "",
vorgang=b.vorgang or "", quelle="journal", herkunft=b.quelle,
pdf_pfad=b.pdf_pfad or "")
anzahl += 1
except Exception as e: # noqa: BLE001
log.warning("Nummernbuch: Schreiben fehlgeschlagen (%s)", e)
if melden:
messagebox.showwarning(
"Nummernbuch nicht erreichbar",
f"Die Nummern konnten nicht eingetragen werden:\n{e}", parent=self)
return
log.info("Nummernbuch: %d Buchungen eingetragen", anzahl)
if melden:
messagebox.showinfo(
"Nummernbuch",
f"{anzahl} Rechnungsnummern stehen jetzt im gemeinsamen Buch.\n\n"
"Wähle im Rechnungstool denselben Ordner - dann vergibt es keine "
"dieser Nummern noch einmal.", parent=self)
self.melde(f"Nummernbuch: {anzahl} Nummern eingetragen")
# ---- 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 "")
+ ". Jede Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG) – eine "
"der beiden im Rechnungstool stornieren und mit freier Nummer neu "
"ausstellen. Bis dahin stehen die Beträge in allen Summen."))
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.\n\n"
"SO WIRD ES GERADEGERÜCKT: eine der beiden Rechnungen im Rechnungstool "
"stornieren und mit einer freien Nummer neu ausstellen. Das Storno trägt "
"Nummer, Datum UND Empfänger der aufgehobenen Rechnung – bei einer doppelt "
"vergebenen Nummer wäre die Nummer allein kein eindeutiger Bezug, und "
"§ 31 Abs. 5 UStDV verlangt genau den. Danach steht die Zeile hier grau als "
"„storniert“ und die Warnung verschwindet 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()
# Was neu eingelesen wurde, gehoert auch ins gemeinsame Buch.
self._buch_fuellen()
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)
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):
# Austragen, damit das Rechnungstool nicht weiter meldet.
self._abmelden()
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()