Merge pull request 'feat(seed): dev-ГК под документы fixtures (Русский Бисквит из ЕЦП, владельцы форм ДКБ)' (#32) from feat/31-dev-seed-company-groups into main

Reviewed-on: #32
This commit is contained in:
aep 2026-10-06 16:08:21 +03:00
commit ffeb26fa9e
9 changed files with 605 additions and 24 deletions

View File

@ -93,11 +93,14 @@ cp api/.env.example api/.env
```bash ```bash
make migrate # alembic upgrade head: схема dkb, 27 enum-типов, 43 таблицы make migrate # alembic upgrade head: схема dkb, 27 enum-типов, 43 таблицы
make seed-dev # справочники продукта + dev-пользователи (по одному на каждую роль) make seed-dev # справочники продукта + dev-пользователи (по одному на каждую роль) + dev-ГК под документы fixtures
``` ```
`make seed` загружает только справочники (роли, права, филиалы, план счетов, правила элиминации, чек-лист), без `make seed` загружает только справочники (роли, права, филиалы, план счетов, правила элиминации, чек-лист), без
dev-пользователей. Seed идемпотентен: повторный запуск обновляет данные и ничего не дублирует; матрицу прав dev-пользователей и dev-ГК. `make seed-dev` дополнительно создаёт три dev-филиала и группы компаний под документы из
`fixtures` (`api/seed/dev/company_groups.yaml`): ГК «Русский Бисквит» ровно как в выгрузке ЕЦП CRM (20 ЮЛ/ИП, ГСК из РБ и
Счастливочной, алиасы под имена контрагентов из ОСВ) и ГК владельцев форм пакета ДКБ — ОСВ и формы из `fixtures` после
этого загружаются на «свои» ЮЛ без импорта справочников. Seed идемпотентен: повторный запуск обновляет данные и ничего не дублирует; матрицу прав
`role_permission` он загружает целиком только в пустую таблицу, в заполненную — лишь связи новых ролей и прав `role_permission` он загружает целиком только в пустую таблицу, в заполненную — лишь связи новых ролей и прав
(правки администратора не перезаписываются, см. «Администрирование»). `make seed-dev` (правки администратора не перезаписываются, см. «Администрирование»). `make seed-dev`
(`--dev`) работает только при `DEBUG=true` в `api/.env`; при `DEBUG=false` загрузчик отказывает с кодом 2 (`--dev`) работает только при `DEBUG=true` в `api/.env`; при `DEBUG=false` загрузчик отказывает с кодом 2

View File

@ -25,7 +25,9 @@ ASSUMPTION(OQ-46)): `seed_lib/loaders_rules.py` проверяет, что ка
`seed_lib/apply_rules.py` — upsert (составы счетов и чек-лист приводятся к файлу без удалений; ключи без уникального `seed_lib/apply_rules.py` — upsert (составы счетов и чек-лист приводятся к файлу без удалений; ключи без уникального
индекса — в Python, NULL в `account_id` не плодит дубли). Общие помощники чтения — `seed_lib/readers.py`. индекса — в Python, NULL в `account_id` не плодит дубли). Общие помощники чтения — `seed_lib/readers.py`.
Dev-данные (`--dev`, `seed/dev/`, задача 15): 2 региональных филиала (`regional_offices.yaml`, upsert по `name`) 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`, и по одному пользователю на каждую системную роль (`users.yaml`, `<роль>@armka.local`, upsert `app_user` по `email`,
`user_role` по (user_id, role_id)). Ссылки пользователей на роли и филиалы проверяются до записи. Без `--dev` ни `user_role` по (user_id, role_id)). Ссылки пользователей на роли и филиалы проверяются до записи. Без `--dev` ни
пользователи, ни dev-филиалы не загружаются. Dev-данные — только для локальной разработки и тестовых стендов: CLI пользователи, ни dev-филиалы не загружаются. Dev-данные — только для локальной разработки и тестовых стендов: CLI
@ -77,15 +79,18 @@ def check_dev_allowed(settings: "Settings") -> str | None:
return None if settings.DEBUG else DEV_FORBIDDEN_MESSAGE return None if settings.DEBUG else DEV_FORBIDDEN_MESSAGE
async def run_seed(session: AsyncSession, *, dev: bool = False, seed_dir: Path | None = None) -> SeedReport: 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 (решение #18 / R-5): валидация до записи, без commit.
`seed_dir` — каталог с `core/` (и `dev/`), по умолчанию `api/seed`; подменяется в тестах. `seed_dir` — каталог с `core/` (и `dev/`), по умолчанию `api/seed`; подменяется в тестах. `dev_groups=False` —
dev-данные без ГК (фикстуры тестов API начинают с пустых справочников).
""" """
return await apply_seed(session, load_seed(seed_dir, dev=dev)) return await apply_seed(session, load_seed(seed_dir, dev=dev, dev_groups=dev_groups))
async def _run_cli(seed_dir: Path, *, dev: bool) -> SeedReport: async def _run_cli(seed_dir: Path, *, dev: bool, dev_groups: bool) -> SeedReport:
# импорт настроек — только после проверки файлов seed: модуль конфигурации читает ENV_FILE при импорте, # импорт настроек — только после проверки файлов seed: модуль конфигурации читает ENV_FILE при импорте,
# поэтому `--help` и ошибки валидации без `--dev` работают без env; `--dev` сначала сверяется с DEBUG # поэтому `--help` и ошибки валидации без `--dev` работают без env; `--dev` сначала сверяется с DEBUG
if dev: if dev:
@ -93,7 +98,7 @@ async def _run_cli(seed_dir: Path, *, dev: bool) -> SeedReport:
if (refusal := check_dev_allowed(get_settings())) is not None: if (refusal := check_dev_allowed(get_settings())) is not None:
raise DevSeedForbiddenError(refusal) raise DevSeedForbiddenError(refusal)
data = load_seed(seed_dir, dev=dev) data = load_seed(seed_dir, dev=dev, dev_groups=dev_groups)
from src.core.config import settings # noqa: PLC0415 from src.core.config import settings # noqa: PLC0415
engine = create_async_engine( engine = create_async_engine(
@ -113,10 +118,15 @@ def main(argv: list[str] | None = None) -> int:
action="store_true", action="store_true",
help="дополнительно загрузить dev-данные из seed/dev/ (только для разработки, не для прода)", 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)") parser.add_argument("--seed-dir", type=Path, default=DEFAULT_SEED_DIR, help="каталог seed (по умолчанию api/seed)")
args = parser.parse_args(argv) args = parser.parse_args(argv)
try: try:
report = asyncio.run(_run_cli(args.seed_dir, dev=args.dev)) report = asyncio.run(_run_cli(args.seed_dir, dev=args.dev, dev_groups=not args.no_dev_groups))
except DevSeedForbiddenError as error: except DevSeedForbiddenError as error:
print(f"Ошибка seed: {error}", file=sys.stderr) print(f"Ошибка seed: {error}", file=sys.stderr)
return EXIT_DEV_FORBIDDEN return EXIT_DEV_FORBIDDEN

View File

@ -1,5 +1,6 @@
"""Фаза 2: применение проверенного набора к БД — upsert по натуральному ключу, без удалений и без commit.""" """Фаза 2: применение проверенного набора к БД — upsert по натуральному ключу, без удалений и без commit."""
import datetime
import uuid import uuid
from typing import Any from typing import Any
@ -8,10 +9,23 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from scripts.seed_lib.apply_rules import apply_elimination_rules, apply_required_document_rules from scripts.seed_lib.apply_rules import apply_elimination_rules, apply_required_document_rules
from scripts.seed_lib.model import SeedData, SeedReport, TableStat from scripts.seed_lib.model import SeedData, SeedError, SeedReport, TableStat
from src.domain.aliases import normalize_alias
from src.models.access import AppUser, Permission, Role, RolePermission, UserRole from src.models.access import AppUser, Permission, Role, RolePermission, UserRole
from src.models.accounting import ChartOfAccount from src.models.accounting import ChartOfAccount
from src.models.directories import RegionalOffice from src.models.directories import (
COMPANY_GROUP_CODE_SEQ,
RELATED_GROUP_CODE_SEQ,
CompanyGroup,
LegalEntity,
LegalEntityAlias,
RegionalOffice,
RelatedGroup,
RelatedGroupMember,
)
from src.repositories.directories import next_code
from src.services.company_groups import COMPANY_GROUP_CODE_PREFIX
from src.services.related_groups import CODE_PREFIXES
async def _upsert( async def _upsert(
@ -233,6 +247,175 @@ async def _apply_users(session: AsyncSession, data: SeedData, user_stat: TableSt
) )
async def _apply_company_groups(session: AsyncSession, data: SeedData, report: SeedReport) -> None: # noqa: PLR0912, PLR0915 - пять таблиц одним проходом по набору
"""Dev-ГК: upsert `company_group` по `name`, `legal_entity` по `inn`, `related_group` по (ГК, name); алиасы и члены
групп только добавляются. Коды — из тех же последовательностей, что у API (`next_code`); истории и аудита нет, как у
импорта ЕЦП при создании; `updated_by`/`created_by` пусты (seed — не пользователь). Удалений нет; вид существующей
ГСК/ГСЗ seed не меняет.
"""
if not data.company_groups:
return
now = datetime.datetime.now(datetime.UTC)
stats = {
name: report.stat(name)
for name in ("company_group", "legal_entity", "legal_entity_alias", "related_group", "related_group_member")
}
offices = {name: id_ for id_, name in (await session.execute(select(RegionalOffice.id, RegionalOffice.name))).all()}
groups = {
row.name: row
for row in (
await session.execute(
select(CompanyGroup.id, CompanyGroup.name, CompanyGroup.crm_id, CompanyGroup.base_regional_office_id)
)
).all()
}
entities = {
row.inn: row
for row in (
await session.execute(
select(
LegalEntity.id,
LegalEntity.inn,
LegalEntity.name,
LegalEntity.entity_type,
LegalEntity.company_group_id,
LegalEntity.regional_office_id,
)
)
).all()
}
alias_owner = {
normalized: entity_id
for entity_id, normalized in (
await session.execute(select(LegalEntityAlias.legal_entity_id, LegalEntityAlias.normalized_alias))
).all()
}
related = {
(row.company_group_id, row.name): row
for row in (
await session.execute(
select(RelatedGroup.id, RelatedGroup.company_group_id, RelatedGroup.name, RelatedGroup.kind)
)
).all()
}
members: set[tuple[uuid.UUID, uuid.UUID]] = {
(row.related_group_id, row.legal_entity_id)
for row in (
await session.execute(select(RelatedGroupMember.related_group_id, RelatedGroupMember.legal_entity_id))
).all()
}
for group in data.company_groups:
office_id = offices[group.regional_office]
stored_group = groups.get(group.name)
if stored_group is None:
group_id = uuid.uuid4()
session.add(
CompanyGroup(
id=group_id,
code=await next_code(session, COMPANY_GROUP_CODE_SEQ, COMPANY_GROUP_CODE_PREFIX),
crm_id=group.crm_id,
name=group.name,
base_regional_office_id=office_id,
valid_from=now,
created_at=now,
updated_at=now,
)
)
stats["company_group"].inserted += 1
else:
group_id = stored_group.id
if (stored_group.crm_id, stored_group.base_regional_office_id) != (group.crm_id, office_id):
await session.execute(
update(CompanyGroup)
.where(CompanyGroup.id == group_id)
.values(crm_id=group.crm_id, base_regional_office_id=office_id, updated_at=now)
)
stats["company_group"].updated += 1
else:
stats["company_group"].unchanged += 1
await session.flush()
entity_ids: dict[str, uuid.UUID] = {}
for entity in group.legal_entities:
values: dict[str, Any] = {
"name": entity.name,
"entity_type": entity.entity_type,
"company_group_id": group_id,
"regional_office_id": office_id,
}
stored_entity = entities.get(entity.inn)
if stored_entity is None:
entity_id = uuid.uuid4()
session.add(
LegalEntity(id=entity_id, inn=entity.inn, **values, valid_from=now, created_at=now, updated_at=now)
)
stats["legal_entity"].inserted += 1
else:
entity_id = stored_entity.id
if any(getattr(stored_entity, name) != value for name, value in values.items()):
await session.execute(
update(LegalEntity).where(LegalEntity.id == entity_id).values(**values, updated_at=now)
)
stats["legal_entity"].updated += 1
else:
stats["legal_entity"].unchanged += 1
entity_ids[entity.inn] = entity_id
for alias in entity.aliases:
normalized = normalize_alias(alias)
owner = alias_owner.get(normalized)
if owner == entity_id:
stats["legal_entity_alias"].unchanged += 1
continue
if owner is not None:
raise SeedError(
f"алиас «{alias}» ЮЛ {entity.inn} уже закреплён за другим ЮЛ (id {owner}); перенос — через API"
)
session.add(
LegalEntityAlias(
legal_entity_id=entity_id, alias=alias, normalized_alias=normalized, created_at=now
)
)
alias_owner[normalized] = entity_id
stats["legal_entity_alias"].inserted += 1
await session.flush()
for related_group in group.related_groups:
stored_related = related.get((group_id, related_group.name))
if stored_related is None:
related_id = uuid.uuid4()
session.add(
RelatedGroup(
id=related_id,
code=await next_code(session, RELATED_GROUP_CODE_SEQ, CODE_PREFIXES[related_group.kind]),
company_group_id=group_id,
kind=related_group.kind,
name=related_group.name,
created_date=now.date(),
)
)
stats["related_group"].inserted += 1
else:
related_id = stored_related.id
stats["related_group"].unchanged += 1
await session.flush()
for inn in related_group.member_inns:
pair = (related_id, entity_ids[inn])
if pair in members:
stats["related_group_member"].unchanged += 1
continue
session.add(
RelatedGroupMember(
related_group_id=related_id,
legal_entity_id=entity_ids[inn],
kind=related_group.kind,
valid_from=now,
valid_to=None,
is_current=True,
)
)
members.add(pair)
stats["related_group_member"].inserted += 1
await session.flush()
async def apply_seed(session: AsyncSession, data: SeedData) -> SeedReport: async def apply_seed(session: AsyncSession, data: SeedData) -> SeedReport:
"""Фаза 2: upsert набора в порядке зависимостей. Без commit — транзакцией управляет вызывающий.""" """Фаза 2: upsert набора в порядке зависимостей. Без commit — транзакцией управляет вызывающий."""
report = SeedReport() report = SeedReport()
@ -285,4 +468,6 @@ async def apply_seed(session: AsyncSession, data: SeedData) -> SeedReport:
await apply_required_document_rules(session, data, report.stat("required_document_rule")) await apply_required_document_rules(session, data, report.stat("required_document_rule"))
# dev-пользователи — после ролей и филиалов (в том числе dev-филиалов, они входят в `regional_offices`) # dev-пользователи — после ролей и филиалов (в том числе dev-филиалов, они входят в `regional_offices`)
await _apply_users(session, data, report.stat("app_user"), report.stat("user_role")) await _apply_users(session, data, report.stat("app_user"), report.stat("user_role"))
# dev-ГК — после филиалов (ссылки по имени); справочники независимы от пользователей
await _apply_company_groups(session, data, report)
return report return report

View File

@ -11,8 +11,11 @@ from typing import Any
from scripts.seed_lib.loaders_rules import load_elimination_rules, load_required_document_rules from scripts.seed_lib.loaders_rules import load_elimination_rules, load_required_document_rules
from scripts.seed_lib.model import ( from scripts.seed_lib.model import (
AccountSeed, AccountSeed,
CompanyGroupSeed,
LegalEntitySeed,
PermissionSeed, PermissionSeed,
RegionalOfficeSeed, RegionalOfficeSeed,
RelatedGroupSeed,
RoleSeed, RoleSeed,
SeedData, SeedData,
SeedError, SeedError,
@ -20,7 +23,17 @@ from scripts.seed_lib.model import (
account_codes, account_codes,
) )
from scripts.seed_lib.readers import bool_field, checked_records, ensure_unique, place, read_yaml, str_field from scripts.seed_lib.readers import bool_field, checked_records, ensure_unique, place, read_yaml, str_field
from src.models.enums import AccountKind, AccountRowType, BusinessSegment, PermissionAction, PermissionObject from src.domain.aliases import normalize_alias
from src.domain.inn import INN_LENGTH, normalize_inn
from src.models.enums import (
AccountKind,
AccountRowType,
BusinessSegment,
EntityType,
PermissionAction,
PermissionObject,
RelatedGroupKind,
)
DEFAULT_SEED_DIR = Path(__file__).resolve().parent.parent.parent / "seed" DEFAULT_SEED_DIR = Path(__file__).resolve().parent.parent.parent / "seed"
@ -254,8 +267,12 @@ def load_chart_of_accounts(core: Path, seed_dir: Path) -> dict[str, AccountSeed]
return accounts return accounts
def load_seed(seed_dir: Path | None = None, *, dev: bool = False) -> SeedData: def load_seed(seed_dir: Path | None = None, *, dev: bool = False, dev_groups: bool | None = None) -> SeedData:
"""Фаза 1: прочитать и проверить все файлы. Бросает `SeedError`; к БД не обращается.""" """Фаза 1: прочитать и проверить все файлы. Бросает `SeedError`; к БД не обращается.
`dev_groups` — грузить ли dev-ГК (`dev/company_groups.yaml`) при `dev`; по умолчанию да. Тесты API засевают
справочники без ГК (`dev_groups=False`), чтобы начинать с пустых справочников.
"""
seed_dir = seed_dir or DEFAULT_SEED_DIR seed_dir = seed_dir or DEFAULT_SEED_DIR
core = seed_dir / "core" core = seed_dir / "core"
data = SeedData() data = SeedData()
@ -271,7 +288,7 @@ def load_seed(seed_dir: Path | None = None, *, dev: bool = False) -> SeedData:
data.elimination_rules = load_elimination_rules(core, seed_dir, codes) data.elimination_rules = load_elimination_rules(core, seed_dir, codes)
data.required_document_rules = load_required_document_rules(core, seed_dir, codes) data.required_document_rules = load_required_document_rules(core, seed_dir, codes)
if dev: if dev:
_load_dev(data, seed_dir / "dev", seed_dir) _load_dev(data, seed_dir / "dev", seed_dir, groups=dev if dev_groups is None else dev_groups)
return data return data
@ -337,7 +354,118 @@ def _load_dev_users(data: SeedData, dev_dir: Path, seed_dir: Path) -> None:
data.users = users data.users = users
def _load_dev(data: SeedData, dev_dir: Path, seed_dir: Path) -> None: _ENTITY_KEYS = {"inn", "name", "entity_type", "aliases"}
"""Dev-данные (задача 15): филиалы, затем пользователи — они ссылаются на филиалы и роли набора.""" _RELATED_KEYS = {"kind", "name", "members"}
def _str_list(value: Any, what: str, where: str) -> list[str]:
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
raise SeedError(f"{where}: поле «{what}» должно быть списком строк")
return value
def _dev_legal_entities(record: dict[str, Any], where: str) -> list[LegalEntitySeed]:
items = record.get("legal_entities")
if not isinstance(items, list) or not items:
raise SeedError(f"{where}: поле «legal_entities» должно быть непустым списком")
entities: list[LegalEntitySeed] = []
for item in items:
if not isinstance(item, dict) or not {"inn", "name", "entity_type"} <= set(item) or set(item) - _ENTITY_KEYS:
raise SeedError(f"{where}: ЮЛ описывается полями inn, name, entity_type и необязательным aliases")
inn = normalize_inn(str(item["inn"]))
try:
entity_type = EntityType(str(item["entity_type"]))
except ValueError:
raise SeedError(f"{where}: ИНН {inn}: entity_type «{item['entity_type']}» (допустимы ЮЛ, ИП)") from None
length = INN_LENGTH[entity_type]
if len(inn) != length or not inn.isdigit():
raise SeedError(f"{where}: ИНН «{inn}» {entity_type.value} — ровно {length} цифр")
aliases = _str_list(item.get("aliases", []), "aliases", f"{where}: ИНН {inn}")
normalized = [normalize_alias(alias) for alias in aliases]
if "" in normalized:
raise SeedError(f"{where}: ИНН {inn}: алиас из одних ОПФ и пунктуации")
if len(set(normalized)) != len(normalized):
raise SeedError(f"{where}: ИНН {inn}: алиасы повторяются после нормализации")
entities.append(
LegalEntitySeed(inn=inn, name=str(item["name"]), entity_type=entity_type, aliases=tuple(aliases))
)
return entities
def _dev_related_groups(record: dict[str, Any], where: str, inns: set[str]) -> list[RelatedGroupSeed]:
items = record.get("related_groups", [])
if not isinstance(items, list):
raise SeedError(f"{where}: поле «related_groups» должно быть списком")
groups: list[RelatedGroupSeed] = []
for item in items:
if not isinstance(item, dict) or set(item) != _RELATED_KEYS:
raise SeedError(f"{where}: ГСК/ГСЗ описывается полями kind, name, members")
try:
kind = RelatedGroupKind(str(item["kind"]))
except ValueError:
raise SeedError(f"{where}: kind «{item['kind']}» (допустимы gsk, gsz)") from None
members = [normalize_inn(inn) for inn in _str_list(item["members"], "members", where)]
if not members:
raise SeedError(f"{where}: группа «{item['name']}» без членов")
unknown = [inn for inn in members if inn not in inns]
if unknown:
raise SeedError(f"{where}: группа «{item['name']}»: ИНН {unknown} не описаны в legal_entities этой ГК")
if len(set(members)) != len(members):
raise SeedError(f"{where}: группа «{item['name']}»: ИНН членов повторяются")
groups.append(RelatedGroupSeed(kind=kind, name=str(item["name"]), member_inns=tuple(members)))
names = [group.name for group in groups]
if len(set(names)) != len(names):
raise SeedError(f"{where}: имена ГСК/ГСЗ внутри ГК повторяются")
return groups
def _load_dev_company_groups(data: SeedData, dev_dir: Path, seed_dir: Path) -> None:
"""Dev-ГК (`dev/company_groups.yaml`): ЮЛ/ИП, алиасы, ГСК/ГСЗ; ссылки на филиалы и между собой — до записи в БД."""
path = dev_dir / "company_groups.yaml"
records = checked_records(
read_yaml(path, seed_dir),
{"name", "crm_id", "regional_office", "legal_entities", "related_groups"},
{"name", "regional_office", "legal_entities"},
path,
seed_dir,
)
office_names = {o.name for o in data.regional_offices}
groups: list[CompanyGroupSeed] = []
all_inns: list[str] = []
members_by_kind: dict[RelatedGroupKind, list[str]] = {kind: [] for kind in RelatedGroupKind}
for index, record in enumerate(records):
where = place(path, seed_dir, index, str(record.get("name")))
office = str(str_field(record, "regional_office", where))
if office not in office_names:
# филиал из выгрузки ЕЦП («Санкт-Петербургский РФ») заводится вместе с dev-ГК как РФ; без dev-ГК
# (`dev_groups=False`, тесты API) его нет, и импорт выгрузки создаёт его сам
data.regional_offices.append(RegionalOfficeSeed(name=office, is_head_office=False, is_active=True))
office_names.add(office)
entities = _dev_legal_entities(record, where)
related = _dev_related_groups(record, where, {entity.inn for entity in entities})
for group in related:
members_by_kind[group.kind].extend(group.member_inns)
all_inns.extend(entity.inn for entity in entities)
groups.append(
CompanyGroupSeed(
name=str(str_field(record, "name", where)),
crm_id=str_field(record, "crm_id", where, optional=True),
regional_office=office,
legal_entities=tuple(entities),
related_groups=tuple(related),
)
)
ensure_unique([group.name for group in groups], "ГК", path, seed_dir)
ensure_unique(all_inns, "ИНН", path, seed_dir)
for kind, inns in members_by_kind.items():
# ЮЛ входит не более чем в одну группу каждого вида (уникальный индекс related_group_member по (ЮЛ, вид))
ensure_unique(inns, f"ЮЛ в группах вида {kind.value}", path, seed_dir)
data.company_groups = groups
def _load_dev(data: SeedData, dev_dir: Path, seed_dir: Path, *, groups: bool = True) -> None:
"""Dev-данные: филиалы, затем пользователи и (если `groups`) ГК — они ссылаются на филиалы (и роли) набора."""
_load_dev_regional_offices(data, dev_dir, seed_dir) _load_dev_regional_offices(data, dev_dir, seed_dir)
_load_dev_users(data, dev_dir, seed_dir) _load_dev_users(data, dev_dir, seed_dir)
if groups:
_load_dev_company_groups(data, dev_dir, seed_dir)

View File

@ -9,8 +9,10 @@ from src.models.enums import (
BusinessSegment, BusinessSegment,
ConsolidationApproach, ConsolidationApproach,
DocumentType, DocumentType,
EntityType,
PermissionAction, PermissionAction,
PermissionObject, PermissionObject,
RelatedGroupKind,
StatementType, StatementType,
) )
@ -106,6 +108,36 @@ class UserSeed:
role_codes: tuple[str, ...] role_codes: tuple[str, ...]
@dataclass(frozen=True)
class LegalEntitySeed:
"""ЮЛ/ИП dev-ГК: ИНН — натуральный ключ; алиасы — как контрагент записан в ОСВ или в шапке документа."""
inn: str
name: str
entity_type: EntityType
aliases: tuple[str, ...]
@dataclass(frozen=True)
class RelatedGroupSeed:
"""ГСК/ГСЗ dev-ГК: имя уникально внутри ГК, члены — ИНН ЮЛ той же ГК."""
kind: RelatedGroupKind
name: str
member_inns: tuple[str, ...]
@dataclass(frozen=True)
class CompanyGroupSeed:
"""Dev-ГК (`dev/company_groups.yaml`): имя — натуральный ключ; филиал — по имени из набора."""
name: str
crm_id: str | None
regional_office: str
legal_entities: tuple[LegalEntitySeed, ...]
related_groups: tuple[RelatedGroupSeed, ...]
@dataclass @dataclass
class SeedData: class SeedData:
"""Проверенный набор seed; ничего не знает о БД.""" """Проверенный набор seed; ничего не знает о БД."""
@ -121,6 +153,8 @@ class SeedData:
required_document_rules: list[RequiredDocumentRuleSeed] = field(default_factory=list) required_document_rules: list[RequiredDocumentRuleSeed] = field(default_factory=list)
#: только при `--dev` (seed/dev/users.yaml); при `--dev` dev-филиалы дописываются в `regional_offices` #: только при `--dev` (seed/dev/users.yaml); при `--dev` dev-филиалы дописываются в `regional_offices`
users: list[UserSeed] = field(default_factory=list) users: list[UserSeed] = field(default_factory=list)
#: только при `--dev` (seed/dev/company_groups.yaml): ГК с ЮЛ/ИП, алиасами и ГСК/ГСЗ
company_groups: list[CompanyGroupSeed] = field(default_factory=list)
def account_codes(data: SeedData) -> frozenset[str]: def account_codes(data: SeedData) -> frozenset[str]:

View File

@ -0,0 +1,56 @@
# DEV-данные (только `python -m scripts.seed --dev`; в прод-режиме не загружаются). Группы компаний, чьи документы лежат
# в fixtures (golden-снимки ОСВ, формы ББ/ОФР): после seed файлы из fixtures загружаются на «свои» ЮЛ, шапки документов
# совпадают с владельцем, контрагенты ОСВ резолвятся по ИНН и алиасам.
# - «Русский Бисквит» — ровно как в выгрузке ЕЦП CRM fixtures/directories/ecp-export-russkiy-biskvit.xlsx (20 ЮЛ/ИП,
# строка ФЛ пропущена, crm_id ГК из выгрузки, филиал «Санкт-Петербургский РФ» заводится вместе с ГК как РФ); ГСК — РБ и
# Счастливочная. Повторный импорт выгрузки после seed даёт GK_EXISTS/LE_EXISTS.
# - владельцы форм пакета ДКБ (fixtures/statements-xlsx): Унагранде Компани и Групп, НПЗ, Межениновская. ОСВ-организации
# ДКБ без ИНН в шапках (Цимлянский, НКЗ, Новоросметалл, Приманыческий, Агрокомплекс) не заводятся — OQ-136.
# Алиасы — как контрагент записан в ОСВ другой компании группы (06-test-data.md §1) и как организация названа в шапке.
# Филиал ГК, которого нет в core/dev regional_office(s).yaml, создаётся как региональный филиал (is_head_office: false).
# Натуральные ключи upsert: ГК — name, ЮЛ — inn, ГСК/ГСЗ — name внутри ГК, алиас — нормализованный текст, член — пара
# (группа, ЮЛ); удалений seed не выполняет; ЮЛ может входить не более чем в одну ГСК и одну ГСЗ. Коды GK-/GSK-/GSZ- —
# из тех же последовательностей, что API.
- name: Русский Бисквит
crm_id: 1-AZFL6BQ
regional_office: Санкт-Петербургский РФ
legal_entities:
- {inn: "3501009024", name: "РЕГИОН ФИНАНС, ООО", entity_type: ЮЛ}
- {inn: "3523023093", name: "МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА, ООО", entity_type: ЮЛ}
- {inn: "3525133239", name: "ЭКО ВОЛОГДА, АО", entity_type: ЮЛ}
- {inn: "3528005122", name: "ЧКФ, АО", entity_type: ЮЛ, aliases: ["ЧКФ АО"]}
- {inn: "3528036219", name: "РСП ЧЕРМК-ЮГ, АО", entity_type: ЮЛ}
- {inn: "3528052718", name: "РУССКИЙ БИСКВИТ, АО", entity_type: ЮЛ, aliases: ['АО "Русский бисквит"']}
- {inn: "3528060540", name: "ВМГ, ООО", entity_type: ЮЛ}
- {inn: "3528104476", name: "ПЕКАРНЯ СЧАСТЛИВОЧНАЯ, ООО", entity_type: ЮЛ, aliases: ["Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ", 'ООО "Пекарня "Счастливочная"']}
- {inn: "3528106723", name: "ГРУППА ЮГ, ООО", entity_type: ЮЛ}
- {inn: "352819549855", name: ИП Юганов Максим Анатольевич, entity_type: ИП}
- {inn: "3528237275", name: "РЕАЛ-СВ, ООО СЗ", entity_type: ЮЛ}
- {inn: "352824068868", name: ИП Юганов Алексей Анатольевич, entity_type: ИП}
- {inn: "3528269164", name: "КРАНМОНТАЖ, ООО", entity_type: ЮЛ}
- {inn: "3528274076", name: "ГОРОДСКОЙ РЫНОК, ООО УК", entity_type: ЮЛ}
- {inn: "3528274083", name: "ГОРОДСКОЙ РЫНОК, ООО", entity_type: ЮЛ}
- {inn: "3528325958", name: "ФОРЕСТГАН, ООО", entity_type: ЮЛ}
- {inn: "3528328973", name: "РЕАЛ, ООО СЗ", entity_type: ЮЛ}
- {inn: "3528338428", name: "ЮМА, ЛИЧНЫЙ ФОНД", entity_type: ЮЛ}
- {inn: "7718879609", name: "ТК РБ, АО", entity_type: ЮЛ, aliases: ["РБ ТК"]}
- {inn: "7733902517", name: "ОФГ, ООО", entity_type: ЮЛ, aliases: ["ОФГ ООО"]}
related_groups:
- {kind: gsk, name: ГСК Русский Бисквит, members: ["3528052718", "3528104476"]}
- name: Унагранде
regional_office: Головной офис
legal_entities:
- {inn: "3249004697", name: 'АО "Унагранде Компани"', entity_type: ЮЛ, aliases: ['Акционерное общество "Унагранде Компани"']}
- {inn: "7728757141", name: 'АО "Унагранде Групп"', entity_type: ЮЛ, aliases: ['Акционерное общество "Унагранде Групп"']}
related_groups:
- {kind: gsk, name: ГСК Унагранде, members: ["3249004697", "7728757141"]}
- name: Новороссийский прокатный завод
regional_office: Краснодарский региональный филиал
legal_entities:
- {inn: "2315995868", name: 'ООО "НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД"', entity_type: ЮЛ, aliases: ['ООО "НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД" с обособленными подразделениями']}
related_groups: []
- name: Агрофирма Межениновская
regional_office: Ростовский региональный филиал
legal_entities:
- {inn: "7016006635", name: 'ООО "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"', entity_type: ЮЛ, aliases: ['ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"']}
related_groups: []

View File

@ -134,8 +134,9 @@ async def nested_transaction(session: AsyncSession) -> AsyncIterator[AsyncSessio
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def seeded(db_session: AsyncSession) -> AsyncSession: async def seeded(db_session: AsyncSession) -> AsyncSession:
"""`db_session` с засеянными справочниками и dev-пользователями (`run_seed(dev=True)`).""" """`db_session` со справочниками и dev-пользователями, без dev-ГК (`run_seed(dev=True, dev_groups=False)`): тесты
await run_seed(db_session, dev=True) API строят справочники сами и считают, что ГК/ЮЛ и филиала «Санкт-Петербургский РФ» в начале нет."""
await run_seed(db_session, dev=True, dev_groups=False)
await db_session.flush() await db_session.flush()
return db_session return db_session
@ -232,7 +233,7 @@ async def committed_sessionmaker() -> AsyncIterator[async_sessionmaker[AsyncSess
await alembic_async(url, "upgrade", "head") await alembic_async(url, "upgrade", "head")
maker = async_sessionmaker(engine, expire_on_commit=False) maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as session, session.begin(): async with maker() as session, session.begin():
await run_seed(session, dev=True) await run_seed(session, dev=True, dev_groups=False)
yield maker yield maker
finally: finally:
await engine.dispose() await engine.dispose()

View File

@ -1,6 +1,7 @@
"""Dev-seed (spec reference-data-seed, требование «Dev-данные только по явному флагу», задача 15).""" """Dev-seed (spec reference-data-seed, требование «Dev-данные только по явному флагу», задача 15)."""
import os import os
import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
@ -11,22 +12,37 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from scripts.seed import DEV_FORBIDDEN_MESSAGE, SeedError, check_dev_allowed, load_seed, run_seed from scripts.seed import DEV_FORBIDDEN_MESSAGE, SeedError, check_dev_allowed, load_seed, run_seed
from scripts.seed_lib.model import SeedReport
from src.core.config import get_settings from src.core.config import get_settings
from src.models.access import AppUser, Permission, Role, RolePermission, UserRole from src.models.access import AppUser, Permission, Role, RolePermission, UserRole
from src.models.directories import RegionalOffice from src.models.directories import (
from src.models.enums import BusinessSegment CompanyGroup,
LegalEntity,
LegalEntityAlias,
RegionalOffice,
RelatedGroup,
RelatedGroupMember,
)
from src.models.enums import BusinessSegment, EntityType, RelatedGroupKind
API_DIR = Path(__file__).resolve().parent.parent API_DIR = Path(__file__).resolve().parent.parent
SEED_DIR = API_DIR / "seed" SEED_DIR = API_DIR / "seed"
ROLE_CODES = ["KA_RF", "KA_GO", "RKA", "CLIENT_MANAGER", "CURATOR_GO", "EXPERT", "ADMIN"] ROLE_CODES = ["KA_RF", "KA_GO", "RKA", "CLIENT_MANAGER", "CURATOR_GO", "EXPERT", "ADMIN"]
USER_COUNT = len(ROLE_CODES) USER_COUNT = len(ROLE_CODES)
DEV_BRANCH_COUNT = 2 DEV_BRANCH_COUNT = 3
DEV_EMAILS = {f"{code.lower()}@armka.local" for code in ROLE_CODES} DEV_EMAILS = {f"{code.lower()}@armka.local" for code in ROLE_CODES}
HEAD_OFFICE = "Головной офис" HEAD_OFFICE = "Головной офис"
#: роли ГО — пользователь в Головном офисе, роли РФ — в региональном филиале #: роли ГО — пользователь в Головном офисе, роли РФ — в региональном филиале
HEAD_OFFICE_ROLES = {"KA_GO", "CURATOR_GO", "EXPERT", "ADMIN"} HEAD_OFFICE_ROLES = {"KA_GO", "CURATOR_GO", "EXPERT", "ADMIN"}
DEV_BRANCHES = {"Ростовский региональный филиал", "Краснодарский региональный филиал"} DEV_BRANCHES = {"Ростовский региональный филиал", "Краснодарский региональный филиал", "Санкт-Петербургский РФ"}
#: dev-ГК под документы fixtures (seed/dev/company_groups.yaml): выгрузка ЕЦП «Русский Бисквит» + владельцы форм ДКБ
DEV_GROUPS = {"Русский Бисквит", "Унагранде", "Новороссийский прокатный завод", "Агрофирма Межениновская"}
DEV_ENTITY_COUNT = 24
DEV_ALIAS_COUNT = 10
DEV_RELATED_GROUP_COUNT = 2
DEV_MEMBER_COUNT = 4
UNAGRANDE_ENTITY_COUNT = 2 # ЮЛ ГК «Унагранде» (тест переноса ГК на другой филиал)
async def _count(session: AsyncSession, model: type) -> int: async def _count(session: AsyncSession, model: type) -> int:
@ -41,6 +57,11 @@ async def _counts(session: AsyncSession) -> dict[str, int]:
"role": await _count(session, Role), "role": await _count(session, Role),
"permission": await _count(session, Permission), "permission": await _count(session, Permission),
"role_permission": await _count(session, RolePermission), "role_permission": await _count(session, RolePermission),
"company_group": await _count(session, CompanyGroup),
"legal_entity": await _count(session, LegalEntity),
"legal_entity_alias": await _count(session, LegalEntityAlias),
"related_group": await _count(session, RelatedGroup),
"related_group_member": await _count(session, RelatedGroupMember),
} }
@ -58,6 +79,7 @@ async def test_without_dev_flag_only_head_office_and_no_users(db_session: AsyncS
await run_seed(db_session, dev=False) await run_seed(db_session, dev=False)
assert await _count(db_session, AppUser) == 0 assert await _count(db_session, AppUser) == 0
assert await _count(db_session, CompanyGroup) == 0 and await _count(db_session, LegalEntity) == 0
assert await _count(db_session, UserRole) == 0 assert await _count(db_session, UserRole) == 0
offices = (await db_session.execute(select(RegionalOffice.name, RegionalOffice.is_head_office))).all() offices = (await db_session.execute(select(RegionalOffice.name, RegionalOffice.is_head_office))).all()
assert [(o.name, o.is_head_office) for o in offices] == [(HEAD_OFFICE, True)] assert [(o.name, o.is_head_office) for o in offices] == [(HEAD_OFFICE, True)]
@ -269,6 +291,23 @@ def test_missing_dev_file_is_seed_error_only_with_dev_flag(tmp_path: Path) -> No
# ---- `--dev` только при DEBUG=true (OQ-60) ---- # ---- `--dev` только при DEBUG=true (OQ-60) ----
def test_cli_no_dev_groups_flag_skips_company_groups(monkeypatch: pytest.MonkeyPatch) -> None:
"""`--dev --no-dev-groups`: загрузчик получает `dev_groups=False` (пустые справочники для импорта ЕЦП)."""
import scripts.seed as seed_module # noqa: PLC0415 - модуль CLI подменяется точечно
seen: dict[str, object] = {}
async def fake_run_cli(seed_dir: Path, *, dev: bool, dev_groups: bool) -> SeedReport:
seen.update(dev=dev, dev_groups=dev_groups)
return SeedReport()
monkeypatch.setattr(seed_module, "_run_cli", fake_run_cli)
assert seed_module.main(["--dev", "--no-dev-groups"]) == 0
assert seen == {"dev": True, "dev_groups": False}
assert seed_module.main(["--dev"]) == 0
assert seen == {"dev": True, "dev_groups": True}
def test_dev_allowed_only_with_debug() -> None: def test_dev_allowed_only_with_debug() -> None:
settings = get_settings() settings = get_settings()
@ -292,3 +331,120 @@ def test_cli_dev_without_debug_exits_with_code_2_before_db() -> None:
assert result.returncode == 2 # noqa: PLR2004 assert result.returncode == 2 # noqa: PLR2004
assert DEV_FORBIDDEN_MESSAGE in result.stderr assert DEV_FORBIDDEN_MESSAGE in result.stderr
assert "Seed применён" not in result.stdout assert "Seed применён" not in result.stdout
# ---- dev-ГК под документы fixtures ----
@pytest.mark.integration
async def test_dev_flag_loads_company_groups_matching_fixture_documents(db_session: AsyncSession) -> None:
"""Требование «Dev-данные только по явному флагу», сценарий «Dev-ГК под документы fixtures»."""
await run_seed(db_session, dev=True)
groups = {group.name: group for group in (await db_session.scalars(select(CompanyGroup))).all()}
offices = {office.id: office.name for office in (await db_session.scalars(select(RegionalOffice))).all()}
entities = {entity.inn: entity for entity in (await db_session.scalars(select(LegalEntity))).all()}
aliases = (await db_session.scalars(select(LegalEntityAlias))).all()
related = (await db_session.scalars(select(RelatedGroup))).all()
members = (await db_session.scalars(select(RelatedGroupMember))).all()
assert set(groups) == DEV_GROUPS
rb = groups["Русский Бисквит"]
assert rb.crm_id == "1-AZFL6BQ" and re.fullmatch(r"GK-\d{6}", rb.code)
assert offices[rb.base_regional_office_id] == "Санкт-Петербургский РФ"
assert all(re.fullmatch(r"GK-\d{6}", group.code) for group in groups.values())
assert len(entities) == DEV_ENTITY_COUNT
rb_entity = entities["3528052718"]
assert rb_entity.company_group_id == rb.id and rb_entity.regional_office_id == rb.base_regional_office_id
assert rb_entity.entity_type is EntityType.ЮЛ and entities["352819549855"].entity_type is EntityType.ИП
assert len(aliases) == DEV_ALIAS_COUNT
happy = entities["3528104476"]
assert {alias.normalized_alias for alias in aliases if alias.legal_entity_id == happy.id} == {
"юг финанс пекарня счастливочная",
"пекарня счастливочная",
}
assert {(group.name, group.kind) for group in related} == {
("ГСК Русский Бисквит", RelatedGroupKind.gsk),
("ГСК Унагранде", RelatedGroupKind.gsk),
}
assert all(re.fullmatch(r"GSK-\d{6}", group.code) for group in related)
assert len(members) == DEV_MEMBER_COUNT
assert all(
member.is_current and member.valid_to is None and member.kind is RelatedGroupKind.gsk for member in members
)
gsk_rb = next(group for group in related if group.name == "ГСК Русский Бисквит")
assert {member.legal_entity_id for member in members if member.related_group_id == gsk_rb.id} == {
rb_entity.id,
happy.id,
}
@pytest.mark.integration
async def test_changed_group_office_updates_group_and_keeps_codes(db_session: AsyncSession, tmp_path: Path) -> None:
await run_seed(db_session, dev=True)
before = {group.name: group.code for group in (await db_session.scalars(select(CompanyGroup))).all()}
target = _copy_seed(tmp_path)
groups_file = target / "dev" / "company_groups.yaml"
text = groups_file.read_text(encoding="utf-8")
assert text.count("regional_office: Головной офис") == 1
groups_file.write_text(
text.replace("regional_office: Головной офис", "regional_office: Ростовский региональный филиал"),
encoding="utf-8",
)
report = await run_seed(db_session, dev=True, seed_dir=target)
after = {group.name: group.code for group in (await db_session.scalars(select(CompanyGroup))).all()}
assert after == before
assert report.tables["company_group"].updated == 1 and report.tables["company_group"].inserted == 0
assert report.tables["legal_entity"].updated == UNAGRANDE_ENTITY_COUNT # ЮЛ переехали вслед за ГК
assert report.tables["legal_entity_alias"].inserted == 0 and report.tables["related_group_member"].inserted == 0
def test_loader_reads_dev_company_groups() -> None:
data = load_seed(dev=True)
assert {group.name for group in data.company_groups} == DEV_GROUPS
assert {office.name for office in data.regional_offices} == {HEAD_OFFICE, *DEV_BRANCHES}
assert {office.name for office in load_seed(dev=True, dev_groups=False).regional_offices} == {
HEAD_OFFICE,
*DEV_BRANCHES,
} - {"Санкт-Петербургский РФ"}
assert sum(len(group.legal_entities) for group in data.company_groups) == DEV_ENTITY_COUNT
assert (
sum(len(entity.aliases) for group in data.company_groups for entity in group.legal_entities) == DEV_ALIAS_COUNT
)
assert sum(len(group.related_groups) for group in data.company_groups) == DEV_RELATED_GROUP_COUNT
@pytest.mark.parametrize(
("old", "new", "expected"),
[
(
'members: ["3249004697", "7728757141"]',
'members: ["3249004697", "9999999999"]',
"не описаны в legal_entities",
),
('{inn: "2315995868"', '{inn: "231599586"', "ровно 10 цифр"),
(
'aliases: [\'ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"\']',
'aliases: ["ООО"]',
"из одних ОПФ",
),
(
' - {kind: gsk, name: ГСК Унагранде, members: ["3249004697", "7728757141"]}',
' - {kind: gsk, name: ГСК Унагранде, members: ["3249004697", "7728757141"]}\n'
' - {kind: gsk, name: ГСК Унагранде 2, members: ["3249004697"]}',
"gsk",
),
],
)
def test_validator_rejects_malformed_dev_company_groups(tmp_path: Path, old: str, new: str, expected: str) -> None:
target = _copy_seed(tmp_path)
groups_file = target / "dev" / "company_groups.yaml"
text = groups_file.read_text(encoding="utf-8")
assert text.count(old) == 1, old
groups_file.write_text(text.replace(old, new), encoding="utf-8")
with pytest.raises(SeedError, match=expected):
load_seed(target, dev=True)

View File

@ -83,6 +83,14 @@ dev-данные загружаются из файлов репозитория
- **WHEN** выполнен `python -m scripts.seed --dev` - **WHEN** выполнен `python -m scripts.seed --dev`
- **THEN** в `app_user` 7 строк с email вида `ka_rf@armka.local`, каждая связана ровно с одной ролью через `user_role` - **THEN** в `app_user` 7 строк с email вида `ka_rf@armka.local`, каждая связана ровно с одной ролью через `user_role`
#### Scenario: Dev-ГК под документы fixtures
- **WHEN** `python -m scripts.seed --dev` на пустой схеме
- **THEN** созданы ГК «Русский Бисквит» (crm_id и состав — выгрузка ЕЦП CRM из fixtures: 20 ЮЛ/ИП, филиал
«Санкт-Петербургский РФ», ГСК из АО «Русский бисквит» и ООО «Пекарня Счастливочная») и ГК владельцев форм пакета ДКБ
(Унагранде, НПЗ, Межениновская); у ЮЛ `3528104476` алиасы «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» и имя из шапки ОСВ, так
что документы из fixtures ложатся на «своих» ЮЛ с `counterparty_check = MATCH`; коды `GK-`/`GSK-NNNNNN` — из тех же
последовательностей, что API; повторный запуск ничего не меняет; без `--dev` ни одной ГК и ЮЛ нет
#### Scenario: Прод-режим #### Scenario: Прод-режим
- **WHEN** выполнен `python -m scripts.seed` без `--dev` - **WHEN** выполнен `python -m scripts.seed` без `--dev`
- **THEN** таблица `app_user` пуста, `regional_office` содержит только Головной офис - **THEN** таблица `app_user` пуста, `regional_office` содержит только Головной офис