Fix Indian Bank extraction and add shared multi-format dates for all banks
This commit is contained in:
@@ -1,20 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from .base import amount, norm
|
||||
from datetime import datetime
|
||||
from typing import Iterable
|
||||
|
||||
def find(pattern,text,group=1,flags=re.I|re.M):
|
||||
m=re.search(pattern,text,flags)
|
||||
return norm(m.group(group)) if m else ''
|
||||
import pandas as pd
|
||||
|
||||
def date_iso(s):
|
||||
import pandas as pd
|
||||
x=pd.to_datetime(s,errors='coerce',dayfirst=True)
|
||||
return '' if pd.isna(x) else x.strftime('%Y-%m-%d')
|
||||
from .base import norm
|
||||
|
||||
|
||||
# Shared date support for all bank parsers. Indian bank statements and the
|
||||
# other supported banks can be rendered differently by pdftotext/pdfplumber,
|
||||
# so parsers must not depend on one literal date layout.
|
||||
SUPPORTED_DATE_FORMATS: tuple[str, ...] = (
|
||||
"%d %b %Y",
|
||||
"%d %B %Y",
|
||||
"%b %d %Y",
|
||||
"%B %d %Y",
|
||||
"%d %b, %Y",
|
||||
"%d %B, %Y",
|
||||
"%b %d, %Y",
|
||||
"%B %d, %Y",
|
||||
"%d-%b-%Y",
|
||||
"%d-%B-%Y",
|
||||
"%d/%b/%Y",
|
||||
"%d/%B/%Y",
|
||||
"%d-%m-%Y",
|
||||
"%d/%m/%Y",
|
||||
"%d.%m.%Y",
|
||||
"%Y-%m-%d",
|
||||
"%Y/%m/%d",
|
||||
"%d %b %y",
|
||||
"%d %B %y",
|
||||
"%b %d %y",
|
||||
"%B %d %y",
|
||||
"%d-%b-%y",
|
||||
"%d-%B-%y",
|
||||
"%d/%b/%y",
|
||||
"%d/%B/%y",
|
||||
"%d-%m-%y",
|
||||
"%d/%m/%y",
|
||||
"%d.%m.%y",
|
||||
)
|
||||
|
||||
# A permissive token used only to locate a candidate date at the start of a
|
||||
# transaction line. parse_flexible_date() performs the actual validation.
|
||||
DATE_TOKEN_PATTERN = (
|
||||
r"(?:"
|
||||
r"\d{1,2}\s+[A-Za-z]{3,9},?\s+\d{2,4}"
|
||||
r"|[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{2,4}"
|
||||
r"|\d{1,2}[-/.]\d{1,2}[-/.]\d{2,4}"
|
||||
r"|\d{4}[-/.]\d{1,2}[-/.]\d{1,2}"
|
||||
r"|\d{1,2}[-/.][A-Za-z]{3,9}[-/.]\d{2,4}"
|
||||
r")"
|
||||
)
|
||||
DATE_TOKEN_RE = re.compile(DATE_TOKEN_PATTERN, re.I)
|
||||
LEADING_DATE_RE = re.compile(rf"^\s*({DATE_TOKEN_PATTERN})(?:\s+|$)(.*)$", re.I)
|
||||
|
||||
|
||||
def find(pattern, text, group=1, flags=re.I | re.M):
|
||||
m = re.search(pattern, text, flags)
|
||||
return norm(m.group(group)) if m else ""
|
||||
|
||||
|
||||
def _normalise_date_text(value: str) -> str:
|
||||
value = norm(value)
|
||||
value = value.replace("–", "-").replace("—", "-")
|
||||
value = re.sub(r"\s*,\s*", ", ", value)
|
||||
value = re.sub(r"\s+", " ", value)
|
||||
return value.strip(" ,")
|
||||
|
||||
|
||||
def parse_flexible_date(value, *, default_year: int | None = None) -> pd.Timestamp | pd.NaT:
|
||||
"""Parse an Indian bank date without silently swapping day and month.
|
||||
|
||||
Explicit formats are attempted before pandas' parser. Numeric dates are
|
||||
always interpreted day-first because all supported statements are Indian
|
||||
banking statements. A default year may be supplied for legacy DD/MM rows.
|
||||
"""
|
||||
if value is None or (isinstance(value, float) and pd.isna(value)):
|
||||
return pd.NaT
|
||||
if isinstance(value, (pd.Timestamp, datetime)):
|
||||
return pd.Timestamp(value)
|
||||
|
||||
text = _normalise_date_text(str(value))
|
||||
if not text:
|
||||
return pd.NaT
|
||||
|
||||
if default_year and re.fullmatch(r"\d{1,2}[-/.]\d{1,2}", text):
|
||||
text = f"{text}/{default_year}"
|
||||
|
||||
for fmt in SUPPORTED_DATE_FORMATS:
|
||||
try:
|
||||
return pd.Timestamp(datetime.strptime(text, fmt))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Final guarded fallback for extraction noise. dayfirst=True is explicit
|
||||
# and yearfirst is enabled only when the candidate begins with four digits.
|
||||
parsed = pd.to_datetime(
|
||||
text,
|
||||
errors="coerce",
|
||||
dayfirst=not bool(re.match(r"^\d{4}[-/.]", text)),
|
||||
yearfirst=bool(re.match(r"^\d{4}[-/.]", text)),
|
||||
)
|
||||
return pd.NaT if pd.isna(parsed) else pd.Timestamp(parsed)
|
||||
|
||||
|
||||
def date_iso(value, *, default_year: int | None = None) -> str:
|
||||
parsed = parse_flexible_date(value, default_year=default_year)
|
||||
return "" if pd.isna(parsed) else parsed.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def leading_dates(line: str, *, maximum: int = 2) -> tuple[list[str], str]:
|
||||
"""Return up to ``maximum`` validated dates from the start of a line."""
|
||||
remaining = line
|
||||
dates: list[str] = []
|
||||
for _ in range(maximum):
|
||||
match = LEADING_DATE_RE.match(remaining)
|
||||
if not match:
|
||||
break
|
||||
candidate = match.group(1)
|
||||
if pd.isna(parse_flexible_date(candidate)):
|
||||
break
|
||||
dates.append(candidate)
|
||||
remaining = match.group(2)
|
||||
return dates, remaining
|
||||
|
||||
|
||||
def find_date_tokens(text: str) -> list[str]:
|
||||
return [m.group(0) for m in DATE_TOKEN_RE.finditer(text or "") if not pd.isna(parse_flexible_date(m.group(0)))]
|
||||
|
||||
|
||||
def split_pages(text):
|
||||
return text.split("\f")
|
||||
|
||||
def split_pages(text): return text.split('\f')
|
||||
|
||||
def infer_mode(n):
|
||||
u=(n or '').upper()
|
||||
for k,v in [('UPI','UPI'),('NEFT','NEFT'),('IMPS','IMPS'),('RTGS','RTGS'),('CASH DEPOSIT','Cash Deposit'),('CASH WITHDRAWAL','Cash Withdrawal'),('ATM','ATM'),('CHEQUE','Cheque'),('CHQ','Cheque'),('POS','POS'),('EDC','Card Settlement'),('ACH','ACH')]:
|
||||
if k in u:return v
|
||||
return 'Other'
|
||||
u = (n or "").upper()
|
||||
for k, v in (
|
||||
("UPI", "UPI"),
|
||||
("NEFT", "NEFT"),
|
||||
("IMPS", "IMPS"),
|
||||
("RTGS", "RTGS"),
|
||||
("CASH DEPOSIT", "Cash Deposit"),
|
||||
("CASH WITHDRAWAL", "Cash Withdrawal"),
|
||||
("ATM", "ATM"),
|
||||
("CHEQUE", "Cheque"),
|
||||
("CHQ", "Cheque"),
|
||||
("POS", "POS"),
|
||||
("EDC", "Card Settlement"),
|
||||
("ACH", "ACH"),
|
||||
):
|
||||
if k in u:
|
||||
return v
|
||||
return "Other"
|
||||
|
||||
Reference in New Issue
Block a user