feat: import organization exchange v3
All checks were successful
CI/CD Pipeline / Run Tests (push) Successful in 3m27s
CI/CD Pipeline / Code Quality Checks (push) Successful in 3m47s
CI/CD Pipeline / Build and Push Dev Images (push) Successful in 2m17s
CI/CD Pipeline / Deploy Dev via Compose (push) Successful in 29s

This commit is contained in:
2026-06-13 14:07:40 +02:00
parent 479d81680d
commit f93663b2a4
13 changed files with 1167 additions and 682 deletions

View File

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

View File

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

View File

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

View File

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