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:
2026-07-19 14:46:32 +02:00
parent 597f7ba552
commit f27eda88f4
6 changed files with 177 additions and 6 deletions

View File

@@ -28,6 +28,25 @@ RE_OPENING = re.compile(r"Kontostand\s+am\s+(\d{2}\.\d{2}\.\d{4}),\s*Auszug\s+Nr
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.,]+)$")
# Wiederkehrender Seitenfuß (Bank-Impressum) und Folgeseiten-Kopf; rein
# strukturelle Muster (keine Kontodaten), damit dieser Block nicht als
# Verwendungszweck der jeweils letzten Buchung einer Seite eingesammelt wird.
RE_NOISE = re.compile(
r"(?:Kreditbank AG" # Impressum-Zeile 1
r"|Taubenstraße" # Impressum-Zeile 2 (Anschrift)
r"|^\d{5}\s+Berlin\b" # Impressum-Zeile 3 (PLZ Ort)
r"|Handelsregister" # Impressum-Zeile 4
r"|^Ein Unternehmen" # Impressum-Zeile 5
r"|Landesbank" # Impressum-Zeile 6
r"|^Kontoauszug\s+\d+/\d+\s+Seite" # Folgeseiten-Kopf
r"|^Girokonto\s+\d" # Folgeseiten-Kopf (Kontozeile)
r"|^Datum\s+Erläuterung" # Spaltenüberschrift
r")"
)
def _is_noise(line: str) -> bool:
return bool(RE_NOISE.search(line))
def parse(path: Path) -> ParsedStatement:
@@ -46,6 +65,10 @@ def parse(path: Path) -> ParsedStatement:
line = line.strip()
if not line:
continue
# Schlusssaldo erreicht: laufende Buchung abschließen und Sammlung beenden
# (danach folgen nur noch Summen, Rechnungsabschluss und Hinweise).
if RE_CLOSING.search(line):
break
m = RE_TX_HEAD.match(line)
if m:
if pending:
@@ -59,7 +82,7 @@ def parse(path: Path) -> ParsedStatement:
"extra": [],
}
continue
if pending and not RE_OPENING.search(line) and not RE_CLOSING.search(line):
if pending and not RE_OPENING.search(line) and not _is_noise(line):
pending["extra"].append(line)
if pending:
txs.append(_finish(pending))