"""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 не отзывается