""" InvoiceForge AI — validators/date_validator.py Multi-format date parser and normaliser. Supported input formats: DD/MM/YYYY DD-MM-YYYY DD.MM.YYYY DD/MM/YY DD-MM-YY DD.MM.YY DD MMM YYYY DD-MMM-YYYY DD.MMM.YYYY (e.g. 15 Jan 2025) MMMM DD, YYYY (e.g. January 15, 2025) YYYY-MM-DD (already normalised) DD MMM YY (e.g. 15 Jan 25) All outputs normalised to YYYY-MM-DD. Sanity checks: - Year must be between 2000 and 2099 - Month must be 1-12 - Day must be 1-31 - Warns if date is in the future """ from __future__ import annotations import logging import re from datetime import date, datetime from typing import Optional logger = logging.getLogger(__name__) # ───────────────────────────────────────────────────────────────────────────── # MONTH MAPS # ───────────────────────────────────────────────────────────────────────────── MONTH_SHORT: dict[str, str] = { "jan": "01", "feb": "02", "mar": "03", "apr": "04", "may": "05", "jun": "06", "jul": "07", "aug": "08", "sep": "09", "oct": "10", "nov": "11", "dec": "12", } MONTH_LONG: dict[str, str] = { "january": "01", "february": "02", "march": "03", "april": "04", "may": "05", "june": "06", "july": "07", "august": "08", "september": "09", "october": "10", "november": "11", "december": "12", } # Combined map MONTH_MAP: dict[str, str] = {**MONTH_LONG, **MONTH_SHORT} # ───────────────────────────────────────────────────────────────────────────── # PATTERNS (evaluated in order — most specific first) # ───────────────────────────────────────────────────────────────────────────── _PATTERNS: list[tuple[re.Pattern, str]] = [ # YYYY-MM-DD (already ISO) (re.compile(r"\b(20\d{2})[-/\.](0[1-9]|1[0-2])[-/\.](0[1-9]|[12]\d|3[01])\b"), "YMD"), # DD-MMM-YYYY or DD.MMM.YYYY or DD MMM YYYY (re.compile(r"\b(\d{1,2})[-/\.\s]([A-Za-z]{3,9})[-/\.\s](20\d{2}|\d{2})\b"), "DMY_TEXT"), # MMMM DD, YYYY or MMMM D YYYY (re.compile(r"\b([A-Za-z]{4,9})\s+(\d{1,2})[,\s]+(20\d{2}|\d{2})\b"), "MY_TEXT"), # DD/MM/YYYY or DD-MM-YYYY or DD.MM.YYYY (4-digit year) (re.compile(r"\b(\d{1,2})[-/\.](\d{1,2})[-/\.](20\d{2})\b"), "DMY"), # DD/MM/YY or DD-MM-YY (2-digit year) (re.compile(r"\b(\d{1,2})[-/\.](\d{1,2})[-/\.](\d{2})\b"), "DMY_SHORT"), ] # ───────────────────────────────────────────────────────────────────────────── # DATE VALIDATOR # ───────────────────────────────────────────────────────────────────────────── class DateValidator: """ Parses and normalises dates from raw OCR text. Usage: dv = DateValidator() iso_date = dv.parse("Dt: 28-Mar-2026") # "2026-03-28" result = dv.validate("2026-03-28") """ def parse(self, text: str) -> str: """ Extract the first plausible date from arbitrary text and return it in YYYY-MM-DD format. Returns empty string if not found. """ for pattern, fmt in _PATTERNS: m = pattern.search(text) if not m: continue try: result = self._parse_match(m, fmt) if result: return result except Exception: continue return "" def parse_all(self, text: str) -> list[str]: """Return all unique valid dates found in the text.""" dates: list[str] = [] seen: set[str] = set() for pattern, fmt in _PATTERNS: for m in pattern.finditer(text): try: d = self._parse_match(m, fmt) if d and d not in seen: dates.append(d) seen.add(d) except Exception: continue return dates def validate(self, date_str: str) -> dict: """ Validate a YYYY-MM-DD date string. Returns: dict with keys: is_valid, date, warnings, errors """ errors: list[str] = [] warnings: list[str] = [] if not date_str: errors.append("Date is empty.") return {"is_valid": False, "date": "", "errors": errors, "warnings": warnings} try: parsed = datetime.strptime(date_str, "%Y-%m-%d").date() except ValueError: errors.append(f"Date '{date_str}' is not in YYYY-MM-DD format.") return {"is_valid": False, "date": date_str, "errors": errors, "warnings": warnings} today = date.today() if parsed.year < 2000 or parsed.year > 2099: errors.append(f"Year {parsed.year} is outside expected range 2000-2099.") if parsed > today: warnings.append(f"Invoice date {date_str} is in the future.") if (today - parsed).days > 365 * 3: warnings.append(f"Invoice date {date_str} is more than 3 years old.") return { "is_valid": len(errors) == 0, "date": date_str, "errors": errors, "warnings": warnings, } # ───────────────────────────────────────────────────────────────────────── # INTERNAL PARSERS # ───────────────────────────────────────────────────────────────────────── def _parse_match(self, m: re.Match, fmt: str) -> Optional[str]: """Parse a regex match into YYYY-MM-DD string.""" if fmt == "YMD": y, mo, d = m.group(1), m.group(2), m.group(3) elif fmt == "DMY_TEXT": d, month_name, y = m.group(1), m.group(2).lower()[:3], m.group(3) mo = MONTH_SHORT.get(month_name) if not mo: return None if len(y) == 2: y = "20" + y elif fmt == "MY_TEXT": month_name, d, y = m.group(1).lower(), m.group(2), m.group(3) mo = MONTH_MAP.get(month_name) if not mo: return None if len(y) == 2: y = "20" + y elif fmt == "DMY": d, mo, y = m.group(1), m.group(2), m.group(3) elif fmt == "DMY_SHORT": d, mo, y_short = m.group(1), m.group(2), m.group(3) y = "20" + y_short else: return None # Pad and validate components d = d.zfill(2) mo = mo.zfill(2) if not (1 <= int(d) <= 31 and 1 <= int(mo) <= 12 and 2000 <= int(y) <= 2099): return None return f"{y}-{mo}-{d}"