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>
This commit is contained in:
TheMockTv 2026-07-12 19:15:44 +02:00
commit 80967de26f
12 changed files with 1908 additions and 0 deletions

12
.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
# Daten und Ausgaben gehören nicht ins Repo (Gästedaten!)
journal.sqlite3
journal.sqlite3-journal
beherbergungssteuer.log
berichte/
# Python
__pycache__/
*.py[cod]
build/
dist/
*.spec

View file

@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0"
start "" pythonw app.py

77
README.md Normal file
View file

@ -0,0 +1,77 @@
# Beherbergungssteuer Campinghof Bartl
Ersetzt die Excel-Mappe `Beherbergungsteuer.xlsx`.
**Quelle der Wahrheit sind die Rechnungs-PDFs** aus dem Rechnungstool. Das Programm
liest den Rechnungs-Ordner ein, führt daraus das Steuerjournal und schreibt auf
Knopfdruck die Excel-Mappe fürs Amt.
## Starten
- **Doppelklick** auf `Beherbergungssteuer starten.bat`
- oder im Terminal: `python app.py`
## Bedienung
1. **Rechnungs-Ordner…** → den Ordner wählen, in dem das Rechnungstool die PDFs ablegt
(Standard: `Dokumente\Campinghof Rechnungen`). Danach wird sofort eingelesen; bei
jedem Start passiert das automatisch.
2. **Rechnungen einlesen** (oder F5) holt neue Rechnungen nach. Bereits gelesene PDFs
werden übersprungen (Vergleich über Pfad, Änderungszeit und Größe) das geht auch
bei tausend Rechnungen schnell.
3. **Monats-Reiter** zeigen die Buchungen mit Summenzeile, unten rechts steht die
Jahressumme.
4. **PDF fürs Amt** schreibt den Monatsbericht des offenen Reiters als PDF im
Campinghof-Layout (gleicher Briefkopf wie die Rechnungen) nach `berichte/`:
Tabelle aller Rechnungen, Summenblock mit der Steuer, Unterschriftszeile.
Über das Menü gibt es zusätzlich den **Jahresbericht**. Gedruckt wird aus dem
PDF-Programm heraus.
Eine Zeile per **Doppelklick** bearbeiten, mit **Entf** aus dem Journal löschen
(die PDF bleibt liegen und wird beim nächsten Einlesen wieder gefunden).
Barzahlungen ohne PDF gehen über **Neue Buchung**.
Der Briefkopf des Berichts (Firma, Logo, Bankzeile) wird aus der `config.json` des
Rechnungstools gelesen also nur an einer Stelle gepflegt.
## Was aus der PDF gelesen wird
| Spalte | Herkunft in der Rechnung |
|--------|--------------------------|
| Datum | `Rechnungsdatum:` |
| Rechnung | `Rechnungsnummer:` (z.B. 2026-238) |
| Nachname | erste Zeile unter `Rechnung an:` |
| Übernachtungen | `Zeitraum: 3 Nächte` (ersatzweise An-/Abreise) |
| Gezahlt | `Zwischensumme:` das ist die Basis der Steuer |
| Steuer | `zzgl. Beherbergungssteuer (5 %):` |
Weicht der in der PDF ausgewiesene Steuerbetrag von der eigenen Rechnung ab,
steht das als Warnung in `beherbergungssteuer.log`.
## Excel einlesen
`Datei → Excel-Tabelle einlesen…` zieht die bisherige `Beherbergungsteuer.xlsx` ins
Journal sowohl den Altbestand aus der Zeit vor dem Rechnungstool als auch jede
spätere Fassung, die noch in Excel gepflegt wurde. Zeilen ohne PDF sind in der
Tabelle grau dargestellt. Rechnungsnummern werden dabei vereinheitlicht
(`001`, `13`, `2026-17``2026-013`), sonst entstünden doppelte Buchungen.
## Dateien
| Datei | Zweck |
|-------|-------|
| `app.py` | Fenster, Menü, Aktionen |
| `gui_monat.py` | ein Monatsblatt (Tabelle + Summenzeile) |
| `gui_dialoge.py` | Buchung von Hand anlegen/bearbeiten |
| `pdf_parser.py` | Rechnungs-PDF → Buchung |
| `bericht_pdf.py` | Monats-/Jahresbericht fürs Amt (reportlab) |
| `firma.py` | Briefkopf aus der `config.json` des Rechnungstools |
| `db.py` | Journal (SQLite) + PDF-Scan mit Cache |
| `xlsx_io.py` | Excel-Import |
| `modell.py` | Datenmodell + Steuerrechnung (die einzige Rechenstelle) |
| `journal.sqlite3` | die Daten (wird beim 1. Start angelegt) |
| `berichte/` | die erzeugten Amts-PDFs |
## Voraussetzungen
Python 3.10+, `pypdf`, `openpyxl` und `reportlab` (`pip install pypdf openpyxl reportlab`).

454
app.py Normal file
View file

@ -0,0 +1,454 @@
# -*- 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
import db
import xlsx_io
import firma as firma_lib
import bericht_pdf
from modell import MONATE, STANDARD_SATZ, summiere, eur
from gui_monat import MonatsTab, SummenLeiste
from gui_dialoge import BuchungDialog
BASE_DIR = os.path.dirname(os.path.abspath(sys.argv[0] if getattr(sys, "frozen", False) else __file__))
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="Buchung von Hand anlegen…", command=self.buchung_anlegen)
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)
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="Neue Buchung", command=self.buchung_anlegen).pack(side="left", padx=6)
ttk.Button(leiste, text="PDF fürs Amt", command=self.bericht_monat).pack(side="left")
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))
fuss = ttk.Frame(rahmen)
fuss.pack(fill="x")
self.status = ttk.Label(fuss, text="", foreground="black")
self.status.pack(side="left")
self.ordner_anzeige = ttk.Label(fuss, text="", foreground="black")
self.ordner_anzeige.pack(side="right")
# ----------------------------------------------------------- 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
for m in range(1, 13):
self.monate[m].fuellen([b for b in sichtbar if b.monat == m])
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)
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)
def buchung_anlegen(self):
d = BuchungDialog(self, jahr=self.jahr.get(), satz=self.satz)
self.wait_window(d)
if d.ergebnis:
self.journal.speichern(d.ergebnis)
self.jahr.set(d.ergebnis.jahr)
self.aktualisieren()
self.blaetter.select(d.ergebnis.monat - 1)
self.melde(f"Buchung {d.ergebnis.voll_nummer} gespeichert")
def buchung_bearbeiten(self, buchung):
d = BuchungDialog(self, jahr=buchung.jahr, satz=self.satz, buchung=buchung)
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 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__":
App().mainloop()

318
bericht_pdf.py Normal file
View file

