Files
bin/finance/app/parsers/vr.py
wlfb f27eda88f4 fix: Parser-Noise-Filter, Sammelstopp am Schlusssaldo, Audit-Werkzeug
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>
2026-07-19 14:46:32 +02:00

140 lines
5.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)