Andrey Epifancev 0847e6786c fix(normalizer): при конфликте label с единодушными кодами строк тип документа — по кодам (#30)
ИС Сканирование вернула ОФР с label «Бухгалтерский баланс» при кодах 2110…2910;
по прежнему правилу побеждал label, и документ уходил в ядро как BALANCE.
Теперь: все коды данных одной группы и противоречат label — тип по кодам,
предупреждение «тип по label (…) не совпал с кодами: взят …», needs_check;
смешанные коды — по-прежнему label. header.form_name не меняется.
Спека image-recognition-gateway, README и CLAUDE.md обновлены.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 20:07:25 +04:00

318 lines
17 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, cell_text, cells_by_index
from src.normalizer.ocr.rows import (
ParsedTable,
RowKind,
classify_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_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