arm-core/api/tests/test_seed_chart.py
2026-10-03 21:17:18 +04:00

303 lines
14 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.

"""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")) == "Паи и доли"