feat: export organizations to state corp v3
All checks were successful
CI/CD Pipeline / Quality Gate (push) Successful in 30s
CI/CD Pipeline / Build and Push Images (push) Successful in 41s
CI/CD Pipeline / Internal Notify (push) Successful in 0s
CI/CD Pipeline / Deploy Dev via Compose (push) Successful in 19s

This commit is contained in:
2026-06-13 14:07:40 +02:00
parent 90d6bb90bd
commit f95ba3f56a
11 changed files with 513 additions and 218 deletions

View File

@@ -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

View File

@@ -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."""

View File

@@ -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."""

View File

@@ -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

View File

@@ -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,
),
]

View File

@@ -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.
"""