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>
This commit is contained in:
@@ -27,6 +27,50 @@ RE_OPENING = re.compile(r"alter\s+Kontostand\s+(?:vom\s+(\d{2}\.\d{2}\.\d{4})\s+
|
||||
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:
|
||||
@@ -48,8 +92,23 @@ def parse(path: Path) -> ParsedStatement:
|
||||
iban_m = RE_IBAN.search(text)
|
||||
txs: list[ParsedTransaction] = []
|
||||
pending: dict | None = None
|
||||
in_header = False
|
||||
for line in text.splitlines():
|
||||
m = RE_TX.match(line.strip())
|
||||
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))
|
||||
@@ -60,8 +119,8 @@ def parse(path: Path) -> ParsedStatement:
|
||||
"amount": parse_german_amount(m.group(4)),
|
||||
"extra": [],
|
||||
}
|
||||
elif pending and line.strip() and not RE_CLOSING.search(line):
|
||||
pending["extra"].append(line.strip())
|
||||
elif pending and not _is_noise(line):
|
||||
pending["extra"].append(line)
|
||||
if pending:
|
||||
txs.append(_finish(pending))
|
||||
return ParsedStatement(bank=BANK,
|
||||
@@ -74,7 +133,7 @@ def parse(path: Path) -> ParsedStatement:
|
||||
|
||||
def _finish(p: dict) -> ParsedTransaction:
|
||||
counterparty = p["extra"][0] if p["extra"] else ""
|
||||
purpose = " ".join([p["head"], *p["extra"][1:]]).strip()
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user