Add SBI Account Summary layout template parser
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .idfc import IDFCFirstParser
|
from .idfc import IDFCFirstParser
|
||||||
from .axis import AxisParser
|
from .axis import AxisParser
|
||||||
@@ -8,7 +8,7 @@ from .hsbc import HSBCParser
|
|||||||
from .indian_bank import IndianBankModernParser, IndianBankLegacyParser
|
from .indian_bank import IndianBankModernParser, IndianBankLegacyParser
|
||||||
from .indusind import IndusIndParser
|
from .indusind import IndusIndParser
|
||||||
from .kotak import KotakParser
|
from .kotak import KotakParser
|
||||||
from .sbi import SBIModernParser, SBIOtherParser
|
from .sbi import SBIAccountSummaryParser, SBIModernParser, SBIOtherParser
|
||||||
from .central_bank_of_india import CentralBankOfIndiaParser
|
from .central_bank_of_india import CentralBankOfIndiaParser
|
||||||
from .yes_bank import YesBankParser
|
from .yes_bank import YesBankParser
|
||||||
from .city_union_bank import CityUnionBankParser
|
from .city_union_bank import CityUnionBankParser
|
||||||
@@ -31,6 +31,7 @@ PARSERS = [
|
|||||||
IndianBankLegacyParser,
|
IndianBankLegacyParser,
|
||||||
IndusIndParser,
|
IndusIndParser,
|
||||||
KotakParser,
|
KotakParser,
|
||||||
|
SBIAccountSummaryParser,
|
||||||
SBIOtherParser,
|
SBIOtherParser,
|
||||||
SBIModernParser,
|
SBIModernParser,
|
||||||
]
|
]
|
||||||
@@ -67,7 +68,7 @@ BANK_PARSERS = {
|
|||||||
"indian_bank": [IndianBankModernParser, IndianBankLegacyParser],
|
"indian_bank": [IndianBankModernParser, IndianBankLegacyParser],
|
||||||
"indusind": [IndusIndParser],
|
"indusind": [IndusIndParser],
|
||||||
"kotak": [KotakParser],
|
"kotak": [KotakParser],
|
||||||
"sbi": [SBIOtherParser, SBIModernParser],
|
"sbi": [SBIAccountSummaryParser, SBIOtherParser, SBIModernParser],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,39 @@ _HEADER_RE = re.compile(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_metrics(text: str) -> dict[str, float | int] | None:
|
||||||
|
"""Extract SBI's printed statement summary without depending on line wrapping."""
|
||||||
|
match = re.search(
|
||||||
|
r"Brought\s+Forward.*?Dr\s+Count\s+Cr\s+Count.*?"
|
||||||
|
r"Total\s+Debits.*?Total\s+Credits.*?Closing\s+Balance.*?"
|
||||||
|
r"([\d,]+\.\d{2})\s*(CR|DR)?\s+"
|
||||||
|
r"(\d{1,6})\s+(\d{1,6})\s+"
|
||||||
|
r"([\d,]+\.\d{2})\s+([\d,]+\.\d{2})\s+"
|
||||||
|
r"([\d,]+\.\d{2})\s*(CR|DR)?",
|
||||||
|
text,
|
||||||
|
re.I | re.S,
|
||||||
|
)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def signed(value: str, suffix: str | None) -> float:
|
||||||
|
parsed = float(value.replace(",", ""))
|
||||||
|
return -parsed if (suffix or "").upper() == "DR" else parsed
|
||||||
|
|
||||||
|
return {
|
||||||
|
"opening_balance": signed(match.group(1), match.group(2)),
|
||||||
|
"debit_count": int(match.group(3)),
|
||||||
|
"credit_count": int(match.group(4)),
|
||||||
|
"total_debit": float(match.group(5).replace(",", "")),
|
||||||
|
"total_credit": float(match.group(6).replace(",", "")),
|
||||||
|
"closing_balance": signed(match.group(7), match.group(8)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _expected_summary_transactions(text: str) -> int | None:
|
def _expected_summary_transactions(text: str) -> int | None:
|
||||||
|
summary = _summary_metrics(text)
|
||||||
|
if summary:
|
||||||
|
return int(summary["debit_count"]) + int(summary["credit_count"])
|
||||||
counts = re.findall(
|
counts = re.findall(
|
||||||
r"(?:Brought\s+Forward.*?)(\d{1,3})\s+(\d{1,3})\s+[\d,]+\.\d{2}\s+[\d,]+\.\d{2}",
|
r"(?:Brought\s+Forward.*?)(\d{1,3})\s+(\d{1,3})\s+[\d,]+\.\d{2}\s+[\d,]+\.\d{2}",
|
||||||
text,
|
text,
|
||||||
@@ -37,6 +69,16 @@ def _expected_summary_transactions(text: str) -> int | None:
|
|||||||
return sum(int(debit_count) + int(credit_count) for debit_count, credit_count in counts)
|
return sum(int(debit_count) + int(credit_count) for debit_count, credit_count in counts)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_printed_summary(meta: StatementMeta, text: str) -> None:
|
||||||
|
summary = _summary_metrics(text)
|
||||||
|
if not summary:
|
||||||
|
return
|
||||||
|
meta.opening_balance = float(summary["opening_balance"])
|
||||||
|
meta.total_debit = float(summary["total_debit"])
|
||||||
|
meta.total_credit = float(summary["total_credit"])
|
||||||
|
meta.closing_balance = float(summary["closing_balance"])
|
||||||
|
|
||||||
|
|
||||||
def _ocr_yono_page(pdf_path: Path, page_number: int, work_dir: Path, dpi: int) -> tuple[int, str]:
|
def _ocr_yono_page(pdf_path: Path, page_number: int, work_dir: Path, dpi: int) -> tuple[int, str]:
|
||||||
prefix = work_dir / f"sbi_yono_{page_number:05d}"
|
prefix = work_dir / f"sbi_yono_{page_number:05d}"
|
||||||
image_path = prefix.with_suffix(".png")
|
image_path = prefix.with_suffix(".png")
|
||||||
@@ -211,7 +253,7 @@ def _parse_rows(
|
|||||||
# physical line. Split before every detected date pair so each transaction
|
# physical line. Split before every detected date pair so each transaction
|
||||||
# reaches the normal row collector independently.
|
# reaches the normal row collector independently.
|
||||||
text = re.sub(
|
text = re.sub(
|
||||||
r"(?<!\n)(?=(?:\d{1,2}[/-]\d{1,2}[/-]\d{2,4})\s+"
|
r"(?<=[^\n\d])(?=(?:\d{1,2}[/-]\d{1,2}[/-]\d{2,4})\s+"
|
||||||
r"(?:\d{1,2}[/-]\d{1,2}[/-]\d{2,4})\s+)",
|
r"(?:\d{1,2}[/-]\d{1,2}[/-]\d{2,4})\s+)",
|
||||||
"\n",
|
"\n",
|
||||||
text,
|
text,
|
||||||
@@ -226,9 +268,10 @@ def _parse_rows(
|
|||||||
|
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
current: dict | None = None
|
current: dict | None = None
|
||||||
|
pending_prefix: list[str] = []
|
||||||
page = 1
|
page = 1
|
||||||
|
|
||||||
for raw_line in text.splitlines():
|
for raw_line in text.split("\n"):
|
||||||
page += _page_increment(raw_line)
|
page += _page_increment(raw_line)
|
||||||
line = raw_line.replace("\f", "").rstrip()
|
line = raw_line.replace("\f", "").rstrip()
|
||||||
match = start_re.match(line)
|
match = start_re.match(line)
|
||||||
@@ -242,11 +285,24 @@ def _parse_rows(
|
|||||||
"transaction_date": transaction_date,
|
"transaction_date": transaction_date,
|
||||||
"value_date": value_date,
|
"value_date": value_date,
|
||||||
"lines": [remainder],
|
"lines": [remainder],
|
||||||
|
"prefix_lines": list(pending_prefix),
|
||||||
"source_page": page,
|
"source_page": page,
|
||||||
}
|
}
|
||||||
|
pending_prefix = []
|
||||||
continue
|
continue
|
||||||
|
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
|
is_mode_prefix = bool(
|
||||||
|
stripped
|
||||||
|
and re.fullmatch(
|
||||||
|
r"(?:DEP|WDL)\s+TFR(?:\s+INB.*)?|INTEREST\s+CREDIT|CEMTEX\s+DEP",
|
||||||
|
stripped,
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if is_mode_prefix:
|
||||||
|
pending_prefix = [stripped]
|
||||||
|
continue
|
||||||
if current and stripped and not _HEADER_RE.match(stripped):
|
if current and stripped and not _HEADER_RE.match(stripped):
|
||||||
current["lines"].append(stripped)
|
current["lines"].append(stripped)
|
||||||
|
|
||||||
@@ -257,7 +313,7 @@ def _parse_rows(
|
|||||||
previous_balance = meta.opening_balance
|
previous_balance = meta.opening_balance
|
||||||
for row in rows:
|
for row in rows:
|
||||||
first_line = row["lines"][0]
|
first_line = row["lines"][0]
|
||||||
full_text = norm(" ".join(row["lines"]))
|
full_text = norm(" ".join([*row.get("prefix_lines", []), *row["lines"]]))
|
||||||
debit, credit, balance, first_narration = _row_values(
|
debit, credit, balance, first_narration = _row_values(
|
||||||
first_line,
|
first_line,
|
||||||
full_text,
|
full_text,
|
||||||
@@ -266,7 +322,7 @@ def _parse_rows(
|
|||||||
if balance is None:
|
if balance is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
narration = norm(" ".join([first_narration, *row["lines"][1:]]))
|
narration = norm(" ".join([*row.get("prefix_lines", []), first_narration, *row["lines"][1:]]))
|
||||||
if debit is None and credit is None and previous_balance is not None:
|
if debit is None and credit is None and previous_balance is not None:
|
||||||
delta = round(balance - previous_balance, 2)
|
delta = round(balance - previous_balance, 2)
|
||||||
if delta < -0.01:
|
if delta < -0.01:
|
||||||
@@ -354,6 +410,49 @@ class SBIYONORelationshipParser(BaseParser):
|
|||||||
return meta, frame
|
return meta, frame
|
||||||
|
|
||||||
|
|
||||||
|
class SBIAccountSummaryParser(BaseParser):
|
||||||
|
"""SBI Account Summary layout with dual dates and a printed summary page."""
|
||||||
|
|
||||||
|
bank_name = "State Bank of India"
|
||||||
|
parser_name = "SBIAccountSummaryParser"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def detect(cls, text: str) -> float:
|
||||||
|
upper = norm(text).upper()
|
||||||
|
required = (
|
||||||
|
"STATE BANK OF INDIA",
|
||||||
|
"ACCOUNT SUMMARY",
|
||||||
|
"STATEMENT OF ACCOUNT",
|
||||||
|
"STATEMENT SUMMARY",
|
||||||
|
"BROUGHT FORWARD",
|
||||||
|
"DR COUNT",
|
||||||
|
"CR COUNT",
|
||||||
|
)
|
||||||
|
if all(marker in upper for marker in required):
|
||||||
|
return 0.998
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def parse(self, path, text=None):
|
||||||
|
extracted_text = text or extract_text(path)
|
||||||
|
meta = _extract_common_meta(extracted_text, path, self.parser_name)
|
||||||
|
_apply_printed_summary(meta, extracted_text)
|
||||||
|
if meta.opening_balance is None:
|
||||||
|
meta.opening_balance = _opening_from_summary(extracted_text)
|
||||||
|
start_re = re.compile(
|
||||||
|
rf"^\s*({DATE_TOKEN_PATTERN})\s+({DATE_TOKEN_PATTERN})\s+(.*)$",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
frame = _parse_rows(extracted_text, meta, start_re, 2)
|
||||||
|
|
||||||
|
summary = _summary_metrics(extracted_text)
|
||||||
|
if summary and len(frame) != int(summary["debit_count"]) + int(summary["credit_count"]):
|
||||||
|
raise ValueError(
|
||||||
|
"SBI Account Summary rows could not be fully reconciled with the printed "
|
||||||
|
"debit and credit counts. Please retain the PDF and contact support."
|
||||||
|
)
|
||||||
|
return meta, frame
|
||||||
|
|
||||||
|
|
||||||
class SBIStandardParser(BaseParser):
|
class SBIStandardParser(BaseParser):
|
||||||
bank_name = "State Bank of India"
|
bank_name = "State Bank of India"
|
||||||
parser_name = "SBIStandardParser"
|
parser_name = "SBIStandardParser"
|
||||||
|
|||||||
Reference in New Issue
Block a user