feat(seed): dev-ГК под документы fixtures — «Русский Бисквит» из выгрузки ЕЦП и владельцы форм ДКБ

seed/dev/company_groups.yaml: ГК «Русский Бисквит» ровно как в fixtures/directories/ecp-export-russkiy-biskvit.xlsx
(20 ЮЛ/ИП, crm_id, филиал «Санкт-Петербургский РФ», ГСК из РБ и Счастливочной), ГК Унагранде/НПЗ/Межениновская по
ИНН из форм ББ/ОФР; алиасы под имена контрагентов и шапок ОСВ («Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ», ...), чтобы
документы из fixtures ложились на «своих» ЮЛ и резолвились. Загрузчик проверяет ИНН, виды, ссылки членов и
уникальность «ЮЛ в одной ГСК/ГСЗ» до записи; apply — upsert по name/inn/(ГК, name), коды из последовательностей
API, без удалений. `--no-dev-groups` / `run_seed(dev_groups=False)` — dev-данные без ГК; фикстуры тестов API
используют его, чтобы начинать с пустых справочников.

make check: 1132 passed; интеграция seed/import: 23 passed. Два теста падают и на main из-за обновлённых golden
(test_mapping_golden 88 → 113, test_consolidation_fixtures UNMAPPED_TOTAL) — отдельная issue.

Closes #31

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
This commit is contained in:
Andrey Epifancev 2026-10-06 17:05:46 +04:00
parent 1d7f2c8176
commit 93cd1f8aa0
9 changed files with 605 additions and 24 deletions

View File

@ -93,11 +93,14 @@ cp api/.env.example api/.env
```bash
make migrate # alembic upgrade head: схема dkb, 27 enum-типов, 43 таблицы
make seed-dev # справочники продукта + dev-пользователи (по одному на каждую роль)
make seed-dev # справочники продукта + dev-пользователи (по одному на каждую роль) + dev-ГК под документы fixtures
```
`make seed` загружает только справочники (роли, права, филиалы, план счетов, правила элиминации, чек-лист), без
dev-пользователей. Seed идемпотентен: повторный запуск обновляет данные и ничего не дублирует; матрицу прав
dev-пользователей и dev-ГК. `make seed-dev` дополнительно создаёт три dev-филиала и группы компаний под документы из
`fixtures` (`api/seed/dev/company_groups.yaml`): ГК «Русский Бисквит» ровно как в выгрузке ЕЦП CRM (20 ЮЛ/ИП, ГСК из РБ и
Счастливочной, алиасы под имена контрагентов из ОСВ) и ГК владельцев форм пакета ДКБ — ОСВ и формы из `fixtures` после
этого загружаются на «свои» ЮЛ без импорта справочников. Seed идемпотентен: повторный запуск обновляет данные и ничего не дублирует; матрицу прав
`role_permission` он загружает целиком только в пустую таблицу, в заполненную — лишь связи новых ролей и прав
(правки администратора не перезаписываются, см. «Администрирование»). `make seed-dev`
(`--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 (составы счетов и чек-лист приводятся к файлу без удалений; ключи без уникального
индекса — в 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`,
`user_role` по (user_id, role_id)). Ссылки пользователей на роли и филиалы проверяются до записи. Без `--dev` ни
пользователи, ни dev-филиалы не загружаются. Dev-данные — только для локальной разработки и тестовых стендов: CLI
@ -77,15 +79,18 @@ def check_dev_allowed(settings: "Settings") -> str | None:
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_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 при импорте,
# поэтому `--help` и ошибки валидации без `--dev` работают без env; `--dev` сначала сверяется с DEBUG
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:
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
engine = create_async_engine(
@ -113,10 +118,15 @@ def main(argv: list[str] | None = None) -> int:
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))
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

View File

