Andrey Epifancev c780dfd858 fix(statements): округление итогов не портит статус (OQ-138), слабые продолжения только сразу после формы и той же формы, подстроки по порядку строк
- OQ-138 (решение владельца 06.10.2026): единицы шапки верны; жёсткое расхождение до rounding_tolerance_percent = 0,001 %
  от наибольшего по модулю числа соотношения — округление итогов: soft_relations_failed + предупреждение, статус не
  меняется. НПЗ, ОФР стр. 1 Унагранде Компани и Групп теперь done.
- N-1/N-2: лист с 1–2 кодами — продолжение только сразу после листа формы, коды из диапазона формы открытого документа;
  присоединение видно в предупреждениях. N-3: негативные тесты BookParser и StatementParser.
- M-9: пятизначная подстрока относится к ближайшей четырёхзначной строке выше с теми же тремя цифрами (12310 под 1230).
- N-4: на продолжении без шапки колонка «Пояснения» узнаётся по коротким значениям и не даёт line_name.
- T7.1: ответ с ошибкой листа ОСВ называет неразобранные листы форм; T7.2: metrics.rows для форм — в спеке.
make check-full: 2217 passed, интеграция 10 passed; golden 138/138 (снимки — arm-ka-fixtures fix/20-golden).

Refs #20

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
2026-10-06 23:14:21 +04:00

420 lines
19 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.

