feat: export organizations to state corp v3
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user