AURORA-1864: группа альтернатив мягких соотношений, 0.1.4 #4
@ -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`
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user