525 lines
21 KiB
Python
525 lines
21 KiB
Python
"""Контрольные суммы §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
|
||
|
||
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_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_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): 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_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
|