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:
parent
1d7f2c8176
commit
93cd1f8aa0
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]:
|
||||
|
||||
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
|
||||
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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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` содержит только Головной офис
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user