167 lines
5.7 KiB
Python
167 lines
5.7 KiB
Python
"""Vergleichswerkzeug: PDF-Parser-Ergebnis gegen CSV-Ground-Truth (Task 5, Ausbaustufe 3).
|
|
|
|
NUR LOKAL VERWENDEN - die Ausgabe enthaelt echte Kontodaten (Betraege,
|
|
Gegenparteien, Verwendungszwecke) und darf nicht in Commits, Reports oder
|
|
Tickets uebernommen werden.
|
|
|
|
Matcht die aus einem Kontoauszugs-PDF geparsten Transaktionen gegen die
|
|
Zeilen einer oder mehrerer Kontoumsatz-CSV-Dateien (auf den vom PDF
|
|
abgedeckten Zeitraum eingeschraenkt) anhand von (booking_date, amount) und
|
|
vergleicht je Match Gegenpartei (counterparty) und Verwendungszweck
|
|
(purpose) mittels difflib.SequenceMatcher.
|
|
|
|
Aufruf:
|
|
PYTHONPATH=. .venv/bin/python scripts/parser_vs_csv.py <pdf> <csv> [<csv>...]
|
|
"""
|
|
import statistics
|
|
import sys
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass
|
|
from datetime import date
|
|
from decimal import Decimal
|
|
from difflib import SequenceMatcher
|
|
from pathlib import Path
|
|
|
|
from app.parsers.base import ParsedTransaction
|
|
from app.parsers.csv_formats import parse_csv
|
|
from app.parsers.registry import parse_pdf
|
|
|
|
TRUNC = 60
|
|
|
|
|
|
@dataclass
|
|
class CsvRow:
|
|
booking_date: date
|
|
amount: Decimal
|
|
counterparty: str
|
|
purpose: str
|
|
|
|
|
|
def _norm(s: str) -> str:
|
|
return " ".join((s or "").casefold().split())
|
|
|
|
|
|
def _sim(a: str, b: str) -> float:
|
|
return SequenceMatcher(None, _norm(a), _norm(b)).ratio()
|
|
|
|
|
|
def _trunc(s: str) -> str:
|
|
s = s or ""
|
|
return s if len(s) <= TRUNC else s[: TRUNC - 1] + "…"
|
|
|
|
|
|
def load_csv_rows(paths: list[Path], period_start: date | None,
|
|
period_end: date | None) -> list[CsvRow]:
|
|
rows: list[CsvRow] = []
|
|
for p in paths:
|
|
parsed = parse_csv(p)
|
|
for t in parsed.statement.transactions:
|
|
if period_start is not None and t.booking_date < period_start:
|
|
continue
|
|
if period_end is not None and t.booking_date > period_end:
|
|
continue
|
|
rows.append(CsvRow(booking_date=t.booking_date, amount=t.amount,
|
|
counterparty=t.counterparty, purpose=t.purpose))
|
|
return rows
|
|
|
|
|
|
def match(pdf_txs: list[ParsedTransaction], csv_rows: list[CsvRow]):
|
|
"""Greedy 1:1 match auf (booking_date, amount); Reihenfolge = PDF-Reihenfolge.
|
|
|
|
Returns (matches, unmatched_pdf, unmatched_csv, ambiguous_keys).
|
|
matches: list[tuple[ParsedTransaction, CsvRow]]
|
|
"""
|
|
buckets: dict[tuple[date, Decimal], list[CsvRow]] = defaultdict(list)
|
|
for r in csv_rows:
|
|
buckets[(r.booking_date, r.amount)].append(r)
|
|
|
|
ambiguous_keys = {k for k, v in buckets.items() if len(v) > 1}
|
|
|
|
matches: list[tuple[ParsedTransaction, CsvRow]] = []
|
|
unmatched_pdf: list[ParsedTransaction] = []
|
|
for t in pdf_txs:
|
|
key = (t.booking_date, t.amount)
|
|
bucket = buckets.get(key)
|
|
if bucket:
|
|
row = bucket.pop(0)
|
|
matches.append((t, row))
|
|
else:
|
|
unmatched_pdf.append(t)
|
|
|
|
unmatched_csv = [r for bucket in buckets.values() for r in bucket]
|
|
return matches, unmatched_pdf, unmatched_csv, ambiguous_keys
|
|
|
|
|
|
def purpose_sim(pdf_purpose: str, csv_counterparty: str, csv_purpose: str) -> float:
|
|
direct = _sim(pdf_purpose, csv_purpose)
|
|
combined = _sim(pdf_purpose, f"{csv_counterparty} {csv_purpose}")
|
|
return max(direct, combined)
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
if len(argv) < 3:
|
|
print(f"Usage: {argv[0]} <pdf> <csv> [<csv>...]", file=sys.stderr)
|
|
return 2
|
|
|
|
pdf_path = Path(argv[1])
|
|
csv_paths = [Path(p) for p in argv[2:]]
|
|
|
|
stmt = parse_pdf(pdf_path)
|
|
csv_rows = load_csv_rows(csv_paths, stmt.period_start, stmt.period_end)
|
|
|
|
print(f"PDF: {pdf_path.name} bank={stmt.bank} "
|
|
f"period={stmt.period_start}..{stmt.period_end} n_pdf={len(stmt.transactions)}")
|
|
print(f"CSV: {', '.join(p.name for p in csv_paths)} "
|
|
f"n_csv(in Periode)={len(csv_rows)}")
|
|
print("-" * 100)
|
|
|
|
matches, unmatched_pdf, unmatched_csv, ambiguous_keys = match(
|
|
stmt.transactions, csv_rows)
|
|
|
|
cp_sims: list[float] = []
|
|
p_sims: list[float] = []
|
|
|
|
for i, (t, r) in enumerate(matches, start=1):
|
|
cp_s = _sim(t.counterparty, r.counterparty)
|
|
p_s = purpose_sim(t.purpose, r.counterparty, r.purpose)
|
|
cp_sims.append(cp_s)
|
|
p_sims.append(p_s)
|
|
print(f"#{i} | {t.booking_date} | {t.amount:>10} "
|
|
f"| PDF-cp={_trunc(t.counterparty)} | CSV-cp={_trunc(r.counterparty)} "
|
|
f"| cp-sim={cp_s:.2f} "
|
|
f"| PDF-p={_trunc(t.purpose)} | CSV-p={_trunc(r.purpose)} "
|
|
f"| p-sim={p_s:.2f}")
|
|
|
|
print("-" * 100)
|
|
if unmatched_pdf:
|
|
print(f"Unmatched PDF-Transaktionen ({len(unmatched_pdf)}):")
|
|
for t in unmatched_pdf:
|
|
print(f" {t.booking_date} | {t.amount:>10}")
|
|
if unmatched_csv:
|
|
print(f"Unmatched CSV-Zeilen ({len(unmatched_csv)}):")
|
|
for r in unmatched_csv:
|
|
print(f" {r.booking_date} | {r.amount:>10}")
|
|
|
|
print("=" * 100)
|
|
print("Summary")
|
|
print(f" n_pdf = {len(stmt.transactions)}")
|
|
print(f" n_csv(in Periode) = {len(csv_rows)}")
|
|
print(f" n_matched = {len(matches)}")
|
|
print(f" ambiguities = {len(ambiguous_keys)} "
|
|
f"(Buchungs-Keys mit >1 CSV-Zeile gleichen Datums/Betrags)")
|
|
if cp_sims:
|
|
print(f" cp-sim mean/median = {statistics.mean(cp_sims):.3f} / "
|
|
f"{statistics.median(cp_sims):.3f}")
|
|
print(f" p-sim mean/median = {statistics.mean(p_sims):.3f} / "
|
|
f"{statistics.median(p_sims):.3f}")
|
|
low_cp = sum(1 for s in cp_sims if s < 0.5)
|
|
print(f" cp-sim < 0.5 = {low_cp} von {len(matches)} Matches")
|
|
else:
|
|
print(" (keine Matches)")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv))
|