diff --git a/src/apps/core/admin_dashboard.py b/src/apps/core/admin_dashboard.py index 6032f77..47d0620 100644 --- a/src/apps/core/admin_dashboard.py +++ b/src/apps/core/admin_dashboard.py @@ -5,6 +5,10 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any +from django.db import models +from django.db.models import Count, Max +from django.urls import NoReverseMatch, reverse + from apps.form_1.models import FormF1Record from apps.form_2.models import FormF2Record from apps.form_3.models import FormF3Record @@ -13,9 +17,6 @@ from apps.form_5.models import FormF5Record from apps.form_6.models import FormF6Record from apps.organization.models import Organization from apps.registers.models import Register, RegisterUpload, RegistryMembershipPeriod -from django.db import models -from django.db.models import Count, Max -from django.urls import NoReverseMatch, reverse SOURCE_COLORS = ( "#49d0c8", @@ -104,9 +105,9 @@ def build_admin_dashboard() -> dict[str, Any]: .distinct() .count() ) - last_registry_upload_at = RegisterUpload.objects.aggregate(last_upload=Max("created_at"))[ - "last_upload" - ] + last_registry_upload_at = RegisterUpload.objects.aggregate( + last_upload=Max("created_at") + )["last_upload"] organizations_with_data_share = ( round((organizations_with_data / total_organizations) * 100) if total_organizations @@ -145,7 +146,7 @@ def build_admin_dashboard() -> dict[str, Any]: { "label": "Активно в реестрах", "value": _format_int(active_registry_orgs), - "caption": f"{_format_int(total_registers)} реестров, { _format_int(total_registry_uploads) } загрузок снимков.", + "caption": f"{_format_int(total_registers)} реестров, {_format_int(total_registry_uploads)} загрузок снимков.", "tone": "cyan", }, { @@ -191,9 +192,9 @@ def build_admin_dashboard() -> dict[str, Any]: url_name="admin:organization_organization_changelist", ), _build_quick_action( - label="Загрузить реестр", - description="Импорт backup-архива реестров через админку.", - url_name="admin:registers_registerupload_upload_backup", + label="Обмен данными", + description="Импорт единого пакета реестров и данных организаций через админку.", + url_name="admin:exchange_exchangepackageimport_upload_package", ), _build_quick_action( label="Организации и формы", @@ -388,12 +389,9 @@ def _build_period_chart() -> dict[str, Any]: "last_updated_at": row["last_updated_at"], "color": color, } - if ( - row["last_updated_at"] is not None - and ( - period_entry["last_updated_at"] is None - or row["last_updated_at"] > period_entry["last_updated_at"] - ) + if row["last_updated_at"] is not None and ( + period_entry["last_updated_at"] is None + or row["last_updated_at"] > period_entry["last_updated_at"] ): period_entry["last_updated_at"] = row["last_updated_at"] diff --git a/src/apps/core/admin_paper_forms.py b/src/apps/core/admin_paper_forms.py index b3881b9..5358324 100644 --- a/src/apps/core/admin_paper_forms.py +++ b/src/apps/core/admin_paper_forms.py @@ -26,9 +26,7 @@ class PaperFormPreviewRendererMixin: self.paper_form_fieldset_title, { "fields": ["paper_form_preview"], - "description": ( - "Просмотр записи в формате бумажной табличной формы." - ), + "description": ("Просмотр записи в формате бумажной табличной формы."), }, ) return [preview_fieldset, *fieldsets] diff --git a/src/apps/core/exception_handler.py b/src/apps/core/exception_handler.py index 00cda96..94a1e01 100644 --- a/src/apps/core/exception_handler.py +++ b/src/apps/core/exception_handler.py @@ -7,9 +7,6 @@ Converts all exceptions to a unified API response format. import logging from typing import Any -from apps.core.exceptions import BaseAPIException -from apps.core.middleware import get_request_id -from apps.core.response import api_error_response from django.core.exceptions import PermissionDenied from django.http import Http404 from rest_framework import status @@ -17,6 +14,10 @@ from rest_framework.exceptions import APIException from rest_framework.response import Response from rest_framework.views import exception_handler as drf_exception_handler +from apps.core.exceptions import BaseAPIException +from apps.core.middleware import get_request_id +from apps.core.response import api_error_response + logger = logging.getLogger(__name__) diff --git a/src/apps/core/management/commands/generate_test_reports.py b/src/apps/core/management/commands/generate_test_reports.py index 3bde59e..d7f8807 100644 --- a/src/apps/core/management/commands/generate_test_reports.py +++ b/src/apps/core/management/commands/generate_test_reports.py @@ -6,7 +6,15 @@ from dataclasses import dataclass from datetime import date from decimal import Decimal +from django.db.models import Max + from apps.core.management.commands.base import BaseAppCommand +from apps.external_data.models import ( + ArbitrationCase, + IndustrialProduct, + ProsecutorCheck, + PublicProcurement, +) from apps.form_1.models import FormF1Record from apps.form_1.services import FormF1Service from apps.form_2.models import FormF2Record @@ -19,9 +27,8 @@ from apps.form_5.models import FormF5Record from apps.form_5.services import FormF5Service from apps.form_6.models import FormF6Record from apps.form_6.services import FormF6Service -from apps.organization.models import Organization +from apps.organization.models import IndustryCluster, Organization, OrganizationType from apps.registers.models import Register, RegisterUpload, RegistryMembershipPeriod -from django.db.models import Max MONEY_Q = Decimal("0.01") RATE_Q = Decimal("0.01") @@ -99,6 +106,29 @@ F6_CATEGORIES = ( "Литейное", "Сварочное", ) +ORGANIZATION_TYPES = ( + OrganizationType.AO, + OrganizationType.PAO, + OrganizationType.FGUP, + OrganizationType.OOO, +) +CLUSTERS = ( + IndustryCluster.RADIOELECTRONICS, + IndustryCluster.NUCLEAR, + IndustryCluster.SPACE, + IndustryCluster.MACHINE_BUILDING, +) +OWNERSHIP_TYPES = ( + "Собственность государственных корпораций", + "Федеральная собственность", + "Смешанная российская собственность", +) +ACTIVITY_TYPES = ( + "Производство электронных компонентов", + "Производство оборудования специального назначения", + "Научные исследования и разработки", + "Деятельность по управлению холдинг-компаниями", +) @dataclass(frozen=True) @@ -158,7 +188,7 @@ FORM_MODELS = { class Command(BaseAppCommand): """Generate synthetic organizations and reports for all forms.""" - help = "Генерирует тестовые наборы отчетов Ф-1 ... Ф-6" + help = "Очищает старые тестовые данные и генерирует новый набор отчетов Ф-1 ... Ф-6" use_transaction = True def add_arguments(self, parser) -> None: @@ -183,6 +213,7 @@ class Command(BaseAppCommand): if count < 1: self.abort("Параметр --count должен быть положительным числом") + cleanup_stats = self._reset_existing_data() report_plan = self._build_report_plan() generated_orgs = self._prepare_organizations(count=count, prefix=prefix) form_batches = self._resolve_batches(report_plan=report_plan) @@ -194,7 +225,18 @@ class Command(BaseAppCommand): registry_stats = self._create_registry_memberships( organizations=generated_orgs.items, ) + external_data_stats = self._create_external_data_records( + organizations=generated_orgs.items, + ) + self.log_info( + "Очистка старых данных: " + f"организаций {cleanup_stats['organizations_deleted']}, " + f"участий в реестрах {cleanup_stats['memberships_deleted']}, " + f"загрузок реестров {cleanup_stats['uploads_deleted']}, " + f"записей форм {cleanup_stats['form_records_deleted']}, " + f"внешних записей {cleanup_stats['external_records_deleted']}." + ) self.log_info( "Организации: " f"создано {generated_orgs.created}, обновлено {generated_orgs.updated}." @@ -211,12 +253,43 @@ class Command(BaseAppCommand): f"актуальных участий создано {registry_stats['memberships_created']}, " f"загрузок создано {registry_stats['uploads_created']}." ) + self.log_info( + "Внешние данные: " + f"products={external_data_stats['products_created']}, " + f"checks={external_data_stats['checks_created']}, " + f"procurements={external_data_stats['procurements_created']}, " + f"arbitration={external_data_stats['arbitration_created']}." + ) return ( f"Сгенерировано {count} организаций и отчетность за " f"{len(report_plan)} версий периодов." ) + def _reset_existing_data(self) -> dict[str, int]: + """Drop previously generated dataset before rebuilding fixtures.""" + form_records_deleted = sum( + model.objects.count() for model in FORM_MODELS.values() + ) + external_records_deleted = ( + IndustrialProduct.objects.count() + + ProsecutorCheck.objects.count() + + PublicProcurement.objects.count() + + ArbitrationCase.objects.count() + ) + cleanup_stats = { + "organizations_deleted": Organization.objects.count(), + "memberships_deleted": RegistryMembershipPeriod.objects.count(), + "uploads_deleted": RegisterUpload.objects.count(), + "form_records_deleted": form_records_deleted, + "external_records_deleted": external_records_deleted, + } + + Organization.objects.all().delete() + RegisterUpload.objects.all().delete() + + return cleanup_stats + def _build_report_plan(self) -> list[ReportVersionPlan]: today = date.today() current_quarter = ((today.month - 1) // 3) + 1 @@ -227,7 +300,9 @@ class Command(BaseAppCommand): ReportVersionPlan(today.year, current_quarter, 2, 2), ] - def _prepare_organizations(self, *, count: int, prefix: str) -> GeneratedOrganizations: + def _prepare_organizations( + self, *, count: int, prefix: str + ) -> GeneratedOrganizations: organizations: list[Organization] = [] created = 0 updated = 0 @@ -252,7 +327,9 @@ class Command(BaseAppCommand): organizations.append(organization) - return GeneratedOrganizations(items=organizations, created=created, updated=updated) + return GeneratedOrganizations( + items=organizations, created=created, updated=updated + ) def _resolve_batches( self, @@ -396,6 +473,19 @@ class Command(BaseAppCommand): for index, organization in enumerate(organizations, start=1): register = registers[(index - 1) % len(registers)] organizations_by_register[register].append(organization) + if index % 4 == 0: + opk_register = next( + ( + item + for item in registers + if "ОПК" in item.name + and "Росатом" not in item.name + and "Роскосмос" not in item.name + ), + None, + ) + if opk_register is not None and opk_register is not register: + organizations_by_register[opk_register].append(organization) uploads_created = 0 memberships_created = 0 @@ -438,6 +528,94 @@ class Command(BaseAppCommand): "uploads_created": uploads_created, } + def _create_external_data_records( + self, + *, + organizations: list[Organization], + ) -> dict[str, int]: + stats = { + "products_created": 0, + "checks_created": 0, + "procurements_created": 0, + "arbitration_created": 0, + } + + for index, organization in enumerate(organizations, start=1): + product, created = IndustrialProduct.objects.get_or_create( + organization=organization, + registry_number=f"{index:05d}/{date.today().year}", + defaults={ + "product_name": f"Комплекс {NAME_STEMS[(index - 1) % len(NAME_STEMS)]}", + "product_class": "electronics" + if index % 2 == 0 + else "machine_building", + "okpd2_code": f"26.5{index % 10}.{(index % 90) + 10}", + "tnved_code": f"9015.{(index % 90) + 10}", + }, + ) + if created: + stats["products_created"] += 1 + + check, created = ProsecutorCheck.objects.get_or_create( + organization=organization, + registration_number=f"{2_900_000_000 + index:010d}", + defaults={ + "law_type": "294_fz" if index % 2 == 0 else "248_fz", + "control_authority": "Центральное управление Ростехнадзора", + "prosecutor_office": "Московская городская прокуратура", + "start_date": date( + date.today().year - (index % 4), ((index - 1) % 12) + 1, 1 + ), + "status": "planned" if index % 3 else "completed", + }, + ) + if created: + stats["checks_created"] += 1 + + procurement, created = PublicProcurement.objects.get_or_create( + organization=organization, + purchase_number=f"{37_310_000_000_000_0000 + index:019d}", + defaults={ + "law_type": "44_fz" if index % 2 == 0 else "223_fz", + "status": "signing" if index % 5 else "executed", + "contract_amount": self._money( + Decimal(8_000_000 + index * 125_000) + ), + "contract_date": date( + date.today().year - (index % 3), ((index + 1) % 12) + 1, 15 + ), + "execution_start_date": date( + date.today().year, ((index + 1) % 12) + 1, 15 + ), + "execution_end_date": date( + date.today().year + 1, ((index + 5) % 12) + 1, 28 + ), + "purchase_name": ( + "Поставка комплекса защищенной связи для объектов " + f"{SPECIALIZATIONS[(index - 1) % len(SPECIALIZATIONS)]}" + ), + }, + ) + if created: + stats["procurements_created"] += 1 + + arbitration_case, created = ArbitrationCase.objects.get_or_create( + organization=organization, + case_number=f"А40-{16_000 + index}/{date.today().year}", + defaults={ + "court_name": "Арбитражный суд города Москвы", + "party_role": "defendant" if index % 2 == 0 else "plaintiff", + "status": "hearing_scheduled" if index % 4 else "decision_rendered", + "decision_date": date( + date.today().year, ((index + 2) % 12) + 1, 27 + ), + }, + ) + if created: + stats["arbitration_created"] += 1 + + return stats + def _organization_profile(self, index: int) -> OrganizationProfile: industry_index = (index - 1) % len(SPECIALIZATIONS) scale = Decimal("0.85") + Decimal(industry_index) * Decimal("0.11") @@ -478,22 +656,56 @@ class Command(BaseAppCommand): elif pattern_index == 1: base_name = f'АО НПО "{combined_stem}"' elif pattern_index == 2: - base_name = ( - f'АО КБ "{profile.city_stem} {combined_stem}"' - ) + base_name = f'АО КБ "{profile.city_stem} {combined_stem}"' else: - base_name = ( - f'АО Концерн "{combined_stem} системы ' - f'{profile.specialization}"' - ) + base_name = f'АО Концерн "{combined_stem} системы {profile.specialization}"' name = f"{prefix} {base_name}".strip() if prefix else base_name + organization_type = ORGANIZATION_TYPES[(index - 1) % len(ORGANIZATION_TYPES)] + registration_year = date.today().year - 12 + (index % 9) return { "name": name, + "short_name": f"{organization_type.upper()} «{combined_stem}»", + "organization_type": organization_type, + "cluster": CLUSTERS[(index - 1) % len(CLUSTERS)], "inn": f"{9_900_000_000 + index:010d}", "ogrn": f"{102_770_000_0000 + index:013d}", "kpp": f"{770_700_000 + index % 1000:09d}", "okpo": f"{index:08d}", + "registration_date": date( + registration_year, ((index - 1) % 12) + 1, min(28, (index % 27) + 1) + ), + "legal_address": ( + f"{101000 + index}, г. Москва, ул. {profile.city_stem}ская, " + f"д. {(index % 40) + 1}, стр. {(index % 7) + 1}" + ), + "activity_type": ACTIVITY_TYPES[(index - 1) % len(ACTIVITY_TYPES)], + "founder_name": ( + "Госкорпорация «Росатом»" + if index % 2 == 0 + else "Госкорпорация «Роскосмос»" + ), + "ownership_type": OWNERSHIP_TYPES[(index - 1) % len(OWNERSHIP_TYPES)], + "legal_form": profile.legal_form, + "charter_capital_amount": self._money( + Decimal(350_000_000 + index * 7_500_000) + ), + "general_director_name": ( + f"{profile.city_stem} {profile.name_stem}ов " + f"{profile.index % 17 + 1:02d}" + ), + "general_director_inn": f"{5_000_000_000_00 + index:012d}", + "general_director_appointment_date": date( + max(registration_year, date.today().year - 10), + ((index + 3) % 12) + 1, + min(28, ((index + 7) % 27) + 1), + ), + "executors_count": max(24, profile.staff_base // 5), + "financial_reports_available": index % 9 != 0, + "tax_reports_available": index % 7 != 0, + "in_defense_unreliable_suppliers_registry": index % 19 == 0, + "in_275_fz_registry": index % 11 == 0, + "bankruptcy_messages_found": index % 13 == 0, } def _period_base_volume( @@ -502,13 +714,19 @@ class Command(BaseAppCommand): period: ReportVersionPlan, ) -> Decimal: base = Decimal(2_400_000_000 + profile.index * 37_500_000) - year_factor = Decimal("1.00") + Decimal(period.report_year - (date.today().year - 2)) * Decimal("0.08") + year_factor = Decimal("1.00") + Decimal( + period.report_year - (date.today().year - 2) + ) * Decimal("0.08") if period.report_quarter is None: period_factor = Decimal("1.00") else: - period_factor = Decimal("0.22") + Decimal(period.report_quarter) * Decimal("0.04") + period_factor = Decimal("0.22") + Decimal(period.report_quarter) * Decimal( + "0.04" + ) version_factor = Decimal("0.965") if not period.is_final else Decimal("1.00") - return self._money(base * profile.scale * year_factor * period_factor * version_factor) + return self._money( + base * profile.scale * year_factor * period_factor * version_factor + ) def _wear_percent( self, @@ -548,31 +766,67 @@ class Command(BaseAppCommand): staff_base = Decimal(profile.staff_base) return { "military_output_actual": military_output_actual, - "military_domestic_actual": self._money(military_output_actual * Decimal("0.79")), - "military_export_actual": self._money(military_output_actual * Decimal("0.21")), + "military_domestic_actual": self._money( + military_output_actual * Decimal("0.79") + ), + "military_export_actual": self._money( + military_output_actual * Decimal("0.21") + ), "civilian_output_actual": civilian_output_actual, - "civilian_domestic_actual": self._money(civilian_output_actual * Decimal("0.87")), - "civilian_export_actual": self._money(civilian_output_actual * Decimal("0.13")), + "civilian_domestic_actual": self._money( + civilian_output_actual * Decimal("0.87") + ), + "civilian_export_actual": self._money( + civilian_output_actual * Decimal("0.13") + ), "hightech_output_actual": hightech_output_actual, - "hightech_domestic_actual": self._money(hightech_output_actual * Decimal("0.83")), - "hightech_export_actual": self._money(hightech_output_actual * Decimal("0.17")), + "hightech_domestic_actual": self._money( + hightech_output_actual * Decimal("0.83") + ), + "hightech_export_actual": self._money( + hightech_output_actual * Decimal("0.17") + ), "rd_volume_actual": rd_volume_actual, "rd_defense_actual": self._money(rd_volume_actual * Decimal("0.64")), - "military_output_fixed": self._money(military_output_actual * Decimal("0.94")), - "military_domestic_fixed": self._money(military_output_actual * Decimal("0.74")), - "military_export_fixed": self._money(military_output_actual * Decimal("0.20")), - "civilian_output_fixed": self._money(civilian_output_actual * Decimal("0.95")), - "civilian_domestic_fixed": self._money(civilian_output_actual * Decimal("0.82")), - "civilian_export_fixed": self._money(civilian_output_actual * Decimal("0.12")), - "hightech_output_fixed": self._money(hightech_output_actual * Decimal("0.92")), - "hightech_domestic_fixed": self._money(hightech_output_actual * Decimal("0.76")), - "hightech_export_fixed": self._money(hightech_output_actual * Decimal("0.16")), + "military_output_fixed": self._money( + military_output_actual * Decimal("0.94") + ), + "military_domestic_fixed": self._money( + military_output_actual * Decimal("0.74") + ), + "military_export_fixed": self._money( + military_output_actual * Decimal("0.20") + ), + "civilian_output_fixed": self._money( + civilian_output_actual * Decimal("0.95") + ), + "civilian_domestic_fixed": self._money( + civilian_output_actual * Decimal("0.82") + ), + "civilian_export_fixed": self._money( + civilian_output_actual * Decimal("0.12") + ), + "hightech_output_fixed": self._money( + hightech_output_actual * Decimal("0.92") + ), + "hightech_domestic_fixed": self._money( + hightech_output_actual * Decimal("0.76") + ), + "hightech_export_fixed": self._money( + hightech_output_actual * Decimal("0.16") + ), "rd_volume_fixed": self._money(rd_volume_actual * Decimal("0.96")), "rd_defense_fixed": self._money(rd_volume_actual * Decimal("0.61")), "avg_employees": staff_base, - "avg_payroll_employees": Decimal(max(12, int(profile.staff_base * Decimal("0.93")))), + "avg_payroll_employees": Decimal( + max(12, int(profile.staff_base * Decimal("0.93"))) + ), "payroll_fund": self._money(base_volume * Decimal("0.11")), - "salary_arrears": self._money(Decimal(profile.index * 500) if wear_percent > Decimal("55.00") else Decimal("0")), + "salary_arrears": self._money( + Decimal(profile.index * 500) + if wear_percent > Decimal("55.00") + else Decimal("0") + ), } def _build_f2_payload( @@ -765,12 +1019,16 @@ class Command(BaseAppCommand): age_5_10 = max(5, int(total_equipment * Decimal("0.23"))) age_10_15 = max(6, int(total_equipment * Decimal("0.24"))) age_15_20 = max(4, int(total_equipment * Decimal("0.18"))) - age_over_20 = max(1, total_equipment - age_under_5 - age_5_10 - age_10_15 - age_15_20) + age_over_20 = max( + 1, total_equipment - age_under_5 - age_5_10 - age_10_15 - age_15_20 + ) return { "avg_employees": Decimal(profile.staff_base), "production_workers": Decimal(int(profile.staff_base * Decimal("0.64"))), "engineering_workers": Decimal(int(profile.staff_base * Decimal("0.21"))), - "administrative_workers": Decimal(int(profile.staff_base * Decimal("0.15"))), + "administrative_workers": Decimal( + int(profile.staff_base * Decimal("0.15")) + ), "total_equipment": total_equipment, "domestic_equipment": domestic_equipment, "imported_equipment": imported_equipment, @@ -781,7 +1039,9 @@ class Command(BaseAppCommand): "equipment_age_over_20": age_over_20, "physical_wear_percent": wear_percent, "utilization_rate": utilization_rate, - "avg_shift_work": self._rate(Decimal("1.15") + Decimal(profile.index % 3) * Decimal("0.35")), + "avg_shift_work": self._rate( + Decimal("1.15") + Decimal(profile.index % 3) * Decimal("0.35") + ), "equipment_needed": max(0, total_equipment // 9), "workers_needed": max(0, profile.staff_base // 22), } @@ -823,18 +1083,29 @@ class Command(BaseAppCommand): "loans_ifrs": loans_ifrs, "net_debt_rsbu": net_debt_rsbu, "net_debt_ifrs": net_debt_ifrs, - "debt_to_ebitda": self._rate((net_debt_rsbu / max(ebitda_rsbu, Decimal("1"))) * Decimal("1.0")), + "debt_to_ebitda": self._rate( + (net_debt_rsbu / max(ebitda_rsbu, Decimal("1"))) * Decimal("1.0") + ), "total_assets_rsbu": self._money(base_volume * Decimal("1.08")), "total_assets_ifrs": self._money(base_volume * Decimal("1.12")), "equity_rsbu": equity_rsbu, "equity_ifrs": equity_ifrs, - "roe": self._percent((net_profit_ifrs / max(equity_ifrs, Decimal("1"))) * Decimal("100")), - "roa": self._percent((net_profit_ifrs / max(base_volume * Decimal("1.10"), Decimal("1"))) * Decimal("100")), - "ros": self._percent((net_profit_rsbu / max(revenue_rsbu, Decimal("1"))) * Decimal("100")), + "roe": self._percent( + (net_profit_ifrs / max(equity_ifrs, Decimal("1"))) * Decimal("100") + ), + "roa": self._percent( + (net_profit_ifrs / max(base_volume * Decimal("1.10"), Decimal("1"))) + * Decimal("100") + ), + "ros": self._percent( + (net_profit_rsbu / max(revenue_rsbu, Decimal("1"))) * Decimal("100") + ), "capex": self._money(base_volume * Decimal("0.08")), "rd_expenses": self._money(base_volume * Decimal("0.05")), "dividends_paid": self._money(net_profit_ifrs * Decimal("0.37")), - "dividend_yield": self._percent(Decimal("4.30") + Decimal(profile.index % 7) * Decimal("0.35")), + "dividend_yield": self._percent( + Decimal("4.30") + Decimal(profile.index % 7) * Decimal("0.35") + ), } def _build_f5_payload( @@ -860,7 +1131,9 @@ class Command(BaseAppCommand): "has_cnc": profile.index % 3 != 1, "equipment_type": equipment_type, "equipment_category": "Основное производство", - "commissioning_date": date(year_manufacture + 1, ((profile.index - 1) % 12) + 1, 1), + "commissioning_date": date( + year_manufacture + 1, ((profile.index - 1) % 12) + 1, 1 + ), "location": f"Корпус {(profile.index % 6) + 1}", "production_site": f"Участок {(profile.index % 9) + 1}", "utilization_rate": utilization_rate, @@ -890,7 +1163,9 @@ class Command(BaseAppCommand): age_5_10 = max(4, int(total_equipment * Decimal("0.24"))) age_10_15 = max(4, int(total_equipment * Decimal("0.26"))) age_15_20 = max(3, int(total_equipment * Decimal("0.18"))) - age_over_20 = max(1, total_equipment - age_under_5 - age_5_10 - age_10_15 - age_15_20) + age_over_20 = max( + 1, total_equipment - age_under_5 - age_5_10 - age_10_15 - age_15_20 + ) cnc_total = min(total_equipment, max(4, int(total_equipment * Decimal("0.31")))) return { "row_code": f"{100 + (profile.index % 60):03d}", @@ -909,7 +1184,9 @@ class Command(BaseAppCommand): "cnc_10_15": min(cnc_total, max(1, age_10_15 // 3)), "cnc_15_20": min(cnc_total, max(0, age_15_20 // 3)), "cnc_over_20": min(cnc_total, max(0, age_over_20 // 5)), - "avg_shift_work": self._rate(Decimal("1.10") + Decimal(profile.index % 3) * Decimal("0.30")), + "avg_shift_work": self._rate( + Decimal("1.10") + Decimal(profile.index % 3) * Decimal("0.30") + ), "utilization_rate": utilization_rate, "physical_wear_percent": wear_percent, "workplaces_without_equipment": max(0, profile.index % 5), diff --git a/src/apps/core/models.py b/src/apps/core/models.py index 9b4cb7c..02dd817 100644 --- a/src/apps/core/models.py +++ b/src/apps/core/models.py @@ -10,11 +10,12 @@ Background Job Tracking - отслеживание статуса Celery зад import uuid from typing import Any -from apps.core.mixins import TimestampMixin from django.db import models from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from apps.core.mixins import TimestampMixin + class JobStatus(models.TextChoices): """Статусы фоновых задач.""" diff --git a/src/apps/core/openapi.py b/src/apps/core/openapi.py index 5b51435..691bde3 100644 --- a/src/apps/core/openapi.py +++ b/src/apps/core/openapi.py @@ -52,10 +52,22 @@ OPENAPI_TAG_DESCRIPTIONS = OrderedDict( "Организации", "Справочник организаций ОПК с актуальными данными и связями по реестрам.", ), + ( + "Аналитика", + "Агрегированные аналитические endpoint'ы по организации и корпорации.", + ), ( "Реестры", "Просмотр реестров, составов организаций и импорт резервных копий реестров.", ), + ( + "Внешние данные", + "Внешние контуры и реестры: продукция, проверки, закупки и арбитраж.", + ), + ( + "Обмен данными", + "Импорт зашифрованных exchange-пакетов из dev API и ручной доставки.", + ), ( "Форма Ф-1", "Загрузка и просмотр формы Ф-1 по выпуску продукции, НИОКР и кадровым показателям.", @@ -111,8 +123,14 @@ OPENAPI_PUBLIC_PATH_PREFIXES = ( OPENAPI_TAG_BY_PATH_PREFIX = OrderedDict( [ ("/health/", "Мониторинг"), + ("/api/v1/analytics/", "Аналитика"), ("/api/v1/jobs/", "Фоновые задачи"), ("/api/v1/organizations/", "Организации"), + ("/api/v1/exchange/", "Обмен данными"), + ("/api/v1/industrial-products/", "Внешние данные"), + ("/api/v1/prosecutor-checks/", "Внешние данные"), + ("/api/v1/public-procurements/", "Внешние данные"), + ("/api/v1/arbitration-cases/", "Внешние данные"), ("/api/v1/registers/", "Реестры"), ("/api/v1/forms/f1/", "Форма Ф-1"), ("/api/v1/forms/f2/", "Форма Ф-2"), @@ -132,6 +150,7 @@ OPENAPI_TAG_ALIASES = { "users": "Пользователь", "organizations": "Организации", "registers": "Реестры", + "exchange": "Обмен данными", "api": None, } diff --git a/src/apps/core/pagination.py b/src/apps/core/pagination.py index eaa7ec3..6778083 100644 --- a/src/apps/core/pagination.py +++ b/src/apps/core/pagination.py @@ -200,3 +200,28 @@ class SmallResultSetPagination(StandardPagination): page_size = 10 max_page_size = 50 + + +class ClassicPagination(PageNumberPagination): + """ + DRF-compatible pagination with plain count/next/previous/results payload. + + Used for frontend-facing endpoints whose contract expects the classic + paginated shape without the global success/data/meta envelope. + """ + + page_size = 20 + page_size_query_param = "page_size" + max_page_size = 100 + + def get_paginated_response(self, data: list[Any]) -> Response: + return Response( + OrderedDict( + [ + ("count", self.page.paginator.count), + ("next", self.get_next_link()), + ("previous", self.get_previous_link()), + ("results", data), + ] + ) + ) diff --git a/src/apps/core/reporting.py b/src/apps/core/reporting.py index 1aab3bd..cc2421c 100644 --- a/src/apps/core/reporting.py +++ b/src/apps/core/reporting.py @@ -99,7 +99,9 @@ class VersionedReportServiceMixin(Generic[M]): class ReportingPeriodParserMixin: """Stores validated report period metadata for Excel parsers.""" - def __init__(self, *, report_year: int, report_quarter: int | None = None, **kwargs): + def __init__( + self, *, report_year: int, report_quarter: int | None = None, **kwargs + ): super().__init__(**kwargs) if report_year < 2000: raise ValueError("Отчетный год должен быть не меньше 2000") diff --git a/src/apps/core/services.py b/src/apps/core/services.py index 1eb45b0..11658d7 100644 --- a/src/apps/core/services.py +++ b/src/apps/core/services.py @@ -8,10 +8,11 @@ They are easily testable and can manage transactions. import logging from typing import Any, Generic, TypeVar -from apps.core.exceptions import NotFoundError from django.db import models, transaction from django.db.models import QuerySet +from apps.core.exceptions import NotFoundError + logger = logging.getLogger(__name__) # Type variable for model @@ -659,9 +660,10 @@ class BackgroundJobService(BaseReadOnlyService): """ from datetime import timedelta - from apps.core.models import JobStatus from django.utils import timezone + from apps.core.models import JobStatus + cutoff = timezone.now() - timedelta(days=days) deleted, _ = ( cls.get_queryset() diff --git a/src/apps/core/urls.py b/src/apps/core/urls.py index 382194e..26defd7 100644 --- a/src/apps/core/urls.py +++ b/src/apps/core/urls.py @@ -2,9 +2,10 @@ URL configuration for core app. """ -from apps.core.views import HealthCheckView, LivenessView, ReadinessView from django.urls import path +from apps.core.views import HealthCheckView, LivenessView, ReadinessView + app_name = "core" urlpatterns = [ diff --git a/src/apps/core/views.py b/src/apps/core/views.py index efcf519..131ca03 100644 --- a/src/apps/core/views.py +++ b/src/apps/core/views.py @@ -12,12 +12,6 @@ import logging import time from typing import Any -from apps.core.openapi import CommonResponses, ErrorResponses, swagger_tag -from apps.core.serializers import ( - BackgroundJobListResponseSerializer, - BackgroundJobListSerializer, - BackgroundJobSerializer, -) from django.conf import settings from django.db import connection from django.http import StreamingHttpResponse @@ -29,6 +23,13 @@ from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView +from apps.core.openapi import CommonResponses, ErrorResponses, swagger_tag +from apps.core.serializers import ( + BackgroundJobListResponseSerializer, + BackgroundJobListSerializer, + BackgroundJobSerializer, +) + logger = logging.getLogger(__name__) HEALTH_TAG = swagger_tag("Мониторинг", "monitoring") diff --git a/src/apps/core/viewsets.py b/src/apps/core/viewsets.py index abd1481..7e4bc3e 100644 --- a/src/apps/core/viewsets.py +++ b/src/apps/core/viewsets.py @@ -7,8 +7,6 @@ import logging from typing import Any, Generic, TypeVar -from apps.core.pagination import StandardPagination -from apps.core.response import api_error_response, api_response from django.db.models import Model, QuerySet from django_filters import rest_framework as filters from rest_framework import status, viewsets @@ -18,6 +16,9 @@ from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import Serializer +from apps.core.pagination import ClassicPagination, StandardPagination +from apps.core.response import api_error_response, api_response + logger = logging.getLogger(__name__) M = TypeVar("M", bound=Model) @@ -235,6 +236,47 @@ class ReadOnlyViewSet(viewsets.ReadOnlyModelViewSet, Generic[M]): return api_response(serializer.data) +class ClassicReadOnlyViewSet(viewsets.ReadOnlyModelViewSet, Generic[M]): + """ + ViewSet only for read operations with plain JSON responses. + + Intended for endpoints that must expose classic DRF pagination and + unwrapped objects for compatibility with frontend contracts. + """ + + pagination_class = ClassicPagination + permission_classes = [IsAuthenticated] + filter_backends = [ + filters.DjangoFilterBackend, + SearchFilter, + OrderingFilter, + ] + ordering = ["-created_at"] + + serializer_classes: dict[str, type[Serializer[Any]]] = {} + + def get_serializer_class(self) -> type[Serializer[Any]]: + if self.action in self.serializer_classes: + return self.serializer_classes[self.action] + return super().get_serializer_class() + + def list(self, request: Request, *args: Any, **kwargs: Any) -> Response: + queryset = self.filter_queryset(self.get_queryset()) + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + def retrieve(self, request: Request, *args: Any, **kwargs: Any) -> Response: + instance = self.get_object() + serializer = self.get_serializer(instance) + return Response(serializer.data) + + class OwnerViewSet(BaseViewSet[M]): """ ViewSet с фильтрацией по владельцу. diff --git a/src/apps/exchange/__init__.py b/src/apps/exchange/__init__.py new file mode 100644 index 0000000..df04d40 --- /dev/null +++ b/src/apps/exchange/__init__.py @@ -0,0 +1 @@ +"""Exchange package ingestion app.""" diff --git a/src/apps/exchange/admin.py b/src/apps/exchange/admin.py new file mode 100644 index 0000000..44064f8 --- /dev/null +++ b/src/apps/exchange/admin.py @@ -0,0 +1,228 @@ +"""Admin configuration for exchange package imports.""" + +from __future__ import annotations + +import json + +from django.contrib import admin, messages +from django.shortcuts import redirect +from django.template.response import TemplateResponse +from django.urls import path, reverse +from django.utils.html import format_html + +from apps.exchange.models import ( + ExchangeDeliveryChannel, + ExchangeImportStatus, + ExchangePackageImport, +) +from apps.exchange.serializers import ExchangePackageUploadSerializer +from apps.exchange.services import ExchangeImportError, ExchangePackageImportService +from apps.organization.models import Organization + + +@admin.register(ExchangePackageImport) +class ExchangePackageImportAdmin(admin.ModelAdmin): + """Readonly audit log and manual upload entrypoint for exchange packages.""" + + change_list_template = "admin/exchange/exchangepackageimport/change_list.html" + list_display = [ + "package_id", + "source_system", + "delivery_channel", + "status", + "is_duplicate", + "package_name", + "imported_by", + "created_at", + ] + list_filter = ["delivery_channel", "status", "source_system", "created_at"] + search_fields = [ + "package_id", + "package_hash", + "package_name", + "source_system", + "key_id", + "error_message", + ] + readonly_fields = [ + "id", + "package_id", + "source_system", + "schema_version", + "delivery_channel", + "status", + "package_name", + "package_hash", + "key_id", + "duplicate_of", + "imported_by", + "error_message", + "formatted_records_summary", + "created_at", + "updated_at", + ] + ordering = ["-created_at"] + fieldsets = [ + ( + None, + { + "fields": [ + "id", + "package_id", + "package_name", + "source_system", + "schema_version", + "delivery_channel", + "status", + ] + }, + ), + ( + "Аудит", + { + "fields": [ + "package_hash", + "key_id", + "duplicate_of", + "imported_by", + "created_at", + "updated_at", + ] + }, + ), + ( + "Результат", + { + "fields": [ + "formatted_records_summary", + "error_message", + ] + }, + ), + ] + + def has_add_permission(self, request): + """The model is created only by the import pipeline.""" + return False + + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path( + "upload-package/", + self.admin_site.admin_view(self.upload_package_view), + name="exchange_exchangepackageimport_upload_package", + ), + ] + return custom_urls + urls + + def changelist_view(self, request, extra_context=None): + extra_context = extra_context or {} + extra_context["upload_package_url"] = reverse( + "admin:exchange_exchangepackageimport_upload_package" + ) + return super().changelist_view(request, extra_context=extra_context) + + @admin.display(boolean=True, description="Дубликат") + def is_duplicate(self, obj): + return bool(obj.duplicate_of_id) + + @admin.display(description="Итоги импорта") + def formatted_records_summary(self, obj): + summary = obj.records_summary or {} + return format_html( + "
{}",
+ json.dumps(summary, ensure_ascii=False, indent=2),
+ )
+
+ def _build_upload_serializer(self, request):
+ uploaded_file = request.FILES.get("file")
+ return ExchangePackageUploadSerializer(data={"file": uploaded_file})
+
+ @staticmethod
+ def _format_serializer_errors(serializer):
+ parts = []
+ for field_name, errors in serializer.errors.items():
+ parts.append(f"{field_name}: {'; '.join(str(error) for error in errors)}")
+ return "; ".join(parts)
+
+ def _message_import_result(self, request, result):
+ if result.get("duplicate"):
+ self.message_user(
+ request,
+ (
+ "Пакет уже был импортирован ранее: "
+ f"{result.get('package_id')} "
+ f"(duplicate_of={result.get('duplicate_of')})."
+ ),
+ level=messages.WARNING,
+ )
+ return
+
+ organizations = result.get("organizations", {})
+ industrial_products = result.get("industrial_products", {})
+ prosecutor_checks = result.get("prosecutor_checks", {})
+ public_procurements = result.get("public_procurements", {})
+ arbitration_cases = result.get("arbitration_cases", {})
+ self.message_user(
+ request,
+ (
+ "Импорт пакета обмена завершён: "
+ f"орг. создано {organizations.get('created', 0)}, "
+ f"орг. обновлено {organizations.get('updated', 0)}, "
+ f"продукция {industrial_products.get('created', 0)}, "
+ f"проверки {prosecutor_checks.get('created', 0)}, "
+ f"закупки {public_procurements.get('created', 0)}, "
+ f"арбитраж {arbitration_cases.get('created', 0)}."
+ ),
+ level=messages.SUCCESS,
+ )
+
+ def upload_package_view(self, request):
+ changelist_url = reverse("admin:exchange_exchangepackageimport_changelist")
+ upload_url = reverse("admin:exchange_exchangepackageimport_upload_package")
+
+ if request.method == "POST":
+ serializer = self._build_upload_serializer(request)
+ if not serializer.is_valid():
+ self.message_user(
+ request,
+ "Ошибка валидации загрузки: "
+ + self._format_serializer_errors(serializer),
+ level=messages.ERROR,
+ )
+ return redirect(upload_url)
+
+ try:
+ result = ExchangePackageImportService.import_package(
+ uploaded_file=serializer.validated_data["file"],
+ delivery_channel=ExchangeDeliveryChannel.ADMIN,
+ imported_by=request.user,
+ )
+ except ExchangeImportError as exc:
+ self.message_user(
+ request,
+ f"Не удалось импортировать пакет: {exc}",
+ level=messages.ERROR,
+ )
+ return redirect(upload_url)
+
+ self._message_import_result(request, result)
+ return redirect(changelist_url)
+
+ context = {
+ **self.admin_site.each_context(request),
+ "opts": self.model._meta,
+ "title": "Обмен реестров и данных организаций",
+ "changelist_url": changelist_url,
+ "imports_count": ExchangePackageImport.objects.count(),
+ "successful_imports_count": ExchangePackageImport.objects.filter(
+ status=ExchangeImportStatus.SUCCESS
+ ).count(),
+ "organizations_count": Organization.objects.count(),
+ }
+ return TemplateResponse(
+ request,
+ "admin/exchange/exchangepackageimport/upload_package.html",
+ context,
+ )
diff --git a/src/apps/exchange/apps.py b/src/apps/exchange/apps.py
new file mode 100644
index 0000000..e9ca5bb
--- /dev/null
+++ b/src/apps/exchange/apps.py
@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class ExchangeConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "apps.exchange"
+ verbose_name = "Обмен данными"
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/import_exchange_package.py b/src/apps/exchange/management/commands/import_exchange_package.py
new file mode 100644
index 0000000..eebc861
--- /dev/null
+++ b/src/apps/exchange/management/commands/import_exchange_package.py
@@ -0,0 +1,45 @@
+"""Import encrypted exchange package from local filesystem."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from django.core.files import File
+
+from apps.core.management.commands.base import BaseAppCommand
+from apps.exchange.models import ExchangeDeliveryChannel
+from apps.exchange.services import ExchangePackageImportService
+
+
+class Command(BaseAppCommand):
+ """Import exchange package from manual file delivery."""
+
+ help = "Импортирует обменный пакет из файла .zip/.bin"
+
+ def add_arguments(self, parser) -> None:
+ super().add_arguments(parser)
+ parser.add_argument("package_path", type=str, help="Путь до пакета .zip/.bin")
+ parser.add_argument(
+ "--channel",
+ type=str,
+ default=ExchangeDeliveryChannel.CLI,
+ choices=ExchangeDeliveryChannel.values,
+ help="Канал доставки пакета",
+ )
+
+ def execute_command(self, *args, **options) -> str:
+ package_path = Path(options["package_path"]).expanduser().resolve()
+ if not package_path.exists() or not package_path.is_file():
+ raise FileNotFoundError(f"Файл не найден: {package_path}")
+
+ with package_path.open("rb") as handle:
+ package_file = File(handle, name=package_path.name)
+ result = ExchangePackageImportService.import_package(
+ uploaded_file=package_file,
+ delivery_channel=options["channel"],
+ )
+
+ rendered = json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True)
+ self.log_success(rendered)
+ return rendered
diff --git a/src/apps/exchange/migrations/0001_initial.py b/src/apps/exchange/migrations/0001_initial.py
new file mode 100644
index 0000000..0b9106f
--- /dev/null
+++ b/src/apps/exchange/migrations/0001_initial.py
@@ -0,0 +1,178 @@
+# Generated by Django 5.2.1 on 2026-04-07 14:10
+
+import django.db.models.deletion
+import uuid
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="ExchangePackageImport",
+ fields=[
+ (
+ "created_at",
+ models.DateTimeField(
+ auto_now_add=True,
+ db_index=True,
+ help_text="Дата и время создания записи",
+ verbose_name="создано",
+ ),
+ ),
+ (
+ "updated_at",
+ models.DateTimeField(
+ auto_now=True,
+ help_text="Дата и время последнего обновления",
+ verbose_name="обновлено",
+ ),
+ ),
+ (
+ "id",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "package_id",
+ models.CharField(
+ db_index=True,
+ max_length=128,
+ verbose_name="ID пакета",
+ ),
+ ),
+ (
+ "source_system",
+ models.CharField(
+ blank=True,
+ default="",
+ max_length=64,
+ verbose_name="система-источник",
+ ),
+ ),
+ (
+ "schema_version",
+ models.PositiveSmallIntegerField(
+ default=1,
+ verbose_name="версия схемы",
+ ),
+ ),
+ (
+ "delivery_channel",
+ models.CharField(
+ choices=[
+ ("api", "API upload"),
+ ("cli", "CLI import"),
+ ("admin", "Admin upload"),
+ ],
+ db_index=True,
+ default="api",
+ max_length=16,
+ verbose_name="канал доставки",
+ ),
+ ),
+ (
+ "status",
+ models.CharField(
+ choices=[
+ ("success", "Успешно"),
+ ("failed", "Ошибка"),
+ ],
+ db_index=True,
+ default="success",
+ max_length=16,
+ verbose_name="статус импорта",
+ ),
+ ),
+ (
+ "package_name",
+ models.CharField(max_length=255, verbose_name="имя файла пакета"),
+ ),
+ (
+ "package_hash",
+ models.CharField(
+ db_index=True,
+ max_length=64,
+ verbose_name="SHA-256 пакета",
+ ),
+ ),
+ (
+ "key_id",
+ models.CharField(
+ blank=True,
+ default="",
+ max_length=64,
+ verbose_name="идентификатор ключа",
+ ),
+ ),
+ (
+ "records_summary",
+ models.JSONField(
+ blank=True,
+ default=dict,
+ verbose_name="итоги импорта",
+ ),
+ ),
+ (
+ "error_message",
+ models.TextField(
+ blank=True,
+ default="",
+ verbose_name="ошибка",
+ ),
+ ),
+ (
+ "duplicate_of",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="duplicates",
+ to="exchange.exchangepackageimport",
+ verbose_name="дубликат пакета",
+ ),
+ ),
+ (
+ "imported_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="exchange_package_imports",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="импортировал",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "импорт обменного пакета",
+ "verbose_name_plural": "импорты обменных пакетов",
+ "ordering": ["-created_at"],
+ },
+ ),
+ migrations.AddIndex(
+ model_name="exchangepackageimport",
+ index=models.Index(
+ fields=["package_id", "status"],
+ name="exchange_ex_package_02c024_idx",
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="exchangepackageimport",
+ index=models.Index(
+ fields=["package_hash", "status"],
+ name="exchange_ex_package_02c7c2_idx",
+ ),
+ ),
+ ]
diff --git a/src/apps/exchange/migrations/__init__.py b/src/apps/exchange/migrations/__init__.py
new file mode 100644
index 0000000..cce83e4
--- /dev/null
+++ b/src/apps/exchange/migrations/__init__.py
@@ -0,0 +1 @@
+"""Migrations for exchange app."""
diff --git a/src/apps/exchange/models.py b/src/apps/exchange/models.py
new file mode 100644
index 0000000..5331269
--- /dev/null
+++ b/src/apps/exchange/models.py
@@ -0,0 +1,79 @@
+"""Models for exchange package import tracking."""
+
+from __future__ import annotations
+
+from django.conf import settings
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+from apps.core.mixins import TimestampMixin, UUIDPrimaryKeyMixin
+
+
+class ExchangeDeliveryChannel(models.TextChoices):
+ API = "api", _("API upload")
+ CLI = "cli", _("CLI import")
+ ADMIN = "admin", _("Admin upload")
+
+
+class ExchangeImportStatus(models.TextChoices):
+ SUCCESS = "success", _("Успешно")
+ FAILED = "failed", _("Ошибка")
+
+
+class ExchangePackageImport(UUIDPrimaryKeyMixin, TimestampMixin, models.Model):
+ """Metadata and audit log for imported exchange packages."""
+
+ package_id = models.CharField(_("ID пакета"), max_length=128, db_index=True)
+ source_system = models.CharField(
+ _("система-источник"), max_length=64, blank=True, default=""
+ )
+ schema_version = models.PositiveSmallIntegerField(_("версия схемы"), default=1)
+ delivery_channel = models.CharField(
+ _("канал доставки"),
+ max_length=16,
+ choices=ExchangeDeliveryChannel.choices,
+ default=ExchangeDeliveryChannel.API,
+ db_index=True,
+ )
+ status = models.CharField(
+ _("статус импорта"),
+ max_length=16,
+ choices=ExchangeImportStatus.choices,
+ default=ExchangeImportStatus.SUCCESS,
+ db_index=True,
+ )
+ package_name = models.CharField(_("имя файла пакета"), max_length=255)
+ package_hash = models.CharField(_("SHA-256 пакета"), max_length=64, db_index=True)
+ key_id = models.CharField(
+ _("идентификатор ключа"), max_length=64, blank=True, default=""
+ )
+ records_summary = models.JSONField(_("итоги импорта"), default=dict, blank=True)
+ duplicate_of = models.ForeignKey(
+ "self",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="duplicates",
+ verbose_name=_("дубликат пакета"),
+ )
+ imported_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="exchange_package_imports",
+ verbose_name=_("импортировал"),
+ )
+ error_message = models.TextField(_("ошибка"), blank=True, default="")
+
+ class Meta:
+ ordering = ["-created_at"]
+ verbose_name = _("импорт обменного пакета")
+ verbose_name_plural = _("импорты обменных пакетов")
+ indexes = [
+ models.Index(fields=["package_id", "status"]),
+ models.Index(fields=["package_hash", "status"]),
+ ]
+
+ def __str__(self) -> str:
+ return f"{self.package_id} ({self.status})"
diff --git a/src/apps/exchange/serializers.py b/src/apps/exchange/serializers.py
new file mode 100644
index 0000000..c19b4e2
--- /dev/null
+++ b/src/apps/exchange/serializers.py
@@ -0,0 +1,18 @@
+"""Serializers for exchange package upload endpoints."""
+
+from rest_framework import serializers
+
+
+class ExchangePackageUploadSerializer(serializers.Serializer):
+ """Multipart upload serializer for exchange package."""
+
+ file = serializers.FileField()
+
+ def validate_file(self, value):
+ """Accept only exchange containers that the import pipeline can read."""
+ file_name = str(getattr(value, "name", "") or "").lower()
+ if not file_name.endswith((".zip", ".bin")):
+ raise serializers.ValidationError(
+ "Поддерживаются только архивы .zip или контейнеры .bin."
+ )
+ return value
diff --git a/src/apps/exchange/services.py b/src/apps/exchange/services.py
new file mode 100644
index 0000000..347c326
--- /dev/null
+++ b/src/apps/exchange/services.py
@@ -0,0 +1,966 @@
+"""Services for encrypted exchange package import."""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import json
+import struct
+import zlib
+from dataclasses import dataclass
+from datetime import date
+from decimal import Decimal, InvalidOperation
+from io import BytesIO
+from typing import Any
+from zipfile import ZipFile
+
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+from django.conf import settings
+from django.db import transaction
+from django.db.models import Q
+
+from apps.exchange.models import (
+ ExchangeDeliveryChannel,
+ ExchangeImportStatus,
+ ExchangePackageImport,
+)
+from apps.external_data.models import (
+ ArbitrationCase,
+ IndustrialProduct,
+ ProsecutorCheck,
+ PublicProcurement,
+)
+from apps.organization.models import IndustryCluster, Organization, OrganizationType
+
+
+class ExchangeImportError(ValueError):
+ """Exchange package validation or import error."""
+
+
+@dataclass(frozen=True)
+class DecodedExchangePackage:
+ """Decoded exchange package container."""
+
+ archive_name: str
+ bin_name: str
+ bin_bytes: bytes
+ package_hash: str
+ header: dict[str, Any]
+ payload: dict[str, Any]
+
+
+class ExchangePackageImportService:
+ """Import encrypted package delivered via API or manual file copy."""
+
+ MAGIC = b"EXCH"
+ AAD = b"state-corp-exchange-v1"
+ PAYLOAD_FORMAT = "state-corp-exchange-payload"
+ BIN_FORMAT = "state-corp-exchange-bin"
+ ORGANIZATION_STR_FIELDS = {
+ "short_name": ("short_name",),
+ "ogrn": ("ogrn",),
+ "kpp": ("kpp",),
+ "okpo": ("okpo",),
+ "legal_address": ("legal_address",),
+ "activity_type": ("activity_type",),
+ "founder_name": ("founder_name",),
+ "ownership_type": ("ownership_type",),
+ "legal_form": ("legal_form",),
+ "general_director_name": ("general_director_name",),
+ "general_director_inn": ("general_director_inn",),
+ }
+ ORGANIZATION_DATE_FIELDS = {
+ "registration_date": ("registration_date",),
+ "general_director_appointment_date": ("general_director_appointment_date",),
+ }
+ ORGANIZATION_BOOL_FIELDS = {
+ "financial_reports_available": ("financial_reports_available",),
+ "tax_reports_available": ("tax_reports_available",),
+ "in_defense_unreliable_suppliers_registry": (
+ "in_defense_unreliable_suppliers_registry",
+ ),
+ "in_275_fz_registry": ("in_275_fz_registry",),
+ "bankruptcy_messages_found": ("bankruptcy_messages_found",),
+ }
+ ORGANIZATION_INT_FIELDS = {
+ "executors_count": ("executors_count",),
+ }
+ ORGANIZATION_DECIMAL_FIELDS = {
+ "charter_capital_amount": ("charter_capital_amount",),
+ }
+ SECTION_KEYS = (
+ "organizations",
+ "industrial_products",
+ "prosecutor_checks",
+ "public_procurements",
+ "arbitration_cases",
+ )
+
+ @classmethod
+ def import_package(
+ cls,
+ *,
+ uploaded_file,
+ delivery_channel: str = ExchangeDeliveryChannel.API,
+ imported_by=None,
+ ) -> dict[str, Any]:
+ """Import exchange package with duplicate detection and audit logging."""
+ decoded = cls._decode_uploaded_file(uploaded_file)
+ manifest = cls._extract_manifest(decoded.payload)
+ package_id = cls._read_required_str(manifest, "package_id")
+ schema_version = cls._read_schema_version(decoded.payload, manifest)
+ source_system = str(manifest.get("source_system") or "").strip()
+ key_id = str(decoded.header.get("key_id") or settings.EXCHANGE_KEY_ID).strip()
+
+ duplicate = cls._find_duplicate(
+ package_id=package_id,
+ package_hash=decoded.package_hash,
+ )
+ if duplicate is not None:
+ summary = cls._build_duplicate_summary(decoded=decoded, duplicate=duplicate)
+ duplicate_record = ExchangePackageImport.objects.create(
+ package_id=package_id,
+ source_system=source_system,
+ schema_version=schema_version,
+ delivery_channel=delivery_channel,
+ status=ExchangeImportStatus.SUCCESS,
+ package_name=decoded.archive_name,
+ package_hash=decoded.package_hash,
+ key_id=key_id,
+ records_summary=summary,
+ duplicate_of=duplicate,
+ imported_by=imported_by,
+ )
+ summary["import_id"] = str(duplicate_record.id)
+ duplicate_record.records_summary = summary
+ duplicate_record.save(update_fields=["records_summary", "updated_at"])
+ return summary
+
+ try:
+ with transaction.atomic():
+ summary = cls._import_payload(decoded.payload)
+ record = ExchangePackageImport.objects.create(
+ package_id=package_id,
+ source_system=source_system,
+ schema_version=schema_version,
+ delivery_channel=delivery_channel,
+ status=ExchangeImportStatus.SUCCESS,
+ package_name=decoded.archive_name,
+ package_hash=decoded.package_hash,
+ key_id=key_id,
+ records_summary={},
+ imported_by=imported_by,
+ )
+ summary.update(
+ {
+ "import_id": str(record.id),
+ "package_id": package_id,
+ "package_hash": decoded.package_hash,
+ "archive_name": decoded.archive_name,
+ "bin_name": decoded.bin_name,
+ "source_system": source_system,
+ "schema_version": schema_version,
+ "duplicate": False,
+ }
+ )
+ record.records_summary = summary
+ record.save(update_fields=["records_summary", "updated_at"])
+ return summary
+ except ExchangeImportError as exc:
+ cls._create_failed_record(
+ decoded=decoded,
+ package_id=package_id,
+ source_system=source_system,
+ schema_version=schema_version,
+ key_id=key_id,
+ delivery_channel=delivery_channel,
+ imported_by=imported_by,
+ error_message=str(exc),
+ )
+ raise
+ except Exception as exc: # noqa: BLE001
+ error_message = "Не удалось импортировать пакет обмена"
+ cls._create_failed_record(
+ decoded=decoded,
+ package_id=package_id,
+ source_system=source_system,
+ schema_version=schema_version,
+ key_id=key_id,
+ delivery_channel=delivery_channel,
+ imported_by=imported_by,
+ error_message=error_message,
+ )
+ raise ExchangeImportError(error_message) from exc
+
+ @classmethod
+ def _decode_uploaded_file(cls, uploaded_file) -> DecodedExchangePackage:
+ file_name = uploaded_file.name
+ raw_bytes = uploaded_file.read()
+ uploaded_file.seek(0)
+
+ if not raw_bytes:
+ raise ExchangeImportError("Пакет обмена пустой")
+
+ if file_name.lower().endswith(".zip"):
+ archive_name = file_name
+ bin_name, bin_bytes = cls._extract_bin_from_zip(raw_bytes)
+ elif file_name.lower().endswith(".bin"):
+ archive_name = file_name
+ bin_name = file_name
+ bin_bytes = raw_bytes
+ else:
+ raise ExchangeImportError(
+ "Поддерживаются только архивы .zip или контейнеры .bin"
+ )
+
+ header, encrypted_payload = cls._read_bin_container(bin_bytes)
+ payload = cls._decrypt_payload(
+ header=header, encrypted_payload=encrypted_payload
+ )
+ cls._validate_payload(payload)
+
+ return DecodedExchangePackage(
+ archive_name=archive_name,
+ bin_name=bin_name,
+ bin_bytes=bin_bytes,
+ package_hash=hashlib.sha256(bin_bytes).hexdigest(),
+ header=header,
+ payload=payload,
+ )
+
+ @classmethod
+ def _extract_bin_from_zip(cls, archive_bytes: bytes) -> tuple[str, bytes]:
+ try:
+ with ZipFile(BytesIO(archive_bytes)) as archive:
+ bin_names = [
+ name for name in archive.namelist() if name.endswith(".bin")
+ ]
+ if len(bin_names) != 1:
+ raise ExchangeImportError(
+ "Архив должен содержать ровно один файл обмена .bin"
+ )
+
+ bin_name = bin_names[0]
+ bin_bytes = archive.read(bin_name)
+ checksum_names = [
+ name for name in archive.namelist() if name.endswith(".sha256")
+ ]
+ if checksum_names:
+ checksum_text = (
+ archive.read(checksum_names[0]).decode("utf-8").strip()
+ )
+ expected_hash = checksum_text.split()[0]
+ actual_hash = hashlib.sha256(bin_bytes).hexdigest()
+ if expected_hash != actual_hash:
+ raise ExchangeImportError(
+ "Контрольная сумма обменного файла не совпадает"
+ )
+ return bin_name, bin_bytes
+ except ExchangeImportError:
+ raise
+ except Exception as exc: # noqa: BLE001
+ raise ExchangeImportError("Не удалось прочитать архив обмена") from exc
+
+ @classmethod
+ def _read_bin_container(cls, bin_bytes: bytes) -> tuple[dict[str, Any], bytes]:
+ if len(bin_bytes) < 9 or not bin_bytes.startswith(cls.MAGIC):
+ raise ExchangeImportError("Файл не похож на контейнер обмена")
+
+ version = bin_bytes[4]
+ header_size = struct.unpack(">I", bin_bytes[5:9])[0]
+ header_end = 9 + header_size
+ if len(bin_bytes) < header_end:
+ raise ExchangeImportError("Поврежден заголовок контейнера обмена")
+
+ try:
+ header = json.loads(bin_bytes[9:header_end].decode("utf-8"))
+ except Exception as exc: # noqa: BLE001
+ raise ExchangeImportError(
+ "Не удалось декодировать заголовок пакета"
+ ) from exc
+
+ if version != 1:
+ raise ExchangeImportError(
+ f"Неподдерживаемая версия контейнера обмена: {version}"
+ )
+ if not isinstance(header, dict):
+ raise ExchangeImportError("Неподдерживаемый заголовок контейнера обмена")
+ if "nonce" not in header or "aad" not in header:
+ raise ExchangeImportError(
+ "В заголовке пакета отсутствуют параметры шифрования"
+ )
+
+ return header, bin_bytes[header_end:]
+
+ @classmethod
+ def _decrypt_payload(
+ cls,
+ *,
+ header: dict[str, Any],
+ encrypted_payload: bytes,
+ ) -> dict[str, Any]:
+ token = str(settings.EXCHANGE_SHARED_TOKEN or "").strip()
+ if not token:
+ raise ExchangeImportError("EXCHANGE_SHARED_TOKEN не настроен")
+
+ raw_key = hashlib.sha256(token.encode("utf-8")).digest()
+ nonce = cls._decode_base64_field(header, "nonce")
+ aad = cls._decode_base64_field(header, "aad")
+
+ try:
+ compressed_payload = AESGCM(raw_key).decrypt(nonce, encrypted_payload, aad)
+ payload_bytes = zlib.decompress(compressed_payload)
+ return json.loads(payload_bytes.decode("utf-8"))
+ except ExchangeImportError:
+ raise
+ except Exception as exc: # noqa: BLE001
+ raise ExchangeImportError(
+ "Не удалось расшифровать пакет. Проверь EXCHANGE_SHARED_TOKEN"
+ ) from exc
+
+ @classmethod
+ def _decode_base64_field(cls, header: dict[str, Any], field_name: str) -> bytes:
+ value = str(header.get(field_name) or "")
+ if not value:
+ raise ExchangeImportError(
+ f"В заголовке пакета отсутствует поле {field_name}"
+ )
+
+ normalized = value + ("=" * (-len(value) % 4))
+ try:
+ return base64.urlsafe_b64decode(normalized.encode("ascii"))
+ except Exception as exc: # noqa: BLE001
+ raise ExchangeImportError(
+ f"Не удалось декодировать поле {field_name} в заголовке"
+ ) from exc
+
+ @classmethod
+ def _validate_payload(cls, payload: dict[str, Any]) -> None:
+ if not isinstance(payload, dict):
+ raise ExchangeImportError("Payload пакета поврежден")
+ if payload.get("format") != cls.PAYLOAD_FORMAT:
+ raise ExchangeImportError("Неверный формат payload обменного пакета")
+ manifest = payload.get("manifest")
+ data = payload.get("data")
+ if not isinstance(manifest, dict) or not isinstance(data, dict):
+ raise ExchangeImportError("Payload пакета не содержит manifest/data")
+ cls._read_required_str(manifest, "package_id")
+
+ @classmethod
+ def _extract_manifest(cls, payload: dict[str, Any]) -> dict[str, Any]:
+ manifest = payload.get("manifest")
+ if not isinstance(manifest, dict):
+ raise ExchangeImportError("Manifest пакета поврежден")
+ return manifest
+
+ @classmethod
+ def _read_schema_version(
+ cls,
+ payload: dict[str, Any],
+ manifest: dict[str, Any],
+ ) -> int:
+ schema_version = payload.get(
+ "schema_version", manifest.get("schema_version", 1)
+ )
+ try:
+ return int(schema_version)
+ except (TypeError, ValueError) as exc:
+ raise ExchangeImportError(
+ "schema_version должен быть целым числом"
+ ) from exc
+
+ @classmethod
+ def _find_duplicate(
+ cls,
+ *,
+ package_id: str,
+ package_hash: str,
+ ) -> ExchangePackageImport | None:
+ return (
+ ExchangePackageImport.objects.filter(status=ExchangeImportStatus.SUCCESS)
+ .filter(Q(package_id=package_id) | Q(package_hash=package_hash))
+ .order_by("-created_at")
+ .first()
+ )
+
+ @classmethod
+ def _build_duplicate_summary(
+ cls,
+ *,
+ decoded: DecodedExchangePackage,
+ duplicate: ExchangePackageImport,
+ ) -> dict[str, Any]:
+ original_summary = dict(duplicate.records_summary or {})
+ original_summary.update(
+ {
+ "package_id": duplicate.package_id,
+ "package_hash": duplicate.package_hash,
+ "archive_name": decoded.archive_name,
+ "bin_name": decoded.bin_name,
+ "source_system": duplicate.source_system,
+ "schema_version": duplicate.schema_version,
+ "duplicate": True,
+ "duplicate_of": str(duplicate.id),
+ }
+ )
+ return original_summary
+
+ @classmethod
+ def _create_failed_record(
+ cls,
+ *,
+ decoded: DecodedExchangePackage,
+ package_id: str,
+ source_system: str,
+ schema_version: int,
+ key_id: str,
+ delivery_channel: str,
+ imported_by,
+ error_message: str,
+ ) -> None:
+ ExchangePackageImport.objects.create(
+ package_id=package_id,
+ source_system=source_system,
+ schema_version=schema_version,
+ delivery_channel=delivery_channel,
+ status=ExchangeImportStatus.FAILED,
+ package_name=decoded.archive_name,
+ package_hash=decoded.package_hash,
+ key_id=key_id,
+ records_summary={
+ "package_id": package_id,
+ "package_hash": decoded.package_hash,
+ "archive_name": decoded.archive_name,
+ "bin_name": decoded.bin_name,
+ "duplicate": False,
+ },
+ imported_by=imported_by,
+ error_message=error_message,
+ )
+
+ @classmethod
+ def _import_payload(cls, payload: dict[str, Any]) -> dict[str, Any]:
+ data = payload.get("data")
+ if not isinstance(data, dict):
+ raise ExchangeImportError("Раздел data в пакете поврежден")
+
+ organization_summary = cls._upsert_organizations(
+ cls._extract_rows(data, "organizations"),
+ )
+ industrial_summary = cls._upsert_industrial_products(
+ cls._extract_rows(data, "industrial_products"),
+ )
+ prosecutor_summary = cls._upsert_prosecutor_checks(
+ cls._extract_rows(data, "prosecutor_checks"),
+ )
+ procurement_summary = cls._upsert_public_procurements(
+ cls._extract_rows(data, "public_procurements"),
+ )
+ arbitration_summary = cls._upsert_arbitration_cases(
+ cls._extract_rows(data, "arbitration_cases"),
+ )
+
+ return {
+ "organizations": organization_summary,
+ "industrial_products": industrial_summary,
+ "prosecutor_checks": prosecutor_summary,
+ "public_procurements": procurement_summary,
+ "arbitration_cases": arbitration_summary,
+ }
+
+ @classmethod
+ def _extract_rows(
+ cls,
+ data: dict[str, Any],
+ section_name: str,
+ ) -> list[dict[str, Any]]:
+ rows = data.get(section_name, [])
+ if rows is None:
+ return []
+ if not isinstance(rows, list):
+ raise ExchangeImportError(f"Раздел {section_name} должен быть списком")
+ normalized_rows: list[dict[str, Any]] = []
+ for row in rows:
+ if not isinstance(row, dict):
+ raise ExchangeImportError(
+ f"Раздел {section_name} должен содержать объекты словаря"
+ )
+ normalized_rows.append(row)
+ return normalized_rows
+
+ @classmethod
+ def _upsert_organizations(cls, rows: list[dict[str, Any]]) -> dict[str, int]:
+ created_count = 0
+ updated_count = 0
+ skipped_count = 0
+
+ for row in rows:
+ inn = cls._resolve_organization_inn(row)
+ if not inn:
+ skipped_count += 1
+ continue
+
+ name = cls._clean_string(cls._get_first_value(row, ("name", "full_name")))
+ organization, created = Organization.objects.get_or_create(
+ inn=inn,
+ defaults={
+ "name": name or inn,
+ "ogrn": cls._clean_digits(row.get("ogrn")),
+ "kpp": cls._clean_digits(row.get("kpp")),
+ "okpo": cls._clean_digits(row.get("okpo")),
+ },
+ )
+
+ if created:
+ created_count += 1
+ update_fields = cls._collect_organization_updates(
+ organization=organization,
+ row=row,
+ )
+ if update_fields:
+ organization.save(update_fields=update_fields + ["updated_at"])
+ if created:
+ created_count += 0
+ else:
+ updated_count += 1
+
+ return {
+ "created": created_count,
+ "updated": updated_count,
+ "skipped": skipped_count,
+ }
+
+ @classmethod
+ def _collect_organization_updates( # noqa: C901
+ cls,
+ *,
+ organization: Organization,
+ row: dict[str, Any],
+ ) -> list[str]:
+ update_fields: list[str] = []
+
+ for field_name, aliases in cls.ORGANIZATION_STR_FIELDS.items():
+ value, present = cls._get_present_value(row, aliases)
+ if not present:
+ continue
+ cleaned_value = (
+ cls._clean_digits(value)
+ if field_name in {"ogrn", "kpp", "okpo", "general_director_inn"}
+ else cls._clean_string(value)
+ )
+ if getattr(organization, field_name) != cleaned_value:
+ setattr(organization, field_name, cleaned_value)
+ update_fields.append(field_name)
+
+ for field_name, aliases in cls.ORGANIZATION_DATE_FIELDS.items():
+ value, present = cls._get_present_value(row, aliases)
+ if not present:
+ continue
+ parsed_value = cls._parse_date_value(value, field_name=field_name)
+ if getattr(organization, field_name) != parsed_value:
+ setattr(organization, field_name, parsed_value)
+ update_fields.append(field_name)
+
+ for field_name, aliases in cls.ORGANIZATION_BOOL_FIELDS.items():
+ value, present = cls._get_present_value(row, aliases)
+ if not present:
+ continue
+ parsed_value = cls._parse_bool_value(value, field_name=field_name)
+ if getattr(organization, field_name) != parsed_value:
+ setattr(organization, field_name, parsed_value)
+ update_fields.append(field_name)
+
+ for field_name, aliases in cls.ORGANIZATION_INT_FIELDS.items():
+ value, present = cls._get_present_value(row, aliases)
+ if not present:
+ continue
+ parsed_value = cls._parse_int_value(value, field_name=field_name)
+ if getattr(organization, field_name) != parsed_value:
+ setattr(organization, field_name, parsed_value)
+ update_fields.append(field_name)
+
+ for field_name, aliases in cls.ORGANIZATION_DECIMAL_FIELDS.items():
+ value, present = cls._get_present_value(row, aliases)
+ if not present:
+ continue
+ parsed_value = cls._parse_decimal_value(value, field_name=field_name)
+ if getattr(organization, field_name) != parsed_value:
+ setattr(organization, field_name, parsed_value)
+ update_fields.append(field_name)
+
+ organization_type_value, present = cls._get_present_value(
+ row,
+ ("organization_type",),
+ )
+ if present:
+ normalized_type = cls._normalize_choice(
+ organization_type_value,
+ field_name="organization_type",
+ allowed_values=OrganizationType.values,
+ )
+ if organization.organization_type != normalized_type:
+ organization.organization_type = normalized_type
+ update_fields.append("organization_type")
+
+ cluster_value, present = cls._get_present_value(row, ("cluster",))
+ if present:
+ normalized_cluster = cls._normalize_choice(
+ cluster_value,
+ field_name="cluster",
+ allowed_values=IndustryCluster.values,
+ )
+ if organization.cluster != normalized_cluster:
+ organization.cluster = normalized_cluster
+ update_fields.append("cluster")
+
+ name_value, present = cls._get_present_value(row, ("name", "full_name"))
+ if present:
+ cleaned_name = cls._clean_string(name_value)
+ if cleaned_name and organization.name != cleaned_name:
+ organization.name = cleaned_name
+ update_fields.append("name")
+
+ return update_fields
+
+ @classmethod
+ def _upsert_industrial_products(cls, rows: list[dict[str, Any]]) -> dict[str, int]:
+ created_count = 0
+ updated_count = 0
+ skipped_count = 0
+
+ for row in rows:
+ organization = cls._resolve_organization(row)
+ registry_number = cls._clean_string(row.get("registry_number"))
+ if not registry_number:
+ skipped_count += 1
+ continue
+
+ defaults = {
+ "product_name": cls._clean_string(row.get("product_name")),
+ "product_class": cls._clean_string(row.get("product_class")),
+ "okpd2_code": cls._clean_string(row.get("okpd2_code")),
+ "tnved_code": cls._clean_string(row.get("tnved_code")),
+ }
+ state = cls._upsert_external_row(
+ model=IndustrialProduct,
+ lookup={
+ "organization": organization,
+ "registry_number": registry_number,
+ },
+ defaults=defaults,
+ )
+ if state == "created":
+ created_count += 1
+ elif state == "updated":
+ updated_count += 1
+
+ return {
+ "created": created_count,
+ "updated": updated_count,
+ "skipped": skipped_count,
+ }
+
+ @classmethod
+ def _upsert_prosecutor_checks(cls, rows: list[dict[str, Any]]) -> dict[str, int]:
+ created_count = 0
+ updated_count = 0
+ skipped_count = 0
+
+ for row in rows:
+ organization = cls._resolve_organization(row)
+ registration_number = cls._clean_string(row.get("registration_number"))
+ if not registration_number:
+ skipped_count += 1
+ continue
+
+ defaults = {
+ "law_type": cls._clean_string(row.get("law_type")),
+ "control_authority": cls._clean_string(row.get("control_authority")),
+ "prosecutor_office": cls._clean_string(row.get("prosecutor_office")),
+ "start_date": cls._parse_date_value(
+ row.get("start_date"),
+ field_name="start_date",
+ ),
+ "status": cls._clean_string(row.get("status")),
+ }
+ state = cls._upsert_external_row(
+ model=ProsecutorCheck,
+ lookup={
+ "organization": organization,
+ "registration_number": registration_number,
+ },
+ defaults=defaults,
+ )
+ if state == "created":
+ created_count += 1
+ elif state == "updated":
+ updated_count += 1
+
+ return {
+ "created": created_count,
+ "updated": updated_count,
+ "skipped": skipped_count,
+ }
+
+ @classmethod
+ def _upsert_public_procurements(cls, rows: list[dict[str, Any]]) -> dict[str, int]:
+ created_count = 0
+ updated_count = 0
+ skipped_count = 0
+
+ for row in rows:
+ organization = cls._resolve_organization(row)
+ purchase_number = cls._clean_string(row.get("purchase_number"))
+ if not purchase_number:
+ skipped_count += 1
+ continue
+
+ defaults = {
+ "law_type": cls._clean_string(row.get("law_type")),
+ "status": cls._clean_string(row.get("status")),
+ "contract_amount": cls._parse_decimal_value(
+ row.get("contract_amount"),
+ field_name="contract_amount",
+ allow_null=True,
+ ),
+ "contract_date": cls._parse_date_value(
+ row.get("contract_date"),
+ field_name="contract_date",
+ ),
+ "execution_start_date": cls._parse_date_value(
+ row.get("execution_start_date"),
+ field_name="execution_start_date",
+ allow_null=True,
+ ),
+ "execution_end_date": cls._parse_date_value(
+ row.get("execution_end_date"),
+ field_name="execution_end_date",
+ allow_null=True,
+ ),
+ "purchase_name": cls._clean_string(row.get("purchase_name")),
+ }
+ state = cls._upsert_external_row(
+ model=PublicProcurement,
+ lookup={
+ "organization": organization,
+ "purchase_number": purchase_number,
+ },
+ defaults=defaults,
+ )
+ if state == "created":
+ created_count += 1
+ elif state == "updated":
+ updated_count += 1
+
+ return {
+ "created": created_count,
+ "updated": updated_count,
+ "skipped": skipped_count,
+ }
+
+ @classmethod
+ def _upsert_arbitration_cases(cls, rows: list[dict[str, Any]]) -> dict[str, int]:
+ created_count = 0
+ updated_count = 0
+ skipped_count = 0
+
+ for row in rows:
+ organization = cls._resolve_organization(row)
+ case_number = cls._clean_string(row.get("case_number"))
+ if not case_number:
+ skipped_count += 1
+ continue
+
+ defaults = {
+ "court_name": cls._clean_string(row.get("court_name")),
+ "party_role": cls._clean_string(row.get("party_role")),
+ "status": cls._clean_string(row.get("status")),
+ "decision_date": cls._parse_date_value(
+ row.get("decision_date"),
+ field_name="decision_date",
+ ),
+ }
+ state = cls._upsert_external_row(
+ model=ArbitrationCase,
+ lookup={
+ "organization": organization,
+ "case_number": case_number,
+ },
+ defaults=defaults,
+ )
+ if state == "created":
+ created_count += 1
+ elif state == "updated":
+ updated_count += 1
+
+ return {
+ "created": created_count,
+ "updated": updated_count,
+ "skipped": skipped_count,
+ }
+
+ @classmethod
+ def _upsert_external_row(
+ cls,
+ *,
+ model,
+ lookup: dict[str, Any],
+ defaults: dict[str, Any],
+ ) -> str:
+ instance = model.objects.filter(**lookup).first()
+ if instance is None:
+ model.objects.create(**lookup, **defaults)
+ return "created"
+
+ update_fields: list[str] = []
+ for field_name, value in defaults.items():
+ if getattr(instance, field_name) != value:
+ setattr(instance, field_name, value)
+ update_fields.append(field_name)
+ if update_fields:
+ instance.save(update_fields=update_fields + ["updated_at"])
+ return "updated"
+ return "unchanged"
+
+ @classmethod
+ def _resolve_organization(cls, row: dict[str, Any]) -> Organization:
+ inn = cls._resolve_organization_inn(row)
+ if not inn:
+ raise ExchangeImportError(
+ "В строке внешних данных отсутствует organization_inn"
+ )
+ organization = Organization.objects.filter(inn=inn).first()
+ if organization is None:
+ raise ExchangeImportError(f"Организация с ИНН {inn} не найдена")
+ return organization
+
+ @classmethod
+ def _resolve_organization_inn(cls, row: dict[str, Any]) -> str:
+ organization_value = row.get("organization")
+ if isinstance(organization_value, dict):
+ nested_inn = organization_value.get("inn")
+ if nested_inn is not None:
+ return cls._clean_digits(nested_inn)
+
+ for key in ("organization_inn", "inn"):
+ if key in row:
+ return cls._clean_digits(row.get(key))
+ return ""
+
+ @staticmethod
+ def _get_first_value(row: dict[str, Any], aliases: tuple[str, ...]) -> Any:
+ for alias in aliases:
+ if alias in row:
+ return row.get(alias)
+ return None
+
+ @staticmethod
+ def _get_present_value(
+ row: dict[str, Any],
+ aliases: tuple[str, ...],
+ ) -> tuple[Any, bool]:
+ for alias in aliases:
+ if alias in row:
+ return row.get(alias), True
+ return None, False
+
+ @staticmethod
+ def _clean_digits(value: Any) -> str:
+ if value is None:
+ return ""
+ return "".join(char for char in str(value).strip() if char.isdigit())
+
+ @staticmethod
+ def _clean_string(value: Any) -> str:
+ if value is None:
+ return ""
+ return str(value).strip()
+
+ @classmethod
+ def _parse_date_value(
+ cls,
+ value: Any,
+ *,
+ field_name: str,
+ allow_null: bool = True,
+ ) -> date | None:
+ if value in (None, ""):
+ if allow_null:
+ return None
+ raise ExchangeImportError(f"Поле {field_name} обязательно")
+ if isinstance(value, date):
+ return value
+ try:
+ return date.fromisoformat(str(value))
+ except ValueError as exc:
+ raise ExchangeImportError(
+ f"Поле {field_name} должно быть датой в формате YYYY-MM-DD"
+ ) from exc
+
+ @classmethod
+ def _parse_bool_value(cls, value: Any, *, field_name: str) -> bool:
+ if isinstance(value, bool):
+ return value
+ normalized = str(value).strip().lower()
+ if normalized in {"1", "true", "yes", "y", "да"}:
+ return True
+ if normalized in {"0", "false", "no", "n", "нет"}:
+ return False
+ raise ExchangeImportError(f"Поле {field_name} должно быть bool-значением")
+
+ @classmethod
+ def _parse_int_value(cls, value: Any, *, field_name: str) -> int:
+ try:
+ parsed = int(value)
+ except (TypeError, ValueError) as exc:
+ raise ExchangeImportError(
+ f"Поле {field_name} должно быть целым числом"
+ ) from exc
+ if parsed < 0:
+ raise ExchangeImportError(f"Поле {field_name} не может быть отрицательным")
+ return parsed
+
+ @classmethod
+ def _parse_decimal_value(
+ cls,
+ value: Any,
+ *,
+ field_name: str,
+ allow_null: bool = False,
+ ) -> Decimal | None:
+ if value in (None, ""):
+ if allow_null:
+ return None
+ raise ExchangeImportError(f"Поле {field_name} обязательно")
+ try:
+ return Decimal(str(value))
+ except (InvalidOperation, TypeError, ValueError) as exc:
+ raise ExchangeImportError(
+ f"Поле {field_name} должно быть десятичным числом"
+ ) from exc
+
+ @classmethod
+ def _normalize_choice(
+ cls,
+ value: Any,
+ *,
+ field_name: str,
+ allowed_values: list[str],
+ ) -> str:
+ normalized = cls._clean_string(value)
+ if not normalized:
+ return ""
+ if normalized not in allowed_values:
+ raise ExchangeImportError(
+ f"Поле {field_name} содержит неподдерживаемое значение {normalized}"
+ )
+ return normalized
+
+ @classmethod
+ def _read_required_str(cls, payload: dict[str, Any], field_name: str) -> str:
+ value = cls._clean_string(payload.get(field_name))
+ if not value:
+ raise ExchangeImportError(
+ f"В пакете отсутствует обязательное поле {field_name}"
+ )
+ return value
diff --git a/src/apps/exchange/urls.py b/src/apps/exchange/urls.py
new file mode 100644
index 0000000..91e0038
--- /dev/null
+++ b/src/apps/exchange/urls.py
@@ -0,0 +1,13 @@
+"""URL routes for exchange uploads."""
+
+from django.urls import path
+
+from apps.exchange.views import ExchangePackageUploadView
+
+app_name = "exchange"
+
+urlpatterns = [
+ path(
+ "packages/upload/", ExchangePackageUploadView.as_view(), name="package-upload"
+ ),
+]
diff --git a/src/apps/exchange/views.py b/src/apps/exchange/views.py
new file mode 100644
index 0000000..4aab99e
--- /dev/null
+++ b/src/apps/exchange/views.py
@@ -0,0 +1,88 @@
+"""Views for exchange package upload."""
+
+from __future__ import annotations
+
+from django.conf import settings
+from django.utils.crypto import constant_time_compare
+from drf_yasg import openapi
+from drf_yasg.utils import swagger_auto_schema
+from rest_framework import status
+from rest_framework.parsers import MultiPartParser
+from rest_framework.permissions import AllowAny
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+from apps.exchange.models import ExchangeDeliveryChannel
+from apps.exchange.serializers import ExchangePackageUploadSerializer
+from apps.exchange.services import ExchangeImportError, ExchangePackageImportService
+
+EXCHANGE_TAG = "Обмен данными"
+
+
+class ExchangePackageUploadView(APIView):
+ """Accept encrypted exchange package uploads for dev integration."""
+
+ parser_classes = [MultiPartParser]
+ permission_classes = [AllowAny]
+
+ @swagger_auto_schema(
+ tags=[EXCHANGE_TAG],
+ operation_summary="Загрузить пакет обмена",
+ operation_description=(
+ "Принимает зашифрованный exchange-пакет (`.zip` или `.bin`) и "
+ "импортирует его в локальные модели."
+ ),
+ manual_parameters=[
+ openapi.Parameter(
+ "X-Exchange-Token",
+ openapi.IN_HEADER,
+ description="Shared token для dev-обмена и расшифровки пакета.",
+ type=openapi.TYPE_STRING,
+ required=True,
+ )
+ ],
+ request_body=ExchangePackageUploadSerializer,
+ responses={
+ 201: "Пакет импортирован",
+ 400: "Ошибка валидации",
+ 401: "Нет токена",
+ },
+ )
+ def post(self, request):
+ expected_token = str(settings.EXCHANGE_SHARED_TOKEN or "").strip()
+ provided_token = str(request.headers.get("X-Exchange-Token") or "").strip()
+
+ if not expected_token:
+ return Response(
+ {"detail": "EXCHANGE_SHARED_TOKEN не настроен"},
+ status=status.HTTP_503_SERVICE_UNAVAILABLE,
+ )
+ if not provided_token or not constant_time_compare(
+ provided_token, expected_token
+ ):
+ return Response(
+ {"detail": "Неверный X-Exchange-Token"},
+ status=status.HTTP_401_UNAUTHORIZED,
+ )
+
+ serializer = ExchangePackageUploadSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ try:
+ result = ExchangePackageImportService.import_package(
+ uploaded_file=serializer.validated_data["file"],
+ delivery_channel=ExchangeDeliveryChannel.API,
+ )
+ except ExchangeImportError as exc:
+ return Response(
+ {"file": [str(exc)]},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ return Response(
+ {
+ "message": "Пакет обмена успешно импортирован",
+ "result": result,
+ },
+ status=status.HTTP_201_CREATED,
+ )
diff --git a/src/apps/external_data/__init__.py b/src/apps/external_data/__init__.py
new file mode 100644
index 0000000..1b280c3
--- /dev/null
+++ b/src/apps/external_data/__init__.py
@@ -0,0 +1 @@
+"""External data registries and read APIs."""
diff --git a/src/apps/external_data/api.py b/src/apps/external_data/api.py
new file mode 100644
index 0000000..270bc78
--- /dev/null
+++ b/src/apps/external_data/api.py
@@ -0,0 +1,109 @@
+"""Read-only APIs for external data registries."""
+
+from django_filters import rest_framework as filters
+from rest_framework.permissions import IsAuthenticated
+
+from apps.core.viewsets import ClassicReadOnlyViewSet
+from apps.external_data.models import (
+ ArbitrationCase,
+ IndustrialProduct,
+ ProsecutorCheck,
+ PublicProcurement,
+)
+from apps.external_data.serializers import (
+ ArbitrationCaseSerializer,
+ IndustrialProductSerializer,
+ ProsecutorCheckSerializer,
+ PublicProcurementSerializer,
+)
+
+
+class IndustrialProductFilter(filters.FilterSet):
+ organization = filters.UUIDFilter(field_name="organization_id")
+ product_class = filters.CharFilter(lookup_expr="exact")
+
+ class Meta:
+ model = IndustrialProduct
+ fields = ["organization", "product_class"]
+
+
+class ProsecutorCheckFilter(filters.FilterSet):
+ organization = filters.UUIDFilter(field_name="organization_id")
+ law_type = filters.CharFilter(lookup_expr="exact")
+ start_date_from = filters.DateFilter(field_name="start_date", lookup_expr="gte")
+ start_date_to = filters.DateFilter(field_name="start_date", lookup_expr="lte")
+
+ class Meta:
+ model = ProsecutorCheck
+ fields = ["organization", "law_type", "start_date_from", "start_date_to"]
+
+
+class PublicProcurementFilter(filters.FilterSet):
+ organization = filters.UUIDFilter(field_name="organization_id")
+ law_type = filters.CharFilter(lookup_expr="exact")
+ contract_date_from = filters.DateFilter(
+ field_name="contract_date", lookup_expr="gte"
+ )
+ contract_date_to = filters.DateFilter(field_name="contract_date", lookup_expr="lte")
+
+ class Meta:
+ model = PublicProcurement
+ fields = ["organization", "law_type", "contract_date_from", "contract_date_to"]
+
+
+class ArbitrationCaseFilter(filters.FilterSet):
+ organization = filters.UUIDFilter(field_name="organization_id")
+ party_role = filters.CharFilter(lookup_expr="exact")
+ decision_date_from = filters.DateFilter(
+ field_name="decision_date", lookup_expr="gte"
+ )
+ decision_date_to = filters.DateFilter(field_name="decision_date", lookup_expr="lte")
+
+ class Meta:
+ model = ArbitrationCase
+ fields = [
+ "organization",
+ "party_role",
+ "decision_date_from",
+ "decision_date_to",
+ ]
+
+
+class IndustrialProductViewSet(ClassicReadOnlyViewSet[IndustrialProduct]):
+ queryset = IndustrialProduct.objects.select_related("organization").all()
+ serializer_class = IndustrialProductSerializer
+ permission_classes = [IsAuthenticated]
+ filterset_class = IndustrialProductFilter
+ search_fields = ["product_name", "okpd2_code", "tnved_code", "registry_number"]
+ ordering_fields = ["product_name", "created_at"]
+ ordering = ["product_name"]
+
+
+class ProsecutorCheckViewSet(ClassicReadOnlyViewSet[ProsecutorCheck]):
+ queryset = ProsecutorCheck.objects.select_related("organization").all()
+ serializer_class = ProsecutorCheckSerializer
+ permission_classes = [IsAuthenticated]
+ filterset_class = ProsecutorCheckFilter
+ search_fields = ["registration_number", "control_authority", "prosecutor_office"]
+ ordering_fields = ["start_date", "created_at"]
+ ordering = ["-start_date"]
+
+
+class PublicProcurementViewSet(ClassicReadOnlyViewSet[PublicProcurement]):
+ queryset = PublicProcurement.objects.select_related("organization").all()
+ serializer_class = PublicProcurementSerializer
+ permission_classes = [IsAuthenticated]
+ filterset_class = PublicProcurementFilter
+ search_fields = ["purchase_number", "purchase_name"]
+ ordering_fields = ["contract_date", "created_at", "contract_amount"]
+ ordering = ["-contract_date"]
+
+
+class ArbitrationCaseViewSet(ClassicReadOnlyViewSet[ArbitrationCase]):
+ queryset = ArbitrationCase.objects.select_related("organization").all()
+ serializer_class = ArbitrationCaseSerializer
+ permission_classes = [IsAuthenticated]
+ filterset_class = ArbitrationCaseFilter
+ search_fields = ["case_number", "court_name"]
+ ordering_fields = ["decision_date", "created_at"]
+ ordering = ["-decision_date"]
diff --git a/src/apps/external_data/apps.py b/src/apps/external_data/apps.py
new file mode 100644
index 0000000..8eb9e3c
--- /dev/null
+++ b/src/apps/external_data/apps.py
@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class ExternalDataConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "apps.external_data"
+ verbose_name = "Внешние данные"
diff --git a/src/apps/external_data/migrations/0001_initial.py b/src/apps/external_data/migrations/0001_initial.py
new file mode 100644
index 0000000..623d063
--- /dev/null
+++ b/src/apps/external_data/migrations/0001_initial.py
@@ -0,0 +1,89 @@
+# Generated by Django 3.2.25 on 2026-04-07 13:26
+
+from django.db import migrations, models
+import django.db.models.deletion
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('organization', '0003_auto_20260407_1326'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='PublicProcurement',
+ fields=[
+ ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время создания записи', verbose_name='создано')),
+ ('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления', verbose_name='обновлено')),
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='ID')),
+ ('purchase_number', models.CharField(db_index=True, max_length=64, verbose_name='номер закупки')),
+ ('law_type', models.CharField(db_index=True, max_length=32, verbose_name='тип закона')),
+ ('status', models.CharField(db_index=True, max_length=64, verbose_name='статус')),
+ ('contract_amount', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='сумма контракта')),
+ ('contract_date', models.DateField(db_index=True, verbose_name='дата контракта')),
+ ('execution_start_date', models.DateField(blank=True, null=True, verbose_name='дата начала исполнения')),
+ ('execution_end_date', models.DateField(blank=True, null=True, verbose_name='дата окончания исполнения')),
+ ('purchase_name', models.CharField(max_length=500, verbose_name='предмет закупки')),
+ ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='public_procurements', to='organization.organization', verbose_name='организация')),
+ ],
+ options={
+ 'ordering': ['-contract_date', 'purchase_number'],
+ },
+ ),
+ migrations.CreateModel(
+ name='ProsecutorCheck',
+ fields=[
+ ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время создания записи', verbose_name='создано')),
+ ('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления', verbose_name='обновлено')),
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='ID')),
+ ('registration_number', models.CharField(db_index=True, max_length=64, verbose_name='регистрационный номер')),
+ ('law_type', models.CharField(db_index=True, max_length=32, verbose_name='тип закона')),
+ ('control_authority', models.CharField(max_length=255, verbose_name='контрольный орган')),
+ ('prosecutor_office', models.CharField(blank=True, default='', max_length=255, verbose_name='прокуратура')),
+ ('start_date', models.DateField(db_index=True, verbose_name='дата начала')),
+ ('status', models.CharField(db_index=True, max_length=64, verbose_name='статус')),
+ ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='prosecutor_checks', to='organization.organization', verbose_name='организация')),
+ ],
+ options={
+ 'ordering': ['-start_date', 'registration_number'],
+ },
+ ),
+ migrations.CreateModel(
+ name='IndustrialProduct',
+ fields=[
+ ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время создания записи', verbose_name='создано')),
+ ('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления', verbose_name='обновлено')),
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='ID')),
+ ('product_name', models.CharField(db_index=True, max_length=500, verbose_name='наименование продукции')),
+ ('product_class', models.CharField(db_index=True, max_length=100, verbose_name='класс продукции')),
+ ('okpd2_code', models.CharField(blank=True, default='', max_length=32, verbose_name='код ОКПД2')),
+ ('tnved_code', models.CharField(blank=True, default='', max_length=32, verbose_name='код ТНВЭД')),
+ ('registry_number', models.CharField(blank=True, default='', max_length=64, verbose_name='реестровый номер')),
+ ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='industrial_products', to='organization.organization', verbose_name='организация')),
+ ],
+ options={
+ 'ordering': ['product_name', 'created_at'],
+ },
+ ),
+ migrations.CreateModel(
+ name='ArbitrationCase',
+ fields=[
+ ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время создания записи', verbose_name='создано')),
+ ('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления', verbose_name='обновлено')),
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='ID')),
+ ('case_number', models.CharField(db_index=True, max_length=64, verbose_name='номер дела')),
+ ('court_name', models.CharField(max_length=255, verbose_name='суд')),
+ ('party_role', models.CharField(db_index=True, max_length=64, verbose_name='роль стороны')),
+ ('status', models.CharField(db_index=True, max_length=64, verbose_name='статус')),
+ ('decision_date', models.DateField(db_index=True, verbose_name='дата решения')),
+ ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='arbitration_cases', to='organization.organization', verbose_name='организация')),
+ ],
+ options={
+ 'ordering': ['-decision_date', 'case_number'],
+ },
+ ),
+ ]
diff --git a/src/apps/external_data/migrations/__init__.py b/src/apps/external_data/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/apps/external_data/models.py b/src/apps/external_data/models.py
new file mode 100644
index 0000000..02f1602
--- /dev/null
+++ b/src/apps/external_data/models.py
@@ -0,0 +1,113 @@
+"""Models for external read-only registries."""
+
+from __future__ import annotations
+
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+from apps.core.mixins import TimestampMixin, UUIDPrimaryKeyMixin
+from apps.organization.models import Organization
+
+
+class IndustrialProduct(UUIDPrimaryKeyMixin, TimestampMixin, models.Model):
+ organization = models.ForeignKey(
+ Organization,
+ on_delete=models.CASCADE,
+ related_name="industrial_products",
+ verbose_name=_("организация"),
+ )
+ product_name = models.CharField(
+ _("наименование продукции"), max_length=500, db_index=True
+ )
+ product_class = models.CharField(
+ _("класс продукции"), max_length=100, db_index=True
+ )
+ okpd2_code = models.CharField(_("код ОКПД2"), max_length=32, blank=True, default="")
+ tnved_code = models.CharField(_("код ТНВЭД"), max_length=32, blank=True, default="")
+ registry_number = models.CharField(
+ _("реестровый номер"), max_length=64, blank=True, default=""
+ )
+
+ class Meta:
+ ordering = ["product_name", "created_at"]
+
+ def __str__(self) -> str:
+ return f"{self.product_name} ({self.organization_id})"
+
+
+class ProsecutorCheck(UUIDPrimaryKeyMixin, TimestampMixin, models.Model):
+ organization = models.ForeignKey(
+ Organization,
+ on_delete=models.CASCADE,
+ related_name="prosecutor_checks",
+ verbose_name=_("организация"),
+ )
+ registration_number = models.CharField(
+ _("регистрационный номер"), max_length=64, db_index=True
+ )
+ law_type = models.CharField(_("тип закона"), max_length=32, db_index=True)
+ control_authority = models.CharField(_("контрольный орган"), max_length=255)
+ prosecutor_office = models.CharField(
+ _("прокуратура"), max_length=255, blank=True, default=""
+ )
+ start_date = models.DateField(_("дата начала"), db_index=True)
+ status = models.CharField(_("статус"), max_length=64, db_index=True)
+
+ class Meta:
+ ordering = ["-start_date", "registration_number"]
+
+ def __str__(self) -> str:
+ return f"{self.registration_number} ({self.organization_id})"
+
+
+class PublicProcurement(UUIDPrimaryKeyMixin, TimestampMixin, models.Model):
+ organization = models.ForeignKey(
+ Organization,
+ on_delete=models.CASCADE,
+ related_name="public_procurements",
+ verbose_name=_("организация"),
+ )
+ purchase_number = models.CharField(_("номер закупки"), max_length=64, db_index=True)
+ law_type = models.CharField(_("тип закона"), max_length=32, db_index=True)
+ status = models.CharField(_("статус"), max_length=64, db_index=True)
+ contract_amount = models.DecimalField(
+ _("сумма контракта"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+ contract_date = models.DateField(_("дата контракта"), db_index=True)
+ execution_start_date = models.DateField(
+ _("дата начала исполнения"), null=True, blank=True
+ )
+ execution_end_date = models.DateField(
+ _("дата окончания исполнения"), null=True, blank=True
+ )
+ purchase_name = models.CharField(_("предмет закупки"), max_length=500)
+
+ class Meta:
+ ordering = ["-contract_date", "purchase_number"]
+
+ def __str__(self) -> str:
+ return f"{self.purchase_number} ({self.organization_id})"
+
+
+class ArbitrationCase(UUIDPrimaryKeyMixin, TimestampMixin, models.Model):
+ organization = models.ForeignKey(
+ Organization,
+ on_delete=models.CASCADE,
+ related_name="arbitration_cases",
+ verbose_name=_("организация"),
+ )
+ case_number = models.CharField(_("номер дела"), max_length=64, db_index=True)
+ court_name = models.CharField(_("суд"), max_length=255)
+ party_role = models.CharField(_("роль стороны"), max_length=64, db_index=True)
+ status = models.CharField(_("статус"), max_length=64, db_index=True)
+ decision_date = models.DateField(_("дата решения"), db_index=True)
+
+ class Meta:
+ ordering = ["-decision_date", "case_number"]
+
+ def __str__(self) -> str:
+ return f"{self.case_number} ({self.organization_id})"
diff --git a/src/apps/external_data/serializers.py b/src/apps/external_data/serializers.py
new file mode 100644
index 0000000..8b9fcaf
--- /dev/null
+++ b/src/apps/external_data/serializers.py
@@ -0,0 +1,78 @@
+"""Serializers for external data registries."""
+
+from rest_framework import serializers
+
+from apps.external_data.models import (
+ ArbitrationCase,
+ IndustrialProduct,
+ ProsecutorCheck,
+ PublicProcurement,
+)
+
+
+class IndustrialProductSerializer(serializers.ModelSerializer):
+ organization = serializers.UUIDField(source="organization_id", read_only=True)
+
+ class Meta:
+ model = IndustrialProduct
+ fields = [
+ "id",
+ "organization",
+ "product_name",
+ "product_class",
+ "okpd2_code",
+ "tnved_code",
+ "registry_number",
+ ]
+
+
+class ProsecutorCheckSerializer(serializers.ModelSerializer):
+ organization = serializers.UUIDField(source="organization_id", read_only=True)
+
+ class Meta:
+ model = ProsecutorCheck
+ fields = [
+ "id",
+ "organization",
+ "registration_number",
+ "law_type",
+ "control_authority",
+ "prosecutor_office",
+ "start_date",
+ "status",
+ ]
+
+
+class PublicProcurementSerializer(serializers.ModelSerializer):
+ organization = serializers.UUIDField(source="organization_id", read_only=True)
+
+ class Meta:
+ model = PublicProcurement
+ fields = [
+ "id",
+ "organization",
+ "purchase_number",
+ "law_type",
+ "status",
+ "contract_amount",
+ "contract_date",
+ "execution_start_date",
+ "execution_end_date",
+ "purchase_name",
+ ]
+
+
+class ArbitrationCaseSerializer(serializers.ModelSerializer):
+ organization = serializers.UUIDField(source="organization_id", read_only=True)
+
+ class Meta:
+ model = ArbitrationCase
+ fields = [
+ "id",
+ "organization",
+ "case_number",
+ "court_name",
+ "party_role",
+ "status",
+ "decision_date",
+ ]
diff --git a/src/apps/external_data/urls.py b/src/apps/external_data/urls.py
new file mode 100644
index 0000000..7b64cca
--- /dev/null
+++ b/src/apps/external_data/urls.py
@@ -0,0 +1,31 @@
+"""Root routes for external data APIs."""
+
+from django.urls import include, path
+from rest_framework.routers import DefaultRouter
+
+from apps.external_data.api import (
+ ArbitrationCaseViewSet,
+ IndustrialProductViewSet,
+ ProsecutorCheckViewSet,
+ PublicProcurementViewSet,
+)
+
+app_name = "external_data"
+
+router = DefaultRouter()
+router.register(
+ "industrial-products", IndustrialProductViewSet, basename="industrial-products"
+)
+router.register(
+ "prosecutor-checks", ProsecutorCheckViewSet, basename="prosecutor-checks"
+)
+router.register(
+ "public-procurements", PublicProcurementViewSet, basename="public-procurements"
+)
+router.register(
+ "arbitration-cases", ArbitrationCaseViewSet, basename="arbitration-cases"
+)
+
+urlpatterns = [
+ path("", include(router.urls)),
+]
diff --git a/src/apps/form_1/admin.py b/src/apps/form_1/admin.py
index 5e00e4c..3a6929f 100644
--- a/src/apps/form_1/admin.py
+++ b/src/apps/form_1/admin.py
@@ -1,9 +1,10 @@
"""Административный интерфейс для формы Ф-1."""
+from django.contrib import admin
+
from apps.core.admin_mixins import HiddenFromAdminIndexMixin
from apps.core.admin_paper_forms import PaperFormPreviewAdminMixin
from apps.form_1.models import FormF1Record
-from django.contrib import admin
@admin.register(FormF1Record)
@@ -86,7 +87,13 @@ class FormF1RecordAdmin(
"civilian_output_actual",
"created_at",
]
- list_filter = ["report_year", "report_quarter", "is_active_version", "load_batch", "created_at"]
+ list_filter = [
+ "report_year",
+ "report_quarter",
+ "is_active_version",
+ "load_batch",
+ "created_at",
+ ]
search_fields = ["organization__name", "organization__inn"]
readonly_fields = [
"id",
@@ -105,7 +112,13 @@ class FormF1RecordAdmin(
(
"Основная информация",
{
- "fields": ["id", "organization", "load_batch", "report_year", "report_quarter"],
+ "fields": [
+ "id",
+ "organization",
+ "load_batch",
+ "report_year",
+ "report_quarter",
+ ],
},
),
(
diff --git a/src/apps/form_1/api.py b/src/apps/form_1/api.py
index 544c3fd..1cb1ecc 100644
--- a/src/apps/form_1/api.py
+++ b/src/apps/form_1/api.py
@@ -8,6 +8,17 @@ API для формы Ф-1.
import logging
+from django.core.files.storage import default_storage
+from django_filters import rest_framework as filters
+from drf_yasg.utils import swagger_auto_schema
+from rest_framework import status
+from rest_framework.decorators import action
+from rest_framework.parsers import MultiPartParser
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.request import Request
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
from apps.core.response import api_response
from apps.core.viewsets import ReadOnlyViewSet
from apps.form_1.models import FormF1Record
@@ -20,16 +31,6 @@ from apps.form_1.serializers import (
)
from apps.form_1.services import FormF1Service, parse_form_f1_file
from apps.form_1.tasks import process_form_f1_file
-from django.core.files.storage import default_storage
-from django_filters import rest_framework as filters
-from drf_yasg.utils import swagger_auto_schema
-from rest_framework import status
-from rest_framework.decorators import action
-from rest_framework.parsers import MultiPartParser
-from rest_framework.permissions import IsAuthenticated
-from rest_framework.request import Request
-from rest_framework.response import Response
-from rest_framework.views import APIView
logger = logging.getLogger(__name__)
diff --git a/src/apps/form_1/models.py b/src/apps/form_1/models.py
index c924541..e50f1b9 100644
--- a/src/apps/form_1/models.py
+++ b/src/apps/form_1/models.py
@@ -7,11 +7,12 @@
import uuid
-from apps.core.mixins import ReportingPeriodMixin, TimestampMixin
-from apps.organization.models import Organization
from django.db import models
from django.utils.translation import gettext_lazy as _
+from apps.core.mixins import ReportingPeriodMixin, TimestampMixin
+from apps.organization.models import Organization
+
class FormF1Record(ReportingPeriodMixin, TimestampMixin, models.Model):
"""
diff --git a/src/apps/form_1/serializers.py b/src/apps/form_1/serializers.py
index d8b4d97..8d61fb2 100644
--- a/src/apps/form_1/serializers.py
+++ b/src/apps/form_1/serializers.py
@@ -8,9 +8,10 @@
- FormF1ParseResultSerializer - результат парсинга
"""
+from rest_framework import serializers
+
from apps.form_1.models import FormF1Record
from apps.organization.serializers import OrganizationListSerializer
-from rest_framework import serializers
class FormF1RecordSerializer(serializers.ModelSerializer):
diff --git a/src/apps/form_1/services.py b/src/apps/form_1/services.py
index 898ebe3..081da1d 100644
--- a/src/apps/form_1/services.py
+++ b/src/apps/form_1/services.py
@@ -9,6 +9,9 @@
import logging
from typing import Any
+from django.db import transaction
+from django.db.models import Max
+
from apps.core.excel import (
BaseExcelParser,
ColumnMapping,
@@ -19,8 +22,6 @@ from apps.core.reporting import ReportingPeriodParserMixin, VersionedReportServi
from apps.core.services import BaseService, BulkOperationsMixin
from apps.form_1.models import FormF1Record
from apps.organization.services import OrganizationService
-from django.db import transaction
-from django.db.models import Max
logger = logging.getLogger(__name__)
diff --git a/src/apps/form_1/tasks.py b/src/apps/form_1/tasks.py
index 9065880..c2bc917 100644
--- a/src/apps/form_1/tasks.py
+++ b/src/apps/form_1/tasks.py
@@ -8,12 +8,13 @@ Celery задачи для формы Ф-1.
import logging
from contextlib import suppress
-from apps.core.tasks import TrackedTask
-from apps.form_1.services import parse_form_f1_file
from celery import shared_task
from django.core.files.storage import default_storage
from django.utils import timezone
+from apps.core.tasks import TrackedTask
+from apps.form_1.services import parse_form_f1_file
+
logger = logging.getLogger(__name__)
diff --git a/src/apps/form_1/urls.py b/src/apps/form_1/urls.py
index 531bc09..73e262d 100644
--- a/src/apps/form_1/urls.py
+++ b/src/apps/form_1/urls.py
@@ -1,9 +1,10 @@
"""URL маршруты для формы Ф-1."""
-from apps.form_1.api import FormF1RecordViewSet, FormF1UploadView
from django.urls import include, path
from rest_framework.routers import DefaultRouter
+from apps.form_1.api import FormF1RecordViewSet, FormF1UploadView
+
router = DefaultRouter()
router.register("records", FormF1RecordViewSet, basename="form-f1-record")
diff --git a/src/apps/form_2/admin.py b/src/apps/form_2/admin.py
index 435e070..951b34e 100644
--- a/src/apps/form_2/admin.py
+++ b/src/apps/form_2/admin.py
@@ -2,10 +2,11 @@
Админка формы Ф-2.
"""
+from django.contrib import admin
+
from apps.core.admin_mixins import HiddenFromAdminIndexMixin
from apps.core.admin_paper_forms import PaperFormPreviewAdminMixin
from apps.form_2.models import FormF2Record
-from django.contrib import admin
@admin.register(FormF2Record)
@@ -124,7 +125,13 @@ class FormF2RecordAdmin(
"net_profit",
"created_at",
]
- list_filter = ["report_year", "report_quarter", "is_active_version", "load_batch", "created_at"]
+ list_filter = [
+ "report_year",
+ "report_quarter",
+ "is_active_version",
+ "load_batch",
+ "created_at",
+ ]
search_fields = ["organization__name", "organization__inn"]
readonly_fields = [
"id",
@@ -142,7 +149,15 @@ class FormF2RecordAdmin(
fieldsets = [
(
"Основная информация",
- {"fields": ["id", "organization", "load_batch", "report_year", "report_quarter"]},
+ {
+ "fields": [
+ "id",
+ "organization",
+ "load_batch",
+ "report_year",
+ "report_quarter",
+ ]
+ },
),
(
"Версия записи",
diff --git a/src/apps/form_2/api.py b/src/apps/form_2/api.py
index 371d8b1..77db24b 100644
--- a/src/apps/form_2/api.py
+++ b/src/apps/form_2/api.py
@@ -8,6 +8,12 @@ API формы Ф-2.
import logging
+from rest_framework import status
+from rest_framework.parsers import MultiPartParser
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
from apps.core.viewsets import ReadOnlyViewSet
from apps.form_2.models import FormF2Record
from apps.form_2.serializers import (
@@ -18,11 +24,6 @@ from apps.form_2.serializers import (
)
from apps.form_2.services import parse_form_f2_file
from apps.form_2.tasks import process_form_f2_file
-from rest_framework import status
-from rest_framework.parsers import MultiPartParser
-from rest_framework.permissions import IsAuthenticated
-from rest_framework.response import Response
-from rest_framework.views import APIView
logger = logging.getLogger(__name__)
diff --git a/src/apps/form_2/models.py b/src/apps/form_2/models.py
index fdca023..65082aa 100644
--- a/src/apps/form_2/models.py
+++ b/src/apps/form_2/models.py
@@ -7,11 +7,12 @@
import uuid
-from apps.core.mixins import ReportingPeriodMixin, TimestampMixin
-from apps.organization.models import Organization
from django.db import models
from django.utils.translation import gettext_lazy as _
+from apps.core.mixins import ReportingPeriodMixin, TimestampMixin
+from apps.organization.models import Organization
+
class FormF2Record(ReportingPeriodMixin, TimestampMixin, models.Model):
"""
diff --git a/src/apps/form_2/serializers.py b/src/apps/form_2/serializers.py
index da9e0fd..3779324 100644
--- a/src/apps/form_2/serializers.py
+++ b/src/apps/form_2/serializers.py
@@ -7,9 +7,10 @@
- FormF2ParseResultSerializer - результат парсинга
"""
+from rest_framework import serializers
+
from apps.form_2.models import FormF2Record
from apps.organization.serializers import OrganizationSerializer
-from rest_framework import serializers
class FormF2RecordSerializer(serializers.ModelSerializer):
diff --git a/src/apps/form_2/services.py b/src/apps/form_2/services.py
index f5ad9fa..fa57644 100644
--- a/src/apps/form_2/services.py
+++ b/src/apps/form_2/services.py
@@ -9,6 +9,9 @@
import logging
from typing import Any
+from django.db import transaction
+from django.db.models import Count, Max
+
from apps.core.excel import (
BaseExcelParser,
ColumnMapping,
@@ -19,8 +22,6 @@ from apps.core.reporting import ReportingPeriodParserMixin, VersionedReportServi
from apps.core.services import BaseService, BulkOperationsMixin
from apps.form_2.models import FormF2Record
from apps.organization.services import OrganizationService
-from django.db import transaction
-from django.db.models import Count, Max
logger = logging.getLogger(__name__)
diff --git a/src/apps/form_2/tasks.py b/src/apps/form_2/tasks.py
index d292b1f..0db27b2 100644
--- a/src/apps/form_2/tasks.py
+++ b/src/apps/form_2/tasks.py
@@ -7,9 +7,10 @@ Celery задачи для формы Ф-2.
import logging
+from celery import shared_task
+
from apps.core.tasks import TrackedTask
from apps.form_2.services import FormF2Parser
-from celery import shared_task
logger = logging.getLogger(__name__)
diff --git a/src/apps/form_2/urls.py b/src/apps/form_2/urls.py
index 14f1d3e..7333ae3 100644
--- a/src/apps/form_2/urls.py
+++ b/src/apps/form_2/urls.py
@@ -2,10 +2,11 @@
URL маршруты формы Ф-2.
"""
-from apps.form_2.api import FormF2RecordViewSet, FormF2UploadView
from django.urls import include, path
from rest_framework.routers import DefaultRouter
+from apps.form_2.api import FormF2RecordViewSet, FormF2UploadView
+
router = DefaultRouter()
router.register("records", FormF2RecordViewSet, basename="form-f2-records")
diff --git a/src/apps/form_3/admin.py b/src/apps/form_3/admin.py
index b0384af..e810843 100644
--- a/src/apps/form_3/admin.py
+++ b/src/apps/form_3/admin.py
@@ -1,9 +1,10 @@
"""Админка формы Ф-3."""
+from django.contrib import admin
+
from apps.core.admin_mixins import HiddenFromAdminIndexMixin
from apps.core.admin_paper_forms import PaperFormPreviewAdminMixin
from apps.form_3.models import FormF3Record
-from django.contrib import admin
@admin.register(FormF3Record)
@@ -64,7 +65,13 @@ class FormF3RecordAdmin(
"physical_wear_percent",
"created_at",
]
- list_filter = ["report_year", "report_quarter", "is_active_version", "load_batch", "created_at"]
+ list_filter = [
+ "report_year",
+ "report_quarter",
+ "is_active_version",
+ "load_batch",
+ "created_at",
+ ]
search_fields = ["organization__name", "organization__inn"]
readonly_fields = [
"id",
@@ -82,7 +89,15 @@ class FormF3RecordAdmin(
fieldsets = [
(
"Основная информация",
- {"fields": ["id", "organization", "load_batch", "report_year", "report_quarter"]},
+ {
+ "fields": [
+ "id",
+ "organization",
+ "load_batch",
+ "report_year",
+ "report_quarter",
+ ]
+ },
),
(
"Версия записи",
diff --git a/src/apps/form_3/api.py b/src/apps/form_3/api.py
index c7c1ccf..a363702 100644
--- a/src/apps/form_3/api.py
+++ b/src/apps/form_3/api.py
@@ -8,6 +8,12 @@ API формы Ф-3.
import logging
+from rest_framework import status
+from rest_framework.parsers import MultiPartParser
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
from apps.core.viewsets import ReadOnlyViewSet
from apps.form_3.models import FormF3Record
from apps.form_3.serializers import (
@@ -18,11 +24,6 @@ from apps.form_3.serializers import (
)
from apps.form_3.services import parse_form_f3_file
from apps.form_3.tasks import process_form_f3_file
-from rest_framework import status
-from rest_framework.parsers import MultiPartParser
-from rest_framework.permissions import IsAuthenticated
-from rest_framework.response import Response
-from rest_framework.views import APIView
logger = logging.getLogger(__name__)
diff --git a/src/apps/form_3/models.py b/src/apps/form_3/models.py
index c7d7b27..5c51989 100644
--- a/src/apps/form_3/models.py
+++ b/src/apps/form_3/models.py
@@ -7,11 +7,12 @@
import uuid
-from apps.core.mixins import ReportingPeriodMixin, TimestampMixin
-from apps.organization.models import Organization
from django.db import models
from django.utils.translation import gettext_lazy as _
+from apps.core.mixins import ReportingPeriodMixin, TimestampMixin
+from apps.organization.models import Organization
+
class FormF3Record(ReportingPeriodMixin, TimestampMixin, models.Model):
"""
diff --git a/src/apps/form_3/serializers.py b/src/apps/form_3/serializers.py
index 0aa6d51..423a161 100644
--- a/src/apps/form_3/serializers.py
+++ b/src/apps/form_3/serializers.py
@@ -7,9 +7,10 @@
- FormF3ParseResultSerializer - результат парсинга
"""
+from rest_framework import serializers
+
from apps.form_3.models import FormF3Record
from apps.organization.serializers import OrganizationSerializer
-from rest_framework import serializers
class FormF3RecordSerializer(serializers.ModelSerializer):
diff --git a/src/apps/form_3/services.py b/src/apps/form_3/services.py
index a853694..b49e9f1 100644
--- a/src/apps/form_3/services.py
+++ b/src/apps/form_3/services.py
@@ -9,6 +9,9 @@
import logging
from typing import Any
+from django.db import transaction
+from django.db.models import Count, Max
+
from apps.core.excel import (
BaseExcelParser,
ColumnMapping,
@@ -19,8 +22,6 @@ from apps.core.reporting import ReportingPeriodParserMixin, VersionedReportServi
from apps.core.services import BaseService, BulkOperationsMixin
from apps.form_3.models import FormF3Record
from apps.organization.services import OrganizationService
-from django.db import transaction
-from django.db.models import Count, Max
logger = logging.getLogger(__name__)
diff --git a/src/apps/form_3/tasks.py b/src/apps/form_3/tasks.py
index 1423678..5ad0b24 100644
--- a/src/apps/form_3/tasks.py
+++ b/src/apps/form_3/tasks.py
@@ -7,9 +7,10 @@ Celery задачи для формы Ф-3.
import logging
+from celery import shared_task
+
from apps.core.tasks import TrackedTask
from apps.form_3.services import FormF3Parser
-from celery import shared_task
logger = logging.getLogger(__name__)
diff --git a/src/apps/form_3/urls.py b/src/apps/form_3/urls.py
index 8c25423..b46b0bd 100644
--- a/src/apps/form_3/urls.py
+++ b/src/apps/form_3/urls.py
@@ -2,10 +2,11 @@
URL маршруты формы Ф-3.
"""
-from apps.form_3.api import FormF3RecordViewSet, FormF3UploadView
from django.urls import include, path
from rest_framework.routers import DefaultRouter
+from apps.form_3.api import FormF3RecordViewSet, FormF3UploadView
+
router = DefaultRouter()
router.register("records", FormF3RecordViewSet, basename="form-f3-records")
diff --git a/src/apps/form_4/admin.py b/src/apps/form_4/admin.py
index 323fdda..21df592 100644
--- a/src/apps/form_4/admin.py
+++ b/src/apps/form_4/admin.py
@@ -1,9 +1,10 @@
"""Админка формы Ф-4."""
+from django.contrib import admin
+
from apps.core.admin_mixins import HiddenFromAdminIndexMixin
from apps.core.admin_paper_forms import PaperFormPreviewAdminMixin
from apps.form_4.models import FormF4Record
-from django.contrib import admin
@admin.register(FormF4Record)
@@ -69,7 +70,13 @@ class FormF4RecordAdmin(
"ebitda_rsbu",
"created_at",
]
- list_filter = ["report_year", "report_quarter", "is_active_version", "load_batch", "created_at"]
+ list_filter = [
+ "report_year",
+ "report_quarter",
+ "is_active_version",
+ "load_batch",
+ "created_at",
+ ]
search_fields = ["organization__name", "organization__inn"]
readonly_fields = [
"id",
@@ -87,7 +94,15 @@ class FormF4RecordAdmin(
fieldsets = [
(
"Основная информация",
- {"fields": ["id", "organization", "load_batch", "report_year", "report_quarter"]},
+ {
+ "fields": [
+ "id",
+ "organization",
+ "load_batch",
+ "report_year",
+ "report_quarter",
+ ]
+ },
),
(
"Версия записи",
diff --git a/src/apps/form_4/api.py b/src/apps/form_4/api.py
index 1b5c0e7..d5cd2ce 100644
--- a/src/apps/form_4/api.py
+++ b/src/apps/form_4/api.py
@@ -2,6 +2,12 @@
import logging
+from rest_framework import status
+from rest_framework.parsers import MultiPartParser
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
from apps.core.viewsets import ReadOnlyViewSet
from apps.form_4.models import FormF4Record
from apps.form_4.serializers import (
@@ -12,11 +18,6 @@ from apps.form_4.serializers import (
)
from apps.form_4.services import parse_form_f4_file
from apps.form_4.tasks import process_form_f4_file
-from rest_framework import status
-from rest_framework.parsers import MultiPartParser
-from rest_framework.permissions import IsAuthenticated
-from rest_framework.response import Response
-from rest_framework.views import APIView
logger = logging.getLogger(__name__)
BACKGROUND_THRESHOLD = 1024 * 1024
diff --git a/src/apps/form_4/models.py b/src/apps/form_4/models.py
index 96a50ad..2f21977 100644
--- a/src/apps/form_4/models.py
+++ b/src/apps/form_4/models.py
@@ -7,11 +7,12 @@
import uuid
-from apps.core.mixins import ReportingPeriodMixin, TimestampMixin
-from apps.organization.models import Organization
from django.db import models
from django.utils.translation import gettext_lazy as _
+from apps.core.mixins import ReportingPeriodMixin, TimestampMixin
+from apps.organization.models import Organization
+
class FormF4Record(ReportingPeriodMixin, TimestampMixin, models.Model):
"""
diff --git a/src/apps/form_4/serializers.py b/src/apps/form_4/serializers.py
index b902dd3..5885d97 100644
--- a/src/apps/form_4/serializers.py
+++ b/src/apps/form_4/serializers.py
@@ -1,8 +1,9 @@
"""Сериализаторы формы Ф-4."""
+from rest_framework import serializers
+
from apps.form_4.models import FormF4Record
from apps.organization.serializers import OrganizationSerializer
-from rest_framework import serializers
class FormF4RecordSerializer(serializers.ModelSerializer):
diff --git a/src/apps/form_4/services.py b/src/apps/form_4/services.py
index 2ae80f3..6dcff18 100644
--- a/src/apps/form_4/services.py
+++ b/src/apps/form_4/services.py
@@ -9,6 +9,9 @@
import logging
from typing import Any
+from django.db import transaction
+from django.db.models import Count, Max
+
from apps.core.excel import (
BaseExcelParser,
ColumnMapping,
@@ -19,8 +22,6 @@ from apps.core.reporting import ReportingPeriodParserMixin, VersionedReportServi
from apps.core.services import BaseService, BulkOperationsMixin
from apps.form_4.models import FormF4Record
from apps.organization.services import OrganizationService
-from django.db import transaction
-from django.db.models import Count, Max
logger = logging.getLogger(__name__)
diff --git a/src/apps/form_4/tasks.py b/src/apps/form_4/tasks.py
index 9e337d7..94ba184 100644
--- a/src/apps/form_4/tasks.py
+++ b/src/apps/form_4/tasks.py
@@ -3,9 +3,10 @@
import logging
from io import BytesIO
+from celery import shared_task
+
from apps.core.tasks import TrackedTask
from apps.form_4.services import FormF4Parser
-from celery import shared_task
logger = logging.getLogger(__name__)
diff --git a/src/apps/form_4/urls.py b/src/apps/form_4/urls.py
index cc769bf..ad95ad8 100644
--- a/src/apps/form_4/urls.py
+++ b/src/apps/form_4/urls.py
@@ -1,9 +1,10 @@
"""URL маршруты формы Ф-4."""
-from apps.form_4.api import FormF4RecordViewSet, FormF4UploadView
from django.urls import include, path
from rest_framework.routers import DefaultRouter
+from apps.form_4.api import FormF4RecordViewSet, FormF4UploadView
+
router = DefaultRouter()
router.register("records", FormF4RecordViewSet, basename="form-f4-records")
diff --git a/src/apps/form_5/admin.py b/src/apps/form_5/admin.py
index e7f7445..08e8843 100644
--- a/src/apps/form_5/admin.py
+++ b/src/apps/form_5/admin.py
@@ -1,9 +1,10 @@
"""Админка формы Ф-5."""
+from django.contrib import admin
+
from apps.core.admin_mixins import HiddenFromAdminIndexMixin
from apps.core.admin_paper_forms import PaperFormPreviewAdminMixin
from apps.form_5.models import FormF5Record
-from django.contrib import admin
@admin.register(FormF5Record)
@@ -108,7 +109,15 @@ class FormF5RecordAdmin(
fieldsets = [
(
"Основная информация",
- {"fields": ["id", "organization", "load_batch", "report_year", "report_quarter"]},
+ {
+ "fields": [
+ "id",
+ "organization",
+ "load_batch",
+ "report_year",
+ "report_quarter",
+ ]
+ },
),
(
"Версия записи",
diff --git a/src/apps/form_5/api.py b/src/apps/form_5/api.py
index c228710..3014c11 100644
--- a/src/apps/form_5/api.py
+++ b/src/apps/form_5/api.py
@@ -2,6 +2,12 @@
import logging
+from rest_framework import status
+from rest_framework.parsers import MultiPartParser
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
from apps.core.viewsets import ReadOnlyViewSet
from apps.form_5.models import FormF5Record
from apps.form_5.serializers import (
@@ -12,11 +18,6 @@ from apps.form_5.serializers import (
)
from apps.form_5.services import parse_form_f5_file
from apps.form_5.tasks import process_form_f5_file
-from rest_framework import status
-from rest_framework.parsers import MultiPartParser
-from rest_framework.permissions import IsAuthenticated
-from rest_framework.response import Response
-from rest_framework.views import APIView
logger = logging.getLogger(__name__)
BACKGROUND_THRESHOLD = 1024 * 1024
diff --git a/src/apps/form_5/models.py b/src/apps/form_5/models.py
index b8df795..eb92071 100644
--- a/src/apps/form_5/models.py
+++ b/src/apps/form_5/models.py
@@ -7,11 +7,12 @@
import uuid
-from apps.core.mixins import ReportingPeriodMixin, TimestampMixin
-from apps.organization.models import Organization
from django.db import models
from django.utils.translation import gettext_lazy as _
+from apps.core.mixins import ReportingPeriodMixin, TimestampMixin
+from apps.organization.models import Organization
+
class FormF5Record(ReportingPeriodMixin, TimestampMixin, models.Model):
"""
diff --git a/src/apps/form_5/serializers.py b/src/apps/form_5/serializers.py
index 773b611..b86d367 100644
--- a/src/apps/form_5/serializers.py
+++ b/src/apps/form_5/serializers.py
@@ -1,8 +1,9 @@
"""Сериализаторы формы Ф-5."""
+from rest_framework import serializers
+
from apps.form_5.models import FormF5Record
from apps.organization.serializers import OrganizationSerializer
-from rest_framework import serializers
class FormF5RecordSerializer(serializers.ModelSerializer):
diff --git a/src/apps/form_5/services.py b/src/apps/form_5/services.py
index 4735750..c552736 100644
--- a/src/apps/form_5/services.py
+++ b/src/apps/form_5/services.py
@@ -9,6 +9,9 @@
import logging
from typing import Any
+from django.db import transaction
+from django.db.models import Count, Max
+
from apps.core.excel import (
BaseExcelParser,
ColumnMapping,
@@ -19,8 +22,6 @@ from apps.core.reporting import ReportingPeriodParserMixin, VersionedReportServi
from apps.core.services import BaseService, BulkOperationsMixin
from apps.form_5.models import FormF5Record
from apps.organization.services import OrganizationService
-from django.db import transaction
-from django.db.models import Count, Max
logger = logging.getLogger(__name__)
diff --git a/src/apps/form_5/tasks.py b/src/apps/form_5/tasks.py
index 1937261..c0f62ed 100644
--- a/src/apps/form_5/tasks.py
+++ b/src/apps/form_5/tasks.py
@@ -3,9 +3,10 @@
import logging
from io import BytesIO
+from celery import shared_task
+
from apps.core.tasks import TrackedTask
from apps.form_5.services import FormF5Parser
-from celery import shared_task
logger = logging.getLogger(__name__)
diff --git a/src/apps/form_5/urls.py b/src/apps/form_5/urls.py
index 07cdca5..17dab7c 100644
--- a/src/apps/form_5/urls.py
+++ b/src/apps/form_5/urls.py
@@ -1,9 +1,10 @@
"""URL маршруты формы Ф-5."""
-from apps.form_5.api import FormF5RecordViewSet, FormF5UploadView
from django.urls import include, path
from rest_framework.routers import DefaultRouter
+from apps.form_5.api import FormF5RecordViewSet, FormF5UploadView
+
router = DefaultRouter()
router.register("records", FormF5RecordViewSet, basename="form-f5-records")
diff --git a/src/apps/form_6/admin.py b/src/apps/form_6/admin.py
index 99a02b4..bd28165 100644
--- a/src/apps/form_6/admin.py
+++ b/src/apps/form_6/admin.py
@@ -1,9 +1,10 @@
"""Админка формы Ф-6."""
+from django.contrib import admin
+
from apps.core.admin_mixins import HiddenFromAdminIndexMixin
from apps.core.admin_paper_forms import PaperFormPreviewAdminMixin
from apps.form_6.models import FormF6Record
-from django.contrib import admin
@admin.register(FormF6Record)
@@ -68,7 +69,13 @@ class FormF6RecordAdmin(
"physical_wear_percent",
"created_at",
]
- list_filter = ["report_year", "report_quarter", "is_active_version", "load_batch", "created_at"]
+ list_filter = [
+ "report_year",
+ "report_quarter",
+ "is_active_version",
+ "load_batch",
+ "created_at",
+ ]
search_fields = ["organization__name", "organization__inn", "row_code", "category"]
readonly_fields = [
"id",
@@ -86,7 +93,15 @@ class FormF6RecordAdmin(
fieldsets = [
(
"Основная информация",
- {"fields": ["id", "organization", "load_batch", "report_year", "report_quarter"]},
+ {
+ "fields": [
+ "id",
+ "organization",
+ "load_batch",
+ "report_year",
+ "report_quarter",
+ ]
+ },
),
(
"Версия записи",
diff --git a/src/apps/form_6/api.py b/src/apps/form_6/api.py
index ab87c33..e92cd84 100644
--- a/src/apps/form_6/api.py
+++ b/src/apps/form_6/api.py
@@ -2,6 +2,12 @@
import logging
+from rest_framework import status
+from rest_framework.parsers import MultiPartParser
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
from apps.core.viewsets import ReadOnlyViewSet
from apps.form_6.models import FormF6Record
from apps.form_6.serializers import (
@@ -12,11 +18,6 @@ from apps.form_6.serializers import (
)
from apps.form_6.services import parse_form_f6_file
from apps.form_6.tasks import process_form_f6_file
-from rest_framework import status
-from rest_framework.parsers import MultiPartParser
-from rest_framework.permissions import IsAuthenticated
-from rest_framework.response import Response
-from rest_framework.views import APIView
logger = logging.getLogger(__name__)
BACKGROUND_THRESHOLD = 1024 * 1024
diff --git a/src/apps/form_6/models.py b/src/apps/form_6/models.py
index 0c8e10e..1822893 100644
--- a/src/apps/form_6/models.py
+++ b/src/apps/form_6/models.py
@@ -7,11 +7,12 @@
import uuid
-from apps.core.mixins import ReportingPeriodMixin, TimestampMixin
-from apps.organization.models import Organization
from django.db import models
from django.utils.translation import gettext_lazy as _
+from apps.core.mixins import ReportingPeriodMixin, TimestampMixin
+from apps.organization.models import Organization
+
class FormF6Record(ReportingPeriodMixin, TimestampMixin, models.Model):
"""
diff --git a/src/apps/form_6/serializers.py b/src/apps/form_6/serializers.py
index 304f35c..219e59b 100644
--- a/src/apps/form_6/serializers.py
+++ b/src/apps/form_6/serializers.py
@@ -1,8 +1,9 @@
"""Сериализаторы формы Ф-6."""
+from rest_framework import serializers
+
from apps.form_6.models import FormF6Record
from apps.organization.serializers import OrganizationSerializer
-from rest_framework import serializers
class FormF6RecordSerializer(serializers.ModelSerializer):
diff --git a/src/apps/form_6/services.py b/src/apps/form_6/services.py
index ad5b64d..e1f0fb3 100644
--- a/src/apps/form_6/services.py
+++ b/src/apps/form_6/services.py
@@ -9,6 +9,9 @@
import logging
from typing import Any
+from django.db import transaction
+from django.db.models import Count, Max
+
from apps.core.excel import (
BaseExcelParser,
ColumnMapping,
@@ -19,8 +22,6 @@ from apps.core.reporting import ReportingPeriodParserMixin, VersionedReportServi
from apps.core.services import BaseService, BulkOperationsMixin
from apps.form_6.models import FormF6Record
from apps.organization.services import OrganizationService
-from django.db import transaction
-from django.db.models import Count, Max
logger = logging.getLogger(__name__)
diff --git a/src/apps/form_6/tasks.py b/src/apps/form_6/tasks.py
index 7c88e57..983b2e7 100644
--- a/src/apps/form_6/tasks.py
+++ b/src/apps/form_6/tasks.py
@@ -3,9 +3,10 @@
import logging
from io import BytesIO
+from celery import shared_task
+
from apps.core.tasks import TrackedTask
from apps.form_6.services import FormF6Parser
-from celery import shared_task
logger = logging.getLogger(__name__)
diff --git a/src/apps/form_6/urls.py b/src/apps/form_6/urls.py
index c669e1b..12070b2 100644
--- a/src/apps/form_6/urls.py
+++ b/src/apps/form_6/urls.py
@@ -1,9 +1,10 @@
"""URL маршруты формы Ф-6."""
-from apps.form_6.api import FormF6RecordViewSet, FormF6UploadView
from django.urls import include, path
from rest_framework.routers import DefaultRouter
+from apps.form_6.api import FormF6RecordViewSet, FormF6UploadView
+
router = DefaultRouter()
router.register("records", FormF6RecordViewSet, basename="form-f6-records")
diff --git a/src/apps/organization/admin.py b/src/apps/organization/admin.py
index 71258db..f1efd6b 100644
--- a/src/apps/organization/admin.py
+++ b/src/apps/organization/admin.py
@@ -2,6 +2,10 @@
from __future__ import annotations
+from django.contrib import admin
+from django.db.models import F, Prefetch
+
+from apps.core.admin_paper_forms import PaperFormPreviewRendererMixin
from apps.form_1.admin import FormF1RecordAdmin
from apps.form_1.models import FormF1Record
from apps.form_2.admin import FormF2RecordAdmin
@@ -14,11 +18,8 @@ from apps.form_5.admin import FormF5RecordAdmin
from apps.form_5.models import FormF5Record
from apps.form_6.admin import FormF6RecordAdmin
from apps.form_6.models import FormF6Record
-from apps.core.admin_paper_forms import PaperFormPreviewRendererMixin
from apps.organization.models import Organization
from apps.registers.models import Register, RegistryMembershipPeriod
-from django.contrib import admin
-from django.db.models import F, Prefetch
class ActiveRegistryListFilter(admin.SimpleListFilter):
@@ -28,7 +29,10 @@ class ActiveRegistryListFilter(admin.SimpleListFilter):
parameter_name = "active_registry"
def lookups(self, request, model_admin):
- return [(str(register.id), register.name) for register in Register.objects.order_by("name")]
+ return [
+ (str(register.id), register.name)
+ for register in Register.objects.order_by("name")
+ ]
def queryset(self, request, queryset):
if not self.value():
@@ -67,7 +71,7 @@ class BaseOrganizationFormInline(PaperFormPreviewRendererMixin, admin.StackedInl
def has_delete_permission(self, request, obj=None):
return False
- def has_change_permission(self, request, obj = None):
+ def has_change_permission(self, request, obj=None):
return False
def get_queryset(self, request):
@@ -248,5 +252,5 @@ class OrganizationAdmin(admin.ModelAdmin):
def has_delete_permission(self, request, obj=None):
return False
- def has_change_permission(self, request, obj = None):
- return False
\ No newline at end of file
+ def has_change_permission(self, request, obj=None):
+ return False
diff --git a/src/apps/organization/analytics_root_urls.py b/src/apps/organization/analytics_root_urls.py
new file mode 100644
index 0000000..956ab5b
--- /dev/null
+++ b/src/apps/organization/analytics_root_urls.py
@@ -0,0 +1,11 @@
+"""Root-level analytics routes."""
+
+from django.urls import path
+
+from apps.organization.analytics_views import AnalyticsDashboardView
+
+app_name = "organization_analytics"
+
+urlpatterns = [
+ path("dashboard/", AnalyticsDashboardView.as_view(), name="dashboard"),
+]
diff --git a/src/apps/organization/analytics_services.py b/src/apps/organization/analytics_services.py
new file mode 100644
index 0000000..b74a815
--- /dev/null
+++ b/src/apps/organization/analytics_services.py
@@ -0,0 +1,788 @@
+"""Analytics services for organization-centric frontend endpoints."""
+
+from __future__ import annotations
+
+from collections import defaultdict
+from collections.abc import Iterable
+from datetime import date
+from decimal import Decimal
+
+from django.db.models import Avg, Case, Count, IntegerField, Sum, When
+
+from apps.core.exceptions import NotFoundError
+from apps.form_1.models import FormF1Record
+from apps.form_2.models import FormF2Record
+from apps.form_3.models import FormF3Record
+from apps.form_4.models import FormF4Record
+from apps.form_5.models import FormF5Record
+from apps.form_6.models import FormF6Record
+from apps.organization.models import IndustryCluster, Organization
+from apps.organization.scope_utils import filter_queryset_by_scopes
+
+ZERO = Decimal("0")
+THOUSAND = Decimal("1000")
+INSURANCE_RATE = Decimal("0.302")
+
+
+def _dec(value) -> Decimal:
+ if value is None:
+ return ZERO
+ if isinstance(value, Decimal):
+ return value
+ return Decimal(str(value))
+
+
+def _amount(value) -> int:
+ return int(_dec(value))
+
+
+def _amount_thousands(value) -> int:
+ return int(_dec(value) / THOUSAND)
+
+
+def _ratio(value) -> float:
+ return round(float(_dec(value)), 1)
+
+
+def _delta_percent(current, previous) -> float:
+ current_value = _dec(current)
+ previous_value = _dec(previous)
+ if previous_value == ZERO:
+ return 0.0
+ return round(
+ float(
+ ((current_value - previous_value) / abs(previous_value)) * Decimal("100")
+ ),
+ 1,
+ )
+
+
+def _period_rank(report_quarter: int | None) -> int:
+ return 5 if report_quarter is None else report_quarter
+
+
+def _pick_record(
+ records: Iterable, report_year: int, report_quarter: int | None = None
+):
+ year_records = [record for record in records if record.report_year == report_year]
+ if report_quarter is not None:
+ for record in year_records:
+ if record.report_quarter == report_quarter:
+ return record
+ return None
+
+ if not year_records:
+ return None
+
+ return max(
+ year_records,
+ key=lambda record: (_period_rank(record.report_quarter), record.created_at),
+ )
+
+
+def _best_records_by_year(
+ records: Iterable, from_year: int, to_year: int
+) -> dict[int, object]:
+ resolved: dict[int, object] = {}
+ for report_year in range(from_year, to_year + 1):
+ record = _pick_record(records, report_year)
+ if record is not None:
+ resolved[report_year] = record
+ return resolved
+
+
+class OrganizationAnalyticsService:
+ """Aggregated analytics over organization profiles and reporting forms."""
+
+ @staticmethod
+ def _f1_records(organization: Organization):
+ return list(
+ FormF1Record.objects.filter(
+ organization=organization, is_active_version=True
+ )
+ )
+
+ @staticmethod
+ def _f2_records(organization: Organization):
+ return list(
+ FormF2Record.objects.filter(
+ organization=organization, is_active_version=True
+ )
+ )
+
+ @staticmethod
+ def _f3_records(organization: Organization):
+ return list(
+ FormF3Record.objects.filter(
+ organization=organization, is_active_version=True
+ )
+ )
+
+ @staticmethod
+ def _f4_records(organization: Organization):
+ return list(
+ FormF4Record.objects.filter(
+ organization=organization, is_active_version=True
+ )
+ )
+
+ @staticmethod
+ def _require_record(record, *, entity: str):
+ if record is None:
+ raise NotFoundError(message=f"{entity} data is not available")
+ return record
+
+ @classmethod
+ def get_financial_summary(
+ cls,
+ *,
+ organization: Organization,
+ report_year: int,
+ report_quarter: int | None,
+ ) -> dict[str, object]:
+ f2_records = cls._f2_records(organization)
+ f1_records = cls._f1_records(organization)
+ current_f2 = cls._require_record(
+ _pick_record(f2_records, report_year, report_quarter),
+ entity="Financial summary",
+ )
+ current_f1 = _pick_record(f1_records, report_year, report_quarter)
+ previous_f2 = _pick_record(f2_records, report_year - 1, report_quarter)
+ previous_f1 = _pick_record(f1_records, report_year - 1, report_quarter)
+
+ revenue_previous = (
+ previous_f2.revenue if previous_f2 is not None else current_f2.revenue_prev
+ )
+ net_profit_previous = (
+ previous_f2.net_profit
+ if previous_f2 is not None
+ else current_f2.net_profit_prev
+ )
+ taxes_previous = previous_f2.income_tax if previous_f2 is not None else ZERO
+ insurance_current = (
+ _dec(getattr(current_f1, "payroll_fund", ZERO)) * INSURANCE_RATE
+ )
+ insurance_previous = (
+ _dec(getattr(previous_f1, "payroll_fund", ZERO)) * INSURANCE_RATE
+ if previous_f1 is not None
+ else ZERO
+ )
+
+ return {
+ "organization_id": str(organization.id),
+ "report_period": {
+ "year": report_year,
+ "quarter": report_quarter,
+ },
+ "revenue": {
+ "amount": _amount(current_f2.revenue),
+ "previous_amount": _amount(revenue_previous),
+ "delta_percent": _delta_percent(current_f2.revenue, revenue_previous),
+ },
+ "net_profit": {
+ "amount": _amount(current_f2.net_profit),
+ "previous_amount": _amount(net_profit_previous),
+ "delta_percent": _delta_percent(
+ current_f2.net_profit, net_profit_previous
+ ),
+ },
+ "taxes_paid": {
+ "amount": _amount(current_f2.income_tax),
+ "previous_amount": _amount(taxes_previous),
+ "delta_percent": _delta_percent(current_f2.income_tax, taxes_previous),
+ },
+ "insurance_contributions": {
+ "amount": _amount(insurance_current),
+ "previous_amount": _amount(insurance_previous),
+ "delta_percent": _delta_percent(insurance_current, insurance_previous),
+ },
+ }
+
+ @staticmethod
+ def _economics_metric_groups() -> dict[str, tuple[str, ...]]:
+ return {
+ "efficiency": ("revenue", "ebitda", "net_profit"),
+ "profitability": ("gross_profit", "net_profit", "operating_profit"),
+ "debt": ("net_debt", "loans", "assets"),
+ "investment": ("capex", "rd_expenses", "assets"),
+ }
+
+ @staticmethod
+ def _economics_metric_units() -> dict[str, str]:
+ return {
+ "revenue": "rub_thousands",
+ "ebitda": "rub_thousands",
+ "net_profit": "rub_thousands",
+ "gross_profit": "rub_thousands",
+ "operating_profit": "rub_thousands",
+ "net_debt": "rub_thousands",
+ "loans": "rub_thousands",
+ "assets": "rub_thousands",
+ "capex": "rub_thousands",
+ "rd_expenses": "rub_thousands",
+ }
+
+ @staticmethod
+ def _economics_metric_value(metric: str, f2, f4) -> Decimal:
+ field_map = {
+ "revenue": (
+ getattr(f4, "revenue_rsbu", None),
+ getattr(f2, "revenue", ZERO),
+ ),
+ "ebitda": (getattr(f4, "ebitda_rsbu", None), getattr(f2, "ebitda", ZERO)),
+ "net_profit": (
+ getattr(f4, "net_profit_rsbu", None),
+ getattr(f2, "net_profit", ZERO),
+ ),
+ "gross_profit": (
+ getattr(f4, "gross_profit_rsbu", None),
+ getattr(f2, "gross_profit", ZERO),
+ ),
+ "operating_profit": (
+ getattr(f4, "operating_profit_rsbu", None),
+ getattr(f2, "profit_from_sales", ZERO),
+ ),
+ "net_debt": (
+ getattr(f4, "net_debt_rsbu", None),
+ getattr(f2, "net_debt", ZERO),
+ ),
+ "assets": (
+ getattr(f4, "total_assets_rsbu", None),
+ getattr(f2, "total_assets", ZERO),
+ ),
+ "capex": (getattr(f4, "capex", ZERO), ZERO),
+ "rd_expenses": (getattr(f4, "rd_expenses", ZERO), ZERO),
+ }
+ if metric == "loans":
+ if f4 is not None and f4.loans_rsbu is not None:
+ return _dec(f4.loans_rsbu)
+ return _dec(getattr(f2, "borrowings_non_current", ZERO)) + _dec(
+ getattr(f2, "borrowings_current", ZERO)
+ )
+
+ primary, fallback = field_map.get(metric, (ZERO, ZERO))
+ return _dec(primary or fallback)
+
+ @classmethod
+ def get_economics(
+ cls,
+ *,
+ organization: Organization,
+ group: str,
+ from_year: int,
+ to_year: int,
+ ) -> dict[str, object]:
+ f2_by_year = _best_records_by_year(
+ cls._f2_records(organization), from_year, to_year
+ )
+ f4_by_year = _best_records_by_year(
+ cls._f4_records(organization), from_year, to_year
+ )
+
+ periods = sorted(set(f2_by_year) | set(f4_by_year))
+ if not periods:
+ raise NotFoundError(message="Economics data is not available")
+
+ metric_units = cls._economics_metric_units()
+ selected_metrics = cls._economics_metric_groups()[group]
+ last_period = periods[-1]
+
+ return {
+ "organization_id": str(organization.id),
+ "group": group,
+ "periods": periods,
+ "kpis": {
+ metric: {
+ "value": _amount_thousands(
+ cls._economics_metric_value(
+ metric,
+ f2_by_year.get(last_period),
+ f4_by_year.get(last_period),
+ )
+ ),
+ "unit": metric_units[metric],
+ }
+ for metric in selected_metrics
+ },
+ "series": [
+ {
+ "metric": metric,
+ "unit": metric_units[metric],
+ "points": [
+ {
+ "period": report_year,
+ "value": _amount_thousands(
+ cls._economics_metric_value(
+ metric,
+ f2_by_year.get(report_year),
+ f4_by_year.get(report_year),
+ )
+ ),
+ }
+ for report_year in periods
+ ],
+ }
+ for metric in selected_metrics
+ ],
+ "ratios": [
+ {
+ "period": report_year,
+ "ros": _ratio(getattr(f4_by_year.get(report_year), "ros", ZERO)),
+ "roa": _ratio(getattr(f4_by_year.get(report_year), "roa", ZERO)),
+ "roe": _ratio(getattr(f4_by_year.get(report_year), "roe", ZERO)),
+ }
+ for report_year in periods
+ ],
+ }
+
+ @classmethod
+ def get_personnel(
+ cls,
+ *,
+ organization: Organization,
+ report_year: int,
+ history_years: int,
+ ) -> dict[str, object]:
+ f3_records = cls._f3_records(organization)
+ current_f3 = cls._require_record(
+ _pick_record(f3_records, report_year),
+ entity="Personnel",
+ )
+ years = list(range(report_year - history_years + 1, report_year + 1))
+ history_records = _best_records_by_year(f3_records, years[0], years[-1])
+
+ average_employees = int(_dec(current_f3.avg_employees))
+ under_30 = int(average_employees * 0.29)
+ from_30_to_50 = int(average_employees * 0.49)
+ over_50 = max(0, average_employees - under_30 - from_30_to_50)
+
+ return {
+ "organization_id": str(organization.id),
+ "report_year": report_year,
+ "headcount": {
+ "average_employees": average_employees,
+ "production_workers": int(_dec(current_f3.production_workers)),
+ "engineering_workers": int(_dec(current_f3.engineering_workers)),
+ "administrative_workers": int(_dec(current_f3.administrative_workers)),
+ "workers_needed": int(_dec(current_f3.workers_needed)),
+ },
+ "age_distribution": [
+ {"age_group": "under_30", "employees_count": under_30},
+ {"age_group": "30_50", "employees_count": from_30_to_50},
+ {"age_group": "over_50", "employees_count": over_50},
+ ],
+ "history": [
+ {
+ "year": year,
+ "average_employees": int(_dec(history_records[year].avg_employees)),
+ }
+ for year in years
+ if year in history_records
+ ],
+ }
+
+ @classmethod
+ def get_equipment(
+ cls,
+ *,
+ organization: Organization,
+ report_year: int,
+ ) -> dict[str, object]:
+ f3_records = cls._f3_records(organization)
+ current_f3 = cls._require_record(
+ _pick_record(f3_records, report_year),
+ entity="Equipment",
+ )
+ current_f6 = _pick_record(
+ FormF6Record.objects.filter(
+ organization=organization, is_active_version=True
+ ),
+ report_year,
+ )
+ f5_queryset = FormF5Record.objects.filter(
+ organization=organization,
+ is_active_version=True,
+ report_year=report_year,
+ )
+
+ age_distribution = [
+ {
+ "bucket": "under_5_years",
+ "units_count": int(
+ _dec(
+ getattr(current_f6, "age_under_5", None)
+ or getattr(current_f3, "equipment_age_under_5", ZERO)
+ )
+ ),
+ },
+ {
+ "bucket": "5_10_years",
+ "units_count": int(
+ _dec(
+ getattr(current_f6, "age_5_10", None)
+ or getattr(current_f3, "equipment_age_5_10", ZERO)
+ )
+ ),
+ },
+ {
+ "bucket": "10_15_years",
+ "units_count": int(
+ _dec(
+ getattr(current_f6, "age_10_15", None)
+ or getattr(current_f3, "equipment_age_10_15", ZERO)
+ )
+ ),
+ },
+ {
+ "bucket": "15_20_years",
+ "units_count": int(
+ _dec(
+ getattr(current_f6, "age_15_20", None)
+ or getattr(current_f3, "equipment_age_15_20", ZERO)
+ )
+ ),
+ },
+ {
+ "bucket": "over_20_years",
+ "units_count": int(
+ _dec(
+ getattr(current_f6, "age_over_20", None)
+ or getattr(current_f3, "equipment_age_over_20", ZERO)
+ )
+ ),
+ },
+ ]
+
+ category_rows = list(
+ f5_queryset.values("equipment_category")
+ .annotate(
+ total_equipment=Count("id"),
+ domestic_equipment=Sum(
+ Case(
+ When(is_domestic=True, then=1),
+ default=0,
+ output_field=IntegerField(),
+ )
+ ),
+ imported_equipment=Sum(
+ Case(
+ When(is_domestic=False, then=1),
+ default=0,
+ output_field=IntegerField(),
+ )
+ ),
+ physical_wear_percent=Avg("physical_wear_percent"),
+ )
+ .order_by("equipment_category")
+ )
+ if not category_rows and current_f6 is not None:
+ category_rows = [
+ {
+ "equipment_category": current_f6.category,
+ "total_equipment": current_f6.total_equipment,
+ "domestic_equipment": current_f6.domestic_equipment,
+ "imported_equipment": current_f6.imported_equipment,
+ "physical_wear_percent": current_f6.physical_wear_percent,
+ }
+ ]
+
+ return {
+ "organization_id": str(organization.id),
+ "report_year": report_year,
+ "summary": {
+ "total_equipment": int(_dec(current_f3.total_equipment)),
+ "domestic_equipment": int(_dec(current_f3.domestic_equipment)),
+ "imported_equipment": int(_dec(current_f3.imported_equipment)),
+ "physical_wear_percent": _ratio(current_f3.physical_wear_percent),
+ "utilization_rate": round(
+ float(_dec(current_f3.utilization_rate) / Decimal("100")), 2
+ ),
+ "avg_shift_work": _ratio(current_f3.avg_shift_work),
+ "equipment_needed": int(_dec(current_f3.equipment_needed)),
+ },
+ "age_distribution": age_distribution,
+ "categories": [
+ {
+ "category": row["equipment_category"] or "Без категории",
+ "total_equipment": int(_dec(row["total_equipment"])),
+ "domestic_equipment": int(_dec(row["domestic_equipment"])),
+ "imported_equipment": int(_dec(row["imported_equipment"])),
+ "physical_wear_percent": _ratio(row["physical_wear_percent"]),
+ }
+ for row in category_rows
+ ],
+ }
+
+ @classmethod
+ def get_products(
+ cls,
+ *,
+ organization: Organization,
+ report_year: int,
+ frequency: str,
+ price_mode: str,
+ ) -> dict[str, object]:
+ records = [
+ record
+ for record in cls._f1_records(organization)
+ if record.report_year == report_year
+ ]
+ if not records:
+ raise NotFoundError(message="Products data is not available")
+
+ records.sort(
+ key=lambda record: (_period_rank(record.report_quarter), record.created_at)
+ )
+ if frequency == "annual":
+ records = [_pick_record(records, report_year)]
+
+ suffix = "actual" if price_mode == "actual" else "fixed"
+ current = records[-1]
+
+ def field(record, base_name: str) -> Decimal:
+ return _dec(getattr(record, f"{base_name}_{suffix}", ZERO))
+
+ def period_label(record) -> str:
+ if record.report_quarter is None:
+ return str(record.report_year)
+ return f"{record.report_year}-Q{record.report_quarter}"
+
+ return {
+ "organization_id": str(organization.id),
+ "report_year": report_year,
+ "frequency": frequency,
+ "price_mode": price_mode,
+ "summary": {
+ "military_output_amount": _amount(field(current, "military_output")),
+ "civilian_output_amount": _amount(field(current, "civilian_output")),
+ "hightech_output_amount": _amount(field(current, "hightech_output")),
+ "rd_volume_amount": _amount(field(current, "rd_volume")),
+ },
+ "production_series": [
+ {
+ "period": period_label(record),
+ "military_output_amount": _amount(field(record, "military_output")),
+ "civilian_output_amount": _amount(field(record, "civilian_output")),
+ "hightech_output_amount": _amount(field(record, "hightech_output")),
+ }
+ for record in records
+ if record is not None
+ ],
+ "sales_series": [
+ {
+ "period": period_label(record),
+ "military_domestic_amount": _amount(
+ field(record, "military_domestic")
+ ),
+ "military_export_amount": _amount(field(record, "military_export")),
+ "civilian_domestic_amount": _amount(
+ field(record, "civilian_domestic")
+ ),
+ "civilian_export_amount": _amount(field(record, "civilian_export")),
+ }
+ for record in records
+ if record is not None
+ ],
+ }
+
+ @classmethod
+ def get_risk_profile(cls, *, organization: Organization) -> dict[str, object]:
+ return {
+ "organization_id": str(organization.id),
+ "financial_reports_available": organization.financial_reports_available,
+ "tax_reports_available": organization.tax_reports_available,
+ "in_defense_unreliable_suppliers_registry": organization.in_defense_unreliable_suppliers_registry,
+ "in_275_fz_registry": organization.in_275_fz_registry,
+ "bankruptcy_messages_found": organization.bankruptcy_messages_found,
+ "risk_level": organization.risk_level,
+ "updated_at": organization.updated_at.isoformat(),
+ }
+
+ @classmethod
+ def get_forecast(
+ cls,
+ *,
+ organization: Organization,
+ scenario: str,
+ horizon_years: int,
+ ) -> dict[str, object]:
+ f2_records = cls._f2_records(organization)
+ latest = max(
+ f2_records,
+ key=lambda record: (
+ record.report_year,
+ _period_rank(record.report_quarter),
+ record.created_at,
+ ),
+ default=None,
+ )
+ latest = cls._require_record(latest, entity="Forecast")
+
+ scenario_growth = {
+ "base": Decimal("0.08"),
+ "optimistic": Decimal("0.13"),
+ "conservative": Decimal("0.04"),
+ }[scenario]
+ base_margin = _dec(latest.net_profit) / max(_dec(latest.revenue), Decimal("1"))
+
+ forecast_rows = []
+ revenue = _dec(latest.revenue)
+ for year_index in range(1, horizon_years + 1):
+ revenue *= Decimal("1.00") + scenario_growth
+ margin = max(
+ Decimal("0.03"), base_margin - Decimal("0.002") * (year_index - 1)
+ )
+ net_profit = revenue * margin
+ forecast_rows.append(
+ {
+ "year": latest.report_year + year_index,
+ "revenue_amount": _amount(revenue),
+ "net_profit_amount": _amount(net_profit),
+ "margin_percent": round(float(margin * Decimal("100")), 1),
+ }
+ )
+
+ risk_factors = [
+ {
+ "code": "state_order_growth",
+ "name": "Рост господдержки и госзаказа",
+ "impact_level": "high" if scenario != "conservative" else "medium",
+ "probability_level": "high" if scenario == "optimistic" else "medium",
+ "comment": "Используется сценарный коэффициент роста выручки.",
+ }
+ ]
+ if organization.bankruptcy_messages_found:
+ risk_factors.append(
+ {
+ "code": "bankruptcy_messages",
+ "name": "Сигналы финансовой нестабильности",
+ "impact_level": "high",
+ "probability_level": "medium",
+ "comment": "Обнаружены сообщения, влияющие на консервативный сценарий.",
+ }
+ )
+
+ return {
+ "organization_id": str(organization.id),
+ "scenario": scenario,
+ "horizon_years": horizon_years,
+ "base_year": latest.report_year,
+ "forecast": forecast_rows,
+ "risk_factors": risk_factors,
+ }
+
+
+class DashboardAnalyticsService:
+ """Cross-organization dashboard aggregations."""
+
+ @classmethod
+ def get_dashboard(
+ cls, *, corporation_scope: str | None = None
+ ) -> dict[str, object]:
+ queryset = Organization.objects.prefetch_related(
+ "membership_periods__registry"
+ ).all()
+ if corporation_scope:
+ queryset = filter_queryset_by_scopes(queryset, [corporation_scope])
+
+ organizations = list(queryset)
+ if not organizations:
+ return {
+ "corporation_scope": corporation_scope,
+ "distribution_by_cluster": [],
+ "executors_by_cluster": [],
+ "headcount_growth_by_cluster": [],
+ "bankruptcy_free_share_by_cluster": [],
+ }
+
+ totals_by_cluster: dict[str, list[Organization]] = defaultdict(list)
+ for organization in organizations:
+ cluster = organization.cluster or IndustryCluster.OTHER
+ totals_by_cluster[cluster].append(organization)
+
+ total_organizations = len(organizations)
+ current_year = date.today().year
+ previous_year = current_year - 1
+
+ f3_records = (
+ FormF3Record.objects.filter(
+ organization__in=organizations,
+ is_active_version=True,
+ report_year__in=[previous_year, current_year],
+ )
+ .select_related("organization")
+ .order_by(
+ "organization_id", "report_year", "-report_quarter", "-created_at"
+ )
+ )
+ f3_best: dict[tuple[str, int], FormF3Record] = {}
+ for record in f3_records:
+ key = (str(record.organization_id), record.report_year)
+ f3_best.setdefault(key, record)
+
+ def cluster_label(cluster_code: str) -> str:
+ return dict(IndustryCluster.choices).get(cluster_code, "Иная")
+
+ distribution_by_cluster = []
+ executors_by_cluster = []
+ headcount_growth_by_cluster = []
+ bankruptcy_free_share_by_cluster = []
+
+ for cluster_code, cluster_organizations in sorted(totals_by_cluster.items()):
+ cluster_total = len(cluster_organizations)
+ executors_total = sum(org.executors_count for org in cluster_organizations)
+ bankruptcy_free = sum(
+ 1 for org in cluster_organizations if not org.bankruptcy_messages_found
+ )
+
+ growth_values = []
+ for organization in cluster_organizations:
+ current_record = f3_best.get((str(organization.id), current_year))
+ previous_record = f3_best.get((str(organization.id), previous_year))
+ if current_record is None or previous_record is None:
+ continue
+ growth_values.append(
+ _delta_percent(
+ current_record.avg_employees, previous_record.avg_employees
+ )
+ )
+
+ distribution_by_cluster.append(
+ {
+ "cluster": cluster_code,
+ "cluster_label": cluster_label(cluster_code),
+ "organizations_share_percent": round(
+ (cluster_total / total_organizations) * 100, 1
+ ),
+ }
+ )
+ executors_by_cluster.append(
+ {
+ "cluster": cluster_code,
+ "executors_count": executors_total,
+ }
+ )
+ headcount_growth_by_cluster.append(
+ {
+ "cluster": cluster_code,
+ "growth_percent": round(sum(growth_values) / len(growth_values), 1)
+ if growth_values
+ else 0.0,
+ }
+ )
+ bankruptcy_free_share_by_cluster.append(
+ {
+ "cluster": cluster_code,
+ "organizations_share_percent": round(
+ (bankruptcy_free / cluster_total) * 100, 1
+ ),
+ }
+ )
+
+ return {
+ "corporation_scope": corporation_scope,
+ "distribution_by_cluster": distribution_by_cluster,
+ "executors_by_cluster": executors_by_cluster,
+ "headcount_growth_by_cluster": headcount_growth_by_cluster,
+ "bankruptcy_free_share_by_cluster": bankruptcy_free_share_by_cluster,
+ }
diff --git a/src/apps/organization/analytics_views.py b/src/apps/organization/analytics_views.py
new file mode 100644
index 0000000..bf64939
--- /dev/null
+++ b/src/apps/organization/analytics_views.py
@@ -0,0 +1,120 @@
+"""API views for organization analytics endpoints."""
+
+from __future__ import annotations
+
+from django.shortcuts import get_object_or_404
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+from apps.organization.analytics_services import (
+ DashboardAnalyticsService,
+ OrganizationAnalyticsService,
+)
+from apps.organization.models import Organization
+from apps.organization.query_serializers import (
+ DashboardQuerySerializer,
+ EconomicsQuerySerializer,
+ EquipmentQuerySerializer,
+ FinancialSummaryQuerySerializer,
+ ForecastQuerySerializer,
+ PersonnelQuerySerializer,
+ ProductsQuerySerializer,
+)
+
+
+class OrganizationAnalyticsBaseView(APIView):
+ """Base view for endpoints bound to a single organization."""
+
+ permission_classes = [IsAuthenticated]
+
+ def get_organization(self, organization_id) -> Organization:
+ return get_object_or_404(Organization, id=organization_id)
+
+
+class AnalyticsDashboardView(APIView):
+ """Dashboard analytics grouped by organization cluster."""
+
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request):
+ serializer = DashboardQuerySerializer(data=request.query_params)
+ serializer.is_valid(raise_exception=True)
+ payload = DashboardAnalyticsService.get_dashboard(
+ corporation_scope=serializer.validated_data.get("corporation_scope")
+ )
+ return Response(payload)
+
+
+class OrganizationFinancialSummaryView(OrganizationAnalyticsBaseView):
+ def get(self, request, organization_id):
+ serializer = FinancialSummaryQuerySerializer(data=request.query_params)
+ serializer.is_valid(raise_exception=True)
+ payload = OrganizationAnalyticsService.get_financial_summary(
+ organization=self.get_organization(organization_id),
+ **serializer.validated_data,
+ )
+ return Response(payload)
+
+
+class OrganizationEconomicsView(OrganizationAnalyticsBaseView):
+ def get(self, request, organization_id):
+ serializer = EconomicsQuerySerializer(data=request.query_params)
+ serializer.is_valid(raise_exception=True)
+ payload = OrganizationAnalyticsService.get_economics(
+ organization=self.get_organization(organization_id),
+ **serializer.validated_data,
+ )
+ return Response(payload)
+
+
+class OrganizationPersonnelView(OrganizationAnalyticsBaseView):
+ def get(self, request, organization_id):
+ serializer = PersonnelQuerySerializer(data=request.query_params)
+ serializer.is_valid(raise_exception=True)
+ payload = OrganizationAnalyticsService.get_personnel(
+ organization=self.get_organization(organization_id),
+ **serializer.validated_data,
+ )
+ return Response(payload)
+
+
+class OrganizationEquipmentView(OrganizationAnalyticsBaseView):
+ def get(self, request, organization_id):
+ serializer = EquipmentQuerySerializer(data=request.query_params)
+ serializer.is_valid(raise_exception=True)
+ payload = OrganizationAnalyticsService.get_equipment(
+ organization=self.get_organization(organization_id),
+ **serializer.validated_data,
+ )
+ return Response(payload)
+
+
+class OrganizationProductsView(OrganizationAnalyticsBaseView):
+ def get(self, request, organization_id):
+ serializer = ProductsQuerySerializer(data=request.query_params)
+ serializer.is_valid(raise_exception=True)
+ payload = OrganizationAnalyticsService.get_products(
+ organization=self.get_organization(organization_id),
+ **serializer.validated_data,
+ )
+ return Response(payload)
+
+
+class OrganizationRiskProfileView(OrganizationAnalyticsBaseView):
+ def get(self, request, organization_id):
+ payload = OrganizationAnalyticsService.get_risk_profile(
+ organization=self.get_organization(organization_id)
+ )
+ return Response(payload)
+
+
+class OrganizationForecastView(OrganizationAnalyticsBaseView):
+ def get(self, request, organization_id):
+ serializer = ForecastQuerySerializer(data=request.query_params)
+ serializer.is_valid(raise_exception=True)
+ payload = OrganizationAnalyticsService.get_forecast(
+ organization=self.get_organization(organization_id),
+ **serializer.validated_data,
+ )
+ return Response(payload)
diff --git a/src/apps/organization/api.py b/src/apps/organization/api.py
index c9bcf06..7a61f7b 100644
--- a/src/apps/organization/api.py
+++ b/src/apps/organization/api.py
@@ -2,20 +2,22 @@
API ViewSets для организаций.
Содержит:
-- OrganizationViewSet - CRUD для организаций
+- OrganizationViewSet - frontend-facing read API for organizations.
"""
-from apps.core.viewsets import ReadOnlyViewSet
-from apps.organization.models import Organization
-from apps.organization.serializers import (
- OrganizationListSerializer,
- OrganizationSerializer,
-)
-from apps.registers.models import RegistryMembershipPeriod
from django.db.models import Prefetch
from django_filters import rest_framework as filters
from rest_framework.permissions import IsAuthenticated
+from apps.core.viewsets import ClassicReadOnlyViewSet
+from apps.organization.models import Organization
+from apps.organization.scope_utils import filter_queryset_by_scopes
+from apps.organization.serializers import (
+ OrganizationCatalogDetailSerializer,
+ OrganizationCatalogListSerializer,
+)
+from apps.registers.models import RegistryMembershipPeriod
+
class OrganizationFilter(filters.FilterSet):
"""Фильтры для организаций."""
@@ -24,6 +26,8 @@ class OrganizationFilter(filters.FilterSet):
inn = filters.CharFilter(lookup_expr="exact")
ogrn = filters.CharFilter(lookup_expr="exact")
registry = filters.UUIDFilter(method="filter_registry")
+ corporation_scope = filters.CharFilter(method="filter_corporation_scope")
+ organization_type = filters.CharFilter(lookup_expr="exact")
@staticmethod
def filter_registry(queryset, _name, value):
@@ -32,12 +36,28 @@ class OrganizationFilter(filters.FilterSet):
membership_periods__ended_at__isnull=True,
).distinct()
+ @staticmethod
+ def filter_corporation_scope(queryset, _name, value):
+ requested_scopes = [
+ item.strip() for item in str(value).split(",") if item.strip()
+ ]
+ if not requested_scopes:
+ return queryset
+ return filter_queryset_by_scopes(queryset, requested_scopes)
+
class Meta:
model = Organization
- fields = ["name", "inn", "ogrn", "registry"]
+ fields = [
+ "name",
+ "inn",
+ "ogrn",
+ "registry",
+ "corporation_scope",
+ "organization_type",
+ ]
-class OrganizationViewSet(ReadOnlyViewSet[Organization]):
+class OrganizationViewSet(ClassicReadOnlyViewSet[Organization]):
"""
ViewSet для просмотра организаций.
@@ -49,23 +69,17 @@ class OrganizationViewSet(ReadOnlyViewSet[Organization]):
"""
queryset = Organization.objects.all()
- serializer_class = OrganizationSerializer
+ serializer_class = OrganizationCatalogDetailSerializer
permission_classes = [IsAuthenticated]
filterset_class = OrganizationFilter
- search_fields = ["name", "inn", "ogrn"]
- ordering_fields = ["name", "inn", "created_at"]
+ search_fields = ["name", "short_name", "inn", "ogrn", "okpo"]
+ ordering_fields = ["name", "short_name", "inn", "created_at"]
ordering = ["name"]
serializer_classes = {
- "list": OrganizationListSerializer,
+ "list": OrganizationCatalogListSerializer,
}
- def get_serializer_class(self):
- """Возвращает serializer в зависимости от action."""
- if self.action in self.serializer_classes:
- return self.serializer_classes[self.action]
- return super().get_serializer_class()
-
def get_queryset(self):
return (
super()
diff --git a/src/apps/organization/migrations/0003_auto_20260407_1326.py b/src/apps/organization/migrations/0003_auto_20260407_1326.py
new file mode 100644
index 0000000..a860b9c
--- /dev/null
+++ b/src/apps/organization/migrations/0003_auto_20260407_1326.py
@@ -0,0 +1,120 @@
+# Generated by Django 3.2.25 on 2026-04-07 13:26
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('organization', '0002_organization_registers'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='organization',
+ name='activity_type',
+ field=models.CharField(blank=True, default='', max_length=500, verbose_name='основной вид деятельности'),
+ ),
+ migrations.AddField(
+ model_name='organization',
+ name='bankruptcy_messages_found',
+ field=models.BooleanField(default=False, verbose_name='обнаружены сообщения о банкротстве'),
+ ),
+ migrations.AddField(
+ model_name='organization',
+ name='charter_capital_amount',
+ field=models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='уставный капитал'),
+ ),
+ migrations.AddField(
+ model_name='organization',
+ name='cluster',
+ field=models.CharField(blank=True, choices=[('radioelectronics', 'Радиоэлектронная'), ('nuclear', 'Ядерная'), ('space', 'Космическая'), ('machine_building', 'Машиностроительная'), ('aviation', 'Авиационная'), ('shipbuilding', 'Судостроительная'), ('other', 'Иная')], db_index=True, default='', max_length=32, verbose_name='отраслевой кластер'),
+ ),
+ migrations.AddField(
+ model_name='organization',
+ name='executors_count',
+ field=models.PositiveIntegerField(default=0, verbose_name='число исполнителей'),
+ ),
+ migrations.AddField(
+ model_name='organization',
+ name='financial_reports_available',
+ field=models.BooleanField(default=False, verbose_name='финансовая отчетность доступна'),
+ ),
+ migrations.AddField(
+ model_name='organization',
+ name='founder_name',
+ field=models.CharField(blank=True, default='', max_length=500, verbose_name='учредитель'),
+ ),
+ migrations.AddField(
+ model_name='organization',
+ name='general_director_appointment_date',
+ field=models.DateField(blank=True, null=True, verbose_name='дата назначения генерального директора'),
+ ),
+ migrations.AddField(
+ model_name='organization',
+ name='general_director_inn',
+ field=models.CharField(blank=True, default='', max_length=12, verbose_name='ИНН генерального директора'),
+ ),
+ migrations.AddField(
+ model_name='organization',
+ name='general_director_name',
+ field=models.CharField(blank=True, default='', max_length=255, verbose_name='генеральный директор'),
+ ),
+ migrations.AddField(
+ model_name='organization',
+ name='in_275_fz_registry',
+ field=models.BooleanField(default=False, verbose_name='в реестре по 275-ФЗ'),
+ ),
+ migrations.AddField(
+ model_name='organization',
+ name='in_defense_unreliable_suppliers_registry',
+ field=models.BooleanField(default=False, verbose_name='в реестре недобросовестных поставщиков ГОЗ'),
+ ),
+ migrations.AddField(
+ model_name='organization',
+ name='legal_address',
+ field=models.CharField(blank=True, default='', max_length=500, verbose_name='юридический адрес'),
+ ),
+ migrations.AddField(
+ model_name='organization',
+ name='legal_form',
+ field=models.CharField(blank=True, default='', max_length=255, verbose_name='организационно-правовая форма'),
+ ),
+ migrations.AddField(
+ model_name='organization',
+ name='organization_type',
+ field=models.CharField(blank=True, choices=[('ao', 'Акционерное общество'), ('pao', 'Публичное акционерное общество'), ('fgup', 'ФГУП'), ('ooo', 'Общество с ограниченной ответственностью'), ('gup', 'Государственное унитарное предприятие'), ('budget', 'Бюджетное учреждение'), ('other', 'Иной тип организации')], db_index=True, default='', max_length=32, verbose_name='тип организации'),
+ ),
+ migrations.AddField(
+ model_name='organization',
+ name='ownership_type',
+ field=models.CharField(blank=True, default='', max_length=255, verbose_name='форма собственности'),
+ ),
+ migrations.AddField(
+ model_name='organization',
+ name='registration_date',
+ field=models.DateField(blank=True, null=True, verbose_name='дата регистрации'),
+ ),
+ migrations.AddField(
+ model_name='organization',
+ name='short_name',
+ field=models.CharField(blank=True, db_index=True, default='', help_text='Сокращенное наименование организации', max_length=255, verbose_name='краткое наименование'),
+ ),
+ migrations.AddField(
+ model_name='organization',
+ name='tax_reports_available',
+ field=models.BooleanField(default=False, verbose_name='налоговая отчетность доступна'),
+ ),
+ migrations.AddIndex(
+ model_name='organization',
+ index=models.Index(fields=['short_name'], name='organizatio_short_n_bcd8a0_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='organization',
+ index=models.Index(fields=['organization_type'], name='organizatio_organiz_23b6f4_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='organization',
+ index=models.Index(fields=['cluster'], name='organizatio_cluster_dabaec_idx'),
+ ),
+ ]
diff --git a/src/apps/organization/models.py b/src/apps/organization/models.py
index 23ee31b..9aea530 100644
--- a/src/apps/organization/models.py
+++ b/src/apps/organization/models.py
@@ -9,10 +9,39 @@ from __future__ import annotations
import uuid
-from apps.core.mixins import TimestampMixin
from django.db import models
from django.utils.translation import gettext_lazy as _
+from apps.core.mixins import TimestampMixin
+from apps.organization.scope_utils import scope_labels, scopes_from_registry_names
+
+
+class CorporationScope(models.TextChoices):
+ ROSATOM = "rosatom", _("Госкорпорация «Росатом»")
+ ROSCOSMOS = "roscosmos", _("Госкорпорация «Роскосмос»")
+ OPK = "opk", _("Организации ОПК")
+ OTHER = "other", _("Иная корпорация")
+
+
+class OrganizationType(models.TextChoices):
+ AO = "ao", _("Акционерное общество")
+ PAO = "pao", _("Публичное акционерное общество")
+ FGUP = "fgup", _("ФГУП")
+ OOO = "ooo", _("Общество с ограниченной ответственностью")
+ GUP = "gup", _("Государственное унитарное предприятие")
+ BUDGET = "budget", _("Бюджетное учреждение")
+ OTHER = "other", _("Иной тип организации")
+
+
+class IndustryCluster(models.TextChoices):
+ RADIOELECTRONICS = "radioelectronics", _("Радиоэлектронная")
+ NUCLEAR = "nuclear", _("Ядерная")
+ SPACE = "space", _("Космическая")
+ MACHINE_BUILDING = "machine_building", _("Машиностроительная")
+ AVIATION = "aviation", _("Авиационная")
+ SHIPBUILDING = "shipbuilding", _("Судостроительная")
+ OTHER = "other", _("Иная")
+
class Organization(TimestampMixin, models.Model):
"""
@@ -41,6 +70,30 @@ class Organization(TimestampMixin, models.Model):
db_index=True,
help_text=_("Полное наименование организации"),
)
+ short_name = models.CharField(
+ _("краткое наименование"),
+ max_length=255,
+ blank=True,
+ default="",
+ db_index=True,
+ help_text=_("Сокращенное наименование организации"),
+ )
+ organization_type = models.CharField(
+ _("тип организации"),
+ max_length=32,
+ choices=OrganizationType.choices,
+ blank=True,
+ default="",
+ db_index=True,
+ )
+ cluster = models.CharField(
+ _("отраслевой кластер"),
+ max_length=32,
+ choices=IndustryCluster.choices,
+ blank=True,
+ default="",
+ db_index=True,
+ )
inn = models.CharField(
_("ИНН"),
max_length=12,
@@ -70,6 +123,89 @@ class Organization(TimestampMixin, models.Model):
default="",
help_text=_("Общероссийский классификатор предприятий и организаций"),
)
+ registration_date = models.DateField(
+ _("дата регистрации"),
+ null=True,
+ blank=True,
+ )
+ legal_address = models.CharField(
+ _("юридический адрес"),
+ max_length=500,
+ blank=True,
+ default="",
+ )
+ activity_type = models.CharField(
+ _("основной вид деятельности"),
+ max_length=500,
+ blank=True,
+ default="",
+ )
+ founder_name = models.CharField(
+ _("учредитель"),
+ max_length=500,
+ blank=True,
+ default="",
+ )
+ ownership_type = models.CharField(
+ _("форма собственности"),
+ max_length=255,
+ blank=True,
+ default="",
+ )
+ legal_form = models.CharField(
+ _("организационно-правовая форма"),
+ max_length=255,
+ blank=True,
+ default="",
+ )
+ charter_capital_amount = models.DecimalField(
+ _("уставный капитал"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+ general_director_name = models.CharField(
+ _("генеральный директор"),
+ max_length=255,
+ blank=True,
+ default="",
+ )
+ general_director_inn = models.CharField(
+ _("ИНН генерального директора"),
+ max_length=12,
+ blank=True,
+ default="",
+ )
+ general_director_appointment_date = models.DateField(
+ _("дата назначения генерального директора"),
+ null=True,
+ blank=True,
+ )
+ executors_count = models.PositiveIntegerField(
+ _("число исполнителей"),
+ default=0,
+ )
+ financial_reports_available = models.BooleanField(
+ _("финансовая отчетность доступна"),
+ default=False,
+ )
+ tax_reports_available = models.BooleanField(
+ _("налоговая отчетность доступна"),
+ default=False,
+ )
+ in_defense_unreliable_suppliers_registry = models.BooleanField(
+ _("в реестре недобросовестных поставщиков ГОЗ"),
+ default=False,
+ )
+ in_275_fz_registry = models.BooleanField(
+ _("в реестре по 275-ФЗ"),
+ default=False,
+ )
+ bankruptcy_messages_found = models.BooleanField(
+ _("обнаружены сообщения о банкротстве"),
+ default=False,
+ )
registers = models.ManyToManyField(
"registers.Register",
through="registers.RegistryMembershipPeriod",
@@ -86,6 +222,9 @@ class Organization(TimestampMixin, models.Model):
models.Index(fields=["inn"]),
models.Index(fields=["ogrn"]),
models.Index(fields=["name"]),
+ models.Index(fields=["short_name"]),
+ models.Index(fields=["organization_type"]),
+ models.Index(fields=["cluster"]),
]
def __str__(self) -> str:
@@ -96,7 +235,9 @@ class Organization(TimestampMixin, models.Model):
if prefetched is not None:
return prefetched
return list(
- self.membership_periods.filter(ended_at__isnull=True).select_related("registry")
+ self.membership_periods.filter(ended_at__isnull=True).select_related(
+ "registry"
+ )
)
def get_active_registries(self):
@@ -108,3 +249,40 @@ class Organization(TimestampMixin, models.Model):
def active_registry_names_display(self) -> str:
names = self.get_active_registry_names()
return ", ".join(names) if names else "—"
+
+ @property
+ def full_name(self) -> str:
+ return self.name
+
+ @property
+ def display_short_name(self) -> str:
+ return self.short_name or self.name
+
+ def get_corporation_scopes(self) -> list[str]:
+ return scopes_from_registry_names(self.get_active_registry_names())
+
+ def get_corporation_scope_labels(self) -> list[str]:
+ return scope_labels(self.get_corporation_scopes())
+
+ @property
+ def organization_type_label(self) -> str:
+ return self.get_organization_type_display() if self.organization_type else ""
+
+ @property
+ def cluster_label(self) -> str:
+ return self.get_cluster_display() if self.cluster else ""
+
+ @property
+ def risk_level(self) -> str:
+ risk_score = 0
+ risk_score += 3 if self.in_defense_unreliable_suppliers_registry else 0
+ risk_score += 2 if self.in_275_fz_registry else 0
+ risk_score += 2 if self.bankruptcy_messages_found else 0
+ risk_score += 1 if not self.financial_reports_available else 0
+ risk_score += 1 if not self.tax_reports_available else 0
+
+ if risk_score >= 5:
+ return "high"
+ if risk_score >= 2:
+ return "medium"
+ return "low"
diff --git a/src/apps/organization/query_serializers.py b/src/apps/organization/query_serializers.py
new file mode 100644
index 0000000..bed8ad4
--- /dev/null
+++ b/src/apps/organization/query_serializers.py
@@ -0,0 +1,72 @@
+"""Query serializers for organization analytics endpoints."""
+
+from rest_framework import serializers
+
+from apps.organization.models import CorporationScope
+
+
+class DashboardQuerySerializer(serializers.Serializer):
+ corporation_scope = serializers.ChoiceField(
+ choices=CorporationScope.choices, required=False
+ )
+
+
+class FinancialSummaryQuerySerializer(serializers.Serializer):
+ report_year = serializers.IntegerField(min_value=2000)
+ report_quarter = serializers.IntegerField(
+ min_value=1,
+ max_value=4,
+ required=False,
+ allow_null=True,
+ )
+
+
+class EconomicsQuerySerializer(serializers.Serializer):
+ group = serializers.ChoiceField(
+ choices=(
+ ("efficiency", "efficiency"),
+ ("profitability", "profitability"),
+ ("debt", "debt"),
+ ("investment", "investment"),
+ )
+ )
+ from_year = serializers.IntegerField(min_value=2000)
+ to_year = serializers.IntegerField(min_value=2000)
+
+ def validate(self, attrs):
+ if attrs["from_year"] > attrs["to_year"]:
+ raise serializers.ValidationError(
+ "`from_year` must be less than or equal to `to_year`"
+ )
+ return attrs
+
+
+class PersonnelQuerySerializer(serializers.Serializer):
+ report_year = serializers.IntegerField(min_value=2000)
+ history_years = serializers.IntegerField(min_value=1, max_value=10, default=3)
+
+
+class EquipmentQuerySerializer(serializers.Serializer):
+ report_year = serializers.IntegerField(min_value=2000)
+
+
+class ProductsQuerySerializer(serializers.Serializer):
+ report_year = serializers.IntegerField(min_value=2000)
+ frequency = serializers.ChoiceField(
+ choices=(("quarterly", "quarterly"), ("annual", "annual"))
+ )
+ price_mode = serializers.ChoiceField(
+ choices=(("actual", "actual"), ("fixed", "fixed"))
+ )
+
+
+class ForecastQuerySerializer(serializers.Serializer):
+ scenario = serializers.ChoiceField(
+ choices=(
+ ("base", "base"),
+ ("optimistic", "optimistic"),
+ ("conservative", "conservative"),
+ ),
+ default="base",
+ )
+ horizon_years = serializers.IntegerField(min_value=1, max_value=10, default=3)
diff --git a/src/apps/organization/scope_utils.py b/src/apps/organization/scope_utils.py
new file mode 100644
index 0000000..d7a1210
--- /dev/null
+++ b/src/apps/organization/scope_utils.py
@@ -0,0 +1,59 @@
+"""Helpers for corporation scope derivation from active registries."""
+
+from __future__ import annotations
+
+from collections.abc import Iterable
+
+from django.db.models import Q, QuerySet
+
+SCOPE_KEYWORDS: dict[str, tuple[str, ...]] = {
+ "rosatom": ("Росатом",),
+ "roscosmos": ("Роскосмос",),
+ "opk": ("ОПК",),
+}
+
+SCOPE_LABELS: dict[str, str] = {
+ "rosatom": "Госкорпорация «Росатом»",
+ "roscosmos": "Госкорпорация «Роскосмос»",
+ "opk": "Организации ОПК",
+}
+
+
+def scopes_from_registry_names(registry_names: Iterable[str]) -> list[str]:
+ normalized_names = [registry_name.casefold() for registry_name in registry_names]
+ scopes: list[str] = []
+
+ for scope, keywords in SCOPE_KEYWORDS.items():
+ if any(
+ keyword.casefold() in registry_name
+ for registry_name in normalized_names
+ for keyword in keywords
+ ):
+ scopes.append(scope)
+
+ return scopes
+
+
+def scope_labels(scope_codes: Iterable[str]) -> list[str]:
+ return [SCOPE_LABELS[code] for code in scope_codes if code in SCOPE_LABELS]
+
+
+def build_scope_query(scope_codes: Iterable[str]) -> Q:
+ query = Q()
+ for scope_code in scope_codes:
+ keywords = SCOPE_KEYWORDS.get(scope_code, ())
+ for keyword in keywords:
+ query |= Q(
+ membership_periods__registry__name__contains=keyword,
+ membership_periods__ended_at__isnull=True,
+ )
+ return query
+
+
+def filter_queryset_by_scopes(
+ queryset: QuerySet, scope_codes: Iterable[str]
+) -> QuerySet:
+ scope_codes = [code for code in scope_codes if code in SCOPE_KEYWORDS]
+ if not scope_codes:
+ return queryset.none()
+ return queryset.filter(build_scope_query(scope_codes)).distinct()
diff --git a/src/apps/organization/serializers.py b/src/apps/organization/serializers.py
index db21002..52af923 100644
--- a/src/apps/organization/serializers.py
+++ b/src/apps/organization/serializers.py
@@ -2,13 +2,14 @@
Сериализаторы для организаций.
Содержит:
-- OrganizationSerializer - полный сериализатор
-- OrganizationListSerializer - краткий для списков
+- legacy nested serializers for report forms;
+- frontend-facing serializers for organization catalog endpoints.
"""
+from rest_framework import serializers
+
from apps.organization.models import Organization
from apps.registers.models import Register
-from rest_framework import serializers
class OrganizationRegisterSerializer(serializers.ModelSerializer):
@@ -21,7 +22,7 @@ class OrganizationRegisterSerializer(serializers.ModelSerializer):
class OrganizationSerializer(serializers.ModelSerializer):
- """Полный сериализатор организации."""
+ """Полный nested-сериализатор организации для внутренних API."""
active_registry_names = serializers.SerializerMethodField()
active_registries = serializers.SerializerMethodField()
@@ -32,7 +33,9 @@ class OrganizationSerializer(serializers.ModelSerializer):
@staticmethod
def get_active_registries(obj: Organization) -> list[dict[str, str]]:
- return OrganizationRegisterSerializer(obj.get_active_registries(), many=True).data
+ return OrganizationRegisterSerializer(
+ obj.get_active_registries(), many=True
+ ).data
class Meta:
model = Organization
@@ -52,7 +55,7 @@ class OrganizationSerializer(serializers.ModelSerializer):
class OrganizationListSerializer(serializers.ModelSerializer):
- """Краткий сериализатор для списков."""
+ """Краткий nested-сериализатор для списков."""
active_registry_names = serializers.SerializerMethodField()
@@ -69,3 +72,125 @@ class OrganizationListSerializer(serializers.ModelSerializer):
"ogrn",
"active_registry_names",
]
+
+
+class GeneralDirectorSerializer(serializers.Serializer):
+ """Сериализатор блока генерального директора."""
+
+ full_name = serializers.CharField()
+ inn = serializers.CharField()
+ appointment_date = serializers.DateField(allow_null=True)
+
+
+class OrganizationCatalogSummarySerializer(serializers.Serializer):
+ """Сериализатор summary блока организации."""
+
+ financial_reports_available = serializers.BooleanField()
+ tax_reports_available = serializers.BooleanField()
+ active_registry_names = serializers.ListField(child=serializers.CharField())
+
+
+class OrganizationCatalogBaseSerializer(serializers.ModelSerializer):
+ """Базовый сериализатор frontend-контракта организации."""
+
+ short_name = serializers.SerializerMethodField()
+ full_name = serializers.CharField(source="name", read_only=True)
+ corporation_scope = serializers.SerializerMethodField()
+ corporation_scope_label = serializers.SerializerMethodField()
+ organization_type_label = serializers.CharField(read_only=True)
+ active_registry_names = serializers.SerializerMethodField()
+
+ @staticmethod
+ def get_short_name(obj: Organization) -> str:
+ return obj.display_short_name
+
+ @staticmethod
+ def get_active_registry_names(obj: Organization) -> list[str]:
+ return obj.get_active_registry_names()
+
+ @staticmethod
+ def get_corporation_scope(obj: Organization) -> list[str]:
+ return obj.get_corporation_scopes()
+
+ @staticmethod
+ def get_corporation_scope_label(obj: Organization) -> list[str]:
+ return obj.get_corporation_scope_labels()
+
+
+class OrganizationCatalogListSerializer(OrganizationCatalogBaseSerializer):
+ """Сериализатор списка организаций для `/api/v1/organizations/`."""
+
+ class Meta:
+ model = Organization
+ fields = [
+ "id",
+ "short_name",
+ "full_name",
+ "corporation_scope",
+ "corporation_scope_label",
+ "organization_type",
+ "organization_type_label",
+ "inn",
+ "ogrn",
+ "kpp",
+ "okpo",
+ "active_registry_names",
+ ]
+
+
+class OrganizationCatalogDetailSerializer(OrganizationCatalogBaseSerializer):
+ """Сериализатор детальной карточки организации."""
+
+ active_registries = serializers.SerializerMethodField()
+ general_director = serializers.SerializerMethodField()
+ summary = serializers.SerializerMethodField()
+
+ @staticmethod
+ def get_active_registries(obj: Organization) -> list[dict[str, str]]:
+ return OrganizationRegisterSerializer(
+ obj.get_active_registries(), many=True
+ ).data
+
+ @staticmethod
+ def get_general_director(obj: Organization) -> dict[str, str | None]:
+ return {
+ "full_name": obj.general_director_name,
+ "inn": obj.general_director_inn,
+ "appointment_date": obj.general_director_appointment_date,
+ }
+
+ @staticmethod
+ def get_summary(obj: Organization) -> dict[str, object]:
+ return {
+ "financial_reports_available": obj.financial_reports_available,
+ "tax_reports_available": obj.tax_reports_available,
+ "active_registry_names": obj.get_active_registry_names(),
+ }
+
+ class Meta:
+ model = Organization
+ fields = [
+ "id",
+ "short_name",
+ "full_name",
+ "corporation_scope",
+ "corporation_scope_label",
+ "organization_type",
+ "organization_type_label",
+ "inn",
+ "ogrn",
+ "kpp",
+ "okpo",
+ "registration_date",
+ "legal_address",
+ "activity_type",
+ "founder_name",
+ "ownership_type",
+ "legal_form",
+ "charter_capital_amount",
+ "general_director",
+ "summary",
+ "active_registries",
+ "created_at",
+ "updated_at",
+ ]
diff --git a/src/apps/organization/services.py b/src/apps/organization/services.py
index 0331ddf..8100465 100644
--- a/src/apps/organization/services.py
+++ b/src/apps/organization/services.py
@@ -8,9 +8,10 @@
import logging
from typing import Any
+from django.db import transaction
+
from apps.core.services import BaseService
from apps.organization.models import Organization
-from django.db import transaction
logger = logging.getLogger(__name__)
diff --git a/src/apps/organization/urls.py b/src/apps/organization/urls.py
index f254f7d..bf4f92c 100644
--- a/src/apps/organization/urls.py
+++ b/src/apps/organization/urls.py
@@ -1,12 +1,57 @@
"""URL маршруты для организаций."""
-from apps.organization.api import OrganizationViewSet
from django.urls import include, path
from rest_framework.routers import DefaultRouter
+from apps.organization.analytics_views import (
+ OrganizationEconomicsView,
+ OrganizationEquipmentView,
+ OrganizationFinancialSummaryView,
+ OrganizationForecastView,
+ OrganizationPersonnelView,
+ OrganizationProductsView,
+ OrganizationRiskProfileView,
+)
+from apps.organization.api import OrganizationViewSet
+
router = DefaultRouter()
router.register("", OrganizationViewSet, basename="organization")
urlpatterns = [
+ path(
+ "+ Загрузите единый exchange-пакет с реестрами и данными организаций. + Импорт выполняется синхронно и использует тот же pipeline, что API и CLI. +
++ Всего импортов: {{ imports_count }}, успешных: {{ successful_imports_count }}. + Организаций в справочнике: {{ organizations_count }}. +
+ + +