Compare commits
No commits in common. "ffeb26fa9e849dfb01fc35947561045cf4ddf4cd" and "1d7f2c81769146ba9cc297596df1484a4b4c60c7" have entirely different histories.
ffeb26fa9e
...
1d7f2c8176
@ -93,14 +93,11 @@ 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-пользователи (по одному на каждую роль) + dev-ГК под документы fixtures
|
make seed-dev # справочники продукта + dev-пользователи (по одному на каждую роль)
|
||||||
```
|
```
|
||||||
|
|
||||||
`make seed` загружает только справочники (роли, права, филиалы, план счетов, правила элиминации, чек-лист), без
|
`make seed` загружает только справочники (роли, права, филиалы, план счетов, правила элиминации, чек-лист), без
|
||||||
dev-пользователей и dev-ГК. `make seed-dev` дополнительно создаёт три dev-филиала и группы компаний под документы из
|
dev-пользователей. Seed идемпотентен: повторный запуск обновляет данные и ничего не дублирует; матрицу прав
|
||||||
`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,9 +25,7 @@ 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/`): dev-ГК под документы fixtures (`company_groups.yaml`: ГК, ЮЛ/ИП, алиасы,
|
Dev-данные (`--dev`, `seed/dev/`, задача 15): 2 региональных филиала (`regional_offices.yaml`, upsert по `name`)
|
||||||
ГСК/ГСЗ; отключаются `--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
|
||||||
@ -79,18 +77,15 @@ 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(
|
async def run_seed(session: AsyncSession, *, dev: bool = False, seed_dir: Path | None = None) -> SeedReport:
|
||||||
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`; подменяется в тестах. `dev_groups=False` —
|
`seed_dir` — каталог с `core/` (и `dev/`), по умолчанию `api/seed`; подменяется в тестах.
|
||||||
dev-данные без ГК (фикстуры тестов API начинают с пустых справочников).
|
|
||||||
"""
|
"""
|
||||||
return await apply_seed(session, load_seed(seed_dir, dev=dev, dev_groups=dev_groups))
|
return await apply_seed(session, load_seed(seed_dir, dev=dev))
|
||||||
|
|
||||||
|
|
||||||
async def _run_cli(seed_dir: Path, *, dev: bool, dev_groups: bool) -> SeedReport:
|
async def _run_cli(seed_dir: Path, *, dev: bool) -> SeedReport:
|
||||||
# импорт настроек — только после проверки файлов seed: модуль конфигурации читает ENV_FILE при импорте,
|
# импорт настроек — только после проверки файлов seed: модуль конфигурации читает ENV_FILE при импорте,
|
||||||
# поэтому `--help` и ошибки валидации без `--dev` работают без env; `--dev` сначала сверяется с DEBUG
|
# поэтому `--help` и ошибки валидации без `--dev` работают без env; `--dev` сначала сверяется с DEBUG
|
||||||
if dev:
|
if dev:
|
||||||
@ -98,7 +93,7 @@ async def _run_cli(seed_dir: Path, *, dev: bool, dev_groups: 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, dev_groups=dev_groups)
|
data = load_seed(seed_dir, dev=dev)
|
||||||
from src.core.config import settings # noqa: PLC0415
|
from src.core.config import settings # noqa: PLC0415
|
||||||
|
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
@ -118,15 +113,10 @@ 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, dev_groups=not args.no_dev_groups))
|
report = asyncio.run(_run_cli(args.seed_dir, dev=args.dev))
|
||||||
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,6 +1,5 @@
|
|||||||
"""Фаза 2: применение проверенного набора к БД — upsert по натуральному ключу, без удалений и без commit."""
|
"""Фаза 2: применение проверенного набора к БД — upsert по натуральному ключу, без удалений и без commit."""
|
||||||
|
|
||||||
import datetime
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@ -9,23 +8,10 @@ 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, SeedError, SeedReport, TableStat
|
from scripts.seed_lib.model import SeedData, 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 (
|
from src.models.directories import RegionalOffice
|
||||||
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(
|
||||||
@ -247,175 +233,6 @@ 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()
|
||||||
@ -468,6 +285,4 @@ 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,11 +11,8 @@ 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,
|
||||||
@ -23,17 +20,7 @@ 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.domain.aliases import normalize_alias
|
from src.models.enums import AccountKind, AccountRowType, BusinessSegment, PermissionAction, PermissionObject
|
||||||
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"
|
||||||
|
|
||||||
@ -267,12 +254,8 @@ 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, dev_groups: bool | None = None) -> SeedData:
|
def load_seed(seed_dir: Path | None = None, *, dev: bool = False) -> 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()
|
||||||
@ -288,7 +271,7 @@ def load_seed(seed_dir: Path | None = None, *, dev: bool = False, dev_groups: bo
|
|||||||
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, groups=dev if dev_groups is None else dev_groups)
|
_load_dev(data, seed_dir / "dev", seed_dir)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@ -354,118 +337,7 @@ def _load_dev_users(data: SeedData, dev_dir: Path, seed_dir: Path) -> None:
|
|||||||
data.users = users
|
data.users = users
|
||||||
|
|
||||||
|
|
||||||
_ENTITY_KEYS = {"inn", "name", "entity_type", "aliases"}
|
def _load_dev(data: SeedData, dev_dir: Path, seed_dir: Path) -> None:
|
||||||
_RELATED_KEYS = {"kind", "name", "members"}
|
"""Dev-данные (задача 15): филиалы, затем пользователи — они ссылаются на филиалы и роли набора."""
|
||||||
|
|
||||||
|
|
||||||
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,10 +9,8 @@ from src.models.enums import (
|
|||||||
BusinessSegment,
|
BusinessSegment,
|
||||||
ConsolidationApproach,
|
ConsolidationApproach,
|
||||||
DocumentType,
|
DocumentType,
|
||||||
EntityType,
|
|
||||||
PermissionAction,
|
PermissionAction,
|
||||||
PermissionObject,
|
PermissionObject,
|
||||||
RelatedGroupKind,
|
|
||||||
StatementType,
|
StatementType,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -108,36 +106,6 @@ 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; ничего не знает о БД."""
|
||||||
@ -153,8 +121,6 @@ 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]:
|
||||||
|
|||||||
@ -1,56 +0,0 @@
|
|||||||
# 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,9 +134,8 @@ 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-пользователями, без dev-ГК (`run_seed(dev=True, dev_groups=False)`): тесты
|
"""`db_session` с засеянными справочниками и dev-пользователями (`run_seed(dev=True)`)."""
|
||||||
API строят справочники сами и считают, что ГК/ЮЛ и филиала «Санкт-Петербургский РФ» в начале нет."""
|
await run_seed(db_session, dev=True)
|
||||||
await run_seed(db_session, dev=True, dev_groups=False)
|
|
||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
return db_session
|
return db_session
|
||||||
|
|
||||||
@ -233,7 +232,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, dev_groups=False)
|
await run_seed(session, dev=True)
|
||||||
yield maker
|
yield maker
|
||||||
finally:
|
finally:
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
"""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
|
||||||
@ -12,37 +11,22 @@ 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 (
|
from src.models.directories import RegionalOffice
|
||||||
CompanyGroup,
|
from src.models.enums import BusinessSegment
|
||||||
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 = 3
|
DEV_BRANCH_COUNT = 2
|
||||||
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:
|
||||||
@ -57,11 +41,6 @@ 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),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -79,7 +58,6 @@ 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)]
|
||||||
@ -291,23 +269,6 @@ 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()
|
||||||
|
|
||||||
@ -331,120 +292,3 @@ 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,14 +83,6 @@ 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