@ -0,0 +1,318 @@
# -*- coding: utf-8 -*-
"""
PDF-Bericht fürs Amt im gleichen Layout wie die Rechnungen des Rechnungstools.
Firmenkopf (Logo) -> Titel + Zeitraum -> Tabelle der Rechnungen
-> Summenblock (Steuer hervorgehoben)
-> Unterschriftszeile -> Bankfußzeile
render_monat(pfad, firma, jahr, monat, buchungen)
render_jahr (pfad, firma, jahr, buchungen) ein Blatt mit allen 12 Monaten
"""
import os
import logging
from datetime import date
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.lib import colors
from reportlab.lib.enums import TA_RIGHT, TA_LEFT, TA_CENTER
from reportlab.lib.styles import ParagraphStyle
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
from modell import MONATE, summiere, eur, eur_kurz
log = logging.getLogger("bst.bericht")
# Farben wie in der Rechnung
ACCENT = colors.HexColor("#1f5c3d")
GREY_BG = colors.HexColor("#eef1ee")
LINE = colors.HexColor("#9aa39c")
DARK = colors.HexColor("#222222")
MARGIN_L = MARGIN_R = 18 * mm
MARGIN_TOP = 40 * mm
MARGIN_BOT = 34 * mm
PAGE_W, PAGE_H = A4
CONTENT_W = PAGE_W - MARGIN_L - MARGIN_R
def _styles():
return {
"zelle": ParagraphStyle("zelle", fontName="Helvetica", fontSize=8.5,
leading=10.5, textColor=DARK, alignment=TA_LEFT),
"zelle_r": ParagraphStyle("zelle_r", fontName="Helvetica", fontSize=8.5,
leading=10.5, textColor=DARK, alignment=TA_RIGHT),
"zelle_c": ParagraphStyle("zelle_c", fontName="Helvetica", fontSize=8.5,
leading=10.5, textColor=DARK, alignment=TA_CENTER),
"th": ParagraphStyle("th", fontName="Helvetica-Bold", fontSize=8,
leading=9.3, textColor=DARK, alignment=TA_CENTER),
"th_l": ParagraphStyle("th_l", fontName="Helvetica-Bold", fontSize=8,
leading=9.3, textColor=DARK, alignment=TA_LEFT),
"h2": ParagraphStyle("h2", fontName="Helvetica-Bold", fontSize=15,
leading=19, textColor=ACCENT, spaceAfter=2),
"unter": ParagraphStyle("unter", fontName="Helvetica", fontSize=9.5,
leading=13, textColor=DARK, spaceAfter=6),
"sum_l": ParagraphStyle("sum_l", fontName="Helvetica-Bold", fontSize=9,
leading=13, textColor=DARK, alignment=TA_RIGHT),
"sum_r": ParagraphStyle("sum_r", fontName="Helvetica", fontSize=9,
leading=13, textColor=DARK, alignment=TA_RIGHT),
"total_l": ParagraphStyle("total_l", fontName="Helvetica-Bold", fontSize=10.5,
leading=14, textColor=DARK, alignment=TA_RIGHT),
"total_v": ParagraphStyle("total_v", fontName="Helvetica-Bold", fontSize=10.5,
leading=14, textColor=ACCENT, alignment=TA_RIGHT),
"klein": ParagraphStyle("klein", fontName="Helvetica", fontSize=8.5,
leading=11, textColor=DARK),
}
def _kopf_fuss(canvas, doc, firma):
canvas.saveState()
line_y = PAGE_H - 25 * mm
logo = (firma.get("logo") or "").strip()
logo_ok = False
if logo and os.path.exists(logo):
try:
img = ImageReader(logo)
iw, ih = img.getSize()
scale = min(60 * mm / iw, 20 * mm / ih)
dw, dh = iw * scale, ih * scale
ly = PAGE_H - 9 * mm - dh
canvas.drawImage(img, MARGIN_L, ly, width=dw, height=dh,
preserveAspectRatio=True, mask="auto")
logo_ok = True
line_y = ly - 3 * mm
except Exception as e: # noqa: BLE001
log.warning("Logo nicht ladbar (%s): %s", logo, e)
if logo_ok:
top = PAGE_H - 15 * mm
canvas.setFillColor(ACCENT)
canvas.setFont("Helvetica-Bold", 16)
canvas.drawRightString(PAGE_W - MARGIN_R, top, firma.get("name", ""))
canvas.setFillColor(DARK)
canvas.setFont("Helvetica-Oblique", 9.5)
canvas.drawRightString(PAGE_W - MARGIN_R, top - 5.5 * mm, firma.get("untertitel", ""))
else:
top = PAGE_H - 16 * mm
canvas.setFillColor(ACCENT)
canvas.setFont("Helvetica-Bold", 19)
canvas.drawString(MARGIN_L, top, firma.get("name", ""))
canvas.setFillColor(DARK)
canvas.setFont("Helvetica-Oblique", 10.5)
canvas.drawString(MARGIN_L, top - 6.2 * mm, firma.get("untertitel", ""))
line_y = top - 9.5 * mm
canvas.setStrokeColor(ACCENT)
canvas.setLineWidth(1.1)
canvas.line(MARGIN_L, line_y, PAGE_W - MARGIN_R, line_y)
# Fußzeile
fy = MARGIN_BOT - 4 * mm
canvas.setStrokeColor(LINE)
canvas.setLineWidth(0.6)
canvas.line(MARGIN_L, fy + 13 * mm, PAGE_W - MARGIN_R, fy + 13 * mm)
canvas.setFont("Helvetica", 7.3)
canvas.setFillColor(DARK)
links = [firma.get("bank", ""), firma.get("iban", ""), firma.get("bic", "")]
mitte = [firma.get("inhaber", ""), firma.get("steuernummer", "")]
rechts = [firma.get("homepage", ""), firma.get("email", ""), firma.get("telefon", "")]
for i, t in enumerate(links):
canvas.drawString(MARGIN_L, fy + 9 * mm - i * 3.6 * mm, t)
for i, t in enumerate(mitte):
canvas.drawString(MARGIN_L + CONTENT_W * 0.40, fy + 9 * mm - i * 3.6 * mm, t)
for i, t in enumerate(rechts):
canvas.drawRightString(PAGE_W - MARGIN_R, fy + 9 * mm - i * 3.6 * mm, t)
canvas.setFont("Helvetica", 7.3)
canvas.drawCentredString(PAGE_W / 2, fy + 1 * mm, f"Seite {canvas.getPageNumber()}")
canvas.restoreState()
def _titel(story, st, firma, titel, zeitraum):
story.append(Paragraph(
f"<u>{firma.get('absender','')}</u>",
ParagraphStyle("abs", fontName="Helvetica", fontSize=7.5, textColor=DARK)))
story.append(Spacer(1, 6 * mm))
story.append(Paragraph(titel, st["h2"]))
story.append(Paragraph(zeitraum, st["unter"]))
story.append(Spacer(1, 2 * mm))
def _summenblock(story, st, s, satz_text=""):
zeilen = [
[Paragraph("Rechnungen:", st["sum_l"]), Paragraph(str(s.buchungen), st["sum_r"])],
[Paragraph("Übernachtungen:", st["sum_l"]), Paragraph(str(s.naechte), st["sum_r"])],
[Paragraph("Steuerpflichtiges Entgelt:", st["sum_l"]), Paragraph(eur(s.gezahlt), st["sum_r"])],
[Paragraph(f"Beherbergungssteuer{satz_text}:", st["total_l"]),
Paragraph(eur(s.steuer), st["total_v"])],
]
t = Table(zeilen, colWidths=[CONTENT_W - 44 * mm, 44 * mm])
t.setStyle(TableStyle([
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 1.5),
("BOTTOMPADDING", (0, 0), (-1, -1), 1.5),
("LINEABOVE", (0, -1), (1, -1), 0.8, ACCENT),
("LINEBELOW", (0, -1), (1, -1), 0.8, ACCENT),
("TOPPADDING", (0, -1), (-1, -1), 4),
]))
story.append(Spacer(1, 4 * mm))
story.append(t)
def _unterschrift(story, st, ort):
story.append(Spacer(1, 14 * mm))
heute = date.today().strftime("%d.%m.%Y")
t = Table([[Paragraph(f"{ort}, den {heute}", st["klein"]),
Paragraph("Unterschrift / Stempel", st["klein"])]],
colWidths=[CONTENT_W * 0.5, CONTENT_W * 0.5])
t.setStyle(TableStyle([
("LINEABOVE", (0, 0), (0, 0), 0.6, LINE),
("LINEABOVE", (1, 0), (1, 0), 0.6, LINE),
("TOPPADDING", (0, 0), (-1, -1), 2),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (1, 0), (1, 0), 0),
]))
story.append(t)
def _dokument(pfad, firma, titel):
return SimpleDocTemplate(
pfad, pagesize=A4,
leftMargin=MARGIN_L, rightMargin=MARGIN_R,
topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT,
title=titel, author=firma.get("name", ""))
def _ort(firma):
"""Ort aus der Absenderzeile ziehen ('… - 04416 Markkleeberg OT Wachau')."""
teile = [t.strip() for t in str(firma.get("absender", "")).split("-")]
if teile and teile[-1]:
worte = teile[-1].split()
if worte and worte[0].isdigit():
worte = worte[1:] # Postleitzahl weg
return " ".join(worte) or "Markkleeberg"
return "Markkleeberg"
# ------------------------------------------------------------- Monat --------
def render_monat(pfad, firma, jahr: int, monat: int, buchungen):
st = _styles()
liste = sorted([b for b in buchungen if b.jahr == jahr and b.monat == monat],
key=lambda b: (b.datum, b.rechnungsnummer))
s = summiere(liste)
saetze = {b.satz for b in liste}
satz_text = f" ({eur_kurz(list(saetze)[0]).replace(',00','')} %)" if len(saetze) == 1 else ""
story = []
_titel(story, st, firma, "Beherbergungssteuer Steueranmeldung",
f"Zeitraum: {MONATE[monat - 1]} {jahr}")
kopf = ["Datum", "Rechnung", "Name", "Nächte", "Entgelt", "Satz %", "Steuer"]
stile = ["th", "th", "th_l", "th", "th", "th", "th"]
daten = [[Paragraph(k, st[s_]) for k, s_ in zip(kopf, stile)]]
for b in liste:
daten.append([
Paragraph(b.datum.strftime("%d.%m.%Y"), st["zelle_c"]),
Paragraph(b.voll_nummer, st["zelle_c"]),
Paragraph(b.nachname, st["zelle"]),
Paragraph(str(b.naechte), st["zelle_c"]),
Paragraph(eur_kurz(b.gezahlt), st["zelle_r"]),
Paragraph(eur_kurz(b.satz).replace(",00", ""), st["zelle_c"]),
Paragraph(eur_kurz(b.steuer), st["zelle_r"]),
])
if len(daten) == 1:
daten.append([Paragraph("", st["zelle_c"]),
Paragraph("<i>(keine Buchungen in diesem Monat)</i>", st["zelle"]),
"", "", "", "", ""])
# Namensspalte nur so breit wie der breiteste Name (Rest der Tabelle ist fest)
breiteste = max([stringWidth(b.nachname, "Helvetica", 8.5) for b in liste]
+ [stringWidth("Name", "Helvetica-Bold", 8)])
name_w = min(max(breiteste + 6 * mm, 25 * mm), CONTENT_W - 120 * mm)
tabelle = Table(daten, repeatRows=1, hAlign="LEFT",
colWidths=[22 * mm, 22 * mm, name_w, 15 * mm, 22 * mm, 14 * mm, 25 * mm])
tabelle.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), GREY_BG),
("LINEBELOW", (0, 0), (-1, 0), 0.8, ACCENT),
("LINEBELOW", (0, 1), (-1, -1), 0.35, LINE),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
("LEFTPADDING", (0, 0), (-1, -1), 3),
("RIGHTPADDING", (0, 0), (-1, -1), 3),
]))
story.append(tabelle)
_summenblock(story, st, s, satz_text)
story.append(Spacer(1, 3 * mm))
story.append(Paragraph(
"Die Beherbergungssteuer wird gemäß kommunaler Satzung auf das steuerpflichtige "
"Entgelt der Beherbergungsleistungen erhoben und mit den Gästen abgerechnet.",
st["klein"]))
_unterschrift(story, st, _ort(firma))
_dokument(pfad, firma, f"Beherbergungssteuer {MONATE[monat - 1]} {jahr}").build(
story,
onFirstPage=lambda c, d: _kopf_fuss(c, d, firma),
onLaterPages=lambda c, d: _kopf_fuss(c, d, firma))
log.info("Monatsbericht %s: %d Buchungen, Steuer %.2f", pfad, s.buchungen, s.steuer)
return pfad
# -------------------------------------------------------------- Jahr --------
def render_jahr(pfad, firma, jahr: int, buchungen):
st = _styles()
liste = [b for b in buchungen if b.jahr == jahr]
gesamt = summiere(liste)
story = []
_titel(story, st, firma, "Beherbergungssteuer Jahresübersicht", f"Zeitraum: Jahr {jahr}")
kopf = ["Monat", "Rechnungen", "Übernachtungen", "Entgelt", "Steuer"]
daten = [[Paragraph(k, st["th_l"] if i == 0 else st["th"]) for i, k in enumerate(kopf)]]
for m in range(1, 13):
s = summiere([b for b in liste if b.monat == m])
daten.append([
Paragraph(MONATE[m - 1], st["zelle"]),
Paragraph(str(s.buchungen), st["zelle_c"]),
Paragraph(str(s.naechte), st["zelle_c"]),
Paragraph(eur_kurz(s.gezahlt), st["zelle_r"]),
Paragraph(eur_kurz(s.steuer), st["zelle_r"]),
])
daten.append([
Paragraph("Summe", st["total_l"]),
Paragraph(str(gesamt.buchungen), st["total_v"]),
Paragraph(str(gesamt.naechte), st["total_v"]),
Paragraph(eur_kurz(gesamt.gezahlt), st["total_v"]),
Paragraph(eur_kurz(gesamt.steuer), st["total_v"]),
])
tabelle = Table(daten, repeatRows=1,
colWidths=[40 * mm, 28 * mm, 34 * mm, 34 * mm, 38 * mm])
tabelle.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), GREY_BG),
("LINEBELOW", (0, 0), (-1, 0), 0.8, ACCENT),
("LINEBELOW", (0, 1), (-1, -2), 0.35, LINE),
("LINEABOVE", (0, -1), (-1, -1), 0.8, ACCENT),
("LINEBELOW", (0, -1), (-1, -1), 0.8, ACCENT),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 3.5),
("BOTTOMPADDING", (0, 0), (-1, -1), 3.5),
("LEFTPADDING", (0, 0), (-1, -1), 3),
("RIGHTPADDING", (0, 0), (-1, -1), 3),
]))
story.append(tabelle)
_unterschrift(story, st, _ort(firma))
_dokument(pfad, firma, f"Beherbergungssteuer Jahresübersicht {jahr}").build(
story,
onFirstPage=lambda c, d: _kopf_fuss(c, d, firma),
onLaterPages=lambda c, d: _kopf_fuss(c, d, firma))
log.info("Jahresbericht %s: %d Buchungen, Steuer %.2f", pfad, gesamt.buchungen, gesamt.steuer)
return pfad

