feat: seed плана счетов 94н с субсчетами правил и чек-листа, шаг загрузчика chart_of_account

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-03 21:17:18 +04:00
parent 81af5e7dd7
commit caf009d343
6 changed files with 609 additions and 3 deletions

View File

@ -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`.
"""

View File

@ -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

View File

@ -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

View File

@ -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

View 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
1 code parent_code name kind row_type is_off_balance
2 01 Основные средства A account false
3 02 Амортизация основных средств P account false
4 03 Доходные вложения в материальные ценности A account false
5 04 Нематериальные активы A account false
6 05 Амортизация нематериальных активов P account false
7 07 Оборудование к установке A account false
8 08 Вложения во внеоборотные активы A account false
9 09 Отложенные налоговые активы A account false
10 10 Материалы A account false
11 11 Животные на выращивании и откорме A account false
12 14 Резервы под снижение стоимости материальных ценностей P account false
13 15 Заготовление и приобретение материальных ценностей A account false
14 16 Отклонение в стоимости материальных ценностей AP account false
15 19 Налог на добавленную стоимость по приобретенным ценностям A account false
16 20 Основное производство A account false
17 21 Полуфабрикаты собственного производства A account false
18 23 Вспомогательные производства A account false
19 25 Общепроизводственные расходы A account false
20 26 Общехозяйственные расходы A account false
21 28 Брак в производстве A account false
22 29 Обслуживающие производства и хозяйства A account false
23 40 Выпуск продукции (работ, услуг) AP account false
24 41 Товары A account false
25 42 Торговая наценка P account false
26 43 Готовая продукция A account false
27 44 Расходы на продажу A account false
28 45 Товары отгруженные A account false
29 46 Выполненные этапы по незавершенным работам A account false
30 50 Касса A account false
31 51 Расчетные счета A account false
32 52 Валютные счета A account false
33 55 Специальные счета в банках A account false
34 57 Переводы в пути A account false
35 58 Финансовые вложения A account false
36 58.01 58 Паи и акции A subaccount false
37 58.03 58 Предоставленные займы A subaccount false
38 59 Резервы под обесценение финансовых вложений P account false
39 60 Расчеты с поставщиками и подрядчиками AP account false
40 60.01 60 Расчеты с поставщиками и подрядчиками AP subaccount false
41 60.02 60 Расчеты по авансам выданным AP subaccount false
42 60.03 60 Расчеты по векселям выданным AP subaccount false
43 60.21 60 Расчеты с поставщиками и подрядчиками (в валюте) AP subaccount false
44 60.22 60 Расчеты по авансам выданным (в валюте) AP subaccount false
45 60.31 60 Расчеты с поставщиками и подрядчиками (в условных единицах) AP subaccount false
46 60.32 60 Расчеты по авансам выданным (в условных единицах) AP subaccount false
47 62 Расчеты с покупателями и заказчиками AP account false
48 62.01 62 Расчеты с покупателями и заказчиками AP subaccount false
49 62.02 62 Расчеты по авансам полученным AP subaccount false
50 62.03 62 Расчеты по векселям полученным AP subaccount false
51 62.21 62 Расчеты с покупателями и заказчиками (в валюте) AP subaccount false
52 62.22 62 Расчеты по авансам полученным (в валюте) AP subaccount false
53 62.31 62 Расчеты с покупателями и заказчиками (в условных единицах) AP subaccount false
54 62.32 62 Расчеты по авансам полученным (в условных единицах) AP subaccount false
55 63 Резервы по сомнительным долгам P account false
56 66 Расчеты по краткосрочным кредитам и займам P account false
57 66.02 66 Проценты по краткосрочным кредитам P subaccount false
58 66.03 66 Краткосрочные займы P subaccount false
59 66.04 66 Проценты по краткосрочным займам P subaccount false
60 66.06 66 Проценты по краткосрочным кредитам и займам (субсчет 06) P subaccount false
61 66.22 66 Проценты по краткосрочным кредитам и займам (субсчет 22) P subaccount false
62 66.24 66 Проценты по краткосрочным кредитам и займам (субсчет 24) P subaccount false
63 66.26 66 Проценты по краткосрочным кредитам и займам (субсчет 26) P subaccount false
64 67 Расчеты по долгосрочным кредитам и займам P account false
65 67.02 67 Проценты по долгосрочным кредитам P subaccount false
66 67.03 67 Долгосрочные займы P subaccount false
67 67.04 67 Проценты по долгосрочным займам P subaccount false
68 67.06 67 Проценты по долгосрочным кредитам и займам (субсчет 06) P subaccount false
69 67.22 67 Проценты по долгосрочным кредитам и займам (субсчет 22) P subaccount false
70 67.24 67 Проценты по долгосрочным кредитам и займам (субсчет 24) P subaccount false
71 67.26 67 Проценты по долгосрочным кредитам и займам (субсчет 26) P subaccount false
72 68 Расчеты по налогам и сборам AP account false
73 69 Расчеты по социальному страхованию и обеспечению AP account false
74 70 Расчеты с персоналом по оплате труда P account false
75 71 Расчеты с подотчетными лицами AP account false
76 73 Расчеты с персоналом по прочим операциям AP account false
77 75 Расчеты с учредителями AP account false
78 76 Расчеты с разными дебиторами и кредиторами AP account false
79 76.03 76 Расчеты по причитающимся дивидендам и другим доходам AP subaccount false
80 76.05 76 Расчеты с прочими поставщиками и подрядчиками AP subaccount false
81 76.07 76 Расчеты по лизингу AP subaccount false
82 76.07.1 76.07 Обязательства по лизингу AP analytic false
83 76.07.2 76.07 Прочие расчеты по лизингу (подсубсчет 2) AP analytic false
84 76.07.5 76.07 Проценты по лизингу AP analytic false
85 76.07.9 76.07 Активы по лизингу AP analytic false
86 76.08 76 Расчеты по процентам AP subaccount false
87 76.09 76 Прочие расчеты с разными дебиторами и кредиторами AP subaccount false
88 76.28 76 Расчеты по процентам (в валюте) AP subaccount false
89 76.38 76 Расчеты по процентам (в условных единицах) AP subaccount false
90 77 Отложенные налоговые обязательства P account false
91 79 Внутрихозяйственные расчеты AP account false
92 80 Уставный капитал P account false
93 81 Собственные акции (доли) A account false
94 82 Резервный капитал P account false
95 83 Добавочный капитал P account false
96 84 Нераспределенная прибыль (непокрытый убыток) AP account false
97 86 Целевое финансирование P account false
98 90 Продажи AP account false
99 90.01 90 Выручка AP subaccount false
100 90.02 90 Себестоимость продаж AP subaccount false
101 90.03 90 Налог на добавленную стоимость AP subaccount false
102 90.04 90 Акцизы AP subaccount false
103 90.09 90 Прибыль (убыток) от продаж AP subaccount false
104 91 Прочие доходы и расходы AP account false
105 91.01 91 Прочие доходы AP subaccount false
106 91.02 91 Прочие расходы AP subaccount false
107 91.09 91 Сальдо прочих доходов и расходов AP subaccount false
108 94 Недостачи и потери от порчи ценностей A account false
109 96 Резервы предстоящих расходов P account false
110 97 Расходы будущих периодов A account false
111 98 Доходы будущих периодов P account false
112 99 Прибыли и убытки AP account false
113 001 Арендованные основные средства A account true
114 002 Товарно-материальные ценности, принятые на ответственное хранение A account true
115 003 Материалы, принятые в переработку A account true
116 004 Товары, принятые на комиссию A account true
117 005 Оборудование, принятое для монтажа A account true
118 006 Бланки строгой отчетности A account true
119 007 Списанная в убыток задолженность неплатежеспособных дебиторов A account true
120 008 Обеспечения обязательств и платежей полученные A account true
121 009 Обеспечения обязательств и платежей выданные A account true
122 010 Износ основных средств A account true
123 011 Основные средства, сданные в аренду A account true

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