- 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
420 lines
19 KiB
Python
420 lines
19 KiB
Python
# 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
|
||
)
|