Andrey Epifancev af8ec918c5 feat(osv): шапка ОСВ без меток и «Счет: Код / Наименование» (ОСВ 63 Новоросметалл), «Итого» ↔ Σ уровня 0 по нетто
- header: период отдельной ячейкой (periods.yaml: standalone), тип данных отдельной ячейкой («Бухгалтерский»,
  data_types.yaml: standalone_header), организация — хвост названия ведомости; код счёта — из первой строки-счёта
  таблицы (с предупреждением), если «по счету» в шапке нет.
- classify/detect: текст строки может стоять правее колонки текста (колонка задаёт уровень), подписи «Код»/
  «Наименование» (osv_headers.yaml: column_captions) — не группировки, строка сумм без подписи в конце таблицы —
  «Итого» (с предупреждением).
- checks: сумма уровня 0 сверяется с «Итого» по правилу R-21 (свёрнутое сальдо «Итого» — по нетто), как у родителя.
- tests: osv_dkb_files, покрытие словарей включает osv-dkb/**; README.

Refs #11

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
2026-10-06 13:04:31 +04:00

368 lines
15 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
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 = "строка",
) -> 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="60",
counterparty_name=None,
counterparty_inn=None,
data_type=data_type,
extra=None,
)
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, warnings = run_checks(tree())
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"),
row(10, ("7", None, None, None, "7", None), level=2, parent=8, group_type="OTHER", data_type="QUANTITY"),
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, warnings = run_checks(rows)
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, warnings = run_checks(rows)
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"))
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, warnings = run_checks(rows)
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, warnings = run_checks(rows)
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, warnings = run_checks(rows)
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, warnings = run_checks(rows)
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