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:
commit
80967de26f
12 changed files with 1908 additions and 0 deletions
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal 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
|
||||||
3
Beherbergungssteuer starten.bat
Normal file
3
Beherbergungssteuer starten.bat
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
@echo off
|
||||||
|
cd /d "%~dp0"
|
||||||
|
start "" pythonw app.py
|
||||||
77
README.md
Normal file
77
README.md
Normal 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
454
app.py
Normal 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
318
bericht_pdf.py
Normal 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
194
db.py
Normal 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
64
firma.py
Normal 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
142
gui_dialoge.py
Normal 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
187
gui_monat.py
Normal 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
150
modell.py
Normal 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
221
pdf_parser.py
Normal 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
86
xlsx_io.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue