diff --git a/bericht_pdf.py b/bericht_pdf.py index 8df7603..e8f8e22 100644 --- a/bericht_pdf.py +++ b/bericht_pdf.py @@ -111,25 +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) - 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.drawCentredString(PAGE_W / 2, MARGIN_BOT - 12 * mm, + f"Seite {canvas.getPageNumber()}") canvas.restoreState() 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,