194
db.py Normal file
View file

@ -0,0 +1,194 @@
# -*- coding: utf-8 -*-
"""
SQLite-Ablage des Steuerjournals.
Die Wahrheit sind die Rechnungs-PDFs die Datenbank ist nur der schnelle
Zwischenspeicher, damit die Oberfläche nicht bei jedem Start alle PDFs neu
lesen muss. Ein Scan liest nur PDFs, die neu sind oder sich geändert haben
(Pfad + Änderungszeit + Größe).
Altbestand (Zeilen aus der alten Excel-Tabelle) und von Hand erfasste
Buchungen stehen mit quelle='xlsx' bzw. 'manuell' daneben und werden vom
PDF-Scan nicht angefasst.
"""
import os
import logging
import sqlite3
from datetime import date, datetime
from modell import Buchung
log = logging.getLogger("bst.db")
SCHEMA = """
CREATE TABLE IF NOT EXISTS buchungen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jahr INTEGER NOT NULL,
rechnungsnummer TEXT NOT NULL,
datum TEXT NOT NULL, -- ISO: YYYY-MM-DD
nachname TEXT NOT NULL DEFAULT '',
naechte INTEGER NOT NULL DEFAULT 0,
gezahlt REAL NOT NULL DEFAULT 0,
satz REAL NOT NULL DEFAULT 5,
quelle TEXT NOT NULL DEFAULT 'pdf',
pdf_pfad TEXT NOT NULL DEFAULT '',
pdf_mtime REAL NOT NULL DEFAULT 0,
pdf_groesse INTEGER NOT NULL DEFAULT 0,
UNIQUE (jahr, rechnungsnummer)
);
CREATE INDEX IF NOT EXISTS idx_datum ON buchungen (jahr, datum);
CREATE TABLE IF NOT EXISTS einstellungen (
schluessel TEXT PRIMARY KEY,
wert TEXT NOT NULL
);
"""
class Journal:
def __init__(self, pfad: str):
self.pfad = pfad
neu = not os.path.exists(pfad)
self.con = sqlite3.connect(pfad)
self.con.row_factory = sqlite3.Row
self.con.executescript(SCHEMA)
self.con.commit()
log.info("Journal %s (%s)", pfad, "neu angelegt" if neu else "geöffnet")
def schliessen(self):
self.con.close()
# ---- Einstellungen -----------------------------------------------------
def hole(self, schluessel: str, standard: str = "") -> str:
r = self.con.execute("SELECT wert FROM einstellungen WHERE schluessel=?",
(schluessel,)).fetchone()
return r["wert"] if r else standard
def setze(self, schluessel: str, wert: str):
self.con.execute(
"INSERT INTO einstellungen (schluessel, wert) VALUES (?, ?) "
"ON CONFLICT(schluessel) DO UPDATE SET wert=excluded.wert",
(schluessel, str(wert)))
self.con.commit()
# ---- Lesen -------------------------------------------------------------
def jahre(self):
return [r["jahr"] for r in
self.con.execute("SELECT DISTINCT jahr FROM buchungen ORDER BY jahr")]
def buchungen(self, jahr: int = None, monat: int = None):
sql = "SELECT * FROM buchungen"
bed, args = [], []
if jahr:
bed.append("jahr = ?")
args.append(jahr)
if monat:
bed.append("CAST(strftime('%m', datum) AS INTEGER) = ?")
args.append(monat)
if bed:
sql += " WHERE " + " AND ".join(bed)
sql += " ORDER BY datum, rechnungsnummer"
return [self._zu_buchung(r) for r in self.con.execute(sql, args)]
@staticmethod
def _zu_buchung(r: sqlite3.Row) -> Buchung:
return Buchung(
id=r["id"], jahr=r["jahr"], rechnungsnummer=r["rechnungsnummer"],
datum=datetime.strptime(r["datum"], "%Y-%m-%d").date(),
nachname=r["nachname"], naechte=r["naechte"], gezahlt=r["gezahlt"],
satz=r["satz"], quelle=r["quelle"], pdf_pfad=r["pdf_pfad"],
)
def _pdf_bekannt(self, pfad: str) -> bool:
"""True, wenn genau diese Datei unverändert schon eingelesen wurde."""
try:
st = os.stat(pfad)
except OSError:
return False
r = self.con.execute(
"SELECT 1 FROM buchungen WHERE pdf_pfad=? AND pdf_mtime=? AND pdf_groesse=?",
(os.path.abspath(pfad), st.st_mtime, st.st_size)).fetchone()
return r is not None
# ---- Schreiben ---------------------------------------------------------
def speichern(self, b: Buchung) -> int:
"""Legt die Buchung an oder aktualisiert sie (Schlüssel: Jahr + Rechnungsnummer)."""
mtime = groesse = 0
if b.pdf_pfad and os.path.exists(b.pdf_pfad):
st = os.stat(b.pdf_pfad)
mtime, groesse = st.st_mtime, st.st_size
cur = self.con.execute(
"INSERT INTO buchungen "
" (jahr, rechnungsnummer, datum, nachname, naechte, gezahlt, satz, "
" quelle, pdf_pfad, pdf_mtime, pdf_groesse) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?) "
"ON CONFLICT(jahr, rechnungsnummer) DO UPDATE SET "
" datum=excluded.datum, nachname=excluded.nachname, naechte=excluded.naechte, "
" gezahlt=excluded.gezahlt, satz=excluded.satz, quelle=excluded.quelle, "
" pdf_pfad=excluded.pdf_pfad, pdf_mtime=excluded.pdf_mtime, "
" pdf_groesse=excluded.pdf_groesse",
(b.jahr, b.rechnungsnummer, b.datum.isoformat(), b.nachname, int(b.naechte or 0),
float(b.gezahlt or 0), float(b.satz or 0), b.quelle, b.pdf_pfad, mtime, groesse))
self.con.commit()
return cur.lastrowid
def loeschen(self, buchung_id: int):
self.con.execute("DELETE FROM buchungen WHERE id=?", (buchung_id,))
self.con.commit()
log.info("Buchung %s gelöscht", buchung_id)
def leeren(self, quelle: str = None):
if quelle:
self.con.execute("DELETE FROM buchungen WHERE quelle=?", (quelle,))
else:
self.con.execute("DELETE FROM buchungen")
self.con.commit()
# ---- PDF-Scan ----------------------------------------------------------
def scanne(self, ordner: str, standard_satz: float = 5.0, voll: bool = False):
"""
Liest den PDF-Ordner ein und schreibt alles Neue/Geänderte ins Journal.
voll=False: unveränderte PDFs werden übersprungen (schnell).
voll=True : alles neu einlesen (nach Layout-/Satz-Änderungen).
Gibt (neu, aktualisiert, uebersprungen, fehler) zurück.
"""
from pdf_parser import parse_pdf, ParserFehler # lokal: hält modell/db leichtgewichtig
neu = akt = uebersprungen = 0
fehler = []
if not os.path.isdir(ordner):
return 0, 0, 0, [(ordner, "Ordner existiert nicht")]
for wurzel, _dirs, dateien in os.walk(ordner):
for name in sorted(dateien):
if not name.lower().endswith(".pdf"):
continue
pfad = os.path.join(wurzel, name)
if not voll and self._pdf_bekannt(pfad):
uebersprungen += 1
continue
try:
b = parse_pdf(pfad, standard_satz)
except ParserFehler as e:
log.info("keine Rechnung: %s (%s)", name, e)
fehler.append((pfad, str(e)))
continue
except Exception as e: # noqa: BLE001
log.warning("Fehler bei %s: %s", name, e)
fehler.append((pfad, f"{type(e).__name__}: {e}"))
continue
vorhanden = self.con.execute(
"SELECT id FROM buchungen WHERE jahr=? AND rechnungsnummer=?",
(b.jahr, b.rechnungsnummer)).fetchone()
self.speichern(b)
if vorhanden:
akt += 1
else:
neu += 1
log.info("Scan fertig: %d neu, %d aktualisiert, %d unverändert, %d Fehler",
neu, akt, uebersprungen, len(fehler))
return neu, akt, uebersprungen, fehler

