From 80967de26fded396c56803d1e4dad86cb7eaab2c Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Sun, 12 Jul 2026 19:15:44 +0200 Subject: [PATCH] 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) --- .gitignore | 12 + Beherbergungssteuer starten.bat | 3 + README.md | 77 ++++++ app.py | 454 ++++++++++++++++++++++++++++++++ bericht_pdf.py | 318 ++++++++++++++++++++++ db.py | 194 ++++++++++++++ firma.py | 64 +++++ gui_dialoge.py | 142 ++++++++++ gui_monat.py | 187 +++++++++++++ modell.py | 150 +++++++++++ pdf_parser.py | 221 ++++++++++++++++ xlsx_io.py | 86 ++++++ 12 files changed, 1908 insertions(+) create mode 100644 .gitignore create mode 100644 Beherbergungssteuer starten.bat create mode 100644 README.md create mode 100644 app.py create mode 100644 bericht_pdf.py create mode 100644 db.py create mode 100644 firma.py create mode 100644 gui_dialoge.py create mode 100644 gui_monat.py create mode 100644 modell.py create mode 100644 pdf_parser.py create mode 100644 xlsx_io.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c05b0d7 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Beherbergungssteuer starten.bat b/Beherbergungssteuer starten.bat new file mode 100644 index 0000000..39ff4c0 --- /dev/null +++ b/Beherbergungssteuer starten.bat @@ -0,0 +1,3 @@ +@echo off +cd /d "%~dp0" +start "" pythonw app.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..a3846ce --- /dev/null +++ b/README.md @@ -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`). diff --git a/app.py b/app.py new file mode 100644 index 0000000..a9bfe4b --- /dev/null +++ b/app.py @@ -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("", lambda _e: self.einlesen()) + self.bind("", lambda _e: self.suchfeld.focus_set()) + self.bind("", 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("<>", 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("", 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("", lambda _e: fenster.destroy()) + fenster.bind("", 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() diff --git a/bericht_pdf.py b/bericht_pdf.py new file mode 100644 index 0000000..8df7603 --- /dev/null +++ b/bericht_pdf.py @@ -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"{firma.get('absender','')}", + 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("(keine Buchungen in diesem Monat)", 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 diff --git a/db.py b/db.py new file mode 100644 index 0000000..42cad6f --- /dev/null +++ b/db.py @@ -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 diff --git a/firma.py b/firma.py new file mode 100644 index 0000000..16ba51d --- /dev/null +++ b/firma.py @@ -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 diff --git a/gui_dialoge.py b/gui_dialoge.py new file mode 100644 index 0000000..14ce1e6 --- /dev/null +++ b/gui_dialoge.py @@ -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("", 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("", lambda _e: self.destroy()) + self.bind("", 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() diff --git a/gui_monat.py b/gui_monat.py new file mode 100644 index 0000000..2d81fe2 --- /dev/null +++ b/gui_monat.py @@ -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("", self._klick) + if bei_doppelklick: + self.baum.bind("", 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)) diff --git a/modell.py b/modell.py new file mode 100644 index 0000000..72ad17b --- /dev/null +++ b/modell.py @@ -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\d{4})\s*[-/]\s*)?(?P.+?)\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 diff --git a/pdf_parser.py b/pdf_parser.py new file mode 100644 index 0000000..458b1cd --- /dev/null +++ b/pdf_parser.py @@ -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.+?)\s*\(\s*(?P[\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 diff --git a/xlsx_io.py b/xlsx_io.py new file mode 100644 index 0000000..9725d60 --- /dev/null +++ b/xlsx_io.py @@ -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