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:
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)
|
||||
Reference in New Issue
Block a user