64
firma.py Normal file
View file

@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
"""
Firmendaten für den Briefkopf des Amts-Berichts.
Sie stehen bereits in der `config.json` des Rechnungstools von dort werden sie
gelesen, damit Rechnung und Steuerbericht garantiert denselben Kopf tragen und
nichts doppelt gepflegt werden muss.
Wird das Rechnungstool nicht gefunden, greift ein neutraler Ersatz und der
Bericht wird trotzdem gedruckt (nur ohne Logo/Bankzeile).
"""
import os
import json
import logging
log = logging.getLogger("bst.firma")
SUCHPFADE = [
r"C:\claude\rechnungstool\config.json",
os.path.join(os.path.expanduser("~"), "Documents", "rechnungstool", "config.json"),
]
ERSATZ = {
"name": "Campinghof Bartl",
"untertitel": "Camping & Ferienwohnung am Markkleeberger See",
"absender": "Campinghof Bartl - Bornaer Chaussee 36 - 04416 Markkleeberg OT Wachau",
"inhaber": "", "bank": "", "iban": "", "bic": "",
"homepage": "", "email": "", "telefon": "", "steuernummer": "", "logo": "",
}
def config_pfad(gespeichert: str = "") -> str:
"""Erster gefundener Pfad zur config.json des Rechnungstools ('' = keiner)."""
for p in ([gespeichert] if gespeichert else []) + SUCHPFADE:
if p and os.path.isfile(p):
return os.path.abspath(p)
return ""
def lade(gespeichert: str = "") -> dict:
"""Firmendaten laden. Der Logo-Pfad wird absolut gemacht."""
pfad = config_pfad(gespeichert)
if not pfad:
log.warning("config.json des Rechnungstools nicht gefunden Ersatz-Briefkopf")
return dict(ERSATZ)
try:
with open(pfad, encoding="utf-8") as f:
cfg = json.load(f)
except Exception as e: # noqa: BLE001 - kaputte/fremde Datei
log.warning("config.json nicht lesbar (%s): %s", pfad, e)
return dict(ERSATZ)
daten = dict(ERSATZ)
daten.update(cfg.get("firma", {}))
logo = str(daten.get("logo") or "").strip()
if logo and not os.path.isabs(logo):
kandidat = os.path.join(os.path.dirname(pfad), logo)
daten["logo"] = kandidat if os.path.exists(kandidat) else ""
log.info("Firmendaten aus %s", pfad)
return daten

