feat: expand registry ingestion and demo exchange
This commit is contained in:
@@ -7,6 +7,7 @@ import time
|
||||
from django.core.cache import cache
|
||||
|
||||
ORGANIZATION_API_CACHE_PREFIX = "api:v2:organizations"
|
||||
ORGANIZATION_API_CACHE_CONTRACT_VERSION = 2
|
||||
ORGANIZATION_API_CACHE_VERSION_KEY = f"{ORGANIZATION_API_CACHE_PREFIX}:version"
|
||||
DEFAULT_ORGANIZATION_API_CACHE_VERSION = 1
|
||||
DEFAULT_ORGANIZATION_API_CACHE_TIMEOUT_SECONDS = 24 * 60 * 60
|
||||
|
||||
@@ -45,7 +45,6 @@ class OrganizationFilter(filters.FilterSet):
|
||||
)
|
||||
registry = filters.CharFilter(method="filter_registry")
|
||||
registry_name = filters.CharFilter(method="filter_registry_name")
|
||||
has_registry = filters.BooleanFilter(method="filter_has_registry")
|
||||
source_group = filters.CharFilter(method="filter_source_group")
|
||||
|
||||
has_financial_indicators = filters.BooleanFilter(method="filter_source_presence")
|
||||
@@ -83,7 +82,6 @@ class OrganizationFilter(filters.FilterSet):
|
||||
"identity_status",
|
||||
"registry",
|
||||
"registry_name",
|
||||
"has_registry",
|
||||
"source_group",
|
||||
]
|
||||
|
||||
@@ -93,9 +91,6 @@ class OrganizationFilter(filters.FilterSet):
|
||||
def filter_registry_name(self, queryset, _name, value):
|
||||
return self._filter_by_registry_membership(queryset, registry_name=value)
|
||||
|
||||
def filter_has_registry(self, queryset, _name, value):
|
||||
return self._filter_by_registry_membership(queryset, has_registry=value)
|
||||
|
||||
def filter_source_group(self, queryset, _name, value):
|
||||
source_group = SOURCE_FILTER_ALIASES.get(str(value), str(value))
|
||||
return self._filter_by_source_group(queryset, source_group, True)
|
||||
@@ -123,15 +118,12 @@ class OrganizationFilter(filters.FilterSet):
|
||||
*,
|
||||
registry_id: str | None = None,
|
||||
registry_name: str | None = None,
|
||||
has_registry: bool = True,
|
||||
):
|
||||
query = cls._registry_directory_query(
|
||||
registry_id=registry_id,
|
||||
registry_name=registry_name,
|
||||
)
|
||||
if has_registry:
|
||||
return queryset.filter(query)
|
||||
return queryset.exclude(query)
|
||||
return queryset.filter(query)
|
||||
|
||||
@staticmethod
|
||||
def _registry_directory_query(
|
||||
@@ -139,11 +131,7 @@ class OrganizationFilter(filters.FilterSet):
|
||||
registry_id: str | None = None,
|
||||
registry_name: str | None = None,
|
||||
) -> Q:
|
||||
query = (
|
||||
Q(opk_registry_membership=True)
|
||||
| Q(goz_participation=True)
|
||||
| ~Q(ropk_num="")
|
||||
)
|
||||
query = Q(opk_registry_membership=True)
|
||||
if registry_id:
|
||||
query &= Q(ropk_razdel_num=str(registry_id)) | Q(gk_code=str(registry_id))
|
||||
if registry_name:
|
||||
@@ -153,20 +141,3 @@ class OrganizationFilter(filters.FilterSet):
|
||||
| Q(business_activity__icontains=registry_name)
|
||||
)
|
||||
return query
|
||||
|
||||
@staticmethod
|
||||
def _registry_identity_value_querysets(
|
||||
*,
|
||||
registry_id: str | None = None,
|
||||
registry_name: str | None = None,
|
||||
):
|
||||
organizations = Organization.objects.filter(
|
||||
OrganizationFilter._registry_directory_query(
|
||||
registry_id=registry_id,
|
||||
registry_name=registry_name,
|
||||
)
|
||||
)
|
||||
return (
|
||||
organizations.exclude(inn="").values_list("inn", flat=True),
|
||||
organizations.exclude(ogrn="").values_list("ogrn", flat=True),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Create or refresh the fixed frontend test-company dataset."""
|
||||
|
||||
import json
|
||||
|
||||
from apps.core.management.commands.base import BaseAppCommand
|
||||
|
||||
from organizations.test_companies import TestCompanyDatasetService
|
||||
|
||||
|
||||
class Command(BaseAppCommand):
|
||||
"""Create twenty deterministic organizations with every source dataset."""
|
||||
|
||||
help = "Создает или обновляет 20 тестовых компаний со всеми наборами данных"
|
||||
use_transaction = True
|
||||
|
||||
def execute_command(self, *args, **options) -> str:
|
||||
result = TestCompanyDatasetService.create_or_update()
|
||||
rendered = json.dumps(
|
||||
{
|
||||
"organizations_created": result.organizations_created,
|
||||
"organizations_updated": result.organizations_updated,
|
||||
"extensions": result.extensions,
|
||||
"records": result.records,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
)
|
||||
self.log_success(rendered)
|
||||
return rendered
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Delete the fixed frontend test-company dataset."""
|
||||
|
||||
import json
|
||||
|
||||
from apps.core.management.commands.base import BaseAppCommand
|
||||
|
||||
from organizations.test_companies import TestCompanyDatasetService
|
||||
|
||||
|
||||
class Command(BaseAppCommand):
|
||||
"""Delete only the twenty deterministic test organizations."""
|
||||
|
||||
help = "Удаляет 20 фиксированных тестовых компаний и связанные данные"
|
||||
use_transaction = True
|
||||
|
||||
def execute_command(self, *args, **options) -> str:
|
||||
result = TestCompanyDatasetService.delete()
|
||||
rendered = json.dumps(
|
||||
{"organizations_deleted": result.organizations_deleted},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
)
|
||||
self.log_success(rendered)
|
||||
return rendered
|
||||
539
src/organizations/test_companies.py
Normal file
539
src/organizations/test_companies.py
Normal 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()
|
||||
@@ -29,6 +29,7 @@ from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||
|
||||
from organizations.cache import (
|
||||
DEFAULT_ORGANIZATION_API_CACHE_TIMEOUT_SECONDS,
|
||||
ORGANIZATION_API_CACHE_CONTRACT_VERSION,
|
||||
ORGANIZATION_API_CACHE_PREFIX,
|
||||
get_organization_api_cache_version,
|
||||
invalidate_organization_api_cache,
|
||||
@@ -56,7 +57,6 @@ from organizations.serializers import (
|
||||
from organizations.source_record_export import build_source_records_export_archive
|
||||
|
||||
ORGANIZATIONS_TAG = swagger_tag("Организации", "Organizations")
|
||||
FALSE_QUERY_VALUES = {"0", "false", "no", "off"}
|
||||
|
||||
|
||||
def _query_parameter(
|
||||
@@ -80,10 +80,6 @@ def _query_parameter(
|
||||
)
|
||||
|
||||
|
||||
def _is_truthy_query_value(value: str) -> bool:
|
||||
return value.strip().lower() not in FALSE_QUERY_VALUES
|
||||
|
||||
|
||||
SOURCE_GROUP_VALUES = [choice.value for choice in SourceGroup]
|
||||
|
||||
ORGANIZATION_LIST_PARAMS = [
|
||||
@@ -129,15 +125,6 @@ ORGANIZATION_LIST_PARAMS = [
|
||||
"registry_name",
|
||||
description="Фильтр по части наименования реестра.",
|
||||
),
|
||||
_query_parameter(
|
||||
"has_registry",
|
||||
description=(
|
||||
"Фильтр наличия активного участия в любом реестре; по умолчанию true "
|
||||
"для list endpoint, если параметр не передан."
|
||||
),
|
||||
param_type=openapi.TYPE_BOOLEAN,
|
||||
default=True,
|
||||
),
|
||||
_query_parameter(
|
||||
"source_group",
|
||||
description="Фильтр по группе источников организации.",
|
||||
@@ -180,11 +167,6 @@ SOURCE_RECORD_LIST_PARAMS = [
|
||||
),
|
||||
_query_parameter("source", description="Фильтр по legacy source внутри группы."),
|
||||
_query_parameter("record_type", description="Фильтр по типу записи."),
|
||||
_query_parameter(
|
||||
"has_registry",
|
||||
description="Фильтр наличия активного участия организации записи в любом реестре.",
|
||||
param_type=openapi.TYPE_BOOLEAN,
|
||||
),
|
||||
_query_parameter(
|
||||
"organization",
|
||||
description="UID организации.",
|
||||
@@ -228,6 +210,7 @@ class CachedReadOnlyMixin:
|
||||
|
||||
cache_version = get_organization_api_cache_version()
|
||||
raw_key = (
|
||||
f"c{ORGANIZATION_API_CACHE_CONTRACT_VERSION}:"
|
||||
f"v{cache_version}:{request.method}:"
|
||||
f"{request.get_full_path()}:{user_marker}"
|
||||
)
|
||||
@@ -287,18 +270,12 @@ class OrganizationViewSet(CachedReadOnlyMixin, ReadOnlyModelViewSet):
|
||||
return super().get_permissions()
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset().prefetch_related("source_extensions")
|
||||
if self.action != "list" or "has_registry" in self.request.query_params:
|
||||
return queryset
|
||||
|
||||
filterset = OrganizationFilter(
|
||||
data={"has_registry": "true"},
|
||||
queryset=queryset,
|
||||
request=self.request,
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(opk_registry_membership=True)
|
||||
.prefetch_related("source_extensions")
|
||||
)
|
||||
if filterset.is_valid():
|
||||
return filterset.qs
|
||||
return queryset
|
||||
|
||||
@swagger_auto_schema(
|
||||
tags=[ORGANIZATIONS_TAG],
|
||||
@@ -306,8 +283,8 @@ class OrganizationViewSet(CachedReadOnlyMixin, ReadOnlyModelViewSet):
|
||||
operation_summary="Список организаций",
|
||||
operation_description=(
|
||||
"Возвращает канонический справочник организаций API v2. "
|
||||
"По умолчанию показывает только организации с активным участием "
|
||||
"в реестрах; передайте has_registry=false, чтобы снять это ограничение. "
|
||||
"Контур frontend всегда ограничен организациями, для которых "
|
||||
"opk_registry_membership=true; снять это ограничение query-параметром нельзя. "
|
||||
"Данные источников возвращаются компактным списком sources; детальные "
|
||||
"записи доступны через endpoints расширений источников."
|
||||
),
|
||||
@@ -435,11 +412,13 @@ class OrganizationViewSet(CachedReadOnlyMixin, ReadOnlyModelViewSet):
|
||||
class OrganizationSourceExtensionViewSet(ReadOnlyModelViewSet):
|
||||
"""Read-only API for source extensions and their records."""
|
||||
|
||||
queryset = OrganizationSourceExtension.objects.select_related(
|
||||
"organization"
|
||||
).order_by(
|
||||
"organization__name",
|
||||
"source_group",
|
||||
queryset = (
|
||||
OrganizationSourceExtension.objects.select_related("organization")
|
||||
.filter(organization__opk_registry_membership=True)
|
||||
.order_by(
|
||||
"organization__name",
|
||||
"source_group",
|
||||
)
|
||||
)
|
||||
serializer_class = OrganizationSourceExtensionSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
@@ -488,6 +467,7 @@ class OrganizationSourceRecordViewSet(ReadOnlyModelViewSet):
|
||||
"extension",
|
||||
"extension__organization",
|
||||
)
|
||||
.filter(extension__organization__opk_registry_membership=True)
|
||||
.prefetch_related("financial_lines")
|
||||
.order_by("-created_at", "-uid")
|
||||
)
|
||||
@@ -542,7 +522,6 @@ class OrganizationSourceRecordViewSet(ReadOnlyModelViewSet):
|
||||
source = params.get("source")
|
||||
record_type = params.get("record_type")
|
||||
organization = params.get("organization")
|
||||
has_registry = params.get("has_registry")
|
||||
search_terms = SearchFilter().get_search_terms(self.request)
|
||||
|
||||
if source_group:
|
||||
@@ -553,30 +532,11 @@ class OrganizationSourceRecordViewSet(ReadOnlyModelViewSet):
|
||||
queryset = queryset.filter(record_type=record_type)
|
||||
if organization:
|
||||
queryset = queryset.filter(extension__organization_id=organization)
|
||||
if has_registry is not None:
|
||||
registry_query = self._registry_membership_query()
|
||||
if _is_truthy_query_value(has_registry):
|
||||
queryset = queryset.filter(registry_query)
|
||||
else:
|
||||
queryset = queryset.exclude(registry_query)
|
||||
if search_terms:
|
||||
queryset = self._filter_search_queryset(queryset, search_terms)
|
||||
|
||||
return queryset
|
||||
|
||||
@staticmethod
|
||||
def _registry_membership_query():
|
||||
(
|
||||
inn_values,
|
||||
ogrn_values,
|
||||
) = OrganizationFilter._registry_identity_value_querysets()
|
||||
|
||||
return (
|
||||
Q(extension__organization__inn__in=inn_values)
|
||||
| Q(extension__organization__ogrn__in=ogrn_values)
|
||||
| Q(extension__organization__ogrip__in=ogrn_values)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _filter_search_queryset(cls, queryset, search_terms: list[str]):
|
||||
queryset = queryset.annotate(
|
||||
|
||||
Reference in New Issue
Block a user