"""Контрольные суммы §1.4 (spec «Контрольные суммы определяют статус», `05-domain-rules.md` §1.4, R-13, R-20). Строки собираются напрямую (`ClassifiedRow` над `BodyRow`): проверки — чистые функции над деревом строк, им не нужна книга. Номера строк произвольные; суммы — `Decimal` или `None` (пусто = 0). """ from decimal import Decimal import pytest from src.contract.common import DataType from src.parser.checks import TOLERANCE, balance_identity, parent_sums, run_checks, total_matches_root from src.parser.classify import ClassifiedRow, RowGroupType from src.parser.hierarchy import BodyRow from src.parser.rules import OsvCheckRules, Pattern Sums = tuple[str | None, str | None, str | None, str | None, str | None, str | None] EMPTY: Sums = (None, None, None, None, None, None) def row( # noqa: PLR0913 - строка теста описывается полностью одним вызовом number: int, sums: Sums, *, level: int = 0, parent: int | None = None, group_type: RowGroupType = "COUNTERPARTY", data_type: DataType | None = "BU", text: str = "строка", code: str | None = "60", indicator: bool = False, is_period: bool = False, ) -> ClassifiedRow: body = BodyRow( row=number, text=text, is_account=group_type == "ACCOUNT", account_rank=1 if group_type == "ACCOUNT" else None, bold=False, indent=0, outline=level, sums=tuple(None if value is None else Decimal(value) for value in sums), ) return ClassifiedRow( body=body, level=level, parent=parent, group_type=group_type, group_name=None, account_code_raw=code, counterparty_name=None, counterparty_inn=None, data_type=data_type, extra=None, indicator=indicator, is_period=is_period, ) def account(number: int, sums: Sums) -> ClassifiedRow: return row(number, sums, group_type="ACCOUNT", text="60") def total(number: int, sums: Sums) -> ClassifiedRow: return row(number, sums, group_type="TOTAL", text="Итого") # Корень 60 с двумя контрагентами, «Итого» равно корню; тождество выполняется во всех строках ROOT: Sums = ("100", None, "50", "30", "120", None) CHILD_A: Sums = ("60", None, "20", "10", "70", None) CHILD_B: Sums = ("40", None, "30", "20", "50", None) def tree() -> list[ClassifiedRow]: return [ account(7, ROOT), row(8, CHILD_A, level=1, parent=7), row(9, CHILD_B, level=1, parent=7), total(10, ROOT), ] def test_tolerance_is_one_kopeck() -> None: assert Decimal("0.01") == TOLERANCE def test_consistent_tree_has_no_violations() -> None: checks, notes = run_checks(tree()) warnings = [note.message for note in notes] assert checks.total_row_matches_root is True assert checks.balance_identity_violations == [] assert checks.parent_sum_violations == [] assert warnings == [] def test_balance_identity_treats_null_as_zero() -> None: rows = [ row(1, (None, None, None, None, None, None)), row(2, (None, "5", None, None, None, "5")), # только кредит: −5 = −5 row(3, ("10", None, None, "10", None, None)), # 10 − 10 = 0 при пустом конце ] assert balance_identity(rows) == [] def test_balance_identity_reports_row_numbers_beyond_tolerance() -> None: rows = [ row(1, ("100", None, "50", "30", "120.01", None)), # в пределах ±0.01 row(2, ("100", None, "50", "30", "121", None)), # конец увеличен на 1 row(3, (None, None, None, None, None, "0.02")), ] assert balance_identity(rows) == [2, 3] def test_parent_differs_from_sum_of_children() -> None: rows = tree() rows[2] = row(9, ("40", None, "30", "20", "51", None), level=1, parent=7) assert parent_sums(rows) == [7] def test_parent_sum_is_checked_per_column_with_tolerance() -> None: rows = [ account(7, ("100", None, "50", "30", "120", None)), row(8, ("99.995", None, "50", "30", "119.995", None), level=1, parent=7), ] assert parent_sums(rows) == [] def test_collapsed_parent_balance_is_compared_by_net() -> None: """Счёт со свёрнутым сальдо (одна сторона), субсчета — с развёрнутым: сверка сальдо по нетто (как в 1С на всех реальных файлах с субсчетами разных сторон); обороты — по колонкам. """ rows = [ account(12, ("16", None, "134", "111", "39", None)), row(13, ("40", None, "131", "111", "60", None), level=1, parent=12, group_type="ACCOUNT"), row(22, (None, "24", "3", None, None, "21"), level=1, parent=12, group_type="ACCOUNT"), ] assert parent_sums(rows) == [] def test_collapsed_parent_with_wrong_net_is_violation() -> None: rows = [ account(12, ("17", None, "134", "111", "39", None)), row(13, ("40", None, "131", "111", "60", None), level=1, parent=12, group_type="ACCOUNT"), row(22, (None, "24", "3", None, None, "21"), level=1, parent=12, group_type="ACCOUNT"), ] assert parent_sums(rows) == [12] def test_collapsed_parent_on_the_opposite_side_is_violation() -> None: """T9.1 (R-21): свёрнутое сальдо родителя на противоположной стороне (16 Кт при нетто детей +16 Дт) — нарушение: нетто сравнивается со знаком, совпадения модуля мало.""" rows = [ account(12, (None, "16", "134", "111", "39", None)), row(13, ("40", None, "131", "111", "60", None), level=1, parent=12, group_type="ACCOUNT"), row(22, (None, "24", "3", None, None, "21"), level=1, parent=12, group_type="ACCOUNT"), ] assert parent_sums(rows) == [12] def test_zero_parent_with_children_netting_to_zero_is_accepted() -> None: """T9.1 (R-21, осознанное поведение): родитель (0, 0) при детях (X Дт, X Кт) — без нарушения: нулевое сальдо родителя — свёрнутое (не больше одной ненулевой стороны), нетто детей X − X = 0. Остаточный риск описан в спеке (§1.4): потерянная пара сальдо у родителя этой проверкой не ловится.""" rows = [ account(12, ("0", "0", "134", "111", "23", None)), row(13, ("50", None, "131", "111", "70", None), level=1, parent=12, group_type="ACCOUNT"), row(22, (None, "50", "3", None, None, "47"), level=1, parent=12, group_type="ACCOUNT"), ] assert balance_identity(rows) == [] assert parent_sums(rows) == [] def test_expanded_parent_balance_must_match_by_columns() -> None: """Родитель с обеими сторонами сальдо — развёрнутое сальдо: совпадение одного нетто недостаточно.""" rows = [ account(12, ("41", "25", "134", "111", "60", "21")), row(13, ("40", None, "131", "111", "60", None), level=1, parent=12, group_type="ACCOUNT"), row(22, (None, "24", "3", None, None, "21"), level=1, parent=12, group_type="ACCOUNT"), ] assert parent_sums(rows) == [12] def netted_child_tree(parent_opening: tuple[str, str] = ("130", "70"), parent_turnover: tuple[str, str] = ("10", "10")): """Развёрнутый счёт 12 над свёрнутым субсчётом 13 (нетто 60 Дт), чьи внуки 14/15 развёрнуты (90 Дт / 30 Кт): 1С схлопнула Дт/Кт субсчёта в нетто, в родителе они остались развёрнутыми (AURORA-1863).""" opening_debit, opening_credit = parent_opening turnover_debit, turnover_credit = parent_turnover return [ account(12, (opening_debit, opening_credit, turnover_debit, turnover_credit, opening_debit, opening_credit)), row(13, ("60", None, "10", "10", "60", None), level=1, parent=12, group_type="ACCOUNT"), row(14, ("90", None, "10", None, "90", None), level=2, parent=13, group_type="ACCOUNT"), row(15, (None, "30", None, "10", None, "30"), level=2, parent=13, group_type="ACCOUNT"), ] def test_expanded_parent_over_netted_child_is_compared_by_net() -> None: """AURORA-1863: у ребёнка сальдо свёрнуто относительно его детей (прошёл только по нетто) — развёрнутый родитель над ним тоже сверяется по нетто.""" assert parent_sums(netted_child_tree()) == [] def test_expanded_parent_over_netted_child_with_wrong_net_is_violation() -> None: assert parent_sums(netted_child_tree(parent_opening=("131", "70"))) == [12] def test_expanded_parent_over_netted_child_turnovers_still_by_columns() -> None: assert parent_sums(netted_child_tree(parent_turnover=("11", "11"))) == [12] def test_expanded_parent_without_netted_child_stays_violation_on_rounding_gap() -> None: """Узость правила: расхождение 0.05 на обеих сторонах при равном нетто у развёрнутого родителя без свёрнутого ребёнка остаётся нарушением.""" rows = [ account(12, ("9977809.90", "8829025.95", None, None, "9977809.90", "8829025.95")), row(13, ("9977809.85", None, None, None, "9977809.85", None), level=1, parent=12, group_type="ACCOUNT"), row(14, (None, "8829025.90", None, None, None, "8829025.90"), level=1, parent=12, group_type="ACCOUNT"), ] assert parent_sums(rows) == [12] def test_netted_flag_does_not_pass_through_column_matched_level() -> None: """Признак передаёт только строка, сама сошедшаяся с детьми по нетто (непрерывная цепочка нетто-строк): счёт 11 развёрнут, субсчёт 12 сошёлся по колонкам (над свёрнутым 13) — признака у 12 нет, 11 сверяется по колонкам.""" rows = [ row(11, ("300", "240", "10", "10", "300", "240"), group_type="ACCOUNT", text="60"), row(12, ("130", "70", "10", "10", "130", "70"), level=1, parent=11, group_type="ACCOUNT"), row(13, ("60", None, "10", "10", "60", None), level=2, parent=12, group_type="ACCOUNT"), row(14, ("90", None, "10", None, "90", None), level=3, parent=13, group_type="ACCOUNT"), row(15, (None, "30", None, "10", None, "30"), level=3, parent=13, group_type="ACCOUNT"), row(16, ("70", "70", "0", "0", "70", "70"), level=2, parent=12, group_type="ACCOUNT"), ] assert parent_sums(rows) == [11] def test_netted_flag_continues_through_chain_of_net_rows() -> None: """Цепочка нетто-строк: 12 сошёлся с 13 по нетто, 11 над ним — тоже по нетто.""" rows = [ row(11, ("260", "200", "10", "10", "260", "200"), group_type="ACCOUNT", text="60"), row(12, ("130", "70", "10", "10", "130", "70"), level=1, parent=11, group_type="ACCOUNT"), row(13, ("60", None, "10", "10", "60", None), level=2, parent=12, group_type="ACCOUNT"), row(14, ("90", None, "10", None, "90", None), level=3, parent=13, group_type="ACCOUNT"), row(15, (None, "30", None, "10", None, "30"), level=3, parent=13, group_type="ACCOUNT"), ] assert parent_sums(rows) == [] def test_net_child_allows_net_on_both_balance_sides() -> None: """R-9: ребёнок свёрнут в нетто по сальдо начала (на конец сошёлся по колонкам), а развёрнутый родитель на конец расходится с ним симметрично: схлопнутую пару 1С не показывает ни в начале, ни в конце — нетто на обе стороны.""" rows = [ row(12, ("130", "70", "10", "10", "130", "70"), group_type="ACCOUNT"), row(13, ("60", None, "10", "10", "60", None), level=1, parent=12, group_type="ACCOUNT"), row(14, ("90", None, "10", None, "60", None), level=2, parent=13, group_type="ACCOUNT"), row(15, (None, "30", None, "10", None, None), level=2, parent=13, group_type="ACCOUNT"), ] assert parent_sums(rows) == [] def period_tree( opening: str = "100", turnover: str = "30", closing: str = "70", *, second_is_period: bool = True ) -> list[ClassifiedRow]: """Контрагент 12 с помесячными строками «Период» 13 (сальдо 100 → 90, оборот Кт 10) и 14 (90 → 70, Кт 20): сальдо родителя — начало первого периода и конец последнего, обороты — сумма (AURORA-1823).""" return [ row(12, (opening, None, None, turnover, closing, None), level=2, parent=10, group_type="COUNTERPARTY"), row(13, ("100", None, None, "10", "90", None), level=3, parent=12, group_type="OTHER", is_period=True), row( 14, ("90", None, None, "20", "70", None), level=3, parent=12, group_type="OTHER", is_period=second_is_period ), ] def test_period_children_are_chained_not_summed() -> None: assert parent_sums(period_tree()) == [] @pytest.mark.parametrize( "broken", [ {"turnover": "31"}, # ошибка оборота периода {"opening": "101"}, # сальдо начала ≠ начало первого периода {"closing": "71"}, # сальдо конца ≠ конец последнего периода ], ) def test_period_children_chain_violations(broken: dict[str, str]) -> None: assert parent_sums(period_tree(**broken)) == [12] def test_period_chain_gap_in_the_middle_is_violation() -> None: """Начало и конец цепочки, обороты сходятся, но сальдо конца месяца ≠ сальдо начала следующего (90 → 95).""" rows = [ row(12, ("100", None, None, "30", "70", None), level=2, parent=10, group_type="COUNTERPARTY"), row(13, ("100", None, None, "10", "90", None), level=3, parent=12, group_type="OTHER", is_period=True), row(14, ("95", None, None, "10", "85", None), level=3, parent=12, group_type="OTHER", is_period=True), row(15, ("85", None, None, "10", "70", None), level=3, parent=12, group_type="OTHER", is_period=True), ] assert parent_sums(rows) == [12] def test_period_chain_may_skip_months_but_must_be_continuous() -> None: """Пропуск месяцев без строк допустим: сравниваются соседние строки периодов.""" rows = [ row(12, ("100", None, None, "30", "70", None), level=2, parent=10, group_type="COUNTERPARTY"), row(13, ("100", None, None, "10", "90", None), level=3, parent=12, group_type="OTHER", is_period=True), row(14, ("90", None, None, "20", "70", None), level=3, parent=12, group_type="OTHER", is_period=True), ] assert parent_sums(rows) == [] def test_period_chain_continuity_by_net_when_one_side_is_collapsed() -> None: """Конец месяца развёрнут (120 Дт / 20 Кт), начало следующего свёрнуто (100 Дт): нетто совпадает.""" rows = [ row(12, ("100", None, None, "30", "70", None), level=2, parent=10, group_type="COUNTERPARTY"), row(13, ("100", None, None, "10", "120", "20"), level=3, parent=12, group_type="OTHER", is_period=True), row(14, ("100", None, None, "20", "70", None), level=3, parent=12, group_type="OTHER", is_period=True), ] assert parent_sums(rows) == [] def test_period_balance_is_compared_by_net_for_collapsed_parent() -> None: """Сальдо периодов сверяется той же функцией, что и обычное: свёрнутый родитель — по нетто.""" rows = [ row(12, ("100", None, None, "30", "70", None), level=2, parent=10, group_type="COUNTERPARTY"), row(13, ("120", "20", None, "10", "90", None), level=3, parent=12, group_type="OTHER", is_period=True), row(14, ("90", None, None, "20", "70", None), level=3, parent=12, group_type="OTHER", is_period=True), ] assert parent_sums(rows) == [] def test_ordinary_children_keep_summing_balances() -> None: """Те же числа, но дети — не периоды: сальдо складывается (100 + 90 ≠ 100).""" assert parent_sums(period_tree(second_is_period=False)) == [12] def test_mixed_period_and_ordinary_children_use_plain_sums() -> None: rows = [ row(12, ("190", None, None, "30", "160", None), level=2, parent=10, group_type="COUNTERPARTY"), row(13, ("100", None, None, "10", "90", None), level=3, parent=12, group_type="OTHER", is_period=True), row(14, ("90", None, None, "20", "70", None), level=3, parent=12, group_type="CONTRACT"), ] assert parent_sums(rows) == [] def test_turnovers_are_compared_by_columns_even_when_net_matches() -> None: rows = [ account(12, ("16", None, "135", "112", "39", None)), row(13, ("40", None, "131", "111", "60", None), level=1, parent=12, group_type="ACCOUNT"), row(22, (None, "24", "3", None, None, "21"), level=1, parent=12, group_type="ACCOUNT"), ] assert parent_sums(rows) == [12] def test_currency_and_quantity_children_are_excluded() -> None: rows = [ account(7, ROOT), row(8, ROOT, level=1, parent=7), row( 9, ("1", None, "2", None, "3", None), level=2, parent=8, group_type="OTHER", data_type="CURRENCY", indicator=True, ), row( 10, ("7", None, None, None, "7", None), level=2, parent=8, group_type="OTHER", data_type="QUANTITY", indicator=True, ), total(11, ROOT), ] assert parent_sums(rows) == [] def test_children_of_other_data_type_are_not_summed() -> None: """Σ детей «того же data_type»: ребёнок НУ под родителем БУ в сумму не входит.""" rows = [ account(7, ("10", None, None, None, "10", None)), row(8, ("10", None, None, None, "10", None), level=1, parent=7), row(9, ("5", None, None, None, "5", None), level=1, parent=7, data_type="NU"), ] assert parent_sums(rows) == [] def test_rows_without_children_are_not_parents() -> None: rows = [account(7, ROOT), account(8, ("1", None, None, None, "1", None))] assert parent_sums(rows) == [] def test_total_check_uses_main_total_when_secondary_comes_first() -> None: """Плоская форма (#59): «Итого развернутое» перед «Итого» — сверка по основной «Итого».""" secondary = [Pattern.compile(r"^итого\s+развернут")] rows = [ account(7, ROOT), row(8, ("1", None, None, None, None, "1"), group_type="TOTAL", text="Итого развернутое"), total(9, ROOT), ] assert total_matches_root(rows, secondary) is True assert total_matches_root(rows) is False checks, notes = run_checks(rows, secondary_totals=secondary) assert checks.total_row_matches_root is True assert not any(note.code == "OSV_TOTAL" for note in notes) SECONDARY = [Pattern.compile(r"^итого\s+развернут")] # «Итого развернутое» перед «Итого» несёт только развёрнутые сальдо, без оборотов: тождество на ней не выполняется SECONDARY_ROW: Sums = ("100", "10", None, None, "120", "5") def _secondary(number: int, sums: Sums) -> ClassifiedRow: return row(number, sums, group_type="TOTAL", text="Итого развернутое") def test_balance_identity_skips_secondary_total_without_turnovers_when_not_the_only_total() -> None: rows = [*tree(), _secondary(11, SECONDARY_ROW)] assert balance_identity(rows, secondary_totals=SECONDARY) == [] checks, notes = run_checks(rows, secondary_totals=SECONDARY) assert checks.balance_identity_violations == [] assert not any(note.code == "OSV_BALANCE_IDENTITY" for note in notes) def test_balance_identity_checks_secondary_total_with_turnovers() -> None: """R-19: «Итого развернутое» после «Итого» несёт обороты — тождество проверяется, сломанное даёт нарушение.""" broken: Sums = ("100", None, "50", "30", "121", None) rows = [*tree(), _secondary(11, broken)] assert balance_identity(rows, secondary_totals=SECONDARY) == [11] checks, notes = run_checks(rows, secondary_totals=SECONDARY) assert checks.balance_identity_violations == [11] assert any(note.code == "OSV_BALANCE_IDENTITY" for note in notes) def test_balance_identity_accepts_secondary_total_with_turnovers_that_balance() -> None: rows = [*tree(), _secondary(11, ROOT)] assert balance_identity(rows, secondary_totals=SECONDARY) == [] def test_balance_identity_checks_only_total_even_if_secondary_without_turnovers() -> None: """Единственная итоговая строка проверяется, даже если она вторая и без оборотов.""" rows = [account(7, ROOT), _secondary(8, SECONDARY_ROW)] assert balance_identity(rows, secondary_totals=SECONDARY) == [8] def test_balance_identity_still_checks_main_total_with_secondary_present() -> None: broken: Sums = ("100", None, "50", "30", "121", None) rows = [account(7, ROOT), total(8, broken), _secondary(9, SECONDARY_ROW)] assert balance_identity(rows, secondary_totals=SECONDARY) == [8] def test_balance_identity_without_secondary_patterns_checks_every_total() -> None: rows = [*tree(), _secondary(11, SECONDARY_ROW)] assert balance_identity(rows) == [11] checks, _ = run_checks(rows) assert checks.balance_identity_violations == [11] def test_total_matches_first_root_account() -> None: rows = tree() assert total_matches_root(rows) is True rows[-1] = total(10, ("100", None, "50", "30", "121", None)) assert total_matches_root(rows) is False # Лист без строки корневого счёта (вариант 2, issue #11 п. 3, OQ-106/OQ-133): верхний уровень — субсчета 58.01, 58.03; # «Итого» равно сумме строк уровня 0 SUB_A: Sums = ("100", None, "10", "0", "110", None) SUB_B: Sums = ("40", None, "30", "20", "50", None) SUB_SUM: Sums = ("140", None, "40", "20", "160", None) def multi_root_tree(total_sums: Sums = SUB_SUM) -> list[ClassifiedRow]: return [ account(7, SUB_A), row(8, SUB_A, level=1, parent=7), account(9, SUB_B), row(10, SUB_B, level=1, parent=9), total(11, total_sums), ] def test_total_matches_sum_of_level_zero_rows_when_there_are_several_roots() -> None: rows = multi_root_tree() checks, notes = run_checks(rows) warnings = [note.message for note in notes] assert total_matches_root(rows) is True assert checks.total_row_matches_root is True assert warnings == [] def test_collapsed_total_matches_sum_of_level_zero_rows_by_net_balance() -> None: """1С: «Итого» — свёрнутое сальдо (одна сторона), субсчета — развёрнутое; нетто сходится (R-21 для пары «Итого» ↔ Σ уровня 0); обороты сверяются по колонкам. """ mixed_a: Sums = ("100", None, "10", "0", "110", None) mixed_b: Sums = (None, "30", "30", "20", None, "20") net_total: Sums = ("70", None, "40", "20", "90", None) rows = [ account(7, mixed_a), account(8, mixed_b), total(9, net_total), ] assert total_matches_root(rows) is True rows[-1] = total(9, ("70", None, "41", "20", "90", None)) assert total_matches_root(rows) is False def test_expanded_balance_total_is_not_matched_by_net_when_total_has_both_sides() -> None: """Нетто допускается, только если сальдо «Итого» свёрнуто; «Итого» с обеими сторонами сверяется по колонкам.""" mixed_a: Sums = ("100", None, "10", "0", "110", None) mixed_b: Sums = (None, "30", "30", "20", None, "20") both_sides: Sums = ("70", "1", "40", "20", "90", "1") rows = [account(7, mixed_a), account(8, mixed_b), total(9, both_sides)] assert total_matches_root(rows) is False def test_total_that_equals_neither_first_root_nor_sum_of_roots_mismatches() -> None: rows = multi_root_tree(("140", None, "40", "20", "161", None)) checks, notes = run_checks(rows) warnings = [note.message for note in notes] assert checks.total_row_matches_root is False assert any("Итого" in warning and "11" in warning and "сумме 2 строк уровня 0" in warning for warning in warnings) def test_sum_of_roots_ignores_indicator_rows_and_total_itself() -> None: rows = multi_root_tree() rows.insert( 2, row(20, ("5", None, None, None, "5", None), group_type="OTHER", data_type="CURRENCY", indicator=True) ) assert total_matches_root(rows) is True def test_total_still_matches_single_root_even_if_other_level_zero_rows_exist() -> None: """Прежнее поведение сохраняется: «Итого» = первый корень достаточно, лишние строки уровня 0 статус не портят.""" rows = tree() rows.insert(3, row(9, ("1", None, None, None, "1", None), group_type="OTHER")) assert total_matches_root(rows) is True def test_second_total_row_does_not_replace_the_first_one() -> None: """Вторая итоговая строка («Итого развернутое») в сверку с корнем не входит: берётся первая TOTAL листа.""" rows = [*tree(), total(11, ("1", None, None, None, "1", None))] assert total_matches_root(rows) is True def test_single_total_matching_secondary_is_used_as_the_total() -> None: """T3.1: единственная TOTAL, чей текст матчит вторые итоговые, — всё равно итоговая строка.""" secondary = [Pattern.compile(r"^итого\s+развернут")] only = row(8, ROOT, group_type="TOTAL", text="Итого развернутое") assert total_matches_root([account(7, ROOT), only], secondary) is True def test_total_without_account_root_matches_level_zero_sum() -> None: flat = [ row(7, ("10", None, "5", "3", "12", None), group_type="OTHER"), row(8, (None, "4", "1", "2", None, "3"), group_type="COUNTERPARTY"), row(9, ("6", None, "2", "1", "7", None), group_type="COUNTERPARTY"), ] # сумма корней: дебет/кредит сальдо 16/4 -> 12 нетто, на конец 19/3 -> 16 нетто; обороты 8/6 по колонкам good = total(10, ("12", None, "8", "6", "16", None)) bad = total(10, ("12", None, "9", "6", "16", None)) assert total_matches_root([*flat, good]) is True assert total_matches_root([*flat, bad]) is False assert total_matches_root([total(10, EMPTY)]) is True def test_empty_osv_without_root_matches_when_total_is_empty_or_zero() -> None: assert total_matches_root([total(8, EMPTY)]) is True assert total_matches_root([total(8, ("0", None, "0.00", None, None, "0"))]) is True assert total_matches_root([total(8, ("1", None, None, None, "1", None))]) is False def test_missing_total_fails_root_check_with_warning() -> None: """R-20: без «Итого» сверку выполнить нельзя — `false` и предупреждение.""" rows = tree()[:-1] checks, notes = run_checks(rows) warnings = [note.message for note in notes] assert total_matches_root(rows) is False assert checks.total_row_matches_root is False assert "строка «Итого» не найдена" in warnings def test_run_checks_warnings_name_row_numbers() -> None: rows = tree() rows[2] = row(9, ("40", None, "30", "20", "51", None), level=1, parent=7) checks, notes = run_checks(rows) warnings = [note.message for note in notes] assert checks.balance_identity_violations == [9] assert checks.parent_sum_violations == [7] assert checks.total_row_matches_root is True assert any("строки 9" in warning for warning in warnings), warnings assert any("строки 7" in warning for warning in warnings), warnings def test_run_checks_reports_total_mismatch_with_row_numbers() -> None: rows = tree() rows[-1] = total(10, ("100", None, "50", "30", "121", None)) checks, notes = run_checks(rows) warnings = [note.message for note in notes] assert checks.total_row_matches_root is False assert any("10" in warning and "7" in warning and "Итого" in warning for warning in warnings), warnings def test_run_checks_warning_lists_are_capped() -> None: """Список нарушений в `checks` полный, текст предупреждения — первые номера и общее число.""" broken: Sums = ("1", None, None, None, "2", None) rows = [row(number, broken) for number in range(1, 101)] checks, notes = run_checks(rows) warnings = [note.message for note in notes] assert checks.balance_identity_violations == list(range(1, 101)) (identity,) = [warning for warning in warnings if "сальдо" in warning] assert "всего 100" in identity assert "100," not in identity # --- Счета без сальдо по аналитике (OQ-132): 90, 91, 99 --- NO_SALDO = OsvCheckRules(accounts_without_analytic_balance=["91", "99"]) def account_91(number: int, sums: Sums, *, level: int, parent: int | None, code: str) -> ClassifiedRow: return row(number, sums, level=level, parent=parent, group_type="ACCOUNT", text=code, code=code) def analytic_91(number: int, sums: Sums, *, parent: int, code: str = "91.01") -> ClassifiedRow: return row(number, sums, level=2, parent=parent, group_type="OTHER", code=code) def tree_91() -> list[ClassifiedRow]: """91: аналитика только с оборотами, сальдо закрыто на 91.09 (как в ОСВ 91 пакета ДКБ).""" return [ account_91(1, (None, None, "30", "30", None, None), level=0, parent=None, code="91"), account_91(2, (None, None, None, "10", None, "10"), level=1, parent=1, code="91.01"), analytic_91(3, (None, None, None, "4", None, None), parent=2), analytic_91(4, (None, None, None, "6", None, None), parent=2), account_91(5, (None, None, "25", None, "25", None), level=1, parent=1, code="91.02"), analytic_91(6, (None, None, "25", None, None, None), parent=5, code="91.02"), account_91(7, (None, None, "5", "20", None, "15"), level=1, parent=1, code="91.09"), analytic_91(8, (None, None, "5", "20", None, None), parent=7, code="91.09"), total(9, (None, None, "30", "30", None, None)), ] def test_without_exemption_account_91_violates_identity_and_parent_sums() -> None: rows = tree_91() assert balance_identity(rows) == [3, 4, 6, 8] assert parent_sums(rows) == [2, 5, 7] def test_identity_is_not_checked_on_accounts_without_analytic_balance() -> None: assert balance_identity(tree_91(), NO_SALDO) == [] def test_parent_sums_on_such_accounts_compare_turnovers_but_not_balance_of_analytics() -> None: assert parent_sums(tree_91(), NO_SALDO) == [] def test_parent_turnover_is_still_checked_on_such_accounts() -> None: rows = tree_91() rows[3] = analytic_91(4, (None, None, None, "7", None, None), parent=2) # Σ оборотов детей 11 ≠ 10 assert parent_sums(rows, NO_SALDO) == [2] def test_balance_of_subaccounts_under_root_is_still_checked_on_such_accounts() -> None: rows = tree_91() rows[6] = account_91(7, (None, None, "5", "20", None, "14"), level=1, parent=1, code="91.09") # сальдо ≠ нетто assert parent_sums(rows, NO_SALDO) == [1] def test_total_row_is_still_checked_on_such_accounts() -> None: rows = tree_91() rows[-1] = total(9, (None, None, "31", "30", None, None)) assert total_matches_root(rows) is False checks, notes = run_checks(rows, NO_SALDO) warnings = [note.message for note in notes] assert checks.total_row_matches_root is False assert any("Итого" in warning for warning in warnings) def test_exemption_is_matched_by_account_root_not_by_prefix_of_digits() -> None: rows = [ account_91(1, ("5", None, "1", None, "9", None), level=0, parent=None, code="910"), # 5 + 1 ≠ 9 account_91(2, ("5", None, "1", None, "9", None), level=0, parent=None, code="91.5"), # субсчёт 91 — вне account_91(3, ("5", None, "1", None, "9", None), level=0, parent=None, code="90.01"), account_91(4, ("5", None, "1", None, "9", None), level=0, parent=None, code="99.01"), # 99 — вне row(5, ("5", None, "1", None, "9", None), code=None), # кода нет — проверяется ] assert balance_identity(rows, NO_SALDO) == [1, 3, 5] def test_run_checks_without_violations_on_account_91_gives_clean_status() -> None: checks, notes = run_checks(tree_91(), NO_SALDO) warnings = [note.message for note in notes] assert checks.balance_identity_violations == [] assert checks.parent_sum_violations == [] assert checks.total_row_matches_root is True assert warnings == [] # --- Признак строки-показателя (#58) --- def test_indicator_nu_child_is_excluded_from_parent_sum() -> None: """Строка-показатель НУ под родителем БУ в сумму детей не входит, хотя её data_type не CURRENCY/QUANTITY.""" rows = [ account(7, ("10", None, None, None, "10", None)), row(8, ("10", None, None, None, "10", None), level=1, parent=7), row( 9, ("5", None, None, None, "5", None), level=1, parent=7, group_type="OTHER", data_type="NU", indicator=True ), ] assert parent_sums(rows) == [] def test_document_of_nu_rows_without_indicator_is_checked_as_usual() -> None: """«Выводимые данные: НУ» (s12): все строки НУ без признака — проверки идут как обычно.""" rows = [ row(7, ("10", None, None, None, "10", None), group_type="ACCOUNT", data_type="NU"), row(8, ("4", None, None, None, "4", None), level=1, parent=7, data_type="NU"), row(9, ("5", None, None, None, "5", None), level=1, parent=7, data_type="NU"), ] assert parent_sums(rows) == [7] def test_indicator_with_same_data_type_as_parent_is_excluded_by_flag() -> None: """Признак — флаг, а не `data_type`: показатель НУ под родителем НУ не суммируется, не родитель и не «Итого».""" rows = [ row(7, ("10", None, None, None, "10", None), group_type="ACCOUNT", data_type="NU"), row(8, ("10", None, None, None, "10", None), level=1, parent=7, data_type="NU"), row( 9, ("5", None, None, None, "5", None), level=1, parent=7, group_type="OTHER", data_type="NU", indicator=True ), row(10, ("3", None, None, None, "3", None), level=2, parent=9, data_type="NU"), row(11, ("15", None, None, None, "15", None), group_type="TOTAL", data_type="NU", indicator=True), row(12, ("10", None, None, None, "10", None), group_type="TOTAL", data_type="NU"), ] assert parent_sums(rows) == [] assert total_matches_root(rows) is True def test_total_skips_leading_indicator_total() -> None: """Первая итоговая строка без признака — основная «Итого», даже если перед ней стоит итог-показатель.""" rows = [ *tree(), row(12, ("1", None, None, None, "1", None), group_type="TOTAL", data_type="NU", indicator=True), ] rows.insert(0, row(2, ("9", None, None, None, "9", None), group_type="TOTAL", data_type="NU", indicator=True)) assert total_matches_root(rows) is True # AURORA-1708 (#73): корень счёта развёрнут (Дт и Кт), основная «Итого» свёрнута, «Итого развернутое» = корню _EXPANDED_ROOT: Sums = ("10", "4", "100", "70", "50", "20") _COLLAPSED_TOTAL: Sums = ("6", None, "100", "70", "30", None) _SECONDARY = [Pattern.compile(r"^итого\s+развернут")] def test_collapsed_total_matches_expanded_root_by_net() -> None: """(б) Только свёрнутая «Итого»: обороты по колонкам, сальдо по нетто — корень из одной строки тоже сходится.""" rows = [account(7, _EXPANDED_ROOT), total(8, _COLLAPSED_TOTAL)] assert total_matches_root(rows) is True def test_expanded_secondary_total_equal_to_root_with_collapsed_total() -> None: """(а) «Итого развернутое» = корню по колонкам, основная «Итого» свёрнута: предупреждения нет.""" rows = [account(7, _EXPANDED_ROOT), total(8, _COLLAPSED_TOTAL), _secondary(9, _EXPANDED_ROOT)] _, warnings = run_checks(rows, secondary_totals=_SECONDARY) assert total_matches_root(rows, _SECONDARY) is True assert not [w for w in warnings if "Итого" in w.message] def test_expanded_secondary_total_before_collapsed_total() -> None: """(а) То же в плоской форме: «Итого развернутое» стоит перед «Итого».""" rows = [account(7, _EXPANDED_ROOT), _secondary(8, _EXPANDED_ROOT), total(9, _COLLAPSED_TOTAL)] assert total_matches_root(rows, _SECONDARY) is True def test_collapsed_total_with_real_net_difference_still_fails() -> None: """(в) Нетто «Итого» не совпало с корнем — расхождение остаётся, даже если «Итого развернутое» = корню.""" wrong_net: Sums = ("7", None, "100", "70", "30", None) wrong_turnover: Sums = ("6", None, "101", "70", "30", None) assert total_matches_root([account(7, _EXPANDED_ROOT), total(8, wrong_net)]) is False assert total_matches_root([account(7, _EXPANDED_ROOT), total(8, wrong_turnover)]) is False rows = [account(7, _EXPANDED_ROOT), total(8, wrong_net), _secondary(9, _EXPANDED_ROOT)] assert total_matches_root(rows, _SECONDARY) is False _, warnings = run_checks(rows, secondary_totals=_SECONDARY) assert any("≠ корневому счёту" in w.message for w in warnings) def test_expanded_total_differing_from_expanded_root_still_fails() -> None: """Развёрнутая «Итого» (обе стороны) с другим распределением Дт/Кт нетто не спасает: свёрнутость — у «Итого».""" shifted: Sums = ("16", "10", "100", "70", "50", "20") assert total_matches_root([account(7, _EXPANDED_ROOT), total(8, shifted)]) is False