arm-core/api/scripts/seed.py
Andrey Epifancev 900fdc8ece fix: seed не возвращает снятые права — матрица только в пустую role_permission (#9 п.7)
`_apply_role_permissions` (`scripts/seed_lib/apply.py`) загружает матрицу из
`role_permissions.yaml` только при пустой `role_permission`; иначе шаг
пропускается целиком с причиной в отчёте (`TableStat.skipped_reason`,
«role_permission: пропущено — …»). Права, снятые администратором, не
возвращаются; `role` и `permission` по-прежнему upsert'ятся.

Тесты: повторный прогон — «пропущено», матрица та же; снятая связь после
повторного seed не возвращается. README «Администрирование» и раздел seed
переписаны под новое поведение.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-05 13:30:09 +04:00

131 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Загрузчик справочников продукта: `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).
План счетов (`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/`, задача 15): 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) -> SeedReport:
"""Загрузить и применить seed (решение #18 / R-5): валидация до записи, без commit.
`seed_dir` — каталог с `core/` (и `dev/`), по умолчанию `api/seed`; подменяется в тестах.
"""
return await apply_seed(session, load_seed(seed_dir, dev=dev))
async def _run_cli(seed_dir: Path, *, dev: 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)
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("--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))
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())