"""Разбор дат из OCR-текста (§11.1 п. 2). Дословный перенос `date_utils` старого ядра DKB_ARMKA (проверен на стенде ИС Сканирование): текстовые формы по приоритету, затем числовые. Вход — текст после `text.fix_lookalikes`; замена двойников здесь не выполняется. Единственное отличие от источника (решение №31 design.md): тексты длиннее `MAX_DATE_TEXT_LENGTH` символов не разбираются — регулярки квартала и полугодия дают кубическое backtracking на длинных строках (`"1" + " " * 4000 + "b"` — десятки секунд при удерживаемом GIL), а дата занимает не более десятков символов.""" import re from collections.abc import Callable from datetime import date, timedelta __all__ = ["MAX_DATE_TEXT_LENGTH", "is_date_sized", "parse_date"] MAX_DATE_TEXT_LENGTH = 200 _ROMAN = {"I": 1, "II": 2, "III": 3, "IV": 4, "1V": 4, "11": 2, "111": 3} _ORDINAL = { "перв": 1, "втор": 2, "трет": 3, "четв": 4, } # --- # Месяцы # --- _MONTHS = { "январ": 1, "феврал": 2, "март": 3, "апрел": 4, "ма": 5, "июн": 6, "июл": 7, "август": 8, "сентябр": 9, "октябр": 10, "ноябр": 11, "декабр": 12, } def _month_num(word: str) -> int | None: w = word.lower() for stem, n in _MONTHS.items(): if w.startswith(stem): return n return None def _month_end(year: int, month: int) -> date: if month == 12: # noqa: PLR2004 return date(year, 12, 31) return date(year, month + 1, 1).replace(day=1) - timedelta(days=1) def _year_full(y: str) -> int: y = int(y) # type: ignore[assignment] return y if y > 100 else 2000 + y # type: ignore[return-value,operator] # noqa: PLR2004 # "25" -> 2025 _Y = r"(\d{4}|\d{2})\s*(?:г\.?|r\.?|год\w*)?" # год + необязательное "г." / "r." / "года" _MONTH_WORD = r"([а-яё]{3,8})" def _qh(num_str: str) -> int | None: """Номер квартала/полугодия из '1', 'I', 'IV', 'первый', '1-й'.""" t = num_str.strip().upper().rstrip("-Й") if t in _ROMAN: return _ROMAN[t] if t.isdigit(): return int(t) for stem, n in _ORDINAL.items(): if t.lower().startswith(stem): return n return None # --- # Паттерны. Порядок = приоритет. # --- _NUM = r"(I{1,3}V?|IV|\d|перв\w*|втор\w*|трет\w*|четв\w*)" _PATTERNS: list[tuple[re.Pattern[str], Callable[[re.Match[str]], date | None]]] = [ # --- диапазон "с 01.01.2025 по 30.09.2025": берём конец ( re.compile(r"по\s*(\d{1,2})[.\-/ ](\d{1,2})[.\-/ ](\d{4}|\d{2})", re.I), lambda m: _safe(_year_full(m[3]), int(m[2]), int(m[1])), ), # --- "31 декабря 2025 г." / "на 30 сентября 2025 года" ( re.compile(rf"(\d{{1,2}})\s+{_MONTH_WORD}\s+{_Y}", re.I), lambda m: _safe(_year_full(m[3]), _month_num(m[2]), int(m[1])), ), # --- "I квартал 2026", "1-й кв. 2026", "первый квартал" ( re.compile(rf"{_NUM}\s*-?\s*(?:й|ый|ого|го)?\s*кв\w*\.?\s*{_Y}", re.I), lambda m: _period(m[2], (_qh(m[1]) or 0) * 3), ), # noqa: PLR2004 # --- "1 полугодие 2026", "первое полугодие", "1 п/г 2026" ( re.compile(rf"{_NUM}\s*-?\s*(?:е|ое|го)?\s*(?:полугоди\w*|п/г)\.?\s*{_Y}", re.I), lambda m: _period(m[2], (_qh(m[1]) or 0) * 6), ), # noqa: PLR2004 # --- "9 месяцев 2025", "9 мес. 2025" (re.compile(rf"(\d{{1,2}})\s*мес\w*\.?\s*{_Y}", re.I), lambda m: _period(m[2], int(m[1]))), # --- "январь - сентябрь 2025": последний месяц (re.compile(rf"{_MONTH_WORD}\s*[-–—]\s*{_MONTH_WORD}\s*{_Y}", re.I), lambda m: _period(m[3], _month_num(m[2]))), # --- "за 2025 г." / "2025 год" / "2025г" / "2025 r." (re.compile(r"(?:за\s*)?(?:отч[её]тный\s*)?(\d{4})\s*(?:г\b|г\.|r\b|r\.|год)", re.I), lambda m: _period(m[1], 12)), ] def _period(year_str: str, month: int | None) -> date | None: if not month or not 1 <= month <= 12: # noqa: PLR2004 return None return _month_end(_year_full(year_str), month) def _safe(y: int, m: int | None, d: int) -> date | None: try: return date(y, m, d) if m else None except ValueError: return None def _text_date(s: str) -> date | None: for rx, fn in _PATTERNS: m = rx.search(s) if m: d = fn(m) if d: return d return None # --- # Числовые формы # --- def _numeric_date(s: str) -> date | None: # noqa: PLR0911 groups = re.findall(r"\d+", s) if not groups: return None # ровно три группы: "30.09.2025", "2025 03 31", "30/09/25" if len(groups) == 3: # noqa: PLR2004 return _from_parts(*groups) # иначе склеиваем цифры и смотрим на длину: # "2 0 2 4", "30 09 2 0 2 5", "3 0 0 9 2025", "| 3 | 0 | 0 | 9 | 2 | 0 | 2 | 5 |" digits = "".join(groups) if len(digits) == 8: # noqa: PLR2004 return _from_digits(digits) if len(digits) == 4: # noqa: PLR2004 return _year(int(digits)) if len(digits) == 6: # noqa: PLR2004 return _from_parts(digits[:2], digits[2:4], digits[4:]) # длинный хвост: "30.09.2025 0:00:00" — дата в первых трёх группах if len(groups) > 3: # noqa: PLR2004 return _from_parts(*groups[:3]) return None def _from_digits(g: str) -> date | None: """8 слитных цифр: ДДММГГГГ, ГГГГММДД, ГГГГДДММ — первый валидный вариант.""" return ( _from_parts(g[:2], g[2:4], g[4:]) # ДД ММ ГГГГ or _from_parts(g[6:], g[4:6], g[:4]) # ГГГГ ММ ДД or _from_parts(g[4:6], g[6:], g[:4]) ) # ГГГГ ДД ММ def _from_parts(a: str, b: str, c: str) -> date | None: """Пробуем DD MM YYYY и YYYY MM DD (год 2- или 4-значный только в позиции года).""" for d, m, y in ((a, b, c), (c, b, a)): if len(y) in (2, 4) and len(d) <= 2 and len(m) <= 2: # noqa: PLR2004 try: return date(_year_full(y), int(m), int(d)) except ValueError: continue return None def _year(y: int) -> date | None: """Одинокий год -> 31.12 этого года (годовая отчётность).""" return date(y, 12, 31) if 1990 <= y <= 2100 else None # noqa: PLR2004 def is_date_sized(text: str) -> bool: """Текст не длиннее `MAX_DATE_TEXT_LENGTH` (по краям пробелы не считаются): только такой разбирается как дата. Вызывающие проверяют это ДО `fix_lookalikes`, чтобы не гонять замену двойников по длинному тексту.""" return len(text.strip()) <= MAX_DATE_TEXT_LENGTH def parse_date(value: object) -> date | None: """Вернуть дату или None, если это не дата (название компании и т.п.) или текст длиннее `MAX_DATE_TEXT_LENGTH`.""" if not isinstance(value, str): return None s = value.strip() if not s or not is_date_sized(s): return None # текстовые формы ищем по всей строке, числовые — по первой (дальше мусор) return _text_date(s) or _numeric_date(s.split("\n", 1)[0]) or _numeric_date(s.replace("\n", " "))