142
gui_dialoge.py Normal file
View file

@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
"""
Dialog für eine Buchung von Hand (z.B. Barzahlung ohne PDF-Rechnung)
und zum Nachbessern einer eingelesenen Zeile.
Schließen mit ESC, Speichern mit Enter oder Knopf.
"""
import tkinter as tk
from tkinter import ttk, messagebox
from datetime import datetime, date
from modell import (Buchung, STANDARD_SATZ, steuer_betrag, eur, zahl_aus_text,
zerlege_nummer)
class BuchungDialog(tk.Toplevel):
"""
Modal. Nach dem Schließen steht das Ergebnis in
.ergebnis die gespeicherte Buchung (oder None, wenn abgebrochen)
.loeschen True, wenn die Buchung gelöscht werden soll
"""
def __init__(self, master, jahr: int, satz: float = STANDARD_SATZ, buchung: Buchung = None):
super().__init__(master)
self.ergebnis = None
self.loeschen = False
self._vorlage = buchung
self._jahr = buchung.jahr if buchung else jahr
self.title("Buchung bearbeiten" if buchung else "Neue Buchung")
self.resizable(False, False)
self.transient(master)
self.grab_set()
rahmen = ttk.Frame(self, padding=14)
rahmen.grid(sticky="nsew")
self.felder = {}
zeilen = [
("datum", "Rechnungsdatum", (buchung.datum.strftime("%d.%m.%Y") if buchung
else date.today().strftime("%d.%m.%Y"))),
("nummer", "Rechnungsnummer", buchung.rechnungsnummer if buchung else ""),
("nachname", "Nachname", buchung.nachname if buchung else ""),
("naechte", "Übernachtungen", str(buchung.naechte) if buchung else "1"),
("gezahlt", "Gezahlt (Zwischensumme)", f"{buchung.gezahlt:.2f}".replace(".", ",") if buchung else ""),
("satz", "Steuersatz %", f"{(buchung.satz if buchung else satz):g}".replace(".", ",")),
]
for i, (schluessel, beschriftung, wert) in enumerate(zeilen):
ttk.Label(rahmen, text=beschriftung + ":").grid(row=i, column=0, sticky="w", pady=3, padx=(0, 10))
e = ttk.Entry(rahmen, width=26)
e.insert(0, wert)
e.grid(row=i, column=1, sticky="ew", pady=3)
e.bind("<KeyRelease>", lambda _e: self._vorschau())
self.felder[schluessel] = e
self.vorschau = ttk.Label(rahmen, text="", foreground="black",
font=("Segoe UI", 10, "bold"), anchor="e")
self.vorschau.grid(row=len(zeilen), column=0, columnspan=2, sticky="ew", pady=(10, 4))
knoepfe = ttk.Frame(rahmen)
knoepfe.grid(row=len(zeilen) + 1, column=0, columnspan=2, sticky="ew", pady=(6, 0))
ttk.Button(knoepfe, text="Speichern", command=self._speichern) \
.pack(side="left", fill="x", expand=True)
if buchung: # nur beim Bearbeiten eine neue Buchung gibt es noch nicht zu löschen
ttk.Button(knoepfe, text="Löschen", command=self._loeschen) \
.pack(side="left", fill="x", expand=True, padx=(8, 0))
rahmen.columnconfigure(1, weight=1)
self.bind("<Escape>", lambda _e: self.destroy())
self.bind("<Return>", lambda _e: self._speichern())
self.felder["datum" if buchung else "nummer"].focus_set()
self._vorschau()
self.update_idletasks()
x = master.winfo_rootx() + (master.winfo_width() - self.winfo_width()) // 2
y = master.winfo_rooty() + 120
self.geometry(f"+{max(0, x)}+{max(0, y)}")
# ---- Eingaben lesen ----------------------------------------------------
def _werte(self):
gezahlt = zahl_aus_text(self.felder["gezahlt"].get())
satz = zahl_aus_text(self.felder["satz"].get())
return gezahlt, satz
def _vorschau(self):
gezahlt, satz = self._werte()
self.vorschau.config(text=f"Beherbergungssteuer: {eur(steuer_betrag(gezahlt, satz))}")
def _loeschen(self):
b = self._vorlage
hinweis = ("\n\nDie Rechnungs-PDF bleibt liegen beim nächsten Einlesen "
"taucht die Buchung wieder auf.") if b.quelle == "pdf" else ""
if not messagebox.askyesno(
"Buchung löschen",
f"Rechnung {b.voll_nummer} ({b.nachname or 'ohne Namen'}) "
f"aus dem Journal löschen?{hinweis}", parent=self):
return
self.loeschen = True
self.destroy()
def _speichern(self):
text_datum = self.felder["datum"].get().strip()
datum = None
for fmt in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d"):
try:
datum = datetime.strptime(text_datum, fmt).date()
break
except ValueError:
continue
if not datum:
messagebox.showerror("Datum", f"Datum nicht lesbar: {text_datum}\n"
"Erwartet wird z.B. 12.07.2026", parent=self)
return
# "238" und "2026-238" werden beide angenommen
jahr, nummer = zerlege_nummer(self.felder["nummer"].get(), datum.year)
if not nummer:
messagebox.showerror("Rechnungsnummer", "Bitte eine Rechnungsnummer eintragen "
"(z.B. 238).", parent=self)
return
gezahlt, satz = self._werte()
if gezahlt <= 0:
messagebox.showerror("Gezahlt", "Bitte den Rechnungsbetrag (Zwischensumme) "
"eintragen.", parent=self)
return
self.ergebnis = Buchung(
datum=datum,
rechnungsnummer=nummer,
jahr=jahr,
nachname=self.felder["nachname"].get().strip(),
naechte=int(zahl_aus_text(self.felder["naechte"].get()) or 0),
gezahlt=gezahlt,
satz=satz,
quelle=self._vorlage.quelle if self._vorlage else "manuell",
pdf_pfad=self._vorlage.pdf_pfad if self._vorlage else "",
id=self._vorlage.id if self._vorlage else 0,
)
self.destroy()

