Andrey Epifancev 9b8b8ea727 fix: финальное ревью — лимиты чтения xlsx (I-1), лог traceback (I-2), ACCOUNT_RE (M-1), ИНН/КПП (M-3)
- I-1 (R-23): readers/_limits.py — сумма ZipInfo.file_size ≤ 10 × PARSE_MAX_BYTES (параметр read_book от
  OsvParser), потоковый скан <mergeCell ref> до openpyxl и площадь merged после чтения любого формата
  (MERGED_AREA_LIMIT = 200 000; реальный максимум 10 090) → UNSUPPORTED_FORMAT; дедлайн в циклах detect
  (поиск строки групп, дополнительные колонки); README «Лимиты» исправлен, «Известные ограничения» (defusedxml).
- I-2: OsvParser логирует стек непредвиденной ошибки (loguru ERROR), UNREADABLE — WARNING без стека.
- M-1: ACCOUNT_RE с re.ASCII | re.DOTALL (многострочное название после запятой, только цифры 0–9).
- M-3 (R-24): правило детектора ИНН не дополняет 9/11 цифр нулём; дополнение — только при извлечении
  (numbers.inn_digits) из колонки, выбранной по заголовку или по строгому правилу.
- T9.1: тесты R-21 (свёрнутый родитель на противоположной стороне; (0,0) при детях (X, X)).
- T1.1: хелперы фикстур пропускают скрытые файлы/каталоги и lock-файлы Excel (~$…).
- T12.3: golden-update не удаляет снимки части без исходного каталога (osv/synthetic или реальные файлы).

Golden-снимки не изменились (82/82).

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

282 lines
11 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
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