Files
arrr-erp/app/modules/bank_statement_analyzer/parsers/template_engine.py
T
2026-08-04 13:10:24 +05:30

429 lines
16 KiB
Python

from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
import pandas as pd
import pdfplumber
from .base import BaseParser, StatementMeta, amount, extract_text, finalize, norm
from .common import date_iso, find, infer_mode, parse_flexible_date
# The template engine is deliberately additive. Existing bank-specific parsers
# remain the primary route. This parser is used only when a selected parser does
# not recognise a layout or when a recognised parser cannot extract rows.
_DATE_CELL_RE = re.compile(
r"^\s*(?:\d{1,2}[/-]\d{1,2}[/-]\d{2,4}|\d{1,2}-[A-Za-z]{3}-\d{2,4}|\d{1,2}\s+[A-Za-z]{3}\s+\d{2,4})\s*$",
re.I,
)
_BANK_MARKERS: tuple[tuple[str, tuple[str, ...]], ...] = (
("State Bank of India", ("STATE BANK OF INDIA", "SBIN0")),
("HDFC Bank", ("HDFC BANK", "HDFC0")),
("Axis Bank", ("AXIS BANK", "UTIB0")),
("ICICI Bank", ("ICICI BANK", "ICIC0")),
("HSBC Bank", ("HSBC",)),
("IDFC FIRST Bank", ("IDFC FIRST BANK", "IDFB0")),
("Indian Bank", ("INDIAN BANK", "IDIB0")),
("IndusInd Bank", ("INDUSIND BANK", "INDB0")),
("Kotak Mahindra Bank", ("KOTAK MAHINDRA BANK", "KKBK0")),
("Central Bank of India", ("CENTRAL BANK OF INDIA", "CBIN0")),
("YES Bank", ("YES BANK", "YESB0")),
("City Union Bank", ("CITY UNION BANK", "CIUB0")),
("Bank of Baroda", ("BANK OF BARODA", "BARB0")),
("RBL Bank", ("RBL BANK", "RATN0")),
)
_BANK_KEY_LABELS = {
"axis": "Axis Bank",
"central_bank_of_india": "Central Bank of India",
"yes_bank": "YES Bank",
"city_union_bank": "City Union Bank",
"bank_of_baroda": "Bank of Baroda",
"rbl_bank": "RBL Bank",
"hdfc": "HDFC Bank",
"icici": "ICICI Bank",
"hsbc": "HSBC Bank",
"idfc": "IDFC FIRST Bank",
"indian_bank": "Indian Bank",
"indusind": "IndusInd Bank",
"kotak": "Kotak Mahindra Bank",
"sbi": "State Bank of India",
}
_HEADER_ALIASES = {
"transaction_date": ("TRANSACTION DATE", "TXN DATE", "POST DATE", "DATE"),
"value_date": ("VALUE DATE",),
"narration": ("TRANSACTION DETAILS", "PARTICULARS", "DESCRIPTION", "DETAILS", "NARRATION"),
"reference_no": ("REF NO./CHEQUE NO.", "REF NO/CHEQUE NO", "CHEQUE NO/REFERENCE NO", "CHQ.NO.", "CHQ.NO", "CHEQUE NO", "REFERENCE NO"),
"debit": ("WITHDRAWAL AMT", "WITHDRAWALS", "WITHDRAWAL", "DEBIT AMT", "DEBIT"),
"credit": ("DEPOSIT AMT", "DEPOSITS", "DEPOSIT", "CREDIT AMT", "CREDIT"),
"balance": ("RUNNING BALANCE", "BALANCE AMT", "BALANCE"),
}
@dataclass(frozen=True)
class LayoutTemplate:
name: str
mapping: dict[str, int]
confidence: float
def _upper(value) -> str:
return norm(value).upper().replace("\n", " ")
def _normalise_date_cell(value) -> str:
"""Normalise PDF table line-wrap artefacts without changing the date value."""
text = norm(value)
text = re.sub(r"\s*([/-])\s*", r"\1", text)
return text
def _is_date(value) -> bool:
text = _normalise_date_cell(value)
return bool(_DATE_CELL_RE.fullmatch(text)) and not pd.isna(parse_flexible_date(text))
def _signed_balance(value) -> float | None:
text = norm(value).upper()
parsed = amount(text)
if parsed is None:
return None
return -abs(parsed) if text.endswith("DR") else abs(parsed)
def _find_header_mapping(row: list) -> dict[str, int]:
cells = [_upper(cell) for cell in row]
mapping: dict[str, int] = {}
for field, aliases in _HEADER_ALIASES.items():
for index, cell in enumerate(cells):
if any(alias == cell or alias in cell for alias in aliases):
mapping[field] = index
break
return mapping
def _looks_like_data_row(row: list) -> bool:
return bool(row) and any(_is_date(row[index]) for index in range(min(4, len(row))))
def _infer_structural_template(table: list[list]) -> LayoutTemplate | None:
sample = next((list(row) for row in table if _looks_like_data_row(list(row))), None)
if sample is None:
return None
width = len(sample)
# Modern ICICI and similar indexed statements:
# serial no, transaction id, transaction date, cheque no, description,
# withdrawal, deposit, available balance.
if width >= 8 and _is_date(sample[2]):
return LayoutTemplate(
"indexed_transaction_id_debit_credit_balance",
{
"transaction_date": 2,
"value_date": 2,
"reference_no": 1,
"narration": 4,
"debit": 5,
"credit": 6,
"balance": 7,
},
0.92,
)
# SBI Account Summary / SBI YONO family:
# value date, post date, details, reference, debit, credit, balance.
if width >= 7 and _is_date(sample[0]) and _is_date(sample[1]):
return LayoutTemplate(
"dual_date_debit_credit_balance",
{
"value_date": 0,
"transaction_date": 1,
"narration": 2,
"reference_no": 3,
"debit": 4,
"credit": 5,
"balance": 6,
},
0.90,
)
# RBL-style:
# transaction date, details, cheque no, value date, withdrawal, deposit, balance.
if width >= 7 and _is_date(sample[0]) and _is_date(sample[3]):
return LayoutTemplate(
"transaction_and_value_date",
{
"transaction_date": 0,
"narration": 1,
"reference_no": 2,
"value_date": 3,
"debit": 4,
"credit": 5,
"balance": 6,
},
0.88,
)
# BOB/CUB and many conventional statements.
if width >= 6 and _is_date(sample[0]):
return LayoutTemplate(
"single_date_reference_debit_credit_balance",
{
"transaction_date": 0,
"narration": 1,
"reference_no": 2,
"debit": width - 3,
"credit": width - 2,
"balance": width - 1,
},
0.82,
)
if width >= 5 and _is_date(sample[0]):
return LayoutTemplate(
"single_date_debit_credit_balance",
{
"transaction_date": 0,
"narration": 1,
"debit": width - 3,
"credit": width - 2,
"balance": width - 1,
},
0.78,
)
return None
def _template_for_table(table: list[list]) -> tuple[LayoutTemplate | None, int]:
for row_index, raw_row in enumerate(table[:4]):
row = list(raw_row or [])
mapping = _find_header_mapping(row)
required = {"transaction_date", "narration", "debit", "credit", "balance"}
if required.issubset(mapping):
if "value_date" not in mapping:
mapping["value_date"] = mapping["transaction_date"]
return LayoutTemplate("header_alias_template", mapping, 0.96), row_index + 1
inferred = _infer_structural_template(table)
return inferred, 0
def _bank_name(text: str, bank_hint: str | None) -> str:
if bank_hint and bank_hint != "auto":
return _BANK_KEY_LABELS.get(bank_hint, bank_hint.replace("_", " ").title())
upper = (text or "").upper()
best_name = "Unknown Bank"
best_score = 0
for name, markers in _BANK_MARKERS:
score = sum(marker in upper for marker in markers)
if score > best_score:
best_name, best_score = name, score
return best_name
def _extract_reference(narration: str, explicit: str) -> str:
explicit = norm(explicit)
if explicit and explicit != "-":
return explicit
patterns = (
r"\bUPI/(?:DR|CR|DRC)?/?([0-9]{10,18})\b",
r"\bIMPS[/ ]([A-Z0-9]{10,30})\b",
r"\b(?:NEFT|RTGS)[*/:/ -]([A-Z0-9]{10,35})\b",
r"\bUTR[:/ ]([A-Z0-9]{8,35})\b",
r"\bCHQ(?:UE)?\s*NO\.?\s*([0-9]{2,12})\b",
)
for pattern in patterns:
match = re.search(pattern, narration, re.I)
if match:
return match.group(1)
return ""
def _parse_summary(text: str, meta: StatementMeta) -> None:
opening_patterns = (
r"Opening\s+Bal(?:ance)?\s*[:\-]?\s*([\d,]+\.\d{2})\s*(CR|DR)?",
r"Brought\s+Forward(?:\([^)]*\))?\s*[:\-]?\s*([\d,]+\.\d{2})\s*(CR|DR)?",
r"\bB/F\s+([\d,]+\.\d{2})\s*(CR|DR)?",
)
closing_patterns = (
r"Closing\s+Bal(?:ance)?\s*[:\-]?\s*([\d,]+\.\d{2})\s*(CR|DR)?",
r"Grand\s+Total:[^\n]*?([\d,]+\.\d{2})\s*(CR|DR)?",
)
def signed(match) -> float | None:
if not match:
return None
value = amount(match.group(1))
suffix = (match.group(2) or "").upper() if match.lastindex and match.lastindex >= 2 else ""
if value is not None and suffix == "DR":
value = -abs(value)
return value
for pattern in opening_patterns:
value = signed(re.search(pattern, text, re.I | re.S))
if value is not None:
meta.opening_balance = value
break
for pattern in closing_patterns:
value = signed(re.search(pattern, text, re.I | re.S))
if value is not None:
meta.closing_balance = value
break
summary = re.search(
r"(?:Total\s+Debits?|Grand\s+Total)\D*([\d,]+\.\d{2})\D+(?:Total\s+Credits?|)([\d,]+\.\d{2})",
text,
re.I | re.S,
)
if summary and "TOTAL DEBIT" in text.upper():
meta.total_debit = amount(summary.group(1))
meta.total_credit = amount(summary.group(2))
period = re.search(
r"(?:Statement\s+(?:From|of account for the period of)|Period\s*:?)\s*"
r"(\d{1,2}[/-]\d{1,2}[/-]\d{2,4}|\d{1,2}-[A-Za-z]{3}-\d{2,4})\s+"
r"(?:to|To)\s+"
r"(\d{1,2}[/-]\d{1,2}[/-]\d{2,4}|\d{1,2}-[A-Za-z]{3}-\d{2,4})",
text,
re.I,
)
if period:
meta.period_from = date_iso(period.group(1))
meta.period_to = date_iso(period.group(2))
def _extract_metadata(text: str, meta: StatementMeta) -> None:
meta.customer_name = norm(
find(r"(?:A/C Name|Account Name|Accountholder Name|Primary Holder)\s*:?[ \t]*([^\n]+)", text, flags=re.I)
)
meta.account_number = find(
r"(?:A/C Number|Account Number|Account No\.?|Savings Account No\.)\s*:?[ \t]*([0-9Xx* -]{6,30})",
text,
flags=re.I,
).replace(" ", "")
meta.customer_id = find(r"(?:CIF ID|CIF Number|Customer ID|Cust Id)\s*:?[ \t]*([0-9Xx*]+)", text, flags=re.I)
meta.ifsc = find(r"(?:IFSC(?:/RTGS/NEFT)?|IFS Code)\s*:?[ \t]*([A-Z]{4}0[A-Z0-9]{6})", text, flags=re.I)
_parse_summary(text, meta)
def _continuity_score(rows: list[dict], opening_balance: float | None) -> float:
if not rows:
return 0.0
checks = matches = 0
previous = opening_balance
for row in rows:
balance = row.get("balance")
debit = row.get("debit") or 0.0
credit = row.get("credit") or 0.0
if balance is None:
continue
if previous is None:
previous = float(balance) + float(debit) - float(credit)
expected = round(float(previous) + float(credit) - float(debit), 2)
checks += 1
if abs(expected - float(balance)) <= 0.05:
matches += 1
previous = float(balance)
return matches / checks if checks else 0.0
class TemplateBasedStatementParser(BaseParser):
bank_name = "Template Matched Bank"
parser_name = "TemplateBasedStatementParser"
@classmethod
def detect(cls, text: str) -> float:
upper = (text or "").upper()
structural = sum(
token in upper
for token in (
"VALUE DATE", "TRANSACTION DATE", "POST DATE", "WITHDRAWAL",
"DEPOSIT", "DEBIT", "CREDIT", "BALANCE", "PARTICULARS",
"TRANSACTION DETAILS",
)
)
return min(0.85, structural * 0.08) if structural >= 4 else 0.0
def __init__(self, bank_hint: str | None = None):
self.bank_hint = (bank_hint or "auto").strip().lower()
def parse(self, path, text=None):
pdf_path = Path(path)
text = text or extract_text(pdf_path)
detected_bank = _bank_name(text, self.bank_hint)
meta = StatementMeta(
bank_name=detected_bank,
source_file=pdf_path.name,
parser_name=self.parser_name,
confidence="Template Matched",
)
_extract_metadata(text, meta)
rows: list[dict] = []
template_names: list[str] = []
with pdfplumber.open(str(pdf_path)) as pdf:
for page_no, page in enumerate(pdf.pages, start=1):
tables = page.extract_tables() or []
for table in tables:
if not table:
continue
template, data_start = _template_for_table(table)
if template is None:
continue
page_rows = 0
for raw in table[data_start:]:
cells = list(raw or [])
mapping = template.mapping
max_index = max(mapping.values())
if len(cells) <= max_index:
cells += [None] * (max_index + 1 - len(cells))
txn_text = _normalise_date_cell(cells[mapping["transaction_date"]])
if not _is_date(txn_text):
continue
value_index = mapping.get("value_date", mapping["transaction_date"])
value_text = _normalise_date_cell(cells[value_index]) or txn_text
narration = norm(cells[mapping["narration"]])
reference = norm(cells[mapping["reference_no"]]) if "reference_no" in mapping else ""
debit = amount(cells[mapping["debit"]])
credit = amount(cells[mapping["credit"]])
balance = _signed_balance(cells[mapping["balance"]])
if balance is None or (debit is None and credit is None):
continue
rows.append(
{
"transaction_date": txn_text,
"value_date": value_text,
"narration": narration,
"reference_no": _extract_reference(narration, reference),
"debit": debit,
"credit": credit,
"balance": balance,
"source_page": page_no,
"mode": infer_mode(narration),
}
)
page_rows += 1
if page_rows:
template_names.append(template.name)
if not rows:
raise ValueError("No transaction table matched a supported statement template.")
continuity = _continuity_score(rows, meta.opening_balance)
dated_ratio = sum(not pd.isna(parse_flexible_date(row["transaction_date"])) for row in rows) / len(rows)
if len(rows) < 2 or dated_ratio < 0.90 or continuity < 0.80:
raise ValueError(
"A similar table layout was found, but transaction balances did not reconcile reliably. "
"The statement was not accepted to prevent an incorrect analysis."
)
meta.parser_name = f"TemplateBasedStatementParser[{','.join(dict.fromkeys(template_names))}]"
meta.confidence = f"High ({continuity:.1%} balance continuity)"
frame = pd.DataFrame(rows)
return meta, finalize(frame, meta)
def parse_with_template(path, text: str | None = None, bank_hint: str | None = None):
return TemplateBasedStatementParser(bank_hint=bank_hint).parse(path, text)