# 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"