C-1: nest_by_sums включается из osv.py, если схема шапки не называет группы после счёта; строка с ИНН не вкладывается и не становится родителем; прямые дети родителя должны быть на одном отступе. M-1: сдвиги складываются. M-2: код без разделителя сегментов не вкладывается. M-3: строка закрытия — только с ненулевыми оборотами. M-4: общий жадный перебор _greedy_ends для strategy_sums и вложенности, граница NEST_SCAN_LIMIT под тестом. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
98 lines
4.1 KiB
Python
98 lines
4.1 KiB
Python
"""Вложенность по суммам при иерархии по отступам (AURORA-1858, ревью C-1): защиты на уровне всего листа.
|
||
|
||
Правило не должно прятать реальную ошибку и менять `group_type`/`group_name` строк: при названных группах аналитики в
|
||
шапке оно отключено, с ИНН строка не вкладывается и не становится родителем.
|
||
"""
|
||
|
||
import pytest
|
||
from gridbuilder import C, M
|
||
|
||
from src.contract.result import OsvDocument
|
||
from src.parser.deadline import Deadline
|
||
from src.parser.osv import OsvParser
|
||
from src.parser.rules import Rules, load_rules
|
||
|
||
from gridbuilder import sheet_from_rows # isort: skip
|
||
|
||
TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."
|
||
TIMEOUT_SECONDS = 60
|
||
Row = list[object]
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def rules() -> Rules:
|
||
return load_rules()
|
||
|
||
|
||
def sums(value: int) -> Row:
|
||
return [None, None, value, None, value, None]
|
||
|
||
|
||
def line(indent: int, text: str, value: int, inn: str | None = None) -> Row:
|
||
return [C(text, indent=indent), inn, None, *sums(value)]
|
||
|
||
|
||
def parse(rules: Rules, head_group_rows: list[Row], body: list[Row]) -> OsvDocument:
|
||
rows: list[Row] = [
|
||
["ООО Ромашка"],
|
||
[TITLE],
|
||
[],
|
||
[
|
||
"Счет",
|
||
"ИНН",
|
||
"КПП",
|
||
M("Сальдо на начало периода", 2),
|
||
M("Обороты за период", 2),
|
||
M("Сальдо на конец периода", 2),
|
||
],
|
||
["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
|
||
*head_group_rows,
|
||
*body,
|
||
]
|
||
return OsvParser(rules).parse_sheet(sheet_from_rows(rows), Deadline(TIMEOUT_SECONDS)).document
|
||
|
||
|
||
def test_reviewer_scenario_with_named_groups_and_inn_stays_needs_check(rules: Rules) -> None:
|
||
"""Счёт 60 = 100, но контрагенты 60 + 40 + 40: это ошибка файла, а не вложенность «Гамма под Бетой»."""
|
||
document = parse(
|
||
rules,
|
||
[["Договоры"]],
|
||
[
|
||
line(0, "60", 100),
|
||
line(1, "ООО Альфа", 60, "7701234567"),
|
||
line(2, "Договор 1", 60),
|
||
line(1, "ООО Бета", 40, "7707654321"),
|
||
line(1, "ООО Гамма", 40, "5001234567"),
|
||
[C("Итого"), None, None, *sums(100)],
|
||
],
|
||
)
|
||
|
||
by_text = {row.row_value: row for row in document.rows}
|
||
gamma = by_text["ООО Гамма"]
|
||
assert document.status == "needs_check"
|
||
assert gamma.group_type == "COUNTERPARTY"
|
||
assert gamma.counterparty_inn == "5001234567"
|
||
assert gamma.parent_row_number == by_text["60"].row_number
|
||
assert by_text["ООО Бета"].parent_row_number == by_text["60"].row_number
|
||
|
||
|
||
def test_named_analytic_group_in_header_disables_nesting_by_sums(rules: Rules) -> None:
|
||
"""Без ИНН и с одним отступом правило бы вложило «Б» в «В» (40 ⊃ 40): шапка называет «Контрагенты», вложение
|
||
сменило бы group_type строки, поэтому дерево остаётся по отступам и лист на проверке."""
|
||
document = parse(
|
||
rules,
|
||
[],
|
||
[
|
||
line(0, "60", 100),
|
||
line(1, "Контрагент А", 60),
|
||
line(1, "Контрагент Б", 40),
|
||
line(1, "Контрагент В", 40),
|
||
[C("Итого"), None, None, *sums(100)],
|
||
],
|
||
)
|
||
|
||
by_text = {row.row_value: row for row in document.rows}
|
||
assert document.status == "needs_check"
|
||
assert {by_text[name].group_type for name in ("Контрагент А", "Контрагент Б", "Контрагент В")} == {"COUNTERPARTY"}
|
||
assert {by_text[name].level for name in ("Контрагент А", "Контрагент Б", "Контрагент В")} == {1}
|