diff --git a/CHANGELOG.md b/CHANGELOG.md index b76afa0..0fa9cdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/VERSION b/VERSION index b1e80bb..845639e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.3 +0.1.4 diff --git a/arm_statements/checks.py b/arm_statements/checks.py index 16a3cf2..788f59a 100644 --- a/arm_statements/checks.py +++ b/arm_statements/checks.py @@ -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, diff --git a/arm_statements/loader.py b/arm_statements/loader.py index 2ffc8f2..541945f 100644 --- a/arm_statements/loader.py +++ b/arm_statements/loader.py @@ -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( diff --git a/arm_statements/rules/statement_checks.yaml b/arm_statements/rules/statement_checks.yaml index 8466325..cce5c42 100644 --- a/arm_statements/rules/statement_checks.yaml +++ b/arm_statements/rules/statement_checks.yaml @@ -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} diff --git a/tests/test_checks.py b/tests/test_checks.py index d7ab03c..d449bed 100644 --- a/tests/test_checks.py +++ b/tests/test_checks.py @@ -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: diff --git a/tests/test_loader.py b/tests/test_loader.py index 066dfaa..a15bf6d 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -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