# API/tests/unit/parser/statements/test_statement.py
"""Конвейер форм: блоки, склейка продолжений, статус документа (design D-8; spec «Склейка листов-продолжений и статус
документа»). Книги собираются `gridbuilder`, вид листа задаётся явно, как это делает `BookParser`."""
from decimal import Decimal
import pytest
from gridbuilder import at, sheet_from_rows
from src.parser.deadline import Deadline
from src.parser.detect import SheetKind
from src.parser.rules import load_rules
from src.parser.statements.statement import CONTINUATION_WARNING, StatementParser
TWO_COLUMNS = TWO_DOCUMENTS = 2
@pytest.fixture(scope="module")
def parser() -> StatementParser:
return StatementParser(load_rules())
def deadline() -> Deadline:
return Deadline(120)
BALANCE_PAGE_1 = [
["Бухгалтерский баланс"],
["на 31 декабря 2024 г."],
["Форма по ОКУД", None, "0710001"],
["Организация", None, 'ООО "Пример"', "ИНН", "7700000000"],
["Единица измерения:", None, "тыс. руб."],
["Наименование показателя", "Код", "На 31 декабря 2024 г.", "На 31 декабря 2023 г."],
["Основные средства", "1150", 100, 90],
["Итого по разделу I", "1100", 100, 90],
["Запасы", "1210", 50, 40],
["Итого по разделу II", "1200", 50, 40],
["БАЛАНС", "1600", 150, 130],
]
BALANCE_PAGE_2 = [
[*at({6: "Форма 0710001 с.2"})],
["Наименование показателя", "Код", "На 31 декабря 2024 г.", "На 31 декабря 2023 г."],
["Уставный капитал", "1310", 150, 130],
["Итого по разделу III", "1300", 150, 130],
["БАЛАНС", "1700", 150, 130],
]
INCOME = [
["Отчет о финансовых результатах"],
["за 2024 г."],
["Организация", None, 'ООО "Пример"'],
["Единица измерения:", None, "тыс. руб."],
["Наименование показателя", "Код", "За 2024 г."],
["Выручка", "2110", 1000],
["Себестоимость продаж", "2120", -600],
["Валовая прибыль (убыток)", "2100", 400],
["Коммерческие расходы", "2210", -50],
["Управленческие расходы", "2220", -50],
["Прибыль (убыток) от продаж", "2200", 300],
["Проценты к уплате", "2330", -100],
["Прибыль (убыток) до налогообложения", "2300", 200],
["Налог на прибыль", "2410", -40],
["Чистая прибыль (убыток)", "2400", 160],
]
# Полный набор: 2200 = 2100 − 2210 − 2220 и 2300 = 2200 − 2330 сходятся через abs_terms (знаки 1С), 2400 — мягкое
NPZ_CONTINUATION = [
["в том числе:"],
["Биологические активы", "11901", 0, 0],
["Расходы будущих периодов", "11903", 0, 0],
["Прочие внеоборотные активы", "1190", 0, 0],
]
def test_full_balance_over_two_sheets_is_done(parser: StatementParser) -> None:
sheets = [
(sheet_from_rows(BALANCE_PAGE_1, "ББ1"), SheetKind.STATEMENT),
(sheet_from_rows(BALANCE_PAGE_2, "ББ2"), SheetKind.STATEMENT_CONTINUATION),
]
documents, warnings, ok = parser.parse_sheets(sheets, deadline())
assert ok and len(documents) == 1
document = documents[0]
assert document.document_type == "BALANCE" and document.locator == "sheet:ББ1,ББ2"
assert [line.line_code for line in document.lines] == [
"1150",
"1100",
"1210",
"1200",
"1600",
"1310",
"1300",
"1700",
]
assert document.checks is not None and document.checks.relations_failed == []
assert document.header.inn == "7700000000" and document.header.units == "тыс. руб."
assert len(document.columns) == TWO_COLUMNS and document.columns[0].label == "На 31 декабря 2024 г."
def test_page_one_alone_fails_total_relation(parser: StatementParser) -> None:
documents, warnings, ok = parser.parse_sheets([(sheet_from_rows(BALANCE_PAGE_1), SheetKind.STATEMENT)], deadline())
assert not ok
assert [item.relation for item in documents[0].checks.relations_failed] == ["1600 = 1700", "1600 = 1700"]
assert any("1600 = 1700" in warning for warning in warnings)
def test_lonely_continuation_is_needs_check(parser: StatementParser) -> None:
documents, warnings, ok = parser.parse_sheets(
[(sheet_from_rows(BALANCE_PAGE_2, "с.2"), SheetKind.STATEMENT_CONTINUATION)], deadline()
)
assert not ok and documents[0].document_type == "BALANCE"
assert documents[0].header.organization_name is None and documents[0].header.form_name == "Форма 0710001 с.2"
assert [line.line_code for line in documents[0].lines] == ["1310", "1300", "1700"]
assert f"sheet=с.2 {CONTINUATION_WARNING}" in warnings
def test_continuation_joins_last_open_document(parser: StatementParser) -> None:
"""Review Focus 4: продолжение после ОФР относится к ОФР, а не к балансу."""
income_tail = [
["Налог", "2450", 0],
["Прочее", "2460", 0],
["Совокупный финансовый результат периода", "2500", 160],
]
sheets = [
(sheet_from_rows(BALANCE_PAGE_1, "ББ"), SheetKind.STATEMENT),
(sheet_from_rows(INCOME, "ОФР"), SheetKind.STATEMENT),
(sheet_from_rows(income_tail, "ОФР_2"), SheetKind.STATEMENT_CONTINUATION),
]
documents, _, _ = parser.parse_sheets(sheets, deadline())
assert [document.document_type for document in documents] == ["BALANCE", "INCOME"]
assert documents[1].locator == "sheet:ОФР,ОФР_2" and [line.line_code for line in documents[1].lines][-2:] == [
"2460",
"2500",
]
def test_continuation_without_header_maps_value_columns_by_order(parser: StatementParser) -> None:
sheets = [
(sheet_from_rows(BALANCE_PAGE_1, "ББ"), SheetKind.STATEMENT),
(sheet_from_rows(NPZ_CONTINUATION, "ББ_2"), SheetKind.STATEMENT_CONTINUATION),
]
documents, _, _ = parser.parse_sheets(sheets, deadline())
tail = documents[0].lines[-1]
assert tail.line_code == "1190" and [value.column_index for value in tail.values] == [0, 1]
assert [line.line_code for line in documents[0].lines][-3:] == ["11901", "11903", "1190"]
def test_continuation_with_other_column_count_opens_new_document(parser: StatementParser) -> None:
three_columns = [
["Форма 0710001 с.2"],
["Наименование", "Код", "На 31.12.2024", "На 31.12.2023", "На 31.12.2022"],
["Уставный капитал", "1310", 1, 1, 1],
["Добавочный капитал", "1350", 1, 1, 1],
["Итого по разделу III", "1300", 2, 2, 2],
]
sheets = [
(sheet_from_rows(BALANCE_PAGE_1, "ББ"), SheetKind.STATEMENT),
(sheet_from_rows(three_columns, "с.2"), SheetKind.STATEMENT_CONTINUATION),
]
documents, warnings, ok = parser.parse_sheets(sheets, deadline())
assert len(documents) == TWO_DOCUMENTS and not ok and any(CONTINUATION_WARNING in warning for warning in warnings)
def test_two_forms_on_one_sheet(parser: StatementParser) -> None:
sheet = sheet_from_rows([*BALANCE_PAGE_1, *BALANCE_PAGE_2[2:], [], *INCOME], "Отчетность")
documents, _, ok = parser.parse_sheets([(sheet, SheetKind.STATEMENT)], deadline())
assert [document.document_type for document in documents] == ["BALANCE", "INCOME"] and ok
assert all(document.locator == "sheet:Отчетность" for document in documents)
assert "2110" not in [line.line_code for line in documents[0].lines]
def test_disclosure_only_book_is_done(parser: StatementParser) -> None:
"""Review Focus 5."""
sheet = sheet_from_rows(
[
["Пояснения к бухгалтерскому балансу и отчету о финансовых результатах"],
["1. Нематериальные активы"],
["текст"],
],
"Пояснения",
)
documents, warnings, ok = parser.parse_sheets([(sheet, SheetKind.STATEMENT)], deadline())
assert (
ok and documents[0].document_type == "DISCLOSURE" and documents[0].lines == [] and documents[0].checks is None
)
def test_no_code_column_is_needs_check_with_warning(parser: StatementParser) -> None:
sheet = sheet_from_rows(
[
["Бухгалтерский баланс"],
["Организация", "ООО Х"],
["Показатель", "На 31.12.2024"],
["Основные средства", 100],
["Запасы", 50],
["БАЛАНС", 150],
],
"ББ",
)
documents, warnings, ok = parser.parse_sheets([(sheet, SheetKind.STATEMENT)], deadline())
assert not ok and documents[0].lines == [] and documents[0].columns == []
assert "sheet=ББ колонка кодов не найдена" in warnings
def test_duplicate_code_is_needs_check(parser: StatementParser) -> None:
rows = [*BALANCE_PAGE_1, ["Запасы", "1210", 1, 1]]
documents, _, ok = parser.parse_sheets([(sheet_from_rows(rows), SheetKind.STATEMENT)], deadline())
assert not ok and documents[0].checks.duplicate_line_codes == ["1210"]
def test_template_without_sums_is_needs_check(parser: StatementParser) -> None:
rows = [
["Бухгалтерский баланс"],
["Наименование показателя", "Код", "На 31 декабря 2024 г."],
["Основные средства", "1150"],
["Запасы", "1210"],
["БАЛАНС", "1600"],
]
documents, _, ok = parser.parse_sheets([(sheet_from_rows(rows), SheetKind.STATEMENT)], deadline())
assert (
not ok
and documents[0].checks.relations_failed == []
and all(value.amount is None for line in documents[0].lines for value in line.values)
)
def test_duplicate_codes_are_unique_in_order_of_first_duplicate(parser: StatementParser) -> None:
"""spec: `duplicate_line_codes` — уникальные коды; повтор на каждое вхождение не плодит записей (T11-ревью)."""
rows = [*BALANCE_PAGE_1, ["Запасы", "1210", 1, 1], ["Запасы", "1210", 2, 2], ["Основные средства", "1150", 1, 1]]
documents, _, ok = parser.parse_sheets([(sheet_from_rows(rows), SheetKind.STATEMENT)], deadline())
assert not ok and documents[0].checks.duplicate_line_codes == ["1210", "1150"]
def test_code_repeated_on_continuation_is_duplicate_and_first_value_kept(parser: StatementParser) -> None:
first = (sheet_from_rows(BALANCE_PAGE_1, "ББ"), SheetKind.STATEMENT)
again = [
["Наименование показателя", "Код", "На 31 декабря 2024 г.", "На 31 декабря 2023 г."],
["Запасы", "1210", 7, 7],
["Итого", "1200", 50, 40],
["Прочее", "1230", 0, 0],
]
documents, _, ok = parser.parse_sheets(
[first, (sheet_from_rows(again, "ББ3"), SheetKind.STATEMENT_CONTINUATION)], deadline()
)
assert not ok and documents[0].checks.duplicate_line_codes == ["1210", "1200"]
assert [line.values[0].amount for line in documents[0].lines if line.line_code == "1210"] == [Decimal(50)]
def test_headerless_lonely_continuation_has_empty_header(parser: StatementParser) -> None:
documents, _, ok = parser.parse_sheets(
[(sheet_from_rows(NPZ_CONTINUATION, "хвост"), SheetKind.STATEMENT_CONTINUATION)], deadline()
)
assert not ok and documents[0].document_type == "OTHER" and documents[0].header.form_name is None
def test_continuation_without_code_column_is_dropped_and_not_ok(parser: StatementParser) -> None:
"""Потерянный лист нельзя оставлять статусом `done`."""
lost = [["Форма 0710001 с.2"], ["Код", "На 31.12.2024"], ["1700", 1]]
sheets = [
(sheet_from_rows(BALANCE_PAGE_1, "ББ"), SheetKind.STATEMENT),
(sheet_from_rows(BALANCE_PAGE_2, "ББ2"), SheetKind.STATEMENT_CONTINUATION),
(sheet_from_rows(lost, "с.3"), SheetKind.STATEMENT_CONTINUATION),
]
documents, warnings, ok = parser.parse_sheets(sheets, deadline())
assert (
not ok
and len(documents) == 1
and documents[0].checks.relations_failed == []
and "sheet=с.3 колонка кодов не найдена" in warnings
)
def test_block_with_okud_but_without_title_has_no_form_name(parser: StatementParser) -> None:
"""Блок, открытый только кодом ОКУД: `form_name` не передаётся (в контракте нет null)."""
rows = [["Форма по ОКУД", None, "0710001"], *BALANCE_PAGE_1[3:]]
documents, _, _ = parser.parse_sheets([(sheet_from_rows(rows), SheetKind.STATEMENT)], deadline())
assert documents[0].header.form_name is None and documents[0].header.okud == "0710001"
def _fractional_sheet(name: str, units: bool, rows: list[list[object]]) -> tuple:
head = [["Бухгалтерский баланс"], ["Организация", None, 'ООО "Пример"']]
if units:
head.append(["Единица измерения:", None, "тыс. руб."])
return sheet_from_rows([*head, ["Наименование показателя", "Код", "На 31 декабря 2024 г."], *rows], name)
def test_fractional_warning_is_one_per_document_over_all_sheets(parser: StatementParser) -> None:
"""M-11: две страницы документа — одно предупреждение с суммарными счётчиками."""
first = _fractional_sheet(
"ББ1", True, [["Основные средства", "1150", Decimal("100.5")], ["Итого", "1100", 100], ["Запасы", "1210", 7]]
)
second = sheet_from_rows(
[
["Форма 0710001 с.2"],
["Наименование показателя", "Код", "На 31 декабря 2024 г."],
["Уставный капитал", "1310", Decimal("50.25")],
["Итого по разделу III", "1300", 50],
["БАЛАНС", "1700", 50],
],
"ББ2",
)
_, warnings, _ = parser.parse_sheets(
[(first, SheetKind.STATEMENT), (second, SheetKind.STATEMENT_CONTINUATION)], deadline()
)
fractional = [text for text in warnings if "дробной частью" in text]
assert fractional == ["sheet=ББ1 значений с дробной частью при единице тыс. руб.: 2 из 6"]
def test_no_fractional_warning_without_thousands_units(parser: StatementParser) -> None:
sheet = _fractional_sheet("ББ", False, [["Основные средства", "1150", Decimal("100.5")], ["Итого", "1100", 100]])
_, warnings, _ = parser.parse_sheets([(sheet, SheetKind.STATEMENT)], deadline())
assert not any("дробной частью" in text for text in warnings)
def test_single_code_sheet_after_a_form_joins_as_continuation(parser: StatementParser) -> None:
"""O-9 (I-1): лист с одной кодовой строкой (меньше min_codes), идущий после формы, присоединяется."""
single = sheet_from_rows([["Результат прочих операций", "2520", 0]], "ОФР_2")
documents, warnings, _ = parser.parse_sheets(
[(sheet_from_rows(INCOME, "ОФР"), SheetKind.STATEMENT), (single, SheetKind.STATEMENT_CONTINUATION)], deadline()
)
assert len(documents) == 1
assert documents[0].locator == "sheet:ОФР,ОФР_2"
assert [line.line_code for line in documents[0].lines][-1] == "2520"
def test_single_code_sheet_that_cannot_join_is_dropped_with_exact_warning(parser: StatementParser) -> None:
"""O-9: число колонок сумм не совпало — лист не присоединён, книга needs_check, предупреждение точное."""
single = sheet_from_rows([["Чистая прибыль (убыток)", "2400", 160, 150]], "ОФР_2")
documents, warnings, ok = parser.parse_sheets(
[(sheet_from_rows(INCOME, "ОФР"), SheetKind.STATEMENT), (single, SheetKind.STATEMENT_CONTINUATION)], deadline()
)
assert not ok and len(documents) == 1
assert (
"sheet=ОФР_2 лист с кодами форм (1 < 3) не присоединён: "
"число колонок сумм 2 не совпадает с открытым документом (1)" in warnings
)
def test_continuation_of_another_form_type_does_not_join(parser: StatementParser) -> None:
"""M-10: маркер «Форма 0710001 с.2» (ББ) не присоединяет лист к открытому ОФР."""
income = sheet_from_rows(INCOME, "ОФР")
balance_page_2 = sheet_from_rows(
[
["Форма 0710001 с.2"],
["Наименование показателя", "Код", "За 2024 г."],
["Уставный капитал", "1310", 150],
["Итого по разделу III", "1300", 150],
["БАЛАНС", "1700", 150],
],
"ББ2",
)
documents, warnings, ok = parser.parse_sheets(
[(income, SheetKind.STATEMENT), (balance_page_2, SheetKind.STATEMENT_CONTINUATION)], deadline()
)
assert [document.document_type for document in documents] == ["INCOME", "BALANCE"]
assert not ok and CONTINUATION_WARNING in " ".join(warnings)
# --- N-1: слабое продолжение (одна кодовая строка) — коды той же формы, присоединение в предупреждениях ---
def test_single_code_sheet_join_is_reported(parser: StatementParser) -> None:
single = sheet_from_rows([["Результат прочих операций", "2520", 0]], "ОФР_2")
_, warnings, _ = parser.parse_sheets(
[(sheet_from_rows(INCOME, "ОФР"), SheetKind.STATEMENT), (single, SheetKind.STATEMENT_CONTINUATION)], deadline()
)
assert "sheet=ОФР_2 присоединён к документу как продолжение по 1 кодовой строке" in warnings
def test_single_code_sheet_of_another_form_does_not_join(parser: StatementParser) -> None:
"""Лист «Справка» с кодом баланса после ОФР — не продолжение ОФР, даже при совпавшем числе колонок."""
single = sheet_from_rows([["Основные средства", "1150", 0]], "Справка")
documents, warnings, ok = parser.parse_sheets(
[(sheet_from_rows(INCOME, "ОФР"), SheetKind.STATEMENT), (single, SheetKind.STATEMENT_CONTINUATION)], deadline()
)
assert not ok and len(documents) == 1 and "1150" not in [line.line_code for line in documents[0].lines]
assert any(
text.startswith("sheet=Справка лист с кодами форм (1 < 3) не присоединён: коды 1150 не из формы INCOME")
for text in warnings
)