@ -1,5 +1,6 @@
"""Фаза 2: применение проверенного набора к БД — upsert по натуральному ключу, без удалений и без commit."""
import datetime
import uuid
from typing import Any
@ -8,10 +9,23 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
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.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(
@ -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:
"""Фаза 2: upsert набора в порядке зависимостей. Без commit — транзакцией управляет вызывающий."""
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"))
# dev-пользователи — после ролей и филиалов (в том числе dev-филиалов, они входят в `regional_offices`)
await _apply_users(session, data, report.stat("app_user"), report.stat("user_role"))
# dev-ГК — после филиалов (ссылки по имени); справочники независимы от пользователей
await _apply_company_groups(session, data, 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.model import (
AccountSeed,
CompanyGroupSeed,
LegalEntitySeed,
PermissionSeed,
RegionalOfficeSeed,
RelatedGroupSeed,
RoleSeed,
SeedData,
SeedError,
@ -20,7 +23,17 @@ from scripts.seed_lib.model import (
account_codes,
)
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"
@ -254,8 +267,12 @@ def load_chart_of_accounts(core: Path, seed_dir: Path) -> dict[str, AccountSeed]
return accounts
def load_seed(seed_dir: Path | None = None, *, dev: bool = False) -> SeedData:
"""Фаза 1: прочитать и проверить все файлы. Бросает `SeedError`; к БД не обращается."""
def load_seed(seed_dir: Path | None = None, *, dev: bool = False, dev_groups: bool | None = None) -> SeedData:
"""Фаза 1: прочитать и проверить все файлы. Бросает `SeedError`; к БД не обращается.
`dev_groups` — грузить ли dev-ГК (`dev/company_groups.yaml`) при `dev`; по умолчанию да. Тесты API засевают
справочники без ГК (`dev_groups=False`), чтобы начинать с пустых справочников.
"""
seed_dir = seed_dir or DEFAULT_SEED_DIR
core = seed_dir / "core"
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.required_document_rules = load_required_document_rules(core, seed_dir, codes)
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
@ -337,7 +354,118 @@ def _load_dev_users(data: SeedData, dev_dir: Path, seed_dir: Path) -> None:
data.users = users
def _load_dev(data: SeedData, dev_dir: Path, seed_dir: Path) -> None:
"""Dev-данные (задача 15): филиалы, затем пользователи — они ссылаются на филиалы и роли набора."""
_ENTITY_KEYS = {"inn", "name", "entity_type", "aliases"}
_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_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,
ConsolidationApproach,
DocumentType,
EntityType,
PermissionAction,
PermissionObject,
RelatedGroupKind,
StatementType,
)
@ -106,6 +108,36 @@ class UserSeed:
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
class SeedData:
"""Проверенный набор seed; ничего не знает о БД."""
@ -121,6 +153,8 @@ class SeedData:
required_document_rules: list[RequiredDocumentRuleSeed] = field(default_factory=list)
#: только при `--dev` (seed/dev/users.yaml); при `--dev` dev-филиалы дописываются в `regional_offices`
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]:

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

View File

@ -1,6 +1,7 @@
"""Dev-seed (spec reference-data-seed, требование «Dev-данные только по явному флагу», задача 15)."""
import os
import re
import shutil
import subprocess
import sys
@ -11,22 +12,37 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
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.models.access import AppUser, Permission, Role, RolePermission, UserRole
from src.models.directories import RegionalOffice
from src.models.enums import BusinessSegment
from src.models.directories import (
CompanyGroup,
LegalEntity,
LegalEntityAlias,
RegionalOffice,
RelatedGroup,
RelatedGroupMember,
)
from src.models.enums import BusinessSegment, EntityType, RelatedGroupKind
API_DIR = Path(__file__).resolve().parent.parent
SEED_DIR = API_DIR / "seed"
ROLE_CODES = ["KA_RF", "KA_GO", "RKA", "CLIENT_MANAGER", "CURATOR_GO", "EXPERT", "ADMIN"]
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}
HEAD_OFFICE = "Головной офис"
#: роли ГО — пользователь в Головном офисе, роли РФ — в региональном филиале
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:
@ -41,6 +57,11 @@ async def _counts(session: AsyncSession) -> dict[str, int]:
"role": await _count(session, Role),
"permission": await _count(session, Permission),
"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)
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
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)]
@ -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) ----
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:
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 DEV_FORBIDDEN_MESSAGE in result.stderr
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`
- **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: Прод-режим
- **WHEN** выполнен `python -m scripts.seed` без `--dev`
- **THEN** таблица `app_user` пуста, `regional_office` содержит только Головной офис