arm-core/api/tests/test_seed_chart.py
Andrey Epifancev 096d53b10c feat(seed): полный план счетов 1С в seed (488 строк)
Seed chart_of_accounts.csv заменён выгрузкой 1С: буквенные субсчета (01.К, 62.ОТ, 76.АВ),
забалансовые 001–012 и буквенные корни (МЦ, ГТД…), «Я…». Валидатор seed принимает буквенные
коды, 000 и 012, сирот выгрузки («Я68.01» без «Я68») с пустым parent_code; kind — из «Вида» 1С.
18 из 19 кодов реальных ОСВ теперь получают account_id (60.АВ — только в синтетике, в 1С нет).
ASSUMPTION(OQ-113): выгрузка считается полной.

Closes #23

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
2026-10-06 12:57:20 +04:00

379 lines
19 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).
План счетов — полная выгрузка 1С заказчика (issue #23, ASSUMPTION(OQ-113): выгрузка полна, 488 строк).
Перечень счетов из §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, 13)} # 001–012
OFF_BALANCE_LETTER = {"ГТД", "ИНВ", "КВ", "МЦ", "НЕ", "ОТ", "РВ", "УСН"} # буквенные забалансовые корни 1С
# Балансовые корни выгрузки 1С вне Плана счетов 94н: вспомогательный 000, 53 (в 94н не активен) и «Я…» (не используется)
EXTRA_ON_BALANCE_ROOTS = {"000", "53", "Я75", "Я80", "Я81", "Я96"}
CHART_SIZE = 488
# Субсчета реальных ОСВ, которых не было в seed из 122 строк (OQ-113: 19 кодов с account_id = NULL)
OSV_SUBACCOUNTS_FORMERLY_MISSING = {"01.01", "66.01", "76.01", "76.ОТ"}
# «60.АВ» есть только в синтетическом снимке s09 (fixtures/golden), в плане 1С его нет: account_id = NULL
ORPHANS_1C = {"Я68.01", "Я68.21", "Я68.32", "Я68.42", "Я01.К", "Я69.06.5"} # без родителя в выгрузке
NOT_IN_1C_PLAN = {"60.АВ"}
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
# kind берётся из «Вида» 1С и у субсчёта может отличаться от родителя (60.01 — П при 60 — АП)
assert account.kind is not None, f"у счёта {code} не задан kind"
if account.parent_code is not None:
assert code == f"{account.parent_code}.{code.rsplit('.', 1)[1]}"
assert accounts[account.parent_code].is_off_balance == account.is_off_balance
assert len(accounts) == CHART_SIZE
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_has_no_parent_cycles_and_orphans_are_only_unused_copies() -> None:
accounts = load_seed(SEED_DIR).accounts
for code in accounts:
seen, current = {code}, accounts[code].parent_code
while current is not None:
assert current not in seen, f"цикл в иерархии через {current}"
seen.add(current)
current = accounts[current].parent_code
orphans = {c for c, a in accounts.items() if a.parent_code is None and a.row_type is not AccountRowType.account}
# в выгрузке 1С нет родителей «Я68», «Я01», «Я69.06»: родителей не выдумываем
assert orphans == {"Я68.01", "Я68.21", "Я68.32", "Я68.42", "Я01.К", "Я69.06.5"}
def test_repository_chart_keeps_letter_and_off_balance_accounts_of_1c() -> None:
accounts = load_seed(SEED_DIR).accounts
assert {"01.К", "004.К", "62.ОТ", "МЦ.04", "ГТД", "Я68.01", "Я96", "99.02.И", "НЕ.01.1", "РВ.1"} <= set(accounts)
assert accounts["01.К"].parent_code == "01"
assert accounts["62.ОТ.1"].parent_code == "62.ОТ"
assert accounts["003.01"].is_off_balance and accounts["МЦ.02"].is_off_balance
assert not accounts["000"].is_off_balance and not accounts["Я68.01"].is_off_balance
assert accounts["60.01"].kind is AccountKind.P and accounts["60.02"].kind is AccountKind.A
def test_repository_chart_resolves_subaccounts_formerly_missing() -> None:
codes = set(load_seed(SEED_DIR).accounts)
assert OSV_SUBACCOUNTS_FORMERLY_MISSING <= codes
assert not NOT_IN_1C_PLAN & codes
def test_repository_chart_off_balance_flag_only_for_001_012_and_letter_roots() -> None:
accounts = load_seed(SEED_DIR).accounts
roots = OFF_BALANCE | OFF_BALANCE_LETTER
assert {c for c, a in accounts.items() if a.is_off_balance and a.parent_code is None} == roots
assert {c for c, a in accounts.items() if a.is_off_balance} == {c for c in accounts if c.split(".")[0] in roots}
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_the_94n_synthetic_accounts_and_1c_extras() -> 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 | EXTRA_ON_BALANCE_ROOTS
assert SYNTHETIC_94N_COUNT == len(SYNTHETIC_94N) == 62 # noqa: PLR2004
assert SYNTHETIC_94N <= synthetic_on_balance
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, 01.К или МЦ.04"
)
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–012 и буквенных (МЦ, НЕ, ГТД…)"
)
def test_validator_rejects_three_digit_code_outside_off_balance_range(tmp_path: Path) -> None:
message = _chart_error(tmp_path, ["013,,Неизвестный,A,account,true"])
assert message == "core/chart_of_accounts.csv, строка 2 (013): трёхзначные коды допустимы только для 000–012"
def test_validator_accepts_letter_codes_orphans_and_auxiliary_000(tmp_path: Path) -> None:
core = _write_chart(
tmp_path,
[
"000,,Вспомогательный счет,AP,account,false",
"01,,Основные средства,A,account,false",
"01.К,01,Корректировка,A,subaccount,false",
"МЦ,,Материальные ценности,A,account,true",
"МЦ.04,МЦ,Инвентарь,A,subaccount,true",
"РВ,,Розничная выручка,A,account,true",
"РВ.1,РВ,Наличная выручка,A,subaccount,true",
"Я68.01,,(Не используется) НДС,P,subaccount,false",
],
)
accounts = load_chart_of_accounts(core, tmp_path)
assert accounts["Я68.01"].parent_code is None
assert accounts["01.К"].parent_code == "01"
assert accounts["МЦ.04"].is_off_balance is True
def test_validator_rejects_empty_parent_when_ancestor_exists(tmp_path: Path) -> None:
message = _chart_error(tmp_path, ["60,,Расчеты,AP,account,false", "60.01,,Расчеты (руб),AP,subaccount,false"])
assert message == "core/chart_of_accounts.csv, строка 3 (60.01): родитель должен быть «60», указан «»"
def test_validator_rejects_letter_root_without_off_balance_flag(tmp_path: Path) -> None:
message = _chart_error(tmp_path, ["МЦ,,Материальные ценности,A,account,false"])
assert message == "core/chart_of_accounts.csv, строка 2 (МЦ): забалансовый счёт должен иметь is_off_balance=true"
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 | EXTRA_ON_BALANCE_ROOTS | OFF_BALANCE | OFF_BALANCE_LETTER | ORPHANS_1C
@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 and rows["60.01"].kind is AccountKind.P
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} == {
c for c in rows if c.split(".")[0] in OFF_BALANCE | OFF_BALANCE_LETTER
}
@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")) == "Паи и доли"