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:
commit
ffeb26fa9e
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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]:
|
||||||
|
|||||||
56
api/seed/dev/company_groups.yaml
Normal file
56
api/seed/dev/company_groups.yaml
Normal 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: []
|
||||||
@ -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()
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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` содержит только Головной офис
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user