Единая нормализация ББ/ОФР: адаптер OCR → arm_statements, контракт результата 2.0 (arm-ka-docs#116) #47

Merged
aep merged 12 commits from feat/46-statements-v2 into main 2026-10-19 07:38:23 +03:00
19 changed files with 0 additions and 2132 deletions
Showing only changes of commit d7bef3e08e - Show all commits

View File

@ -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

View File

@ -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"

View File

@ -1,6 +0,0 @@
# Нормализованные (нижний регистр, не-буквенно-цифровое → пробел) подстроки label таблицы → тип документа (§11 / D-6).
labels:
"бухгалтерский баланс": BALANCE
"отчет о финансовых результатах": INCOME
"отчёт о финансовых результатах": INCOME
"отчет о прибылях и убытках": INCOME

View File

@ -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: {}

View File

@ -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",

View File

@ -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)

View File

@ -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", " "))

View File

@ -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,
)

View File

@ -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))

View File

@ -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

View File

@ -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(("на ", "за "))

View File

@ -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)

View File

@ -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

View File

@ -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 # одним символом больше — не разбирается, хотя дата в начале есть

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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