"""Загрузчик справочников продукта: `python -m scripts.seed [--dev]` (запуск из `api/`). Spec reference-data-seed; design D-4 и решение #18 (R-5): `run_seed(session, *, dev)` не делает commit — коммитит CLI. Источник данных — `api/seed/core/*.yaml|csv` (+ `api/seed/dev/` при `--dev`). Загрузка двухфазная: 1. `load_seed` (`seed_lib/loaders.py`) читает ВСЕ файлы в память и проверяет их (обязательные поля, enum'ы из `src.models.enums`, ссылки внутри наборов); любая ошибка — `SeedError`, база при этом не затрагивается. 2. `apply_seed` (`seed_lib/apply.py`) применяет набор к БД шагами в порядке зависимостей: upsert по натуральному ключу, без удалений. Исключение — матрица `role_permission`: целиком загружается только в пустую таблицу; в заполненную — лишь пары ролей и прав, вставленных этим же запуском, остальное пропускается (в отчёте — «пропущено»), чтобы не вернуть права, снятые администратором (issue #9 п.7, ревью R-3). План счетов (`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)`. Правила элиминации ББ (`core/elimination_rules_v1.yaml`, §4, версия 1) и чек-лист (`core/required_document_rules.yaml`, ASSUMPTION(OQ-46)): `seed_lib/loaders_rules.py` проверяет, что каждый счёт есть в плане счетов набора, ДО записи в БД; `seed_lib/apply_rules.py` — upsert (составы счетов и чек-лист приводятся к файлу без удалений; ключи без уникального индекса — в Python, NULL в `account_id` не плодит дубли). Общие помощники чтения — `seed_lib/readers.py`. Dev-данные (`--dev`, `seed/dev/`): dev-ГК под документы fixtures (`company_groups.yaml`: ГК, ЮЛ/ИП, алиасы, ГСК/ГСЗ; отключаются `--no-dev-groups` — пустые справочники для импорта ЕЦП), 2 региональных филиала (`regional_offices.yaml`, upsert по `name`) и по одному пользователю на каждую системную роль (`users.yaml`, `<роль>@armka.local`, upsert `app_user` по `email`, `user_role` по (user_id, role_id)). Ссылки пользователей на роли и филиалы проверяются до записи. Без `--dev` ни пользователи, ни dev-филиалы не загружаются. Dev-данные — только для локальной разработки и тестовых стендов: CLI принимает `--dev` лишь при `DEBUG=true` (`check_dev_allowed`, OQ-60, issue #9), иначе — код возврата 2 до чтения seed и подключения к БД. `run_seed(session, dev=True)` защиты не имеет (её вызывают тесты). Удалений нет: роль, убранная из `users.yaml`, остаётся назначенной. Расширение (задачи 13–15): поле в `SeedData` (`seed_lib/model.py`), читатель в `load_seed`, шаг в `apply_seed`. """ import argparse import asyncio import sys from pathlib import Path from typing import TYPE_CHECKING from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from scripts.seed_lib.apply import apply_seed from scripts.seed_lib.loaders import DEFAULT_SEED_DIR, load_seed from scripts.seed_lib.model import SeedError, SeedReport if TYPE_CHECKING: from src.core.config import Settings __all__ = [ "DEFAULT_SEED_DIR", "DEV_FORBIDDEN_MESSAGE", "SeedError", "SeedReport", "apply_seed", "check_dev_allowed", "load_seed", "main", "run_seed", ] DEV_FORBIDDEN_MESSAGE = "--dev доступен только при DEBUG=true (dev-пользователи не для прода)" #: код возврата CLI при `--dev` без DEBUG (1 — ошибка файлов seed) EXIT_DEV_FORBIDDEN = 2 class DevSeedForbiddenError(Exception): """`--dev` запрошен при `DEBUG=false`.""" def check_dev_allowed(settings: "Settings") -> str | None: """Можно ли грузить dev-данные: `None` — да, иначе текст отказа (dev-пользователи только при `DEBUG=true`).""" return None if settings.DEBUG else DEV_FORBIDDEN_MESSAGE async def run_seed( session: AsyncSession, *, dev: bool = False, seed_dir: Path | None = None, dev_groups: bool | None = None ) -> SeedReport: """Загрузить и применить seed (решение #18 / R-5): валидация до записи, без commit. `seed_dir` — каталог с `core/` (и `dev/`), по умолчанию `api/seed`; подменяется в тестах. `dev_groups=False` — dev-данные без ГК (фикстуры тестов API начинают с пустых справочников). """ return await apply_seed(session, load_seed(seed_dir, dev=dev, dev_groups=dev_groups)) async def _run_cli(seed_dir: Path, *, dev: bool, dev_groups: bool) -> SeedReport: # импорт настроек — только после проверки файлов seed: модуль конфигурации читает ENV_FILE при импорте, # поэтому `--help` и ошибки валидации без `--dev` работают без env; `--dev` сначала сверяется с DEBUG if dev: from src.core.config import get_settings # noqa: PLC0415 if (refusal := check_dev_allowed(get_settings())) is not None: raise DevSeedForbiddenError(refusal) data = load_seed(seed_dir, dev=dev, dev_groups=dev_groups) from src.core.config import settings # noqa: PLC0415 engine = create_async_engine( settings.OPENBAO.DATABASE.DATABASE_URL, connect_args={"server_settings": {"search_path": "dkb"}} ) try: async with async_sessionmaker(engine, expire_on_commit=False)() as session, session.begin(): return await apply_seed(session, data) finally: await engine.dispose() def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(prog="python -m scripts.seed", description="Загрузка справочников продукта.") parser.add_argument( "--dev", action="store_true", help="дополнительно загрузить dev-данные из seed/dev/ (только для разработки, не для прода)", ) parser.add_argument( "--no-dev-groups", action="store_true", help="с --dev: не загружать dev-ГК из seed/dev/company_groups.yaml (пустые справочники для импорта ЕЦП)", ) parser.add_argument("--seed-dir", type=Path, default=DEFAULT_SEED_DIR, help="каталог seed (по умолчанию api/seed)") args = parser.parse_args(argv) try: report = asyncio.run(_run_cli(args.seed_dir, dev=args.dev, dev_groups=not args.no_dev_groups)) except DevSeedForbiddenError as error: print(f"Ошибка seed: {error}", file=sys.stderr) return EXIT_DEV_FORBIDDEN except SeedError as error: print(f"Ошибка seed: {error}", file=sys.stderr) return 1 print(report.format()) return 0 if __name__ == "__main__": sys.exit(main())