From f95ba3f56a0d1341ab6f3bac99a480f67c887f54 Mon Sep 17 00:00:00 2001 From: Aleksandr Meshchriakov Date: Sat, 13 Jun 2026 14:07:40 +0200 Subject: [PATCH] feat: export organizations to state corp v3 --- docker-compose.dev.yml | 6 +- docker-compose.service.yml | 6 +- src/apps/exchange/state_corp_services.py | 163 ++++++----- src/organizations/serializers.py | 22 ++ src/organizations/views.py | 76 +++++ src/registers/apps.py | 3 - .../0008_remove_legacy_register_seed_data.py | 49 ++++ src/registers/signals.py | 29 +- .../apps/exchange/test_state_corp_services.py | 270 +++++++++++------- tests/apps/organizations/test_api_v2.py | 84 ++++++ .../test_legacy_registers_removed.py | 23 ++ 11 files changed, 513 insertions(+), 218 deletions(-) create mode 100644 src/registers/migrations/0008_remove_legacy_register_seed_data.py create mode 100644 tests/apps/registers/test_legacy_registers_removed.py diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 97dd27b..82aa951 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -26,7 +26,7 @@ services: - ./data/db:/var/lib/postgresql/data - ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql ports: - - "5432:5432" + - "${MOSTOVIK_POSTGRES_HOST_PORT:-15432}:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 10s @@ -38,7 +38,7 @@ services: container_name: mostovik_redis restart: unless-stopped ports: - - "6379:6379" + - "${MOSTOVIK_REDIS_HOST_PORT:-16379}:6379" volumes: - ./data/redis:/data healthcheck: @@ -78,7 +78,7 @@ services: migrate: condition: service_completed_successfully ports: - - "8000:8000" + - "${MOSTOVIK_WEB_HOST_PORT:-18000}:8000" volumes: - ./media:/app/media - ./staticfiles:/app/staticfiles diff --git a/docker-compose.service.yml b/docker-compose.service.yml index 7d02630..e1e31be 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" + - "${MOSTOVIK_POSTGRES_HOST_PORT:-15432}:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 30s @@ -29,7 +29,7 @@ services: volumes: - ./data/db_exchange_target:/var/lib/postgresql/data ports: - - "5433:5432" + - "${MOSTOVIK_TARGET_POSTGRES_HOST_PORT:-15433}:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 30s @@ -41,7 +41,7 @@ services: container_name: redis restart: unless-stopped ports: - - "6379:6379" + - "${MOSTOVIK_REDIS_HOST_PORT:-16379}:6379" volumes: - ./data/redis:/data healthcheck: diff --git a/src/apps/exchange/state_corp_services.py b/src/apps/exchange/state_corp_services.py index a0d519b..4d28f79 100644 --- a/src/apps/exchange/state_corp_services.py +++ b/src/apps/exchange/state_corp_services.py @@ -34,7 +34,7 @@ from cryptography.hazmat.primitives.ciphers.aead import AESGCM from django.conf import settings from django.db.models import Q from django.utils import timezone -from registers.models import Organization, RegistryMembershipPeriod +from organizations.models import Organization class StateCorpExchangeError(ValueError): @@ -60,15 +60,9 @@ class StateCorpExchangeService: AAD = b"state-corp-exchange-v1" PAYLOAD_FORMAT = "state-corp-exchange-payload" BIN_FORMAT = "state-corp-exchange-bin" - SCHEMA_VERSION = 2 - ROSATOM_ROSCOSMOS_REGISTRY_NAMES = ( - "Реестр госкорпорации Роскосмос", - "Реестр госкорпорации Роскосмос ГОЗ", - "Реестр госкорпорации Роскосмос ОПК", - "Реестр госкорпорации Росатом", - "Реестр госкорпорации Росатом ГОЗ", - "Реестр госкорпорации Росатом ОПК", - ) + SCHEMA_VERSION = 3 + ROSATOM_ROSCOSMOS_GK_CODE_VALUES = ("rosatom", "roscosmos", "roskosmos") + ROSATOM_ROSCOSMOS_GK_NAME_KEYWORDS = ("Росатом", "Роскосмос") @classmethod def build_package( @@ -86,20 +80,16 @@ class StateCorpExchangeService: organizations = ( cls._get_organizations(normalized_inns) if normalized_inns - else cls._get_rosatom_roscosmos_organizations(snapshot_date) + else cls._get_rosatom_roscosmos_organizations() ) - allowed_inns = {str(item.mn_inn) for item in organizations} + allowed_inns = {str(item.inn) for item in organizations if item.inn} allowed_ogrn_to_inn = { - str(item.mn_ogrn): str(item.mn_inn) + str(item.ogrn): str(item.inn) for item in organizations - if item.mn_ogrn + if item.ogrn and item.inn } data = { "organizations": cls._serialize_organizations(organizations), - "registry_memberships": cls._serialize_registry_memberships( - organizations=organizations, - actual_date=snapshot_date, - ), "industrial_certificates": cls._serialize_industrial_certificates( allowed_inns ), @@ -288,85 +278,92 @@ class StateCorpExchangeService: @classmethod def _get_organizations(cls, organization_inns: list[str]) -> list[Organization]: - queryset = Organization.objects.all().order_by("id") + queryset = cls._rosatom_roscosmos_queryset() if organization_inns: - queryset = queryset.filter( - mn_inn__in=[int(item) for item in organization_inns] - ) + queryset = queryset.filter(inn__in=organization_inns) return list(queryset) @classmethod - def _get_rosatom_roscosmos_organizations( - cls, - actual_date: date, - ) -> list[Organization]: - organization_ids = ( - RegistryMembershipPeriod.objects.filter( - registry__name__in=cls.ROSATOM_ROSCOSMOS_REGISTRY_NAMES, - started_at__lte=actual_date, - ) - .filter(Q(ended_at__isnull=True) | Q(ended_at__gt=actual_date)) - .order_by("organization_id") - .values_list("organization_id", flat=True) - .distinct() - ) - return list(Organization.objects.filter(id__in=organization_ids).order_by("id")) + def _get_rosatom_roscosmos_organizations(cls) -> list[Organization]: + return list(cls._rosatom_roscosmos_queryset()) + + @classmethod + def _rosatom_roscosmos_queryset(cls): + queryset = Organization.objects.exclude(inn="") + name_query = Q() + for keyword in cls.ROSATOM_ROSCOSMOS_GK_NAME_KEYWORDS: + name_query |= Q(gk_name__icontains=keyword) + code_query = Q() + for code in cls.ROSATOM_ROSCOSMOS_GK_CODE_VALUES: + code_query |= Q(gk_code__iexact=code) + return queryset.filter(name_query | code_query).order_by("rn", "uid") @classmethod def _serialize_organizations( cls, organizations: list[Organization], - ) -> list[dict[str, str]]: + ) -> list[dict[str, str | int | bool | None]]: return [ { - "inn": str(item.mn_inn), - "name": item.pn_name, - "ogrn": str(item.mn_ogrn), - "kpp": str(item.in_kpp or ""), - "okpo": item.mn_okpo, - } - for item in organizations - ] - - @classmethod - def _serialize_registry_memberships( - cls, - *, - organizations: list[Organization], - actual_date: date, - ) -> list[dict[str, str | None]]: - organization_ids = [organization.id for organization in organizations] - if not organization_ids: - return [] - - memberships = ( - RegistryMembershipPeriod.objects.select_related("registry", "organization") - .filter( - organization_id__in=organization_ids, - registry__name__in=cls.ROSATOM_ROSCOSMOS_REGISTRY_NAMES, - started_at__lte=actual_date, - ) - .filter(Q(ended_at__isnull=True) | Q(ended_at__gt=actual_date)) - .order_by( - "registry__name", - "organization__mn_inn", - "started_at", - "id", - ) - ) - - return [ - { - "organization_inn": str(membership.organization.mn_inn), - "registry_name": membership.registry.name, - "started_at": membership.started_at.isoformat(), - "ended_at": ( - membership.ended_at.isoformat() - if membership.ended_at and membership.ended_at <= actual_date + "mostovik_uid": str(item.uid), + "rn": item.rn, + "name": item.name, + "full_name": item.full_name, + "short_name": item.short_name, + "pn_name": item.pn_name, + "pn_name_en": item.pn_name_en, + "inn": item.inn, + "kpp": item.kpp, + "ogrn": item.ogrn, + "ogrip": item.ogrip, + "okpo": item.okpo, + "identity_status": item.identity_status, + "primary_identity": item.primary_identity, + "gk_code": item.gk_code, + "gk_name": item.gk_name, + "in_korp_code": item.in_korp_code, + "in_korp_name": item.in_korp_name, + "filial": item.filial, + "is_branch": item.is_branch, + "re_za": item.re_za, + "re_zasf": item.re_zasf, + "goz_participation": item.goz_participation, + "opk_registry_membership": item.opk_registry_membership, + "ropk_num": item.ropk_num, + "ropk_razdel_num": item.ropk_razdel_num, + "ropk_razdel_name": item.ropk_razdel_name, + "registration_date": ( + item.registration_date.isoformat() + if item.registration_date else None ), + "create_date": item.create_date, + "organizational_legal_form": item.organizational_legal_form, + "organizational_legal_form1": item.organizational_legal_form1, + "ownership_form": item.ownership_form, + "ownership_form1": item.ownership_form1, + "authorized_capital": cls._serialize_decimal(item.authorized_capital), + "legal_address": item.legal_address, + "business_act_cod": item.business_act_cod, + "business_activity": item.business_activity, + "general_director": item.general_director, + "general_director_tax_id": item.general_director_tax_id, + "uk": item.uk, + "inn_uk": item.inn_uk, + "appointment_date": ( + item.appointment_date.isoformat() if item.appointment_date else None + ), + "cf_fl_rn": item.cf_fl_rn, + "akc_fs": item.akc_fs, + "akc_sf": item.akc_sf, + "min": item.min, + "dep": item.dep, + "otr": item.otr, + "integrated_structure": item.integrated_structure, + "state_sector_code": item.state_sector_code, + "state_sector_name": item.state_sector_name, } - for membership in memberships + for item in organizations ] @classmethod diff --git a/src/organizations/serializers.py b/src/organizations/serializers.py index 01db42b..77cfdcc 100644 --- a/src/organizations/serializers.py +++ b/src/organizations/serializers.py @@ -148,6 +148,28 @@ class OrganizationSourceRecordExportRequestSerializer(serializers.Serializer): return value +class OrganizationDirectoryImportUploadSerializer(serializers.Serializer): + """Request for uploading the canonical organization directory XLSX.""" + + file = serializers.FileField() + + def validate_file(self, value): + if not value.name.lower().endswith(".xlsx"): + raise serializers.ValidationError("Поддерживаются только файлы .xlsx") + return value + + +class OrganizationDirectoryImportResponseSerializer(serializers.Serializer): + """Counters returned after canonical organization directory upload.""" + + success = serializers.BooleanField(read_only=True) + message = serializers.CharField(read_only=True) + scanned = serializers.IntegerField(read_only=True) + created = serializers.IntegerField(read_only=True) + updated = serializers.IntegerField(read_only=True) + skipped = serializers.IntegerField(read_only=True) + + class OrganizationSourceExtensionSerializer(serializers.ModelSerializer): """Compact source extension representation.""" diff --git a/src/organizations/views.py b/src/organizations/views.py index 44c7498..101b2ee 100644 --- a/src/organizations/views.py +++ b/src/organizations/views.py @@ -4,6 +4,9 @@ from __future__ import annotations import hashlib import json +import os +from contextlib import suppress +from tempfile import NamedTemporaryFile from typing import Any from apps.core.openapi import swagger_tag @@ -17,7 +20,9 @@ from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema from rest_framework import status from rest_framework.decorators import action +from rest_framework.exceptions import ValidationError from rest_framework.filters import OrderingFilter, SearchFilter +from rest_framework.parsers import MultiPartParser from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ReadOnlyModelViewSet @@ -26,6 +31,11 @@ from organizations.cache import ( DEFAULT_ORGANIZATION_API_CACHE_TIMEOUT_SECONDS, ORGANIZATION_API_CACHE_PREFIX, get_organization_api_cache_version, + invalidate_organization_api_cache, +) +from organizations.directory_import import ( + OrganizationDirectoryImportError, + OrganizationDirectoryImportService, ) from organizations.filters import OrganizationFilter from organizations.models import ( @@ -35,6 +45,8 @@ from organizations.models import ( SourceGroup, ) from organizations.serializers import ( + OrganizationDirectoryImportResponseSerializer, + OrganizationDirectoryImportUploadSerializer, OrganizationSerializer, OrganizationSourceExtensionSerializer, OrganizationSourceRecordExportRequestSerializer, @@ -355,6 +367,70 @@ class OrganizationViewSet(CachedReadOnlyMixin, ReadOnlyModelViewSet): ) return Response(serializer.data) + @swagger_auto_schema( + tags=[ORGANIZATIONS_TAG], + operation_id="v2_organizations_import_directory", + operation_summary="Загрузка справочника организаций", + operation_description=( + "Загружает XLSX сводного реестра организаций ОПК в каноническую " + "таблицу organizations.Organization." + ), + manual_parameters=[ + openapi.Parameter( + name="file", + in_=openapi.IN_FORM, + type=openapi.TYPE_FILE, + required=True, + description="XLSX файл сводного реестра организаций", + ), + ], + consumes=["multipart/form-data"], + responses={ + 201: OrganizationDirectoryImportResponseSerializer, + 400: "Некорректный XLSX файл", + 403: "Доступ только для администратора", + }, + ) + @action( + detail=False, + methods=["post"], + url_path="import-directory", + parser_classes=[MultiPartParser], + permission_classes=[IsAdminUser], + ) + def import_directory(self, request, *args: Any, **kwargs: Any) -> Response: + serializer = OrganizationDirectoryImportUploadSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + uploaded_file = serializer.validated_data["file"] + + temp_path = "" + try: + with NamedTemporaryFile(suffix=".xlsx", delete=False) as temp_file: + temp_path = temp_file.name + for chunk in uploaded_file.chunks(): + temp_file.write(chunk) + + result = OrganizationDirectoryImportService.import_xlsx(temp_path) + except OrganizationDirectoryImportError as exc: + raise ValidationError({"file": str(exc)}) from exc + finally: + if temp_path: + with suppress(FileNotFoundError): + os.unlink(temp_path) + + invalidate_organization_api_cache() + return Response( + { + "success": True, + "message": "Справочник организаций успешно загружен", + "scanned": result.scanned, + "created": result.created, + "updated": result.updated, + "skipped": result.skipped, + }, + status=status.HTTP_201_CREATED, + ) + class OrganizationSourceExtensionViewSet(ReadOnlyModelViewSet): """Read-only API for source extensions and their records.""" diff --git a/src/registers/apps.py b/src/registers/apps.py index 7146cca..3bcb7de 100644 --- a/src/registers/apps.py +++ b/src/registers/apps.py @@ -7,6 +7,3 @@ class RegistersConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "registers" verbose_name = "Реестры организаций" - - def ready(self): - from . import signals # noqa: F401 diff --git a/src/registers/migrations/0008_remove_legacy_register_seed_data.py b/src/registers/migrations/0008_remove_legacy_register_seed_data.py new file mode 100644 index 0000000..f81f86c --- /dev/null +++ b/src/registers/migrations/0008_remove_legacy_register_seed_data.py @@ -0,0 +1,49 @@ +from django.db import migrations + +LEGACY_REGISTER_NAMES = ( + "Реестр предприятий ОПК", + "Реестр госкорпорации Роскосмос", + "Реестр госкорпорации Роскосмос ГОЗ", + "Реестр госкорпорации Роскосмос ОПК", + "Реестр госкорпорации Росатом", + "Реестр госкорпорации Росатом ГОЗ", + "Реестр госкорпорации Росатом ОПК", +) + + +def remove_legacy_register_seed_data(apps, schema_editor): + Register = apps.get_model("registers", "Register") + RegisterUpload = apps.get_model("registers", "RegisterUpload") + Organization = apps.get_model("registers", "Organization") + RegistryMembershipPeriod = apps.get_model("registers", "RegistryMembershipPeriod") + db_alias = schema_editor.connection.alias + + legacy_register_ids = list( + Register.objects.using(db_alias) + .filter(name__in=LEGACY_REGISTER_NAMES) + .values_list("id", flat=True) + ) + + if legacy_register_ids: + RegistryMembershipPeriod.objects.using(db_alias).filter( + registry_id__in=legacy_register_ids + ).delete() + RegisterUpload.objects.using(db_alias).filter( + registry_id__in=legacy_register_ids + ).delete() + Register.objects.using(db_alias).filter(id__in=legacy_register_ids).delete() + + Organization.objects.using(db_alias).all().delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("registers", "0007_restore_membership_period_fields"), + ] + + operations = [ + migrations.RunPython( + remove_legacy_register_seed_data, + migrations.RunPython.noop, + ), + ] diff --git a/src/registers/signals.py b/src/registers/signals.py index cbe081c..de892b5 100644 --- a/src/registers/signals.py +++ b/src/registers/signals.py @@ -1,26 +1,5 @@ -"""Signals for registers app.""" +"""Signals for registers app. -from django.apps import apps -from django.db.models.signals import post_migrate -from django.dispatch import receiver - -DEFAULT_REGISTER_NAMES = ( - "Реестр предприятий ОПК", - "Реестр госкорпорации Роскосмос", - "Реестр госкорпорации Роскосмос ГОЗ", - "Реестр госкорпорации Роскосмос ОПК", - "Реестр госкорпорации Росатом", - "Реестр госкорпорации Росатом ГОЗ", - "Реестр госкорпорации Росатом ОПК", -) - - -@receiver(post_migrate) -def seed_default_registers(sender, **kwargs): - """Create default registries on fresh environments.""" - if sender.name != "registers": - return - - Register = apps.get_model("registers", "Register") - for name in DEFAULT_REGISTER_NAMES: - Register.objects.get_or_create(name=name) +Legacy corporate register seed records were removed when organizations became +the canonical source for the Mostovik directory import/export flow. +""" diff --git a/tests/apps/exchange/test_state_corp_services.py b/tests/apps/exchange/test_state_corp_services.py index 6f97a7f..1498163 100644 --- a/tests/apps/exchange/test_state_corp_services.py +++ b/tests/apps/exchange/test_state_corp_services.py @@ -7,6 +7,8 @@ import hashlib import json import struct import zlib +from datetime import date +from decimal import Decimal from io import BytesIO from unittest.mock import Mock, patch from zipfile import ZipFile @@ -18,9 +20,9 @@ from apps.parsers.models import ( GenericParserRecord, ParserLoadLog, ) -from apps.registers.models import Register from cryptography.hazmat.primitives.ciphers.aead import AESGCM from django.test import TestCase, override_settings +from organizations.models import Organization from tests.apps.parsers.factories import ( IndustrialCertificateRecordFactory, @@ -29,11 +31,6 @@ from tests.apps.parsers.factories import ( ManufacturerRecordFactory, ProcurementRecordFactory, ) -from tests.apps.registers.factories import ( - OrganizationFactory, - RegisterFactory, - RegistryMembershipPeriodFactory, -) def _b64url_decode(value: str) -> bytes: @@ -71,24 +68,59 @@ class StateCorpExchangeServiceTest(TestCase): """Verify package compatibility with state-corp receiver contract.""" def test_build_package_contains_expected_payload(self): - registry = Register.objects.get(name="Реестр госкорпорации Росатом") - organization = OrganizationFactory.create( - mn_inn=7707083893, - mn_ogrn=1027700132195, + organization = Organization.objects.create( + rn=1001, + name="АО Альфа", + full_name="Акционерное общество Альфа", + short_name="АО Альфа", pn_name="АО Альфа", - in_kpp=770701001, - mn_okpo="12345678", - ) - RegistryMembershipPeriodFactory.create( - registry=registry, - organization=organization, - started_at="2026-01-01", - ended_at=None, + pn_name_en="Alpha JSC", + inn="7707083893", + ogrn="1027700132195", + kpp="770701001", + okpo="12345678", + gk_code="1", + gk_name="Росатом", + in_korp_code="head", + in_korp_name="Головная организация", + filial=".F.", + is_branch=False, + registration_date=date(2024, 2, 15), + create_date="2024", + organizational_legal_form="12267", + organizational_legal_form1="Акционерное общество", + ownership_form="61", + ownership_form1="Федеральная собственность", + authorized_capital=Decimal("1500000.50"), + legal_address="г. Москва, ул. Тверская, д. 1", + business_act_cod="26.11", + business_activity="Производство электронных компонентов", + general_director="Иванов Иван Иванович", + general_director_tax_id="123456789012", + uk="АО Управляющая компания", + inn_uk="7707000000", + appointment_date=date(2025, 1, 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="Государственный сектор", ) IndustrialCertificateRecordFactory.create( - inn=str(organization.mn_inn), - ogrn=str(organization.mn_ogrn), - organisation_name=organization.pn_name, + inn=organization.inn, + ogrn=organization.ogrn, + organisation_name=organization.name, certificate_number="cert-001", issue_date="2026-01-10", issue_date_normalized="2026-01-10", @@ -97,23 +129,23 @@ class StateCorpExchangeServiceTest(TestCase): certificate_file_url="https://minpromtorg.gov.ru/cert/001", ) ManufacturerRecordFactory.create( - inn=str(organization.mn_inn), - ogrn=str(organization.mn_ogrn), - full_legal_name=organization.pn_name, + inn=organization.inn, + ogrn=organization.ogrn, + full_legal_name=organization.name, address="г. Москва, ул. Тверская, д. 1", ) IndustrialProductRecordFactory.create( - inn=str(organization.mn_inn), - ogrn=str(organization.mn_ogrn), - full_organisation_name=organization.pn_name, + inn=organization.inn, + ogrn=organization.ogrn, + full_organisation_name=organization.name, registry_number="prod-001", product_name="Система связи М-1", product_model="Связь", ) InspectionRecordFactory.create( - inn=str(organization.mn_inn), - ogrn=str(organization.mn_ogrn), - organisation_name=organization.pn_name, + inn=organization.inn, + ogrn=organization.ogrn, + organisation_name=organization.name, registration_number="insp-001", control_authority="Минпромторг", legal_basis="294-ФЗ", @@ -122,9 +154,9 @@ class StateCorpExchangeServiceTest(TestCase): status="active", ) ProcurementRecordFactory.create( - customer_inn=str(organization.mn_inn), - customer_ogrn=str(organization.mn_ogrn), - customer_name=organization.pn_name, + customer_inn=organization.inn, + customer_ogrn=organization.ogrn, + customer_name=organization.name, purchase_number="purchase-001", purchase_name="Поставка оборудования", law_type="223-ФЗ", @@ -137,7 +169,7 @@ class StateCorpExchangeServiceTest(TestCase): ) financial_report = FinancialReport.objects.create( external_id="fin-001", - ogrn=str(organization.mn_ogrn), + ogrn=organization.ogrn, file_name="fin_001_1027700132195.xlsx", file_hash="f" * 64, load_batch=1, @@ -157,8 +189,8 @@ class StateCorpExchangeServiceTest(TestCase): source=ParserLoadLog.Source.PROCUREMENTS_44FZ, load_batch=1, external_id="purchase-generic-001", - inn=str(organization.mn_inn), - ogrn=str(organization.mn_ogrn), + inn=organization.inn, + ogrn=organization.ogrn, title="Поставка generic-оборудования", record_date="15.02.2026", amount="2500000.00", @@ -173,8 +205,8 @@ class StateCorpExchangeServiceTest(TestCase): source=ParserLoadLog.Source.ARBITRATION, load_batch=1, external_id="case-001", - inn=str(organization.mn_inn), - ogrn=str(organization.mn_ogrn), + inn=organization.inn, + ogrn=organization.ogrn, title="А40-1/2026", record_date="2026-03-25", status="in_progress", @@ -188,8 +220,8 @@ class StateCorpExchangeServiceTest(TestCase): source=ParserLoadLog.Source.FEDRESURS_BANKRUPTCY, load_batch=1, external_id="fedresurs-001", - inn=str(organization.mn_inn), - ogrn=str(organization.mn_ogrn), + inn=organization.inn, + ogrn=organization.ogrn, title="Сообщение о намерении", record_date="2026-03-26", status="published", @@ -204,15 +236,15 @@ class StateCorpExchangeServiceTest(TestCase): source=ParserLoadLog.Source.FAS_GOZ, load_batch=1, external_id="fas-goz-001", - inn=str(organization.mn_inn), - ogrn=str(organization.mn_ogrn), + inn=organization.inn, + ogrn=organization.ogrn, title="Уклонение от заключения контракта", record_date="2026-02-20", status="active", url="https://fas.gov.ru/register/001", payload={ "Номер реестровой записи": "ГОЗ-001", - "Полное наименование лица": organization.pn_name, + "Полное наименование лица": organization.name, "Дата вступления постановления": "2026-02-20", }, ) @@ -220,8 +252,8 @@ class StateCorpExchangeServiceTest(TestCase): source=ParserLoadLog.Source.FSTEC, load_batch=1, external_id="fstec-001", - inn=str(organization.mn_inn), - ogrn=str(organization.mn_ogrn), + inn=organization.inn, + ogrn=organization.ogrn, title="Реестр лицензий ФСТЭК", record_date="2026-01-10", status="present", @@ -236,8 +268,8 @@ class StateCorpExchangeServiceTest(TestCase): source=ParserLoadLog.Source.TRUDVSEM, load_batch=1, external_id="trudvsem-001", - inn=str(organization.mn_inn), - ogrn=str(organization.mn_ogrn), + inn=organization.inn, + ogrn=organization.ogrn, title="Инженер-испытатель", record_date="2026-04-01", amount="175000.00", @@ -248,7 +280,7 @@ class StateCorpExchangeServiceTest(TestCase): package = StateCorpExchangeService.build_package(actual_date="2026-03-15") self.assertEqual(package.payload_counts["organizations"], 1) - self.assertEqual(package.payload_counts["registry_memberships"], 1) + self.assertNotIn("registry_memberships", package.payload_counts) self.assertEqual(package.payload_counts["industrial_certificates"], 1) self.assertEqual(package.payload_counts["manufacturers"], 1) self.assertEqual(package.payload_counts["industrial_products"], 1) @@ -264,15 +296,65 @@ class StateCorpExchangeServiceTest(TestCase): payload = _decode_package_payload(package) self.assertEqual(payload["format"], StateCorpExchangeService.PAYLOAD_FORMAT) + self.assertEqual(payload["schema_version"], 3) + self.assertEqual(payload["manifest"]["schema_version"], 3) self.assertEqual(payload["manifest"]["source_system"], "mostovik") - self.assertEqual(payload["data"]["organizations"][0]["inn"], "7707083893") + self.assertNotIn("registry_memberships", payload["data"]) + self.assertNotIn("registry_memberships", payload["manifest"]["sections"]) self.assertEqual( - payload["data"]["registry_memberships"][0], + payload["data"]["organizations"][0], { - "organization_inn": "7707083893", - "registry_name": "Реестр госкорпорации Росатом", - "started_at": "2026-01-01", - "ended_at": None, + "mostovik_uid": str(organization.uid), + "rn": 1001, + "name": "АО Альфа", + "full_name": "Акционерное общество Альфа", + "short_name": "АО Альфа", + "pn_name": "АО Альфа", + "pn_name_en": "Alpha JSC", + "inn": "7707083893", + "kpp": "770701001", + "ogrn": "1027700132195", + "ogrip": "", + "okpo": "12345678", + "identity_status": "complete", + "primary_identity": "inn:7707083893:kpp:770701001", + "gk_code": "1", + "gk_name": "Росатом", + "in_korp_code": "head", + "in_korp_name": "Головная организация", + "filial": ".F.", + "is_branch": False, + "re_za": True, + "re_zasf": False, + "goz_participation": True, + "opk_registry_membership": True, + "ropk_num": "РОПК-001", + "ropk_razdel_num": "opk-1", + "ropk_razdel_name": "Росатом ОПК", + "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", + "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", + "min": "Минпромторг", + "dep": "Департамент радиоэлектроники", + "otr": "Радиоэлектроника", + "integrated_structure": "Интегрированная структура Альфа", + "state_sector_code": "10", + "state_sector_name": "Государственный сектор", }, ) self.assertEqual( @@ -320,56 +402,38 @@ class StateCorpExchangeServiceTest(TestCase): "Инженер-испытатель", ) - def test_build_package_exports_only_active_rosatom_roscosmos_registry_members(self): - target_registry = Register.objects.get(name="Реестр госкорпорации Роскосмос") - non_target_registry = RegisterFactory.create() - - target = OrganizationFactory.create( - mn_inn=7707000001, - mn_ogrn=1027700000001, - pn_name="АО Целевая", + def test_build_package_exports_only_rosatom_roscosmos_organizations(self): + target = Organization.objects.create( + inn="7707000001", + ogrn="1027700000001", + name="АО Целевая", + gk_name="Роскосмос", ) - non_target = OrganizationFactory.create( - mn_inn=7707000002, - mn_ogrn=1027700000002, - pn_name="АО Не экспортируется", + non_target = Organization.objects.create( + inn="7707000002", + ogrn="1027700000002", + name="АО Не экспортируется", + gk_name="Иная корпорация", ) - inactive_target = OrganizationFactory.create( - mn_inn=7707000003, - mn_ogrn=1027700000003, - pn_name="АО Бывшая", - ) - RegistryMembershipPeriodFactory.create( - registry=target_registry, - organization=target, - started_at="2026-01-01", - ended_at=None, - ) - RegistryMembershipPeriodFactory.create( - registry=non_target_registry, - organization=non_target, - started_at="2026-01-01", - ended_at=None, - ) - RegistryMembershipPeriodFactory.create( - registry=target_registry, - organization=inactive_target, - started_at="2026-01-01", - ended_at="2026-03-01", + rosatom = Organization.objects.create( + inn="7707000003", + ogrn="1027700000003", + name="АО Росатом", + gk_name="ГК Росатом", ) IndustrialProductRecordFactory.create( - inn=str(target.mn_inn), + inn=target.inn, registry_number="target-product", ) IndustrialProductRecordFactory.create( - inn=str(non_target.mn_inn), + inn=non_target.inn, registry_number="non-target-product", ) GenericParserRecord.objects.create( source=ParserLoadLog.Source.TRUDVSEM, load_batch=1, external_id="target-vacancy", - inn=str(target.mn_inn), + inn=target.inn, title="Целевая вакансия", record_date="2026-03-10", ) @@ -377,7 +441,7 @@ class StateCorpExchangeServiceTest(TestCase): source=ParserLoadLog.Source.TRUDVSEM, load_batch=1, external_id="non-target-vacancy", - inn=str(non_target.mn_inn), + inn=non_target.inn, title="Лишняя вакансия", record_date="2026-03-10", ) @@ -386,16 +450,10 @@ class StateCorpExchangeServiceTest(TestCase): payload = _decode_package_payload(package) self.assertEqual( - [item["inn"] for item in payload["data"]["organizations"]], - [str(target.mn_inn)], - ) - self.assertEqual( - [ - (item["organization_inn"], item["registry_name"]) - for item in payload["data"]["registry_memberships"] - ], - [(str(target.mn_inn), "Реестр госкорпорации Роскосмос")], + sorted(item["inn"] for item in payload["data"]["organizations"]), + sorted([target.inn, rosatom.inn]), ) + self.assertNotIn("registry_memberships", payload["data"]) self.assertEqual( [ item["registry_number"] @@ -408,6 +466,16 @@ class StateCorpExchangeServiceTest(TestCase): ["target-vacancy"], ) + package = StateCorpExchangeService.build_package( + organization_inns=[target.inn, non_target.inn], + actual_date="2026-03-15", + ) + payload = _decode_package_payload(package) + self.assertEqual( + [item["inn"] for item in payload["data"]["organizations"]], + [target.inn], + ) + @patch("apps.exchange.state_corp_services.requests.post") def test_send_package_posts_multipart_archive(self, post_mock): response_mock = Mock(status_code=201) diff --git a/tests/apps/organizations/test_api_v2.py b/tests/apps/organizations/test_api_v2.py index ec7b9b2..8b70985 100644 --- a/tests/apps/organizations/test_api_v2.py +++ b/tests/apps/organizations/test_api_v2.py @@ -1,12 +1,17 @@ """Tests for organizations API v2.""" +from io import BytesIO + from apps.parsers.models import ParserLoadLog from django.core.cache import cache +from django.core.files.uploadedfile import SimpleUploadedFile from django.db import connection from django.test import override_settings from django.test.utils import CaptureQueriesContext from django.urls import reverse +from openpyxl import Workbook from organizations.cache import invalidate_organization_api_cache +from organizations.directory_import import DIRECTORY_SHEET, SOURCE_HEADERS from organizations.filters import OrganizationFilter from organizations.models import ( FinancialIndicatorsExtension, @@ -19,6 +24,7 @@ from organizations.models import ( SecurityRegistryExtension, VacancyExtension, ) +from registers.models import Organization as LegacyRegisterOrganization from rest_framework import status from rest_framework.test import APITestCase @@ -33,6 +39,17 @@ class OrganizationsApiV2Test(APITestCase): self.user = UserFactory.create_user() self.client.force_authenticate(self.user) + @staticmethod + def _append_reference_sheet( + workbook: Workbook, + title: str, + values: dict[str, str], + ) -> None: + worksheet = workbook.create_sheet(title=title) + worksheet.append(["kod", "value"]) + for key, value in values.items(): + worksheet.append([key, value]) + def test_list_is_paginated_and_available_only_under_v2(self): Organization.objects.create( name='ООО "Альфа"', @@ -60,6 +77,73 @@ class OrganizationsApiV2Test(APITestCase): v1_response = self.client.get("/api/v1/organizations/") self.assertEqual(v1_response.status_code, status.HTTP_404_NOT_FOUND) + def test_admin_import_directory_uploads_xlsx_into_canonical_organizations(self): + self.client.force_authenticate(UserFactory.create_superuser()) + workbook = Workbook() + worksheet = workbook.active + worksheet.title = DIRECTORY_SHEET + worksheet.append(SOURCE_HEADERS) + worksheet.append( + [ + { + "rn": "10", + "_gk": "1", + "in_korp": "1", + "full_name": 'Акционерное общество "Тест"', + "short_name": 'АО "Тест"', + "pn_name": 'АО "Тест"', + "inn": "7701001001", + "ogrn": "1027700100001", + "okpo": "00123456", + "filial": ".F.", + "kpp": "770101001", + "registration_date": "01.02.2020", + "authorized_capita": 1000, + "business_act_cod": "2", + "goz_participation": ".T.", + "opk_registry_membership": ".F.", + "_k": "2", + }.get(header, "") + for header in SOURCE_HEADERS + ] + ) + self._append_reference_sheet(workbook, "_gk", {"1": "Роскосмос"}) + self._append_reference_sheet(workbook, "in_korp", {"1": "Входит в состав"}) + self._append_reference_sheet( + workbook, + "business_act_cod", + {"2": "Производственная"}, + ) + self._append_reference_sheet( + workbook, + "_k - Госектор", + {"2": "Головные исполнители"}, + ) + buffer = BytesIO() + workbook.save(buffer) + upload = SimpleUploadedFile( + "directory.xlsx", + buffer.getvalue(), + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + + response = self.client.post( + reverse("api_v2:organizations:organizations-import-directory"), + {"file": upload}, + format="multipart", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["success"], True) + self.assertEqual(response.data["created"], 1) + self.assertEqual(LegacyRegisterOrganization.objects.count(), 0) + organization = Organization.objects.get(rn=10) + self.assertEqual(organization.name, 'Акционерное общество "Тест"') + self.assertEqual(organization.inn, "7701001001") + self.assertEqual(organization.gk_name, "Роскосмос") + self.assertEqual(organization.business_activity, "Производственная") + self.assertTrue(organization.goz_participation) + def test_openapi_documents_v2_organizations_endpoints(self): response = self.client.get( reverse("schema-swagger-ui"), diff --git a/tests/apps/registers/test_legacy_registers_removed.py b/tests/apps/registers/test_legacy_registers_removed.py new file mode 100644 index 0000000..80eefaf --- /dev/null +++ b/tests/apps/registers/test_legacy_registers_removed.py @@ -0,0 +1,23 @@ +"""Regression tests for removed legacy register seed records.""" + +from apps.registers.models import Register +from django.test import TestCase + +LEGACY_REGISTER_NAMES = ( + "Реестр предприятий ОПК", + "Реестр госкорпорации Роскосмос", + "Реестр госкорпорации Роскосмос ГОЗ", + "Реестр госкорпорации Роскосмос ОПК", + "Реестр госкорпорации Росатом", + "Реестр госкорпорации Росатом ГОЗ", + "Реестр госкорпорации Росатом ОПК", +) + + +class LegacyRegistersRemovedTest(TestCase): + """Old Mostovik corporate registers are no longer seeded.""" + + def test_legacy_register_seed_records_are_absent(self): + self.assertFalse( + Register.objects.filter(name__in=LEGACY_REGISTER_NAMES).exists() + )