Andrey Epifancev c1288d08fb fix(normalizer): итоги финального ревью — Money, статус при ошибочном файле, пропущенные строки, длина дат, дубли yaml
- 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
2026-10-07 03:35:10 +04:00

208 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Разбор дат из 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", " "))