AURORA-1864: группа альтернатив мягких соотношений, 0.1.4 #4

Merged
aep merged 2 commits from feat/3-checks-group into main 2026-11-06 21:59:29 +03:00
7 changed files with 158 additions and 8 deletions

View File

@ -1,5 +1,14 @@
# Changelog
## 0.1.4 — 2026-11-06
- `statement_checks.yaml`: необязательный ключ `group` — группа альтернатив мягких соотношений одной цели. В колонке
сошлась хоть одна — нарушений группы нет; не сошлась ни одна — одно нарушение, ближайшая к цели. `group` у жёсткого
соотношения или разные цели в одной группе — ошибка загрузки словаря. Подсветка, `needs_check` и подтверждение 2400 (Р-21) не менялись (AURORA-1864).
- Соотношения 2400 ОФР — группа `net_profit`: полная форма «2400 = 2300 − 2410 + 2420» и «2400 = 2300 + 2410 + 2460»;
в упрощённую добавлено «2400 = 2110 − 2120 − 2330 + 2340 − 2350 + 2410». Положительный 2410 (доход по налогу,
ФСБУ 4/2023) больше не даёт ложного предупреждения.
## 0.1.3 — 2026-11-02
- `statement_code_orders.yaml`: шапка приведена к байтовой копии текущего `docs/contracts/statement-code-orders.yaml`

View File

@ -1 +1 @@
0.1.3
0.1.4

View File

