"""Seed плана счетов (spec reference-data-seed, задача 13; design D-9, OQ-47). Перечень счетов из §4/§6 правил (`docs/context/05-domain-rules.md`) переписан в тест заново, независимо от `seed/core/chart_of_accounts.csv` и от yaml правил (он появится в задаче 14). """ from pathlib import Path import pytest from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import aliased from scripts.seed import SeedError, load_seed, run_seed from scripts.seed_lib.loaders import load_chart_of_accounts from scripts.seed_lib.model import account_codes from src.models.accounting import ChartOfAccount from src.models.enums import AccountKind, AccountRowType API_DIR = Path(__file__).resolve().parent.parent SEED_DIR = API_DIR / "seed" HEADER = "code,parent_code,name,kind,row_type,is_off_balance" # Счета, на которые ссылаются правила 1–8 (§4, ББ), ОФР-таблица (§4) и результирующая таблица (§6); # «76 кроме …» — сам 76 и исключения 76.07.x. Перенесено из текста §4 вручную. ACCOUNTS_S4_RULES_1_8 = { "01", "03", "58.01", "58.03", "80", "83", "60.01", "60.02", "60.03", "60.21", "60.22", "60.31", "60.32", "62.01", "62.02", "62.03", "62.21", "62.22", "62.31", "62.32", "66", "66.03", "66.04", "67", "67.03", "67.04", "76", "76.07.1", "76.07.5", "76.07.9", } # fmt: skip ACCOUNTS_S4_INCOME_AND_S6 = { "90.01", "76.03", "76.05", "76.07.2", "76.08", "76.09", "76.28", "76.38", "66.02", "66.04", "66.06", "66.22", "66.24", "66.26", "67.02", "67.04", "67.06", "67.22", "67.24", "67.26", } # fmt: skip # Чек-лист: BASIC (01, 58, 60, 62, 66, 67, 76, 90) и EXTENDED (OQ-46) CHECKLIST_ACCOUNTS = { "01", "58", "60", "62", "66", "67", "76", "90", "02", "03", "08", "44", "26", "91", "51", "52", "59", "80", "83", "86", "97", "001", "008", "009", "011", } # fmt: skip # Активные синтетические счета Плана счетов 94н (допущение OQ-47) SYNTHETIC_94N = { "01", "02", "03", "04", "05", "07", "08", "09", "10", "11", "14", "15", "16", "19", "20", "21", "23", "25", "26", "28", "29", "40", "41", "42", "43", "44", "45", "46", "50", "51", "52", "55", "57", "58", "59", "60", "62", "63", "66", "67", "68", "69", "70", "71", "73", "75", "76", "77", "79", "80", "81", "82", "83", "84", "86", "90", "91", "94", "96", "97", "98", "99", } # fmt: skip SYNTHETIC_94N_COUNT = 62 MAX_CODE_DOTS = 2 # NN.MM.K OFF_BALANCE = {f"{n:03d}" for n in range(1, 12)} def _write_chart(tmp_path: Path, lines: list[str]) -> Path: core = tmp_path / "core" core.mkdir() (core / "chart_of_accounts.csv").write_text("\n".join([HEADER, *lines]) + "\n", encoding="utf-8") return core def _chart_error(tmp_path: Path, lines: list[str]) -> str: core = _write_chart(tmp_path, lines) with pytest.raises(SeedError) as info: load_chart_of_accounts(core, tmp_path) return str(info.value) # ---------- файл репозитория ---------- def test_repository_chart_is_valid() -> None: accounts = load_seed(SEED_DIR).accounts assert len(accounts) == len(set(accounts)) for code, account in accounts.items(): assert account.code == code assert account.parent_code is None or account.parent_code in accounts if account.row_type is AccountRowType.account: assert account.kind is not None, f"у синтетического счёта {code} не задан kind" else: parent = accounts[str(account.parent_code)] assert account.kind is parent.kind, f"субсчёт {code} должен наследовать kind родителя" assert "." not in code or code == f"{account.parent_code}.{code.rsplit('.', 1)[1]}" assert all(code.count(".") <= MAX_CODE_DOTS for code in accounts) assert "60.01" in accounts assert "60.1" not in accounts def test_repository_chart_off_balance_flag_only_for_001_011() -> None: accounts = load_seed(SEED_DIR).accounts assert {c for c, a in accounts.items() if a.is_off_balance} == OFF_BALANCE assert all( accounts[c].row_type is AccountRowType.account and accounts[c].kind is AccountKind.A for c in OFF_BALANCE ) def test_repository_chart_has_exactly_the_94n_synthetic_accounts() -> None: accounts = load_seed(SEED_DIR).accounts synthetic_on_balance = { c for c, a in accounts.items() if a.row_type is AccountRowType.account and not a.is_off_balance } assert synthetic_on_balance == SYNTHETIC_94N assert len(synthetic_on_balance) == SYNTHETIC_94N_COUNT def test_repository_chart_contains_accounts_of_rules_s4_s6_and_checklist() -> None: codes = account_codes(load_seed(SEED_DIR)) required = ACCOUNTS_S4_RULES_1_8 | ACCOUNTS_S4_INCOME_AND_S6 | CHECKLIST_ACCOUNTS assert required - codes == set() def test_repository_chart_names_are_non_empty_and_codes_normalized() -> None: accounts = load_seed(SEED_DIR).accounts assert all(a.name.strip() for a in accounts.values()) assert accounts["76.07.1"].parent_code == "76.07" assert accounts["76.07"].parent_code == "76" assert accounts["76"].parent_code is None # ---------- валидатор ---------- def test_validator_accepts_minimal_valid_chart(tmp_path: Path) -> None: core = _write_chart( tmp_path, [ "60,,Расчеты с поставщиками,AP,account,false", "60.01,60,Расчеты с поставщиками (руб),AP,subaccount,false", "001,,Арендованные ОС,A,account,true", ], ) accounts = load_chart_of_accounts(core, tmp_path) assert list(accounts) == ["60", "60.01", "001"] assert accounts["60.01"].parent_code == "60" assert accounts["60"].parent_code is None assert accounts["001"].is_off_balance is True def test_validator_rejects_not_normalized_code(tmp_path: Path) -> None: message = _chart_error( tmp_path, ["60,,Расчеты,AP,account,false", "60.1,60,Расчеты (руб),AP,subaccount,false"], ) assert message == ( "core/chart_of_accounts.csv, строка 3 (60.1): код не нормализован, ожидается вид 60, 001, 60.01 или 76.07.1" ) def test_validator_rejects_duplicate_code(tmp_path: Path) -> None: message = _chart_error(tmp_path, ["60,,Расчеты,AP,account,false", "60,,Расчеты еще раз,AP,account,false"]) assert message == "core/chart_of_accounts.csv, строка 3 (60): код уже встречался в строке 2" def test_validator_rejects_missing_parent(tmp_path: Path) -> None: message = _chart_error(tmp_path, ["60.01,60,Расчеты (руб),AP,subaccount,false"]) assert message == "core/chart_of_accounts.csv, строка 2 (60.01): родитель «60» не найден в файле" def test_validator_rejects_parent_that_is_not_code_prefix(tmp_path: Path) -> None: message = _chart_error( tmp_path, [ "60,,Расчеты,AP,account,false", "62,,Покупатели,AP,account,false", "60.01,62,Расчеты (руб),AP,subaccount,false", ], ) assert message == "core/chart_of_accounts.csv, строка 4 (60.01): родитель должен быть «60», указан «62»" def test_validator_rejects_row_type_not_matching_depth(tmp_path: Path) -> None: message = _chart_error(tmp_path, ["60,,Расчеты,AP,account,false", "60.01,60,Расчеты (руб),AP,analytic,false"]) assert message == ( "core/chart_of_accounts.csv, строка 3 (60.01): row_type «analytic» не соответствует глубине кода " "(ожидался «subaccount»)" ) def test_validator_rejects_off_balance_flag_missing_for_off_balance_account(tmp_path: Path) -> None: message = _chart_error(tmp_path, ["001,,Арендованные ОС,A,account,false"]) assert message == "core/chart_of_accounts.csv, строка 2 (001): забалансовый счёт должен иметь is_off_balance=true" def test_validator_rejects_off_balance_flag_for_on_balance_account(tmp_path: Path) -> None: message = _chart_error(tmp_path, ["60,,Расчеты,AP,account,true"]) assert ( message == "core/chart_of_accounts.csv, строка 2 (60): is_off_balance=true допустим только для счетов 001–011" ) def test_validator_rejects_three_digit_code_outside_off_balance_range(tmp_path: Path) -> None: message = _chart_error(tmp_path, ["012,,Неизвестный,A,account,true"]) assert message == "core/chart_of_accounts.csv, строка 2 (012): трёхзначные коды допустимы только для 001–011" def test_validator_rejects_account_without_kind(tmp_path: Path) -> None: message = _chart_error(tmp_path, ["60,,Расчеты,,account,false"]) assert message == "core/chart_of_accounts.csv, строка 2 (60): у синтетического счёта должен быть задан kind" def test_validator_rejects_unknown_kind_and_row_type(tmp_path: Path) -> None: assert _chart_error(tmp_path, ["60,,Расчеты,X,account,false"]) == ( "core/chart_of_accounts.csv, строка 2 (60): неизвестный kind «X» (допустимы A, P, AP)" ) def test_validator_rejects_unknown_row_type(tmp_path: Path) -> None: message = _chart_error(tmp_path, ["60,,Расчеты,AP,synthetic,false"]) assert message == ( "core/chart_of_accounts.csv, строка 2 (60): " "неизвестный row_type «synthetic» (допустимы account, subaccount, analytic)" ) def test_validator_rejects_non_boolean_flag(tmp_path: Path) -> None: message = _chart_error(tmp_path, ["60,,Расчеты,AP,account,yes"]) assert ( message == "core/chart_of_accounts.csv, строка 2 (60): is_off_balance должен быть true или false, получено «yes»" ) def test_validator_rejects_wrong_header(tmp_path: Path) -> None: core = tmp_path / "core" core.mkdir() (core / "chart_of_accounts.csv").write_text("code,name\n60,Расчеты\n", encoding="utf-8") with pytest.raises(SeedError) as info: load_chart_of_accounts(core, tmp_path) assert str(info.value) == ( "core/chart_of_accounts.csv: заголовок должен быть «code,parent_code,name,kind,row_type,is_off_balance»" ) def test_validator_rejects_missing_file(tmp_path: Path) -> None: (tmp_path / "core").mkdir() with pytest.raises(SeedError, match="core/chart_of_accounts.csv: файл не найден"): load_chart_of_accounts(tmp_path / "core", tmp_path) # ---------- БД ---------- @pytest.mark.integration async def test_run_seed_inserts_chart_with_hierarchy(db_session: AsyncSession) -> None: report = await run_seed(db_session) expected = len(load_seed(SEED_DIR).accounts) assert report.tables["chart_of_account"].inserted == expected assert (await db_session.scalar(select(func.count()).select_from(ChartOfAccount))) == expected stored = set((await db_session.scalars(select(ChartOfAccount.code))).all()) assert (ACCOUNTS_S4_RULES_1_8 | ACCOUNTS_S4_INCOME_AND_S6 | CHECKLIST_ACCOUNTS) - stored == set() parent = aliased(ChartOfAccount) pairs = dict( ( await db_session.execute( select(ChartOfAccount.code, parent.code).join(parent, ChartOfAccount.parent_id == parent.id) ) ).all() ) assert pairs["76.07.1"] == "76.07" assert pairs["76.07"] == "76" assert pairs["60.01"] == "60" assert "76" not in pairs root_codes = set( (await db_session.scalars(select(ChartOfAccount.code).where(ChartOfAccount.parent_id.is_(None)))).all() ) assert root_codes == SYNTHETIC_94N | OFF_BALANCE @pytest.mark.integration async def test_chart_columns_stored_as_in_file(db_session: AsyncSession) -> None: await run_seed(db_session) rows = {c.code: c for c in (await db_session.scalars(select(ChartOfAccount))).all()} assert rows["60"].kind is AccountKind.AP assert rows["01"].kind is AccountKind.A and rows["02"].kind is AccountKind.P assert rows["76.07.1"].row_type is AccountRowType.analytic assert rows["60.01"].row_type is AccountRowType.subaccount assert {c for c, r in rows.items() if r.is_off_balance} == OFF_BALANCE @pytest.mark.integration async def test_second_run_keeps_chart_row_count(db_session: AsyncSession) -> None: await run_seed(db_session) first = await db_session.scalar(select(func.count()).select_from(ChartOfAccount)) report = await run_seed(db_session) assert await db_session.scalar(select(func.count()).select_from(ChartOfAccount)) == first stat = report.tables["chart_of_account"] assert (stat.inserted, stat.updated, stat.unchanged) == (0, 0, first) @pytest.mark.integration async def test_chart_name_change_updates_row_without_new_one(db_session: AsyncSession, tmp_path: Path) -> None: await run_seed(db_session) seed_copy = tmp_path / "seed" (seed_copy / "core").mkdir(parents=True) for file in (SEED_DIR / "core").iterdir(): (seed_copy / "core" / file.name).write_bytes(file.read_bytes()) path = seed_copy / "core" / "chart_of_accounts.csv" path.write_text(path.read_text(encoding="utf-8").replace("Паи и акции", "Паи и доли"), encoding="utf-8") before = await db_session.scalar(select(func.count()).select_from(ChartOfAccount)) report = await run_seed(db_session, seed_dir=seed_copy) assert report.tables["chart_of_account"].updated == 1 assert await db_session.scalar(select(func.count()).select_from(ChartOfAccount)) == before assert await db_session.scalar(select(ChartOfAccount.name).where(ChartOfAccount.code == "58.01")) == "Паи и доли"