diff --git a/src/apps/exchange/management/__init__.py b/src/apps/exchange/management/__init__.py new file mode 100644 index 0000000..2fbf066 --- /dev/null +++ b/src/apps/exchange/management/__init__.py @@ -0,0 +1 @@ +"""Management package for exchange app.""" diff --git a/src/apps/exchange/management/commands/__init__.py b/src/apps/exchange/management/commands/__init__.py new file mode 100644 index 0000000..133099d --- /dev/null +++ b/src/apps/exchange/management/commands/__init__.py @@ -0,0 +1 @@ +"""Exchange management commands.""" diff --git a/src/apps/exchange/management/commands/export_state_corp_exchange_package.py b/src/apps/exchange/management/commands/export_state_corp_exchange_package.py new file mode 100644 index 0000000..39371b1 --- /dev/null +++ b/src/apps/exchange/management/commands/export_state_corp_exchange_package.py @@ -0,0 +1,52 @@ +"""Export encrypted package for manual delivery to state-corp.""" + +from __future__ import annotations + +import json + +from apps.core.management.commands.base import BaseAppCommand +from apps.exchange.state_corp_services import StateCorpExchangeService + + +class Command(BaseAppCommand): + """Build package and save it to disk.""" + + help = "Экспортирует зашифрованный пакет обмена для state-corp в .zip файл" + + def add_arguments(self, parser) -> None: + super().add_arguments(parser) + parser.add_argument("output_path", type=str, help="Куда сохранить .zip пакет") + parser.add_argument( + "--package-id", type=str, default="", help="Явный package_id" + ) + parser.add_argument( + "--organization-inn", + action="append", + dest="organization_inns", + default=[], + help="Ограничить экспорт конкретными ИНН. Параметр можно повторять.", + ) + + def execute_command(self, *args, **options) -> str: + package = StateCorpExchangeService.build_package( + package_id=options["package_id"] or None, + organization_inns=options["organization_inns"], + ) + output_path = StateCorpExchangeService.save_package( + package=package, + output_path=options["output_path"], + ) + rendered = json.dumps( + { + "package_id": package.package_id, + "archive_name": package.archive_name, + "output_path": str(output_path), + "payload_counts": package.payload_counts, + "produced_at": package.produced_at, + }, + ensure_ascii=False, + indent=2, + sort_keys=True, + ) + self.log_success(rendered) + return rendered diff --git a/src/apps/exchange/management/commands/push_state_corp_exchange_package.py b/src/apps/exchange/management/commands/push_state_corp_exchange_package.py new file mode 100644 index 0000000..d0794fe --- /dev/null +++ b/src/apps/exchange/management/commands/push_state_corp_exchange_package.py @@ -0,0 +1,74 @@ +"""Push encrypted package to state-corp exchange endpoint.""" + +from __future__ import annotations + +import json + +from apps.core.management.commands.base import BaseAppCommand +from apps.exchange.state_corp_services import StateCorpExchangeService + + +class Command(BaseAppCommand): + """Build and send package to state-corp.""" + + help = "Собирает и отправляет пакет обмена в state-corp dev endpoint" + + def add_arguments(self, parser) -> None: + super().add_arguments(parser) + parser.add_argument( + "--package-id", type=str, default="", help="Явный package_id" + ) + parser.add_argument( + "--target-url", + type=str, + default="", + help="Override для STATE_CORP_EXCHANGE_URL", + ) + parser.add_argument( + "--token", + type=str, + default="", + help="Override для STATE_CORP_EXCHANGE_TOKEN", + ) + parser.add_argument( + "--save-package", + type=str, + default="", + help="Дополнительно сохранить сформированный пакет в файл", + ) + parser.add_argument( + "--organization-inn", + action="append", + dest="organization_inns", + default=[], + help="Ограничить экспорт конкретными ИНН. Параметр можно повторять.", + ) + + def execute_command(self, *args, **options) -> str: + package = StateCorpExchangeService.build_package( + package_id=options["package_id"] or None, + organization_inns=options["organization_inns"], + ) + saved_path = None + if options["save_package"]: + saved_path = StateCorpExchangeService.save_package( + package=package, + output_path=options["save_package"], + ) + result = StateCorpExchangeService.send_package( + package=package, + target_url=options["target_url"] or None, + token=options["token"] or None, + ) + rendered = json.dumps( + { + **result, + "payload_counts": package.payload_counts, + "saved_path": str(saved_path) if saved_path else None, + }, + ensure_ascii=False, + indent=2, + sort_keys=True, + ) + self.log_success(rendered) + return rendered diff --git a/src/apps/exchange/state_corp_services.py b/src/apps/exchange/state_corp_services.py new file mode 100644 index 0000000..a380b24 --- /dev/null +++ b/src/apps/exchange/state_corp_services.py @@ -0,0 +1,388 @@ +"""Build and deliver exchange packages for state-corp-backend.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import secrets +import struct +import zlib +from dataclasses import dataclass +from datetime import date +from decimal import Decimal +from io import BytesIO +from pathlib import Path +from typing import Any +from uuid import uuid4 +from zipfile import ZIP_DEFLATED, ZipFile + +import requests +from apps.parsers.models import ( + IndustrialProductRecord, + InspectionRecord, + ProcurementRecord, +) +from apps.registers.models import Organization +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from django.conf import settings +from django.utils import timezone + + +class StateCorpExchangeError(ValueError): + """Raised when building or sending state-corp exchange packages fails.""" + + +@dataclass(frozen=True) +class StateCorpExchangePackage: + """Built archive ready for upload or manual delivery.""" + + package_id: str + archive_name: str + bin_name: str + archive_bytes: bytes + payload_counts: dict[str, int] + produced_at: str + + +class StateCorpExchangeService: + """Create and send packages compatible with state-corp exchange import.""" + + MAGIC = b"EXCH" + AAD = b"state-corp-exchange-v1" + PAYLOAD_FORMAT = "state-corp-exchange-payload" + BIN_FORMAT = "state-corp-exchange-bin" + + @classmethod + def build_package( + cls, + *, + package_id: str | None = None, + source_system: str = "mostovik", + organization_inns: list[str] | None = None, + ) -> StateCorpExchangePackage: + """Build encrypted archive for state-corp import.""" + produced_at = timezone.now() + normalized_inns = cls._normalize_inn_list(organization_inns) + organizations = cls._get_organizations(normalized_inns) + allowed_inns = {str(item.mn_inn) for item in organizations} + data = { + "organizations": cls._serialize_organizations(organizations), + "industrial_products": cls._serialize_industrial_products(allowed_inns), + "prosecutor_checks": cls._serialize_prosecutor_checks(allowed_inns), + "public_procurements": cls._serialize_public_procurements(allowed_inns), + "arbitration_cases": [], + } + payload_counts = {key: len(value) for key, value in data.items()} + package_id = package_id or cls._build_package_id() + payload = { + "format": cls.PAYLOAD_FORMAT, + "schema_version": 1, + "manifest": { + "package_id": package_id, + "source_system": source_system, + "produced_at": produced_at.isoformat(), + "sections": [key for key, items in data.items() if items], + }, + "data": data, + } + + archive_name, bin_name, archive_bytes = cls._render_archive( + package_id=package_id, + payload=payload, + produced_at=produced_at, + ) + return StateCorpExchangePackage( + package_id=package_id, + archive_name=archive_name, + bin_name=bin_name, + archive_bytes=archive_bytes, + payload_counts=payload_counts, + produced_at=produced_at.isoformat(), + ) + + @classmethod + def save_package( + cls, + *, + package: StateCorpExchangePackage, + output_path: str | Path, + ) -> Path: + """Persist package to disk for manual transfer.""" + target_path = Path(output_path).expanduser().resolve() + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_bytes(package.archive_bytes) + return target_path + + @classmethod + def send_package( + cls, + *, + package: StateCorpExchangePackage, + target_url: str | None = None, + token: str | None = None, + timeout_seconds: int | None = None, + ) -> dict[str, Any]: + """Send package to state-corp dev endpoint.""" + resolved_url = str(target_url or settings.STATE_CORP_EXCHANGE_URL).strip() + resolved_token = str(token or settings.STATE_CORP_EXCHANGE_TOKEN).strip() + timeout_seconds = timeout_seconds or int( + getattr(settings, "STATE_CORP_EXCHANGE_TIMEOUT_SECONDS", 60) + ) + + if not resolved_url: + raise StateCorpExchangeError("STATE_CORP_EXCHANGE_URL не настроен") + if not resolved_token: + raise StateCorpExchangeError("STATE_CORP_EXCHANGE_TOKEN не настроен") + + try: + response = requests.post( + resolved_url, + files={ + "file": ( + package.archive_name, + package.archive_bytes, + "application/zip", + ) + }, + headers={"X-Exchange-Token": resolved_token}, + timeout=timeout_seconds, + ) + except requests.RequestException as exc: + raise StateCorpExchangeError( + f"Не удалось отправить пакет в state-corp: {exc}" + ) from exc + + try: + body = response.json() + except ValueError: + body = {"raw": response.text} + + if response.status_code >= 400: + raise StateCorpExchangeError( + f"state-corp вернул HTTP {response.status_code}: {body}" + ) + + return { + "status_code": response.status_code, + "response": body, + "package_id": package.package_id, + "archive_name": package.archive_name, + } + + @classmethod + def _build_package_id(cls) -> str: + return f"mostovik-{timezone.now():%Y%m%d%H%M%S}-{uuid4().hex[:8]}" + + @classmethod + def _render_archive( + cls, + *, + package_id: str, + payload: dict[str, Any], + produced_at, + ) -> tuple[str, str, bytes]: + token = str(settings.STATE_CORP_EXCHANGE_TOKEN or "").strip() + if not token: + raise StateCorpExchangeError("STATE_CORP_EXCHANGE_TOKEN не настроен") + + payload_bytes = json.dumps( + payload, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + compressed_payload = zlib.compress(payload_bytes, level=9) + nonce = secrets.token_bytes(12) + encrypted_payload = AESGCM( + hashlib.sha256(token.encode("utf-8")).digest() + ).encrypt( + nonce, + compressed_payload, + cls.AAD, + ) + header = { + "format": cls.BIN_FORMAT, + "version": 1, + "key_id": settings.STATE_CORP_EXCHANGE_KEY_ID, + "nonce": cls._b64url(nonce), + "aad": cls._b64url(cls.AAD), + "package_id": package_id, + "schema_version": 1, + "plaintext_sha256": hashlib.sha256(payload_bytes).hexdigest(), + "compressed_sha256": hashlib.sha256(compressed_payload).hexdigest(), + "ciphertext_sha256": hashlib.sha256(encrypted_payload).hexdigest(), + } + header_bytes = json.dumps( + header, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + bin_bytes = ( + cls.MAGIC + + bytes([1]) + + struct.pack(">I", len(header_bytes)) + + header_bytes + + encrypted_payload + ) + + timestamp = produced_at.strftime("%Y%m%d_%H%M%S") + archive_name = f"state_corp_exchange_{timestamp}.zip" + bin_name = f"state_corp_exchange_{timestamp}.bin" + + archive_bytes = BytesIO() + with ZipFile(archive_bytes, "w", compression=ZIP_DEFLATED) as archive: + archive.writestr(bin_name, bin_bytes) + archive.writestr( + f"{bin_name}.sha256", + f"{hashlib.sha256(bin_bytes).hexdigest()} {bin_name}\n", + ) + + return archive_name, bin_name, archive_bytes.getvalue() + + @classmethod + def _get_organizations(cls, organization_inns: list[str]) -> list[Organization]: + queryset = Organization.objects.all().order_by("id") + if organization_inns: + queryset = queryset.filter( + mn_inn__in=[int(item) for item in organization_inns] + ) + return list(queryset) + + @classmethod + def _serialize_organizations( + cls, + organizations: list[Organization], + ) -> list[dict[str, str]]: + 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_industrial_products( + cls, + allowed_inns: set[str], + ) -> list[dict[str, str]]: + queryset = IndustrialProductRecord.objects.filter( + inn__in=allowed_inns + ).order_by("id") + return [ + { + "organization_inn": cls._digits(record.inn), + "product_name": record.product_name, + "product_class": record.product_model + or record.regulatory_document + or "Промышленная продукция", + "okpd2_code": record.okpd2_code, + "tnved_code": record.tnved_code, + "registry_number": record.registry_number, + } + for record in queryset + ] + + @classmethod + def _serialize_prosecutor_checks( + cls, + allowed_inns: set[str], + ) -> list[dict[str, str]]: + items: list[dict[str, str]] = [] + queryset = InspectionRecord.objects.filter(inn__in=allowed_inns).order_by("id") + for record in queryset: + start_date = cls._coerce_date( + record.start_date_normalized, record.start_date + ) + if start_date is None: + continue + items.append( + { + "organization_inn": cls._digits(record.inn), + "registration_number": record.registration_number, + "law_type": record.legal_basis + or ("248-ФЗ" if record.is_federal_law_248 else "294-ФЗ"), + "control_authority": record.control_authority, + "prosecutor_office": "", + "start_date": start_date.isoformat(), + "status": record.status, + } + ) + return items + + @classmethod + def _serialize_public_procurements( + cls, + allowed_inns: set[str], + ) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + queryset = ProcurementRecord.objects.filter( + customer_inn__in=allowed_inns + ).order_by("id") + for record in queryset: + contract_date = cls._coerce_date( + record.publish_date_normalized, + record.publish_date, + ) + if contract_date is None: + continue + execution_end_date = cls._coerce_date( + record.end_date_normalized, + record.end_date, + ) + items.append( + { + "organization_inn": cls._digits(record.customer_inn), + "purchase_number": record.purchase_number, + "law_type": record.law_type, + "status": record.status, + "contract_amount": cls._serialize_decimal(record.max_price_amount), + "contract_date": contract_date.isoformat(), + "execution_start_date": contract_date.isoformat(), + "execution_end_date": ( + execution_end_date.isoformat() if execution_end_date else None + ), + "purchase_name": record.purchase_name, + } + ) + return items + + @staticmethod + def _normalize_inn_list(organization_inns: list[str] | None) -> list[str]: + normalized_items: list[str] = [] + for item in organization_inns or []: + normalized = "".join(char for char in str(item).strip() if char.isdigit()) + if normalized: + normalized_items.append(normalized) + return normalized_items + + @staticmethod + def _digits(value: str | int) -> str: + return "".join(char for char in str(value).strip() if char.isdigit()) + + @staticmethod + def _b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") + + @classmethod + def _coerce_date( + cls, normalized: date | None, raw_value: str | None + ) -> date | None: + if normalized is not None: + return normalized + raw_text = str(raw_value or "").strip() + if not raw_text: + return None + try: + return date.fromisoformat(raw_text) + except ValueError: + return None + + @staticmethod + def _serialize_decimal(value: Decimal | None) -> str | None: + if value is None: + return None + return format(value, "f") diff --git a/src/settings/base.py b/src/settings/base.py index ae82af0..0527911 100644 --- a/src/settings/base.py +++ b/src/settings/base.py @@ -217,6 +217,14 @@ PROXY_TOOLS_SYNC_INTERVAL_SECONDS = int( ) BACKUP_ENCRYPTION_KEY = os.getenv("BACKUP_ENCRYPTION_KEY", "") BACKUP_KEY_ID = os.getenv("BACKUP_KEY_ID", "default") +STATE_CORP_EXCHANGE_URL = os.getenv("STATE_CORP_EXCHANGE_URL", "").strip() +STATE_CORP_EXCHANGE_TOKEN = os.getenv("STATE_CORP_EXCHANGE_TOKEN", "").strip() +STATE_CORP_EXCHANGE_KEY_ID = os.getenv( + "STATE_CORP_EXCHANGE_KEY_ID", "state-corp-shared-token" +).strip() +STATE_CORP_EXCHANGE_TIMEOUT_SECONDS = int( + os.getenv("STATE_CORP_EXCHANGE_TIMEOUT_SECONDS", "60") +) BACKUP_EXPORT_DIRECTORY = os.getenv( "BACKUP_EXPORT_DIRECTORY", str(PROJECT_ROOT / "media" / "backups"), diff --git a/src/settings/dev.py b/src/settings/dev.py index f49306a..c5dac60 100644 --- a/src/settings/dev.py +++ b/src/settings/dev.py @@ -10,6 +10,14 @@ SECRET_KEY = "django-insecure-development-key-mostovik-2024" DEBUG = True ALLOWED_HOSTS = ["*"] OPENAPI_USE_ENGLISH_TAGS = True +STATE_CORP_EXCHANGE_URL = os.getenv( + "STATE_CORP_EXCHANGE_URL", + "http://127.0.0.1:8001/api/v1/exchange/packages/upload/", +) +STATE_CORP_EXCHANGE_TOKEN = os.getenv( + "STATE_CORP_EXCHANGE_TOKEN", + "state-corp-dev-exchange-token-v1", +) # JWT SIMPLE_JWT["SIGNING_KEY"] = SECRET_KEY diff --git a/src/settings/test.py b/src/settings/test.py index 7023c3c..c1bef6d 100644 --- a/src/settings/test.py +++ b/src/settings/test.py @@ -7,6 +7,8 @@ from .base import * SECRET_KEY = "django-insecure-test-key-only-for-testing" DEBUG = True STARTUP_CHECKS_ENABLED = False +STATE_CORP_EXCHANGE_URL = "http://state-corp.test/api/v1/exchange/packages/upload/" +STATE_CORP_EXCHANGE_TOKEN = "state-corp-test-exchange-token" # JWT SIMPLE_JWT["SIGNING_KEY"] = SECRET_KEY diff --git a/tests/apps/exchange/test_state_corp_services.py b/tests/apps/exchange/test_state_corp_services.py new file mode 100644 index 0000000..2902fce --- /dev/null +++ b/tests/apps/exchange/test_state_corp_services.py @@ -0,0 +1,146 @@ +"""Tests for state-corp package build and delivery from mostovik.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import struct +import zlib +from io import BytesIO +from unittest.mock import Mock, patch +from zipfile import ZipFile + +from apps.exchange.state_corp_services import StateCorpExchangeService +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from django.test import TestCase, override_settings + +from tests.apps.parsers.factories import ( + IndustrialProductRecordFactory, + InspectionRecordFactory, + ProcurementRecordFactory, +) +from tests.apps.registers.factories import OrganizationFactory + + +def _b64url_decode(value: str) -> bytes: + normalized = value + ("=" * (-len(value) % 4)) + return base64.urlsafe_b64decode(normalized.encode("ascii")) + + +TEST_STATE_CORP_TOKEN = "state-corp-test-exchange-token" # noqa: S105 + + +@override_settings( + STATE_CORP_EXCHANGE_TOKEN=TEST_STATE_CORP_TOKEN, + STATE_CORP_EXCHANGE_URL="http://state-corp.test/api/v1/exchange/packages/upload/", +) +class StateCorpExchangeServiceTest(TestCase): + """Verify package compatibility with state-corp receiver contract.""" + + def test_build_package_contains_expected_payload(self): + organization = OrganizationFactory.create( + mn_inn=7707083893, + mn_ogrn=1027700132195, + pn_name="АО Альфа", + in_kpp=770701001, + mn_okpo="12345678", + ) + IndustrialProductRecordFactory.create( + inn=str(organization.mn_inn), + ogrn=str(organization.mn_ogrn), + full_organisation_name=organization.pn_name, + registry_number="prod-001", + product_name="Система связи М-1", + product_model="Связь", + registry_organization=organization, + ) + InspectionRecordFactory.create( + inn=str(organization.mn_inn), + ogrn=str(organization.mn_ogrn), + organisation_name=organization.pn_name, + registration_number="insp-001", + control_authority="Минпромторг", + legal_basis="294-ФЗ", + start_date="2026-03-10", + start_date_normalized="2026-03-10", + status="active", + registry_organization=organization, + ) + ProcurementRecordFactory.create( + customer_inn=str(organization.mn_inn), + customer_ogrn=str(organization.mn_ogrn), + customer_name=organization.pn_name, + purchase_number="purchase-001", + purchase_name="Поставка оборудования", + law_type="223-ФЗ", + status="executing", + publish_date="2026-02-15", + publish_date_normalized="2026-02-15", + end_date="2026-11-30", + end_date_normalized="2026-11-30", + max_price_amount="4500000.75", + registry_organization=organization, + ) + + package = StateCorpExchangeService.build_package() + self.assertEqual(package.payload_counts["organizations"], 1) + self.assertEqual(package.payload_counts["industrial_products"], 1) + self.assertEqual(package.payload_counts["prosecutor_checks"], 1) + self.assertEqual(package.payload_counts["public_procurements"], 1) + + with ZipFile(BytesIO(package.archive_bytes)) as archive: + bin_names = [name for name in archive.namelist() if name.endswith(".bin")] + self.assertEqual(len(bin_names), 1) + bin_bytes = archive.read(bin_names[0]) + + header_size = struct.unpack(">I", bin_bytes[5:9])[0] + header_end = 9 + header_size + header = json.loads(bin_bytes[9:header_end].decode("utf-8")) + encrypted_payload = bin_bytes[header_end:] + raw_key = hashlib.sha256(TEST_STATE_CORP_TOKEN.encode("utf-8")).digest() + compressed_payload = AESGCM(raw_key).decrypt( + _b64url_decode(header["nonce"]), + encrypted_payload, + _b64url_decode(header["aad"]), + ) + payload = json.loads(zlib.decompress(compressed_payload).decode("utf-8")) + + self.assertEqual(payload["format"], StateCorpExchangeService.PAYLOAD_FORMAT) + self.assertEqual(payload["manifest"]["source_system"], "mostovik") + self.assertEqual(payload["data"]["organizations"][0]["inn"], "7707083893") + self.assertEqual( + payload["data"]["industrial_products"][0]["registry_number"], + "prod-001", + ) + self.assertEqual( + payload["data"]["prosecutor_checks"][0]["registration_number"], + "insp-001", + ) + self.assertEqual( + payload["data"]["public_procurements"][0]["purchase_number"], + "purchase-001", + ) + self.assertEqual(payload["data"]["arbitration_cases"], []) + + @patch("apps.exchange.state_corp_services.requests.post") + def test_send_package_posts_multipart_archive(self, post_mock): + response_mock = Mock(status_code=201) + response_mock.json.return_value = { + "message": "ok", + "result": {"duplicate": False}, + } + post_mock.return_value = response_mock + + package = StateCorpExchangeService.build_package() + result = StateCorpExchangeService.send_package(package=package) + + self.assertEqual(result["status_code"], 201) + self.assertEqual(result["response"]["result"]["duplicate"], False) + post_mock.assert_called_once() + _, kwargs = post_mock.call_args + self.assertEqual( + kwargs["headers"]["X-Exchange-Token"], + TEST_STATE_CORP_TOKEN, + ) + self.assertEqual(kwargs["files"]["file"][0], package.archive_name)