Andrey Epifancev c1288d08fb fix(normalizer): итоги финального ревью — Money, статус при ошибочном файле, пропущенные строки, длина дат, дубли yaml
- C-1 (№30): сумма с более чем двумя знаками после запятой не принимается (amount=null, string_value, is_recognized=false);
  confidence ячеек и шапки приводится к [0, 100], NaN и бесконечность дают 0
- F-A (№28): ошибочный файл набора при успешном понижает статус до needs_check
- F-B: «ИНН не распознан» только когда ИНН не извлечён, иначе «ИНН распознан неуверенно»
- №29: предупреждения «строк с нечисловым кодом пропущено: N» и «строк без кода с суммами пропущено: N»
- I-3 (№31): тексты дат длиннее 200 символов не разбираются и не проходят fix_lookalikes
- T1.3 (№33): загрузчик yaml отклоняет повторяющиеся ключи; тавтологичный тест дублей заменён настоящим
- гигиена: докстринг amounts.py описывает фактическое поведение (все точки удаляются), мёртвые noqa в ocr/__init__.py

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 03:35:10 +04:00

226 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""Строки таблицы OCR (§11.1 п. 3–5): шапка настоящая/повторная/синтетическая, классы строк, тип по кодам."""
import json
from pathlib import Path
import pytest
from src.normalizer.ocr.layout import ColumnLayout, cells_by_index
from src.normalizer.ocr.rows import ParsedTable, RowKind, classify_row, parse_table_rows, table_type_by_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_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 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