Единая нормализация ББ/ОФР: адаптер OCR → arm_statements, контракт результата 2.0 (arm-ka-docs#116) #47
@ -61,9 +61,6 @@ SCAN_MAX_INFLIGHT=4
|
||||
# Очистка горячей папки после прочтения ответа (D-2): true — удалить всё содержимое Output/<job_id>/, входящий файл
|
||||
# (и его .done); false (по умолчанию) — ничего не удалять
|
||||
SCAN_CLEANUP=false
|
||||
# Каталог справочников нормализатора OCR (statement_lines.yaml, ocr_lookalikes.yaml, statement_labels.yaml);
|
||||
# относительный путь — от каталога запуска (api/); битый справочник — сервис не стартует
|
||||
SCAN_RULES_DIR=rules
|
||||
|
||||
# --- SMB (только при SCAN_HOTFOLDER_MODE=smb; в режиме local весь блок не нужен) ---
|
||||
# OPENBAO__SMB__SMB_HOST=smb.example.local
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
# Буквы и знаки, которые OCR ставит вместо цифр (§11.1 п. 1). Замена — только вплотную к цифре, в несколько проходов.
|
||||
digits:
|
||||
"О": "0"
|
||||
"о": "0"
|
||||
"O": "0"
|
||||
"o": "0"
|
||||
"З": "3"
|
||||
"з": "3"
|
||||
"l": "1"
|
||||
"I": "1"
|
||||
"|": "1"
|
||||
"і": "1"
|
||||
"б": "6"
|
||||
"S": "5"
|
||||
"s": "5"
|
||||
"Ч": "4"
|
||||
@ -1,6 +0,0 @@
|
||||
# Нормализованные (нижний регистр, не-буквенно-цифровое → пробел) подстроки label таблицы → тип документа (§11 / D-6).
|
||||
labels:
|
||||
"бухгалтерский баланс": BALANCE
|
||||
"отчет о финансовых результатах": INCOME
|
||||
"отчёт о финансовых результатах": INCOME
|
||||
"отчет о прибылях и убытках": INCOME
|
||||
@ -1,76 +0,0 @@
|
||||
# Справочник «код строки → наименование показателя» форм ББ/ОФР по редакциям (§11.1 п. 6, D-7).
|
||||
# Редакция выбирается по дате отчёта документа: valid_until / valid_from — включительно.
|
||||
# 66n — Приказ Минфина № 66н (отчётность до 2024 года включительно), источник — codes_mapping старого ядра DKB_ARMKA
|
||||
# (проверен на стенде ИС Сканирование). fsbu4 — ФСБУ 4/2023 (отчётность за 2025 год и позже): заполняет владелец
|
||||
# по Приложениям 3–4 ФСБУ 4/2023 (черновой перечень кодов — шаблоны fixtures/statements-xlsx/templates/*после 01.01.2025*.xlsx);
|
||||
# пока пуста, документы 2025 года получают имена из OCR и предупреждение.
|
||||
editions:
|
||||
- id: 66n
|
||||
valid_until: 2024-12-31
|
||||
lines:
|
||||
"1105": "Гудвил"
|
||||
"1100": "Итого по разделу I"
|
||||
"1110": "Нематериальные активы"
|
||||
"1120": "Результаты исследований и разработок"
|
||||
"1130": "Нематериальные поисковые активы"
|
||||
"1140": "Материальные поисковые активы"
|
||||
"1150": "Основные средства"
|
||||
"1160": "Инвестиционная недвижимость"
|
||||
"1170": "Финансовые вложения"
|
||||
"1180": "Отложенные налоговые активы"
|
||||
"1190": "Прочие внеоборотные активы"
|
||||
"1200": "Итого по разделу II"
|
||||
"1210": "Запасы"
|
||||
"1215": "Долгосрочные активы к продаже"
|
||||
"1220": "Налог на добавленную стоимость по приобретённым ценностям"
|
||||
"1230": "Дебиторская задолженность"
|
||||
"1240": "Финансовые вложения (за исключением денежных эквивалентов)"
|
||||
"1250": "Денежные средства и денежные эквиваленты"
|
||||
"1260": "Прочие оборотные активы"
|
||||
"1600": "БАЛАНС"
|
||||
"1300": "Итого по разделу III"
|
||||
"1310": "Уставный капитал"
|
||||
"1320": "Собственные акции, выкупленные у акционеров"
|
||||
"1330": "Целевые средства"
|
||||
"1340": "Переоценка внеоборотных активов"
|
||||
"1350": "Добавочный капитал (без переоценки)"
|
||||
"1360": "Резервный капитал"
|
||||
"1370": "Нераспределенная прибыль (непокрытый убыток)"
|
||||
"1400": "Итого по разделу IV"
|
||||
"1410": "Заемные средства"
|
||||
"1420": "Отложенные налоговые обязательства"
|
||||
"1430": "Оценочные обязательства"
|
||||
"1450": "Прочие обязательства"
|
||||
"1500": "Итого по разделу V"
|
||||
"1510": "Заемные средства"
|
||||
"1520": "Кредиторская задолженность"
|
||||
"1530": "Доходы будущих периодов"
|
||||
"1540": "Оценочные обязательства"
|
||||
"1550": "Прочие обязательства"
|
||||
"1700": "БАЛАНС"
|
||||
"2110": "Выручка"
|
||||
"2120": "Себестоимость продаж"
|
||||
"2100": "Валовая прибыль (убыток)"
|
||||
"2210": "Коммерческие расходы"
|
||||
"2220": "Управленческие расходы"
|
||||
"2200": "Прибыль (убыток) от продаж"
|
||||
"2310": "Доходы от участия в других организациях"
|
||||
"2320": "Проценты к получению"
|
||||
"2330": "Проценты к уплате"
|
||||
"2340": "Прочие доходы"
|
||||
"2350": "Прочие расходы"
|
||||
"2300": "Прибыль (убыток) до налогообложения"
|
||||
"2410": "Налог на прибыль"
|
||||
"2411": "в т.ч. текущий налог на прибыль"
|
||||
"2412": "отложенный налог на прибыль"
|
||||
"2460": "Прочее"
|
||||
"2400": "Чистая прибыль (убыток)"
|
||||
"2510": "Результат от переоценки внеоборотных активов, не включаемый в чистую прибыль (убыток) периода"
|
||||
"2520": "Результат от прочих операций, не включаемый в чистую прибыль (убыток) периода"
|
||||
"2530": "Налог на прибыль от операций, результат которых не включается в чистую прибыль (убыток) периода"
|
||||
"2500": "Совокупный финансовый результат периода"
|
||||
"2900": "Базовая прибыль (убыток) на акцию"
|
||||
"2910": "Разводненная прибыль (убыток) на акцию"
|
||||
- id: fsbu4
|
||||
valid_from: 2025-01-01
|
||||
lines: {}
|
||||
@ -154,7 +154,6 @@ class Settings(BaseSettings):
|
||||
# Очистка горячей папки после прочтения набора ответа (D-2): ответ, входящий файл, .done — только при true.
|
||||
# На SMB-шаре контура права на удаление не подтверждены — по умолчанию выключено
|
||||
SCAN_CLEANUP: bool = False
|
||||
SCAN_RULES_DIR: str = "rules" # справочники нормализатора (yaml), путь от текущего каталога (api/)
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
extra="ignore",
|
||||
|
||||
@ -1,96 +0,0 @@
|
||||
"""Суммы из OCR-ячеек (§11.1 п. 7). Перенос `_parse_amount`, `_is_blank` и регулярок старого ядра с одним отличием —
|
||||
порядком вычисления `verified` (решение №13 design.md, см. комментарий в `parse_amount`).
|
||||
|
||||
Что делает разбор: пробелы долой (но пробел между цифрами вне схемы тысяч `1 234`, `12 345 678` — догадка, `verified`
|
||||
сбрасывается: «42831 1» → 428311, не чистое; AURORA-1511/1519);
|
||||
если текст целиком (а при скобках на всю строку — их содержимое) имеет вид
|
||||
`[+-]?[1-9]\\d{0,2}(,\\d{3})+`, запятые — разделители тысяч и удаляются (`4,200` → `4200`, `(4,200)` → `-4200`,
|
||||
`1,234,567` → `1234567`); то же для точек (`4.200`, `837.994`, `(4.200)`, `1.234.567`, а также `1234.567` →
|
||||
`1234567`: ровно три цифры после точки, цифр до неё сколько угодно; scan-gateway#37);
|
||||
это не отбрасывание символов, `verified` не страдает. Правило тысяч — решение владельца 15.10.2026 (AURORA-1405,
|
||||
scan-gateway#35; для точки — 16.10.2026, #37), см. docs/context/05-domain-rules.md §11.1 п. 7. Затем запятая → точка;
|
||||
если есть круглые скобки — берётся содержимое первой пары, и сумма
|
||||
отрицательная; всё, кроме цифр, `+`, `-` и `.`, отбрасывается; если точек больше одной, удаляются ВСЕ точки
|
||||
(`1.234,56` → `123456`, а не `1234.56`: последняя точка тоже теряется, такая сумма всегда `verified = False`).
|
||||
`verified` — разбор не отбрасывал символов (скобки в счёт не идут). Побочный эффект №13: скобки без содержимого
|
||||
(`()`, `( )`, `(.)`) дают `(None, verified = True)`, в источнике — `False`.
|
||||
|
||||
Количество знаков после запятой здесь не проверяется: `0,125` → `0.125`, `12,3456` → `12.3456`;
|
||||
ограничение `Money` (не больше двух знаков) — в `document._value` (решение №30)."""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
__all__ = ["ParsedAmount", "is_blank", "parse_amount"]
|
||||
|
||||
_BLANK_CHARS_RE = re.compile(r"[\s\-–—]")
|
||||
_ALLOWED_RE = re.compile(r"[^\d+\-.]")
|
||||
_BRACKET_RE = re.compile(r"\(([^()]*)\)")
|
||||
# запятая-разделитель тысяч (AURORA-1405, scan-gateway#35): «4,200», «-1,234,567»; только если строка целиком
|
||||
# (или содержимое пары скобок, занимающей всю строку) совпала; «(4,200) руб», «1,234(5,678)» — прежний разбор
|
||||
# пробел между цифрами (AURORA-1511/1519, «42831 1»): допустим только как разделитель тысяч — первая группа 1–3 цифры,
|
||||
# дальше ровно по три; любой другой — догадка, сумма собирается из цифр, но verified = False
|
||||
_DIGIT_SPACE_RUN_RE = re.compile(r"\d+(?:\s+\d+)+")
|
||||
_SPACE_THOUSANDS_RE = re.compile(r"\d{1,3}(?:\s\d{3})+")
|
||||
_THOUSANDS_RE = re.compile(r"[+-]?[1-9]\d{0,2}(?:,\d{3})+")
|
||||
# то же для точки (AURORA-1405, scan-gateway#37): ИС Сканирование иногда возвращает «4.200», «837.994»; смешанные
|
||||
# «1.234,56» / «1,234.56» не подходят ни под одну из двух регулярок (разделитель один и тот же по всей строке)
|
||||
# Для точки — шире, чем для запятой (решение владельца 16.10.2026): ровно три цифры после каждой точки, число цифр до
|
||||
# первой точки любое («1234.567», «12345.678»); первая цифра ненулевая. Запятая остаётся строго `\d{0,2}`.
|
||||
_DOT_THOUSANDS_RE = re.compile(r"[+-]?[1-9]\d*(?:\.\d{3})+")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedAmount:
|
||||
amount: Decimal | None
|
||||
verified: bool
|
||||
|
||||
|
||||
def parse_amount(raw: str) -> ParsedAmount:
|
||||
"""Парсит сумму из распознанного текста OCR-ячейки.
|
||||
|
||||
Убирает пробелы; если строка целиком (или пара скобок на всю строку — её содержимое) вида
|
||||
`[+-]?[1-9]\\d{0,2}(,\\d{3})+` или `[+-]?[1-9]\\d*(\\.\\d{3})+` — удаляет запятые или точки (разделители
|
||||
тысяч: `4,200`, `4.200` → 4200) без потери verified.
|
||||
Затем заменяет запятую на точку, извлекает содержимое круглых скобок
|
||||
(означает отрицательное число), удаляет непотребные символы; если точек больше одной — считает их разделителями
|
||||
тысяч и удаляет все (десятичная часть при этом не сохраняется: `1.234,56` → 123456).
|
||||
|
||||
verified=True только если в исходной строке не пришлось отбрасывать символы
|
||||
(после удаления скобок).
|
||||
"""
|
||||
spaces_ok = all(_SPACE_THOUSANDS_RE.fullmatch(run.group(0)) for run in _DIGIT_SPACE_RUN_RE.finditer(raw.strip()))
|
||||
s = raw.strip().replace(" ", "")
|
||||
inner = _BRACKET_RE.search(s)
|
||||
core = inner.group(1) if inner and inner.group(0) == s else s
|
||||
if _THOUSANDS_RE.fullmatch(core):
|
||||
s = s.replace(",", "")
|
||||
elif _DOT_THOUSANDS_RE.fullmatch(core):
|
||||
s = s.replace(".", "")
|
||||
s = s.replace(",", ".")
|
||||
stripped_raw = s
|
||||
if not stripped_raw:
|
||||
return ParsedAmount(None, stripped_raw == raw)
|
||||
match = _BRACKET_RE.search(s)
|
||||
bracketed = match is not None
|
||||
if match is not None:
|
||||
s = match.group(1)
|
||||
s = _ALLOWED_RE.sub("", s)
|
||||
if s.count(".") > 1:
|
||||
*head, tail = s.split(".")
|
||||
s = "".join(head) + tail
|
||||
# verified считается ДО постановки знака (отличие от источника, решение №13 design.md): в источнике сравнение шло
|
||||
# после `s = "-" + s`, и любая сумма в скобках получала verified = False, то есть is_recognized = false
|
||||
verified = s == stripped_raw.replace(")", "").replace("(", "")
|
||||
if bracketed:
|
||||
s = "-" + s.lstrip("+-")
|
||||
try:
|
||||
return ParsedAmount(Decimal(s), verified and spaces_ok)
|
||||
except (InvalidOperation, ValueError):
|
||||
return ParsedAmount(None, verified and spaces_ok)
|
||||
|
||||
|
||||
def is_blank(text: str) -> bool:
|
||||
"""Прочерк в отчётности означает ноль — считаем пустым значением (NULL, не 0)."""
|
||||
return not _BLANK_CHARS_RE.sub("", text)
|
||||
@ -1,207 +0,0 @@
|
||||
"""Разбор дат из 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", " "))
|
||||
@ -1,63 +0,0 @@
|
||||
"""Шапка документа из полей `data_ocr` (§11.1 п. 8): ИНН — только цифры, единицы — очищенный текст, дата отчёта —
|
||||
`parse_date` после замены двойников; «распознано» — `confidence == 100` и сырой текст без мусора."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
from src.normalizer.ocr.dates import is_date_sized, parse_date
|
||||
from src.normalizer.ocr.text import clamp_confidence, clean_value, collapse_whitespace, digits_only, fix_lookalikes
|
||||
|
||||
__all__ = ["FileHeader", "parse_file_header"]
|
||||
|
||||
FULL_CONFIDENCE = 100.0
|
||||
CAPPED_CONFIDENCE = 99.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileHeader:
|
||||
organization_name: str | None
|
||||
organization_confidence: float | None
|
||||
inn: str | None
|
||||
inn_confidence: float | None
|
||||
units: str | None
|
||||
report_date_text: str | None
|
||||
report_date: date | None
|
||||
report_date_recognized: bool
|
||||
|
||||
|
||||
def _field(data_ocr: Mapping[str, object], name: str) -> tuple[str, float | None]:
|
||||
raw = data_ocr.get(name)
|
||||
if not isinstance(raw, Mapping):
|
||||
return "", None
|
||||
text = raw.get("form_name")
|
||||
text_str = str(text) if isinstance(text, str) else ""
|
||||
return (text_str, clamp_confidence(raw.get("confidence")))
|
||||
|
||||
|
||||
def parse_file_header(data_ocr: Mapping[str, object], lookalikes: Mapping[str, str]) -> FileHeader:
|
||||
organization, organization_confidence = _field(data_ocr, "organization")
|
||||
inn_raw, inn_confidence = _field(data_ocr, "inn")
|
||||
units_raw, _ = _field(data_ocr, "units")
|
||||
date_raw, date_confidence = _field(data_ocr, "date")
|
||||
|
||||
organization_name = collapse_whitespace(organization) or None
|
||||
inn = digits_only(inn_raw) or None
|
||||
if inn is None:
|
||||
inn_confidence = None
|
||||
elif inn_confidence is not None and inn_raw.strip() != inn:
|
||||
inn_confidence = min(inn_confidence, CAPPED_CONFIDENCE)
|
||||
# решение №31: длинный текст не разбирается и не проходит `fix_lookalikes`
|
||||
report_date = (
|
||||
parse_date(fix_lookalikes(date_raw, lookalikes)) if date_raw.strip() and is_date_sized(date_raw) else None
|
||||
)
|
||||
return FileHeader(
|
||||
organization_name=organization_name,
|
||||
organization_confidence=organization_confidence if organization_name is not None else None,
|
||||
inn=inn,
|
||||
inn_confidence=inn_confidence,
|
||||
units=clean_value(units_raw) or None,
|
||||
report_date_text=date_raw.strip() or None,
|
||||
report_date=report_date,
|
||||
report_date_recognized=date_confidence == FULL_CONFIDENCE and report_date is not None,
|
||||
)
|
||||
@ -1,100 +0,0 @@
|
||||
"""Раскладка колонок таблицы OCR (§11.1 п. 3). Перенос `_build_layout`, `_infer_layout`, `_cells_by_index`,
|
||||
`_cell_value` старого ядра: справочник кодов приходит параметром, ячейки — `Cell` вместо словарей."""
|
||||
|
||||
from collections.abc import Collection, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.normalizer.ocr.amounts import is_blank, parse_amount
|
||||
from src.normalizer.ocr.text import clamp_confidence, is_code_header, is_value_header, normalize_header
|
||||
|
||||
__all__ = ["Cell", "Cells", "ColumnLayout", "build_layout", "cell_text", "cells_by_index", "infer_layout"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Cell:
|
||||
value: str
|
||||
confidence: float
|
||||
|
||||
|
||||
Cells = dict[int, Cell]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ColumnLayout:
|
||||
"""Индексы значимых столбцов таблицы OCR: из строки заголовка или выведенные по строкам данных."""
|
||||
|
||||
label_idx: int | None
|
||||
code_idx: int
|
||||
value_idxs: tuple[int, ...]
|
||||
|
||||
|
||||
def cells_by_index(row: Mapping[str, object]) -> Cells:
|
||||
"""Нормализует набор ячеек из распознанной строки в словарь по индексам колонок.
|
||||
|
||||
value → str(value or ""), confidence → число из [0, 100] (`clamp_confidence`; не число → 0);
|
||||
ячейка без column_index пропускается.
|
||||
"""
|
||||
cells: Cells = {}
|
||||
raw_cells = row.get("cells")
|
||||
if not isinstance(raw_cells, list):
|
||||
return cells
|
||||
for raw in raw_cells:
|
||||
if not isinstance(raw, Mapping) or not isinstance(raw.get("column_index"), int):
|
||||
continue
|
||||
confidence = clamp_confidence(raw.get("confidence"))
|
||||
cells[int(raw["column_index"])] = Cell(
|
||||
str(raw.get("value") or ""), confidence if confidence is not None else 0.0
|
||||
)
|
||||
return cells
|
||||
|
||||
|
||||
def cell_text(cells: Cells, idx: int | None) -> str:
|
||||
"""Возвращает текст ячейки по индексу; отсутствующей или None индекс — пустая строка."""
|
||||
if idx is None or idx not in cells:
|
||||
return ""
|
||||
return cells[idx].value
|
||||
|
||||
|
||||
def build_layout(cells: Cells) -> ColumnLayout | None:
|
||||
"""Раскладка, если строка является заголовком (есть ячейка «Код…»): наименование — ячейка с «наименование»,
|
||||
иначе последняя заполненная левее кода; значения — правее кода, где год или «на »/«за »."""
|
||||
headers = {idx: normalize_header(cells[idx].value) for idx in sorted(cells)}
|
||||
code_idx = next((idx for idx, text in headers.items() if is_code_header(text)), None)
|
||||
if code_idx is None:
|
||||
return None
|
||||
label_idx = next((idx for idx, text in headers.items() if "наименование" in text), None)
|
||||
if label_idx is None:
|
||||
left_filled = [idx for idx, text in headers.items() if idx < code_idx and text]
|
||||
label_idx = left_filled[-1] if left_filled else None
|
||||
value_idxs = tuple(idx for idx, text in headers.items() if idx > code_idx and is_value_header(text))
|
||||
return ColumnLayout(label_idx=label_idx, code_idx=code_idx, value_idxs=value_idxs)
|
||||
|
||||
|
||||
def infer_layout(rows_cells: Sequence[Cells], known_codes: Collection[str]) -> ColumnLayout | None:
|
||||
"""Раскладка по строкам данных, когда заголовка нет: код — столбец с наибольшим числом
|
||||
известных кодов; наименование — левее кода с наибольшим числом текстовых ячеек; значения —
|
||||
правее кода, где суммы не реже мусора или столбец пуст. Столбец оценивается по всем
|
||||
строкам сразу, поэтому пустые ячейки отдельных строк не влияют."""
|
||||
columns: dict[int, list[str]] = {}
|
||||
for cells in rows_cells:
|
||||
for idx, cell in cells.items():
|
||||
columns.setdefault(idx, []).append(cell.value.strip())
|
||||
code_hits = {idx: sum(1 for v in values if v in known_codes) for idx, values in columns.items()}
|
||||
code_idx = min(code_hits, key=lambda idx: (-code_hits[idx], idx), default=None)
|
||||
if code_idx is None or code_hits[code_idx] == 0:
|
||||
return None
|
||||
text_hits = {
|
||||
idx: sum(1 for v in values if any(c.isalpha() for c in v)) for idx, values in columns.items() if idx < code_idx
|
||||
}
|
||||
label_idx = min(text_hits, key=lambda idx: (-text_hits[idx], -idx), default=None)
|
||||
if label_idx is not None and text_hits[label_idx] == 0:
|
||||
label_idx = None
|
||||
value_idxs = []
|
||||
for idx in sorted(columns):
|
||||
if idx <= code_idx:
|
||||
continue
|
||||
filled = [v for v in columns[idx] if not is_blank(v)]
|
||||
amounts = sum(1 for v in filled if parse_amount(v).amount is not None)
|
||||
if amounts >= len(filled) - amounts:
|
||||
value_idxs.append(idx)
|
||||
return ColumnLayout(label_idx=label_idx, code_idx=code_idx, value_idxs=tuple(value_idxs))
|
||||
@ -1,252 +0,0 @@
|
||||
"""Строки таблицы OCR (§11.1 п. 3–5). Перенос `_classify_row`, `_parse_table_rows`, `_synthetic_header_cells`,
|
||||
`_resolve_table_type` старого ядра: шапка — строка с «Код» (повторная перезаписывает раскладку), без шапки раскладка
|
||||
выводится по данным и шапка синтезируется; строки до первой шапки — по выведенной раскладке. Расхождение с источником —
|
||||
склеенные строки ИС (`1500\n1700`) разлепляются до классификации (OQ-156, решение владельца 08.10.2026)."""
|
||||
|
||||
import re
|
||||
from collections.abc import Collection, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
from src.normalizer.ocr.amounts import parse_amount
|
||||
from src.normalizer.ocr.layout import Cell, Cells, ColumnLayout, build_layout, cell_text, cells_by_index, infer_layout
|
||||
|
||||
__all__ = [
|
||||
"ParsedRow",
|
||||
"ParsedTable",
|
||||
"RowKind",
|
||||
"classify_row",
|
||||
"is_numbering_row",
|
||||
"parse_table_rows",
|
||||
"split_merged_row",
|
||||
"table_type_by_codes",
|
||||
"table_type_by_unanimous_codes",
|
||||
]
|
||||
|
||||
HEADER_CODE = "Код"
|
||||
HEADER_LABEL = "Наименование показателя"
|
||||
FULL_CONFIDENCE = 100.0
|
||||
|
||||
|
||||
class RowKind(StrEnum):
|
||||
HEADER = "HEADER"
|
||||
ROW = "ROW"
|
||||
TOTAL = "TOTAL"
|
||||
BALANCE = "BALANCE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedRow:
|
||||
cells: Cells
|
||||
layout: ColumnLayout
|
||||
kind: RowKind
|
||||
ordinal: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedTable:
|
||||
"""Разобранные строки: `rows[0]` — шапка (настоящая или синтетическая), дальше строки данных по порядку."""
|
||||
|
||||
rows: list[ParsedRow]
|
||||
synthetic_header: bool
|
||||
# решение №29: строки, молча отбрасываемые разбором (сам разбор — дословный перенос, он их не меняет);
|
||||
# шапки (в том числе повторные) в счёт не идут
|
||||
skipped_non_numeric_code: int = 0 # код непустой и не целое число (`classify_row` → HEADER)
|
||||
skipped_no_code_with_amounts: int = 0 # код пуст, но в колонках значений раскладки строки есть разобранная сумма
|
||||
split_merged_codes: int = 0 # OQ-156: склеенных строк ИС («1500\n1700»), разлеплённых на несколько
|
||||
|
||||
|
||||
def classify_row(cells: Cells, layout: ColumnLayout) -> RowKind | None:
|
||||
code = cell_text(cells, layout.code_idx).strip()
|
||||
if not code:
|
||||
return None
|
||||
try:
|
||||
int(code)
|
||||
except (TypeError, ValueError):
|
||||
return RowKind.HEADER
|
||||
label = cell_text(cells, layout.label_idx)
|
||||
if label == "Баланс":
|
||||
return RowKind.BALANCE
|
||||
if label.lower().startswith("итого"):
|
||||
return RowKind.TOTAL
|
||||
return RowKind.ROW
|
||||
|
||||
|
||||
_NUMBERING_CELL_RE = re.compile(r"\d{1,2}")
|
||||
_MIN_NUMBERING_CELLS = 3
|
||||
|
||||
|
||||
def is_numbering_row(cells: Cells) -> bool:
|
||||
"""AURORA-1517: строка нумерации колонок «1 2 3 4 5» под шапкой — часть заголовка. Все непустые ячейки — короткие
|
||||
(1–2 цифры) целые строго по возрастанию, ячеек не меньше трёх (как `is_numbering_row` в tabular-parser)."""
|
||||
texts = [cell.value.strip() for _, cell in sorted(cells.items()) if cell.value.strip()]
|
||||
if len(texts) < _MIN_NUMBERING_CELLS or not all(_NUMBERING_CELL_RE.fullmatch(t) for t in texts):
|
||||
return False
|
||||
numbers = [int(t) for t in texts]
|
||||
return all(a < b for a, b in zip(numbers, numbers[1:], strict=False))
|
||||
|
||||
|
||||
def _data_row(cells: Cells, layout: ColumnLayout, ordinal: int) -> ParsedRow | None:
|
||||
kind = classify_row(cells, layout)
|
||||
if kind is None or kind is RowKind.HEADER:
|
||||
return None
|
||||
return ParsedRow(cells, layout, kind, ordinal)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Skipped:
|
||||
non_numeric_code: int = 0
|
||||
no_code_with_amounts: int = 0
|
||||
split_merged_codes: int = 0
|
||||
|
||||
|
||||
def _parts(text: str) -> list[str]:
|
||||
return [part.strip() for part in text.split("\n") if part.strip()]
|
||||
|
||||
|
||||
def split_merged_row(cells: Cells, layout: ColumnLayout) -> list[Cells] | None:
|
||||
"""OQ-156 (§11.1 п. 4): ИС склеила N соседних строк формы в одну — в ячейке кода N ≥ 2 целых кодов через перевод
|
||||
строки, в каждой непустой ячейке значений ровно N частей. Возвращает N строк (i-й код ↔ i-я часть значений;
|
||||
наименование — i-я часть, если частей тоже N, иначе целиком; уверенность — исходной ячейки) или `None`, если строка
|
||||
не склеенная или число частей где-то не сходится (тогда она разбирается как раньше)."""
|
||||
codes = _parts(cell_text(cells, layout.code_idx))
|
||||
if len(codes) < 2 or not all(code.isdigit() for code in codes): # noqa: PLR2004 — склейка хотя бы двух строк
|
||||
return None
|
||||
values: dict[int, list[str]] = {}
|
||||
for idx in layout.value_idxs:
|
||||
parts = _parts(cell_text(cells, idx))
|
||||
if parts and len(parts) != len(codes):
|
||||
return None
|
||||
values[idx] = parts
|
||||
labels = _parts(cell_text(cells, layout.label_idx))
|
||||
split: list[Cells] = []
|
||||
for i, code in enumerate(codes):
|
||||
row = dict(cells)
|
||||
row[layout.code_idx] = Cell(code, cells[layout.code_idx].confidence)
|
||||
if layout.label_idx is not None and layout.label_idx in cells and len(labels) == len(codes):
|
||||
row[layout.label_idx] = Cell(labels[i], cells[layout.label_idx].confidence)
|
||||
for idx, parts in values.items():
|
||||
if parts:
|
||||
row[idx] = Cell(parts[i], cells[idx].confidence)
|
||||
split.append(row)
|
||||
return split
|
||||
|
||||
|
||||
def _data_rows(cells: Cells, layout: ColumnLayout, ordinal: int, skipped: _Skipped) -> list[ParsedRow]:
|
||||
"""Строки данных из одной строки OCR: склеенная разлепляется (OQ-156), отброшенная — учитывается в `skipped`."""
|
||||
if is_numbering_row(cells): # часть заголовка: в данные не попадает и в счётчики отброшенного не идёт
|
||||
return []
|
||||
split = split_merged_row(cells, layout)
|
||||
if split is not None:
|
||||
skipped.split_merged_codes += 1
|
||||
rows: list[ParsedRow] = []
|
||||
for part in split or [cells]:
|
||||
parsed = _data_row(part, layout, ordinal)
|
||||
if parsed is not None:
|
||||
rows.append(parsed)
|
||||
else:
|
||||
_count_skipped(part, layout, skipped)
|
||||
return rows
|
||||
|
||||
|
||||
def _count_skipped(cells: Cells, layout: ColumnLayout, skipped: _Skipped) -> None:
|
||||
"""Строка отброшена `_data_row`: считаем её, если потеряна возможная строка данных (с кодом-мусором или с суммами);
|
||||
пустой код без сумм — заголовок группы, «в том числе:» — не считается."""
|
||||
kind = classify_row(cells, layout)
|
||||
if kind is RowKind.HEADER:
|
||||
skipped.non_numeric_code += 1
|
||||
elif kind is None and any(parse_amount(cell_text(cells, idx)).amount is not None for idx in layout.value_idxs):
|
||||
skipped.no_code_with_amounts += 1
|
||||
|
||||
|
||||
def _synthetic_header_cells(layout: ColumnLayout) -> Cells:
|
||||
"""Ячейки восстановленного заголовка: код и наименование, тексты периодов пустые (ячеек под значениями нет)."""
|
||||
cells: Cells = {layout.code_idx: Cell(HEADER_CODE, FULL_CONFIDENCE)}
|
||||
if layout.label_idx is not None:
|
||||
cells[layout.label_idx] = Cell(HEADER_LABEL, FULL_CONFIDENCE)
|
||||
return cells
|
||||
|
||||
|
||||
def _ordinal(row: Mapping[str, object], fallback: int) -> int:
|
||||
index = row.get("row_index")
|
||||
return index if isinstance(index, int) else fallback
|
||||
|
||||
|
||||
def parse_table_rows(table_rows: Sequence[Mapping[str, object]], known_codes: Collection[str]) -> ParsedTable | None:
|
||||
rows_cells = [(_ordinal(row, position), cells_by_index(row)) for position, row in enumerate(table_rows)]
|
||||
first_header = next(
|
||||
(pos for pos, (_, cells) in enumerate(rows_cells) if build_layout(cells) is not None), len(rows_cells)
|
||||
)
|
||||
before_header = rows_cells[:first_header]
|
||||
inferred = infer_layout([cells for _, cells in before_header], known_codes) if before_header else None
|
||||
leading_rows: list[ParsedRow] = []
|
||||
skipped = _Skipped()
|
||||
if inferred is not None:
|
||||
for ordinal, cells in before_header:
|
||||
leading_rows.extend(_data_rows(cells, inferred, ordinal, skipped))
|
||||
|
||||
if first_header == len(rows_cells):
|
||||
if not leading_rows or inferred is None:
|
||||
return None
|
||||
header_ordinal = min(p.ordinal for p in leading_rows) - 1
|
||||
header = ParsedRow(_synthetic_header_cells(inferred), inferred, RowKind.HEADER, header_ordinal)
|
||||
return ParsedTable(
|
||||
[header, *leading_rows],
|
||||
synthetic_header=True,
|
||||
skipped_non_numeric_code=skipped.non_numeric_code,
|
||||
skipped_no_code_with_amounts=skipped.no_code_with_amounts,
|
||||
split_merged_codes=skipped.split_merged_codes,
|
||||
)
|
||||
|
||||
header_row_index, header_cells = rows_cells[first_header]
|
||||
layout = build_layout(header_cells)
|
||||
if layout is None: # исключено выбором first_header
|
||||
return None
|
||||
header_ordinal = min([header_row_index, *(p.ordinal - 1 for p in leading_rows)])
|
||||
parsed_rows = [ParsedRow(header_cells, layout, RowKind.HEADER, header_ordinal), *leading_rows]
|
||||
for ordinal, cells in rows_cells[first_header + 1 :]:
|
||||
header_layout = build_layout(cells)
|
||||
if header_layout is not None:
|
||||
layout = header_layout
|
||||
continue
|
||||
parsed_rows.extend(_data_rows(cells, layout, ordinal, skipped))
|
||||
return ParsedTable(
|
||||
parsed_rows,
|
||||
synthetic_header=False,
|
||||
skipped_non_numeric_code=skipped.non_numeric_code,
|
||||
skipped_no_code_with_amounts=skipped.no_code_with_amounts,
|
||||
split_merged_codes=skipped.split_merged_codes,
|
||||
)
|
||||
|
||||
|
||||
def _code_group_counts(parsed: ParsedTable) -> dict[str, int]:
|
||||
"""Число кодов строк данных по первой цифре (`1xxx` — ББ, `2xxx` — ОФР); ключи «1» и «2» есть всегда."""
|
||||
counts = {"1": 0, "2": 0}
|
||||
for row in parsed.rows:
|
||||
if row.kind is RowKind.HEADER:
|
||||
continue
|
||||
code = cell_text(row.cells, row.layout.code_idx).strip()
|
||||
if code:
|
||||
counts[code[0]] = counts.get(code[0], 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def table_type_by_codes(parsed: ParsedTable) -> Literal["BALANCE", "INCOME"] | None:
|
||||
"""Тип таблицы по первой цифре кодов строк данных: больше `1xxx` → BALANCE, больше `2xxx` → INCOME."""
|
||||
counts = _code_group_counts(parsed)
|
||||
if counts["1"] > counts["2"]:
|
||||
return "BALANCE"
|
||||
if counts["2"] > counts["1"]:
|
||||
return "INCOME"
|
||||
return None
|
||||
|
||||
|
||||
def table_type_by_unanimous_codes(parsed: ParsedTable) -> Literal["BALANCE", "INCOME"] | None:
|
||||
"""Тип по единодушным кодам (#30): все коды данных `1xxx` → BALANCE, все `2xxx` → INCOME; смесь или пусто → None."""
|
||||
counts = _code_group_counts(parsed)
|
||||
if counts["1"] and not counts["2"]:
|
||||
return "BALANCE"
|
||||
if counts["2"] and not counts["1"]:
|
||||
return "INCOME"
|
||||
return None
|
||||
@ -1,86 +0,0 @@
|
||||
"""Очистка OCR-текста (§11.1 п. 1, 6). Перенос из `date_utils._normalize`, `utils._clear_value`,
|
||||
`_keep_digits_only`, `_remove_spec_space`, `_normalize_header`, `_is_code_header`, `_is_value_header`
|
||||
старого ядра; алгоритмы без изменений."""
|
||||
|
||||
import math
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
|
||||
__all__ = [
|
||||
"clamp_confidence",
|
||||
"clean_value",
|
||||
"collapse_whitespace",
|
||||
"digits_only",
|
||||
"fix_lookalikes",
|
||||
"is_code_header",
|
||||
"is_value_header",
|
||||
"normalize_header",
|
||||
]
|
||||
|
||||
_YEAR_RE = re.compile(r"\d{4}")
|
||||
# первая строка значения из цифр, пробелов, `|`, точек и т. п. — `|` в ней разделяет клетки формы, а не единица
|
||||
_CELL_SEPARATOR_LINE = re.compile(r"[\s\d|•·.:,]*")
|
||||
|
||||
|
||||
def fix_lookalikes(text: str, lookalikes: Mapping[str, str]) -> str:
|
||||
"""Буквы-двойники → цифры только вплотную к цифре, в несколько проходов («2ОО5» → «2005»); `|` — разделитель
|
||||
клеток, если первая строка состоит из цифр, пробелов и пунктуации («2|0|2|4|» → «2 0 2 4 »)."""
|
||||
text = text.replace("\xa0", " ")
|
||||
if _CELL_SEPARATOR_LINE.fullmatch(text.split("\n", 1)[0]):
|
||||
text = text.replace("|", " ")
|
||||
if not lookalikes:
|
||||
return text
|
||||
chars = re.escape("".join(lookalikes))
|
||||
near_digit = re.compile(rf"(?<=\d)[{chars}]|[{chars}](?=\d)")
|
||||
table = str.maketrans(dict(lookalikes))
|
||||
previous = None
|
||||
while previous != text: # «2ОО5» требует двух проходов
|
||||
previous = text
|
||||
text = near_digit.sub(lambda match: match.group(0).translate(table), text)
|
||||
return text
|
||||
|
||||
|
||||
def clamp_confidence(raw: object) -> float | None:
|
||||
"""Уверенность OCR как число из [0, 100]: не число → `None`; NaN, бесконечность и целое вне диапазона float → 0;
|
||||
остальное зажимается в [0, 100] (решение №30: `json.loads` принимает `NaN`/`Infinity`, контракт требует 0..100)."""
|
||||
if not isinstance(raw, int | float):
|
||||
return None
|
||||
try:
|
||||
value = float(raw)
|
||||
except OverflowError: # целое больше диапазона float
|
||||
return 0.0
|
||||
if not math.isfinite(value):
|
||||
return 0.0
|
||||
return min(max(value, 0.0), 100.0)
|
||||
|
||||
|
||||
def clean_value(text: str) -> str:
|
||||
"""Остаются буквы, цифры, пробел и точка; переносы строк и табуляции — пробел; края обрезаются."""
|
||||
cleaned = "".join(
|
||||
char for char in text.replace("\n", " ").replace("\t", " ") if char.isdigit() or char.isalpha() or char in " ."
|
||||
)
|
||||
return cleaned.strip()
|
||||
|
||||
|
||||
def digits_only(text: str) -> str:
|
||||
return "".join(char for char in text if char.isdigit())
|
||||
|
||||
|
||||
def collapse_whitespace(text: str) -> str:
|
||||
return text.replace("\n", " ").replace("\t", " ").strip()
|
||||
|
||||
|
||||
def normalize_header(text: object) -> str:
|
||||
"""Нижний регистр, всё не-буквенно-цифровое → пробел, пробелы схлопнуты (для сравнения заголовков и label)."""
|
||||
lowered = str(text or "").lower()
|
||||
return " ".join("".join(char if char.isalnum() else " " for char in lowered).split())
|
||||
|
||||
|
||||
def is_code_header(text: str) -> bool:
|
||||
"""Ячейка шапки «Код…» (текст уже нормализован `normalize_header`)."""
|
||||
return text.startswith("код")
|
||||
|
||||
|
||||
def is_value_header(text: str) -> bool:
|
||||
"""Ячейка шапки колонки значений: есть четыре цифры года или текст начинается с «на » / «за »."""
|
||||
return bool(_YEAR_RE.search(text)) or text.startswith(("на ", "за "))
|
||||
@ -1,160 +0,0 @@
|
||||
"""Справочники нормализатора OCR (D-7, §11.1 п. 1, 6): yaml в `SCAN_RULES_DIR`, загрузка один раз при старте.
|
||||
|
||||
`statement_lines.yaml` — редакции форм (`66n` до 2024-12-31, `fsbu4` с 2025-01-01) с таблицей код → наименование;
|
||||
`ocr_lookalikes.yaml` — двойники букв и цифр; `statement_labels.yaml` — подстроки `label` таблицы → тип документа.
|
||||
Битый файл — `ValueError` с именем файла (сервис не стартует); повторяющиеся ключи yaml (код строки дважды) — тоже
|
||||
ошибка (№33).
|
||||
"""
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
|
||||
|
||||
__all__ = ["Edition", "OcrRules", "load_rules"]
|
||||
|
||||
LINES_FILE = "statement_lines.yaml"
|
||||
LOOKALIKES_FILE = "ocr_lookalikes.yaml"
|
||||
LABELS_FILE = "statement_labels.yaml"
|
||||
DocumentKind = Literal["BALANCE", "INCOME"]
|
||||
|
||||
|
||||
class Edition(BaseModel):
|
||||
"""Редакция форм: интервал дат отчёта (включительно, открытый край — `None`) и таблица код → наименование."""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
valid_from: date | None = None
|
||||
valid_until: date | None = None
|
||||
lines: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("lines")
|
||||
@classmethod
|
||||
def _check_lines(cls, lines: dict[str, str]) -> dict[str, str]:
|
||||
for code, name in lines.items():
|
||||
if not (code.isdigit() and 4 <= len(code) <= 5): # noqa: PLR2004 — коды строк форм: 4–5 цифр
|
||||
raise ValueError(f"код строки {code!r}: ожидаются 4–5 цифр")
|
||||
if not name.strip():
|
||||
raise ValueError(f"код строки {code!r}: пустое наименование")
|
||||
return lines
|
||||
|
||||
def covers(self, report_date: date) -> bool:
|
||||
return (self.valid_from is None or self.valid_from <= report_date) and (
|
||||
self.valid_until is None or report_date <= self.valid_until
|
||||
)
|
||||
|
||||
|
||||
class _LinesFile(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
editions: list[Edition] = Field(min_length=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _unique_ids(self) -> "_LinesFile":
|
||||
ids = [edition.id for edition in self.editions]
|
||||
if len(ids) != len(set(ids)):
|
||||
raise ValueError(f"id редакций повторяются: {ids}")
|
||||
return self
|
||||
|
||||
|
||||
class _LookalikesFile(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
digits: dict[str, str]
|
||||
|
||||
@field_validator("digits")
|
||||
@classmethod
|
||||
def _single_chars(cls, digits: dict[str, str]) -> dict[str, str]:
|
||||
for char, digit in digits.items():
|
||||
if len(char) != 1 or len(digit) != 1 or not digit.isdigit():
|
||||
raise ValueError(f"двойник {char!r} -> {digit!r}: ожидается один символ и одна цифра")
|
||||
return digits
|
||||
|
||||
|
||||
class _LabelsFile(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
labels: dict[str, DocumentKind]
|
||||
|
||||
|
||||
class OcrRules(BaseModel):
|
||||
"""Все справочники нормализатора; передаётся в конвейер параметром."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
editions: list[Edition]
|
||||
lookalikes: dict[str, str]
|
||||
labels: dict[str, DocumentKind]
|
||||
|
||||
def edition_for(self, report_date: date | None) -> Edition | None:
|
||||
"""Редакция по дате отчёта (первая, чей интервал покрывает дату); `None` без даты или вне интервалов."""
|
||||
if report_date is None:
|
||||
return None
|
||||
return next((edition for edition in self.editions if edition.covers(report_date)), None)
|
||||
|
||||
def edition_by_codes(self, codes: Iterable[str]) -> Edition | None:
|
||||
"""Редакция с наибольшим числом известных кодов; при равенстве — первая; `None`, если совпадений нет."""
|
||||
wanted = set(codes)
|
||||
best: Edition | None = None
|
||||
best_hits = 0
|
||||
for edition in self.editions:
|
||||
hits = len(wanted & edition.lines.keys())
|
||||
if hits > best_hits:
|
||||
best, best_hits = edition, hits
|
||||
return best
|
||||
|
||||
def known_codes(self) -> frozenset[str]:
|
||||
return frozenset(code for edition in self.editions for code in edition.lines)
|
||||
|
||||
def editions_with(self, code: str) -> list[str]:
|
||||
return [edition.id for edition in self.editions if code in edition.lines]
|
||||
|
||||
|
||||
class _UniqueKeyLoader(yaml.SafeLoader):
|
||||
"""`SafeLoader`, отклоняющий повторяющиеся ключи отображений (№33): `safe_load` молча оставляет последнее значение,
|
||||
и повторный код в справочнике терялся бы без следа."""
|
||||
|
||||
def construct_mapping(self, node: yaml.MappingNode, deep: bool = False) -> dict[object, object]:
|
||||
seen: set[object] = set()
|
||||
for key_node, _ in node.value:
|
||||
key = self.construct_object(key_node, deep=True)
|
||||
try:
|
||||
duplicate = key in seen
|
||||
seen.add(key)
|
||||
except TypeError: # нехешируемый ключ — его отклонит базовый `construct_mapping`
|
||||
continue
|
||||
if duplicate:
|
||||
raise yaml.constructor.ConstructorError(
|
||||
"при разборе отображения", node.start_mark, f"повторяющийся ключ {key!r}", key_node.start_mark
|
||||
)
|
||||
return super().construct_mapping(node, deep=deep)
|
||||
|
||||
|
||||
def _read_yaml(directory: Path, name: str) -> object:
|
||||
path = directory / name
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise ValueError(f"справочник {name} не прочитан ({path}): {exc}") from exc
|
||||
try:
|
||||
return yaml.load(text, Loader=_UniqueKeyLoader) # SafeLoader с проверкой дублей
|
||||
except yaml.YAMLError as exc:
|
||||
raise ValueError(f"справочник {name} не разобран как YAML: {exc}") from exc
|
||||
|
||||
|
||||
def load_rules(directory: Path | str) -> OcrRules:
|
||||
"""Загрузить три справочника из каталога; `ValueError` с именем файла при любой ошибке."""
|
||||
base = Path(directory)
|
||||
loaded: dict[str, BaseModel] = {}
|
||||
for name, model in ((LINES_FILE, _LinesFile), (LOOKALIKES_FILE, _LookalikesFile), (LABELS_FILE, _LabelsFile)):
|
||||
try:
|
||||
loaded[name] = model.model_validate(_read_yaml(base, name))
|
||||
except ValidationError as exc:
|
||||
raise ValueError(f"справочник {name}: {exc}") from exc
|
||||
lines = loaded[LINES_FILE]
|
||||
lookalikes = loaded[LOOKALIKES_FILE]
|
||||
labels = loaded[LABELS_FILE]
|
||||
assert isinstance(lines, _LinesFile) and isinstance(lookalikes, _LookalikesFile) # noqa: S101 — сужение типов
|
||||
assert isinstance(labels, _LabelsFile) # noqa: S101
|
||||
return OcrRules(editions=lines.editions, lookalikes=lookalikes.digits, labels=labels.labels)
|
||||
@ -1,119 +0,0 @@
|
||||
"""Суммы из OCR-ячеек (§11.1 п. 7): перенос `_parse_amount`/`_is_blank` старого ядра."""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from src.normalizer.ocr.amounts import ParsedAmount, is_blank, parse_amount
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "amount", "verified"),
|
||||
[
|
||||
("13 070", Decimal("13070"), True),
|
||||
("(6032409)", Decimal("-6032409"), True),
|
||||
("(7744536}", Decimal("7744536"), False), # закрывающая скобка не распознана: минус не ставится (решение №12)
|
||||
("1 234,56", Decimal("1234.56"), True),
|
||||
("1.234.567", Decimal("1234567"), True), # точки строго через три цифры — разделители тысяч (AURORA-1405, #37)
|
||||
("30127", Decimal("30127"), True),
|
||||
("211(1 |", Decimal("2111"), False),
|
||||
("не ****", None, False),
|
||||
("", None, True),
|
||||
("--", None, True), # прочерк: ничего не отброшено, суммы нет (NULL)
|
||||
(":", None, False),
|
||||
# несколько точек: удаляются ВСЕ точки, десятичная часть теряется (`1.234,56` — не 1234.56), всегда не verified
|
||||
("1.234,56", Decimal("123456"), False),
|
||||
("1,234.56", Decimal("123456"), False),
|
||||
# запятая-разделитель тысяч (AURORA-1405, scan-gateway#35): удаляется до замены запятой на точку, verified=True
|
||||
("1,234", Decimal("1234"), True),
|
||||
("4,200", Decimal("4200"), True),
|
||||
("(4,200)", Decimal("-4200"), True),
|
||||
("1,234,567", Decimal("1234567"), True),
|
||||
("-4,200", Decimal("-4200"), True),
|
||||
("+4,200", Decimal("4200"), True),
|
||||
("(-4,200)", Decimal("-4200"), True),
|
||||
("4,2", Decimal("4.2"), True),
|
||||
("12,34", Decimal("12.34"), True),
|
||||
("1,23", Decimal("1.23"), True),
|
||||
# не тысячи: больше двух знаков после запятой остаётся десятичным — `Money` отсекает `document._value` (№30)
|
||||
("0,125", Decimal("0.125"), True),
|
||||
("12,3456", Decimal("12.3456"), True),
|
||||
("4,200,", Decimal("4200"), False), # хвостовая запятая: правило не применяется (две точки удалены, как раньше)
|
||||
("(4,200}", Decimal("4.200"), False), # нет пары скобок: правило не применяется, минус не ставится
|
||||
# скобки не на всю строку — правило тысяч не применяется, прежний разбор (запятая → точка)
|
||||
("(4,200) руб", Decimal("-4.200"), False),
|
||||
("abc(4,200)", Decimal("-4.200"), False),
|
||||
("(4,200)x", Decimal("-4.200"), False),
|
||||
("(4,200)(1,000)", Decimal("-4.200"), False),
|
||||
("1,234(5,678)", Decimal("-5.678"), False),
|
||||
("((4,200))", Decimal("-4.200"), True),
|
||||
("1234,567", Decimal("1234.567"), True), # запятая НЕ расширена (R-7): четыре цифры до неё — не тысячи
|
||||
("1 234,567", Decimal("1234.567"), True),
|
||||
("01,234", Decimal("1.234"), True), # ведущий ноль — не тысячи
|
||||
("4,2000", Decimal("4.2000"), True),
|
||||
("1, 234", Decimal("1234"), True), # пробелы убираются первыми
|
||||
# точка-разделитель тысяч (AURORA-1405, scan-gateway#37): то же правило, что для запятой, verified=True
|
||||
("4.200", Decimal("4200"), True),
|
||||
("837.994", Decimal("837994"), True),
|
||||
("(4.200)", Decimal("-4200"), True),
|
||||
("-4.200", Decimal("-4200"), True),
|
||||
("+4.200", Decimal("4200"), True),
|
||||
("(-4.200)", Decimal("-4200"), True),
|
||||
("1.234", Decimal("1234"), True),
|
||||
("100.000", Decimal("100000"), True),
|
||||
("1. 234", Decimal("1234"), True), # пробелы убираются первыми
|
||||
# ровно три цифры после точки — тысячи независимо от числа цифр до неё (R-3, решение владельца 16.10.2026)
|
||||
("1 234.567", Decimal("1234567"), True),
|
||||
("1234.567", Decimal("1234567"), True),
|
||||
("12345.678", Decimal("12345678"), True),
|
||||
("(1 234.567)", Decimal("-1234567"), True),
|
||||
("-1234.567", Decimal("-1234567"), True),
|
||||
("1234.567.890", Decimal("1234567890"), True),
|
||||
# не тысячи: не три цифры после точки — десятичная дробь, как раньше
|
||||
("4.2", Decimal("4.2"), True),
|
||||
("4.20", Decimal("4.20"), True),
|
||||
("12.34", Decimal("12.34"), True),
|
||||
("1.2345", Decimal("1.2345"), True),
|
||||
("0.125", Decimal("0.125"), True), # ведущий ноль — не тысячи
|
||||
("01.234", Decimal("1.234"), True),
|
||||
("1234.56", Decimal("1234.56"), True), # дробная часть не из трёх цифр — дробь
|
||||
("1234.5678", Decimal("1234.5678"), True),
|
||||
# смешанные разделители и прочий мусор — правило не применяется (R-1): прежний разбор
|
||||
("1.234,567", Decimal("1234567"), False),
|
||||
("1,234.567", Decimal("1234567"), False),
|
||||
("1.234.56", Decimal("123456"), False),
|
||||
("4.200.", Decimal("4200"), False), # хвостовая точка
|
||||
("(4.200}", Decimal("4.200"), False), # нет пары скобок: правило не применяется, минус не ставится
|
||||
("(4.200) руб", Decimal("-4.200"), False),
|
||||
("abc(4.200)", Decimal("-4.200"), False),
|
||||
("(4.200)(1.000)", Decimal("-4.200"), False),
|
||||
("1.234(5.678)", Decimal("-5.678"), False),
|
||||
# пробел внутри суммы допустим только как разделитель тысяч (AURORA-1511/1519): иначе догадка, verified=False
|
||||
("42831 1", Decimal("428311"), False),
|
||||
("1 2", Decimal("12"), False),
|
||||
("1234 567", Decimal("1234567"), False),
|
||||
("12 34", Decimal("1234"), False),
|
||||
("1 2345", Decimal("12345"), False),
|
||||
("(42831 1)", Decimal("-428311"), False),
|
||||
("1 234,5 6", Decimal("1234.56"), False),
|
||||
("12 345 678", Decimal("12345678"), True),
|
||||
("(12 345)", Decimal("-12345"), True),
|
||||
("-1 234", Decimal("-1234"), True),
|
||||
("123 456", Decimal("123456"), True),
|
||||
# №13: скобки без содержимого — не сумма, но и не «неразобранный текст»
|
||||
("()", None, True),
|
||||
("( )", None, True),
|
||||
("(.)", None, True),
|
||||
("(+5)", Decimal("-5"), True), # в скобках знак заменяется минусом
|
||||
],
|
||||
)
|
||||
def test_parse_amount(raw: str, amount: Decimal | None, verified: bool) -> None:
|
||||
assert parse_amount(raw) == ParsedAmount(amount, verified)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[("", True), ("-", True), ("—", True), (" – ", True), ("0", False), ("x", False)],
|
||||
)
|
||||
def test_is_blank(raw: str, expected: bool) -> None:
|
||||
assert is_blank(raw) is expected
|
||||
@ -1,103 +0,0 @@
|
||||
"""Разбор дат из OCR-текста (§11.1 п. 2): дословный перенос `date_utils` старого ядра, вход — после `fix_lookalikes`."""
|
||||
|
||||
import time
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from src.normalizer.ocr.dates import MAX_DATE_TEXT_LENGTH, is_date_sized, parse_date
|
||||
from src.normalizer.ocr.text import fix_lookalikes
|
||||
|
||||
LOOKALIKES = {"О": "0", "о": "0", "З": "3", "l": "1", "I": "1", "|": "1", "б": "6", "S": "5", "Ч": "4"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("на 30 сентября 2025 г.", date(2025, 9, 30)),
|
||||
("На 30 июня\n2025 г", date(2025, 6, 30)),
|
||||
("31 декабря 2024 года", date(2024, 12, 31)),
|
||||
("30 сентября 2025 r.", date(2025, 9, 30)), # латинская r вместо г
|
||||
("с 01.01.2025 по 30.09.2025", date(2025, 9, 30)), # диапазон — конец
|
||||
("I квартал 2026", date(2026, 3, 31)),
|
||||
("1-й кв. 2026", date(2026, 3, 31)),
|
||||
("первый квартал 2025 г.", date(2025, 3, 31)),
|
||||
("1 полугодие 2026", date(2026, 6, 30)),
|
||||
("1 п/г 2026", date(2026, 6, 30)),
|
||||
("9 месяцев 2025", date(2025, 9, 30)),
|
||||
("9 мес. 2025", date(2025, 9, 30)),
|
||||
("За январь - июнь\n2025 г.", date(2025, 6, 30)),
|
||||
("январь – сентябрь 2025", date(2025, 9, 30)),
|
||||
("за 2025 г.", date(2025, 12, 31)),
|
||||
("2024 год", date(2024, 12, 31)),
|
||||
("30.09.2025", date(2025, 9, 30)),
|
||||
("2025 03 31", date(2025, 3, 31)),
|
||||
("30/09/25", date(2025, 9, 30)),
|
||||
("2 0 2 4", date(2024, 12, 31)),
|
||||
("30 09 2 0 2 5", date(2025, 9, 30)),
|
||||
("30092025", date(2025, 9, 30)),
|
||||
("20250930", date(2025, 9, 30)),
|
||||
("30 09 25", date(2025, 9, 30)),
|
||||
("30.09.2025 0:00:00", date(2025, 9, 30)),
|
||||
("2|0|2|4|", date(2024, 12, 31)), # через fix_lookalikes
|
||||
("3l.12.2О24", date(2024, 12, 31)),
|
||||
],
|
||||
)
|
||||
def test_parses_real_forms(raw: str, expected: date) -> None:
|
||||
assert parse_date(fix_lookalikes(raw, LOOKALIKES)) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
"Общество с ограниченной ответственностью", # Review Focus 3: название организации — не дата
|
||||
"ЗАКРЫТОЕ АКЦИОНЕРНОЕ ОБЩЕСТВО",
|
||||
"",
|
||||
"+",
|
||||
"тыс. руб.",
|
||||
"1999999999999", # 13 цифр — ни одна форма
|
||||
],
|
||||
)
|
||||
def test_organization_name_is_not_a_date(raw: str) -> None:
|
||||
assert parse_date(fix_lookalikes(raw, LOOKALIKES)) is None
|
||||
|
||||
|
||||
def test_non_string_is_none() -> None:
|
||||
assert parse_date(None) is None
|
||||
assert parse_date(20250930) is None
|
||||
|
||||
|
||||
def test_year_out_of_range_is_none() -> None:
|
||||
assert parse_date("1 2 3 4") is None # 1234 вне 1990–2100
|
||||
|
||||
|
||||
class TestLengthCap:
|
||||
"""Решение №31: текст длиннее `MAX_DATE_TEXT_LENGTH` не разбирается; патологические строки не вешают процесс."""
|
||||
|
||||
PATHOLOGICAL = {
|
||||
"spaces-then-letter": "1" + " " * 4000 + "b",
|
||||
"newlines": "1" + "\n" * 4000, # после strip — «1»: короткий текст, отсекается не длиной
|
||||
"newlines-inside": "1" + "\n" * 4000 + "b",
|
||||
"lookalikes-after-digit": "1" + "О" * 5000,
|
||||
}
|
||||
|
||||
@pytest.mark.parametrize("raw", PATHOLOGICAL.values(), ids=PATHOLOGICAL.keys())
|
||||
def test_pathological_text_is_fast_and_none(self, raw: str) -> None:
|
||||
start = time.perf_counter()
|
||||
assert parse_date(raw) is None
|
||||
assert parse_date(fix_lookalikes(raw, LOOKALIKES)) is None if is_date_sized(raw) else True
|
||||
assert time.perf_counter() - start < 1.0
|
||||
|
||||
def test_long_text_is_not_date_sized(self) -> None:
|
||||
assert not is_date_sized("1" + " " * 4000 + "b") and not is_date_sized("1" + "О" * 5000)
|
||||
|
||||
def test_boundary(self) -> None:
|
||||
date_text = "на 30 сентября 2025 г."
|
||||
padded = date_text + " " * 300 # пробелы по краям не считаются
|
||||
assert is_date_sized(padded) and parse_date(padded) == date(2025, 9, 30)
|
||||
at_limit = date_text + "x" * (MAX_DATE_TEXT_LENGTH - len(date_text))
|
||||
assert len(at_limit) == MAX_DATE_TEXT_LENGTH and is_date_sized(at_limit)
|
||||
assert parse_date(at_limit) == date(2025, 9, 30) # на пределе дата ещё разбирается
|
||||
over = date_text + "x" * (MAX_DATE_TEXT_LENGTH + 1 - len(date_text))
|
||||
assert len(over) == MAX_DATE_TEXT_LENGTH + 1 and not is_date_sized(over)
|
||||
assert parse_date(over) is None # одним символом больше — не разбирается, хотя дата в начале есть
|
||||
@ -1,99 +0,0 @@
|
||||
"""Шапка документа из `data_ocr` (§11.1 п. 8) на реальных `aep_3.json` (мусор) и `aep_6.json` (чисто)."""
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.normalizer.ocr.header import FileHeader, parse_file_header
|
||||
|
||||
API_DIR = Path(__file__).resolve().parents[3]
|
||||
REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr"
|
||||
LOOKALIKES = {"О": "0", "о": "0", "З": "3", "l": "1", "I": "1", "|": "1", "б": "6", "S": "5", "Ч": "4"}
|
||||
|
||||
|
||||
def _real(name: str) -> dict:
|
||||
if not REAL_FIXTURES.is_dir():
|
||||
pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace")
|
||||
return dict(json.loads((REAL_FIXTURES / name).read_text(encoding="utf-8"))["data_ocr"])
|
||||
|
||||
|
||||
def _field(text: str, confidence: float) -> dict:
|
||||
return {"form_name": text, "confidence": confidence}
|
||||
|
||||
|
||||
def test_clean_header() -> None:
|
||||
header = parse_file_header(
|
||||
{
|
||||
"organization": _field("ООО Ромашка", 97.5),
|
||||
"inn": _field("7700000000", 100),
|
||||
"units": _field("тыс. руб.", 100),
|
||||
"date": _field("на 31 декабря 2024 г.", 100),
|
||||
},
|
||||
LOOKALIKES,
|
||||
)
|
||||
assert header == FileHeader(
|
||||
"ООО Ромашка",
|
||||
97.5,
|
||||
"7700000000",
|
||||
100.0,
|
||||
"тыс. руб.",
|
||||
"на 31 декабря 2024 г.",
|
||||
date(2024, 12, 31),
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
def test_noisy_inn_caps_confidence() -> None:
|
||||
header = parse_file_header({"inn": _field("3|1 21 2| о! О' 0 0; 3: 51", 100)}, LOOKALIKES)
|
||||
assert header.inn == "3121200351"
|
||||
assert header.inn_confidence == 99.0
|
||||
assert header.organization_name is None and header.organization_confidence is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("raw", "expected"), [(float("nan"), 0.0), (150, 100.0), (-5, 0.0), (float("inf"), 0.0)])
|
||||
def test_header_confidence_is_clamped(raw: float, expected: float) -> None:
|
||||
"""Решение №30: уверенность полей шапки — в [0, 100], NaN → 0 (контракт требует 0..100)."""
|
||||
header = parse_file_header(
|
||||
{"organization": _field("ООО Ромашка", raw), "inn": _field("7700000000", raw), "date": _field("2024", raw)},
|
||||
LOOKALIKES,
|
||||
)
|
||||
assert header.organization_confidence == expected and header.inn_confidence == expected
|
||||
assert header.report_date_recognized is (expected == 100.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw", ["1" + " " * 4000 + "b", "1" + "\n" * 4000, "1" + "О" * 5000], ids=["spaces", "nl", "look"]
|
||||
)
|
||||
def test_pathological_report_date_is_fast(raw: str) -> None:
|
||||
"""Решение №31: длинный текст даты не разбирается и не проходит `fix_lookalikes` (иначе — секунды и минуты)."""
|
||||
start = time.perf_counter()
|
||||
header = parse_file_header({"date": _field(raw, 100)}, LOOKALIKES)
|
||||
assert time.perf_counter() - start < 1.0
|
||||
assert header.report_date is None and header.report_date_recognized is False
|
||||
assert header.report_date_text == raw.strip() # исходный текст не теряется
|
||||
|
||||
|
||||
def test_missing_fields() -> None:
|
||||
header = parse_file_header({}, LOOKALIKES)
|
||||
assert header == FileHeader(None, None, None, None, None, None, None, False)
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
def test_aep6_header() -> None:
|
||||
header = parse_file_header(_real("aep_6.json"), LOOKALIKES)
|
||||
assert header.inn is None # текст «+»
|
||||
assert header.units == "тыс. руб."
|
||||
assert header.report_date is None # в поле date — название организации
|
||||
assert header.report_date_text is not None and header.report_date_recognized is False
|
||||
assert header.organization_name is not None and "Дорстроймонтаж" in header.organization_name
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
def test_aep3_header() -> None:
|
||||
header = parse_file_header(_real("aep_3.json"), LOOKALIKES)
|
||||
assert header.inn == "3121200351" and header.inn_confidence is not None and header.inn_confidence < 100
|
||||
assert header.report_date == date(2024, 12, 31)
|
||||
assert header.report_date_recognized is False # confidence 75
|
||||
@ -1,108 +0,0 @@
|
||||
"""Раскладка колонок таблицы OCR (§11.1 п. 3): шапка «Код» и вывод по данным.
|
||||
|
||||
Перенос `_build_layout`/`_infer_layout` из старого ядра."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.normalizer.ocr.layout import Cell, ColumnLayout, build_layout, cell_text, cells_by_index, infer_layout
|
||||
|
||||
KNOWN = frozenset({"1110", "1120", "1130", "1150", "2110", "2120"})
|
||||
|
||||
|
||||
def _row(*cells: tuple[int, str, float]) -> dict:
|
||||
return {"row_index": 0, "cells": [{"column_index": i, "value": v, "confidence": c} for i, v, c in cells]}
|
||||
|
||||
|
||||
def test_cells_by_index_normalizes_types() -> None:
|
||||
cells = cells_by_index(_row((0, "Выручка", 71.4), (1, None, None)))
|
||||
assert cells == {0: Cell("Выручка", 71.4), 1: Cell("", 0.0)}
|
||||
assert cell_text(cells, 0) == "Выручка"
|
||||
assert cell_text(cells, 7) == ""
|
||||
assert cell_text(cells, None) == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[(float("nan"), 0.0), (150, 100.0), (-5, 0.0), (float("inf"), 0.0), (10**400, 0.0), ("90", 0.0), (None, 0.0)],
|
||||
)
|
||||
def test_cells_by_index_clamps_confidence(raw: object, expected: float) -> None:
|
||||
"""Решение №30: `json.loads` принимает NaN/Infinity — уверенность ячейки приводится к [0, 100], NaN → 0."""
|
||||
row = {"cells": [{"column_index": 0, "value": "x", "confidence": raw}]}
|
||||
assert cells_by_index(row)[0].confidence == expected
|
||||
|
||||
|
||||
class TestBuildLayout:
|
||||
def test_header_with_code_and_years(self) -> None:
|
||||
cells = cells_by_index(
|
||||
_row(
|
||||
(0, "Наименование показателя", 100),
|
||||
(1, "Код", 100),
|
||||
(2, "На 30 июня\n2025 г", 100),
|
||||
(4, "На 31 декабря\n2024 г.", 100),
|
||||
(5, "На 31 декабря\n2023 г.", 100),
|
||||
)
|
||||
)
|
||||
assert build_layout(cells) == ColumnLayout(label_idx=0, code_idx=1, value_idxs=(2, 4, 5))
|
||||
|
||||
def test_label_defaults_to_last_filled_left_of_code(self) -> None:
|
||||
cells = cells_by_index(_row((0, "Пояснения", 100), (1, "Показатель", 100), (2, "Код", 100), (3, "2024", 100)))
|
||||
assert build_layout(cells) == ColumnLayout(label_idx=1, code_idx=2, value_idxs=(3,))
|
||||
|
||||
def test_no_code_cell_is_not_a_header(self) -> None:
|
||||
assert build_layout(cells_by_index(_row((0, "Выручка", 100), (1, "2110", 100), (2, "13 070", 100)))) is None
|
||||
|
||||
|
||||
class TestInferLayout:
|
||||
def test_aep3_like_rows_without_header(self) -> None:
|
||||
rows = [
|
||||
cells_by_index(
|
||||
_row(
|
||||
(1, "НИ", 0),
|
||||
(0, "Нематериальные активы", 100),
|
||||
(2, "30127", 60),
|
||||
(4, "| | г", 16),
|
||||
(5, "", 100),
|
||||
)
|
||||
),
|
||||
cells_by_index(
|
||||
_row(
|
||||
(1, "1120", 50),
|
||||
(0, "Результаты исследований и", 94),
|
||||
(2, ":", 0),
|
||||
(4, "--", 0),
|
||||
(5, ":", 0),
|
||||
)
|
||||
),
|
||||
cells_by_index(
|
||||
_row(
|
||||
(1, "1130", 80),
|
||||
(0, "Нематериальные поисковые", 87),
|
||||
(2, "не ****", 5),
|
||||
(4, "3", 0),
|
||||
(5, "+ :", 0),
|
||||
)
|
||||
),
|
||||
cells_by_index(
|
||||
_row(
|
||||
(1, "1150", 100),
|
||||
(0, "Основные средства", 100),
|
||||
(2, "1 234", 100),
|
||||
(4, "5 678", 100),
|
||||
(5, "", 100),
|
||||
)
|
||||
),
|
||||
]
|
||||
layout = infer_layout(rows, KNOWN)
|
||||
assert layout is not None
|
||||
assert layout.code_idx == 1
|
||||
assert layout.label_idx == 0
|
||||
# R-1: в колонке 5 только мусор (пусто, ':', '+ :'), настоящих сумм нет
|
||||
# в колонке 4 и мусор, и суммы ('--', '3', '5 678'), но сумм не меньше, чем мусора
|
||||
assert layout.value_idxs == (2, 4)
|
||||
|
||||
def test_no_known_codes_is_none(self) -> None:
|
||||
rows = [cells_by_index(_row((0, "текст", 100), (1, "мусор", 100)))]
|
||||
assert infer_layout(rows, KNOWN) is None
|
||||
|
||||
def test_empty_rows_is_none(self) -> None:
|
||||
assert infer_layout([], KNOWN) is None
|
||||
@ -1,353 +0,0 @@
|
||||
"""Строки таблицы OCR (§11.1 п. 3–5): шапка настоящая/повторная/синтетическая, классы строк, тип по кодам."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.normalizer.ocr.layout import ColumnLayout, cell_text, cells_by_index
|
||||
from src.normalizer.ocr.rows import (
|
||||
ParsedTable,
|
||||
RowKind,
|
||||
classify_row,
|
||||
is_numbering_row,
|
||||
parse_table_rows,
|
||||
table_type_by_codes,
|
||||
table_type_by_unanimous_codes,
|
||||
)
|
||||
|
||||
API_DIR = Path(__file__).resolve().parents[3]
|
||||
REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr"
|
||||
KNOWN = frozenset({"1105", "1110", "1120", "1130", "1150", "1600", "2110", "2120", "2100"})
|
||||
|
||||
|
||||
def _row(index: int, *cells: tuple[int, str, float]) -> dict:
|
||||
return {"row_index": index, "cells": [{"column_index": i, "value": v, "confidence": c} for i, v, c in cells]}
|
||||
|
||||
|
||||
HEADER = _row(
|
||||
0,
|
||||
(0, "Наименование показателя", 100),
|
||||
(1, "Код", 100),
|
||||
(2, "На 30 июня\n2025 г", 100),
|
||||
(4, "На 31 декабря\n2024 г.", 100),
|
||||
)
|
||||
|
||||
|
||||
def _real_table(name: str, index: int) -> list[dict]:
|
||||
if not REAL_FIXTURES.is_dir():
|
||||
pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace")
|
||||
data = json.loads((REAL_FIXTURES / name).read_text(encoding="utf-8"))
|
||||
return list(data["data_ocr"]["table_structure"][index]["rows"])
|
||||
|
||||
|
||||
class TestClassifyRow:
|
||||
layout = ColumnLayout(label_idx=0, code_idx=1, value_idxs=(2,))
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("label", "code", "expected"),
|
||||
[
|
||||
("Выручка", "2110", RowKind.ROW),
|
||||
("Итого по разделу I", "1100", RowKind.TOTAL),
|
||||
("Баланс", "1600", RowKind.BALANCE),
|
||||
("Наименование", "Код", RowKind.HEADER),
|
||||
("в том числе:", "", None),
|
||||
],
|
||||
)
|
||||
def test_kinds(self, label: str, code: str, expected: RowKind | None) -> None:
|
||||
cells = cells_by_index(_row(1, (0, label, 100), (1, code, 100), (2, "1", 100)))
|
||||
assert classify_row(cells, self.layout) is expected
|
||||
|
||||
|
||||
class TestParseTableRows:
|
||||
def test_header_then_rows(self) -> None:
|
||||
rows = [
|
||||
HEADER,
|
||||
_row(1, (0, "Выручка", 100), (1, "2110", 100), (2, "13 070", 100), (4, "", 100)),
|
||||
_row(2, (0, "в том числе:", 93), (1, "", 100), (2, "13 070", 100), (4, "", 100)),
|
||||
]
|
||||
parsed = parse_table_rows(rows, KNOWN)
|
||||
assert isinstance(parsed, ParsedTable) and not parsed.synthetic_header
|
||||
assert [r.kind for r in parsed.rows] == [RowKind.HEADER, RowKind.ROW]
|
||||
assert parsed.rows[0].layout == ColumnLayout(label_idx=0, code_idx=1, value_idxs=(2, 4))
|
||||
assert parsed.rows[1].ordinal == 1
|
||||
|
||||
def test_numbering_row_is_not_data(self) -> None:
|
||||
"""AURORA-1517: строка «1 2 3 4 5» под шапкой — часть заголовка, не строка данных с кодом «3»."""
|
||||
rows = [
|
||||
HEADER,
|
||||
_row(1, (0, "1", 100), (1, "2", 100), (2, "3", 100), (4, "4", 100)),
|
||||
_row(2, (0, "Выручка", 100), (1, "2110", 100), (2, "13 070", 100), (4, "", 100)),
|
||||
]
|
||||
parsed = parse_table_rows(rows, KNOWN)
|
||||
assert parsed is not None
|
||||
assert [r.kind for r in parsed.rows] == [RowKind.HEADER, RowKind.ROW]
|
||||
assert parsed.rows[1].ordinal == 2
|
||||
assert parsed.skipped_non_numeric_code == 0 and parsed.skipped_no_code_with_amounts == 0
|
||||
|
||||
def test_numbering_row_with_empty_cells(self) -> None:
|
||||
rows = [
|
||||
HEADER,
|
||||
_row(1, (0, "1", 100), (1, "2", 100), (2, "3", 100), (3, "", 100), (4, "5", 100)),
|
||||
_row(2, (0, "Выручка", 100), (1, "2110", 100), (2, "13 070", 100)),
|
||||
]
|
||||
parsed = parse_table_rows(rows, KNOWN)
|
||||
assert parsed is not None and [r.kind for r in parsed.rows] == [RowKind.HEADER, RowKind.ROW]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"values",
|
||||
[
|
||||
("1", "2"), # меньше трёх ячеек
|
||||
("5", "3", "4"), # не по возрастанию
|
||||
("1", "2", "2"),
|
||||
("1105", "2", "3"), # не короткие
|
||||
("Выручка", "2110", "5"),
|
||||
],
|
||||
)
|
||||
def test_not_numbering_row(self, values: tuple[str, ...]) -> None:
|
||||
assert not is_numbering_row(cells_by_index(_row(1, *((i, v, 100) for i, v in enumerate(values)))))
|
||||
|
||||
def test_second_header_overrides_layout(self) -> None:
|
||||
"""Review Focus 1: после повторной шапки с другим набором колонок строки идут по новой раскладке."""
|
||||
rows = [
|
||||
HEADER,
|
||||
_row(1, (0, "Выручка", 100), (1, "2110", 100), (2, "1", 100), (4, "2", 100)),
|
||||
_row(2, (0, "Наименование показателя", 100), (1, "Код", 100), (3, "За 2024 г.", 100)),
|
||||
_row(3, (0, "Себестоимость", 100), (1, "2120", 100), (3, "(5)", 100)),
|
||||
]
|
||||
parsed = parse_table_rows(rows, KNOWN)
|
||||
assert parsed is not None
|
||||
kinds = [r.kind for r in parsed.rows]
|
||||
assert kinds == [RowKind.HEADER, RowKind.ROW, RowKind.ROW] # повторная шапка не становится строкой
|
||||
assert parsed.rows[1].layout.value_idxs == (2, 4)
|
||||
assert parsed.rows[2].layout.value_idxs == (3,)
|
||||
|
||||
def test_rows_before_header_use_inferred_layout(self) -> None:
|
||||
rows = [
|
||||
_row(0, (1, "1110", 100), (0, "Нематериальные активы", 100), (2, "5", 100)),
|
||||
_row(1, (0, "Наименование показателя", 100), (1, "Код", 100), (2, "2024", 100)),
|
||||
_row(2, (0, "Основные средства", 100), (1, "1150", 100), (2, "7", 100)),
|
||||
]
|
||||
parsed = parse_table_rows(rows, KNOWN)
|
||||
assert parsed is not None and not parsed.synthetic_header
|
||||
assert [r.ordinal for r in parsed.rows] == [-1, 0, 2] # шапка встаёт раньше первой строки данных
|
||||
assert parsed.rows[0].kind is RowKind.HEADER
|
||||
|
||||
def test_synthetic_header_when_none(self) -> None:
|
||||
rows = [
|
||||
_row(0, (1, "1110", 100), (0, "Нематериальные активы", 100), (2, "5", 100)),
|
||||
_row(1, (1, "1150", 100), (0, "Основные средства", 100), (2, "7", 100)),
|
||||
]
|
||||
parsed = parse_table_rows(rows, KNOWN)
|
||||
assert parsed is not None and parsed.synthetic_header
|
||||
header = parsed.rows[0]
|
||||
assert header.kind is RowKind.HEADER and header.ordinal == -1
|
||||
assert header.cells[1].value == "Код" and header.cells[0].value == "Наименование показателя"
|
||||
assert header.layout.value_idxs == (2,) and header.cells.get(2) is None # тексты периодов пустые
|
||||
|
||||
def test_no_header_no_codes_is_none(self) -> None:
|
||||
assert parse_table_rows([_row(0, (0, "текст", 100), (1, "мусор", 100))], KNOWN) is None
|
||||
assert parse_table_rows([], KNOWN) is None
|
||||
|
||||
@pytest.mark.fixtures
|
||||
def test_aep3_balance_without_header(self) -> None:
|
||||
parsed = parse_table_rows(_real_table("aep_3.json", 0), KNOWN)
|
||||
assert parsed is not None and parsed.synthetic_header
|
||||
assert parsed.rows[0].layout.code_idx == 1
|
||||
assert any(r.cells[1].value.strip() == "1120" for r in parsed.rows[1:])
|
||||
|
||||
@pytest.mark.fixtures
|
||||
def test_aep6_balance_with_header(self) -> None:
|
||||
parsed = parse_table_rows(_real_table("aep_6.json", 0), KNOWN)
|
||||
assert parsed is not None and not parsed.synthetic_header
|
||||
assert parsed.rows[0].layout == ColumnLayout(label_idx=0, code_idx=1, value_idxs=(2, 4, 5))
|
||||
assert table_type_by_codes(parsed) == "BALANCE"
|
||||
|
||||
|
||||
class TestSkippedRows:
|
||||
"""Решение №29: отброшенные строки считаются, не меняя сам разбор (строки данных те же, что и раньше)."""
|
||||
|
||||
def test_clean_table_counts_nothing(self) -> None:
|
||||
rows = [HEADER, _row(1, (0, "Выручка", 100), (1, "2110", 100), (2, "5", 100), (4, "6", 100))]
|
||||
parsed = parse_table_rows(rows, KNOWN)
|
||||
assert parsed is not None
|
||||
assert (parsed.skipped_non_numeric_code, parsed.skipped_no_code_with_amounts) == (0, 0)
|
||||
|
||||
def test_non_numeric_code_is_counted(self) -> None:
|
||||
rows = [
|
||||
HEADER,
|
||||
_row(1, (0, "ОС", 100), (1, "1150 :", 100), (2, "5", 100), (4, "6", 100)),
|
||||
_row(2, (0, "НМА", 100), (1, "1l10", 100), (2, "5", 100), (4, "6", 100)), # латинская l вместо единицы
|
||||
_row(3, (0, "Выручка", 100), (1, "2110", 100), (2, "5", 100), (4, "6", 100)),
|
||||
]
|
||||
parsed = parse_table_rows(rows, KNOWN)
|
||||
assert parsed is not None
|
||||
assert [r.ordinal for r in parsed.rows] == [0, 3] # разбор прежний: строки с кодом-мусором отброшены
|
||||
assert (parsed.skipped_non_numeric_code, parsed.skipped_no_code_with_amounts) == (2, 0)
|
||||
|
||||
def test_empty_code_counted_only_with_amount_in_value_columns(self) -> None:
|
||||
rows = [
|
||||
HEADER,
|
||||
_row(1, (0, "БАЛАНС", 100), (1, "", 100), (2, "5 553 383", 100), (4, "", 100)), # сумма — считается
|
||||
_row(2, (0, "Итого", 100), (4, "(77)", 100)), # ячейки кода нет вовсе, сумма в колонке 4 — считается
|
||||
_row(3, (0, "в том числе:", 93), (1, "", 100), (2, "", 100), (4, "-", 100)), # прочерки — не сумма
|
||||
_row(4, (0, "Раздел I", 100), (1, "", 100), (2, "текст", 100)), # текст — не сумма
|
||||
_row(5, (0, "Справочно", 100), (1, "", 100), (3, "9", 100)), # сумма вне колонок значений — не считается
|
||||
_row(6, (0, "Выручка", 100), (1, "2110", 100), (2, "1", 100)),
|
||||
]
|
||||
parsed = parse_table_rows(rows, KNOWN)
|
||||
assert parsed is not None and [r.ordinal for r in parsed.rows] == [0, 6]
|
||||
assert (parsed.skipped_non_numeric_code, parsed.skipped_no_code_with_amounts) == (0, 2)
|
||||
|
||||
def test_repeated_header_is_not_counted_and_later_rows_use_new_layout(self) -> None:
|
||||
rows = [
|
||||
HEADER,
|
||||
_row(1, (0, "Выручка", 100), (1, "2110", 100), (2, "1", 100)),
|
||||
_row(2, (0, "Наименование показателя", 100), (1, "Код", 100), (3, "За 2024 г.", 100)),
|
||||
_row(3, (0, "Мусор", 100), (1, "21x0", 100), (3, "7", 100)),
|
||||
_row(4, (0, "БАЛАНС", 100), (1, "", 100), (3, "8", 100)), # сумма в колонке новой раскладки (3)
|
||||
_row(5, (0, "Прочее", 100), (1, "", 100), (2, "8", 100)), # колонка 2 в новой раскладке — не значение
|
||||
]
|
||||
parsed = parse_table_rows(rows, KNOWN)
|
||||
assert parsed is not None and [r.ordinal for r in parsed.rows] == [0, 1]
|
||||
assert (parsed.skipped_non_numeric_code, parsed.skipped_no_code_with_amounts) == (1, 1)
|
||||
|
||||
def test_leading_rows_before_header_are_counted(self) -> None:
|
||||
rows = [
|
||||
_row(0, (1, "1110", 100), (0, "Нематериальные активы", 100), (2, "5", 100)),
|
||||
_row(1, (1, "11x0", 100), (0, "Мусор", 100), (2, "5", 100)),
|
||||
_row(2, (1, "", 100), (0, "БАЛАНС", 100), (2, "9", 100)),
|
||||
_row(3, (1, "", 100), (0, "в том числе:", 100), (2, "", 100)),
|
||||
_row(4, (0, "Наименование показателя", 100), (1, "Код", 100), (2, "2024", 100)),
|
||||
_row(5, (0, "Основные средства", 100), (1, "1150", 100), (2, "7", 100)),
|
||||
]
|
||||
parsed = parse_table_rows(rows, KNOWN)
|
||||
assert parsed is not None and not parsed.synthetic_header
|
||||
assert [r.ordinal for r in parsed.rows] == [-1, 0, 5] # строка шапки — не в счёте
|
||||
assert (parsed.skipped_non_numeric_code, parsed.skipped_no_code_with_amounts) == (1, 1)
|
||||
|
||||
def test_synthetic_header_table_counts_leading_rows(self) -> None:
|
||||
rows = [
|
||||
_row(0, (1, "1110", 100), (0, "Нематериальные активы", 100), (2, "5", 100)),
|
||||
_row(1, (1, "1150 :", 100), (0, "ОС", 100), (2, "5", 100)),
|
||||
_row(2, (1, "", 100), (0, "БАЛАНС", 100), (2, "9", 100)),
|
||||
]
|
||||
parsed = parse_table_rows(rows, KNOWN)
|
||||
assert parsed is not None and parsed.synthetic_header
|
||||
assert (parsed.skipped_non_numeric_code, parsed.skipped_no_code_with_amounts) == (1, 1)
|
||||
|
||||
@pytest.mark.fixtures
|
||||
@pytest.mark.parametrize(
|
||||
("name", "index", "non_numeric", "no_code_amounts"),
|
||||
[("aep_3.json", 0, 8, 2), ("aep_3.json", 1, 2, 2), ("aep_6.json", 0, 2, 19), ("aep_6.json", 1, 1, 2)],
|
||||
)
|
||||
def test_real_tables(self, name: str, index: int, non_numeric: int, no_code_amounts: int) -> None:
|
||||
"""aep_3: «БАЛАНС» и «Выручка» с ПУСТЫМ кодом и суммами теряются; aep_6: «в том числе:» с суммами (OQ-141)."""
|
||||
parsed = parse_table_rows(_real_table(name, index), KNOWN)
|
||||
assert parsed is not None
|
||||
assert (parsed.skipped_non_numeric_code, parsed.skipped_no_code_with_amounts) == (non_numeric, no_code_amounts)
|
||||
|
||||
|
||||
class TestMergedCodeRows:
|
||||
"""OQ-156 (§11.1 п. 4): ИС склеила соседние строки формы — код `1500\\n1700`, суммы через перевод строки."""
|
||||
|
||||
MERGED = _row(
|
||||
7,
|
||||
(0, "Итого по разделу V\n\nБАЛАНС", 97),
|
||||
(1, "1500\n1700", 99),
|
||||
(2, "917 775\n2 371 333", 100),
|
||||
(4, "783 941\n2 096 754", 90),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _data(parsed: ParsedTable | None) -> list[tuple[str, str, str, str]]:
|
||||
assert parsed is not None
|
||||
return [
|
||||
(cell_text(r.cells, 0), cell_text(r.cells, 1), cell_text(r.cells, 2), cell_text(r.cells, 4))
|
||||
for r in parsed.rows
|
||||
if r.kind is not RowKind.HEADER
|
||||
]
|
||||
|
||||
def test_split_into_rows_by_parts(self) -> None:
|
||||
parsed = parse_table_rows([HEADER, self.MERGED], KNOWN)
|
||||
assert self._data(parsed) == [
|
||||
("Итого по разделу V", "1500", "917 775", "783 941"),
|
||||
("БАЛАНС", "1700", "2 371 333", "2 096 754"),
|
||||
]
|
||||
assert parsed is not None
|
||||
assert [r.ordinal for r in parsed.rows[1:]] == [7, 7]
|
||||
assert parsed.rows[1].kind is RowKind.TOTAL
|
||||
assert (parsed.rows[2].cells[1].confidence, parsed.rows[2].cells[4].confidence) == (99, 90)
|
||||
assert (parsed.split_merged_codes, parsed.skipped_non_numeric_code) == (1, 0)
|
||||
|
||||
def test_blank_value_cell_stays_blank_in_every_part(self) -> None:
|
||||
row = _row(1, (0, "А\nБ", 100), (1, "1150\n1160", 100), (2, "5\n6", 100), (4, "", 100))
|
||||
assert self._data(parse_table_rows([HEADER, row], KNOWN)) == [("А", "1150", "5", ""), ("Б", "1160", "6", "")]
|
||||
|
||||
def test_label_parts_mismatch_keeps_whole_label(self) -> None:
|
||||
row = _row(1, (0, "Итого", 100), (1, "1150\n1160", 100), (2, "5\n6", 100))
|
||||
assert [d[0] for d in self._data(parse_table_rows([HEADER, row], KNOWN))] == ["Итого", "Итого"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"row",
|
||||
[
|
||||
_row(1, (0, "А\nБ", 100), (1, "1150\n1160", 100), (2, "5", 100)), # одна сумма на два кода
|
||||
_row(1, (0, "А\nБ", 100), (1, "1150\n116O", 100), (2, "5\n6", 100)), # вторая часть — не целое
|
||||
_row(1, (0, "А\nБ", 100), (1, "1150\n1160", 100), (2, "5\n6", 100), (4, "1\n2\n3", 100)), # 3 части
|
||||
],
|
||||
)
|
||||
def test_parts_mismatch_is_skipped_as_before(self, row: dict) -> None:
|
||||
parsed = parse_table_rows([HEADER, row], KNOWN)
|
||||
assert self._data(parsed) == []
|
||||
assert parsed is not None and (parsed.split_merged_codes, parsed.skipped_non_numeric_code) == (0, 1)
|
||||
|
||||
def test_rows_before_header_are_split_too(self) -> None:
|
||||
rows = [
|
||||
_row(0, (1, "1110", 100), (0, "НМА", 100), (2, "5", 100)),
|
||||
_row(1, (1, "1150\n1160", 100), (0, "ОС\nДоходные вложения", 100), (2, "7\n8", 100)),
|
||||
]
|
||||
parsed = parse_table_rows(rows, KNOWN)
|
||||
assert parsed is not None and parsed.synthetic_header
|
||||
assert [r.cells[1].value for r in parsed.rows[1:]] == ["1110", "1150", "1160"]
|
||||
assert parsed.split_merged_codes == 1
|
||||
|
||||
@pytest.mark.fixtures
|
||||
@pytest.mark.parametrize("name", ["Бухгалтерская отчетность 1 кв.25.json", "БО 1 кв 2025.json"])
|
||||
def test_real_balance_keeps_1500_and_1700(self, name: str) -> None:
|
||||
parsed = parse_table_rows(_real_table(name, 0), KNOWN)
|
||||
assert parsed is not None
|
||||
codes = [r.cells[r.layout.code_idx].value for r in parsed.rows if r.kind is not RowKind.HEADER]
|
||||
assert "1500" in codes and "1700" in codes
|
||||
assert parsed.split_merged_codes == 1
|
||||
|
||||
|
||||
class TestTableType:
|
||||
def test_by_first_digit(self) -> None:
|
||||
rows = [
|
||||
HEADER,
|
||||
_row(1, (0, "Выручка", 100), (1, "2110", 100), (2, "1", 100)),
|
||||
_row(2, (0, "Себестоимость", 100), (1, "2120", 100), (2, "1", 100)),
|
||||
_row(3, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100)),
|
||||
]
|
||||
assert table_type_by_codes(parse_table_rows(rows, KNOWN)) == "INCOME"
|
||||
|
||||
def test_tie_or_empty_is_none(self) -> None:
|
||||
rows = [
|
||||
HEADER,
|
||||
_row(1, (0, "a", 100), (1, "2110", 100), (2, "1", 100)),
|
||||
_row(2, (0, "b", 100), (1, "1150", 100), (2, "1", 100)),
|
||||
]
|
||||
assert table_type_by_codes(parse_table_rows(rows, KNOWN)) is None
|
||||
assert table_type_by_codes(ParsedTable(rows=[], synthetic_header=False)) is None
|
||||
|
||||
def test_unanimous_requires_single_group(self) -> None:
|
||||
"""#30: тип «по единодушным кодам» — только когда все коды данных одной группы."""
|
||||
income_only = [
|
||||
HEADER,
|
||||
_row(1, (0, "Выручка", 100), (1, "2110", 100), (2, "1", 100)),
|
||||
_row(2, (0, "Себестоимость", 100), (1, "2120", 100), (2, "1", 100)),
|
||||
]
|
||||
assert table_type_by_unanimous_codes(parse_table_rows(income_only, KNOWN)) == "INCOME"
|
||||
mixed = [*income_only, _row(3, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100))]
|
||||
assert table_type_by_unanimous_codes(parse_table_rows(mixed, KNOWN)) is None
|
||||
assert table_type_by_unanimous_codes(ParsedTable(rows=[], synthetic_header=False)) is None
|
||||
@ -1,180 +0,0 @@
|
||||
"""Справочники нормализатора (spec «Справочники нормализации — данные с редакциями форм», D-7)."""
|
||||
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from src.core.config import get_settings
|
||||
from src.normalizer.rules import Edition, OcrRules, load_rules
|
||||
|
||||
API_DIR = Path(__file__).resolve().parents[3]
|
||||
RULES_DIR = API_DIR / "rules"
|
||||
|
||||
|
||||
def _write_rules(tmp_path: Path, lines: dict | None = None) -> Path:
|
||||
(tmp_path / "statement_lines.yaml").write_text(
|
||||
yaml.safe_dump(
|
||||
lines
|
||||
or {
|
||||
"editions": [
|
||||
{"id": "66n", "valid_until": "2024-12-31", "lines": {"1150": "Основные средства"}},
|
||||
{"id": "fsbu4", "valid_from": "2025-01-01", "lines": {}},
|
||||
]
|
||||
},
|
||||
allow_unicode=True,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "ocr_lookalikes.yaml").write_text(yaml.safe_dump({"digits": {"О": "0", "l": "1"}}), encoding="utf-8")
|
||||
(tmp_path / "statement_labels.yaml").write_text(
|
||||
yaml.safe_dump({"labels": {"бухгалтерский баланс": "BALANCE"}}, allow_unicode=True), encoding="utf-8"
|
||||
)
|
||||
return tmp_path
|
||||
|
||||
|
||||
class TestRealRules:
|
||||
def test_loads_repository_rules(self) -> None:
|
||||
rules = load_rules(RULES_DIR)
|
||||
ids = [edition.id for edition in rules.editions]
|
||||
assert ids == ["66n", "fsbu4"]
|
||||
assert rules.editions[0].lines["1150"] == "Основные средства"
|
||||
assert rules.editions[0].lines["1120"] == "Результаты исследований и разработок"
|
||||
assert rules.lookalikes["О"] == "0"
|
||||
assert rules.labels["бухгалтерский баланс"] == "BALANCE"
|
||||
assert rules.labels["отчет о финансовых результатах"] == "INCOME"
|
||||
|
||||
def test_codes_are_four_or_five_digits_with_names(self) -> None:
|
||||
"""Дубли кодов проверяет загрузчик yaml (`TestDuplicateKeys`): после `safe_load` в словаре их уже не видно."""
|
||||
rules = load_rules(RULES_DIR)
|
||||
for edition in rules.editions:
|
||||
assert all(code.isdigit() and 4 <= len(code) <= 5 for code in edition.lines), edition.id
|
||||
assert all(name.strip() for name in edition.lines.values()), edition.id
|
||||
|
||||
def test_fsbu4_edition_exists_even_if_empty(self) -> None:
|
||||
rules = load_rules(RULES_DIR)
|
||||
fsbu4 = next(edition for edition in rules.editions if edition.id == "fsbu4")
|
||||
assert fsbu4.valid_from == date(2025, 1, 1)
|
||||
|
||||
def test_66n_edition_data_integrity(self) -> None:
|
||||
rules = load_rules(RULES_DIR)
|
||||
edition_66n = next(edition for edition in rules.editions if edition.id == "66n")
|
||||
# Проверка точного количества кодов в редакции 66n
|
||||
assert len(edition_66n.lines) == 63, f"66n должна содержать 63 кода, а содержит {len(edition_66n.lines)}"
|
||||
# Проверка специфических кодов, упомянутых в брифе (опечатка и нестандартные)
|
||||
assert edition_66n.lines["1220"] == "Налог на добавленную стоимость по приобретённым ценностям"
|
||||
assert edition_66n.lines["1105"] == "Гудвил"
|
||||
assert edition_66n.lines["2411"] == "в т.ч. текущий налог на прибыль"
|
||||
assert edition_66n.lines["2412"] == "отложенный налог на прибыль"
|
||||
|
||||
|
||||
class TestEditionChoice:
|
||||
def test_by_report_date(self, tmp_path: Path) -> None:
|
||||
rules = load_rules(_write_rules(tmp_path))
|
||||
assert rules.edition_for(date(2024, 12, 31)).id == "66n"
|
||||
assert rules.edition_for(date(2025, 6, 30)).id == "fsbu4"
|
||||
assert rules.edition_for(None) is None
|
||||
|
||||
def test_by_codes_prefers_more_hits(self, tmp_path: Path) -> None:
|
||||
rules = load_rules(
|
||||
_write_rules(
|
||||
tmp_path,
|
||||
{
|
||||
"editions": [
|
||||
{"id": "66n", "valid_until": "2024-12-31", "lines": {"1150": "ОС", "1110": "НМА"}},
|
||||
{"id": "fsbu4", "valid_from": "2025-01-01", "lines": {"1150": "ОС", "1231": "ДЗ долгосрочная"}},
|
||||
]
|
||||
},
|
||||
)
|
||||
)
|
||||
assert rules.edition_by_codes(["1150", "1231"]).id == "fsbu4"
|
||||
assert rules.edition_by_codes(["1150"]).id == "66n" # равенство — первая
|
||||
assert rules.edition_by_codes(["9999"]) is None
|
||||
|
||||
def test_known_codes_and_editions_with(self, tmp_path: Path) -> None:
|
||||
rules = load_rules(_write_rules(tmp_path))
|
||||
assert rules.known_codes() == frozenset({"1150"})
|
||||
assert rules.editions_with("1150") == ["66n"]
|
||||
assert rules.editions_with("1110") == []
|
||||
|
||||
|
||||
class TestValidation:
|
||||
def test_bad_code_fails_with_file_name(self, tmp_path: Path) -> None:
|
||||
_write_rules(tmp_path, {"editions": [{"id": "66n", "lines": {"11A0": "x"}}]})
|
||||
with pytest.raises(ValueError, match="statement_lines.yaml"):
|
||||
load_rules(tmp_path)
|
||||
|
||||
def test_missing_file_fails(self, tmp_path: Path) -> None:
|
||||
_write_rules(tmp_path)
|
||||
(tmp_path / "ocr_lookalikes.yaml").unlink()
|
||||
with pytest.raises(ValueError, match="ocr_lookalikes.yaml"):
|
||||
load_rules(tmp_path)
|
||||
|
||||
def test_lookalike_must_map_to_single_digit(self, tmp_path: Path) -> None:
|
||||
_write_rules(tmp_path)
|
||||
(tmp_path / "ocr_lookalikes.yaml").write_text(yaml.safe_dump({"digits": {"О": "00"}}), encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="ocr_lookalikes.yaml"):
|
||||
load_rules(tmp_path)
|
||||
|
||||
|
||||
class TestDuplicateKeys:
|
||||
"""Решение №33: повторяющийся ключ yaml — ошибка загрузки (иначе `safe_load` молча оставил бы последний)."""
|
||||
|
||||
def test_duplicate_line_code_fails_with_file_and_key(self, tmp_path: Path) -> None:
|
||||
_write_rules(tmp_path)
|
||||
(tmp_path / "statement_lines.yaml").write_text(
|
||||
"editions:\n"
|
||||
" - id: 66n\n"
|
||||
" lines:\n"
|
||||
' "1150": "Основные средства"\n'
|
||||
' "1110": "Нематериальные активы"\n'
|
||||
' "1150": "Другое наименование"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(ValueError, match="statement_lines.yaml") as caught:
|
||||
load_rules(tmp_path)
|
||||
assert "повторяющийся ключ '1150'" in str(caught.value) and "line 6" in str(caught.value)
|
||||
|
||||
def test_duplicate_edition_field_fails(self, tmp_path: Path) -> None:
|
||||
_write_rules(tmp_path)
|
||||
(tmp_path / "statement_lines.yaml").write_text(
|
||||
"editions:\n - id: 66n\n id: other\n lines: {}\n", encoding="utf-8"
|
||||
)
|
||||
with pytest.raises(ValueError, match="повторяющийся ключ 'id'"):
|
||||
load_rules(tmp_path)
|
||||
|
||||
def test_duplicate_lookalike_fails(self, tmp_path: Path) -> None:
|
||||
_write_rules(tmp_path)
|
||||
(tmp_path / "ocr_lookalikes.yaml").write_text('digits:\n "О": "0"\n "l": "1"\n "О": "6"\n', encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="ocr_lookalikes.yaml") as caught:
|
||||
load_rules(tmp_path)
|
||||
assert "повторяющийся ключ 'О'" in str(caught.value)
|
||||
|
||||
def test_duplicate_label_fails(self, tmp_path: Path) -> None:
|
||||
_write_rules(tmp_path)
|
||||
(tmp_path / "statement_labels.yaml").write_text(
|
||||
"labels:\n бухгалтерский баланс: BALANCE\n бухгалтерский баланс: INCOME\n", encoding="utf-8"
|
||||
)
|
||||
with pytest.raises(ValueError, match="statement_labels.yaml") as caught:
|
||||
load_rules(tmp_path)
|
||||
assert "повторяющийся ключ 'бухгалтерский баланс'" in str(caught.value)
|
||||
|
||||
def test_same_code_in_different_editions_is_fine(self, tmp_path: Path) -> None:
|
||||
_write_rules(
|
||||
tmp_path,
|
||||
{
|
||||
"editions": [
|
||||
{"id": "66n", "valid_until": "2024-12-31", "lines": {"1150": "ОС"}},
|
||||
{"id": "fsbu4", "valid_from": "2025-01-01", "lines": {"1150": "ОС"}},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert load_rules(tmp_path).editions_with("1150") == ["66n", "fsbu4"]
|
||||
|
||||
|
||||
def test_settings_defaults() -> None:
|
||||
settings = get_settings()
|
||||
assert settings.SCAN_CLEANUP is False
|
||||
assert settings.SCAN_RULES_DIR == "rules"
|
||||
assert isinstance(Edition, type) and isinstance(OcrRules, type)
|
||||
@ -1,104 +0,0 @@
|
||||
"""Очистка OCR-текста (§11.1 п. 1, 6; D-4 `text.py`): перенос функций старого ядра, поведение — как в источнике."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.normalizer.ocr.text import (
|
||||
clean_value,
|
||||
collapse_whitespace,
|
||||
digits_only,
|
||||
fix_lookalikes,
|
||||
is_code_header,
|
||||
is_value_header,
|
||||
normalize_header,
|
||||
)
|
||||
|
||||
LOOKALIKES = {
|
||||
"О": "0",
|
||||
"о": "0",
|
||||
"O": "0",
|
||||
"o": "0",
|
||||
"З": "3",
|
||||
"з": "3",
|
||||
"l": "1",
|
||||
"I": "1",
|
||||
"|": "1",
|
||||
"і": "1",
|
||||
"б": "6",
|
||||
"S": "5",
|
||||
"s": "5",
|
||||
"Ч": "4",
|
||||
}
|
||||
|
||||
|
||||
class TestFixLookalikes:
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("2ОО5", "2005"), # два прохода: О после 2, затем О после 0
|
||||
("3l.12.2О24", "31.12.2024"),
|
||||
("Общество", "Общество"), # буквы без соседней цифры не трогаются
|
||||
("2|0|2|4|", "2 0 2 4 "), # первая строка — только цифры и |: это разделитель клеток
|
||||
("3l", "31"), # | не разделитель, если есть буквы
|
||||
("", ""),
|
||||
],
|
||||
)
|
||||
def test_replaces_only_next_to_digits(self, raw: str, expected: str) -> None:
|
||||
assert fix_lookalikes(raw, LOOKALIKES) == expected
|
||||
|
||||
def test_separator_rule_looks_at_first_line_only(self) -> None:
|
||||
assert fix_lookalikes("2|0|2|4|\nмусор", LOOKALIKES) == "2 0 2 4 \nмусор"
|
||||
|
||||
def test_empty_table_keeps_text(self) -> None:
|
||||
assert fix_lookalikes("2ОО5", {}) == "2ОО5"
|
||||
|
||||
def test_nbsp_replacement(self) -> None:
|
||||
# неразрывный пробел (U+00A0) → обычный пробел (U+0020)
|
||||
assert fix_lookalikes("31\xa012\xa02024", LOOKALIKES) == "31 12 2024"
|
||||
assert fix_lookalikes("31\xa012\xa02024", {}) == "31 12 2024"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("13 070", "13 070"),
|
||||
("(7744536}", "7744536"),
|
||||
("изо :", "изо"),
|
||||
("На 30 июня\n2025 г", "На 30 июня 2025 г"),
|
||||
("", ""),
|
||||
],
|
||||
)
|
||||
def test_clean_value(raw: str, expected: str) -> None:
|
||||
assert clean_value(raw) == expected
|
||||
|
||||
|
||||
def test_digits_only() -> None:
|
||||
assert digits_only("3|1 21 2| о! О' 0 0; 3: 51") == "3121200351"
|
||||
assert digits_only("+") == ""
|
||||
|
||||
|
||||
def test_collapse_whitespace() -> None:
|
||||
assert collapse_whitespace(" На 31 декабря\n2024 г.\t") == "На 31 декабря 2024 г."
|
||||
|
||||
|
||||
class TestHeaders:
|
||||
def test_normalize_header(self) -> None:
|
||||
assert normalize_header("Наименование\nпоказателя:") == "наименование показателя"
|
||||
assert normalize_header(None) == ""
|
||||
|
||||
def test_code_header(self) -> None:
|
||||
assert is_code_header(normalize_header("Код"))
|
||||
assert is_code_header(normalize_header("Код строки"))
|
||||
assert not is_code_header(normalize_header("Наименование"))
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "expected"),
|
||||
[
|
||||
("на 30 июня 2025 г", True),
|
||||
("за январь июнь 2025 г", True),
|
||||
("2024", True),
|
||||
("пояснения", False),
|
||||
("", False),
|
||||
],
|
||||
)
|
||||
def test_value_header(self, text: str, expected: bool) -> None:
|
||||
assert is_value_header(text) is expected
|
||||
Loading…
x
Reference in New Issue
Block a user