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
379 lines
19 KiB
Python
379 lines
19 KiB
Python
"""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")) == "Паи и доли"
|