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

621 lines
33 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.

"""Сборка `statementDocument` из таблицы OCR (spec «Нормализация ответа ИС», D-6, D-7)."""
import json
import time
from dataclasses import replace
from datetime import date
from decimal import Decimal
from pathlib import Path
import pytest
from src.normalizer.ocr.document import BuiltDocument, build_document
from src.normalizer.ocr.header import FileHeader
from src.normalizer.rules import Edition, OcrRules, load_rules
API_DIR = Path(__file__).resolve().parents[3]
RULES = load_rules(API_DIR / "rules")
HEADER_2024 = FileHeader(
"ООО Ромашка", 100.0, "7700000000", 100.0, "тыс. руб.", "на 31 декабря 2024 г.", date(2024, 12, 31), True
)
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]}
def _table(label: str, *rows: dict) -> dict:
return {"label": {"form_name": label, "confidence": 100}, "rows": list(rows)}
CLEAN_BALANCE = _table(
"Бухгалтерский баланс",
_row(
0,
(0, "Наименование показателя", 100),
(1, "Код", 100),
(2, "На 31 декабря 2024 г.", 100),
(3, "На 31 декабря 2023 г.", 100),
),
_row(1, (0, "Основные средства", 100), (1, "1150", 100), (2, "1 234", 100), (3, "(56)", 100)),
_row(2, (0, "Итого по разделу I", 100), (1, "1100", 100), (2, "1 234", 100), (3, "-", 100)),
)
def _build(table: dict, header: FileHeader = HEADER_2024, index: int = 0) -> BuiltDocument:
built = build_document(table, index, "job.json", header, RULES)
assert built is not None
return built
class TestCleanTable:
def test_document_fields(self) -> None:
built = _build(CLEAN_BALANCE)
doc = built.document
assert doc.document_type == "BALANCE"
assert doc.locator == "file:job.json;table:0"
assert doc.header.form_name == "Бухгалтерский баланс"
assert doc.header.inn == "7700000000" and doc.header.inn_confidence == 100
assert doc.header.report_date == date(2024, 12, 31) and doc.header.report_date_text == "на 31 декабря 2024 г."
assert doc.header.units == "тыс. руб." and doc.header.okud is None
assert [(c.column_index, c.label, c.period_date) for c in doc.columns] == [
(0, "На 31 декабря 2024 г.", date(2024, 12, 31)),
(1, "На 31 декабря 2023 г.", date(2023, 12, 31)),
]
assert doc.checks is None
def test_lines_and_values(self) -> None:
doc = _build(CLEAN_BALANCE).document
assert [line.line_code for line in doc.lines] == ["1150", "1100"]
first = doc.lines[0]
assert (
first.line_name == "Основные средства"
and first.line_code_confidence == 100
and first.line_name_confidence == 100
)
values = first.values
assert [v.column_index for v in values] == [0, 1]
assert (
values[0].amount == Decimal("1234") and values[0].string_value is None and values[0].is_recognized is True
)
assert values[1].amount == Decimal("-56") and values[1].is_recognized is True
blank = doc.lines[1].values[1]
assert blank.amount is None and blank.string_value is None and blank.is_recognized is True
def test_fully_recognized_and_edition(self) -> None:
built = _build(CLEAN_BALANCE)
assert built.fully_recognized is True and built.warnings == [] and built.edition_id == "66n"
class TestNoise:
def test_unknown_code_uses_ocr_name_and_caps_confidence(self) -> None:
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "Расшифровка", 100), (1, "11501", 100), (2, "1", 100), (3, "2", 100)),
_row(2, (0, "ОС", 100), (1, "1150", 90), (2, "1", 100), (3, "2", 100)),
)
built = _build(table)
doc = built.document
# кода нет в справочнике — имя из OCR, уверенность кода не выше 99 (spec: «иначе min(confidence, 99)»)
assert doc.lines[0].line_code == "11501" and doc.lines[0].line_name == "Расшифровка"
assert doc.lines[0].line_code_confidence == 99 and doc.lines[0].line_name_confidence == 100
assert doc.lines[1].line_code == "1150" and doc.lines[1].line_code_confidence == 90 # уже < 100, не поднимается
assert doc.lines[1].line_name == "Основные средства"
assert built.fully_recognized is False
def test_non_integer_code_row_is_dropped(self) -> None:
"""Перенос `_classify_row`: «1150 :» не целое число → строка считается повторным заголовком и пропускается."""
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "НМА", 100), (1, "1110", 100), (2, "1", 100), (3, "2", 100)),
_row(2, (0, "ОС", 100), (1, "1150 :", 100), (2, "1", 100), (3, "2", 100)),
)
assert [line.line_code for line in _build(table).document.lines] == ["1110"]
def test_duplicate_code_keeps_first(self) -> None:
"""Решение №15: DBML уникален по (meta_id, line_code) — повтор кода даёт одну строку и предупреждение."""
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "Основные средства", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)),
_row(2, (0, "Основные средства", 100), (1, "1150", 100), (2, "7", 100), (3, "8", 100)),
)
built = _build(table)
assert [line.line_code for line in built.document.lines] == ["1150"]
assert built.document.lines[0].values[0].amount == Decimal("1")
assert "код 1150 повторяется: оставлена первая строка" in built.warnings and built.fully_recognized is False
def test_missing_cells_keep_column_count(self) -> None:
"""Review Focus 2: у строки нет ячеек под колонками — values всё равно по числу колонок."""
table = _table("Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "ОС", 100), (1, "1150", 100)))
values = _build(table).document.lines[0].values
assert [(v.column_index, v.amount, v.string_value, v.confidence, v.is_recognized) for v in values] == [
(0, None, None, 0, False),
(1, None, None, 0, False),
]
def test_unverified_amount_is_not_recognized(self) -> None:
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "(7744536}", 100), (3, "x", 100)),
)
values = _build(table).document.lines[0].values
assert (
values[0].amount == Decimal("7744536")
and values[0].is_recognized is False
and values[0].string_value is None
)
assert values[1].amount is None and values[1].is_recognized is False and values[1].string_value == "x"
def test_synthetic_header_columns_without_dates(self) -> None:
table = _table(
"Бухгалтерский баланс",
_row(0, (1, "1150", 100), (0, "ОС", 100), (2, "1", 100)),
_row(1, (1, "1110", 100), (0, "НМА", 100), (2, "2", 100)),
)
built = _build(table)
assert [(c.column_index, c.label, c.period_date) for c in built.document.columns] == [(0, "", None)]
assert "шапка синтезирована" in " ".join(built.warnings) and built.fully_recognized is False
class TestTypeAndEdition:
def test_type_by_codes_when_label_unknown(self) -> None:
built = _build(_table("Бvxгалтерский баланс", *CLEAN_BALANCE["rows"]))
assert built.document.document_type == "BALANCE" and "тип определён по кодам" in built.warnings
def test_unanimous_codes_win_over_label_with_warning(self) -> None:
"""#30: label «Бухгалтерский баланс» от ИС при кодах только 2xxx — тип по кодам, label в предупреждении."""
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "Выручка", 100), (1, "2110", 100), (2, "1 182 108", 100), (3, "807 029", 100)),
_row(2, (0, "Себестоимость продаж", 100), (1, "2120", 100), (2, "(618 060)", 100), (3, "(371 193)", 100)),
)
built = _build(table)
assert built.document.document_type == "INCOME"
assert built.document.header.form_name == "Бухгалтерский баланс" # первичка не мутируется
assert "тип по label (Бухгалтерский баланс) не совпал с кодами: взят INCOME" in built.warnings
assert built.fully_recognized is False
def test_label_wins_over_mixed_codes_with_warning(self) -> None:
"""Коды обеих групп (шум OCR) — кодам не верим, тип по label."""
table = _table(
"Отчет о финансовых результатах",
*CLEAN_BALANCE["rows"],
_row(3, (0, "Выручка", 100), (1, "2110", 100), (2, "1", 100), (3, "2", 100)),
)
built = _build(table)
assert built.document.document_type == "INCOME"
assert any(w.startswith("тип по кодам (BALANCE) не совпал с label") for w in built.warnings)
assert built.fully_recognized is False
def test_other_when_nothing_known(self) -> None:
table = _table(
"Приложение",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "a", 100), (1, "5100", 100), (2, "1", 100), (3, "2", 100)),
_row(2, (0, "b", 100), (1, "5200", 100), (2, "1", 100), (3, "2", 100)),
)
built = _build(table)
assert built.document.document_type == "OTHER" and "тип не определён: OTHER" in built.warnings
def test_edition_2025_is_empty(self) -> None:
header = FileHeader("ООО", 100.0, "7700000000", 100.0, "тыс. руб.", None, None, False)
table = _table(
"Бухгалтерский баланс",
_row(0, (0, "Наименование показателя", 100), (1, "Код", 100), (2, "На 30 июня 2025 г.", 100)),
_row(1, (0, "Основные средства", 100), (1, "1150", 100), (2, "1", 100)),
)
built = _build(table, header)
assert built.edition_id == "fsbu4" and "редакция fsbu4 не заполнена" in built.warnings
assert built.document.lines[0].line_name == "Основные средства" # из OCR
assert built.document.lines[0].line_name_confidence == 100 # confidence ячейки наименования
assert built.fully_recognized is False
def test_edition_by_codes_when_no_dates(self) -> None:
header = FileHeader(None, None, None, None, None, None, None, False)
table = _table("Бухгалтерский баланс", _row(0, (1, "1150", 100), (0, "ОС", 100), (2, "1", 100)))
built = _build(table, header)
assert built.edition_id == "66n" and "редакция определена по кодам: 66n" in built.warnings
def test_unparsed_table_is_none(self) -> None:
assert (
build_document(_table("x", _row(0, (0, "a", 100), (1, "b", 100))), 0, "job.json", HEADER_2024, RULES)
is None
)
def test_missing_label_omits_form_name() -> None:
"""Контракт: `form_name` без `null` — при отсутствии label поле не задаётся, сборка не падает."""
table = {"rows": CLEAN_BALANCE["rows"]}
built = _build(table)
assert built.document.header.form_name is None
assert "form_name" not in built.document.to_wire()["header"]
def test_table_rows_not_list_is_none() -> None:
assert build_document({"label": {"form_name": "x"}, "rows": "oops"}, 0, "job.json", HEADER_2024, RULES) is None
REPEATED_HEADER = "повторная шапка изменила число колонок значений"
def _head(*dates: str) -> dict:
cells = [(0, "Наименование показателя", 100), (1, "Код", 100)]
cells += [(2 + i, text, 100) for i, text in enumerate(dates)]
return _row(0, *cells)
class TestRepeatedHeader:
"""R-3 (design #21): `columns` — по первой шапке, `values` каждой строки — по позиции этой колонки."""
def test_wider_first_header_pads_missing_values(self) -> None:
table = _table(
"Бухгалтерский баланс",
_head("На 31 декабря 2024 г.", "На 31 декабря 2023 г.", "На 31 декабря 2022 г."),
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100), (4, "3", 100)),
_row(
2,
(0, "Наименование показателя", 100),
(1, "Код", 100),
(2, "На 31 декабря 2024 г.", 100),
(3, "На 31 декабря 2023 г.", 100),
),
_row(3, (0, "НМА", 100), (1, "1110", 100), (2, "4", 100), (3, "5", 100)),
)
built = _build(table)
assert [line.line_code for line in built.document.lines] == ["1150", "1110"]
assert all(len(line.values) == 3 for line in built.document.lines)
last = built.document.lines[1].values[2]
assert (last.amount, last.string_value, last.confidence, last.is_recognized) == (None, None, 0, False)
assert built.document.lines[1].values[1].amount == Decimal("5")
assert built.warnings.count(REPEATED_HEADER) == 1 and built.fully_recognized is False
def test_narrower_first_header_drops_extra_indexes(self) -> None:
table = _table(
"Бухгалтерский баланс",
_head("На 31 декабря 2024 г.", "На 31 декабря 2023 г."),
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)),
_row(
2,
(0, "Наименование показателя", 100),
(1, "Код", 100),
(2, "На 31 декабря 2024 г.", 100),
(3, "На 31 декабря 2023 г.", 100),
(4, "На 31 декабря 2022 г.", 100),
),
_row(3, (0, "НМА", 100), (1, "1110", 100), (2, "4", 100), (3, "5", 100), (4, "6", 100)),
)
built = _build(table)
assert [c.column_index for c in built.document.columns] == [0, 1]
for line in built.document.lines:
assert [v.column_index for v in line.values] == [0, 1] # нет «сироты» с column_index = 2
assert built.document.lines[1].values[1].amount == Decimal("5")
assert built.warnings.count(REPEATED_HEADER) == 1 and built.fully_recognized is False
def test_same_layout_after_repeated_header_is_silent(self) -> None:
table = _table(
"Бухгалтерский баланс",
*CLEAN_BALANCE["rows"][:2],
_row(
5,
(0, "Наименование показателя", 100),
(1, "Код", 100),
(2, "На 31 декабря 2024 г.", 100),
(3, "На 31 декабря 2023 г.", 100),
),
_row(6, (0, "Итого по разделу I", 100), (1, "1100", 100), (2, "1", 100), (3, "2", 100)),
)
built = _build(table)
assert [line.line_code for line in built.document.lines] == ["1150", "1100"]
assert built.warnings == [] and built.fully_recognized is True
def test_latin_o_in_code_drops_row() -> None:
"""R-4 (design #22): лукалайки применяются только к датам — «11O0» не целое число, строка отбрасывается."""
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)),
_row(2, (0, "Итого", 100), (1, "11O0", 100), (2, "1", 100), (3, "2", 100)),
)
assert [line.line_code for line in _build(table).document.lines] == ["1150"]
class TestEditionWarnings:
def test_empty_edition_gives_single_warning(self) -> None:
"""R-5 (design #23): пустая fsbu4 — одно «не заполнена», без «код N из редакции 66n» по строкам."""
header = FileHeader("ООО", 100.0, "7700000000", 100.0, "тыс. руб.", None, None, False)
table = _table(
"Бухгалтерский баланс",
_head("На 30 июня 2025 г"),
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100)),
_row(2, (0, "НМА", 100), (1, "1110", 100), (2, "2", 100)),
)
built = _build(table, header)
assert built.edition_id == "fsbu4"
assert built.warnings == ["редакция fsbu4 не заполнена"]
assert not any("из редакции" in w for w in built.warnings)
def test_code_from_other_nonempty_edition_is_reported(self) -> None:
rules = OcrRules(
editions=[
Edition(id="old", valid_until=date(2024, 12, 31), lines={"1150": "Основные средства"}),
Edition(id="new", valid_from=date(2025, 1, 1), lines={"1110": "Нематериальные активы"}),
],
lookalikes=RULES.lookalikes,
labels=RULES.labels,
)
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)),
_row(2, (0, "НМА", 100), (1, "1110", 100), (2, "3", 100), (3, "4", 100)),
)
built = build_document(table, 0, "job.json", HEADER_2024, rules)
assert built is not None and built.edition_id == "old"
assert built.warnings == ["код 1110 из редакции new"]
assert built.document.lines[1].line_name == "НМА" and built.document.lines[1].line_code_confidence == 99
def test_table_without_value_columns() -> None:
"""R-6 (design #25): нет колонок значений — предупреждение и needs_check; values пустые."""
table = _table(
"Бухгалтерский баланс",
_row(0, (0, "Наименование показателя", 100), (1, "Код", 100)),
_row(1, (0, "ОС", 100), (1, "1150", 100)),
)
built = _build(table)
assert built.document.columns == [] and built.document.lines[0].values == []
assert "нет колонок значений" in built.warnings and built.fully_recognized is False
assert REPEATED_HEADER not in built.warnings
class TestHeaderConditions:
"""D-6: каждое отклонение шапки файла лишает документ статуса «всё распознано»."""
@pytest.mark.parametrize(
"changes",
[
{"inn_confidence": 90.0},
{"inn": None, "inn_confidence": None},
{"organization_confidence": 90.0},
{"organization_name": None, "organization_confidence": None},
{"report_date": None, "report_date_recognized": False},
{"report_date_recognized": False},
],
ids=["inn-low", "inn-none", "org-low", "org-none", "no-date", "date-not-recognized"],
)
def test_header_deviation_is_not_fully_recognized(self, changes: dict) -> None:
assert _build(CLEAN_BALANCE, replace(HEADER_2024, **changes)).fully_recognized is False
def test_baseline_is_fully_recognized(self) -> None:
assert _build(CLEAN_BALANCE, HEADER_2024).fully_recognized is True
def test_column_without_date_warning() -> None:
table = _table(
"Бухгалтерский баланс",
_head("На отчетную дату"),
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100)),
)
built = _build(table)
assert "колонка 0 без даты: На отчетную дату" in built.warnings
assert built.document.columns[0].period_date is None and built.fully_recognized is False
def test_edition_not_determined_warning() -> None:
header = FileHeader(None, None, None, None, None, None, None, False)
table = _table(
"Приложение",
_row(0, (0, "Наименование показателя", 100), (1, "Код", 100), (2, "На 1 января", 100)),
_row(1, (0, "a", 100), (1, "5100", 100), (2, "1", 100)),
_row(2, (0, "b", 100), (1, "5200", 100), (2, "2", 100)),
)
built = _build(table, header)
assert built.edition_id is None and "редакция не определена" in built.warnings
assert built.document.lines[0].line_name == "a" and built.fully_recognized is False
class TestKnownCodeConfidence:
"""R-2, ветка «код в выбранной редакции»: confidence ячейки, если текст чистый, иначе не выше 99."""
def test_clean_code_keeps_cell_confidence(self) -> None:
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 80), (1, "1150", 80), (2, "1", 100), (3, "2", 100)),
)
line = _build(table).document.lines[0]
assert line.line_code_confidence == 80 and line.line_name_confidence == 80
assert line.line_name == "Основные средства"
def test_dirty_code_is_capped_at_99(self) -> None:
"""«+1150» проходит `int()`, `clean_value` убирает «+» — текст не равен сырому, уверенность ≤ 99."""
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "+1150", 100), (2, "1", 100), (3, "2", 100)),
)
built = _build(table)
line = built.document.lines[0]
assert line.line_code == "1150" and line.line_name == "Основные средства"
assert line.line_code_confidence == 99 and line.line_name_confidence == 99
assert built.fully_recognized is False
class TestMoneyPrecision:
"""Решение №30: `Money` контракта — не больше двух знаков; ячейка с большим числом знаков не валит таблицу."""
@pytest.mark.parametrize("text", ["1,234", "0,125", "12.3456", "(1,234)"])
def test_more_than_two_decimals_is_not_an_amount(self, text: str) -> None:
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, text, 100), (3, "2", 100)),
)
built = _build(table)
value = built.document.lines[0].values[0]
assert value.amount is None and value.string_value == text and value.is_recognized is False
assert value.confidence == 100
assert built.fully_recognized is False
def test_neighbours_stay_intact_and_document_is_built(self) -> None:
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1,234", 100), (3, "56,78", 100)),
_row(2, (0, "НМА", 100), (1, "1110", 100), (2, "7", 100), (3, "8,5", 100)),
)
built = _build(table)
first, second = built.document.lines
assert (first.values[0].amount, first.values[0].string_value) == (None, "1,234")
assert (first.values[1].amount, first.values[1].is_recognized) == (Decimal("56.78"), True)
assert (second.values[0].amount, second.values[0].is_recognized) == (Decimal("7"), True)
assert (second.values[1].amount, second.values[1].is_recognized) == (Decimal("8.5"), True)
built.document.to_wire() # контрактная модель сериализуется без ValidationError
@pytest.mark.parametrize("text", ["1,23", "1,2", "0", "-5", "1 234,50"])
def test_up_to_two_decimals_is_kept(self, text: str) -> None:
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, text, 100), (3, "2", 100)),
)
value = _build(table).document.lines[0].values[0]
assert value.amount is not None and value.string_value is None and value.is_recognized is True
class TestConfidenceRange:
"""Решение №30: confidence ячеек приводится к [0, 100], NaN — в 0 (контракт требует 0..100)."""
@pytest.mark.parametrize(
("raw", "expected"), [(float("nan"), 0), (150, 100), (-5, 0), (float("inf"), 0), (float("-inf"), 0)]
)
def test_out_of_range_cell_confidence(self, raw: float, expected: float) -> None:
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "5", raw), (3, "2", 100)),
)
built = _build(table)
assert built.document.lines[0].values[0].confidence == expected
built.document.to_wire()
def test_header_with_nan_confidence_builds_document(self) -> None:
header = replace(HEADER_2024, inn_confidence=0.0, organization_confidence=0.0)
built = _build(CLEAN_BALANCE, header)
assert built.document.header.inn_confidence == 0 and built.fully_recognized is False
class TestSkippedRowsWarnings:
"""Решение №29: отброшенные строки — по одному табличному предупреждению каждого вида, needs_check."""
def test_both_warnings_and_not_fully_recognized(self) -> None:
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)),
_row(2, (0, "НМА", 100), (1, "1110 :", 100), (2, "3", 100), (3, "4", 100)),
_row(3, (0, "Мусор", 100), (1, "11x0", 100), (2, "3", 100), (3, "4", 100)),
_row(4, (0, "БАЛАНС", 100), (1, "", 100), (2, "5", 100), (3, "6", 100)),
_row(5, (0, "Итого", 100), (1, "", 100), (2, "7", 100), (3, "", 100)),
_row(6, (0, "в том числе:", 100), (1, "", 100), (2, "", 100), (3, "-", 100)), # без сумм — без счёта
)
built = _build(table)
assert [line.line_code for line in built.document.lines] == ["1150"]
assert built.warnings == [
"строк с нечисловым кодом пропущено: 2",
"строк без кода с суммами пропущено: 2",
]
assert built.fully_recognized is False
def test_group_titles_without_amounts_are_silent(self) -> None:
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "I. Внеоборотные активы", 100), (1, "", 100)),
_row(2, (0, "в том числе:", 100), (1, "", 100), (2, "", 100), (3, "-", 100)),
_row(3, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)),
)
built = _build(table)
assert built.warnings == [] and built.fully_recognized is True
def test_aep_like_table_with_synthetic_header(self) -> None:
"""Как в aep_3: пустой код у «Выручка» (мусор «211(1 |» — в колонке значения) и мусорный код «2.10»."""
table = _table(
"Отчет о финансовых результатах",
_row(0, (0, "Выручка", 100), (1, "", 100), (2, "211(1 |", 71), (3, "8096738", 100)),
_row(1, (0, "Себестоимость продаж", 100), (1, "2120", 100), (2, "(5)", 100), (3, "(6)", 100)),
_row(2, (0, "Доходы от участия", 100), (1, "2.10", 100), (2, "5060", 100), (3, "*", 100)),
_row(3, (0, "Валовая прибыль", 100), (1, "2100", 100), (2, "1", 100), (3, "2", 100)),
)
built = _build(table)
assert [line.line_code for line in built.document.lines] == ["2120", "2100"]
assert "строк с нечисловым кодом пропущено: 1" in built.warnings
assert "строк без кода с суммами пропущено: 1" in built.warnings
assert "шапка синтезирована: колонки без дат" in built.warnings
def test_merged_codes_are_split_with_warning(self) -> None:
"""OQ-156: склеенная строка ИС «1500\\n1700» даёт две строки документа и предупреждение таблицы."""
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)),
_row(2, (0, "Итого V\nБАЛАНС", 100), (1, "1500\n1700", 100), (2, "3\n4", 100), (3, "5\n6", 100)),
)
built = _build(table)
assert [line.line_code for line in built.document.lines] == ["1150", "1500", "1700"]
assert "строк с несколькими кодами разлеплено: 1" in built.warnings
assert built.fully_recognized is False
@pytest.mark.fixtures
class TestSkippedRowsRealFixtures:
@pytest.mark.parametrize(
("name", "index", "expected"),
[
("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_warnings_on_real_tables(self, name: str, index: int, expected: list[str]) -> None:
real = Path(API_DIR).parent.parent / "fixtures" / "ocr" / name
if not real.is_file():
pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace")
data_ocr = json.loads(real.read_text(encoding="utf-8"))["data_ocr"]
built = build_document(data_ocr["table_structure"][index], index, "job.json", HEADER_2024, RULES)
assert built is not None
skipped = [w for w in built.warnings if w.startswith(("строк с нечисловым", "строк без кода"))]
assert skipped == expected
assert built.fully_recognized is False
@pytest.mark.parametrize(
"label",
[
"на 2024" + " " * 4000 + "b",
"на 2024" + "\n" * 4000 + "b",
"на 2024" + "О" * 5000,
], # «на » — признак колонки значений
ids=["spaces", "nl", "look"],
)
def test_long_column_label_is_fast_and_has_no_date(label: str) -> None:
"""Решение №31: подпись колонки длиннее даты не разбирается и не проходит `fix_lookalikes` (нет квадратичного
роста через `_columns`)."""
table = _table(
"Бухгалтерский баланс",
_head(label),
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100)),
)
start = time.perf_counter()
built = _build(table)
assert time.perf_counter() - start < 1.0
assert built.document.columns[0].period_date is None and built.fully_recognized is False
assert any(w.startswith("колонка 0 без даты") for w in built.warnings)