feat: PDF-Parser VR/HVB/DKB mit Registry und Fixture-Tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
MARKERS = [
|
||||
("vr", ["Volksbank", "Raiffeisenbank", "VR-Bank", "VR Bank"]),
|
||||
("hvb", ["HypoVereinsbank", "UniCredit"]),
|
||||
("hvb", ["HypoVereinsbank", "UniCredit", "Unicredit", "HYVEDEMM"]),
|
||||
("dkb", ["Deutsche Kreditbank", "DKB"]),
|
||||
]
|
||||
|
||||
|
||||
79
finance/app/parsers/dkb.py
Normal file
79
finance/app/parsers/dkb.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Parser für DKB (Deutsche Kreditbank)-Kontoauszüge.
|
||||
|
||||
Realer Fixture-Abgleich (Task 7, Schritt 4): Der eigentliche Auszugszeitraum
|
||||
steht nicht als "vom X bis Y"-Satz im Text (dieser Wortlaut kommt nur in einem
|
||||
separaten quartalsweisen Zinsabschluss-Absatz mit abweichendem Zeitraum vor);
|
||||
er wird daher aus den Datumsangaben der beiden Kontostand-Zeilen abgeleitet.
|
||||
Buchungszeilen haben nur eine Datumsspalte, die ohne Leerzeichen direkt am
|
||||
Buchungstext klebt, und der Betrag trägt ein optionales führendes Minus statt
|
||||
eines nachgestellten S/H-Kürzels. Der Anfangssaldo trägt den Zusatz
|
||||
", Auszug Nr. N", der Endsaldo den Zusatz "um HH:MM Uhr" – beides
|
||||
unterscheidet die echten Saldo-Zeilen von einer abweichend formulierten
|
||||
Zwischensumme in einem quartalsweisen Rechnungsabschluss-Anhang.
|
||||
"""
|
||||
import re
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pdfplumber
|
||||
|
||||
from app.parsers.base import (ParsedStatement, ParsedTransaction, ParserError,
|
||||
parse_german_amount, parse_german_date)
|
||||
|
||||
BANK = "dkb"
|
||||
RE_IBAN = re.compile(r"\b([A-Z]{2}\d{2}(?:\s?\d{4}){4,5}(?:\s?\d{1,2})?)\b")
|
||||
# "Kontostand am 01.06.2020, Auszug Nr. 1 1.234,56"
|
||||
RE_OPENING = re.compile(r"Kontostand\s+am\s+(\d{2}\.\d{2}\.\d{4}),\s*Auszug\s+Nr\.\s*\d+\s+(-?[\d.,]+)", re.I)
|
||||
# "Kontostand am 30.06.2020 um 12:00 Uhr -2.345,67"
|
||||
RE_CLOSING = re.compile(r"Kontostand\s+am\s+(\d{2}\.\d{2}\.\d{4})\s+um\s+\d{2}:\d{2}\s*Uhr\s+(-?[\d.,]+)", re.I)
|
||||
# Buchungszeile: "01.06.2020Überweisung -123,45" (Datum direkt am Text, kein Leerzeichen)
|
||||
RE_TX_HEAD = re.compile(r"^(\d{2}\.\d{2}\.\d{4})(\S.*?)\s+(-?[\d.,]+)$")
|
||||
|
||||
|
||||
def parse(path: Path) -> ParsedStatement:
|
||||
with pdfplumber.open(path) as pdf:
|
||||
text = "\n".join((page.extract_text() or "") for page in pdf.pages)
|
||||
opening = RE_OPENING.search(text)
|
||||
closing = RE_CLOSING.search(text)
|
||||
if not opening or not closing:
|
||||
raise ParserError("DKB: Anfangs-/Endsaldo nicht gefunden")
|
||||
period_start = parse_german_date(opening.group(1))
|
||||
period_end = parse_german_date(closing.group(1))
|
||||
iban_m = RE_IBAN.search(text)
|
||||
txs: list[ParsedTransaction] = []
|
||||
pending: dict | None = None
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
m = RE_TX_HEAD.match(line)
|
||||
if m:
|
||||
if pending:
|
||||
txs.append(_finish(pending))
|
||||
booking = parse_german_date(m.group(1))
|
||||
pending = {
|
||||
"booking": booking,
|
||||
"value": booking,
|
||||
"head": m.group(2).strip(),
|
||||
"amount": parse_german_amount(m.group(3)),
|
||||
"extra": [],
|
||||
}
|
||||
continue
|
||||
if pending and not RE_OPENING.search(line) and not RE_CLOSING.search(line):
|
||||
pending["extra"].append(line)
|
||||
if pending:
|
||||
txs.append(_finish(pending))
|
||||
return ParsedStatement(bank=BANK,
|
||||
iban=iban_m.group(1).replace(" ", "") if iban_m else None,
|
||||
period_start=period_start, period_end=period_end,
|
||||
opening_balance=parse_german_amount(opening.group(2)),
|
||||
closing_balance=parse_german_amount(closing.group(2)),
|
||||
transactions=txs)
|
||||
|
||||
|
||||
def _finish(p: dict) -> ParsedTransaction:
|
||||
counterparty = p["extra"][0] if p["extra"] else ""
|
||||
purpose = " ".join([p["head"], *p["extra"][1:]]).strip()
|
||||
return ParsedTransaction(booking_date=p["booking"], value_date=p["value"],
|
||||
amount=p["amount"], purpose=purpose,
|
||||
counterparty=counterparty)
|
||||
118
finance/app/parsers/hvb.py
Normal file
118
finance/app/parsers/hvb.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Parser für HypoVereinsbank (UniCredit)-Kontoauszüge.
|
||||
|
||||
Realer Fixture-Abgleich (Task 7, Schritt 4): Das Layout weicht strukturell vom
|
||||
generischen Platzhalter ab, nicht nur in der Wortwahl:
|
||||
- Eine Buchung besteht aus zwei Zeilen: Zeile 1 = Buchungsdatum + Vorgangsart +
|
||||
Saldo/Umsatz-Betrag mit direkt angehängtem Vorzeichen (kein Leerzeichen vor
|
||||
"+"/"-"); Zeile 2 = Wertstellungsdatum + Buchungsinformation (Text kann
|
||||
leer sein, dann folgt nur ein Datum ohne weiteren Text). Fehlt Zeile 2 ganz
|
||||
(z. B. Seitenumbruch mitten in der Buchung), gilt das als Parserfehler statt
|
||||
eine unvollständige Buchung zu erzeugen.
|
||||
- Mehrseitige Auszüge wiederholen Kopfzeilen und Zwischensummen
|
||||
("Übertrag: EUR ...", "Saldo per DATUM: EUR ..."), die nicht mit dem
|
||||
tatsächlichen Anfangs-/Endsaldo verwechselt werden dürfen und aus dem
|
||||
Verwendungszweck herausgefiltert werden.
|
||||
- Der Auszugszeitraum steht nur in der wiederholten Kopfzeile als
|
||||
"<Start> - <Ende> <Auszug-Nr.> <Seite>".
|
||||
"""
|
||||
import re
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pdfplumber
|
||||
|
||||
from app.parsers.base import (ParsedStatement, ParsedTransaction, ParserError,
|
||||
parse_german_amount, parse_german_date)
|
||||
|
||||
BANK = "hvb"
|
||||
RE_IBAN = re.compile(r"\b([A-Z]{2}\d{2}(?:\s?\d{4}){4,5}(?:\s?\d{1,2})?)\b")
|
||||
# Kopfzeile pro Seite: "... <Zeitraum-Start> - <Zeitraum-Ende> <Auszug-Nr> <Seite>"
|
||||
RE_PERIOD = re.compile(r"(\d{2}\.\d{2}\.\d{4})\s*-\s*(\d{2}\.\d{2}\.\d{4})\s+\d+\s+\d+\s*$", re.M)
|
||||
# "Ihr alter Kontostand per 01.06.2020: EUR 1.234,56+"
|
||||
RE_OPENING = re.compile(r"Ihr\s+alter\s+Kontostand\s+per\s+\d{2}\.\d{2}\.\d{4}:\s*EUR\s+([\d.,]+[+-]?)", re.I)
|
||||
# "Ihr neuer Kontostand: EUR 2.345,67+" (Zwischensummen heißen "Saldo per ...:" und matchen hier nicht)
|
||||
RE_CLOSING = re.compile(r"Ihr\s+neuer\s+Kontostand:\s*EUR\s+([\d.,]+[+-]?)", re.I)
|
||||
# Buchungszeile Teil 1: "01.06. LASTSCHRIFT 123,45-" (Buchungsdatum, Vorgang, Betrag+Vorzeichen ohne Leerzeichen)
|
||||
RE_TX_HEAD = re.compile(r"^(\d{2}\.\d{2}\.)\s+(.+?)\s+([\d.,]+[+-])$")
|
||||
# Buchungszeile Teil 2: "01.06. Muster GmbH" (Wertdatum, Buchungsinformation; Text darf leer sein)
|
||||
RE_TX_DETAIL = re.compile(r"^(\d{2}\.\d{2}\.)\s*(.*)$")
|
||||
# Zwischensummen/Übertrag zwischen Seiten, dürfen nicht in den Verwendungszweck rutschen
|
||||
RE_CARRYOVER = re.compile(r"^(?:Übertrag|Saldo\s+per)\b", re.I)
|
||||
# Wiederkehrende Seiten-Kopf-/Fußzeilen (Titel, Spaltenköpfe, Fußnoten), ebenfalls kein Buchungstext
|
||||
RE_PAGE_NOISE = re.compile(
|
||||
r"^(?:K\s?O\s?N\s?T\s?O\s?A\s?U\s?S\s?Z\s?U\s?G"
|
||||
r"|Kontonummer\s+Bankleitzahl"
|
||||
r"|IBAN\s+BIC"
|
||||
r"|Kontoinhaber\s+Ansprechpartner"
|
||||
r"|Buchung/Wert\s+Buchungsinformation"
|
||||
r"|Bitte\s+R(?:ü|ue)ckseite\s+beachten"
|
||||
r"|Seite\s+\d+\s+von\s+\d+)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def _is_noise(line: str) -> bool:
|
||||
return bool(RE_OPENING.search(line) or RE_CLOSING.search(line)
|
||||
or RE_CARRYOVER.match(line) or RE_PAGE_NOISE.match(line))
|
||||
|
||||
|
||||
def parse(path: Path) -> ParsedStatement:
|
||||
with pdfplumber.open(path) as pdf:
|
||||
text = "\n".join((page.extract_text() or "") for page in pdf.pages)
|
||||
period = RE_PERIOD.search(text)
|
||||
if not period:
|
||||
raise ParserError("HVB: Abrechnungszeitraum nicht gefunden")
|
||||
period_start = parse_german_date(period.group(1))
|
||||
period_end = parse_german_date(period.group(2))
|
||||
opening = RE_OPENING.search(text)
|
||||
closing = RE_CLOSING.search(text)
|
||||
if not opening or not closing:
|
||||
raise ParserError("HVB: Anfangs-/Endsaldo nicht gefunden")
|
||||
iban_m = RE_IBAN.search(text)
|
||||
txs: list[ParsedTransaction] = []
|
||||
pending: dict | None = None
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
m = RE_TX_HEAD.match(line)
|
||||
if m:
|
||||
if pending:
|
||||
if pending["value"] is None:
|
||||
raise ParserError("HVB: Buchungszeile ohne Detailzeile")
|
||||
txs.append(_finish(pending))
|
||||
pending = {
|
||||
"booking": parse_german_date(m.group(1), period_end.year),
|
||||
"value": None,
|
||||
"head": m.group(2).strip(),
|
||||
"amount": parse_german_amount(m.group(3)),
|
||||
"extra": [],
|
||||
}
|
||||
continue
|
||||
if pending and pending["value"] is None:
|
||||
d = RE_TX_DETAIL.match(line)
|
||||
if d:
|
||||
pending["value"] = parse_german_date(d.group(1), period_end.year)
|
||||
if d.group(2).strip():
|
||||
pending["extra"].append(d.group(2).strip())
|
||||
continue
|
||||
if pending and not _is_noise(line):
|
||||
pending["extra"].append(line)
|
||||
if pending:
|
||||
if pending["value"] is None:
|
||||
raise ParserError("HVB: Buchungszeile ohne Detailzeile")
|
||||
txs.append(_finish(pending))
|
||||
return ParsedStatement(bank=BANK,
|
||||
iban=iban_m.group(1).replace(" ", "") if iban_m else None,
|
||||
period_start=period_start, period_end=period_end,
|
||||
opening_balance=parse_german_amount(opening.group(1)),
|
||||
closing_balance=parse_german_amount(closing.group(1)),
|
||||
transactions=txs)
|
||||
|
||||
|
||||
def _finish(p: dict) -> ParsedTransaction:
|
||||
counterparty = p["extra"][0] if p["extra"] else ""
|
||||
purpose = " ".join([p["head"], *p["extra"][1:]]).strip()
|
||||
return ParsedTransaction(booking_date=p["booking"], value_date=p["value"],
|
||||
amount=p["amount"], purpose=purpose,
|
||||
counterparty=counterparty)
|
||||
21
finance/app/parsers/registry.py
Normal file
21
finance/app/parsers/registry.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import pdfplumber
|
||||
|
||||
from app.parsers import dkb, hvb, vr
|
||||
from app.parsers.base import ParsedStatement, ParserError
|
||||
from app.parsers.detect import detect_bank
|
||||
|
||||
PARSERS: dict[str, Callable[[Path], ParsedStatement]] = {
|
||||
"vr": vr.parse, "hvb": hvb.parse, "dkb": dkb.parse,
|
||||
}
|
||||
|
||||
|
||||
def parse_pdf(path: Path) -> ParsedStatement:
|
||||
with pdfplumber.open(path) as pdf:
|
||||
first = pdf.pages[0].extract_text() or ""
|
||||
bank = detect_bank(first)
|
||||
if bank is None:
|
||||
raise ParserError("Bank nicht erkannt")
|
||||
return PARSERS[bank](path)
|
||||
80
finance/app/parsers/vr.py
Normal file
80
finance/app/parsers/vr.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Parser für Volksbank/Raiffeisenbank-Kontoauszüge.
|
||||
|
||||
Realer Fixture-Abgleich (Task 7, Schritt 4): Der eigentliche Auszugszeitraum
|
||||
steht nicht als "vom X bis Y"-Satz im Text (dieser Wortlaut kommt nur in einem
|
||||
separaten quartalsweisen Zinsabschluss-Absatz mit abweichendem Zeitraum vor).
|
||||
Anfangs-/Endsaldo tragen stattdessen je ein eigenes Datum ("alter/neuer
|
||||
Kontostand vom DD.MM.YYYY ..."), aus dem der Auszugszeitraum abgeleitet wird;
|
||||
RE_PERIOD bleibt als Fallback erhalten, falls ein Auszug diese Daten nicht
|
||||
enthält.
|
||||
"""
|
||||
import re
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pdfplumber
|
||||
|
||||
from app.parsers.base import (ParsedStatement, ParsedTransaction, ParserError,
|
||||
parse_german_amount, parse_german_date)
|
||||
|
||||
BANK = "vr"
|
||||
RE_IBAN = re.compile(r"\b([A-Z]{2}\d{2}(?:\s?\d{4}){4,5}(?:\s?\d{1,2})?)\b")
|
||||
# Fallback, falls kein Datum an den Kontostand-Zeilen hängt: "vom 01.06.2020 bis 30.06.2020"
|
||||
RE_PERIOD = re.compile(r"vom\s+(\d{2}\.\d{2}\.\d{4})\s+bis\s+(\d{2}\.\d{2}\.\d{4})")
|
||||
# "alter Kontostand vom 01.06.2020 1.234,56 H" (Datum optional, je nach Formatvariante)
|
||||
RE_OPENING = re.compile(r"alter\s+Kontostand\s+(?:vom\s+(\d{2}\.\d{2}\.\d{4})\s+)?([\d.,]+\s*[SH-]?)", re.I)
|
||||
# "neuer Kontostand vom 30.06.2020 2.345,67 H"
|
||||
RE_CLOSING = re.compile(r"neuer\s+Kontostand\s+(?:vom\s+(\d{2}\.\d{2}\.\d{4})\s+)?([\d.,]+\s*[SH-]?)", re.I)
|
||||
# Buchungszeile: "01.06. 01.06. Muster GmbH 1.234,56 S"
|
||||
RE_TX = re.compile(r"^(\d{2}\.\d{2}\.)\s+(\d{2}\.\d{2}\.)\s+(.*?)\s+([\d.,]+\s*[SH-])$")
|
||||
|
||||
|
||||
def parse(path: Path) -> ParsedStatement:
|
||||
with pdfplumber.open(path) as pdf:
|
||||
text = "\n".join((page.extract_text() or "") for page in pdf.pages)
|
||||
opening = RE_OPENING.search(text)
|
||||
closing = RE_CLOSING.search(text)
|
||||
if not opening or not closing:
|
||||
raise ParserError("VR: Anfangs-/Endsaldo nicht gefunden")
|
||||
if opening.group(1) and closing.group(1):
|
||||
period_start = parse_german_date(opening.group(1))
|
||||
period_end = parse_german_date(closing.group(1))
|
||||
else:
|
||||
period = RE_PERIOD.search(text)
|
||||
if not period:
|
||||
raise ParserError("VR: Abrechnungszeitraum nicht gefunden")
|
||||
period_start = parse_german_date(period.group(1))
|
||||
period_end = parse_german_date(period.group(2))
|
||||
iban_m = RE_IBAN.search(text)
|
||||
txs: list[ParsedTransaction] = []
|
||||
pending: dict | None = None
|
||||
for line in text.splitlines():
|
||||
m = RE_TX.match(line.strip())
|
||||
if m:
|
||||
if pending:
|
||||
txs.append(_finish(pending))
|
||||
pending = {
|
||||
"booking": parse_german_date(m.group(1), period_end.year),
|
||||
"value": parse_german_date(m.group(2), period_end.year),
|
||||
"head": m.group(3).strip(),
|
||||
"amount": parse_german_amount(m.group(4)),
|
||||
"extra": [],
|
||||
}
|
||||
elif pending and line.strip() and not RE_CLOSING.search(line):
|
||||
pending["extra"].append(line.strip())
|
||||
if pending:
|
||||
txs.append(_finish(pending))
|
||||
return ParsedStatement(bank=BANK,
|
||||
iban=iban_m.group(1).replace(" ", "") if iban_m else None,
|
||||
period_start=period_start, period_end=period_end,
|
||||
opening_balance=parse_german_amount(opening.group(2)),
|
||||
closing_balance=parse_german_amount(closing.group(2)),
|
||||
transactions=txs)
|
||||
|
||||
|
||||
def _finish(p: dict) -> ParsedTransaction:
|
||||
counterparty = p["extra"][0] if p["extra"] else ""
|
||||
purpose = " ".join([p["head"], *p["extra"][1:]]).strip()
|
||||
return ParsedTransaction(booking_date=p["booking"], value_date=p["value"],
|
||||
amount=p["amount"], purpose=purpose,
|
||||
counterparty=counterparty)
|
||||
9
finance/scripts/dump_pdf_text.py
Normal file
9
finance/scripts/dump_pdf_text.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""PDF-Text anzeigen, um Parser-Regexe abzugleichen: python scripts/dump_pdf_text.py <pdf>"""
|
||||
import sys
|
||||
|
||||
import pdfplumber
|
||||
|
||||
with pdfplumber.open(sys.argv[1]) as pdf:
|
||||
for i, page in enumerate(pdf.pages, 1):
|
||||
print(f"--- Seite {i} ---")
|
||||
print(page.extract_text() or "(kein Text)")
|
||||
9
finance/tests/fixtures/README.md
vendored
Normal file
9
finance/tests/fixtures/README.md
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
# Beispiel-Auszüge (nicht committen!)
|
||||
|
||||
Hier pro Bank mindestens einen echten PDF-Kontoauszug ablegen:
|
||||
- `vr_beispiel.pdf` (Volksbank/Raiffeisenbank)
|
||||
- `hvb_beispiel.pdf` (HypoVereinsbank)
|
||||
- `dkb_beispiel.pdf` (DKB)
|
||||
|
||||
Beträge dürfen verfremdet sein, das Layout muss echt sein.
|
||||
PDFs sind via .gitignore vom Repo ausgeschlossen.
|
||||
24
finance/tests/test_bank_parsers.py
Normal file
24
finance/tests/test_bank_parsers.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.parsers.registry import parse_pdf
|
||||
from app.parsers.validate import balance_difference
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
CASES = [("vr_beispiel.pdf", "vr"), ("hvb_beispiel.pdf", "hvb"),
|
||||
("dkb_beispiel.pdf", "dkb")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename,bank", CASES)
|
||||
def test_parse_fixture(filename, bank):
|
||||
path = FIXTURES / filename
|
||||
if not path.exists():
|
||||
pytest.skip(f"Fixture {filename} fehlt")
|
||||
stmt = parse_pdf(path)
|
||||
assert stmt.bank == bank
|
||||
assert stmt.opening_balance is not None
|
||||
assert stmt.closing_balance is not None
|
||||
assert len(stmt.transactions) > 0
|
||||
assert balance_difference(stmt) == Decimal("0")
|
||||
Reference in New Issue
Block a user