Andrey Epifancev 133b31a1f3 fix(osv): нетто-признак только цепочкой нетто-строк, непрерывность периодов, спека и CLAUDE.md (AURORA-1863, AURORA-1823, #78, ревью)
I-2: в netted попадает только строка, сама сошедшаяся с детьми лишь по нетто; сошедшаяся по колонкам признак не
передаёт. I-3: сальдо конца периода = сальдо начала следующего (по колонкам либо по нетто), разрыв — нарушение.
M-6: сценарии спеки (защиты вложения по суммам, цепочка нетто-строк, непрерывность периодов); CLAUDE.md и докстринг
checks.py ссылаются на решения владельца 28.10.2026.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:56:18 +04:00

861 lines
39 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.

"""Контрольные суммы §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