Logo Das Programmlogo (Wolke mit Paragraphenzeichen) steht jetzt rechts in der Kopfleiste - damit sich die beiden Werkzeuge auf einen Blick unterscheiden. bauen.py Der Weg von der Quelle zur Setup-Datei war eine Folge von Befehlen, die jemand von Hand eintippen musste. Wer einen Schritt vergisst, liefert eine Setup-Datei mit einer alten EXE aus - und sieht es ihr nicht an. Jetzt ist es ein Befehl, die Nummer kommt aus version.py, und am Ende stehen die SHA256-Summen fuer den Release-Text. pruef_start_exe.py Prueft, was kein anderer Pruefstand prueft: ob die GEBAUTE EXE ein sichtbares Fenster zeigt. Alle uebrigen fahren das Programm aus der Quelle, dort trat der Fehler nie auf. pruef_einzelinstanz.py wieder gruen Der Erprobungshinweis haette in jedem der drei Starts ein echtes Fenster geoeffnet - in einem eigenen Prozess laesst sich kein Dialog abfangen, es klickt niemand. Geprueft wird dort die SPERRE, nicht der Hinweis (der hat einen eigenen Pruefstand). Die Kenntnisnahme wird deshalb vorher eingetragen, und zwar mit genau dem Code, den auch der Klick auf "Ja" ausfuehrt. Das trifft nur die Kopie im Temp-Ordner: im echten Journal entsteht keine Zustimmung, die niemand gegeben hat. Aufgeraeumt Beherbergungssteuer.exe und die gleichnamige .spec sind raus - das Programm heisst jetzt anders. Gebaute Dateien gehoeren ins Release, nicht ins Repo; die .spec-Dateien dagegen schon. Fassung 1.8.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
362 lines
13 KiB
Python
362 lines
13 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
Liest eine Rechnungs-PDF des Rechnungstools 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 €
|
||
|
||
Gelesen wird nur Seite 1. Ein zweites Blatt „fürs Amt“ gibt es seit dem
|
||
05.09.2026 nicht mehr – die Rechnung des Rechnungstools ist EIN Blatt.
|
||
"""
|
||
|
||
import os
|
||
import re
|
||
import json
|
||
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 _block_nach(zeilen, i: int, hoechstens: int = 5):
|
||
"""
|
||
Die Zeilen des Adressblocks nach 'Rechnung an:' – bis zum nächsten Label
|
||
(z.B. 'Rechnungsnummer:'). Anrede, Name, Zusatz, Straße, PLZ/Ort.
|
||
"""
|
||
block = []
|
||
for z in zeilen[i + 1:]:
|
||
if not z.strip():
|
||
continue
|
||
if z.strip().endswith(":"): # nächstes Label -> Adressblock zu Ende
|
||
break
|
||
block.append(z.strip())
|
||
if len(block) >= hoechstens:
|
||
break
|
||
return block
|
||
|
||
|
||
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'"}
|
||
# Das Rechnungstool hat ein eigenes Feld "Anrede (optional)" über "Name / Firma".
|
||
# Steht dort etwas, ist es die erste Zeile unter "Rechnung an:" – und darf NICHT
|
||
# als Name durchgehen.
|
||
ANREDE = {"herr", "herrn", "frau", "familie", "fam", "eheleute", "firma", "hr", "fr",
|
||
"dr", "prof", "mr", "mrs", "ms", "und", "&", "an", "die", "z.h.", "zhd"}
|
||
|
||
|
||
def _nur_anrede(zeile: str) -> bool:
|
||
"""True, wenn die Zeile ausschließlich aus Anrede-Wörtern besteht ('Herr', 'Familie')."""
|
||
teile = str(zeile or "").split()
|
||
return bool(teile) and all(t.lower().strip(".,") in ANREDE for t in teile)
|
||
|
||
|
||
def _ohne_anrede(zeile: str) -> str:
|
||
"""Führende Anrede-Wörter abschneiden: 'Firma Muster GmbH' -> 'Muster GmbH'."""
|
||
teile = str(zeile or "").split()
|
||
while teile and teile[0].lower().strip(".,") in ANREDE:
|
||
teile.pop(0)
|
||
return " ".join(teile)
|
||
|
||
|
||
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)
|
||
'Firma Muster GmbH' -> 'Muster GmbH' (Anrede vorn fällt weg)
|
||
"""
|
||
z = " ".join(_ohne_anrede(zeile).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 name_aus_block(zeilen) -> str:
|
||
"""
|
||
Aus dem Adressblock unter 'Rechnung an:' die Zeile mit Name/Firma holen.
|
||
Reine Anrede-Zeilen ('Herr', 'Familie') werden übersprungen.
|
||
"""
|
||
for z in zeilen:
|
||
if z.strip() and not _nur_anrede(z):
|
||
return nachname_aus_zeile(z)
|
||
return ""
|
||
|
||
|
||
def _aus_metadaten(pfad: str):
|
||
"""
|
||
Rechnungen des Rechnungstools tragen ihre Kenndaten als JSON in den
|
||
PDF-Metadaten (Feld "Subject"). Dann muss nichts geraten werden.
|
||
Gibt None zurück, wenn die PDF das nicht hat (ältere Rechnungen).
|
||
"""
|
||
try:
|
||
info = PdfReader(pfad).metadata or {}
|
||
roh = info.get("/Subject") or ""
|
||
if not roh.strip().startswith("{"):
|
||
return None
|
||
daten = json.loads(roh)
|
||
except Exception as e: # noqa: BLE001 - fremde/kaputte Metadaten
|
||
log.debug("%s: keine lesbaren Metadaten (%s)", os.path.basename(pfad), e)
|
||
return None
|
||
if daten.get("quelle") != "rechnungstool" or not daten.get("rechnungsnummer"):
|
||
return None
|
||
return daten
|
||
|
||
|
||
# Blaetter, die nichts abrechnen und deshalb nicht gebucht werden.
|
||
# "proforma" steht aus der ersten Fassung des Angebots-Blattes noch mit drin.
|
||
KEINE_BUCHUNG = ("berichtigung", "angebot", "proforma")
|
||
|
||
|
||
def berichtigung_daten(pfad: str):
|
||
"""Kenndaten eines Berichtigungsblatts (§ 31 Abs. 5 UStDV), sonst None.
|
||
|
||
Das Blatt bucht nichts - es reicht die Angaben zum Empfaenger nach. Genau
|
||
die will das Journal aber uebernehmen: steht auf der Rechnung noch der
|
||
falsche Name, muss der Amtsbericht den berichtigten zeigen.
|
||
"""
|
||
kopf = _aus_metadaten(pfad)
|
||
if not kopf or kopf.get("art") != "berichtigung":
|
||
return None
|
||
return kopf
|
||
|
||
|
||
def keine_buchung(pfad: str) -> bool:
|
||
"""True fuer Blaetter, die keine Uebernachtung abrechnen.
|
||
|
||
Ein Berichtigungsdokument (§ 31 Abs. 5 UStDV) reicht nur fehlende
|
||
Empfaengerangaben nach. Es traegt in den Kenndaten die Nummer DER RECHNUNG,
|
||
die es berichtigt, und lauter Nullen. Wer es einliest, ueberschreibt damit
|
||
die echte Buchung - im Amtsbericht fehlt die Uebernachtung dann.
|
||
"""
|
||
if berichtigung_daten(pfad) is not None:
|
||
return True
|
||
# Das Angebot ist eine unverbindliche Vorab-Aufstellung, kein Umsatz:
|
||
# gemeldet wird eine Uebernachtung erst, wenn sie abgerechnet ist.
|
||
kopf = _aus_metadaten(pfad)
|
||
if kopf and kopf.get("art") in KEINE_BUCHUNG:
|
||
return True
|
||
# Zweite Sicherung ueber den Dateinamen: die Kenndaten sind die erste
|
||
# Quelle (sie ueberleben auch ein Umbenennen), aber fehlen sie einmal,
|
||
# sagt der Name es auch - das Rechnungstool schreibt "Angebot_..." und
|
||
# legt sie in den Unterordner "Angebote".
|
||
name = os.path.basename(pfad).lower()
|
||
ordner = os.path.basename(os.path.dirname(os.path.abspath(pfad))).lower()
|
||
return name.startswith("angebot_") or ordner == "angebote"
|
||
|
||
|
||
def parse_pdf(pfad: str, standard_satz: float = STANDARD_SATZ) -> Buchung:
|
||
"""Rechnungs-PDF -> Buchung. Wirft ParserFehler, wenn Pflichtfelder fehlen."""
|
||
meta = _aus_metadaten(pfad)
|
||
if meta:
|
||
datum = _datum(str(meta.get("datum", "")))
|
||
jahr, nummer = zerlege_nummer(meta.get("rechnungsnummer"), datum.year)
|
||
b = Buchung(
|
||
datum=datum,
|
||
rechnungsnummer=nummer,
|
||
jahr=jahr,
|
||
nachname=str(meta.get("nachname") or "").strip(),
|
||
naechte=int(zahl_aus_text(meta.get("naechte")) or 0),
|
||
gezahlt=runde(meta.get("zwischensumme") or 0),
|
||
satz=float(meta.get("steuer_satz") or 0) or standard_satz,
|
||
quelle="pdf",
|
||
pdf_pfad=os.path.abspath(pfad),
|
||
art=str(meta.get("art") or "rechnung"),
|
||
storno_zu=str(meta.get("storno_zu") or ""),
|
||
storno_datum=str(meta.get("storno_datum") or ""),
|
||
vorgang=str(meta.get("vorgang") or ""),
|
||
folge_nummer=str(meta.get("korrektur_nummer") or ""),
|
||
folge_art=("" if meta.get("nur_storno")
|
||
else "vorhanden" if meta.get("ersatz_vorhanden")
|
||
else "neu" if meta.get("korrektur_nummer") else ""),
|
||
)
|
||
log.debug("aus Metadaten: %s | %s | %s | %s Nächte | %.2f €",
|
||
os.path.basename(pfad), b.voll_nummer, b.nachname, b.naechte, b.gezahlt)
|
||
return b
|
||
|
||
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 = name_aus_block(_block_nach(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=kunde, # kommt schon fertig aus name_aus_block()
|
||
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)
|
||
if keine_buchung(pfad):
|
||
continue
|
||
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
|