From 93cd1f8aa0b9f1eafc2857343f633ca5866931aa Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 17:05:46 +0400 Subject: [PATCH] =?UTF-8?q?feat(seed):=20dev-=D0=93=D0=9A=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D1=8B?= =?UTF-8?q?=20fixtures=20=E2=80=94=20=C2=AB=D0=A0=D1=83=D1=81=D1=81=D0=BA?= =?UTF-8?q?=D0=B8=D0=B9=20=D0=91=D0=B8=D1=81=D0=BA=D0=B2=D0=B8=D1=82=C2=BB?= =?UTF-8?q?=20=D0=B8=D0=B7=20=D0=B2=D1=8B=D0=B3=D1=80=D1=83=D0=B7=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=95=D0=A6=D0=9F=20=D0=B8=20=D0=B2=D0=BB=D0=B0=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D1=86=D1=8B=20=D1=84=D0=BE=D1=80=D0=BC=20?= =?UTF-8?q?=D0=94=D0=9A=D0=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- README.md | 7 +- api/scripts/seed.py | 24 ++- api/scripts/seed_lib/apply.py | 189 ++++++++++++++++++++- api/scripts/seed_lib/loaders.py | 140 ++++++++++++++- api/scripts/seed_lib/model.py | 34 ++++ api/seed/dev/company_groups.yaml | 56 ++++++ api/tests/conftest.py | 7 +- api/tests/test_seed_dev.py | 164 +++++++++++++++++- openspec/specs/reference-data-seed/spec.md | 8 + 9 files changed, 605 insertions(+), 24 deletions(-) create mode 100644 api/seed/dev/company_groups.yaml diff --git a/README.md b/README.md index 5d84a03..7a85e03 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/api/scripts/seed.py b/api/scripts/seed.py index 4134ada..83104e6 100644 --- a/api/scripts/seed.py +++ b/api/scripts/seed.py @@ -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 diff --git a/api/scripts/seed_lib/apply.py b/api/scripts/seed_lib/apply.py index 975f78d..9fcaaff 100644 --- a/api/scripts/seed_lib/apply.py +++ b/api/scripts/seed_lib/apply.py @@ -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 diff --git a/api/scripts/seed_lib/loaders.py b/api/scripts/seed_lib/loaders.py index 009d401..545f15f 100644 --- a/api/scripts/seed_lib/loaders.py +++ b/api/scripts/seed_lib/loaders.py @@ -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) diff --git a/api/scripts/seed_lib/model.py b/api/scripts/seed_lib/model.py index 03bca4e..ee59cac 100644 --- a/api/scripts/seed_lib/model.py +++ b/api/scripts/seed_lib/model.py @@ -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]: diff --git a/api/seed/dev/company_groups.yaml b/api/seed/dev/company_groups.yaml new file mode 100644 index 0000000..6176be7 --- /dev/null +++ b/api/seed/dev/company_groups.yaml @@ -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: [] diff --git a/api/tests/conftest.py b/api/tests/conftest.py index dade4ff..108e1b9 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -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() diff --git a/api/tests/test_seed_dev.py b/api/tests/test_seed_dev.py index 5ac65e6..81de2a8 100644 --- a/api/tests/test_seed_dev.py +++ b/api/tests/test_seed_dev.py @@ -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) diff --git a/openspec/specs/reference-data-seed/spec.md b/openspec/specs/reference-data-seed/spec.md index a764ec2..084375d 100644 --- a/openspec/specs/reference-data-seed/spec.md +++ b/openspec/specs/reference-data-seed/spec.md @@ -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` содержит только Головной офис