arm-core/api/tests/test_seed_rules.py

493 lines
21 KiB
Python
Raw Permalink 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.

"""Seed правил элиминации ББ (§4) и чек-листа обязательных документов (spec reference-data-seed, задача 14).
Составы счетов правил §4 и чек-лист (ТЗ 5.1, OQ-46) переписаны в тест заново, независимо от yaml в `seed/core/`.
"""
import copy
import datetime
import shutil
from pathlib import Path
from typing import Any
import pytest
import yaml
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from scripts.seed import SeedError, load_seed, run_seed
from scripts.seed_lib.loaders_rules import load_elimination_rules, load_required_document_rules
from src.models.access import Permission, Role, RolePermission
from src.models.accounting import ChartOfAccount, EliminationRule, EliminationRuleAccount
from src.models.audit_ex import RequiredDocumentRule
from src.models.directories import RegionalOffice
from src.models.enums import ConsolidationApproach, DocumentType, StatementType
API_DIR = Path(__file__).resolve().parent.parent
SEED_DIR = API_DIR / "seed"
# docs/context/05-domain-rules.md §4, таблица ББ. Состав: rule_no -> (статья А, статья Б, mode,
# счета А, исключения А, счета Б, исключения Б). «Кроме» — исключения; «01 — резервный» включён в счета А правила 1;
# «76.07.1 − 76.07.5 − 76.07.9» (Б, правило 1) — 76.07.1 с исключениями 76.07.5, 76.07.9.
RECEIVABLES_A = ["60.02", "60.22", "60.32", "62.01", "62.03", "62.21", "62.31", "76"]
RULES_S4: dict[int, dict[str, Any]] = {
1: {
"lines": ("1150", "1450/1550"),
"mode": "manual",
"a": ["01", "03", "76.07.9"],
"a_excl": [],
"b": ["76.07.1"],
"b_excl": ["76.07.5", "76.07.9"],
},
2: {
"lines": ("1170", "1310/1350"),
"mode": "manual",
"a": ["58.01"],
"a_excl": [],
"b": ["80", "83"],
"b_excl": [],
},
3: {"lines": ("1170", "1410"), "mode": "min", "a": ["58.03"], "a_excl": [], "b": ["67.03"], "b_excl": []},
4: {"lines": ("1240", "1510"), "mode": "min", "a": ["58.03"], "a_excl": [], "b": ["66.03"], "b_excl": []},
5: {
"lines": ("1230", "1510"),
"mode": "min",
"a": ["76"],
"a_excl": ["76.07.5", "76.07.9"],
"b": ["66.04", "67.04"],
"b_excl": [],
},
6: {
"lines": ("1230", "1520"),
"mode": "min",
"a": RECEIVABLES_A,
"a_excl": ["76.07.5", "76.07.9"],
"b": ["60.01", "60.03", "60.21", "60.31", "62.02", "62.22", "62.32", "76"],
"b_excl": ["76.07.1"],
},
7: {
"lines": ("1230", "1410"),
"mode": "min",
"a": RECEIVABLES_A,
"a_excl": ["76.07.5", "76.07.9"],
"b": ["67"],
"b_excl": [],
},
8: {
"lines": ("1230", "1510"),
"mode": "min",
"a": RECEIVABLES_A,
"a_excl": ["76.07.5", "76.07.9"],
"b": ["66"],
"b_excl": [],
},
}
# ТЗ 5.1 / spec «Первый запуск»: BASIC — ББ, ОФР и ОСВ по 8 счетам; EXTENDED — BASIC + 17 ОСВ (ASSUMPTION(OQ-46))
BASIC_OSV = {"01", "58", "60", "62", "66", "67", "76", "90"}
EXTENDED_ONLY_OSV = {
"02",
"03",
"08",
"44",
"26",
"91",
"51",
"52",
"59",
"80",
"83",
"86",
"97",
"001",
"008",
"009",
"011",
}
BASIC_CHECKLIST = {("BALANCE", None), ("INCOME", None)} | {("OSV", code) for code in BASIC_OSV}
EXTENDED_CHECKLIST = BASIC_CHECKLIST | {("OSV", code) for code in EXTENDED_ONLY_OSV}
#: таблицы, затрагиваемые seed: «ничего не записано» и «повтор не плодит строки» проверяются по ним всем
SEEDED_TABLES = (
RegionalOffice,
Role,
Permission,
RolePermission,
ChartOfAccount,
EliminationRule,
EliminationRuleAccount,
RequiredDocumentRule,
)
RULES_COUNT = 8
MAIN_PAIR_NO = 6
COMPANY_2 = 2
NULL_ACCOUNT_ROWS = 4 # BALANCE и INCOME для BASIC и EXTENDED
BASIC_COUNT = 10
EXTENDED_COUNT = 27
def _copy_seed(tmp_path: Path) -> Path:
target = tmp_path / "seed"
shutil.copytree(SEED_DIR, target)
return target
def _edit_yaml(path: Path, change: Any) -> None:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
change(data)
path.write_text(yaml.safe_dump(data, allow_unicode=True, sort_keys=False), encoding="utf-8")
def _rules_error(tmp_path: Path, change: Any, known: set[str] | None = None) -> str:
"""Копия yaml правил с правкой -> текст SeedError загрузчика."""
core = tmp_path / "core"
core.mkdir(exist_ok=True)
target = core / "elimination_rules_v1.yaml"
target.write_text((SEED_DIR / "core" / "elimination_rules_v1.yaml").read_text(encoding="utf-8"), encoding="utf-8")
_edit_yaml(target, change)
with pytest.raises(SeedError) as info:
load_elimination_rules(core, tmp_path, known if known is not None else set(load_seed(SEED_DIR).accounts))
return str(info.value)
def _checklist_error(tmp_path: Path, change: Any) -> str:
core = tmp_path / "core"
core.mkdir(exist_ok=True)
target = core / "required_document_rules.yaml"
target.write_text(
(SEED_DIR / "core" / "required_document_rules.yaml").read_text(encoding="utf-8"), encoding="utf-8"
)
_edit_yaml(target, change)
with pytest.raises(SeedError) as info:
load_required_document_rules(core, tmp_path, set(load_seed(SEED_DIR).accounts))
return str(info.value)
# ---------- файлы репозитория (без БД) ----------
def test_repository_rules_match_s4_literal() -> None:
rules = {r.rule_no: r for r in load_seed(SEED_DIR).elimination_rules}
assert set(rules) == set(RULES_S4)
for no, expected in RULES_S4.items():
rule = rules[no]
assert (rule.article_1_line, rule.article_2_line) == expected["lines"], no
assert rule.elimination_mode == expected["mode"], no
assert (rule.statement_type, rule.version_no, rule.measure, rule.side_1) == (
StatementType.balance,
1,
"closing",
"D",
)
assert rule.valid_from == datetime.date(2024, 1, 1)
assert rule.vat_handling is None and rule.excise_adjust is False
assert set(rule.sides[1].accounts) == set(expected["a"]), no
assert set(rule.sides[1].excluded) == set(expected["a_excl"]), no
assert set(rule.sides[2].accounts) == set(expected["b"]), no
assert set(rule.sides[2].excluded) == set(expected["b_excl"]), no
def test_repository_checklist_matches_spec() -> None:
rules = load_seed(SEED_DIR).required_document_rules
for approach, expected in (("BASIC", BASIC_CHECKLIST), ("EXTENDED", EXTENDED_CHECKLIST)):
actual = {(r.document_type.value, r.account_code) for r in rules if r.approach.value == approach}
assert actual == expected
assert len([r for r in rules if r.approach.value == approach]) == len(expected)
assert len(rules) == BASIC_COUNT + EXTENDED_COUNT
assert all(r.is_mandatory for r in rules)
def test_every_rule_has_accounts_on_both_sides() -> None:
for rule in load_seed(SEED_DIR).elimination_rules:
assert rule.sides[1].accounts and rule.sides[2].accounts, rule.rule_no
# ---------- валидатор правил ----------
def test_unknown_account_in_rule_names_account_and_rule(tmp_path: Path) -> None:
def change(data: dict[str, Any]) -> None:
data["rules"][5]["company_1"]["accounts"].append("99.99")
message = _rules_error(tmp_path, change)
assert "99.99" in message and "№ 6" in message and "сторона 1" in message
assert "не найден в плане счетов" in message
def test_unknown_excluded_account_is_rejected(tmp_path: Path) -> None:
def change(data: dict[str, Any]) -> None:
data["rules"][0]["company_2"]["excluded"].append("76.99")
message = _rules_error(tmp_path, change)
assert "76.99" in message and "№ 1" in message and "сторона 2" in message
def test_account_written_without_quotes_is_rejected(tmp_path: Path) -> None:
def change(data: dict[str, Any]) -> None:
data["rules"][2]["company_1"]["accounts"] = [58.03]
assert "не строка" in _rules_error(tmp_path, change)
@pytest.mark.parametrize(
("change", "fragment"),
[
(lambda d: d["rules"][1].update(side_1="X"), "side_1"),
(lambda d: d["rules"][1].update(measure="avg"), "measure"),
(lambda d: d["rules"][1].update(elimination_mode="max"), "elimination_mode"),
(lambda d: d["rules"][1].pop("article_1_line"), "article_1_line"),
(lambda d: d["rules"][1].update(excise_adjust="да"), "excise_adjust"),
(lambda d: d["rules"][1].update(unknown=1), "unknown"),
(lambda d: d["rules"][1].update(rule_no=1), "№ 1"),
(lambda d: d["rules"][1].update(rule_no=0), "rule_no"),
(lambda d: d.update(statement_type="profit"), "statement_type"),
(lambda d: d.update(version_no=0), "version_no"),
(lambda d: d.update(valid_from="2024-01-01"), "valid_from"),
(lambda d: d.pop("rules"), "rules"),
(lambda d: d["rules"][1].pop("company_2"), "company_2"),
(lambda d: d["rules"][1].update(company_2={"accounts": []}), "нет счетов стороны"),
(lambda d: d["rules"][1].update(company_2={"accounts": ["80", "80"]}), "больше одного раза"),
(lambda d: d["rules"][1].update(company_2={"accounts": ["80"], "excluded": ["80"]}), "включён и исключён"),
(lambda d: d["rules"][1].update(company_2={"accounts": ["80"], "extra": []}), "extra"),
(lambda d: d["rules"][1].update(company_3={"accounts": ["80"]}), "company_3"),
],
)
def test_rules_validator_rejects(tmp_path: Path, change: Any, fragment: str) -> None:
assert fragment in _rules_error(tmp_path, change)
def test_rules_file_missing(tmp_path: Path) -> None:
(tmp_path / "core").mkdir()
with pytest.raises(SeedError, match="elimination_rules_v1.yaml: файл не найден"):
load_elimination_rules(tmp_path / "core", tmp_path, set())
# ---------- валидатор чек-листа ----------
def test_checklist_unknown_account_names_approach_and_account(tmp_path: Path) -> None:
def change(data: list[dict[str, Any]]) -> None:
data.append({"approach": "EXTENDED", "document_type": "OSV", "account": "95", "is_mandatory": True})
message = _checklist_error(tmp_path, change)
assert "чек-лист (EXTENDED)" in message and "счёт 95 не найден в плане счетов" in message
@pytest.mark.parametrize(
("change", "fragment"),
[
(lambda d: d[0].update(approach="FULL"), "approach"),
(lambda d: d[0].update(document_type="PASSPORT"), "document_type"),
(lambda d: d[0].update(account="01"), "account допустим только для OSV"),
(lambda d: d[2].pop("account"), "для OSV обязателен account"),
(lambda d: d[2].update(account=1), "обязателен account"),
(lambda d: d.append(copy.deepcopy(d[0])), "больше одного раза"),
(lambda d: d.append(copy.deepcopy(d[2])), "больше одного раза"),
(lambda d: d[0].update(is_mandatory="yes"), "is_mandatory"),
(lambda d: d[0].update(extra=1), "extra"),
(lambda d: d[0].pop("document_type"), "document_type"),
],
)
def test_checklist_validator_rejects(tmp_path: Path, change: Any, fragment: str) -> None:
assert fragment in _checklist_error(tmp_path, change)
def test_checklist_file_missing(tmp_path: Path) -> None:
(tmp_path / "core").mkdir()
with pytest.raises(SeedError, match="required_document_rules.yaml: файл не найден"):
load_required_document_rules(tmp_path / "core", tmp_path, set())
def test_load_seed_fails_on_unknown_account_without_database(tmp_path: Path) -> None:
seed = _copy_seed(tmp_path)
_edit_yaml(
seed / "core" / "elimination_rules_v1.yaml", lambda d: d["rules"][5]["company_1"]["accounts"].append("99.99")
)
with pytest.raises(SeedError, match=r"№ 6.*99\.99"):
load_seed(seed)
# ---------- БД ----------
async def _counts(session: AsyncSession) -> dict[str, int]:
return {
model.__tablename__: await session.scalar(select(func.count()).select_from(model)) or 0
for model in SEEDED_TABLES
}
async def _rule_sides(session: AsyncSession) -> set[tuple[int, int, str, bool]]:
rows = (
await session.execute(
select(
EliminationRule.rule_no,
EliminationRuleAccount.company_no,
ChartOfAccount.code,
EliminationRuleAccount.is_excluded,
)
.join(EliminationRuleAccount, EliminationRuleAccount.rule_id == EliminationRule.id)
.join(ChartOfAccount, ChartOfAccount.id == EliminationRuleAccount.account_id)
)
).all()
return {(no, company, code, excluded) for no, company, code, excluded in rows}
@pytest.mark.integration
async def test_first_run_inserts_rules_and_checklist(db_session: AsyncSession) -> None:
report = await run_seed(db_session)
assert report.tables["elimination_rule"].inserted == RULES_COUNT
assert report.tables["required_document_rule"].inserted == BASIC_COUNT + EXTENDED_COUNT
assert report.tables["elimination_rule_account"].inserted == sum(
len(r["a"]) + len(r["a_excl"]) + len(r["b"]) + len(r["b_excl"]) for r in RULES_S4.values()
)
rules = (await db_session.scalars(select(EliminationRule).order_by(EliminationRule.rule_no))).all()
assert [r.rule_no for r in rules] == list(range(1, RULES_COUNT + 1))
assert all(r.statement_type is StatementType.balance and r.version_no == 1 for r in rules)
assert all(r.valid_from == datetime.date(2024, 1, 1) and r.valid_to is None for r in rules)
assert all(r.measure == "closing" and r.side_1 == "D" and r.excise_adjust is False for r in rules)
assert all(r.vat_handling is None for r in rules)
sides = await _rule_sides(db_session)
for rule in rules:
companies = {company for no, company, _, _ in sides if no == rule.rule_no}
assert companies == {1, 2}, rule.rule_no
@pytest.mark.integration
async def test_rule_6_main_pair_scenario(db_session: AsyncSession) -> None:
await run_seed(db_session)
rule = (await db_session.scalars(select(EliminationRule).where(EliminationRule.rule_no == MAIN_PAIR_NO))).one()
assert rule.elimination_mode == "min"
assert rule.measure == "closing"
assert rule.side_1 == "D"
sides = {(c, code, excluded) for no, c, code, excluded in await _rule_sides(db_session) if no == MAIN_PAIR_NO}
side_1 = {code for c, code, excluded in sides if c == 1 and not excluded}
side_1_excluded = {code for c, code, excluded in sides if c == 1 and excluded}
side_2 = {code for c, code, excluded in sides if c == COMPANY_2 and not excluded}
side_2_excluded = {code for c, code, excluded in sides if c == COMPANY_2 and excluded}
assert side_1 == {"60.02", "60.22", "60.32", "62.01", "62.03", "62.21", "62.31", "76"}
assert side_1_excluded == {"76.07.5", "76.07.9"}
assert side_2 == {"60.01", "60.03", "60.21", "60.31", "62.02", "62.22", "62.32", "76"}
assert side_2_excluded == {"76.07.1"}
@pytest.mark.integration
async def test_rule_accounts_equal_s4_literal(db_session: AsyncSession) -> None:
await run_seed(db_session)
expected = {
(no, company, code, excluded)
for no, rule in RULES_S4.items()
for company, included, excluded_codes in ((1, rule["a"], rule["a_excl"]), (2, rule["b"], rule["b_excl"]))
for code, excluded in [(c, False) for c in included] + [(c, True) for c in excluded_codes]
}
assert await _rule_sides(db_session) == expected
@pytest.mark.integration
async def test_rule_columns_equal_s4(db_session: AsyncSession) -> None:
await run_seed(db_session)
rules = {r.rule_no: r for r in (await db_session.scalars(select(EliminationRule))).all()}
for no, expected in RULES_S4.items():
assert (rules[no].article_1_line, rules[no].article_2_line) == expected["lines"], no
assert rules[no].elimination_mode == expected["mode"], no
assert rules[1].comment and "76.07.5" in rules[1].comment
@pytest.mark.integration
async def test_checklist_rows(db_session: AsyncSession) -> None:
await run_seed(db_session)
rows = (
await db_session.execute(
select(
RequiredDocumentRule.approach,
RequiredDocumentRule.document_type,
ChartOfAccount.code,
RequiredDocumentRule.is_mandatory,
).outerjoin(ChartOfAccount, ChartOfAccount.id == RequiredDocumentRule.account_id)
)
).all()
by_approach: dict[ConsolidationApproach, set[tuple[str, str | None]]] = {}
for approach, document_type, code, mandatory in rows:
assert mandatory is True
assert (document_type is DocumentType.OSV) == (code is not None)
by_approach.setdefault(approach, set()).add((document_type.value, code))
assert by_approach[ConsolidationApproach.BASIC] == BASIC_CHECKLIST
assert by_approach[ConsolidationApproach.EXTENDED] == EXTENDED_CHECKLIST
assert len(rows) == BASIC_COUNT + EXTENDED_COUNT
null_account = await db_session.scalar(
select(func.count()).select_from(RequiredDocumentRule).where(RequiredDocumentRule.account_id.is_(None))
)
assert null_account == NULL_ACCOUNT_ROWS
@pytest.mark.integration
async def test_second_run_keeps_all_row_counts(db_session: AsyncSession) -> None:
await run_seed(db_session)
first = await _counts(db_session)
assert first["required_document_rule"] == BASIC_COUNT + EXTENDED_COUNT
report = await run_seed(db_session)
assert await _counts(db_session) == first
for table in ("elimination_rule", "elimination_rule_account", "required_document_rule"):
stat = report.tables[table]
assert (stat.inserted, stat.updated) == (0, 0), table
assert stat.unchanged == first[table]
@pytest.mark.integration
async def test_unknown_account_aborts_before_any_write(db_session: AsyncSession, tmp_path: Path) -> None:
seed = _copy_seed(tmp_path)
_edit_yaml(
seed / "core" / "elimination_rules_v1.yaml", lambda d: d["rules"][5]["company_1"]["accounts"].append("99.99")
)
before = await _counts(db_session)
assert set(before.values()) == {0} # свежая БД: ничего не загружено
with pytest.raises(SeedError) as info:
await run_seed(db_session, seed_dir=seed)
assert "99.99" in str(info.value) and "№ 6" in str(info.value)
assert await _counts(db_session) == before
@pytest.mark.integration
async def test_unknown_account_in_checklist_aborts_before_any_write(db_session: AsyncSession, tmp_path: Path) -> None:
seed = _copy_seed(tmp_path)
_edit_yaml(
seed / "core" / "required_document_rules.yaml",
lambda d: d.append({"approach": "EXTENDED", "document_type": "OSV", "account": "95", "is_mandatory": True}),
)
with pytest.raises(SeedError, match=r"чек-лист \(EXTENDED\).*95"):
await run_seed(db_session, seed_dir=seed)
assert set((await _counts(db_session)).values()) == {0}
@pytest.mark.integration
async def test_changed_rule_and_flags_are_updated_without_duplicates(db_session: AsyncSession, tmp_path: Path) -> None:
await run_seed(db_session)
before = await _counts(db_session)
seed = _copy_seed(tmp_path)
def change(data: dict[str, Any]) -> None:
data["rules"][2]["comment"] = "уточнено"
side = data["rules"][5]["company_2"]
side["accounts"].remove("62.32") # убран из файла — в БД остаётся
side["excluded"].append("76.07.2")
data["rules"][4]["company_1"][
"excluded"
] = [] # было исключение, теперь нет — строка остаётся, флаг не меняется
data["rules"][4]["company_1"]["accounts"].append("76.07.5") # теперь включён: is_excluded true -> false
_edit_yaml(seed / "core" / "elimination_rules_v1.yaml", change)
_edit_yaml(seed / "core" / "required_document_rules.yaml", lambda d: d[2].update(is_mandatory=False))
report = await run_seed(db_session, seed_dir=seed)
assert report.tables["elimination_rule"].updated == 1
assert report.tables["elimination_rule_account"].inserted == 1 # 76.07.2 в исключения правила 6
assert report.tables["elimination_rule_account"].updated == 1 # 76.07.5 правила 5: исключён -> включён
assert report.tables["required_document_rule"].updated == 1
after = await _counts(db_session)
assert after["elimination_rule"] == before["elimination_rule"]
assert after["required_document_rule"] == before["required_document_rule"]
assert after["elimination_rule_account"] == before["elimination_rule_account"] + 1
sides = await _rule_sides(db_session)
assert (6, 2, "62.32", False) in sides # без удалений
assert (6, 2, "76.07.2", True) in sides
assert (5, 1, "76.07.5", False) in sides and (5, 1, "76.07.5", True) not in sides
assert (5, 1, "76.07.9", True) in sides # убранное из excluded не отзывается