Andrey Epifancev 065bcba25a 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>
2026-11-06 22:55:22 +04:00

194 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import re
from decimal import Decimal
from pathlib import Path
import pytest
import yaml
from arm_statements.issues import WarningCode, make_warning
from arm_statements.loader import LABEL_KEYS, Rules, RulesError, load_rules
from arm_statements.model import DocumentType, Edition, FormVariant, Severity
RULES_DIR = Path(__file__).resolve().parent.parent / "arm_statements" / "rules"
SPEC_WARNINGS = {
"JSON_FAILED": ("error", "file"),
"TABLE_PARSE_ERROR": ("error", "file"),
"TABLE_LOST": ("error", "file"),
"TABLE_SKIPPED": ("info", "file"),
"CODE_COLUMN_NOT_FOUND": ("error", "document"),
"SEGMENT_WITHOUT_CODES": ("error", "document"),
"SHEET_NOT_JOINED": ("error", "document"),
"CONTINUATION_TYPE_CONFLICT": ("error", "document"),
"SEGMENT_COLUMNS_MISMATCH": ("error", "document"),
"COLUMN_WITHOUT_DATE": ("error", "document"),
"COLUMN_DATE_UNCERTAIN": ("error", "document"),
"HEADER_ROW_NOT_FOUND": ("error", "document"),
"TYPE_CONFLICT": ("error", "document"),
"NO_ORGANIZATION_NO_INN": ("error", "document"),
"PERIOD_UNKNOWN": ("error", "document"),
"REPORT_DATE_UNCERTAIN": ("error", "document"),
"XLSB_SCALE_SUSPECT": ("error", "document"),
"INN_NOT_FOUND": ("info", "document"),
"INN_UNCERTAIN": ("info", "document"),
"ORGANIZATION_NOT_FOUND": ("info", "document"),
"ORGANIZATION_UNCERTAIN": ("info", "document"),
"REPORT_DATE_UNPARSED": ("info", "document"),
"UNITS_NOT_FOUND": ("info", "document"),
"FILE_TYPE_MISMATCH": ("info", "file"),
"COLUMN_SKIPPED": ("info", "document"),
"FRACTIONAL_IN_THOUSANDS": ("info", "document"),
"XLSB_NO_FORMATS": ("info", "document"),
"NUMBER_FORMAT_UNPARSED": ("info", "document"),
"VALUE_OUT_OF_RANGE": ("info", "document"),
"SHEET_OTHER": ("info", "file"),
"ROW_SPLIT": ("info", "document"),
"NUMBERS_OUTSIDE_TABLE": ("info", "document"),
"XLS_NO_CODEPAGE": ("info", "file"),
}
def test_warning_table_matches_spec(rules: Rules) -> None:
assert {c: (w.severity.value, w.scope) for c, w in rules.warnings.items()} == SPEC_WARNINGS
def test_simplified_income_relation_is_soft(rules: Rules) -> None:
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_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
ras = rules.code_orders.sequence(Edition.RAS_66N, DocumentType.BALANCE, FormVariant.FULL)
assert "1120" in ras and ras.index("1110") < ras.index("1100")
assert rules.code_orders.editions == (Edition.RAS_66N, Edition.FSBU4)
assert rules.code_orders.sequence(Edition.FSBU4, DocumentType.DISCLOSURE, FormVariant.FULL) == ()
def test_public_rule_types(rules: Rules) -> None:
assert isinstance(rules.checks.tolerance, Decimal) and rules.checks.tolerance == Decimal("1")
assert all(isinstance(e, Edition) for e in rules.code_orders.editions)
fsbu = rules.code_orders.sequence(Edition.FSBU4, DocumentType.INCOME, FormVariant.SIMPLIFIED)
assert isinstance(fsbu, tuple) and all(isinstance(c, str) for c in fsbu)
assert (rules.code_ranges.min_codes_continuation, rules.code_ranges.min_codes) == (1, 3)
assert tuple(rules.headers.labels) == LABEL_KEYS
def test_warning_code_enum_matches_dictionary(rules: Rules) -> None:
assert {c.value for c in WarningCode} == set(rules.warnings) == set(SPEC_WARNINGS)
assert WarningCode.XLS_NO_CODEPAGE == "XLS_NO_CODEPAGE"
def test_no_code_to_name_mapping_anywhere() -> None:
code = re.compile(r"^\d{4,5}$")
def walk(node: object) -> None:
if isinstance(node, dict):
for key, value in node.items():
assert not (code.match(str(key)) and isinstance(value, str) and re.search(r"[А-Яа-яЁё]", value))
walk(value)
elif isinstance(node, list):
for item in node:
walk(item)
for path in RULES_DIR.glob("*.yaml"):
walk(yaml.safe_load(path.read_text(encoding="utf-8")))
def test_broken_repeat_count_rejected(tmp_path: Path) -> None:
for path in RULES_DIR.glob("*.yaml"):
(tmp_path / path.name).write_bytes(path.read_bytes())
orders = tmp_path / "statement_code_orders.yaml"
orders.write_text(
orders.read_text(encoding="utf-8").replace('- "1520" # «Обязательство', '# "1520"'), encoding="utf-8"
)
with pytest.raises(RulesError, match="statement_code_orders"):
load_rules(tmp_path)
def test_unknown_severity_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_warnings.yaml"
target.write_text(
target.read_text(encoding="utf-8").replace("severity: info", "severity: warn", 1), encoding="utf-8"
)
with pytest.raises(RulesError, match="statement_warnings"):
load_rules(tmp_path)
def test_make_warning_takes_severity_from_dictionary(rules: Rules) -> None:
warning = make_warning(WarningCode.COLUMN_SKIPPED, "колонка «Изменение, %» пропущена", rules, column_index=5)
assert (warning.code, warning.severity, warning.column_index) == ("COLUMN_SKIPPED", Severity.INFO, 5)
assert make_warning("JSON_FAILED", "x", rules).severity is Severity.ERROR
def test_make_warning_unknown_code_raises(rules: Rules) -> None:
with pytest.raises(KeyError, match="NOT_A_CODE"):
make_warning("NOT_A_CODE", "x", rules)
def test_label_keys_are_checked(tmp_path: Path) -> None:
for path in RULES_DIR.glob("*.yaml"):
(tmp_path / path.name).write_bytes(path.read_bytes())
target = tmp_path / "statement_headers.yaml"
target.write_text(target.read_text(encoding="utf-8").replace(" okpo:", " okpo_x:", 1), encoding="utf-8")
with pytest.raises(RulesError, match="statement_headers"):
load_rules(tmp_path)
def test_absolute_period_kinds_come_from_dictionary(rules: Rules) -> None:
expected = [
"range_end",
"day_month_year",
"quarter",
"half",
"months",
"month_range",
"year",
]
data = yaml.safe_load((RULES_DIR / "statement_periods.yaml").read_text(encoding="utf-8"))
assert [p["kind"] for p in data["absolute"]] == expected
assert [p.kind for p in rules.periods.absolute] == expected
def test_repeats_for_unknown_form_rejected(tmp_path: Path) -> None:
for path in RULES_DIR.glob("*.yaml"):
(tmp_path / path.name).write_bytes(path.read_bytes())
orders = tmp_path / "statement_code_orders.yaml"
orders.write_text(
orders.read_text(encoding="utf-8").replace(" INCOME: []", " DISCLOSURE: []", 1), encoding="utf-8"
)
with pytest.raises(RulesError, match="repeats"):
load_rules(tmp_path)