From caf009d34354b89a7bf9cf80e560330eff6efc84 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 21:17:18 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20seed=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B0?= =?UTF-8?q?=20=D1=81=D1=87=D0=B5=D1=82=D0=BE=D0=B2=2094=D0=BD=20=D1=81=20?= =?UTF-8?q?=D1=81=D1=83=D0=B1=D1=81=D1=87=D0=B5=D1=82=D0=B0=D0=BC=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=20=D0=B8=20=D1=87=D0=B5?= =?UTF-8?q?=D0=BA-=D0=BB=D0=B8=D1=81=D1=82=D0=B0,=20=D1=88=D0=B0=D0=B3=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D1=87=D0=B8=D0=BA=D0=B0=20?= =?UTF-8?q?chart=5Fof=5Faccount?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/scripts/seed.py | 9 + api/scripts/seed_lib/apply.py | 61 ++++++ api/scripts/seed_lib/loaders.py | 96 ++++++++- api/scripts/seed_lib/model.py | 21 +- api/seed/core/chart_of_accounts.csv | 123 +++++++++++ api/tests/test_seed_chart.py | 302 ++++++++++++++++++++++++++++ 6 files changed, 609 insertions(+), 3 deletions(-) create mode 100644 api/seed/core/chart_of_accounts.csv create mode 100644 api/tests/test_seed_chart.py diff --git a/api/scripts/seed.py b/api/scripts/seed.py index 94ca722..e2f4d1f 100644 --- a/api/scripts/seed.py +++ b/api/scripts/seed.py @@ -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`. """ diff --git a/api/scripts/seed_lib/apply.py b/api/scripts/seed_lib/apply.py index ea3e748..2d4f276 100644 --- a/api/scripts/seed_lib/apply.py +++ b/api/scripts/seed_lib/apply.py @@ -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 diff --git a/api/scripts/seed_lib/loaders.py b/api/scripts/seed_lib/loaders.py index 7ed5be1..c0636af 100644 --- a/api/scripts/seed_lib/loaders.py +++ b/api/scripts/seed_lib/loaders.py @@ -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 diff --git a/api/scripts/seed_lib/model.py b/api/scripts/seed_lib/model.py index 34f28f0..9428bf8 100644 --- a/api/scripts/seed_lib/model.py +++ b/api/scripts/seed_lib/model.py @@ -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 diff --git a/api/seed/core/chart_of_accounts.csv b/api/seed/core/chart_of_accounts.csv new file mode 100644 index 0000000..f41fdd2 --- /dev/null +++ b/api/seed/core/chart_of_accounts.csv @@ -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 diff --git a/api/tests/test_seed_chart.py b/api/tests/test_seed_chart.py new file mode 100644 index 0000000..850e1e1 --- /dev/null +++ b/api/tests/test_seed_chart.py @@ -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")) == "Паи и доли"