158 lines
4.5 KiB
Python
158 lines
4.5 KiB
Python
from __future__ import annotations
|
||
|
||
import re
|
||
from datetime import datetime
|
||
from typing import Iterable
|
||
|
||
import pandas as pd
|
||
|
||
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 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"
|