Add state-corp exchange package sender
All checks were successful
CI/CD Pipeline / Code Quality Checks (push) Successful in 1m54s
CI/CD Pipeline / Run Tests (push) Successful in 3m35s
CI/CD Pipeline / Run API Inventory E2E Tests (push) Successful in 50s
CI/CD Pipeline / Telegram Notify Success (push) Successful in 19s

This commit is contained in:
2026-04-07 16:53:06 +02:00
parent e306ad8f0f
commit 898e492538
9 changed files with 680 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Management package for exchange app."""

View File

@@ -0,0 +1 @@
"""Exchange management commands."""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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