From f93663b2a49184030be7344bc81eb58d9be9203e Mon Sep 17 00:00:00 2001 From: Aleksandr Meshchriakov Date: Sat, 13 Jun 2026 14:07:40 +0200 Subject: [PATCH] feat: import organization exchange v3 --- docker-compose.dev.yml | 6 +- docker-compose.service.yml | 4 +- src/apps/exchange/services.py | 506 ++++++++---------- src/apps/organization/analytics_services.py | 4 +- src/apps/organization/api.py | 32 -- .../0004_organization_directory_fields.py | 228 ++++++++ src/apps/organization/models.py | 294 +++++++++- src/apps/organization/scope_utils.py | 160 ++++-- src/apps/organization/serializers.py | 103 ++-- tests/apps/exchange/test_api.py | 233 ++++++-- tests/apps/organization/test_analytics_api.py | 19 +- tests/apps/organization/test_api.py | 177 +----- tests/apps/organization/test_models.py | 83 +-- 13 files changed, 1167 insertions(+), 682 deletions(-) create mode 100644 src/apps/organization/migrations/0004_organization_directory_fields.py diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index d176df4..105dd0d 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -19,7 +19,7 @@ services: - ./data/db:/var/lib/postgresql/data - ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql ports: - - "5432:5432" + - "${STATE_CORP_POSTGRES_HOST_PORT:-25432}:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 10s @@ -31,7 +31,7 @@ services: container_name: state_corp_redis restart: unless-stopped ports: - - "6379:6379" + - "${STATE_CORP_REDIS_HOST_PORT:-26379}:6379" volumes: - ./data/redis:/data healthcheck: @@ -54,7 +54,7 @@ services: redis: condition: service_healthy ports: - - "8000:8000" + - "${STATE_CORP_WEB_HOST_PORT:-28000}:8000" volumes: - ./src:/app/src - ./logs:/app/logs diff --git a/docker-compose.service.yml b/docker-compose.service.yml index 1234bd8..7041ba2 100644 --- a/docker-compose.service.yml +++ b/docker-compose.service.yml @@ -11,7 +11,7 @@ services: - ./data/db:/var/lib/postgresql/data - ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql ports: - - "5432:5432" + - "${STATE_CORP_POSTGRES_HOST_PORT:-25432}:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 30s @@ -23,7 +23,7 @@ services: container_name: state_corp_redis restart: unless-stopped ports: - - "6379:6379" + - "${STATE_CORP_REDIS_HOST_PORT:-26379}:6379" volumes: - ./data/redis:/data healthcheck: diff --git a/src/apps/exchange/services.py b/src/apps/exchange/services.py index 99aa4fb..ca0773b 100644 --- a/src/apps/exchange/services.py +++ b/src/apps/exchange/services.py @@ -6,6 +6,7 @@ import base64 import hashlib import json import struct +import uuid import zlib from dataclasses import dataclass from datetime import date, datetime @@ -34,7 +35,6 @@ from apps.external_data.models import ( PublicProcurement, ) from apps.organization.models import IndustryCluster, Organization, OrganizationType -from apps.registers.models import Register, RegisterUpload, RegistryMembershipPeriod from cryptography.hazmat.primitives.ciphers.aead import AESGCM from django.conf import settings from django.db import transaction @@ -65,41 +65,123 @@ class ExchangePackageImportService: AAD = b"state-corp-exchange-v1" PAYLOAD_FORMAT = "state-corp-exchange-payload" BIN_FORMAT = "state-corp-exchange-bin" + SUPPORTED_SCHEMA_VERSION = 3 ORGANIZATION_STR_FIELDS = { + "full_name": ("full_name",), "short_name": ("short_name",), + "pn_name": ("pn_name",), + "pn_name_en": ("pn_name_en",), "ogrn": ("ogrn",), "kpp": ("kpp",), "okpo": ("okpo",), + "ogrip": ("ogrip",), + "identity_status": ("identity_status",), + "primary_identity": ("primary_identity",), + "gk_code": ("gk_code",), + "gk_name": ("gk_name",), + "in_korp_code": ("in_korp_code",), + "in_korp_name": ("in_korp_name",), + "filial": ("filial",), + "create_date": ("create_date",), + "organizational_legal_form": ("organizational_legal_form",), + "organizational_legal_form1": ("organizational_legal_form1",), + "ownership_form": ("ownership_form",), + "ownership_form1": ("ownership_form1",), "legal_address": ("legal_address",), - "activity_type": ("activity_type",), - "founder_name": ("founder_name",), - "ownership_type": ("ownership_type",), - "legal_form": ("legal_form",), - "general_director_name": ("general_director_name",), - "general_director_inn": ("general_director_inn",), + "business_act_cod": ("business_act_cod",), + "business_activity": ("business_activity",), + "general_director": ("general_director",), + "general_director_tax_id": ("general_director_tax_id",), + "uk": ("uk",), + "inn_uk": ("inn_uk",), + "cf_fl_rn": ("cf_fl_rn",), + "akc_fs": ("akc_fs",), + "akc_sf": ("akc_sf",), + "ropk_num": ("ropk_num",), + "ropk_razdel_num": ("ropk_razdel_num",), + "ropk_razdel_name": ("ropk_razdel_name",), + "min": ("min",), + "dep": ("dep",), + "otr": ("otr",), + "integrated_structure": ("integrated_structure",), + "state_sector_code": ("state_sector_code",), + "state_sector_name": ("state_sector_name",), } ORGANIZATION_DATE_FIELDS = { "registration_date": ("registration_date",), - "general_director_appointment_date": ("general_director_appointment_date",), + "appointment_date": ("appointment_date",), } ORGANIZATION_BOOL_FIELDS = { - "financial_reports_available": ("financial_reports_available",), - "tax_reports_available": ("tax_reports_available",), - "in_defense_unreliable_suppliers_registry": ( - "in_defense_unreliable_suppliers_registry", - ), - "in_275_fz_registry": ("in_275_fz_registry",), - "bankruptcy_messages_found": ("bankruptcy_messages_found",), + "is_branch": ("is_branch",), + "re_za": ("re_za",), + "re_zasf": ("re_zasf",), + "goz_participation": ("goz_participation",), + "opk_registry_membership": ("opk_registry_membership",), } ORGANIZATION_INT_FIELDS = { - "executors_count": ("executors_count",), + "rn": ("rn",), } ORGANIZATION_DECIMAL_FIELDS = { - "charter_capital_amount": ("charter_capital_amount",), + "authorized_capital": ("authorized_capital",), } + ORGANIZATION_UUID_FIELDS = { + "mostovik_uid": ("mostovik_uid",), + } + ORGANIZATION_REQUIRED_FIELDS = ( + "mostovik_uid", + "rn", + "name", + "full_name", + "short_name", + "pn_name", + "pn_name_en", + "inn", + "kpp", + "ogrn", + "ogrip", + "okpo", + "identity_status", + "primary_identity", + "gk_code", + "gk_name", + "in_korp_code", + "in_korp_name", + "filial", + "is_branch", + "re_za", + "re_zasf", + "goz_participation", + "opk_registry_membership", + "ropk_num", + "ropk_razdel_num", + "ropk_razdel_name", + "registration_date", + "create_date", + "organizational_legal_form", + "organizational_legal_form1", + "ownership_form", + "ownership_form1", + "authorized_capital", + "legal_address", + "business_act_cod", + "business_activity", + "general_director", + "general_director_tax_id", + "uk", + "inn_uk", + "appointment_date", + "cf_fl_rn", + "akc_fs", + "akc_sf", + "min", + "dep", + "otr", + "integrated_structure", + "state_sector_code", + "state_sector_name", + ) SECTION_KEYS = ( "organizations", - "registry_memberships", "industrial_certificates", "manufacturers", "industrial_products", @@ -366,6 +448,34 @@ class ExchangePackageImportService: if not isinstance(manifest, dict) or not isinstance(data, dict): raise ExchangeImportError("Payload пакета не содержит manifest/data") cls._read_required_str(manifest, "package_id") + schema_version = cls._read_schema_version(payload, manifest) + if schema_version != cls.SUPPORTED_SCHEMA_VERSION: + raise ExchangeImportError( + "Поддерживается только schema_version " + f"{cls.SUPPORTED_SCHEMA_VERSION}" + ) + if "registry_memberships" in data: + raise ExchangeImportError( + "Раздел registry_memberships не поддерживается в schema_version 3" + ) + cls._validate_organization_rows_schema(data) + + @classmethod + def _validate_organization_rows_schema(cls, data: dict[str, Any]) -> None: + organization_rows = cls._extract_rows(data, "organizations") + for index, row in enumerate(organization_rows, start=1): + missing_fields = [ + field_name + for field_name in cls.ORGANIZATION_REQUIRED_FIELDS + if field_name not in row + ] + if missing_fields: + raise ExchangeImportError( + "Строка organizations " + f"#{index} не соответствует schema_version 3; " + "отсутствуют поля: " + f"{', '.join(missing_fields)}" + ) @classmethod def _extract_manifest(cls, payload: dict[str, Any]) -> dict[str, Any]: @@ -492,21 +602,12 @@ class ExchangePackageImportService: if not isinstance(data, dict): raise ExchangeImportError("Раздел data в пакете поврежден") - manifest = cls._extract_manifest(payload) - actual_date = cls._read_actual_date(manifest) organization_rows = cls._extract_rows(data, "organizations") allowed_organization_inns = cls._collect_package_organization_inns( organization_rows ) organization_summary = cls._upsert_organizations(organization_rows) - registry_membership_summary = cls._sync_registry_memberships( - cls._extract_rows(data, "registry_memberships"), - allowed_organization_inns=allowed_organization_inns, - actual_date=actual_date, - package_hash=package_hash, - package_name=package_name, - ) industrial_certificate_summary = cls._upsert_industrial_certificates( cls._extract_rows(data, "industrial_certificates"), allowed_organization_inns=allowed_organization_inns, @@ -554,7 +655,6 @@ class ExchangePackageImportService: return { "organizations": organization_summary, - "registry_memberships": registry_membership_summary, "industrial_certificates": industrial_certificate_summary, "manufacturers": manufacturer_summary, "industrial_products": industrial_summary, @@ -646,54 +746,96 @@ class ExchangePackageImportService: ) -> list[str]: update_fields: list[str] = [] + for field_name, aliases in cls.ORGANIZATION_UUID_FIELDS.items(): + value, present = cls._get_present_value(row, aliases) + if not present: + continue + parsed_value = cls._parse_uuid_value(value, field_name=field_name) + cls._set_organization_field( + organization=organization, + field_name=field_name, + value=parsed_value, + update_fields=update_fields, + ) + for field_name, aliases in cls.ORGANIZATION_STR_FIELDS.items(): value, present = cls._get_present_value(row, aliases) if not present: continue cleaned_value = ( cls._clean_digits(value) - if field_name in {"ogrn", "kpp", "okpo", "general_director_inn"} + if field_name + in { + "ogrn", + "kpp", + "okpo", + "ogrip", + "general_director_tax_id", + "inn_uk", + } else cls._clean_string(value) ) - if getattr(organization, field_name) != cleaned_value: - setattr(organization, field_name, cleaned_value) - update_fields.append(field_name) + cls._set_organization_field( + organization=organization, + field_name=field_name, + value=cleaned_value, + update_fields=update_fields, + ) for field_name, aliases in cls.ORGANIZATION_DATE_FIELDS.items(): value, present = cls._get_present_value(row, aliases) if not present: continue parsed_value = cls._parse_date_value(value, field_name=field_name) - if getattr(organization, field_name) != parsed_value: - setattr(organization, field_name, parsed_value) - update_fields.append(field_name) + cls._set_organization_field( + organization=organization, + field_name=field_name, + value=parsed_value, + update_fields=update_fields, + ) for field_name, aliases in cls.ORGANIZATION_BOOL_FIELDS.items(): value, present = cls._get_present_value(row, aliases) if not present: continue - parsed_value = cls._parse_bool_value(value, field_name=field_name) - if getattr(organization, field_name) != parsed_value: - setattr(organization, field_name, parsed_value) - update_fields.append(field_name) + parsed_value = cls._parse_optional_bool_value( + value, + field_name=field_name, + ) + cls._set_organization_field( + organization=organization, + field_name=field_name, + value=parsed_value, + update_fields=update_fields, + ) for field_name, aliases in cls.ORGANIZATION_INT_FIELDS.items(): value, present = cls._get_present_value(row, aliases) if not present: continue - parsed_value = cls._parse_int_value(value, field_name=field_name) - if getattr(organization, field_name) != parsed_value: - setattr(organization, field_name, parsed_value) - update_fields.append(field_name) + parsed_value = cls._parse_optional_int_value(value, field_name=field_name) + cls._set_organization_field( + organization=organization, + field_name=field_name, + value=parsed_value, + update_fields=update_fields, + ) for field_name, aliases in cls.ORGANIZATION_DECIMAL_FIELDS.items(): value, present = cls._get_present_value(row, aliases) if not present: continue - parsed_value = cls._parse_decimal_value(value, field_name=field_name) - if getattr(organization, field_name) != parsed_value: - setattr(organization, field_name, parsed_value) - update_fields.append(field_name) + parsed_value = cls._parse_decimal_value( + value, + field_name=field_name, + allow_null=True, + ) + cls._set_organization_field( + organization=organization, + field_name=field_name, + value=parsed_value, + update_fields=update_fields, + ) organization_type_value, present = cls._get_present_value( row, @@ -729,233 +871,17 @@ class ExchangePackageImportService: return update_fields - @classmethod - def _sync_registry_memberships( - cls, - rows: list[dict[str, Any]], - *, - allowed_organization_inns: set[str], - actual_date: date, - package_hash: str, - package_name: str, - ) -> dict[str, int]: - registers_created_count = 0 - uploads_created_count = 0 - opened_count = 0 - updated_count = 0 - closed_count = 0 - skipped_count = 0 - ( - rows_by_registry, - normalized_skipped_count, - ) = cls._normalize_registry_membership_rows( - rows, - allowed_organization_inns=allowed_organization_inns, - ) - skipped_count += normalized_skipped_count - - for registry_name, registry_rows in rows_by_registry.items(): - registry, registry_created = Register.objects.get_or_create( - name=registry_name - ) - if registry_created: - registers_created_count += 1 - - upload, upload_created = cls._get_registry_membership_upload( - registry=registry, - actual_date=actual_date, - file_hash=package_hash, - file_name=package_name, - rows_count=len(registry_rows), - ) - if upload_created: - uploads_created_count += 1 - - sync_result = cls._sync_registry_membership_registry( - registry=registry, - upload=upload, - actual_date=actual_date, - registry_rows=registry_rows, - ) - opened_count += sync_result["opened"] - updated_count += sync_result["updated"] - closed_count += sync_result["closed"] - - return { - "registers_created": registers_created_count, - "uploads_created": uploads_created_count, - "opened": opened_count, - "updated": updated_count, - "closed": closed_count, - "skipped": skipped_count, - } - - @classmethod - def _normalize_registry_membership_rows( - cls, - rows: list[dict[str, Any]], - *, - allowed_organization_inns: set[str], - ) -> tuple[dict[str, dict[str, tuple[Organization, date]]], int]: - rows_by_registry: dict[str, dict[str, tuple[Organization, date]]] = {} - skipped_count = 0 - - for row in rows: - registry_name = cls._clean_string(row.get("registry_name")) - if not registry_name: - skipped_count += 1 - continue - - organization = cls._resolve_organization( - row, - allowed_organization_inns=allowed_organization_inns, - ) - started_at = cls._parse_date_value( - row.get("started_at"), - field_name="started_at", - allow_null=False, - ) - ended_at = cls._parse_date_value( - row.get("ended_at"), - field_name="ended_at", - allow_null=True, - ) - if ended_at is not None: - skipped_count += 1 - continue - - registry_rows = rows_by_registry.setdefault(registry_name, {}) - if organization.inn in registry_rows: - skipped_count += 1 - continue - registry_rows[organization.inn] = (organization, started_at) - - return rows_by_registry, skipped_count - @staticmethod - def _get_registry_membership_upload( + def _set_organization_field( *, - registry: Register, - actual_date: date, - file_hash: str, - file_name: str, - rows_count: int, - ) -> tuple[RegisterUpload, bool]: - upload, created = RegisterUpload.objects.get_or_create( - registry=registry, - actual_date=actual_date, - file_hash=file_hash, - defaults={ - "file_name": file_name, - "rows_count": rows_count, - }, - ) - if created: - return upload, True - - update_fields: list[str] = [] - if upload.file_name != file_name: - upload.file_name = file_name - update_fields.append("file_name") - if upload.rows_count != rows_count: - upload.rows_count = rows_count - update_fields.append("rows_count") - if update_fields: - upload.save(update_fields=update_fields + ["updated_at"]) - return upload, False - - @classmethod - def _sync_registry_membership_registry( - cls, - *, - registry: Register, - upload: RegisterUpload, - actual_date: date, - registry_rows: dict[str, tuple[Organization, date]], - ) -> dict[str, int]: - desired_by_org_id = { - organization.id: started_at - for organization, started_at in registry_rows.values() - } - active_by_org_id = { - period.organization_id: period - for period in RegistryMembershipPeriod.objects.select_for_update().filter( - registry=registry, - ended_at__isnull=True, - ) - } - closed_count = cls._close_stale_registry_memberships( - active_by_org_id=active_by_org_id, - desired_by_org_id=desired_by_org_id, - actual_date=actual_date, - upload=upload, - ) - opened_count, updated_count = cls._upsert_active_registry_memberships( - registry=registry, - upload=upload, - registry_rows=registry_rows, - active_by_org_id=active_by_org_id, - ) - return { - "opened": opened_count, - "updated": updated_count, - "closed": closed_count, - } - - @staticmethod - def _close_stale_registry_memberships( - *, - active_by_org_id: dict[str, RegistryMembershipPeriod], - desired_by_org_id: dict[str, date], - actual_date: date, - upload: RegisterUpload, - ) -> int: - stale_period_ids = [ - period.id - for organization_id, period in active_by_org_id.items() - if organization_id not in desired_by_org_id - ] - if not stale_period_ids: - return 0 - return RegistryMembershipPeriod.objects.filter(id__in=stale_period_ids).update( - ended_at=actual_date, - ended_by_upload=upload, - ) - - @staticmethod - def _upsert_active_registry_memberships( - *, - registry: Register, - upload: RegisterUpload, - registry_rows: dict[str, tuple[Organization, date]], - active_by_org_id: dict[str, RegistryMembershipPeriod], - ) -> tuple[int, int]: - opened_count = 0 - updated_count = 0 - for organization, started_at in registry_rows.values(): - active_period = active_by_org_id.get(organization.id) - if active_period is None: - RegistryMembershipPeriod.objects.create( - registry=registry, - organization=organization, - started_at=started_at, - started_by_upload=upload, - ) - opened_count += 1 - continue - - update_fields = [] - if active_period.started_at != started_at: - active_period.started_at = started_at - update_fields.append("started_at") - if active_period.started_by_upload_id != upload.id: - active_period.started_by_upload = upload - update_fields.append("started_by_upload") - if update_fields: - active_period.save(update_fields=update_fields + ["updated_at"]) - updated_count += 1 - - return opened_count, updated_count + organization: Organization, + field_name: str, + value: Any, + update_fields: list[str], + ) -> None: + if getattr(organization, field_name) != value: + setattr(organization, field_name, value) + update_fields.append(field_name) @classmethod def _upsert_industrial_certificates( @@ -1708,6 +1634,20 @@ class ExchangePackageImportService: return "" return str(value).strip() + @classmethod + def _parse_uuid_value( + cls, + value: Any, + *, + field_name: str, + ) -> uuid.UUID | None: + if value in (None, ""): + return None + try: + return uuid.UUID(str(value)) + except (TypeError, ValueError) as exc: + raise ExchangeImportError(f"Поле {field_name} должно быть UUID") from exc + @classmethod def _parse_date_value( cls, @@ -1740,6 +1680,17 @@ class ExchangePackageImportService: return False raise ExchangeImportError(f"Поле {field_name} должно быть bool-значением") + @classmethod + def _parse_optional_bool_value( + cls, + value: Any, + *, + field_name: str, + ) -> bool | None: + if value in (None, ""): + return None + return cls._parse_bool_value(value, field_name=field_name) + @classmethod def _parse_int_value(cls, value: Any, *, field_name: str) -> int: try: @@ -1752,6 +1703,17 @@ class ExchangePackageImportService: raise ExchangeImportError(f"Поле {field_name} не может быть отрицательным") return parsed + @classmethod + def _parse_optional_int_value( + cls, + value: Any, + *, + field_name: str, + ) -> int | None: + if value in (None, ""): + return None + return cls._parse_int_value(value, field_name=field_name) + @classmethod def _parse_optional_int( cls, diff --git a/src/apps/organization/analytics_services.py b/src/apps/organization/analytics_services.py index 4e453c0..05f3b3b 100644 --- a/src/apps/organization/analytics_services.py +++ b/src/apps/organization/analytics_services.py @@ -1149,9 +1149,7 @@ class DashboardAnalyticsService: def get_dashboard( cls, *, corporation_scope: str | None = None ) -> dict[str, object]: - queryset = Organization.objects.prefetch_related( - "membership_periods__registry" - ).all() + queryset = Organization.objects.all() if corporation_scope: queryset = filter_queryset_by_scopes(queryset, [corporation_scope]) diff --git a/src/apps/organization/api.py b/src/apps/organization/api.py index 89887bc..daa1c49 100644 --- a/src/apps/organization/api.py +++ b/src/apps/organization/api.py @@ -18,8 +18,6 @@ from apps.organization.serializers import ( OrganizationCatalogDetailSerializer, OrganizationCatalogListSerializer, ) -from apps.registers.models import RegistryMembershipPeriod -from django.db.models import Prefetch from django_filters import rest_framework as filters from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema @@ -32,19 +30,11 @@ class OrganizationFilter(filters.FilterSet): name = filters.CharFilter(lookup_expr="icontains") inn = filters.CharFilter(lookup_expr="exact") ogrn = filters.CharFilter(lookup_expr="exact") - registry = filters.UUIDFilter(method="filter_registry") corporation_scope = filters.CharFilter(method="filter_corporation_scope") registry_category = filters.CharFilter(method="filter_registry_category") registryCategory = filters.CharFilter(method="filter_registry_category") organization_type = filters.CharFilter(lookup_expr="exact") - @staticmethod - def filter_registry(queryset, _name, value): - return queryset.filter( - membership_periods__registry_id=value, - membership_periods__ended_at__isnull=True, - ).distinct() - @staticmethod def filter_corporation_scope(queryset, _name, value): requested_scopes = [ @@ -69,7 +59,6 @@ class OrganizationFilter(filters.FilterSet): "name", "inn", "ogrn", - "registry", "corporation_scope", "registry_category", "organization_type", @@ -125,12 +114,6 @@ class OrganizationViewSet(ClassicReadOnlyViewSet[Organization]): description="Фильтр по ОГРН", type=openapi.TYPE_STRING, ), - openapi.Parameter( - "registry", - openapi.IN_QUERY, - description="ID активного реестра", - type=openapi.TYPE_STRING, - ), openapi.Parameter( "corporation_scope", openapi.IN_QUERY, @@ -196,18 +179,3 @@ class OrganizationViewSet(ClassicReadOnlyViewSet[Organization]): ) def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) - - def get_queryset(self): - return ( - super() - .get_queryset() - .prefetch_related( - Prefetch( - "membership_periods", - queryset=RegistryMembershipPeriod.objects.filter( - ended_at__isnull=True - ).select_related("registry"), - to_attr="active_membership_periods_prefetched", - ) - ) - ) diff --git a/src/apps/organization/migrations/0004_organization_directory_fields.py b/src/apps/organization/migrations/0004_organization_directory_fields.py new file mode 100644 index 0000000..b68ba85 --- /dev/null +++ b/src/apps/organization/migrations/0004_organization_directory_fields.py @@ -0,0 +1,228 @@ +# Generated manually for Mostovik organization directory exchange v3. + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("organization", "0003_auto_20260407_1326"), + ] + + operations = [ + migrations.AddField( + model_name="organization", + name="full_name", + field=models.TextField(blank=True, default="", verbose_name="полное наименование"), + ), + migrations.AddField( + model_name="organization", + name="mostovik_uid", + field=models.UUIDField(blank=True, db_index=True, null=True, verbose_name="UID организации в Моставике"), + ), + migrations.AddField( + model_name="organization", + name="rn", + field=models.PositiveBigIntegerField(blank=True, db_index=True, null=True, verbose_name="регистрационный номер"), + ), + migrations.AddField( + model_name="organization", + name="pn_name", + field=models.TextField(blank=True, default="", verbose_name="приведенное наименование"), + ), + migrations.AddField( + model_name="organization", + name="pn_name_en", + field=models.TextField(blank=True, default="", verbose_name="приведенное наименование на английском"), + ), + migrations.AddField( + model_name="organization", + name="ogrip", + field=models.CharField(blank=True, db_index=True, default="", max_length=15, verbose_name="ОГРИП"), + ), + migrations.AddField( + model_name="organization", + name="identity_status", + field=models.CharField(blank=True, db_index=True, default="", max_length=16, verbose_name="полнота реквизитов"), + ), + migrations.AddField( + model_name="organization", + name="primary_identity", + field=models.CharField(blank=True, db_index=True, default="", max_length=255, verbose_name="основной идентификатор"), + ), + migrations.AddField( + model_name="organization", + name="gk_code", + field=models.CharField(blank=True, db_index=True, default="", max_length=16, verbose_name="код государственной корпорации"), + ), + migrations.AddField( + model_name="organization", + name="gk_name", + field=models.CharField(blank=True, db_index=True, default="", max_length=255, verbose_name="государственная корпорация"), + ), + migrations.AddField( + model_name="organization", + name="in_korp_code", + field=models.CharField(blank=True, db_index=True, default="", max_length=16, verbose_name="код положения внутри ГК"), + ), + migrations.AddField( + model_name="organization", + name="in_korp_name", + field=models.CharField(blank=True, default="", max_length=255, verbose_name="положение внутри ГК"), + ), + migrations.AddField( + model_name="organization", + name="filial", + field=models.CharField(blank=True, db_index=True, default="", max_length=64, verbose_name="признак филиала из источника"), + ), + migrations.AddField( + model_name="organization", + name="is_branch", + field=models.BooleanField(blank=True, db_index=True, null=True, verbose_name="филиал"), + ), + migrations.AddField( + model_name="organization", + name="create_date", + field=models.CharField(blank=True, default="", max_length=32, verbose_name="год или дата создания"), + ), + migrations.AddField( + model_name="organization", + name="organizational_legal_form", + field=models.CharField(blank=True, db_index=True, default="", max_length=32, verbose_name="код организационно-правовой формы"), + ), + migrations.AddField( + model_name="organization", + name="organizational_legal_form1", + field=models.CharField(blank=True, default="", max_length=512, verbose_name="организационно-правовая форма"), + ), + migrations.AddField( + model_name="organization", + name="ownership_form", + field=models.CharField(blank=True, db_index=True, default="", max_length=32, verbose_name="код формы собственности"), + ), + migrations.AddField( + model_name="organization", + name="ownership_form1", + field=models.CharField(blank=True, default="", max_length=512, verbose_name="форма собственности"), + ), + migrations.AddField( + model_name="organization", + name="authorized_capital", + field=models.DecimalField(blank=True, decimal_places=2, max_digits=24, null=True, verbose_name="уставный капитал из перечня"), + ), + migrations.AddField( + model_name="organization", + name="business_act_cod", + field=models.CharField(blank=True, db_index=True, default="", max_length=32, verbose_name="код вида деятельности"), + ), + migrations.AddField( + model_name="organization", + name="business_activity", + field=models.CharField(blank=True, default="", max_length=500, verbose_name="вид деятельности"), + ), + migrations.AddField( + model_name="organization", + name="general_director", + field=models.TextField(blank=True, default="", verbose_name="руководитель"), + ), + migrations.AddField( + model_name="organization", + name="general_director_tax_id", + field=models.CharField(blank=True, default="", max_length=32, verbose_name="ИНН руководителя"), + ), + migrations.AddField( + model_name="organization", + name="uk", + field=models.TextField(blank=True, default="", verbose_name="управляющая компания"), + ), + migrations.AddField( + model_name="organization", + name="inn_uk", + field=models.CharField(blank=True, db_index=True, default="", max_length=12, verbose_name="ИНН управляющей компании"), + ), + migrations.AddField( + model_name="organization", + name="appointment_date", + field=models.DateField(blank=True, null=True, verbose_name="дата назначения руководителя"), + ), + migrations.AddField( + model_name="organization", + name="cf_fl_rn", + field=models.CharField(blank=True, default="", max_length=64, verbose_name="регистрационный номер руководителя"), + ), + migrations.AddField( + model_name="organization", + name="akc_fs", + field=models.CharField(blank=True, default="", max_length=64, verbose_name="акционерный капитал федеральная собственность"), + ), + migrations.AddField( + model_name="organization", + name="akc_sf", + field=models.CharField(blank=True, default="", max_length=64, verbose_name="акционерный капитал собственность субъекта РФ"), + ), + migrations.AddField( + model_name="organization", + name="re_za", + field=models.BooleanField(blank=True, db_index=True, null=True, verbose_name="реестр организаций-заказчиков"), + ), + migrations.AddField( + model_name="organization", + name="re_zasf", + field=models.BooleanField(blank=True, db_index=True, null=True, verbose_name="реестр организаций-заказчиков субъекта РФ"), + ), + migrations.AddField( + model_name="organization", + name="goz_participation", + field=models.BooleanField(blank=True, db_index=True, null=True, verbose_name="участие в ГОЗ"), + ), + migrations.AddField( + model_name="organization", + name="opk_registry_membership", + field=models.BooleanField(blank=True, db_index=True, null=True, verbose_name="членство в реестре ОПК"), + ), + migrations.AddField( + model_name="organization", + name="ropk_num", + field=models.CharField(blank=True, db_index=True, default="", max_length=64, verbose_name="номер в реестре ОПК"), + ), + migrations.AddField( + model_name="organization", + name="ropk_razdel_num", + field=models.CharField(blank=True, default="", max_length=64, verbose_name="номер раздела реестра ОПК"), + ), + migrations.AddField( + model_name="organization", + name="ropk_razdel_name", + field=models.TextField(blank=True, default="", verbose_name="раздел реестра ОПК"), + ), + migrations.AddField( + model_name="organization", + name="min", + field=models.TextField(blank=True, default="", verbose_name="министерство"), + ), + migrations.AddField( + model_name="organization", + name="dep", + field=models.TextField(blank=True, default="", verbose_name="департамент"), + ), + migrations.AddField( + model_name="organization", + name="otr", + field=models.TextField(blank=True, default="", verbose_name="отрасль"), + ), + migrations.AddField( + model_name="organization", + name="integrated_structure", + field=models.TextField(blank=True, default="", verbose_name="интегрированная структура"), + ), + migrations.AddField( + model_name="organization", + name="state_sector_code", + field=models.CharField(blank=True, db_index=True, default="", max_length=16, verbose_name="код госсектора"), + ), + migrations.AddField( + model_name="organization", + name="state_sector_name", + field=models.CharField(blank=True, default="", max_length=255, verbose_name="госсектор"), + ), + ] diff --git a/src/apps/organization/models.py b/src/apps/organization/models.py index ecd7c09..27aa768 100644 --- a/src/apps/organization/models.py +++ b/src/apps/organization/models.py @@ -10,7 +10,12 @@ from __future__ import annotations import uuid from apps.core.mixins import TimestampMixin -from apps.organization.scope_utils import scope_labels, scopes_from_registry_names +from apps.organization.scope_utils import ( + active_registry_items_from_organization_fields, + active_registry_names_from_organization_fields, + scope_labels, + scopes_from_organization_fields, +) from django.db import models from django.utils.translation import gettext_lazy as _ @@ -69,6 +74,11 @@ class Organization(TimestampMixin, models.Model): db_index=True, help_text=_("Полное наименование организации"), ) + full_name = models.TextField( + _("полное наименование"), + blank=True, + default="", + ) short_name = models.CharField( _("краткое наименование"), max_length=255, @@ -122,17 +132,152 @@ class Organization(TimestampMixin, models.Model): default="", help_text=_("Общероссийский классификатор предприятий и организаций"), ) + mostovik_uid = models.UUIDField( + _("UID организации в Моставике"), + null=True, + blank=True, + db_index=True, + ) + rn = models.PositiveBigIntegerField( + _("регистрационный номер"), + null=True, + blank=True, + db_index=True, + ) + pn_name = models.TextField( + _("приведенное наименование"), + blank=True, + default="", + ) + pn_name_en = models.TextField( + _("приведенное наименование на английском"), + blank=True, + default="", + ) + ogrip = models.CharField( + _("ОГРИП"), + max_length=15, + blank=True, + default="", + db_index=True, + ) + identity_status = models.CharField( + _("полнота реквизитов"), + max_length=16, + blank=True, + default="", + db_index=True, + ) + primary_identity = models.CharField( + _("основной идентификатор"), + max_length=255, + blank=True, + default="", + db_index=True, + ) + gk_code = models.CharField( + _("код государственной корпорации"), + max_length=16, + blank=True, + default="", + db_index=True, + ) + gk_name = models.CharField( + _("государственная корпорация"), + max_length=255, + blank=True, + default="", + db_index=True, + ) + in_korp_code = models.CharField( + _("код положения внутри ГК"), + max_length=16, + blank=True, + default="", + db_index=True, + ) + in_korp_name = models.CharField( + _("положение внутри ГК"), + max_length=255, + blank=True, + default="", + ) + filial = models.CharField( + _("признак филиала из источника"), + max_length=64, + blank=True, + default="", + db_index=True, + ) + is_branch = models.BooleanField( + _("филиал"), + null=True, + blank=True, + db_index=True, + ) registration_date = models.DateField( _("дата регистрации"), null=True, blank=True, ) + create_date = models.CharField( + _("год или дата создания"), + max_length=32, + blank=True, + default="", + ) + organizational_legal_form = models.CharField( + _("код организационно-правовой формы"), + max_length=32, + blank=True, + default="", + db_index=True, + ) + organizational_legal_form1 = models.CharField( + _("организационно-правовая форма"), + max_length=512, + blank=True, + default="", + ) + ownership_form = models.CharField( + _("код формы собственности"), + max_length=32, + blank=True, + default="", + db_index=True, + ) + ownership_form1 = models.CharField( + _("форма собственности"), + max_length=512, + blank=True, + default="", + ) + authorized_capital = models.DecimalField( + _("уставный капитал из перечня"), + max_digits=24, + decimal_places=2, + null=True, + blank=True, + ) legal_address = models.CharField( _("юридический адрес"), max_length=500, blank=True, default="", ) + business_act_cod = models.CharField( + _("код вида деятельности"), + max_length=32, + blank=True, + default="", + db_index=True, + ) + business_activity = models.CharField( + _("вид деятельности"), + max_length=500, + blank=True, + default="", + ) activity_type = models.CharField( _("основной вид деятельности"), max_length=500, @@ -181,6 +326,127 @@ class Organization(TimestampMixin, models.Model): null=True, blank=True, ) + general_director = models.TextField( + _("руководитель"), + blank=True, + default="", + ) + general_director_tax_id = models.CharField( + _("ИНН руководителя"), + max_length=32, + blank=True, + default="", + ) + uk = models.TextField( + _("управляющая компания"), + blank=True, + default="", + ) + inn_uk = models.CharField( + _("ИНН управляющей компании"), + max_length=12, + blank=True, + default="", + db_index=True, + ) + appointment_date = models.DateField( + _("дата назначения руководителя"), + null=True, + blank=True, + ) + cf_fl_rn = models.CharField( + _("регистрационный номер руководителя"), + max_length=64, + blank=True, + default="", + ) + akc_fs = models.CharField( + _("акционерный капитал федеральная собственность"), + max_length=64, + blank=True, + default="", + ) + akc_sf = models.CharField( + _("акционерный капитал собственность субъекта РФ"), + max_length=64, + blank=True, + default="", + ) + re_za = models.BooleanField( + _("реестр организаций-заказчиков"), + null=True, + blank=True, + db_index=True, + ) + re_zasf = models.BooleanField( + _("реестр организаций-заказчиков субъекта РФ"), + null=True, + blank=True, + db_index=True, + ) + goz_participation = models.BooleanField( + _("участие в ГОЗ"), + null=True, + blank=True, + db_index=True, + ) + opk_registry_membership = models.BooleanField( + _("членство в реестре ОПК"), + null=True, + blank=True, + db_index=True, + ) + ropk_num = models.CharField( + _("номер в реестре ОПК"), + max_length=64, + blank=True, + default="", + db_index=True, + ) + ropk_razdel_num = models.CharField( + _("номер раздела реестра ОПК"), + max_length=64, + blank=True, + default="", + ) + ropk_razdel_name = models.TextField( + _("раздел реестра ОПК"), + blank=True, + default="", + ) + min = models.TextField( + _("министерство"), + blank=True, + default="", + ) + dep = models.TextField( + _("департамент"), + blank=True, + default="", + ) + otr = models.TextField( + _("отрасль"), + blank=True, + default="", + ) + integrated_structure = models.TextField( + _("интегрированная структура"), + blank=True, + default="", + ) + state_sector_code = models.CharField( + _("код госсектора"), + max_length=16, + blank=True, + default="", + db_index=True, + ) + state_sector_name = models.CharField( + _("госсектор"), + max_length=255, + blank=True, + default="", + ) executors_count = models.PositiveIntegerField( _("число исполнителей"), default=0, @@ -240,25 +506,37 @@ class Organization(TimestampMixin, models.Model): ) def get_active_registries(self): - return [period.registry for period in self.get_active_membership_periods()] + return active_registry_items_from_organization_fields( + gk_code=self.gk_code, + gk_name=self.gk_name, + goz_participation=self.goz_participation, + opk_registry_membership=self.opk_registry_membership, + ropk_razdel_num=self.ropk_razdel_num, + ropk_razdel_name=self.ropk_razdel_name, + ) def get_active_registry_names(self) -> list[str]: - return [registry.name for registry in self.get_active_registries()] + return active_registry_names_from_organization_fields( + gk_name=self.gk_name, + goz_participation=self.goz_participation, + opk_registry_membership=self.opk_registry_membership, + ropk_razdel_name=self.ropk_razdel_name, + ) def active_registry_names_display(self) -> str: names = self.get_active_registry_names() return ", ".join(names) if names else "—" - @property - def full_name(self) -> str: - return self.name - @property def display_short_name(self) -> str: return self.short_name or self.name def get_corporation_scopes(self) -> list[str]: - return scopes_from_registry_names(self.get_active_registry_names()) + return scopes_from_organization_fields( + gk_code=self.gk_code, + gk_name=self.gk_name, + opk_registry_membership=self.opk_registry_membership, + ) def get_corporation_scope_labels(self) -> list[str]: return scope_labels(self.get_corporation_scopes()) diff --git a/src/apps/organization/scope_utils.py b/src/apps/organization/scope_utils.py index 0a6e811..4f4b45c 100644 --- a/src/apps/organization/scope_utils.py +++ b/src/apps/organization/scope_utils.py @@ -1,4 +1,4 @@ -"""Helpers for corporation scope derivation from active registries.""" +"""Helpers for corporation scope derivation from organization fields.""" from __future__ import annotations @@ -49,21 +49,121 @@ REGISTRY_CATEGORY_LABELS: dict[str, str] = { } -def scopes_from_registry_names(registry_names: Iterable[str]) -> list[str]: - normalized_names = [registry_name.casefold() for registry_name in registry_names] - scopes: list[str] = [] +def _append_unique(items: list[str], value: str) -> None: + if value and value not in items: + items.append(value) + + +def _scope_from_code_or_name(gk_code: str | None, gk_name: str | None) -> str: + normalized_code = normalize_scope_code(str(gk_code or "")) + if normalized_code in {"rosatom", "roscosmos"}: + return normalized_code + + normalized_values = [ + str(value or "").casefold() + for value in (gk_code, gk_name) + if str(value or "").strip() + ] for scope, keywords in SCOPE_KEYWORDS.items(): + if scope == "opk": + continue if any( - keyword.casefold() in registry_name - for registry_name in normalized_names + keyword.casefold() in value + for value in normalized_values for keyword in keywords ): - scopes.append(scope) + return scope + return "" + +def scopes_from_organization_fields( + *, + gk_code: str | None, + gk_name: str | None, + opk_registry_membership: bool | None, +) -> list[str]: + scopes: list[str] = [] + _append_unique(scopes, _scope_from_code_or_name(gk_code, gk_name)) + if opk_registry_membership: + _append_unique(scopes, "opk") return scopes +def active_registry_names_from_organization_fields( + *, + gk_name: str | None, + goz_participation: bool | None, + opk_registry_membership: bool | None, + ropk_razdel_name: str | None, +) -> list[str]: + names: list[str] = [] + if gk_name: + _append_unique(names, str(gk_name).strip()) + if goz_participation: + _append_unique(names, "Участие в ГОЗ") + if opk_registry_membership: + _append_unique(names, str(ropk_razdel_name or "").strip() or "Реестр ОПК") + return names + + +def active_registry_items_from_organization_fields( + *, + gk_code: str | None, + gk_name: str | None, + goz_participation: bool | None, + opk_registry_membership: bool | None, + ropk_razdel_num: str | None, + ropk_razdel_name: str | None, +) -> list[dict[str, str]]: + items: list[dict[str, str]] = [] + if gk_name: + items.append({"id": str(gk_code or "gk"), "name": str(gk_name).strip()}) + if goz_participation: + items.append({"id": "goz", "name": "Участие в ГОЗ"}) + if opk_registry_membership: + items.append( + { + "id": str(ropk_razdel_num or "opk"), + "name": str(ropk_razdel_name or "").strip() or "Реестр ОПК", + } + ) + return items + + +def registry_categories_from_organization_fields( + *, + goz_participation: bool | None, + opk_registry_membership: bool | None, + ropk_num: str | None, + ropk_razdel_num: str | None, + ropk_razdel_name: str | None, +) -> list[str]: + categories: list[str] = [] + if opk_registry_membership or ropk_num or ropk_razdel_num or ropk_razdel_name: + categories.append("opk") + if goz_participation: + categories.append("goz") + return categories or ["other"] + + +def primary_registry_category_from_organization_fields( + *, + goz_participation: bool | None, + opk_registry_membership: bool | None, + ropk_num: str | None, + ropk_razdel_num: str | None, + ropk_razdel_name: str | None, +) -> str: + return registry_categories_from_organization_fields( + goz_participation=goz_participation, + opk_registry_membership=opk_registry_membership, + ropk_num=ropk_num, + ropk_razdel_num=ropk_razdel_num, + ropk_razdel_name=ropk_razdel_name, + )[0] + + def scope_labels(scope_codes: Iterable[str]) -> list[str]: return [SCOPE_LABELS[code] for code in scope_codes if code in SCOPE_LABELS] @@ -92,25 +192,6 @@ def get_corporation_scope_dictionary() -> list[dict[str, str | int]]: return items -def registry_categories_from_registry_names(registry_names: Iterable[str]) -> list[str]: - normalized_names = [registry_name.casefold() for registry_name in registry_names] - categories: list[str] = [] - - for category, keywords in REGISTRY_CATEGORY_KEYWORDS.items(): - if any( - keyword.casefold() in registry_name - for registry_name in normalized_names - for keyword in keywords - ): - categories.append(category) - - return categories or ["other"] - - -def primary_registry_category(registry_names: Iterable[str]) -> str: - return registry_categories_from_registry_names(registry_names)[0] - - def registry_category_label(category_code: str) -> str: return REGISTRY_CATEGORY_LABELS.get(category_code, "") @@ -118,12 +199,16 @@ def registry_category_label(category_code: str) -> str: def build_scope_query(scope_codes: Iterable[str]) -> Q: query = Q() for scope_code in scope_codes: - keywords = SCOPE_KEYWORDS.get(scope_code, ()) - for keyword in keywords: - query |= Q( - membership_periods__registry__name__contains=keyword, - membership_periods__ended_at__isnull=True, + if scope_code == "rosatom": + query |= Q(gk_code__iexact="rosatom") | Q(gk_name__icontains="Росатом") + elif scope_code == "roscosmos": + query |= ( + Q(gk_code__iexact="roscosmos") + | Q(gk_code__iexact="roskosmos") + | Q(gk_name__icontains="Роскосмос") ) + elif scope_code == "opk": + query |= Q(opk_registry_membership=True) | ~Q(ropk_num="") return query @@ -144,15 +229,14 @@ def build_registry_category_query(category_codes: Iterable[str]) -> Q: query = Q() for category_code in category_codes: if category_code == "opk": - query |= Q( - membership_periods__registry__name__icontains="ОПК", - membership_periods__ended_at__isnull=True, + query |= ( + Q(opk_registry_membership=True) + | ~Q(ropk_num="") + | ~Q(ropk_razdel_num="") + | ~Q(ropk_razdel_name="") ) elif category_code == "goz": - query |= Q( - membership_periods__registry__name__icontains="ГОЗ", - membership_periods__ended_at__isnull=True, - ) + query |= Q(goz_participation=True) return query diff --git a/src/apps/organization/serializers.py b/src/apps/organization/serializers.py index 5bb480e..1cee2f1 100644 --- a/src/apps/organization/serializers.py +++ b/src/apps/organization/serializers.py @@ -10,22 +10,12 @@ from apps.organization.availability import has_financial_reports, has_tax_report from apps.organization.models import Organization from apps.organization.scope_utils import ( SCOPE_LABELS, - primary_registry_category, + primary_registry_category_from_organization_fields, registry_category_label, ) -from apps.registers.models import Register from rest_framework import serializers -class OrganizationRegisterSerializer(serializers.ModelSerializer): - """Краткий сериализатор активного реестра организации.""" - - class Meta: - model = Register - fields = ["id", "name"] - read_only_fields = fields - - class OrganizationSerializer(serializers.ModelSerializer): """Полный nested-сериализатор организации для внутренних API.""" @@ -38,9 +28,7 @@ class OrganizationSerializer(serializers.ModelSerializer): @staticmethod def get_active_registries(obj: Organization) -> list[dict[str, str]]: - return OrganizationRegisterSerializer( - obj.get_active_registries(), many=True - ).data + return obj.get_active_registries() class Meta: model = Organization @@ -79,14 +67,6 @@ class OrganizationListSerializer(serializers.ModelSerializer): ] -class GeneralDirectorSerializer(serializers.Serializer): - """Сериализатор блока генерального директора.""" - - full_name = serializers.CharField() - inn = serializers.CharField() - appointment_date = serializers.DateField(allow_null=True) - - class OrganizationCatalogSummarySerializer(serializers.Serializer): """Сериализатор summary блока организации.""" @@ -99,7 +79,7 @@ class OrganizationCatalogBaseSerializer(serializers.ModelSerializer): """Базовый сериализатор frontend-контракта организации.""" short_name = serializers.SerializerMethodField() - full_name = serializers.CharField(source="name", read_only=True) + full_name = serializers.SerializerMethodField() corporation_scope = serializers.SerializerMethodField() corporation_scope_label = serializers.SerializerMethodField() registry_category = serializers.SerializerMethodField() @@ -111,13 +91,23 @@ class OrganizationCatalogBaseSerializer(serializers.ModelSerializer): def get_short_name(obj: Organization) -> str: return obj.display_short_name + @staticmethod + def get_full_name(obj: Organization) -> str: + return obj.full_name or obj.name + @staticmethod def get_active_registry_names(obj: Organization) -> list[str]: return obj.get_active_registry_names() @staticmethod def get_registry_category(obj: Organization) -> str: - return primary_registry_category(obj.get_active_registry_names()) + return primary_registry_category_from_organization_fields( + goz_participation=obj.goz_participation, + opk_registry_membership=obj.opk_registry_membership, + ropk_num=obj.ropk_num, + ropk_razdel_num=obj.ropk_razdel_num, + ropk_razdel_name=obj.ropk_razdel_name, + ) @staticmethod def get_registry_category_label(obj: Organization) -> str: @@ -166,6 +156,12 @@ class OrganizationCatalogListSerializer(OrganizationCatalogBaseSerializer): "kpp", "okpo", "active_registry_names", + "gk_code", + "gk_name", + "in_korp_code", + "in_korp_name", + "goz_participation", + "opk_registry_membership", ] @@ -173,22 +169,11 @@ class OrganizationCatalogDetailSerializer(OrganizationCatalogBaseSerializer): """Сериализатор детальной карточки организации.""" active_registries = serializers.SerializerMethodField() - general_director = serializers.SerializerMethodField() summary = serializers.SerializerMethodField() @staticmethod def get_active_registries(obj: Organization) -> list[dict[str, str]]: - return OrganizationRegisterSerializer( - obj.get_active_registries(), many=True - ).data - - @staticmethod - def get_general_director(obj: Organization) -> dict[str, str | None]: - return { - "full_name": obj.general_director_name, - "inn": obj.general_director_inn, - "appointment_date": obj.general_director_appointment_date, - } + return obj.get_active_registries() @staticmethod def get_summary(obj: Organization) -> dict[str, object]: @@ -204,6 +189,10 @@ class OrganizationCatalogDetailSerializer(OrganizationCatalogBaseSerializer): "id", "short_name", "full_name", + "mostovik_uid", + "rn", + "pn_name", + "pn_name_en", "corporation_scope", "corporation_scope_label", "registry_category", @@ -214,14 +203,46 @@ class OrganizationCatalogDetailSerializer(OrganizationCatalogBaseSerializer): "ogrn", "kpp", "okpo", + "ogrip", + "identity_status", + "primary_identity", + "gk_code", + "gk_name", + "in_korp_code", + "in_korp_name", + "filial", + "is_branch", "registration_date", + "create_date", + "organizational_legal_form", + "organizational_legal_form1", + "ownership_form", + "ownership_form1", + "authorized_capital", "legal_address", - "activity_type", - "founder_name", - "ownership_type", - "legal_form", - "charter_capital_amount", + "business_act_cod", + "business_activity", "general_director", + "general_director_tax_id", + "uk", + "inn_uk", + "appointment_date", + "cf_fl_rn", + "akc_fs", + "akc_sf", + "re_za", + "re_zasf", + "goz_participation", + "opk_registry_membership", + "ropk_num", + "ropk_razdel_num", + "ropk_razdel_name", + "min", + "dep", + "otr", + "integrated_structure", + "state_sector_code", + "state_sector_name", "summary", "active_registries", "created_at", diff --git a/tests/apps/exchange/test_api.py b/tests/apps/exchange/test_api.py index d5ee1bb..44c48bc 100644 --- a/tests/apps/exchange/test_api.py +++ b/tests/apps/exchange/test_api.py @@ -9,6 +9,7 @@ import struct import tempfile import zlib from datetime import date +from decimal import Decimal from io import BytesIO from zipfile import ZIP_DEFLATED, ZipFile @@ -29,7 +30,6 @@ from apps.external_data.models import ( PublicProcurement, ) from apps.organization.models import Organization -from apps.registers.models import RegisterUpload, RegistryMembershipPeriod from apps.user.models import User from cryptography.hazmat.primitives.ciphers.aead import AESGCM from django.conf import settings @@ -53,15 +53,17 @@ def build_exchange_archive( archive_name: str = "exchange_package_20260407.zip", bin_name: str = "exchange_package_20260407.bin", data: dict[str, list[dict[str, object]]] | None = None, + schema_version: int = ExchangePackageImportService.SUPPORTED_SCHEMA_VERSION, ) -> SimpleUploadedFile: """Build encrypted exchange archive compatible with import service.""" payload = { "format": ExchangePackageImportService.PAYLOAD_FORMAT, - "schema_version": 1, + "schema_version": schema_version, "manifest": { "package_id": package_id, "source_system": "mostovik-dev", "produced_at": "2026-04-07T12:00:00+00:00", + "schema_version": schema_version, "sections": list((data or {}).keys()), }, "data": data or {}, @@ -84,7 +86,7 @@ def build_exchange_archive( "nonce": _b64url(nonce), "aad": _b64url(aad), "package_id": package_id, - "schema_version": 1, + "schema_version": schema_version, "plaintext_sha256": hashlib.sha256(payload_bytes).hexdigest(), "compressed_sha256": hashlib.sha256(compressed_payload).hexdigest(), "ciphertext_sha256": hashlib.sha256(encrypted_payload).hexdigest(), @@ -122,41 +124,110 @@ def build_exchange_payload() -> dict[str, list[dict[str, object]]]: return { "organizations": [ { + "mostovik_uid": "11111111-1111-4111-8111-111111111111", + "rn": 1001, "inn": "7707083893", "name": "АО Альфа Обновленная", + "full_name": "Акционерное общество Альфа Обновленная", "short_name": "АО Альфа", - "organization_type": "ao", - "cluster": "radioelectronics", + "pn_name": "АО Альфа", + "pn_name_en": "Alpha JSC", "ogrn": "1027700132195", "kpp": "770701001", "okpo": "12345678", + "ogrip": "", + "identity_status": "complete", + "primary_identity": "inn:7707083893|ogrn:1027700132195|kpp:770701001", + "gk_code": "1", + "gk_name": "Росатом", + "in_korp_code": "head", + "in_korp_name": "Головная организация", + "filial": ".F.", + "is_branch": False, "registration_date": "2024-02-15", + "create_date": "2024", + "organizational_legal_form": "12267", + "organizational_legal_form1": "Акционерное общество", + "ownership_form": "61", + "ownership_form1": "Федеральная собственность", + "authorized_capital": "1500000.50", "legal_address": "г. Москва, ул. Тверская, д. 1", - "activity_type": "Производство электронных компонентов", - "founder_name": "Госкорпорация Пример", - "ownership_type": "Федеральная собственность", - "legal_form": "Акционерное общество", - "charter_capital_amount": "1500000.50", - "general_director_name": "Иванов Иван Иванович", - "general_director_inn": "123456789012", - "general_director_appointment_date": "2025-01-10", - "executors_count": 175, - "financial_reports_available": True, - "tax_reports_available": True, - "in_defense_unreliable_suppliers_registry": False, - "in_275_fz_registry": True, - "bankruptcy_messages_found": False, + "business_act_cod": "26.11", + "business_activity": "Производство электронных компонентов", + "general_director": "Иванов Иван Иванович", + "general_director_tax_id": "123456789012", + "uk": "АО Управляющая компания", + "inn_uk": "7707000000", + "appointment_date": "2025-01-10", + "cf_fl_rn": "director-001", + "akc_fs": "100", + "akc_sf": "0", + "re_za": True, + "re_zasf": False, + "goz_participation": True, + "opk_registry_membership": True, + "ropk_num": "РОПК-001", + "ropk_razdel_num": "opk-1", + "ropk_razdel_name": "Росатом ОПК", + "min": "Минпромторг", + "dep": "Департамент радиоэлектроники", + "otr": "Радиоэлектроника", + "integrated_structure": "Интегрированная структура Альфа", + "state_sector_code": "10", + "state_sector_name": "Государственный сектор", }, { + "mostovik_uid": "22222222-2222-4222-8222-222222222222", + "rn": 1002, "inn": "7707083894", "name": "АО Бета", + "full_name": "Акционерное общество Бета", "short_name": "АО Бета", - "organization_type": "pao", - "cluster": "space", + "pn_name": "АО Бета", + "pn_name_en": "Beta JSC", "ogrn": "1027700132196", "kpp": "770701002", "okpo": "12345679", + "ogrip": "", + "identity_status": "complete", + "primary_identity": "inn:7707083894|ogrn:1027700132196|kpp:770701002", + "gk_code": "2", + "gk_name": "Роскосмос", + "in_korp_code": "member", + "in_korp_name": "Организация внутри ГК", + "filial": ".F.", + "is_branch": False, "registration_date": "2023-09-01", + "create_date": "2023", + "organizational_legal_form": "12267", + "organizational_legal_form1": "Акционерное общество", + "ownership_form": "61", + "ownership_form1": "Федеральная собственность", + "authorized_capital": None, + "legal_address": "", + "business_act_cod": "", + "business_activity": "", + "general_director": "", + "general_director_tax_id": "", + "uk": "", + "inn_uk": "", + "appointment_date": None, + "cf_fl_rn": "", + "akc_fs": "", + "akc_sf": "", + "re_za": None, + "re_zasf": None, + "goz_participation": False, + "opk_registry_membership": False, + "ropk_num": "", + "ropk_razdel_num": "", + "ropk_razdel_name": "", + "min": "", + "dep": "", + "otr": "", + "integrated_structure": "", + "state_sector_code": "", + "state_sector_name": "", }, ], "industrial_products": [ @@ -169,20 +240,6 @@ def build_exchange_payload() -> dict[str, list[dict[str, object]]]: "registry_number": "prod-001", } ], - "registry_memberships": [ - { - "organization_inn": "7707083893", - "registry_name": "Реестр госкорпорации Росатом", - "started_at": "2026-01-01", - "ended_at": None, - }, - { - "organization_inn": "7707083894", - "registry_name": "Реестр госкорпорации Роскосмос", - "started_at": "2026-02-01", - "ended_at": None, - }, - ], "industrial_certificates": [ { "organization_inn": "7707083893", @@ -341,13 +398,8 @@ class ExchangePackageApiTest(APITestCase): self.assertFalse(response.data["result"]["duplicate"]) self.assertEqual(response.data["result"]["organizations"]["created"], 1) self.assertEqual(response.data["result"]["organizations"]["updated"], 1) - self.assertEqual( - response.data["result"]["registry_memberships"]["opened"], - 2, - ) + self.assertNotIn("registry_memberships", response.data["result"]) self.assertEqual(Organization.objects.count(), 2) - self.assertEqual(RegisterUpload.objects.count(), 2) - self.assertEqual(RegistryMembershipPeriod.objects.count(), 2) self.assertEqual(IndustrialCertificate.objects.count(), 1) self.assertEqual(ManufacturerRegistryEntry.objects.count(), 1) self.assertEqual(IndustrialProduct.objects.count(), 1) @@ -380,16 +432,34 @@ class ExchangePackageApiTest(APITestCase): organization = Organization.objects.get(inn="7707083893") self.assertEqual(organization.name, "АО Альфа Обновленная") + self.assertEqual( + organization.full_name, "Акционерное общество Альфа Обновленная" + ) self.assertEqual(organization.short_name, "АО Альфа") - self.assertEqual(organization.organization_type, "ao") - self.assertEqual(organization.cluster, "radioelectronics") + self.assertEqual( + str(organization.mostovik_uid), "11111111-1111-4111-8111-111111111111" + ) + self.assertEqual(organization.rn, 1001) + self.assertEqual(organization.gk_name, "Росатом") + self.assertEqual(organization.in_korp_name, "Головная организация") self.assertEqual(organization.registration_date, date(2024, 2, 15)) - self.assertEqual(organization.executors_count, 175) - self.assertTrue(organization.tax_reports_available) - self.assertTrue(organization.in_275_fz_registry) + self.assertEqual(organization.appointment_date, date(2025, 1, 10)) + self.assertEqual( + organization.business_activity, "Производство электронных компонентов" + ) + self.assertEqual( + organization.organizational_legal_form1, "Акционерное общество" + ) + self.assertEqual(organization.ownership_form1, "Федеральная собственность") + self.assertEqual(organization.authorized_capital, Decimal("1500000.50")) + self.assertEqual(organization.general_director, "Иванов Иван Иванович") + self.assertEqual(organization.general_director_tax_id, "123456789012") + self.assertTrue(organization.goz_participation) + self.assertTrue(organization.opk_registry_membership) + self.assertEqual(organization.ropk_razdel_name, "Росатом ОПК") self.assertEqual( organization.get_active_registry_names(), - ["Реестр госкорпорации Росатом"], + ["Росатом", "Участие в ГОЗ", "Росатом ОПК"], ) package_import = ExchangePackageImport.objects.get() @@ -411,6 +481,75 @@ class ExchangePackageApiTest(APITestCase): self.assertEqual(ExchangePackageImport.objects.count(), 0) self.assertEqual(Organization.objects.count(), 0) + def test_upload_rejects_legacy_schema_version(self): + archive = build_exchange_archive( + package_id="pkg-legacy-schema-version", + schema_version=2, + data=build_exchange_payload(), + ) + + response = self.client.post( + self.url, + {"file": archive}, + format="multipart", + HTTP_X_EXCHANGE_TOKEN=TEST_TOKEN, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("schema_version 3", response.data["file"][0]) + self.assertEqual(Organization.objects.count(), 0) + + def test_upload_rejects_legacy_registry_memberships_section(self): + payload = build_exchange_payload() + payload["registry_memberships"] = [ + { + "organization_inn": "7707083893", + "registry_name": "Реестр госкорпорации Росатом", + "started_at": "2026-01-01", + "ended_at": None, + } + ] + archive = build_exchange_archive( + package_id="pkg-legacy-registry-memberships", + data=payload, + ) + + response = self.client.post( + self.url, + {"file": archive}, + format="multipart", + HTTP_X_EXCHANGE_TOKEN=TEST_TOKEN, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("registry_memberships", response.data["file"][0]) + self.assertEqual(Organization.objects.count(), 0) + + def test_upload_rejects_legacy_organization_row_schema(self): + archive = build_exchange_archive( + package_id="pkg-legacy-organization-row", + data={ + "organizations": [ + { + "inn": "7707083893", + "name": "АО Альфа", + "ogrn": "1027700132195", + } + ], + }, + ) + + response = self.client.post( + self.url, + {"file": archive}, + format="multipart", + HTTP_X_EXCHANGE_TOKEN=TEST_TOKEN, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("schema_version 3", response.data["file"][0]) + self.assertEqual(Organization.objects.count(), 0) + def test_upload_rejects_external_rows_for_organization_absent_from_package(self): Organization.objects.create( inn="7707083893", diff --git a/tests/apps/organization/test_analytics_api.py b/tests/apps/organization/test_analytics_api.py index 1a99541..7108c36 100644 --- a/tests/apps/organization/test_analytics_api.py +++ b/tests/apps/organization/test_analytics_api.py @@ -5,7 +5,6 @@ from __future__ import annotations from datetime import date from decimal import Decimal -from apps.registers.models import Register, RegisterUpload, RegistryMembershipPeriod from django.test import override_settings from rest_framework import status from rest_framework.test import APITestCase @@ -32,20 +31,10 @@ class OrganizationAnalyticsApiTest(APITestCase): financial_reports_available=True, tax_reports_available=True, bankruptcy_messages_found=False, - ) - register = Register.objects.create(name="Реестр госкорпорации Росатом ОПК") - upload = RegisterUpload.objects.create( - registry=register, - actual_date=date(2026, 4, 1), - file_name="analytics.xlsx", - file_hash="analytics-hash", - rows_count=1, - ) - RegistryMembershipPeriod.objects.create( - registry=register, - organization=self.organization, - started_at=date(2026, 4, 1), - started_by_upload=upload, + gk_code="rosatom", + gk_name="Госкорпорация Росатом", + opk_registry_membership=True, + ropk_razdel_name="Росатом ОПК", ) FormF1RecordFactory.create( diff --git a/tests/apps/organization/test_api.py b/tests/apps/organization/test_api.py index 064005c..fcfa9bd 100644 --- a/tests/apps/organization/test_api.py +++ b/tests/apps/organization/test_api.py @@ -2,9 +2,6 @@ from __future__ import annotations -from datetime import date - -from apps.registers.models import Register, RegisterUpload, RegistryMembershipPeriod from django.test import override_settings from rest_framework import status from rest_framework.test import APITestCase @@ -24,41 +21,11 @@ class OrganizationApiTest(APITestCase): self.client.force_authenticate(self.user) def test_list_includes_only_active_registry_names(self): - organization = OrganizationFactory.create( + OrganizationFactory.create( name="АО Альфа", short_name="АО «Альфа»", organization_type="ao", - ) - active_register = Register.objects.create(name="Реестр ОПК") - closed_register = Register.objects.create(name="Архивный реестр") - active_upload = RegisterUpload.objects.create( - registry=active_register, - actual_date=date(2026, 3, 1), - file_name="active.xlsx", - file_hash="active-api-hash", - rows_count=1, - ) - closed_upload = RegisterUpload.objects.create( - registry=closed_register, - actual_date=date(2026, 2, 1), - file_name="closed.xlsx", - file_hash="closed-api-hash", - rows_count=1, - ) - - RegistryMembershipPeriod.objects.create( - registry=active_register, - organization=organization, - started_at=date(2026, 3, 1), - started_by_upload=active_upload, - ) - RegistryMembershipPeriod.objects.create( - registry=closed_register, - organization=organization, - started_at=date(2026, 2, 1), - ended_at=date(2026, 3, 1), - started_by_upload=closed_upload, - ended_by_upload=closed_upload, + opk_registry_membership=True, ) response = self.client.get("/api/v1/organizations/") @@ -79,24 +46,13 @@ class OrganizationApiTest(APITestCase): def test_detail_includes_active_registries(self): organization = OrganizationFactory.create( short_name="АО «Бета»", - general_director_name="Иванов Иван Иванович", - general_director_inn="123456789012", + general_director="Иванов Иван Иванович", + general_director_tax_id="123456789012", financial_reports_available=True, tax_reports_available=True, - ) - register = Register.objects.create(name="Реестр Роскосмос") - upload = RegisterUpload.objects.create( - registry=register, - actual_date=date(2026, 3, 1), - file_name="detail.xlsx", - file_hash="detail-api-hash", - rows_count=1, - ) - RegistryMembershipPeriod.objects.create( - registry=register, - organization=organization, - started_at=date(2026, 3, 1), - started_by_upload=upload, + gk_code="2", + gk_name="Роскосмос", + in_korp_name="Организация внутри ГК", ) response = self.client.get(f"/api/v1/organizations/{organization.id}/") @@ -104,11 +60,11 @@ class OrganizationApiTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual( response.data["summary"]["active_registry_names"], - ["Реестр Роскосмос"], + ["Роскосмос"], ) self.assertEqual( response.data["active_registries"], - [{"id": str(register.id), "name": "Реестр Роскосмос"}], + [{"id": "2", "name": "Роскосмос"}], ) self.assertEqual(response.data["corporation_scope"], "roscosmos") self.assertEqual( @@ -117,10 +73,10 @@ class OrganizationApiTest(APITestCase): ) self.assertEqual(response.data["registry_category"], "other") self.assertEqual(response.data["registry_category_label"], "Прочие") - self.assertEqual( - response.data["general_director"]["full_name"], - "Иванов Иван Иванович", - ) + self.assertEqual(response.data["general_director"], "Иванов Иван Иванович") + self.assertEqual(response.data["general_director_tax_id"], "123456789012") + self.assertEqual(response.data["gk_name"], "Роскосмос") + self.assertEqual(response.data["in_korp_name"], "Организация внутри ГК") def test_detail_summary_uses_available_related_report_data(self): organization = OrganizationFactory.create( @@ -136,71 +92,10 @@ class OrganizationApiTest(APITestCase): self.assertTrue(response.data["summary"]["financial_reports_available"]) self.assertTrue(response.data["summary"]["tax_reports_available"]) - def test_registry_filter_uses_only_active_memberships(self): - active_organization = OrganizationFactory.create(name="АО Актив") - closed_organization = OrganizationFactory.create(name="АО Закрыт") - register = Register.objects.create(name="Реестр Росатом") - upload = RegisterUpload.objects.create( - registry=register, - actual_date=date(2026, 3, 1), - file_name="filter.xlsx", - file_hash="filter-api-hash", - rows_count=2, - ) - - RegistryMembershipPeriod.objects.create( - registry=register, - organization=active_organization, - started_at=date(2026, 3, 1), - started_by_upload=upload, - ) - RegistryMembershipPeriod.objects.create( - registry=register, - organization=closed_organization, - started_at=date(2026, 2, 1), - ended_at=date(2026, 3, 1), - started_by_upload=upload, - ended_by_upload=upload, - ) - - response = self.client.get(f"/api/v1/organizations/?registry={register.id}") - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data["results"]), 1) - self.assertEqual(response.data["results"][0]["id"], str(active_organization.id)) - - def test_corporation_scope_filter_uses_active_registers(self): - rosatom_org = OrganizationFactory.create(name="АО Росатом") - roscosmos_org = OrganizationFactory.create(name="АО Роскосмос") - rosatom_register = Register.objects.create(name="Реестр госкорпорации Росатом") - roscosmos_register = Register.objects.create( - name="Реестр госкорпорации Роскосмос" - ) - rosatom_upload = RegisterUpload.objects.create( - registry=rosatom_register, - actual_date=date(2026, 4, 1), - file_name="rosatom.xlsx", - file_hash="rosatom-hash", - rows_count=1, - ) - roscosmos_upload = RegisterUpload.objects.create( - registry=roscosmos_register, - actual_date=date(2026, 4, 1), - file_name="roscosmos.xlsx", - file_hash="roscosmos-hash", - rows_count=1, - ) - RegistryMembershipPeriod.objects.create( - registry=rosatom_register, - organization=rosatom_org, - started_at=date(2026, 4, 1), - started_by_upload=rosatom_upload, - ) - RegistryMembershipPeriod.objects.create( - registry=roscosmos_register, - organization=roscosmos_org, - started_at=date(2026, 4, 1), - started_by_upload=roscosmos_upload, + def test_corporation_scope_filter_uses_organization_fields(self): + rosatom_org = OrganizationFactory.create(name="АО Росатом", gk_name="Росатом") + roscosmos_org = OrganizationFactory.create( + name="АО Роскосмос", gk_name="Роскосмос" ) response = self.client.get("/api/v1/organizations/?corporation_scope=rosatom") @@ -222,40 +117,14 @@ class OrganizationApiTest(APITestCase): ) def test_registry_category_filters_support_snake_and_camel_case(self): - opk_org = OrganizationFactory.create(name="АО ОПК") - goz_org = OrganizationFactory.create(name="АО ГОЗ", in_275_fz_registry=True) + opk_org = OrganizationFactory.create( + name="АО ОПК", + opk_registry_membership=True, + ropk_razdel_name="Раздел ОПК", + ) + goz_org = OrganizationFactory.create(name="АО ГОЗ", goz_participation=True) other_org = OrganizationFactory.create(name="АО Прочие") - opk_register = Register.objects.create(name="Реестр предприятий ОПК") - goz_register = Register.objects.create(name="Реестр госкорпорации Росатом ГОЗ") - upload = RegisterUpload.objects.create( - registry=opk_register, - actual_date=date(2026, 4, 1), - file_name="registry-category.xlsx", - file_hash="registry-category-hash", - rows_count=2, - ) - goz_upload = RegisterUpload.objects.create( - registry=goz_register, - actual_date=date(2026, 4, 1), - file_name="registry-category-goz.xlsx", - file_hash="registry-category-goz-hash", - rows_count=1, - ) - - RegistryMembershipPeriod.objects.create( - registry=opk_register, - organization=opk_org, - started_at=date(2026, 4, 1), - started_by_upload=upload, - ) - RegistryMembershipPeriod.objects.create( - registry=goz_register, - organization=goz_org, - started_at=date(2026, 4, 1), - started_by_upload=goz_upload, - ) - response = self.client.get("/api/v1/organizations/?registry_category=goz") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual( diff --git a/tests/apps/organization/test_models.py b/tests/apps/organization/test_models.py index 3906916..2972c6c 100644 --- a/tests/apps/organization/test_models.py +++ b/tests/apps/organization/test_models.py @@ -1,8 +1,5 @@ """Tests for Organization model.""" -from datetime import date - -from apps.registers.models import Register, RegisterUpload, RegistryMembershipPeriod from django.test import TestCase from .factories import OrganizationFactory @@ -54,73 +51,25 @@ class OrganizationModelTest(TestCase): """Test OGRN has db_index.""" self.assertTrue(self.org._meta.get_field("ogrn").db_index) - def test_active_registry_names_include_only_current_memberships(self): - """Test only active registry memberships are shown on organization.""" - active_register = Register.objects.create(name="Активный реестр") - inactive_register = Register.objects.create(name="Неактуальный реестр") - active_upload = RegisterUpload.objects.create( - registry=active_register, - actual_date=date(2026, 3, 1), - file_name="active.xlsx", - file_hash="active-hash", - rows_count=1, + def test_active_registry_names_are_derived_from_organization_fields(self): + """Test registry display names are derived from organization directory fields.""" + self.org.gk_name = "Росатом" + self.org.goz_participation = True + self.org.opk_registry_membership = True + self.org.ropk_razdel_name = "Росатом ОПК" + + self.assertEqual( + self.org.get_active_registry_names(), + ["Росатом", "Участие в ГОЗ", "Росатом ОПК"], ) - inactive_upload = RegisterUpload.objects.create( - registry=inactive_register, - actual_date=date(2026, 2, 1), - file_name="inactive.xlsx", - file_hash="inactive-hash", - rows_count=1, + self.assertEqual( + self.org.active_registry_names_display(), + "Росатом, Участие в ГОЗ, Росатом ОПК", ) - RegistryMembershipPeriod.objects.create( - registry=active_register, - organization=self.org, - started_at=date(2026, 3, 1), - started_by_upload=active_upload, - ) - RegistryMembershipPeriod.objects.create( - registry=inactive_register, - organization=self.org, - started_at=date(2026, 2, 1), - ended_at=date(2026, 3, 1), - started_by_upload=inactive_upload, - ended_by_upload=inactive_upload, - ) - - self.assertEqual(self.org.get_active_registry_names(), ["Активный реестр"]) - self.assertEqual(self.org.active_registry_names_display(), "Активный реестр") - - def test_corporation_scopes_are_derived_from_active_registers(self): - rosatom_register = Register.objects.create(name="Реестр госкорпорации Росатом") - opk_register = Register.objects.create(name="Реестр предприятий ОПК") - upload = RegisterUpload.objects.create( - registry=rosatom_register, - actual_date=date(2026, 4, 1), - file_name="scopes.xlsx", - file_hash="scopes-hash", - rows_count=2, - ) - opk_upload = RegisterUpload.objects.create( - registry=opk_register, - actual_date=date(2026, 4, 1), - file_name="scopes-opk.xlsx", - file_hash="scopes-opk-hash", - rows_count=2, - ) - - RegistryMembershipPeriod.objects.create( - registry=rosatom_register, - organization=self.org, - started_at=date(2026, 4, 1), - started_by_upload=upload, - ) - RegistryMembershipPeriod.objects.create( - registry=opk_register, - organization=self.org, - started_at=date(2026, 4, 1), - started_by_upload=opk_upload, - ) + def test_corporation_scopes_are_derived_from_organization_fields(self): + self.org.gk_name = "Росатом" + self.org.opk_registry_membership = True self.assertEqual(self.org.get_corporation_scopes(), ["rosatom", "opk"]) self.assertEqual(