187
gui_monat.py Normal file
View file

@ -0,0 +1,187 @@
# -*- 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))

150
modell.py Normal file
View file

@ -0,0 +1,150 @@
# -*- coding: utf-8 -*-
"""
Datenmodell + Rechenkern der Beherbergungssteuer.
Eine Buchung = eine Zeile im Steuerjournal = eine Rechnung.
Gerechnet wird ausschließlich hier, damit GUI, Excel-Export und PDF-Import
garantiert dieselben Zahlen zeigen.
Steuerlogik (wie in der Excel-Vorlage und im Rechnungstool):
Steuer = Gezahlt / 100 * Satz
wobei "Gezahlt" die Zwischensumme der Rechnung ist (Leistungen brutto,
also inkl. USt, aber ohne die Beherbergungssteuer selbst).
"""
import re
from dataclasses import dataclass, field
from datetime import date
MONATE = [
"Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember",
]
MONATE_KURZ = ["JAN", "FEB", "MÄR", "APR", "MAI", "JUN",
"JUL", "AUG", "SEP", "OKT", "NOV", "DEZ"]
STANDARD_SATZ = 5.0 # Prozent Beherbergungssteuer
def runde(x) -> float:
"""Kaufmännisch auf 2 Nachkommastellen runden."""
return round(float(x) + 1e-9, 2)
def steuer_betrag(gezahlt, satz) -> float:
"""Beherbergungssteuer auf einen Rechnungsbetrag: Gezahlt / 100 * Satz."""
return runde(float(gezahlt or 0) / 100.0 * float(satz or 0))
@dataclass
class Buchung:
"""Eine Zeile des Steuerjournals."""
datum: date
rechnungsnummer: str # nur der Zähler, z.B. "238" (das Jahr steckt in .jahr)
jahr: int
nachname: str
naechte: int
gezahlt: float # Zwischensumme der Rechnung (Basis der Steuer)
satz: float = STANDARD_SATZ
quelle: str = "pdf" # pdf | xlsx | manuell
pdf_pfad: str = ""
id: int = 0
@property
def steuer(self) -> float:
return steuer_betrag(self.gezahlt, self.satz)
@property
def monat(self) -> int:
"""1-12"""
return self.datum.month
@property
def voll_nummer(self) -> str:
return f"{self.jahr}-{self.rechnungsnummer}"
@property
def schluessel(self) -> str:
"""Eindeutig pro Rechnung verhindert Doppel-Import derselben PDF."""
return f"{self.jahr}-{self.rechnungsnummer}"
@dataclass
class Summe:
"""Aufsummierte Kennzahlen einer Menge von Buchungen (Monat oder Jahr)."""
buchungen: int = 0
naechte: int = 0
gezahlt: float = 0.0
steuer: float = 0.0
gesamt: float = field(default=0.0) # gezahlt + steuer (wie I2 in der Vorlage)
def summiere(buchungen) -> Summe:
s = Summe()
for b in buchungen:
s.buchungen += 1
s.naechte += int(b.naechte or 0)
s.gezahlt += float(b.gezahlt or 0)
s.steuer += b.steuer
s.gezahlt = runde(s.gezahlt)
s.steuer = runde(s.steuer)
s.gesamt = runde(s.gezahlt + s.steuer)
return s
def eur(x) -> str:
"""1234.5 -> '1.234,50 €' (deutsche Schreibweise)."""
s = f"{float(x or 0):,.2f}"
s = s.replace(",", "#").replace(".", ",").replace("#", ".")
return s + ""
def eur_kurz(x) -> str:
"""Wie eur(), aber ohne Währungszeichen (für enge Tabellenspalten)."""
s = f"{float(x or 0):,.2f}"
return s.replace(",", "#").replace(".", ",").replace("#", ".")
RE_NUMMER = re.compile(r"^\s*(?:(?P<jahr>\d{4})\s*[-/]\s*)?(?P<nr>.+?)\s*$")
def zerlege_nummer(text, standard_jahr: int):
"""
Bringt jede Schreibweise der Rechnungsnummer auf eine Form.
'2026-238' -> (2026, '238')
'013' -> (standard_jahr, '013')
'13' -> (standard_jahr, '013') dreistellig wie im Rechnungstool
'A-7' -> (standard_jahr, 'A-7') nicht-numerisch bleibt, wie es ist
Ohne diese Normalisierung wären '17' und '2026-17' zwei verschiedene
Buchungen in der alten Excel-Tabelle kommt beides vor.
"""
s = " ".join(str(text or "").split())
if not s:
return standard_jahr, ""
m = RE_NUMMER.match(s)
jahr = int(m.group("jahr")) if m.group("jahr") else standard_jahr
nr = m.group("nr")
if nr.isdigit():
nr = f"{int(nr):03d}"
return jahr, nr
def zahl_aus_text(text) -> float:
"""'1.234,50 €' / '87,00' / '13.00' -> float. Leere/kaputte Eingabe -> 0.0"""
if text is None:
return 0.0
if isinstance(text, (int, float)):
return float(text)
s = "".join(c for c in str(text) if c.isdigit() or c in ",.-")
if not s:
return 0.0
# Deutsche Schreibweise: Punkt = Tausender, Komma = Dezimal
if "," in s:
s = s.replace(".", "").replace(",", ".")
try:
return float(s)
except ValueError:
return 0.0

