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) <noreply@anthropic.com>
This commit is contained in:
parent
2b7990b1b6
commit
6a8cb0849f
1 changed files with 91 additions and 3 deletions
|
|
@ -28,6 +28,7 @@ Nur Seite 1 wird gelesen – Seite 2 ist die 1:1-Kopie fürs Amt.
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
|
|
||||||
|
|
@ -70,6 +71,23 @@ def _naechster_wert(zeilen, i: int) -> str:
|
||||||
return ""
|
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:
|
def _datum(text: str) -> date:
|
||||||
for fmt in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d"):
|
for fmt in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d"):
|
||||||
try:
|
try:
|
||||||
|
|
@ -83,6 +101,25 @@ RECHTSFORM = {"gmbh", "ag", "kg", "ug", "gbr", "ohg", "mbh", "e.k.", "ek", "eg",
|
||||||
"gmbh & co. kg", "verein", "e.v."}
|
"gmbh & co. kg", "verein", "e.v."}
|
||||||
NAMENSZUSATZ = {"van", "von", "de", "der", "den", "di", "du", "le", "la", "el", "al",
|
NAMENSZUSATZ = {"van", "von", "de", "der", "den", "di", "du", "le", "la", "el", "al",
|
||||||
"ten", "ter", "zu", "vom", "zum", "zur", "mc", "mac", "o'"}
|
"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:
|
def nachname_aus_zeile(zeile: str) -> str:
|
||||||
|
|
@ -92,8 +129,9 @@ def nachname_aus_zeile(zeile: str) -> str:
|
||||||
'Meier, Peter' -> 'Meier'
|
'Meier, Peter' -> 'Meier'
|
||||||
'Familie van Dijk' -> 'van Dijk' (Namenszusatz bleibt dran)
|
'Familie van Dijk' -> 'van Dijk' (Namenszusatz bleibt dran)
|
||||||
'Hentschke Bau GmbH' -> 'Hentschke Bau GmbH' (Firma komplett)
|
'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:
|
if not z:
|
||||||
return ""
|
return ""
|
||||||
if "," in z: # "Nachname, Vorname"
|
if "," in z: # "Nachname, Vorname"
|
||||||
|
|
@ -108,8 +146,58 @@ def nachname_aus_zeile(zeile: str) -> str:
|
||||||
return teile[-1]
|
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:
|
def parse_pdf(pfad: str, standard_satz: float = STANDARD_SATZ) -> Buchung:
|
||||||
"""Rechnungs-PDF -> Buchung. Wirft ParserFehler, wenn Pflichtfelder fehlen."""
|
"""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)
|
text = _text_seite1(pfad)
|
||||||
zeilen = text.splitlines()
|
zeilen = text.splitlines()
|
||||||
|
|
||||||
|
|
@ -136,7 +224,7 @@ def parse_pdf(pfad: str, standard_satz: float = STANDARD_SATZ) -> Buchung:
|
||||||
if m:
|
if m:
|
||||||
naechte = int(m.group(1))
|
naechte = int(m.group(1))
|
||||||
elif RE_KUNDE.match(z):
|
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:
|
elif RE_ZWISCHEN.match(z) and zwischensumme is None:
|
||||||
zwischensumme = zahl_aus_text(_naechster_wert(zeilen, i))
|
zwischensumme = zahl_aus_text(_naechster_wert(zeilen, i))
|
||||||
elif RE_GESAMT.match(z) and gesamtbetrag is None:
|
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,
|
datum=datum,
|
||||||
rechnungsnummer=nummer,
|
rechnungsnummer=nummer,
|
||||||
jahr=jahr,
|
jahr=jahr,
|
||||||
nachname=nachname_aus_zeile(kunde),
|
nachname=kunde, # kommt schon fertig aus name_aus_block()
|
||||||
naechte=max(0, naechte),
|
naechte=max(0, naechte),
|
||||||
gezahlt=runde(gezahlt),
|
gezahlt=runde(gezahlt),
|
||||||
satz=satz,
|
satz=satz,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue