From 6a8cb0849f66542c3444138448fcec6fe83616f9 Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Sun, 12 Jul 2026 19:45:55 +0200 Subject: [PATCH 1/3] Nachname aus den PDF-Metadaten, Anrede nicht mehr als Name lesen Der Parser nahm die erste Zeile unter "Rechnung an:" als Namen. Steht im Rechnungstool eine Anrede, war das "Herr" statt "Mustermann". - Neue Rechnungen: Kenndaten kommen aus den PDF-Metadaten (JSON), inkl. getrenntem Nachnamen. Nichts wird mehr geraten. - Alte Rechnungen ohne Metadaten: der Adressblock wird gesammelt, reine Anrede-Zeilen (Herr/Frau/Familie/Firma) werden uebersprungen, fuehrende Anreden abgeschnitten. Co-Authored-By: Claude Opus 4.8 (1M context) --- pdf_parser.py | 94 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 91 insertions(+), 3 deletions(-) diff --git a/pdf_parser.py b/pdf_parser.py index 458b1cd..1dbefd1 100644 --- a/pdf_parser.py +++ b/pdf_parser.py @@ -28,6 +28,7 @@ Nur Seite 1 wird gelesen – Seite 2 ist die 1:1-Kopie fürs Amt. import os import re +import json import logging from datetime import datetime, date @@ -70,6 +71,23 @@ def _naechster_wert(zeilen, i: int) -> str: 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: @@ -83,6 +101,25 @@ RECHTSFORM = {"gmbh", "ag", "kg", "ug", "gbr", "ohg", "mbh", "e.k.", "ek", "eg", "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: @@ -92,8 +129,9 @@ def nachname_aus_zeile(zeile: str) -> str: '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(str(zeile or "").split()) + z = " ".join(_ohne_anrede(zeile).split()) if not z: return "" if "," in z: # "Nachname, Vorname" @@ -108,8 +146,58 @@ def nachname_aus_zeile(zeile: str) -> str: 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 + + 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), + ) + 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() @@ -136,7 +224,7 @@ def parse_pdf(pfad: str, standard_satz: float = STANDARD_SATZ) -> Buchung: if m: naechte = int(m.group(1)) elif RE_KUNDE.match(z): - kunde = _naechster_wert(zeilen, i) + 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: @@ -175,7 +263,7 @@ def parse_pdf(pfad: str, standard_satz: float = STANDARD_SATZ) -> Buchung: datum=datum, rechnungsnummer=nummer, jahr=jahr, - nachname=nachname_aus_zeile(kunde), + nachname=kunde, # kommt schon fertig aus name_aus_block() naechte=max(0, naechte), gezahlt=runde(gezahlt), satz=satz, From 7c66a669b4eb65c874aa29a5a5d6baad4de3dc8b Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Mon, 13 Jul 2026 20:47:04 +0200 Subject: [PATCH 2/3] Amtsbericht ohne Bankverbindung und Steuernummer Die Fusszeile druckte Bankname, IBAN, BIC und Steuernummer. In einer Meldung ans Amt haben die nichts verloren - es zieht nichts ein und ueberweist nichts. Es bleiben Inhaber (wer meldet) und die Kontaktdaten fuer Rueckfragen; leere Felder werden uebersprungen. Auf der Rechnung bleibt alles unveraendert. --- bericht_pdf.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/bericht_pdf.py b/bericht_pdf.py index 8df7603..5c12976 100644 --- a/bericht_pdf.py +++ b/bericht_pdf.py @@ -118,13 +118,19 @@ def _kopf_fuss(canvas, doc, firma): 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", "")] + + # KEINE Bankverbindung und KEINE Steuernummer im Amtsbericht. + # + # Das ist eine Meldung, keine Rechnung: Das Amt zieht nichts ein und überweist + # nichts – IBAN, BIC, Bankname und Steuernummer haben hier nichts verloren. Auf + # der Rechnung (Rechnungstool) bleiben sie, dort werden sie gebraucht. + # + # Es bleibt: wer meldet (Inhaber) und wie man ihn bei Rückfragen erreicht. + links = [t for t in [firma.get("inhaber", "")] if t] + rechts = [t for t in [firma.get("homepage", ""), firma.get("email", ""), + firma.get("telefon", "")] if t] 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) From 700ddc3bd1f86ef171ea2c2b5ea52bca1d17ae54 Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Mon, 13 Jul 2026 20:59:20 +0200 Subject: [PATCH 3/3] Fusszeile raus - sie ragte in die Tabelle Der Briefkopf-Fuss (Bank, Steuernummer, Inhaber, Homepage, E-Mail, Telefon) wurde oberhalb des unteren Seitenrands gezeichnet und lief damit in den Textbereich. Auf vollen Seiten stand er quer ueber der letzten Tabellenzeile. Die Kontaktdaten waren zudem Platzhalter (example.de). Es bleibt die Seitenzahl, bewusst unterhalb von MARGIN_BOT. --- bericht_pdf.py | 38 +++++++++++++++----------------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/bericht_pdf.py b/bericht_pdf.py index 5c12976..e8f8e22 100644 --- a/bericht_pdf.py +++ b/bericht_pdf.py @@ -111,31 +111,23 @@ def _kopf_fuss(canvas, doc, firma): 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) + # Fußzeile: nur die Seitenzahl. + # + # Hier stand vorher der komplette Briefkopf-Fuß – Bankverbindung, Steuernummer, + # Inhaber, Homepage, E-Mail, Telefon. Zwei Gründe, warum das weg ist: + # + # 1. Es ist eine Meldung, keine Rechnung. Das Amt zieht nichts ein und überweist + # nichts; Bankdaten und Steuernummer haben darin nichts verloren. Auf der + # Rechnung (Rechnungstool) bleiben sie, dort werden sie gebraucht. + # 2. Der Block lag ÜBER dem unteren Seitenrand (bottomMargin) und ragte damit in + # die Tabelle hinein – auf vollen Seiten stand er quer über der letzten Zeile. + # + # Die Seitenzahl sitzt bewusst unterhalb von MARGIN_BOT, sonst passiert dasselbe + # wieder. canvas.setFont("Helvetica", 7.3) canvas.setFillColor(DARK) - - # KEINE Bankverbindung und KEINE Steuernummer im Amtsbericht. - # - # Das ist eine Meldung, keine Rechnung: Das Amt zieht nichts ein und überweist - # nichts – IBAN, BIC, Bankname und Steuernummer haben hier nichts verloren. Auf - # der Rechnung (Rechnungstool) bleiben sie, dort werden sie gebraucht. - # - # Es bleibt: wer meldet (Inhaber) und wie man ihn bei Rückfragen erreicht. - links = [t for t in [firma.get("inhaber", "")] if t] - rechts = [t for t in [firma.get("homepage", ""), firma.get("email", ""), - firma.get("telefon", "")] if t] - for i, t in enumerate(links): - canvas.drawString(MARGIN_L, 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.drawCentredString(PAGE_W / 2, MARGIN_BOT - 12 * mm, + f"Seite {canvas.getPageNumber()}") canvas.restoreState()