- C-1 (№30): сумма с более чем двумя знаками после запятой не принимается (amount=null, string_value, is_recognized=false); confidence ячеек и шапки приводится к [0, 100], NaN и бесконечность дают 0 - F-A (№28): ошибочный файл набора при успешном понижает статус до needs_check - F-B: «ИНН не распознан» только когда ИНН не извлечён, иначе «ИНН распознан неуверенно» - №29: предупреждения «строк с нечисловым кодом пропущено: N» и «строк без кода с суммами пропущено: N» - I-3 (№31): тексты дат длиннее 200 символов не разбираются и не проходят fix_lookalikes - T1.3 (№33): загрузчик yaml отклоняет повторяющиеся ключи; тавтологичный тест дублей заменён настоящим - гигиена: докстринг amounts.py описывает фактическое поведение (все точки удаляются), мёртвые noqa в ocr/__init__.py Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
208 lines
7.9 KiB
Python
208 lines
7.9 KiB
Python
"""Разбор дат из 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", " "))
|