221
pdf_parser.py Normal file
View file

@ -0,0 +1,221 @@
# -*- coding: utf-8 -*-
"""
Liest eine Rechnungs-PDF des Rechnungstools (Campinghof Bartl) und macht
daraus eine Buchung fürs Steuerjournal.
Der PDF-Text ist zeilenweise aufgebaut: erst das Label, dann in der
nächsten Zeile der Wert.
Rechnung an:
Familie Peter Meier
Musterweg 12
Rechnungsnummer:
2026-238
Rechnungsdatum:
12.07.2026
Zeitraum:
3 Nächte
...
Zwischensumme:
87,00
zzgl. Beherbergungssteuer (5 %):
4,35
Gesamtbetrag:
91,35
Nur Seite 1 wird gelesen Seite 2 ist die 1:1-Kopie fürs Amt.
"""
import os
import re
import logging
from datetime import datetime, date
from pypdf import PdfReader
from modell import Buchung, STANDARD_SATZ, zahl_aus_text, runde, zerlege_nummer
log = logging.getLogger("bst.pdf")
# Labels, hinter denen der Wert in der nächsten Zeile steht
RE_NUMMER = re.compile(r"^\s*Rechnungsnummer:\s*$", re.I)
RE_DATUM = re.compile(r"^\s*Rechnungsdatum:\s*$", re.I)
RE_ANREISE = re.compile(r"^\s*Anreise:\s*$", re.I)
RE_ABREISE = re.compile(r"^\s*Abreise:\s*$", re.I)
RE_ZEITRAUM = re.compile(r"^\s*Zeitraum:\s*$", re.I)
RE_KUNDE = re.compile(r"^\s*Rechnung an:\s*$", re.I)
RE_ZWISCHEN = re.compile(r"^\s*Zwischensumme:\s*$", re.I)
RE_GESAMT = re.compile(r"^\s*Gesamtbetrag:\s*$", re.I)
# "zzgl. Beherbergungssteuer (5 %):" Satz steht in der Klammer
RE_AUFSCHLAG = re.compile(r"^\s*zzgl\.\s*(?P<name>.+?)\s*\(\s*(?P<satz>[\d.,]+)\s*%\s*\):\s*$", re.I)
RE_NAECHTE = re.compile(r"(\d+)")
class ParserFehler(Exception):
"""Die Datei ist keine (lesbare) Rechnung dieses Tools."""
def _text_seite1(pfad: str) -> str:
reader = PdfReader(pfad)
if not reader.pages:
raise ParserFehler("PDF hat keine Seiten")
return reader.pages[0].extract_text() or ""
def _naechster_wert(zeilen, i: int) -> str:
"""Erste nicht-leere Zeile nach Index i."""
for z in zeilen[i + 1:]:
if z.strip():
return z.strip()
return ""
def _datum(text: str) -> date:
for fmt in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d"):
try:
return datetime.strptime(text.strip(), fmt).date()
except ValueError:
continue
raise ParserFehler(f"Datum nicht lesbar: {text!r}")
RECHTSFORM = {"gmbh", "ag", "kg", "ug", "gbr", "ohg", "mbh", "e.k.", "ek", "eg", "se",
"gmbh & co. kg", "verein", "e.v."}
NAMENSZUSATZ = {"van", "von", "de", "der", "den", "di", "du", "le", "la", "el", "al",
"ten", "ter", "zu", "vom", "zum", "zur", "mc", "mac", "o'"}
def nachname_aus_zeile(zeile: str) -> str:
"""
'Familie Peter Meier' -> 'Meier'
'Anna Schmidt-Lorenz' -> 'Schmidt-Lorenz'
'Meier, Peter' -> 'Meier'
'Familie van Dijk' -> 'van Dijk' (Namenszusatz bleibt dran)
'Hentschke Bau GmbH' -> 'Hentschke Bau GmbH' (Firma komplett)
"""
z = " ".join(str(zeile or "").split())
if not z:
return ""
if "," in z: # "Nachname, Vorname"
return z.split(",", 1)[0].strip()
teile = z.split()
if teile[-1].lower().strip(".,") in RECHTSFORM:
return z # Firma der ganze Name gehört dazu
if len(teile) >= 2 and teile[-2].lower().strip(".") in NAMENSZUSATZ:
return " ".join(teile[-2:]) # "van Dijk"
return teile[-1]
def parse_pdf(pfad: str, standard_satz: float = STANDARD_SATZ) -> Buchung:
"""Rechnungs-PDF -> Buchung. Wirft ParserFehler, wenn Pflichtfelder fehlen."""
text = _text_seite1(pfad)
zeilen = text.splitlines()
voll_nr = datum_txt = ""
anreise = abreise = ""
naechte = 0
kunde = ""
zwischensumme = None
gesamtbetrag = None
steuer_betrag_pdf = None
satz = None
for i, z in enumerate(zeilen):
if RE_NUMMER.match(z):
voll_nr = _naechster_wert(zeilen, i)
elif RE_DATUM.match(z):
datum_txt = _naechster_wert(zeilen, i)
elif RE_ANREISE.match(z):
anreise = _naechster_wert(zeilen, i)
elif RE_ABREISE.match(z):
abreise = _naechster_wert(zeilen, i)
elif RE_ZEITRAUM.match(z):
m = RE_NAECHTE.search(_naechster_wert(zeilen, i))
if m:
naechte = int(m.group(1))
elif RE_KUNDE.match(z):
kunde = _naechster_wert(zeilen, i)
elif RE_ZWISCHEN.match(z) and zwischensumme is None:
zwischensumme = zahl_aus_text(_naechster_wert(zeilen, i))
elif RE_GESAMT.match(z) and gesamtbetrag is None:
gesamtbetrag = zahl_aus_text(_naechster_wert(zeilen, i))
else:
m = RE_AUFSCHLAG.match(z)
if m and steuer_betrag_pdf is None:
satz = zahl_aus_text(m.group("satz"))
steuer_betrag_pdf = zahl_aus_text(_naechster_wert(zeilen, i))
if not voll_nr or not datum_txt:
raise ParserFehler("keine Rechnungsnummer/kein Rechnungsdatum gefunden")
datum = _datum(datum_txt)
jahr, nummer = zerlege_nummer(voll_nr, datum.year)
if not nummer:
raise ParserFehler(f"Rechnungsnummer nicht lesbar: {voll_nr!r}")
# Basis der Steuer = Zwischensumme. Fehlt sie (Rechnung ohne Beherbergungs-
# steuer), ist der Gesamtbetrag die Zwischensumme.
gezahlt = zwischensumme if zwischensumme is not None else gesamtbetrag
if gezahlt is None:
raise ParserFehler("weder Zwischensumme noch Gesamtbetrag gefunden")
if satz is None:
satz = 0.0 if steuer_betrag_pdf is None else standard_satz
# Nächte notfalls aus An-/Abreise ableiten
if not naechte and anreise and abreise:
try:
naechte = (_datum(abreise) - _datum(anreise)).days
except ParserFehler:
naechte = 0
b = Buchung(
datum=datum,
rechnungsnummer=nummer,
jahr=jahr,
nachname=nachname_aus_zeile(kunde),
naechte=max(0, naechte),
gezahlt=runde(gezahlt),
satz=satz,
quelle="pdf",
pdf_pfad=os.path.abspath(pfad),
)
# Gegenprobe: unsere Rechnung gegen den in der PDF ausgewiesenen Betrag
if steuer_betrag_pdf is not None and abs(b.steuer - steuer_betrag_pdf) > 0.02:
log.warning("%s: Steuer laut PDF %.2f, berechnet %.2f (Basis %.2f, Satz %s%%)",
os.path.basename(pfad), steuer_betrag_pdf, b.steuer, b.gezahlt, satz)
log.debug("gelesen: %s | %s | %s | %s Nächte | %.2f € | Steuer %.2f",
os.path.basename(pfad), b.voll_nummer, b.nachname, b.naechte, b.gezahlt, b.steuer)
return b
def scanne_ordner(ordner: str, standard_satz: float = STANDARD_SATZ, rekursiv: bool = True):
"""
Liest alle PDFs unter `ordner`.
Gibt (buchungen, fehler) zurück fehler = Liste von (pfad, grund).
"""
buchungen, fehler = [], []
if not os.path.isdir(ordner):
return buchungen, [(ordner, "Ordner existiert nicht")]
for wurzel, _dirs, dateien in os.walk(ordner):
for name in sorted(dateien):
if not name.lower().endswith(".pdf"):
continue
pfad = os.path.join(wurzel, name)
try:
buchungen.append(parse_pdf(pfad, standard_satz))
except ParserFehler as e:
log.info("übersprungen: %s (%s)", name, e)
fehler.append((pfad, str(e)))
except Exception as e: # noqa: BLE001 - kaputte/verschlüsselte PDF
log.warning("Fehler bei %s: %s", name, e)
fehler.append((pfad, f"{type(e).__name__}: {e}"))
if not rekursiv:
break
log.info("Scan %s: %d Rechnungen, %d übersprungen", ordner, len(buchungen), len(fehler))
return buchungen, fehler

