Andrey Epifancev e705d75520 feat(parser): контрольные суммы §1.4, статусы, OsvParser как парсер по умолчанию, лимиты
checks.py: тождество сальдо по каждой строке, суммы родителя = Σ детей того же data_type
(CURRENCY/QUANTITY исключены; обороты — по колонкам, сальдо — по колонкам или по нетто при
свёрнутом сальдо родителя), «Итого» = корневой счёт (R-13: нет корня — «Итого» пусто/0;
R-20: нет «Итого» — false и предупреждение).
osv.py: OsvParser — лимит размера, чтение по сигнатуре, листы по содержимому (формы ББ/ОФР и
прочие — пропуск с предупреждением), карта исходов D-6, кооперативный дедлайн, metrics
(rows без TOTAL, warnings с «sheet=<имя> hierarchy=<стратегия>» первой записью по листу).
get_parser(settings, rules_dir=…) → OsvParser (словари грузятся при создании: битый словарь —
падение старта); PARSE_MAX_BYTES / PARSE_TIMEOUT_SECONDS в Settings и .env.*; app.py передаёт
настройки. test_null_parser.py → test_parser_factory.py.
Тесты: юнит-карта статусов, «Испорченная сумма», «Пустая ОСВ», два листа ОСВ, новый синоним
без релиза кода (R-8); property-тест на 66 реальных файлах (все done, < 5 с, схема v1.1).
test_app и интеграционный round-trip ждут ошибку реального парсера вместо NOT_IMPLEMENTED.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-05 14:37:21 +04:00

256 lines
9.5 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_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
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