feat: expand registry ingestion and demo exchange
All checks were successful
CI/CD Pipeline / Quality Gate (push) Successful in 36s
CI/CD Pipeline / Build and Push Images (push) Successful in 15s
CI/CD Pipeline / Internal Notify (push) Successful in 0s
CI/CD Pipeline / Deploy Dev via Compose (push) Successful in 24s

This commit is contained in:
2026-07-16 12:59:23 +02:00
parent cc5aa84556
commit 007cecc8d5
43 changed files with 3723 additions and 352 deletions

View File

@@ -0,0 +1,539 @@
"""Deterministic, realistic test-company dataset for frontend demonstrations."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from hashlib import sha256
from uuid import UUID, uuid5
from apps.parsers.models import ParserLoadLog
from django.db.models import Count, Max, Min
from django.utils import timezone
from organizations.cache import invalidate_organization_api_cache
from organizations.models import (
Organization,
OrganizationSourceFinancialLine,
OrganizationSourceRecord,
SourceExtensionStatus,
)
from organizations.source_cache import invalidate_source_data_cache
from organizations.source_groups import SOURCE_GROUP_DESCRIPTORS
TEST_COMPANY_COUNT = 20
TEST_COMPANY_NAMESPACE = UUID("59b36ae9-bcf8-4b7c-b77a-77578f485a01")
TEST_RECORD_PREFIX = "mostovik-test-company"
@dataclass(frozen=True)
class TestCompanyDatasetResult:
"""Counters returned by create and delete operations."""
organizations_created: int = 0
organizations_updated: int = 0
organizations_deleted: int = 0
extensions: int = 0
records: int = 0
class TestCompanyDatasetService:
"""Create, refresh, and remove the fixed frontend demonstration dataset."""
@classmethod
def company_uids(cls) -> list[UUID]:
return [
uuid5(TEST_COMPANY_NAMESPACE, f"company:{index}")
for index in range(1, TEST_COMPANY_COUNT + 1)
]
@classmethod
def create_or_update(cls) -> TestCompanyDatasetResult:
now = timezone.now()
created_count = 0
updated_count = 0
total_extensions = 0
total_records = 0
descriptors_by_group = cls._descriptors_by_group()
for index, uid in enumerate(cls.company_uids(), start=1):
organization, created = Organization.objects.update_or_create(
uid=uid,
defaults=cls._organization_defaults(index=index, now=now),
)
created_count += int(created)
updated_count += int(not created)
extensions = {}
for source_group, descriptors in descriptors_by_group.items():
descriptor = descriptors[0]
extension, _ = descriptor.extension_model.objects.update_or_create(
organization=organization,
defaults={
"title": descriptor.title,
"status": SourceExtensionStatus.ACTIVE,
"first_seen_at": now,
"last_seen_at": now,
"last_load_batch": 9_000_000 + index,
"metadata": {
"dataset": TEST_RECORD_PREFIX,
"sources": [item.source for item in descriptors],
},
},
)
extensions[source_group] = extension
expected_external_ids = []
for source, descriptor in SOURCE_GROUP_DESCRIPTORS.items():
if source not in ParserLoadLog.Source.values:
continue
external_id = f"{TEST_RECORD_PREFIX}:{index:02d}:{source}"
expected_external_ids.append(external_id)
record_defaults = cls._record_defaults(
source=source,
index=index,
organization=organization,
)
record, _ = OrganizationSourceRecord.objects.update_or_create(
source=source,
external_id=external_id,
defaults={
"extension": extensions[descriptor.source_group],
"record_type": descriptor.record_type,
"load_batch": 9_000_000 + index,
**record_defaults,
},
)
if source == ParserLoadLog.Source.FNS_REPORTS:
cls._refresh_financial_lines(record=record, index=index)
OrganizationSourceRecord.objects.filter(
extension__organization=organization,
external_id__startswith=f"{TEST_RECORD_PREFIX}:",
).exclude(external_id__in=expected_external_ids).delete()
cls._refresh_extension_counters(organization=organization)
total_extensions += len(extensions)
total_records += len(expected_external_ids)
cls._invalidate_caches()
return TestCompanyDatasetResult(
organizations_created=created_count,
organizations_updated=updated_count,
extensions=total_extensions,
records=total_records,
)
@classmethod
def delete(cls) -> TestCompanyDatasetResult:
company_uids = cls.company_uids()
queryset = Organization.objects.filter(uid__in=company_uids)
deleted_count = queryset.count()
extension_models = {
descriptor.extension_model
for source, descriptor in SOURCE_GROUP_DESCRIPTORS.items()
if source in ParserLoadLog.Source.values
}
for extension_model in extension_models:
extension_model.objects.filter(organization_id__in=company_uids).delete()
queryset.delete()
cls._invalidate_caches()
return TestCompanyDatasetResult(organizations_deleted=deleted_count)
@staticmethod
def _descriptors_by_group():
descriptors = {}
for source, descriptor in SOURCE_GROUP_DESCRIPTORS.items():
if source in ParserLoadLog.Source.values:
descriptors.setdefault(descriptor.source_group, []).append(descriptor)
return descriptors
@classmethod
def _organization_defaults(cls, *, index: int, now):
is_rosatom = index <= 10
corporation_name = (
'Госкорпорация "Росатом"' if is_rosatom else 'Госкорпорация "Роскосмос"'
)
corporation_ministry = (
'Государственная корпорация по атомной энергии "Росатом"'
if is_rosatom
else 'Государственная корпорация по космической деятельности "Роскосмос"'
)
industry = (
"Атомная промышленность"
if is_rosatom
else "Ракетно-космическая промышленность"
)
name = f"Тестовая компания {index}"
inn = cls._legal_entity_inn(index)
ogrn = cls._legal_entity_ogrn(index)
return {
"rn": 9_900_000 + index,
"gk_code": "2" if is_rosatom else "1",
"gk_name": corporation_name,
"in_korp_code": "1",
"in_korp_name": "Входит в состав",
"name": name,
"full_name": f'Акционерное общество "{name}"',
"short_name": name,
"pn_name": name,
"pn_name_en": f"Test Company {index}",
"inn": inn,
"kpp": f"7709{index:02d}001",
"ogrn": ogrn,
"okpo": f"90{index:06d}",
"filial": ".F.",
"is_branch": False,
"registration_date": date(2010 + index % 10, (index - 1) % 12 + 1, 15),
"create_date": str(2010 + index % 10),
"organizational_legal_form": "12267",
"organizational_legal_form1": "Непубличные акционерные общества",
"ownership_form": "61" if is_rosatom else "16",
"ownership_form1": (
"Собственность государственных корпораций"
if is_rosatom
else "Частная собственность"
),
"authorized_capital": Decimal(1_000_000 + index * 100_000),
"legal_address": (
f"{101000 + index}, г. Москва, Тестовый проезд, д. {index}"
),
"business_act_cod": "4" if is_rosatom else "2",
"business_activity": "Прочая" if is_rosatom else "Производственная",
"general_director": f"Иванов Иван Иванович {index}",
"general_director_tax_id": f"770100{index:06d}",
"appointment_date": date(2024, 1, min(index, 28)),
"akc_fs": "0",
"akc_sf": "0",
"re_za": False,
"re_zasf": False,
"goz_participation": True,
"opk_registry_membership": True,
"ropk_num": f"ТЕСТ-ОПК-{index:04d}",
"ropk_razdel_num": "3" if is_rosatom else "2",
"ropk_razdel_name": (
"Организации, подведомственные и находящиеся в сфере деятельности "
f"{corporation_name}"
),
"min": corporation_ministry,
"dep": "Департамент тестовых данных",
"otr": industry,
"integrated_structure": f"Тестовая интегрированная структура {index}",
"state_sector_code": "2",
"state_sector_name": (
"контролируются РФ косвенно или совместно с организациями госсектора"
),
"directory_source_file_hash": sha256(
f"{TEST_RECORD_PREFIX}:{index}".encode()
).hexdigest(),
"directory_source_row_number": index,
"directory_imported_at": now,
}
@classmethod
def _record_defaults(cls, *, source: str, index: int, organization):
common = {
"inn": organization.inn,
"ogrn": organization.ogrn,
"organisation_name": organization.name,
"source": source,
"load_batch": 9_000_000 + index,
}
url = f"https://example.test/{source}/{index}"
values = {
ParserLoadLog.Source.FNS_REPORTS: {
"title": f"Бухгалтерская отчетность за 2025 год — {organization.name}",
"record_date": "2025",
"status": "processed",
"payload": {
**common,
"file_name": f"fin_test_{organization.ogrn}.xlsx",
"file_hash": sha256(
f"financial:{organization.ogrn}".encode()
).hexdigest(),
"lines_count": 4,
},
},
ParserLoadLog.Source.PROCUREMENTS: cls._procurement_values(
common, index, organization, law="44-ФЗ/223-ФЗ", source=source
),
ParserLoadLog.Source.PROCUREMENTS_44FZ: cls._procurement_values(
common, index, organization, law="44-ФЗ", source=source
),
ParserLoadLog.Source.PROCUREMENTS_223FZ: cls._procurement_values(
common, index, organization, law="223-ФЗ", source=source
),
ParserLoadLog.Source.CONTRACTS: {
"title": f"Контракт на поставку оборудования № {index}",
"record_date": "15.03.2026",
"amount": Decimal(3_000_000 + index * 25_000),
"status": "Исполнение",
"url": url,
"payload": {
**common,
"contract_number": f"КОНТРАКТ-{index:05d}",
"subject": "Поставка испытательного оборудования",
"customer": organization.name,
"price": str(3_000_000 + index * 25_000),
"law": "44-ФЗ",
"status": "Исполнение",
"url": url,
},
},
ParserLoadLog.Source.INDUSTRIAL: {
"title": f"Сертификат промышленного производства {index:05d}/26",
"record_date": "15.01.2026",
"url": url,
"payload": {
**common,
"certificate_number": f"{index:05d}/26",
"issue_date": "15.01.2026",
"issue_date_normalized": "2026-01-15",
"expiry_date": "15.01.2029",
"expiry_date_normalized": "2029-01-15",
"certificate_file_url": url,
},
},
ParserLoadLog.Source.INDUSTRIAL_PRODUCTS: {
"title": f"Испытательный комплекс ТК-{index}",
"payload": {
**common,
"full_organisation_name": organization.full_name,
"registry_number": f"ПРОД-{index:06d}",
"product_name": f"Испытательный комплекс ТК-{index}",
"product_model": f"ТК-{index}.2026",
"okpd2_code": "28.99.39.190",
"tnved_code": "8479899707",
"regulatory_document": "ТУ 28.99.39-001-2026",
},
},
ParserLoadLog.Source.MANUFACTURES: {
"title": organization.full_name,
"payload": {
**common,
"full_legal_name": organization.full_name,
"address": organization.legal_address,
},
},
ParserLoadLog.Source.INSPECTIONS: {
"title": f"Плановая выездная проверка {organization.name}",
"record_date": "01.06.2026",
"status": "Запланирована",
"payload": {
**common,
"registration_number": f"24800000{index:04d}",
"control_authority": "Ростехнадзор",
"inspection_type": "scheduled",
"inspection_form": "documentary_and_on_site",
"legal_basis": "Федеральный закон № 248-ФЗ",
"start_date": "01.06.2026",
"start_date_normalized": "2026-06-01",
"end_date": "15.06.2026",
"end_date_normalized": "2026-06-15",
"data_year": 2026,
"data_month": 6,
"status": "Запланирована",
},
},
ParserLoadLog.Source.FEDRESURS_BANKRUPTCY: {
"title": f"Сообщение по делу А40-{10000 + index}/2026",
"record_date": "01.05.2026",
"status": "Наблюдение",
"url": url,
"payload": {
**common,
"case_number": f"А40-{10000 + index}/2026",
"message_type": "Введение наблюдения",
"message_date": "01.05.2026",
"messages_count": 3,
"messages": [
{
"type": "Введение наблюдения",
"date": "01.05.2026",
}
],
"url": url,
},
},
ParserLoadLog.Source.UNFAIR_SUPPLIERS: {
"title": f"Реестровая запись РНП-{index:05d}",
"record_date": "20.01.2026",
"status": "Включен",
"url": url,
"payload": {
**common,
"supplier": organization.name,
"registry_number": f"РНП-{index:05d}",
"included_at": "20.01.2026",
"reason": "Нарушение условий закупки",
"url": url,
},
},
ParserLoadLog.Source.FAS_GOZ: {
"title": f"Постановление ФАС по ГОЗ № {index}",
"record_date": "20.01.2026",
"status": "Исполнено",
"payload": {
**common,
"registry_number": str(index),
"authority": "ФАС России",
"decision": "Постановление по ГОЗ",
"decision_date": "20.01.2026",
"execution_status": "Исполнено",
},
},
ParserLoadLog.Source.ARBITRATION: {
"title": f"Дело А40-{20000 + index}/2026",
"record_date": "10.02.2026",
"amount": Decimal(500_000 + index * 10_000),
"status": "В производстве",
"url": url,
"payload": {
**common,
"case_number": f"А40-{20000 + index}/2026",
"court": "Арбитражный суд города Москвы",
"role": "ответчик",
"claim_amount": str(500_000 + index * 10_000),
"status": "В производстве",
"url": url,
},
},
ParserLoadLog.Source.FSTEC: {
"title": f"Сертификат ФСТЭК № ТЕСТ-{index:04d}",
"record_date": "15.01.2026",
"status": "Действует",
"payload": {
**common,
"licensee": organization.name,
"registry_number": f"ТЕСТ-{index:04d}",
"issued_at": "15.01.2026",
"status": "Действует",
"№ сертификата": f"ТЕСТ-{index:04d}",
"Заявитель": organization.name,
"Дата внесения в реестр": "15.01.2026",
"Срок действия сертификата": "15.01.2029",
},
},
ParserLoadLog.Source.TRUDVSEM: {
"title": f"Инженер-испытатель {index} категории",
"record_date": "20.05.2026",
"status": "Открыта",
"url": url,
"payload": {
**common,
"id": f"VAC-{index:05d}",
"company": organization.name,
"job-name": f"Инженер-испытатель {index} категории",
"creation-date": "2026-05-20",
"salary": "120000.00",
"salary_min": 110000,
"salary_max": 140000,
"currency": "RUB",
"employment": "Полная занятость",
"schedule": "Полный день",
"region": "Москва",
"status": "Открыта",
"vacancy_source": "trudvsem",
"vac_url": url,
},
},
}
return values[source]
@staticmethod
def _procurement_values(common, index, organization, *, law: str, source: str):
purchase_number = f"03732000000{index:08d}"[:19]
amount = Decimal(1_250_000 + index * 50_000)
url = f"https://example.test/{source}/{index}"
return {
"title": f"Поставка оборудования для {organization.name}",
"record_date": "01.02.2026",
"amount": amount,
"status": "Размещено",
"url": url,
"payload": {
**common,
"purchase_number": purchase_number,
"subject": "Поставка испытательного оборудования",
"customer": organization.name,
"customer_inn": organization.inn,
"price": str(amount),
"law": law,
"published_at": "01.02.2026",
"status": "Размещено",
"region_code": "77",
"data_year": 2026,
"data_month": 2,
"url": url,
},
}
@staticmethod
def _refresh_financial_lines(*, record, index: int) -> None:
expected = {
("1", "1600", "Баланс (актив)", 10_000_000 + index * 100_000),
("1", "1300", "Капитал и резервы", 4_000_000 + index * 50_000),
("2", "2110", "Выручка", 20_000_000 + index * 200_000),
("2", "2400", "Чистая прибыль", 2_000_000 + index * 25_000),
}
expected_keys = []
for form_code, line_code, line_name, period_end in expected:
expected_keys.append((form_code, line_code, 2025))
OrganizationSourceFinancialLine.objects.update_or_create(
source_record=record,
form_code=form_code,
line_code=line_code,
year=2025,
defaults={
"line_name": line_name,
"period_start": period_end - 100_000,
"period_end": period_end,
},
)
lines = OrganizationSourceFinancialLine.objects.filter(source_record=record)
for line in lines:
key = (line.form_code, line.line_code, line.year)
if key not in expected_keys:
line.delete()
@staticmethod
def _refresh_extension_counters(*, organization) -> None:
aggregates = {
item["extension_id"]: item
for item in OrganizationSourceRecord.objects.filter(
extension__organization=organization
)
.values("extension_id")
.annotate(
records_count=Count("uid"),
first_seen_at=Min("created_at"),
last_seen_at=Max("updated_at"),
)
}
for extension in organization.source_extensions.all():
aggregate = aggregates.get(extension.uid, {})
extension.records_count = aggregate.get("records_count", 0)
extension.first_seen_at = aggregate.get("first_seen_at")
extension.last_seen_at = aggregate.get("last_seen_at")
extension.save(
update_fields=["records_count", "first_seen_at", "last_seen_at"]
)
@staticmethod
def _legal_entity_inn(index: int) -> str:
base = f"770900{index:03d}"
weights = (2, 4, 10, 3, 5, 9, 4, 6, 8)
checksum = sum(
int(digit) * weight for digit, weight in zip(base, weights, strict=True)
)
return f"{base}{checksum % 11 % 10}"
@staticmethod
def _legal_entity_ogrn(index: int) -> str:
base = f"1267700{index:05d}"
return f"{base}{int(base) % 11 % 10}"
@staticmethod
def _invalidate_caches() -> None:
invalidate_organization_api_cache()
invalidate_source_data_cache()