AURORA-1864: группа альтернатив мягких соотношений, 0.1.4 (#3)
statement_checks.yaml: необязательный ключ group у мягких соотношений одной формы и варианта. В колонке сошлась хоть одна альтернатива — нарушений группы нет; не сошлась ни одна — одно нарушение, ближайшая к цели. group у жёсткого — ошибка загрузки. Соотношения 2400 ОФР — группа net_profit; в упрощённую форму добавлен вариант с +2410. Положительный 2410 (доход по налогу, ФСБУ 4/2023) больше не даёт ложного «2400 = 2300 − 2410 + 2420». Прогон ОФР Фарватера — без нарушений. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3b9166511e
commit
065bcba25a
@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# 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
|
## 0.1.3 — 2026-11-02
|
||||||
|
|
||||||
- `statement_code_orders.yaml`: шапка приведена к байтовой копии текущего `docs/contracts/statement-code-orders.yaml`
|
- `statement_code_orders.yaml`: шапка приведена к байтовой копии текущего `docs/contracts/statement-code-orders.yaml`
|
||||||
|
|||||||
@ -166,12 +166,41 @@ def evaluate_relations(
|
|||||||
return outcomes
|
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:
|
def checks_from(outcomes: Sequence[RelationOutcome], column_index_of: Mapping[str, int | None]) -> StatementChecks:
|
||||||
hard: list[FailedRelation] = []
|
hard: list[FailedRelation] = []
|
||||||
soft: 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:
|
if not item.computed or item.within_tolerance:
|
||||||
continue
|
continue
|
||||||
|
if item.rule.group is not None and not item.rule.strict and position not in reported:
|
||||||
|
continue
|
||||||
failure = FailedRelation(
|
failure = FailedRelation(
|
||||||
column_index=column_index_of.get(item.column),
|
column_index=column_index_of.get(item.column),
|
||||||
column_key=item.column,
|
column_key=item.column,
|
||||||
|
|||||||
@ -83,6 +83,8 @@ class RelationRule:
|
|||||||
terms: tuple[tuple[int, str], ...]
|
terms: tuple[tuple[int, str], ...]
|
||||||
strict: bool
|
strict: bool
|
||||||
abs_terms: frozenset[str]
|
abs_terms: frozenset[str]
|
||||||
|
# группа альтернатив (только мягкие, одна форма и вариант): сошлась одна — сошлась группа (AURORA-1864)
|
||||||
|
group: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@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)
|
abs_terms = frozenset(_str(file, f"{key}.abs_terms", code) for code in raw_abs)
|
||||||
if not abs_terms <= {code for _, code in terms}:
|
if not abs_terms <= {code for _, code in terms}:
|
||||||
raise _fail(file, f"{key}.abs_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(
|
relations.append(
|
||||||
RelationRule(
|
RelationRule(
|
||||||
form=form,
|
form=form,
|
||||||
@ -289,8 +296,9 @@ def _load_checks(directory: Path) -> CheckRules:
|
|||||||
relation=text,
|
relation=text,
|
||||||
target=target,
|
target=target,
|
||||||
terms=terms,
|
terms=terms,
|
||||||
strict=_bool(file, f"{key}.strict", _get(file, entry, "strict")),
|
strict=strict,
|
||||||
abs_terms=abs_terms,
|
abs_terms=abs_terms,
|
||||||
|
group=group,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return CheckRules(
|
return CheckRules(
|
||||||
|
|||||||
@ -3,6 +3,9 @@
|
|||||||
# relation: «<код> = <код> [+|-] <код> …», только четырёхзначные коды; strict: нарушение переводит документ в needs_check
|
# relation: «<код> = <код> [+|-] <код> …», только четырёхзначные коды; strict: нарушение переводит документ в needs_check
|
||||||
# (false — только мягкое предупреждение, ASSUMPTION(OQ-12)); abs_terms: коды, которые 1С выгружает со знаком минус, а 66н —
|
# (false — только мягкое предупреждение, ASSUMPTION(OQ-12)); abs_terms: коды, которые 1С выгружает со знаком минус, а 66н —
|
||||||
# в скобках: правая часть считается и как записано, и с этими членами по модулю.
|
# в скобках: правая часть считается и как записано, и с этими членами по модулю.
|
||||||
|
# group: только у мягких — альтернативы одной цели (одна форма и вариант): сошлась хоть одна в колонке — нарушений нет;
|
||||||
|
# не сошлась ни одна — одно нарушение, ближайшая к цели. Знак 2410 (налог: расход или доход, ФСБУ 4/2023) не
|
||||||
|
# определяем — принимаем любой вариант группы (AURORA-1864, OQ-12).
|
||||||
tolerance: 1 # допуск в единицах формы (тыс. руб. округляются построчно)
|
tolerance: 1 # допуск в единицах формы (тыс. руб. округляются построчно)
|
||||||
# OQ-138: единицы шапки верны, расхождение до 0,001 % итоговой строки — округление (мягкое нарушение, статус не портит).
|
# OQ-138: единицы шапки верны, расхождение до 0,001 % итоговой строки — округление (мягкое нарушение, статус не портит).
|
||||||
rounding_tolerance_percent: 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: "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: "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: "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 + 2420", strict: false, abs_terms: ["2410"], group: net_profit}
|
||||||
- {form: INCOME, variant: full, relation: "2400 = 2300 + 2410 + 2460", strict: false}
|
- {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: "2410 = 2411 + 2412", strict: false}
|
||||||
- {form: INCOME, variant: full, relation: "2500 = 2400 + 2510 + 2520 + 2530", 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: "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: "1700 = 1300 + 1350 + 1360 + 1410 + 1450 + 1510 + 1520 + 1550", strict: true}
|
||||||
- {form: BALANCE, variant: simplified, relation: "1600 = 1700", 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")]
|
lines = [line("2110", "1000"), line("2120", "-600"), line("2410", "50"), line("2400", "500")]
|
||||||
checks = failed(lines, INC, FormVariant.SIMPLIFIED, rules)
|
checks = failed(lines, INC, FormVariant.SIMPLIFIED, rules)
|
||||||
assert checks.relations_failed == ()
|
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:
|
def test_rounding_goes_soft(rules: Rules) -> None:
|
||||||
|
|||||||
@ -53,14 +53,39 @@ def test_warning_table_matches_spec(rules: Rules) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_simplified_income_relation_is_soft(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.target == "2400" and not rule.strict
|
||||||
assert rule.abs_terms == frozenset({"2120", "2330", "2350", "2410"})
|
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(
|
assert rules.checks.abs_codes(DocumentType.INCOME, FormVariant.FULL) == frozenset(
|
||||||
{"2120", "2210", "2220", "2330", "2350", "2410"}
|
{"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_code_orders_with_repeats(rules: Rules) -> None:
|
def test_code_orders_with_repeats(rules: Rules) -> None:
|
||||||
fsbu = rules.code_orders.sequence(Edition.FSBU4, DocumentType.BALANCE, FormVariant.FULL)
|
fsbu = rules.code_orders.sequence(Edition.FSBU4, DocumentType.BALANCE, FormVariant.FULL)
|
||||||
assert fsbu.count("1220") == len(("1220", "1220")) and "1120" not in fsbu
|
assert fsbu.count("1220") == len(("1220", "1220")) and "1120" not in fsbu
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user