feat: import exchange registry memberships
All checks were successful
CI/CD Pipeline / Code Quality Checks (push) Successful in 2m47s
CI/CD Pipeline / Run Tests (push) Successful in 2m49s
CI/CD Pipeline / Build Docker Images (push) Successful in 41s
CI/CD Pipeline / Push to Gitea Registry (push) Successful in 1s
CI/CD Pipeline / Deploy to Server (push) Successful in 1s
All checks were successful
CI/CD Pipeline / Code Quality Checks (push) Successful in 2m47s
CI/CD Pipeline / Run Tests (push) Successful in 2m49s
CI/CD Pipeline / Build Docker Images (push) Successful in 41s
CI/CD Pipeline / Push to Gitea Registry (push) Successful in 1s
CI/CD Pipeline / Deploy to Server (push) Successful in 1s
This commit is contained in:
@@ -8,7 +8,7 @@ import json
|
|||||||
import struct
|
import struct
|
||||||
import zlib
|
import zlib
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date, datetime
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -34,10 +34,12 @@ from apps.external_data.models import (
|
|||||||
PublicProcurement,
|
PublicProcurement,
|
||||||
)
|
)
|
||||||
from apps.organization.models import IndustryCluster, Organization, OrganizationType
|
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 cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
class ExchangeImportError(ValueError):
|
class ExchangeImportError(ValueError):
|
||||||
@@ -97,6 +99,7 @@ class ExchangePackageImportService:
|
|||||||
}
|
}
|
||||||
SECTION_KEYS = (
|
SECTION_KEYS = (
|
||||||
"organizations",
|
"organizations",
|
||||||
|
"registry_memberships",
|
||||||
"industrial_certificates",
|
"industrial_certificates",
|
||||||
"manufacturers",
|
"manufacturers",
|
||||||
"industrial_products",
|
"industrial_products",
|
||||||
@@ -152,7 +155,11 @@ class ExchangePackageImportService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
summary = cls._import_payload(decoded.payload)
|
summary = cls._import_payload(
|
||||||
|
decoded.payload,
|
||||||
|
package_hash=decoded.package_hash,
|
||||||
|
package_name=decoded.archive_name,
|
||||||
|
)
|
||||||
record = ExchangePackageImport.objects.create(
|
record = ExchangePackageImport.objects.create(
|
||||||
package_id=package_id,
|
package_id=package_id,
|
||||||
source_system=source_system,
|
source_system=source_system,
|
||||||
@@ -383,6 +390,27 @@ class ExchangePackageImportService:
|
|||||||
"schema_version должен быть целым числом"
|
"schema_version должен быть целым числом"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _read_actual_date(cls, manifest: dict[str, Any]) -> date:
|
||||||
|
raw_value = manifest.get("actual_date") or manifest.get("produced_at")
|
||||||
|
if raw_value in (None, ""):
|
||||||
|
return timezone.localdate()
|
||||||
|
if isinstance(raw_value, date):
|
||||||
|
return raw_value
|
||||||
|
|
||||||
|
raw_text = str(raw_value).strip()
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(raw_text)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(raw_text.replace("Z", "+00:00")).date()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ExchangeImportError(
|
||||||
|
"actual_date должен быть датой или датой-временем ISO"
|
||||||
|
) from exc
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _find_duplicate(
|
def _find_duplicate(
|
||||||
cls,
|
cls,
|
||||||
@@ -453,17 +481,32 @@ class ExchangePackageImportService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _import_payload(cls, payload: dict[str, Any]) -> dict[str, Any]:
|
def _import_payload(
|
||||||
|
cls,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
package_hash: str,
|
||||||
|
package_name: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
data = payload.get("data")
|
data = payload.get("data")
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise ExchangeImportError("Раздел data в пакете поврежден")
|
raise ExchangeImportError("Раздел data в пакете поврежден")
|
||||||
|
|
||||||
|
manifest = cls._extract_manifest(payload)
|
||||||
|
actual_date = cls._read_actual_date(manifest)
|
||||||
organization_rows = cls._extract_rows(data, "organizations")
|
organization_rows = cls._extract_rows(data, "organizations")
|
||||||
allowed_organization_inns = cls._collect_package_organization_inns(
|
allowed_organization_inns = cls._collect_package_organization_inns(
|
||||||
organization_rows
|
organization_rows
|
||||||
)
|
)
|
||||||
|
|
||||||
organization_summary = cls._upsert_organizations(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(
|
industrial_certificate_summary = cls._upsert_industrial_certificates(
|
||||||
cls._extract_rows(data, "industrial_certificates"),
|
cls._extract_rows(data, "industrial_certificates"),
|
||||||
allowed_organization_inns=allowed_organization_inns,
|
allowed_organization_inns=allowed_organization_inns,
|
||||||
@@ -511,6 +554,7 @@ class ExchangePackageImportService:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"organizations": organization_summary,
|
"organizations": organization_summary,
|
||||||
|
"registry_memberships": registry_membership_summary,
|
||||||
"industrial_certificates": industrial_certificate_summary,
|
"industrial_certificates": industrial_certificate_summary,
|
||||||
"manufacturers": manufacturer_summary,
|
"manufacturers": manufacturer_summary,
|
||||||
"industrial_products": industrial_summary,
|
"industrial_products": industrial_summary,
|
||||||
@@ -685,6 +729,234 @@ class ExchangePackageImportService:
|
|||||||
|
|
||||||
return update_fields
|
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(
|
||||||
|
*,
|
||||||
|
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
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _upsert_industrial_certificates(
|
def _upsert_industrial_certificates(
|
||||||
cls,
|
cls,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from apps.external_data.models import (
|
|||||||
PublicProcurement,
|
PublicProcurement,
|
||||||
)
|
)
|
||||||
from apps.organization.models import Organization
|
from apps.organization.models import Organization
|
||||||
|
from apps.registers.models import RegisterUpload, RegistryMembershipPeriod
|
||||||
from apps.user.models import User
|
from apps.user.models import User
|
||||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -168,6 +169,20 @@ def build_exchange_payload() -> dict[str, list[dict[str, object]]]:
|
|||||||
"registry_number": "prod-001",
|
"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": [
|
"industrial_certificates": [
|
||||||
{
|
{
|
||||||
"organization_inn": "7707083893",
|
"organization_inn": "7707083893",
|
||||||
@@ -326,7 +341,13 @@ class ExchangePackageApiTest(APITestCase):
|
|||||||
self.assertFalse(response.data["result"]["duplicate"])
|
self.assertFalse(response.data["result"]["duplicate"])
|
||||||
self.assertEqual(response.data["result"]["organizations"]["created"], 1)
|
self.assertEqual(response.data["result"]["organizations"]["created"], 1)
|
||||||
self.assertEqual(response.data["result"]["organizations"]["updated"], 1)
|
self.assertEqual(response.data["result"]["organizations"]["updated"], 1)
|
||||||
|
self.assertEqual(
|
||||||
|
response.data["result"]["registry_memberships"]["opened"],
|
||||||
|
2,
|
||||||
|
)
|
||||||
self.assertEqual(Organization.objects.count(), 2)
|
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(IndustrialCertificate.objects.count(), 1)
|
||||||
self.assertEqual(ManufacturerRegistryEntry.objects.count(), 1)
|
self.assertEqual(ManufacturerRegistryEntry.objects.count(), 1)
|
||||||
self.assertEqual(IndustrialProduct.objects.count(), 1)
|
self.assertEqual(IndustrialProduct.objects.count(), 1)
|
||||||
@@ -366,6 +387,10 @@ class ExchangePackageApiTest(APITestCase):
|
|||||||
self.assertEqual(organization.executors_count, 175)
|
self.assertEqual(organization.executors_count, 175)
|
||||||
self.assertTrue(organization.tax_reports_available)
|
self.assertTrue(organization.tax_reports_available)
|
||||||
self.assertTrue(organization.in_275_fz_registry)
|
self.assertTrue(organization.in_275_fz_registry)
|
||||||
|
self.assertEqual(
|
||||||
|
organization.get_active_registry_names(),
|
||||||
|
["Реестр госкорпорации Росатом"],
|
||||||
|
)
|
||||||
|
|
||||||
package_import = ExchangePackageImport.objects.get()
|
package_import = ExchangePackageImport.objects.get()
|
||||||
self.assertEqual(package_import.delivery_channel, ExchangeDeliveryChannel.API)
|
self.assertEqual(package_import.delivery_channel, ExchangeDeliveryChannel.API)
|
||||||
|
|||||||
Reference in New Issue
Block a user