614 lines
25 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
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,
) -> 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,
)
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 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+развернут")]
# «Итого развернутое» в 1С несёт только развёрнутые сальдо, без оборотов: тождество строки на ней не выполняется
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() -> 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_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