feat: seed плана счетов 94н с субсчетами правил и чек-листа, шаг загрузчика chart_of_account
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
81af5e7dd7
commit
caf009d343
@ -9,6 +9,15 @@ Spec reference-data-seed; design D-4 и решение #18 (R-5): `run_seed(sess
|
||||
2. `apply_seed` (`seed_lib/apply.py`) применяет набор к БД шагами в порядке зависимостей: upsert по натуральному
|
||||
ключу, без удалений.
|
||||
|
||||
План счетов (`core/chart_of_accounts.csv`, design D-9) — ДОПУЩЕНИЕ OQ-47: полного плана 1С клиента нет, в файле
|
||||
Приказ Минфина № 94н: 62 активных синтетических счёта (01–05, 07–11, 14–16, 19–21, 23, 25, 26, 28, 29, 40–46,
|
||||
50–52, 55, 57–60, 62, 63, 66–71, 73, 75–77, 79–84, 86, 90, 91, 94, 96–99; номера 06, 12, 13, 17, 18, 22, 24, 27,
|
||||
30–39, 47–49, 53, 54, 56, 61, 64, 65, 72, 74, 78, 85, 87–89, 92, 93, 95 в плане отсутствуют), забалансовые 001–011
|
||||
(`kind = A`, `is_off_balance = true`) и только те субсчета и подсубсчета типового 1С БП 3.0, на которые ссылаются §4/§6
|
||||
правил и чек-лист; `kind` субсчетов наследуется от счёта.
|
||||
Остальное добавляет администратор (ARCH-004). Реестр кодов для проверки ссылок правил и чек-листа —
|
||||
`scripts.seed_lib.model.account_codes(data)`.
|
||||
|
||||
Расширение (задачи 13–15): поле в `SeedData` (`seed_lib/model.py`), читатель в `load_seed`, шаг в `apply_seed`.
|
||||
"""
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"""Фаза 2: применение проверенного набора к БД — upsert по натуральному ключу, без удалений и без commit."""
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
@ -8,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from scripts.seed_lib.model import SeedData, SeedReport, TableStat
|
||||
from src.models.access import Permission, Role, RolePermission
|
||||
from src.models.accounting import ChartOfAccount
|
||||
from src.models.directories import RegionalOffice
|
||||
|
||||
|
||||
@ -71,6 +73,64 @@ async def _apply_role_permissions(session: AsyncSession, data: SeedData, stat: T
|
||||
await session.execute(statement)
|
||||
|
||||
|
||||
async def _apply_chart_of_accounts(session: AsyncSession, data: SeedData, stat: TableStat) -> None:
|
||||
"""План счетов: upsert по `code` в порядке глубины (родитель раньше потомков), одним `run_seed` и одной транзакцией.
|
||||
|
||||
Идентификаторы новых строк генерирует приложение заранее, поэтому `parent_id` известен при вставке и второй проход
|
||||
не нужен; существующим строкам при расхождении с файлом обновляются поля и `parent_id`. Удалений нет.
|
||||
"""
|
||||
if not data.accounts:
|
||||
return
|
||||
stored = (
|
||||
await session.execute(
|
||||
select(
|
||||
ChartOfAccount.code,
|
||||
ChartOfAccount.id,
|
||||
ChartOfAccount.parent_id,
|
||||
ChartOfAccount.name,
|
||||
ChartOfAccount.kind,
|
||||
ChartOfAccount.row_type,
|
||||
ChartOfAccount.is_off_balance,
|
||||
)
|
||||
)
|
||||
).all()
|
||||
ids = {row.code: row.id for row in stored}
|
||||
existing = {row.code: (row.parent_id, row.name, row.kind, row.row_type, row.is_off_balance) for row in stored}
|
||||
for code in data.accounts:
|
||||
ids.setdefault(code, uuid.uuid4())
|
||||
rows = []
|
||||
for code, account in sorted(data.accounts.items(), key=lambda item: item[0].count(".")):
|
||||
parent_id = ids[account.parent_code] if account.parent_code is not None else None
|
||||
values = (parent_id, account.name, account.kind, account.row_type, account.is_off_balance)
|
||||
if code not in existing:
|
||||
stat.inserted += 1
|
||||
elif existing[code] != values:
|
||||
stat.updated += 1
|
||||
else:
|
||||
stat.unchanged += 1
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"id": ids[code],
|
||||
"code": code,
|
||||
"parent_id": parent_id,
|
||||
"name": account.name,
|
||||
"kind": account.kind,
|
||||
"row_type": account.row_type,
|
||||
"is_off_balance": account.is_off_balance,
|
||||
}
|
||||
)
|
||||
if rows:
|
||||
statement = pg_insert(ChartOfAccount)
|
||||
statement = statement.on_conflict_do_update(
|
||||
index_elements=[ChartOfAccount.code],
|
||||
set_={
|
||||
name: statement.excluded[name] for name in ("parent_id", "name", "kind", "row_type", "is_off_balance")
|
||||
},
|
||||
)
|
||||
await session.execute(statement, rows)
|
||||
|
||||
|
||||
async def apply_seed(session: AsyncSession, data: SeedData) -> SeedReport:
|
||||
"""Фаза 2: upsert набора в порядке зависимостей. Без commit — транзакцией управляет вызывающий."""
|
||||
report = SeedReport()
|
||||
@ -114,4 +174,5 @@ async def apply_seed(session: AsyncSession, data: SeedData) -> SeedReport:
|
||||
report.stat("permission"),
|
||||
)
|
||||
await _apply_role_permissions(session, data, report.stat("role_permission"))
|
||||
await _apply_chart_of_accounts(session, data, report.stat("chart_of_account"))
|
||||
return report
|
||||
|
||||
@ -3,13 +3,15 @@
|
||||
База данных здесь не используется; любая ошибка — `SeedError` с файлом и записью.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from scripts.seed_lib.model import PermissionSeed, RegionalOfficeSeed, RoleSeed, SeedData, SeedError
|
||||
from src.models.enums import PermissionAction, PermissionObject
|
||||
from scripts.seed_lib.model import AccountSeed, PermissionSeed, RegionalOfficeSeed, RoleSeed, SeedData, SeedError
|
||||
from src.models.enums import AccountKind, AccountRowType, PermissionAction, PermissionObject
|
||||
|
||||
DEFAULT_SEED_DIR = Path(__file__).resolve().parent.parent.parent / "seed"
|
||||
|
||||
@ -184,6 +186,95 @@ def _load_role_permissions(
|
||||
return result
|
||||
|
||||
|
||||
CHART_COLUMNS = ["code", "parent_code", "name", "kind", "row_type", "is_off_balance"]
|
||||
#: нормализованный код: NN или NNN (забалансовый), NN.MM, NN.MM.K (K без ведущего нуля: 76.07.1, как в ТЗ; 60.1 — нет)
|
||||
_ACCOUNT_CODE = re.compile(r"^\d{2,3}(\.\d{2}(\.[1-9]\d?)?)?$")
|
||||
_ROW_TYPE_BY_DEPTH = (AccountRowType.account, AccountRowType.subaccount, AccountRowType.analytic)
|
||||
_OFF_BALANCE_ROOTS = {f"{n:03d}" for n in range(1, 12)}
|
||||
_OFF_BALANCE_CODE_LENGTH = 3 # забалансовые счета — трёхзначные (001–011), балансовые синтетические — двузначные
|
||||
|
||||
|
||||
def _chart_row(row: dict[str | None, Any], where: str, code: str) -> AccountSeed:
|
||||
"""Проверка одной строки плана счетов, не требующая других строк."""
|
||||
where = f"{where} ({code})"
|
||||
if not _ACCOUNT_CODE.match(code):
|
||||
raise SeedError(f"{where}: код не нормализован, ожидается вид 60, 001, 60.01 или 76.07.1")
|
||||
root = code.split(".", maxsplit=1)[0]
|
||||
off_balance_root = len(root) == _OFF_BALANCE_CODE_LENGTH
|
||||
if off_balance_root and root not in _OFF_BALANCE_ROOTS:
|
||||
raise SeedError(f"{where}: трёхзначные коды допустимы только для 001–011")
|
||||
if root == "00":
|
||||
raise SeedError(f"{where}: счёта 00 не существует")
|
||||
kind_text = str(row["kind"]).strip()
|
||||
try:
|
||||
kind = AccountKind(kind_text) if kind_text else None
|
||||
except ValueError:
|
||||
raise SeedError(f"{where}: неизвестный kind «{kind_text}» (допустимы A, P, AP)") from None
|
||||
row_type_text = str(row["row_type"]).strip()
|
||||
try:
|
||||
row_type = AccountRowType(row_type_text)
|
||||
except ValueError:
|
||||
raise SeedError(
|
||||
f"{where}: неизвестный row_type «{row_type_text}» (допустимы account, subaccount, analytic)"
|
||||
) from None
|
||||
flag_text = str(row["is_off_balance"]).strip()
|
||||
if flag_text not in ("true", "false"):
|
||||
raise SeedError(f"{where}: is_off_balance должен быть true или false, получено «{flag_text}»")
|
||||
is_off_balance = flag_text == "true"
|
||||
expected_type = _ROW_TYPE_BY_DEPTH[code.count(".")]
|
||||
if row_type is not expected_type:
|
||||
raise SeedError(
|
||||
f"{where}: row_type «{row_type.value}» не соответствует глубине кода (ожидался «{expected_type.value}»)"
|
||||
)
|
||||
if off_balance_root and not is_off_balance:
|
||||
raise SeedError(f"{where}: забалансовый счёт должен иметь is_off_balance=true")
|
||||
if not off_balance_root and is_off_balance:
|
||||
raise SeedError(f"{where}: is_off_balance=true допустим только для счетов 001–011")
|
||||
if row_type is AccountRowType.account and kind is None:
|
||||
raise SeedError(f"{where}: у синтетического счёта должен быть задан kind")
|
||||
name = str(row["name"]).strip()
|
||||
if not name:
|
||||
raise SeedError(f"{where}: пустое название счёта")
|
||||
parent_text = str(row["parent_code"]).strip()
|
||||
return AccountSeed(code, parent_text or None, name, kind, row_type, is_off_balance)
|
||||
|
||||
|
||||
def load_chart_of_accounts(core: Path, seed_dir: Path) -> dict[str, AccountSeed]:
|
||||
"""План счетов из `chart_of_accounts.csv` (UTF-8): проверка до записи, порядок файла сохраняется."""
|
||||
path = core / "chart_of_accounts.csv"
|
||||
if not path.is_file():
|
||||
raise SeedError(f"{_where(path, seed_dir)}: файл не найден")
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError as error:
|
||||
raise SeedError(f"{_where(path, seed_dir)}: не удалось прочитать файл как UTF-8: {error}") from error
|
||||
reader = csv.DictReader(text.splitlines())
|
||||
if reader.fieldnames != CHART_COLUMNS:
|
||||
raise SeedError(f"{_where(path, seed_dir)}: заголовок должен быть «{','.join(CHART_COLUMNS)}»")
|
||||
accounts: dict[str, AccountSeed] = {}
|
||||
lines: dict[str, int] = {}
|
||||
for line_no, row in enumerate(reader, start=2):
|
||||
where = f"{_where(path, seed_dir)}, строка {line_no}"
|
||||
if None in row or any(value is None for value in row.values()):
|
||||
raise SeedError(f"{where}: число полей не совпадает с заголовком")
|
||||
code = str(row["code"]).strip()
|
||||
account = _chart_row(row, where, code)
|
||||
if code in accounts:
|
||||
raise SeedError(f"{where} ({code}): код уже встречался в строке {lines[code]}")
|
||||
accounts[code] = account
|
||||
lines[code] = line_no
|
||||
for code, account in accounts.items():
|
||||
where = f"{_where(path, seed_dir)}, строка {lines[code]} ({code})"
|
||||
expected_parent = code.rsplit(".", 1)[0] if "." in code else None
|
||||
if account.parent_code != expected_parent:
|
||||
if expected_parent is None:
|
||||
raise SeedError(f"{where}: у синтетического счёта parent_code должен быть пустым")
|
||||
raise SeedError(f"{where}: родитель должен быть «{expected_parent}», указан «{account.parent_code or ''}»")
|
||||
if account.parent_code is not None and account.parent_code not in accounts:
|
||||
raise SeedError(f"{where}: родитель «{account.parent_code}» не найден в файле")
|
||||
return accounts
|
||||
|
||||
|
||||
def load_seed(seed_dir: Path | None = None, *, dev: bool = False) -> SeedData:
|
||||
"""Фаза 1: прочитать и проверить все файлы. Бросает `SeedError`; к БД не обращается."""
|
||||
seed_dir = seed_dir or DEFAULT_SEED_DIR
|
||||
@ -195,6 +286,7 @@ def load_seed(seed_dir: Path | None = None, *, dev: bool = False) -> SeedData:
|
||||
core, seed_dir, {r.code for r in data.roles}, {p.code for p in data.permissions}
|
||||
)
|
||||
data.regional_offices = _load_regional_offices(core, seed_dir)
|
||||
data.accounts = load_chart_of_accounts(core, seed_dir)
|
||||
if dev:
|
||||
_load_dev(data, seed_dir / "dev")
|
||||
return data
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from src.models.enums import PermissionAction, PermissionObject
|
||||
from src.models.enums import AccountKind, AccountRowType, PermissionAction, PermissionObject
|
||||
|
||||
|
||||
class SeedError(Exception):
|
||||
@ -34,6 +34,18 @@ class PermissionSeed:
|
||||
description: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AccountSeed:
|
||||
"""Строка плана счетов; `code` нормализован (60.01, 76.07.1), `parent_code` — код родителя или None."""
|
||||
|
||||
code: str
|
||||
parent_code: str | None
|
||||
name: str
|
||||
kind: AccountKind | None
|
||||
row_type: AccountRowType
|
||||
is_off_balance: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeedData:
|
||||
"""Проверенный набор seed; ничего не знает о БД."""
|
||||
@ -43,6 +55,13 @@ class SeedData:
|
||||
permissions: list[PermissionSeed] = field(default_factory=list)
|
||||
#: код роли -> коды прав (порядок как в файле)
|
||||
role_permissions: dict[str, list[str]] = field(default_factory=dict)
|
||||
#: код счёта -> счёт; порядок файла (родитель раньше потомков). Реестр кодов для загрузчика правил: `account_codes`
|
||||
accounts: dict[str, AccountSeed] = field(default_factory=dict)
|
||||
|
||||
|
||||
def account_codes(data: SeedData) -> frozenset[str]:
|
||||
"""Коды плана счетов набора: правила элиминации и чек-лист проверяют свои ссылки на счета по этому реестру."""
|
||||
return frozenset(data.accounts)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
123
api/seed/core/chart_of_accounts.csv
Normal file
123
api/seed/core/chart_of_accounts.csv
Normal file
@ -0,0 +1,123 @@
|
||||
code,parent_code,name,kind,row_type,is_off_balance
|
||||
01,,Основные средства,A,account,false
|
||||
02,,Амортизация основных средств,P,account,false
|
||||
03,,Доходные вложения в материальные ценности,A,account,false
|
||||
04,,Нематериальные активы,A,account,false
|
||||
05,,Амортизация нематериальных активов,P,account,false
|
||||
07,,Оборудование к установке,A,account,false
|
||||
08,,Вложения во внеоборотные активы,A,account,false
|
||||
09,,Отложенные налоговые активы,A,account,false
|
||||
10,,Материалы,A,account,false
|
||||
11,,Животные на выращивании и откорме,A,account,false
|
||||
14,,Резервы под снижение стоимости материальных ценностей,P,account,false
|
||||
15,,Заготовление и приобретение материальных ценностей,A,account,false
|
||||
16,,Отклонение в стоимости материальных ценностей,AP,account,false
|
||||
19,,Налог на добавленную стоимость по приобретенным ценностям,A,account,false
|
||||
20,,Основное производство,A,account,false
|
||||
21,,Полуфабрикаты собственного производства,A,account,false
|
||||
23,,Вспомогательные производства,A,account,false
|
||||
25,,Общепроизводственные расходы,A,account,false
|
||||
26,,Общехозяйственные расходы,A,account,false
|
||||
28,,Брак в производстве,A,account,false
|
||||
29,,Обслуживающие производства и хозяйства,A,account,false
|
||||
40,,"Выпуск продукции (работ, услуг)",AP,account,false
|
||||
41,,Товары,A,account,false
|
||||
42,,Торговая наценка,P,account,false
|
||||
43,,Готовая продукция,A,account,false
|
||||
44,,Расходы на продажу,A,account,false
|
||||
45,,Товары отгруженные,A,account,false
|
||||
46,,Выполненные этапы по незавершенным работам,A,account,false
|
||||
50,,Касса,A,account,false
|
||||
51,,Расчетные счета,A,account,false
|
||||
52,,Валютные счета,A,account,false
|
||||
55,,Специальные счета в банках,A,account,false
|
||||
57,,Переводы в пути,A,account,false
|
||||
58,,Финансовые вложения,A,account,false
|
||||
58.01,58,Паи и акции,A,subaccount,false
|
||||
58.03,58,Предоставленные займы,A,subaccount,false
|
||||
59,,Резервы под обесценение финансовых вложений,P,account,false
|
||||
60,,Расчеты с поставщиками и подрядчиками,AP,account,false
|
||||
60.01,60,Расчеты с поставщиками и подрядчиками,AP,subaccount,false
|
||||
60.02,60,Расчеты по авансам выданным,AP,subaccount,false
|
||||
60.03,60,Расчеты по векселям выданным,AP,subaccount,false
|
||||
60.21,60,Расчеты с поставщиками и подрядчиками (в валюте),AP,subaccount,false
|
||||
60.22,60,Расчеты по авансам выданным (в валюте),AP,subaccount,false
|
||||
60.31,60,Расчеты с поставщиками и подрядчиками (в условных единицах),AP,subaccount,false
|
||||
60.32,60,Расчеты по авансам выданным (в условных единицах),AP,subaccount,false
|
||||
62,,Расчеты с покупателями и заказчиками,AP,account,false
|
||||
62.01,62,Расчеты с покупателями и заказчиками,AP,subaccount,false
|
||||
62.02,62,Расчеты по авансам полученным,AP,subaccount,false
|
||||
62.03,62,Расчеты по векселям полученным,AP,subaccount,false
|
||||
62.21,62,Расчеты с покупателями и заказчиками (в валюте),AP,subaccount,false
|
||||
62.22,62,Расчеты по авансам полученным (в валюте),AP,subaccount,false
|
||||
62.31,62,Расчеты с покупателями и заказчиками (в условных единицах),AP,subaccount,false
|
||||
62.32,62,Расчеты по авансам полученным (в условных единицах),AP,subaccount,false
|
||||
63,,Резервы по сомнительным долгам,P,account,false
|
||||
66,,Расчеты по краткосрочным кредитам и займам,P,account,false
|
||||
66.02,66,Проценты по краткосрочным кредитам,P,subaccount,false
|
||||
66.03,66,Краткосрочные займы,P,subaccount,false
|
||||
66.04,66,Проценты по краткосрочным займам,P,subaccount,false
|
||||
66.06,66,Проценты по краткосрочным кредитам и займам (субсчет 06),P,subaccount,false
|
||||
66.22,66,Проценты по краткосрочным кредитам и займам (субсчет 22),P,subaccount,false
|
||||
66.24,66,Проценты по краткосрочным кредитам и займам (субсчет 24),P,subaccount,false
|
||||
66.26,66,Проценты по краткосрочным кредитам и займам (субсчет 26),P,subaccount,false
|
||||
67,,Расчеты по долгосрочным кредитам и займам,P,account,false
|
||||
67.02,67,Проценты по долгосрочным кредитам,P,subaccount,false
|
||||
67.03,67,Долгосрочные займы,P,subaccount,false
|
||||
67.04,67,Проценты по долгосрочным займам,P,subaccount,false
|
||||
67.06,67,Проценты по долгосрочным кредитам и займам (субсчет 06),P,subaccount,false
|
||||
67.22,67,Проценты по долгосрочным кредитам и займам (субсчет 22),P,subaccount,false
|
||||
67.24,67,Проценты по долгосрочным кредитам и займам (субсчет 24),P,subaccount,false
|
||||
67.26,67,Проценты по долгосрочным кредитам и займам (субсчет 26),P,subaccount,false
|
||||
68,,Расчеты по налогам и сборам,AP,account,false
|
||||
69,,Расчеты по социальному страхованию и обеспечению,AP,account,false
|
||||
70,,Расчеты с персоналом по оплате труда,P,account,false
|
||||
71,,Расчеты с подотчетными лицами,AP,account,false
|
||||
73,,Расчеты с персоналом по прочим операциям,AP,account,false
|
||||
75,,Расчеты с учредителями,AP,account,false
|
||||
76,,Расчеты с разными дебиторами и кредиторами,AP,account,false
|
||||
76.03,76,Расчеты по причитающимся дивидендам и другим доходам,AP,subaccount,false
|
||||
76.05,76,Расчеты с прочими поставщиками и подрядчиками,AP,subaccount,false
|
||||
76.07,76,Расчеты по лизингу,AP,subaccount,false
|
||||
76.07.1,76.07,Обязательства по лизингу,AP,analytic,false
|
||||
76.07.2,76.07,Прочие расчеты по лизингу (подсубсчет 2),AP,analytic,false
|
||||
76.07.5,76.07,Проценты по лизингу,AP,analytic,false
|
||||
76.07.9,76.07,Активы по лизингу,AP,analytic,false
|
||||
76.08,76,Расчеты по процентам,AP,subaccount,false
|
||||
76.09,76,Прочие расчеты с разными дебиторами и кредиторами,AP,subaccount,false
|
||||
76.28,76,Расчеты по процентам (в валюте),AP,subaccount,false
|
||||
76.38,76,Расчеты по процентам (в условных единицах),AP,subaccount,false
|
||||
77,,Отложенные налоговые обязательства,P,account,false
|
||||
79,,Внутрихозяйственные расчеты,AP,account,false
|
||||
80,,Уставный капитал,P,account,false
|
||||
81,,Собственные акции (доли),A,account,false
|
||||
82,,Резервный капитал,P,account,false
|
||||
83,,Добавочный капитал,P,account,false
|
||||
84,,Нераспределенная прибыль (непокрытый убыток),AP,account,false
|
||||
86,,Целевое финансирование,P,account,false
|
||||
90,,Продажи,AP,account,false
|
||||
90.01,90,Выручка,AP,subaccount,false
|
||||
90.02,90,Себестоимость продаж,AP,subaccount,false
|
||||
90.03,90,Налог на добавленную стоимость,AP,subaccount,false
|
||||
90.04,90,Акцизы,AP,subaccount,false
|
||||
90.09,90,Прибыль (убыток) от продаж,AP,subaccount,false
|
||||
91,,Прочие доходы и расходы,AP,account,false
|
||||
91.01,91,Прочие доходы,AP,subaccount,false
|
||||
91.02,91,Прочие расходы,AP,subaccount,false
|
||||
91.09,91,Сальдо прочих доходов и расходов,AP,subaccount,false
|
||||
94,,Недостачи и потери от порчи ценностей,A,account,false
|
||||
96,,Резервы предстоящих расходов,P,account,false
|
||||
97,,Расходы будущих периодов,A,account,false
|
||||
98,,Доходы будущих периодов,P,account,false
|
||||
99,,Прибыли и убытки,AP,account,false
|
||||
001,,Арендованные основные средства,A,account,true
|
||||
002,,"Товарно-материальные ценности, принятые на ответственное хранение",A,account,true
|
||||
003,,"Материалы, принятые в переработку",A,account,true
|
||||
004,,"Товары, принятые на комиссию",A,account,true
|
||||
005,,"Оборудование, принятое для монтажа",A,account,true
|
||||
006,,Бланки строгой отчетности,A,account,true
|
||||
007,,Списанная в убыток задолженность неплатежеспособных дебиторов,A,account,true
|
||||
008,,Обеспечения обязательств и платежей полученные,A,account,true
|
||||
009,,Обеспечения обязательств и платежей выданные,A,account,true
|
||||
010,,Износ основных средств,A,account,true
|
||||
011,,"Основные средства, сданные в аренду",A,account,true
|
||||
|
302
api/tests/test_seed_chart.py
Normal file
302
api/tests/test_seed_chart.py
Normal file
@ -0,0 +1,302 @@
|
||||
"""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")) == "Паи и доли"
|
||||
Loading…
x
Reference in New Issue
Block a user