Alle drei Bank-Parser gegen die realen Fixtures gehaertet: - DKB/VR/HVB: Sammlung endet am Schlusssaldo (break), damit Summen, Rechnungsabschluss und Hinweisseiten nicht mehr in die letzte Buchung rutschen. - Generische, rein strukturelle Noise-Filter fuer wiederkehrende Seitenfuss-/Kopfbloecke und Blattwechsel-Reste (keine Kontodaten hart codiert); Kopf-/Hinweisbloecke werden als Sequenz uebersprungen. - VR: Rekonstruktion harter Zeilenumbrueche im Verwendungszweck unter konservativen Schutzbedingungen (nur purpose, nicht counterparty). - Neue Noise- und expected-Value-Tests; expected_*.json gitignored. - Audit-Werkzeug scripts/parser_audit.py ergaenzt (nur lokale Nutzung). Transaktionszahlen und Saldo-Differenzen unveraendert; max. Laenge des Verwendungszwecks je Bank deutlich unter 300 Zeichen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
140 lines
5.9 KiB
Python
140 lines
5.9 KiB
Python
"""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-])$")
|
||
# Folgeseiten-Kopfblock (Bankanschrift bis Spaltenüberschrift) als Sequenz
|
||
# überspringen: fängt auch die Kontoinhaber-Kopfzeile ab, ohne gleichnamige
|
||
# Gegenparteien in Buchungen zu zerstören.
|
||
RE_HEADER_START = re.compile(r"^Beraterpark am Olgaplatz")
|
||
RE_HEADER_END = re.compile(r"^Bu-Tag\s+Wert\s+Vorgang")
|
||
# Blattwechsel-/Formular-Reste, die sonst in den Verwendungszweck rutschen.
|
||
RE_NOISE = re.compile(
|
||
r"(?:Übertrag" # "Übertrag auf/von Blatt N"
|
||
r"|^\d{3,4}$" # Formularcodes (z. B. 0007, 000)
|
||
r"|^K\d{6}" # Formularcode (K + Ziffernblock)
|
||
r"|Bitte beachten Sie die Hinweise" # Fußzeilen-Hinweis
|
||
r"|^[─—–\-]{3,}$" # Trennlinien
|
||
r")"
|
||
)
|
||
|
||
|
||
def _is_noise(line: str) -> bool:
|
||
return bool(RE_NOISE.search(line))
|
||
|
||
|
||
def _join_purpose(parts: list[str]) -> str:
|
||
"""Verwendungszweck zusammensetzen und harte Zeilenumbrüche der VR-PDF
|
||
(~54 Zeichen, teils mitten im Wort) rekonstruieren.
|
||
|
||
Ohne Leerzeichen wird nur dann verbunden, wenn die Vorzeile mindestens 53
|
||
Zeichen lang ist UND (Vorzeile endet auf Ziffer und Folgezeile beginnt mit
|
||
Ziffer) ODER (beide an der Bruchstelle Kleinbuchstaben) – sonst mit
|
||
Leerzeichen. So bleibt z. B. "... 75" + "Euro" mit Leerzeichen getrennt.
|
||
"""
|
||
if not parts:
|
||
return ""
|
||
out = parts[0]
|
||
for prev, cur in zip(parts, parts[1:]):
|
||
glue = ""
|
||
if len(prev) >= 53 and prev and cur:
|
||
if (prev[-1].isdigit() and cur[0].isdigit()) or \
|
||
(prev[-1].islower() and cur[0].islower()):
|
||
glue = ""
|
||
else:
|
||
glue = " "
|
||
else:
|
||
glue = " "
|
||
out += glue + cur
|
||
return out.strip()
|
||
|
||
|
||
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
|
||
in_header = False
|
||
for line in text.splitlines():
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
# Folgeseiten-Kopfblock komplett überspringen (inkl. Kontoinhaber-Zeile).
|
||
if in_header:
|
||
if RE_HEADER_END.match(line):
|
||
in_header = False
|
||
continue
|
||
if RE_HEADER_START.match(line):
|
||
in_header = True
|
||
continue
|
||
# Schlusssaldo erreicht: laufende Buchung abschließen und Sammlung beenden.
|
||
if RE_CLOSING.search(line):
|
||
break
|
||
m = RE_TX.match(line)
|
||
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 not _is_noise(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_purpose([p["head"], *p["extra"][1:]])
|
||
return ParsedTransaction(booking_date=p["booking"], value_date=p["value"],
|
||
amount=p["amount"], purpose=purpose,
|
||
counterparty=counterparty)
|