86
xlsx_io.py Normal file
View file

@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
"""
Excel-Import.
Liest die bisherige Steuertabelle (`Beherbergungsteuer.xlsx`) ins Journal
sowohl den Altbestand aus der Zeit vor dem Rechnungstool als auch jede spätere
Fassung, die noch von Hand in Excel gepflegt wurde.
Ausgegeben wird nicht mehr nach Excel: Der Bericht fürs Amt ist ein PDF
(siehe `bericht_pdf.py`).
"""
import re
import logging
from datetime import datetime, date
import openpyxl
from modell import Buchung, MONATE, STANDARD_SATZ, runde, zerlege_nummer
log = logging.getLogger("bst.xlsx")
ERSTE_ZEILE = 5
RE_BLATT_JAHR = re.compile(r"(20\d{2})")
def _blatt_jahr(ws):
"""Jahr aus der Blatt-Überschrift ('JAN 2026 - Beherbergungssteuer') oder None."""
m = RE_BLATT_JAHR.search(str(ws["A1"].value or ""))
return int(m.group(1)) if m else None
def importiere(pfad: str, standard_satz: float = STANDARD_SATZ, quelle: str = "xlsx"):
"""
Liest eine Steuertabelle im Layout der Vorlage ein:
A Datum | B Rechnungsnummer | C Nachname | D Übernachtungen | E Gezahlt | F Satz
Zeilen, deren Datums-Jahr nicht zum Jahr des Monatsblattes passt (in der alten
Tabelle steht z.B. 27.01.2025 mitten im Januar-2026-Blatt), werden als Vertipper
behandelt und aufs Blatt-Jahr gezogen mit Eintrag im Log.
Gibt (buchungen, fehler) zurück; fehler = Liste von (Ort, Grund).
"""
buchungen, fehler = [], []
wb = openpyxl.load_workbook(pfad, data_only=True)
for ws in wb.worksheets:
if ws.title not in MONATE: # nur die Monatsblätter
continue
jahr_blatt = _blatt_jahr(ws)
for row in ws.iter_rows(min_row=ERSTE_ZEILE, max_row=ws.max_row, max_col=6):
datum, nummer, nachname, naechte, gezahlt, satz = [c.value for c in row]
if datum is None and nachname is None:
continue
ort = f"{ws.title}!{row[0].row}"
if not isinstance(datum, (datetime, date)):
fehler.append((ort, f"Datum fehlt oder ist kein Datum: {datum!r}"))
continue
d = datum.date() if isinstance(datum, datetime) else datum
if jahr_blatt and d.year != jahr_blatt:
log.warning("%s: Datum %s passt nicht zum Blatt-Jahr %d auf %d korrigiert",
ort, d.strftime("%d.%m.%Y"), jahr_blatt, jahr_blatt)
d = d.replace(year=jahr_blatt)
jahr, nr = zerlege_nummer(nummer, d.year)
if not nr:
fehler.append((ort, "keine Rechnungsnummer"))
continue
buchungen.append(Buchung(
datum=d,
rechnungsnummer=nr,
jahr=jahr,
nachname=str(nachname or "").strip(),
naechte=int(naechte or 0),
gezahlt=runde(gezahlt or 0),
satz=float(satz or standard_satz),
quelle=quelle,
pdf_pfad="",
))
log.info("Import %s: %d Zeilen, %d übersprungen", pfad, len(buchungen), len(fehler))
return buchungen, fehler