@ -166,12 +166,41 @@ def evaluate_relations(
return outcomes
def _miss(item: RelationOutcome) -> Decimal | None:
"""Расхождение цели и правой части; `None` — сравнить не с чем (пуста цель или все члены)."""
if item.expected is None or item.actual is None:
return None
return abs(item.expected - item.actual)
def _group_reported(outcomes: Sequence[RelationOutcome]) -> set[int]:
"""Позиции нарушений групп альтернатив, которые выдаются (AURORA-1864).
Группа — мягкие соотношения с одним `group` в одной колонке. Сошлась хоть одна — не выдаётся ни одна;
не сошлась ни одна — одна, ближайшая к цели (при равенстве — первая по словарю)."""
members: dict[tuple[str, str], list[int]] = {}
for position, item in enumerate(outcomes):
if item.rule.group is not None and not item.rule.strict:
members.setdefault((item.column, item.rule.group), []).append(position)
reported: set[int] = set()
for positions in members.values():
failed = [p for p in positions if outcomes[p].computed and not outcomes[p].within_tolerance]
if not failed or any(outcomes[p].computed and outcomes[p].within_tolerance for p in positions):
continue
comparable = [p for p in failed if _miss(outcomes[p]) is not None]
reported.add(min(comparable, key=lambda p: _miss(outcomes[p]) or _ZERO) if comparable else failed[0])
return reported
def checks_from(outcomes: Sequence[RelationOutcome], column_index_of: Mapping[str, int | None]) -> StatementChecks:
hard: list[FailedRelation] = []
soft: list[FailedRelation] = []
for item in outcomes:
reported = _group_reported(outcomes)
for position, item in enumerate(outcomes):
if not item.computed or item.within_tolerance:
continue
if item.rule.group is not None and not item.rule.strict and position not in reported:
continue
failure = FailedRelation(
column_index=column_index_of.get(item.column),
column_key=item.column,

View File

@ -83,6 +83,8 @@ class RelationRule:
terms: tuple[tuple[int, str], ...]
strict: bool
abs_terms: frozenset[str]
# группа альтернатив (только мягкие, одна форма и вариант): сошлась одна — сошлась группа (AURORA-1864)
group: str | None = None
@dataclass(frozen=True, slots=True)
@ -282,6 +284,11 @@ def _load_checks(directory: Path) -> CheckRules:
abs_terms = frozenset(_str(file, f"{key}.abs_terms", code) for code in raw_abs)
if not abs_terms <= {code for _, code in terms}:
raise _fail(file, f"{key}.abs_terms", "коды должны быть членами правой части соотношения")
strict = _bool(file, f"{key}.strict", _get(file, entry, "strict"))
raw_group = entry.get("group")
group = None if raw_group is None else _str(file, f"{key}.group", raw_group)
if group is not None and strict:
raise _fail(file, f"{key}.group", "группа альтернатив — только у мягкого соотношения (strict: false)")
relations.append(
RelationRule(
form=form,
@ -289,10 +296,18 @@ def _load_checks(directory: Path) -> CheckRules:
relation=text,
target=target,
terms=terms,
strict=_bool(file, f"{key}.strict", _get(file, entry, "strict")),
strict=strict,
abs_terms=abs_terms,
group=group,
)
)
group_targets: dict[tuple[DocumentType, FormVariant, str], str] = {}
for i, rule in enumerate(relations):
if rule.group is None:
continue
known = group_targets.setdefault((rule.form, rule.variant, rule.group), rule.target)
if known != rule.target:
raise _fail(file, f"relations[{i}].group", f"у группы {rule.group!r} разные цели: {known} и {rule.target}")
return CheckRules(
tolerance=_number(file, "tolerance", _get(file, data, "tolerance")),
rounding_tolerance_percent=_number(

View File

@ -3,6 +3,9 @@
# relation: «<код> = <код> [+|-] <код> …», только четырёхзначные коды; strict: нарушение переводит документ в needs_check
# (false — только мягкое предупреждение, ASSUMPTION(OQ-12)); abs_terms: коды, которые 1С выгружает со знаком минус, а 66н —
# в скобках: правая часть считается и как записано, и с этими членами по модулю.
# group: только у мягких — альтернативы одной цели (одна форма и вариант): сошлась хоть одна в колонке — нарушений нет;
# не сошлась ни одна — одно нарушение, ближайшая к цели. Знак 2410 (налог: расход или доход, ФСБУ 4/2023) не
# определяем — принимаем любой вариант группы (AURORA-1864, OQ-12).
tolerance: 1 # допуск в единицах формы (тыс. руб. округляются построчно)
# OQ-138: единицы шапки верны, расхождение до 0,001 % итоговой строки — округление (мягкое нарушение, статус не портит).
rounding_tolerance_percent: 0.001
@ -19,11 +22,12 @@ relations:
- {form: INCOME, variant: full, relation: "2100 = 2110 - 2120", strict: true, abs_terms: ["2120"]}
- {form: INCOME, variant: full, relation: "2200 = 2100 - 2210 - 2220", strict: true, abs_terms: ["2210", "2220"]}
- {form: INCOME, variant: full, relation: "2300 = 2200 + 2310 + 2320 - 2330 + 2340 - 2350", strict: true, abs_terms: ["2330", "2350"]}
- {form: INCOME, variant: full, relation: "2400 = 2300 - 2410 + 2420", strict: false, abs_terms: ["2410"]}
- {form: INCOME, variant: full, relation: "2400 = 2300 + 2410 + 2460", strict: false}
- {form: INCOME, variant: full, relation: "2400 = 2300 - 2410 + 2420", strict: false, abs_terms: ["2410"], group: net_profit}
- {form: INCOME, variant: full, relation: "2400 = 2300 + 2410 + 2460", strict: false, group: net_profit}
- {form: INCOME, variant: full, relation: "2410 = 2411 + 2412", strict: false}
- {form: INCOME, variant: full, relation: "2500 = 2400 + 2510 + 2520 + 2530", strict: false}
- {form: BALANCE, variant: simplified, relation: "1600 = 1150 + 1170 + 1210 + 1230 + 1250", strict: true}
- {form: BALANCE, variant: simplified, relation: "1700 = 1300 + 1350 + 1360 + 1410 + 1450 + 1510 + 1520 + 1550", strict: true}
- {form: BALANCE, variant: simplified, relation: "1600 = 1700", strict: true}
- {form: INCOME, variant: simplified, relation: "2400 = 2110 - 2120 - 2330 + 2340 - 2350 - 2410", strict: false, abs_terms: ["2120", "2330", "2350", "2410"]}
- {form: INCOME, variant: simplified, relation: "2400 = 2110 - 2120 - 2330 + 2340 - 2350 - 2410", strict: false, abs_terms: ["2120", "2330", "2350", "2410"], group: net_profit}
- {form: INCOME, variant: simplified, relation: "2400 = 2110 - 2120 - 2330 + 2340 - 2350 + 2410", strict: false, abs_terms: ["2120", "2330", "2350"], group: net_profit}

View File

@ -64,7 +64,63 @@ def test_simplified_income_soft(rules: Rules) -> None:
lines = [line("2110", "1000"), line("2120", "-600"), line("2410", "50"), line("2400", "500")]
checks = failed(lines, INC, FormVariant.SIMPLIFIED, rules)
assert checks.relations_failed == ()
assert [f.relation for f in checks.soft_relations_failed] == ["2400 = 2110 - 2120 - 2330 + 2340 - 2350 - 2410"]
# группа альтернатив 2400 (AURORA-1864): одно нарушение — ближайшая к цели (450 против 350)
assert [f.relation for f in checks.soft_relations_failed] == ["2400 = 2110 - 2120 - 2330 + 2340 - 2350 + 2410"]
def _net_profit_soft(checks: StatementChecks) -> list[str]:
return [f.relation for f in checks.soft_relations_failed if f.relation.startswith("2400 =")]
def test_net_profit_group_full_any_sign_of_2410(rules: Rules) -> None:
"""AURORA-1864: 2410 доходом (плюс), расходом с минусом и расходом без знака — 2400 без мягких нарушений."""
for tax, net in (("20", "120"), ("-20", "80"), ("20", "80")):
lines = [line("2300", "100"), line("2410", tax), line("2400", net)]
checks = failed(lines, INC, FormVariant.FULL, rules)
assert _net_profit_soft(checks) == [], (tax, net)
def test_net_profit_group_simplified_any_sign_of_2410(rules: Rules) -> None:
for tax, net in (("50", "450"), ("-50", "350"), ("50", "350")):
lines = [line("2110", "1000"), line("2120", "-600"), line("2410", tax), line("2400", net)]
assert _net_profit_soft(failed(lines, INC, FormVariant.SIMPLIFIED, rules)) == [], (tax, net)
def test_net_profit_group_wrong_target_is_one_violation(rules: Rules) -> None:
lines = [line("2300", "100"), line("2410", "20"), line("2400", "150")]
soft = failed(lines, INC, FormVariant.FULL, rules).soft_relations_failed
net = [f for f in soft if f.relation.startswith("2400 =")]
# ближайшая к цели: 100 + 20 = 120 (расхождение 30) против 100 − 20 = 80 (70)
assert [(f.relation, f.expected, f.actual) for f in net] == [("2400 = 2300 + 2410 + 2460", D("150"), D("120"))]
def test_net_profit_group_empty_target_is_one_violation(rules: Rules) -> None:
lines = [line("2300", "100"), line("2410", "20")]
net = _net_profit_soft(failed(lines, INC, FormVariant.FULL, rules))
assert net == ["2400 = 2300 - 2410 + 2420"]
def test_net_profit_group_is_per_column(rules: Rules) -> None:
def two(code: str, a: str, b: str) -> EvalLine:
return EvalLine(
f"k{code}",
0,
LineStatus.OK,
code,
100.0,
False,
(
EvalValue("0", D(a), a, 100.0, True),
EvalValue("1", D(b), b, 100.0, True),
),
)
lines = [two("2300", "100", "100"), two("2410", "20", "20"), two("2400", "120", "150")]
outcomes = evaluate_relations(lines, ["0", "1"], INC, FormVariant.FULL, rules)
soft = checks_from(outcomes, {"0": 0, "1": 1}).soft_relations_failed
assert [(f.column_key, f.relation) for f in soft if f.relation.startswith("2400 =")] == [
("1", "2400 = 2300 + 2410 + 2460")
]
def test_rounding_goes_soft(rules: Rules) -> None:

View File

@ -53,14 +53,51 @@ def test_warning_table_matches_spec(rules: Rules) -> None:
def test_simplified_income_relation_is_soft(rules: Rules) -> None:
(rule,) = rules.checks.relations_for(DocumentType.INCOME, FormVariant.SIMPLIFIED)
rule, alternative = rules.checks.relations_for(DocumentType.INCOME, FormVariant.SIMPLIFIED)
assert rule.target == "2400" and not rule.strict
assert rule.abs_terms == frozenset({"2120", "2330", "2350", "2410"})
assert alternative.target == "2400" and not alternative.strict
assert alternative.abs_terms == frozenset({"2120", "2330", "2350"})
assert rules.checks.abs_codes(DocumentType.INCOME, FormVariant.FULL) == frozenset(
{"2120", "2210", "2220", "2330", "2350", "2410"}
)
def test_net_profit_relations_share_group(rules: Rules) -> None:
"""AURORA-1864: соотношения 2400 каждой формы — одна группа альтернатив."""
for variant, count in ((FormVariant.FULL, 2), (FormVariant.SIMPLIFIED, 2)):
net = [r for r in rules.checks.relations_for(DocumentType.INCOME, variant) if r.target == "2400"]
assert len(net) == count
assert len({r.group for r in net}) == 1 and net[0].group is not None
assert all(not r.strict for r in net)
others = [r for r in rules.checks.relations if r.target != "2400"]
assert all(r.group is None for r in others)
def test_group_on_strict_relation_rejected(tmp_path: Path) -> None:
for path in RULES_DIR.glob("*.yaml"):
(tmp_path / path.name).write_bytes(path.read_bytes())
target = tmp_path / "statement_checks.yaml"
text = target.read_text(encoding="utf-8")
hard = 'relation: "2100 = 2110 - 2120", strict: true'
assert hard in text
target.write_text(text.replace(hard, hard + ", group: x"), encoding="utf-8")
with pytest.raises(RulesError, match="statement_checks.*group"):
load_rules(tmp_path)
def test_group_with_different_targets_rejected(tmp_path: Path) -> None:
for path in RULES_DIR.glob("*.yaml"):
(tmp_path / path.name).write_bytes(path.read_bytes())
target = tmp_path / "statement_checks.yaml"
text = target.read_text(encoding="utf-8")
soft = 'relation: "2500 = 2400 + 2510 + 2520 + 2530", strict: false'
assert soft in text
target.write_text(text.replace(soft, soft + ", group: net_profit"), encoding="utf-8")
with pytest.raises(RulesError, match="statement_checks.*group"):
load_rules(tmp_path)
def test_code_orders_with_repeats(rules: Rules) -> None:
fsbu = rules.code_orders.sequence(Edition.FSBU4, DocumentType.BALANCE, FormVariant.FULL)
assert fsbu.count("1220") == len(("1220", "1220")) and "1120" not in fsbu