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( + "/analytics/financial-summary/", + OrganizationFinancialSummaryView.as_view(), + name="organization-financial-summary", + ), + path( + "/analytics/economics/", + OrganizationEconomicsView.as_view(), + name="organization-economics", + ), + path( + "/analytics/personnel/", + OrganizationPersonnelView.as_view(), + name="organization-personnel", + ), + path( + "/analytics/equipment/", + OrganizationEquipmentView.as_view(), + name="organization-equipment", + ), + path( + "/analytics/products/", + OrganizationProductsView.as_view(), + name="organization-products", + ), + path( + "/analytics/forecast/", + OrganizationForecastView.as_view(), + name="organization-forecast", + ), + path( + "/risk-profile/", + OrganizationRiskProfileView.as_view(), + name="organization-risk-profile", + ), path("", include(router.urls)), ] diff --git a/src/apps/registers/admin.py b/src/apps/registers/admin.py index 235cf69..7bfa0ec 100644 --- a/src/apps/registers/admin.py +++ b/src/apps/registers/admin.py @@ -1,15 +1,17 @@ """Admin configuration for registers app.""" -from apps.organization.models import Organization -from apps.registers.models import RegisterUpload -from apps.registers.serializers import RegisterFileUploadSerializer -from apps.registers.services import RegisterBackupImportService, RegisterImportError from django.contrib import admin, messages from django.shortcuts import redirect from django.template.response import TemplateResponse from django.urls import path, reverse from rest_framework import serializers +from apps.organization.models import Organization +from apps.registers.models import RegisterUpload +from apps.registers.serializers import RegisterFileUploadSerializer +from apps.registers.services import RegisterBackupImportService, RegisterImportError + + @admin.register(RegisterUpload) class RegisterUploadAdmin(admin.ModelAdmin): """Admin для загрузок реестров.""" diff --git a/src/apps/registers/models.py b/src/apps/registers/models.py index 8db5ccd..2ecc454 100644 --- a/src/apps/registers/models.py +++ b/src/apps/registers/models.py @@ -1,12 +1,13 @@ """Модели приложения реестров организаций.""" -from apps.core.mixins import TimestampMixin, UUIDPrimaryKeyMixin -from apps.organization.models import Organization from django.conf import settings from django.db import models from django.db.models import Q from django.utils.translation import gettext_lazy as _ +from apps.core.mixins import TimestampMixin, UUIDPrimaryKeyMixin +from apps.organization.models import Organization + class Register(UUIDPrimaryKeyMixin, TimestampMixin, models.Model): """Справочник реестров.""" @@ -151,4 +152,3 @@ class RegistryMembershipPeriod(TimestampMixin, models.Model): f"{self.registry.name}: {self.organization.name[:40]} " f"[{self.started_at} - {self.ended_at or '...'}]" ) - diff --git a/src/apps/registers/pagination.py b/src/apps/registers/pagination.py index 00f17fb..2e87258 100644 --- a/src/apps/registers/pagination.py +++ b/src/apps/registers/pagination.py @@ -22,4 +22,3 @@ class RegistersPagination(StandardPagination): response.data["meta"] = meta return response - diff --git a/src/apps/registers/serializers.py b/src/apps/registers/serializers.py index 6e03902..6b39f2d 100644 --- a/src/apps/registers/serializers.py +++ b/src/apps/registers/serializers.py @@ -1,8 +1,9 @@ """Сериализаторы для API реестров.""" +from rest_framework import serializers + from apps.organization.models import Organization from apps.registers.models import Register, RegistryMembershipPeriod -from rest_framework import serializers class RegisterSerializer(serializers.ModelSerializer): @@ -70,9 +71,7 @@ class RegisterFileUploadSerializer(serializers.Serializer): def validate_file(self, value): if not value.name.lower().endswith((".zip", ".bin")): - raise serializers.ValidationError( - "Поддерживаются только файлы .zip и .bin" - ) + raise serializers.ValidationError("Поддерживаются только файлы .zip и .bin") return value @@ -93,7 +92,9 @@ class RegisterOrganizationListQuerySerializer(serializers.Serializer): def validate(self, attrs): if attrs.get("actual_date") and not attrs.get("registry"): raise serializers.ValidationError( - {"actual_date": "Параметр actual_date допустим только вместе с registry"} + { + "actual_date": "Параметр actual_date допустим только вместе с registry" + } ) return attrs diff --git a/src/apps/registers/services.py b/src/apps/registers/services.py index aba6e1c..469cb03 100644 --- a/src/apps/registers/services.py +++ b/src/apps/registers/services.py @@ -12,8 +12,6 @@ from datetime import date from io import BytesIO from zipfile import ZipFile -from apps.organization.models import Organization -from apps.registers.models import Register, RegisterUpload, RegistryMembershipPeriod from cryptography.hazmat.primitives.ciphers.aead import AESGCM from django.conf import settings from django.db import transaction @@ -21,6 +19,9 @@ from django.db.models import Q from django.utils import timezone from openpyxl import load_workbook +from apps.organization.models import Organization +from apps.registers.models import Register, RegisterUpload, RegistryMembershipPeriod + class RegisterImportError(ValueError): """Ошибка импорта Excel файла реестра.""" @@ -676,7 +677,9 @@ class RegisterBackupImportService: ) header, compressed_payload = cls._read_bin_container(bin_bytes) - payload = cls._decrypt_payload(header=header, encrypted_payload=compressed_payload) + payload = cls._decrypt_payload( + header=header, encrypted_payload=compressed_payload + ) cls._validate_payload(payload) return DecodedBackupPayload( @@ -690,7 +693,9 @@ class RegisterBackupImportService: 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")] + bin_names = [ + name for name in archive.namelist() if name.endswith(".bin") + ] if len(bin_names) != 1: raise RegisterImportError( "Архив должен содержать ровно один backup-файл .bin" @@ -702,7 +707,9 @@ class RegisterBackupImportService: name for name in archive.namelist() if name.endswith(".sha256") ] if checksum_names: - checksum_text = archive.read(checksum_names[0]).decode("utf-8").strip() + 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: @@ -717,7 +724,9 @@ class RegisterBackupImportService: return bin_name, bin_bytes @classmethod - def _read_bin_container(cls, bin_bytes: bytes) -> tuple[dict[str, str | int], bytes]: + def _read_bin_container( + cls, bin_bytes: bytes + ) -> tuple[dict[str, str | int], bytes]: if len(bin_bytes) < 9 or not bin_bytes.startswith(cls.MAGIC): raise RegisterImportError("Файл не похож на backup реестров") @@ -730,7 +739,9 @@ class RegisterBackupImportService: try: header = json.loads(bin_bytes[9:header_end].decode("utf-8")) except Exception as exc: # noqa: BLE001 - raise RegisterImportError("Не удалось декодировать заголовок backup") from exc + raise RegisterImportError( + "Не удалось декодировать заголовок backup" + ) from exc if version != 1: raise RegisterImportError(f"Неподдерживаемая версия backup bin: {version}") @@ -766,12 +777,16 @@ class RegisterBackupImportService: @classmethod def _validate_payload(cls, payload: dict[str, object]) -> None: if "actual_date" not in payload or "data" not in payload: - raise RegisterImportError("Backup payload не содержит обязательных разделов") + raise RegisterImportError( + "Backup payload не содержит обязательных разделов" + ) if not isinstance(payload.get("data"), dict): raise RegisterImportError("Раздел data в backup payload повреждён") @classmethod - def _extract_data(cls, payload: dict[str, object]) -> dict[str, list[dict[str, object]]]: + def _extract_data( + cls, payload: dict[str, object] + ) -> dict[str, list[dict[str, object]]]: data = payload.get("data") if not isinstance(data, dict): raise RegisterImportError("Раздел data в backup payload повреждён") @@ -955,13 +970,19 @@ class RegisterBackupImportService: upload_map: dict[str, RegisterUpload], ) -> tuple[str, str] | None: registry = register_map.get(str(row.get("registry_id") or "").strip()) - organization = organization_map.get(str(row.get("organization_id") or "").strip()) + organization = organization_map.get( + str(row.get("organization_id") or "").strip() + ) if registry is None or organization is None: return None key = (str(registry.id), str(organization.id)) - started_by_upload = upload_map.get(str(row.get("started_by_upload_id") or "").strip()) - ended_by_upload = upload_map.get(str(row.get("ended_by_upload_id") or "").strip()) + started_by_upload = upload_map.get( + str(row.get("started_by_upload_id") or "").strip() + ) + ended_by_upload = upload_map.get( + str(row.get("ended_by_upload_id") or "").strip() + ) period = active_by_key.get(key) started_at = cls._parse_date(str(row.get("started_at"))) ended_at = cls._parse_optional_date(row.get("ended_at")) diff --git a/src/apps/registers/urls.py b/src/apps/registers/urls.py index 71138e3..ff9b697 100644 --- a/src/apps/registers/urls.py +++ b/src/apps/registers/urls.py @@ -1,13 +1,14 @@ """URL конфигурация для приложения реестров.""" +from django.urls import include, path +from rest_framework.routers import DefaultRouter + from apps.registers.views import ( RegisterOrganizationViewSet, RegisterUploadView, RegisterViewSet, RegistryOrganizationListView, ) -from django.urls import include, path -from rest_framework.routers import DefaultRouter app_name = "registers" @@ -24,4 +25,3 @@ urlpatterns = [ ), path("", include(router.urls)), ] - diff --git a/src/apps/registers/views.py b/src/apps/registers/views.py index 577c8d6..e8adbd8 100644 --- a/src/apps/registers/views.py +++ b/src/apps/registers/views.py @@ -4,6 +4,14 @@ from __future__ import annotations from datetime import date +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.exceptions import ValidationError +from rest_framework.generics import ListAPIView +from rest_framework.parsers import MultiPartParser +from rest_framework.permissions import IsAdminUser, IsAuthenticated +from rest_framework.views import APIView + from apps.core.response import api_response from apps.core.viewsets import ReadOnlyViewSet from apps.organization.models import Organization @@ -22,13 +30,6 @@ from apps.registers.services import ( RegisterImportError, RegisterImportService, ) -from django.shortcuts import get_object_or_404 -from rest_framework import status -from rest_framework.exceptions import ValidationError -from rest_framework.generics import ListAPIView -from rest_framework.parsers import MultiPartParser -from rest_framework.permissions import IsAdminUser, IsAuthenticated -from rest_framework.views import APIView class RegisterViewSet(ReadOnlyViewSet[Register]): diff --git a/src/apps/user/admin.py b/src/apps/user/admin.py index 9fee37a..627f5d5 100644 --- a/src/apps/user/admin.py +++ b/src/apps/user/admin.py @@ -1,7 +1,7 @@ """Admin configuration for user app.""" -from apps.core.models import BackgroundJob -from apps.user.models import Profile, User +from contextlib import suppress + from django.contrib import admin from django.contrib.admin.sites import NotRegistered from django.contrib.auth.admin import UserAdmin as BaseUserAdmin @@ -9,6 +9,9 @@ from django.contrib.auth.models import Group from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ +from apps.core.models import BackgroundJob +from apps.user.models import Profile, User + try: from django_celery_beat.models import ( ClockedSchedule, @@ -38,10 +41,8 @@ def _unregister(model) -> None: if model is None: return - try: + with suppress(NotRegistered): admin.site.unregister(model) - except NotRegistered: - pass _unregister(User) diff --git a/src/apps/user/serializers.py b/src/apps/user/serializers.py index 7d5aa10..7cdac99 100644 --- a/src/apps/user/serializers.py +++ b/src/apps/user/serializers.py @@ -86,6 +86,73 @@ class UserSerializer(serializers.ModelSerializer): read_only_fields = ("id", "is_verified", "created_at", "updated_at") +class CurrentUserProfileSerializer(serializers.ModelSerializer): + """Профиль текущего пользователя в контракте `/users/me/`.""" + + middle_name = serializers.CharField(source="mid_name", allow_null=True) + full_name = serializers.ReadOnlyField() + + class Meta: + model = Profile + fields = ( + "first_name", + "middle_name", + "last_name", + "full_name", + ) + + +class CurrentUserCapabilitiesSerializer(serializers.Serializer): + """Возможности пользователя для UI-контрактов.""" + + can_access_admin_page = serializers.BooleanField() + + +class CurrentUserSerializer(serializers.ModelSerializer): + """Расширенный сериализатор текущего пользователя.""" + + profile = CurrentUserProfileSerializer(read_only=True) + role = serializers.SerializerMethodField() + role_label = serializers.SerializerMethodField() + capabilities = serializers.SerializerMethodField() + + @staticmethod + def get_role(obj) -> str: + if obj.is_superuser: + return "admin" + if obj.is_staff: + return "staff" + return "user" + + def get_role_label(self, obj) -> str: + labels = { + "admin": "Администратор системы", + "staff": "Сотрудник системы", + "user": "Пользователь системы", + } + return labels[self.get_role(obj)] + + @staticmethod + def get_capabilities(obj) -> dict[str, bool]: + return { + "can_access_admin_page": bool(obj.is_staff or obj.is_superuser), + } + + class Meta: + model = User + fields = ( + "id", + "username", + "email", + "phone", + "is_active", + "role", + "role_label", + "capabilities", + "profile", + ) + + class UserUpdateSerializer(serializers.ModelSerializer): """Сериализатор для обновления данных пользователя""" @@ -99,7 +166,14 @@ class ProfileUpdateSerializer(serializers.ModelSerializer): class Meta: model = Profile - fields = ("first_name", "mid_name", "last_name", "bio", "avatar", "date_of_birth") + fields = ( + "first_name", + "mid_name", + "last_name", + "bio", + "avatar", + "date_of_birth", + ) class LoginSerializer(serializers.Serializer): diff --git a/src/apps/user/services.py b/src/apps/user/services.py index de36d1b..c63607e 100644 --- a/src/apps/user/services.py +++ b/src/apps/user/services.py @@ -1,10 +1,11 @@ from typing import Any -from apps.core.exceptions import NotFoundError from django.contrib.auth import get_user_model from django.db import transaction from rest_framework_simplejwt.tokens import RefreshToken +from apps.core.exceptions import NotFoundError + from .models import Profile User = get_user_model() diff --git a/src/apps/user/views.py b/src/apps/user/views.py index 29f0eb3..2fd5b1a 100644 --- a/src/apps/user/views.py +++ b/src/apps/user/views.py @@ -10,6 +10,7 @@ from rest_framework.views import APIView from rest_framework_simplejwt.tokens import RefreshToken from .serializers import ( + CurrentUserSerializer, LoginSerializer, PasswordChangeSerializer, ProfileUpdateSerializer, @@ -125,10 +126,10 @@ class CurrentUserView(APIView): tags=[USER_TAG], operation_summary="Текущий пользователь", operation_description="Возвращает данные авторизованного пользователя.", - responses={200: UserSerializer}, + responses={200: CurrentUserSerializer}, ) def get(self, request): - serializer = UserSerializer(request.user) + serializer = CurrentUserSerializer(request.user) return Response(serializer.data) diff --git a/src/core/api_v1_urls.py b/src/core/api_v1_urls.py index a5ec94a..bb417f7 100644 --- a/src/core/api_v1_urls.py +++ b/src/core/api_v1_urls.py @@ -4,12 +4,13 @@ API v1 URL configuration. Все API эндпоинты версионированы под /api/v1/ """ +from django.urls import include, path + from apps.core.views import ( BackgroundJobListView, BackgroundJobStatusView, BackgroundJobStreamView, ) -from django.urls import include, path app_name = "api_v1" @@ -20,10 +21,13 @@ jobs_urlpatterns = [ ] urlpatterns = [ + path("analytics/", include("apps.organization.analytics_root_urls")), + path("exchange/", include("apps.exchange.urls")), path("users/", include("apps.user.urls")), path("jobs/", include((jobs_urlpatterns, "jobs"))), path("organizations/", include("apps.organization.urls")), path("registers/", include("apps.registers.urls")), + path("", include("apps.external_data.urls")), path("forms/f1/", include("apps.form_1.urls")), path("forms/f2/", include("apps.form_2.urls")), path("forms/f3/", include("apps.form_3.urls")), diff --git a/src/core/asgi.py b/src/core/asgi.py index 0ee4e62..1a0a0b2 100644 --- a/src/core/asgi.py +++ b/src/core/asgi.py @@ -9,9 +9,10 @@ https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ import os -from apps.core.startup_checks import run_startup_checks from django.core.asgi import get_asgi_application +from apps.core.startup_checks import run_startup_checks + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.production") run_startup_checks(component="asgi") diff --git a/src/core/celery.py b/src/core/celery.py index bb17498..fa27285 100644 --- a/src/core/celery.py +++ b/src/core/celery.py @@ -8,9 +8,10 @@ import logging import os import sys -from apps.core.startup_checks import run_startup_checks from celery import Celery +from apps.core.startup_checks import run_startup_checks + logger = logging.getLogger(__name__) # Set the Django settings module for the 'celery' program. diff --git a/src/core/urls.py b/src/core/urls.py index 6afcb67..1b5ff53 100644 --- a/src/core/urls.py +++ b/src/core/urls.py @@ -8,14 +8,15 @@ from django.conf import settings from django.conf.urls.static import static from django.contrib import admin from django.urls import include, path +from drf_yasg import openapi +from drf_yasg.views import get_schema_view +from rest_framework import permissions + from apps.core.openapi import ( OPENAPI_PROJECT_DESCRIPTION, OPENAPI_PROJECT_TITLE, RussianTagSchemaGenerator, ) -from drf_yasg import openapi -from drf_yasg.views import get_schema_view -from rest_framework import permissions # Swagger schema view schema_view = get_schema_view( diff --git a/src/core/wsgi.py b/src/core/wsgi.py index 513d1c1..a426684 100644 --- a/src/core/wsgi.py +++ b/src/core/wsgi.py @@ -9,9 +9,10 @@ https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ import os -from apps.core.startup_checks import run_startup_checks from django.core.wsgi import get_wsgi_application +from apps.core.startup_checks import run_startup_checks + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.dev") run_startup_checks(component="wsgi") diff --git a/src/manage.py b/src/manage.py index b29b5c5..5591b3f 100644 --- a/src/manage.py +++ b/src/manage.py @@ -1,5 +1,6 @@ #!/usr/bin/env python """Django's command-line utility for administrative tasks.""" + import os import sys diff --git a/src/settings/base.py b/src/settings/base.py index a40a798..8242dda 100644 --- a/src/settings/base.py +++ b/src/settings/base.py @@ -17,6 +17,8 @@ STARTUP_DB_TIMEOUT_SECONDS = 3 STARTUP_REDIS_TIMEOUT_SECONDS = 3 BACKUP_ENCRYPTION_KEY = os.getenv("BACKUP_ENCRYPTION_KEY", "") BACKUP_KEY_ID = os.getenv("BACKUP_KEY_ID", "default") +EXCHANGE_SHARED_TOKEN = os.getenv("EXCHANGE_SHARED_TOKEN", "") +EXCHANGE_KEY_ID = os.getenv("EXCHANGE_KEY_ID", "dev-shared-token") warnings.filterwarnings( "ignore", @@ -46,6 +48,8 @@ INSTALLED_APPS = [ "apps.user", "apps.organization", "apps.registers", + "apps.exchange", + "apps.external_data", "apps.form_1", "apps.form_2", "apps.form_3", diff --git a/src/settings/dev.py b/src/settings/dev.py index 5acc2be..d15adc9 100644 --- a/src/settings/dev.py +++ b/src/settings/dev.py @@ -10,6 +10,10 @@ SECRET_KEY = "django-insecure-development-key-state-corp-2026" DEBUG = True ALLOWED_HOSTS = ["*"] OPENAPI_USE_ENGLISH_TAGS = False +EXCHANGE_SHARED_TOKEN = os.getenv( + "EXCHANGE_SHARED_TOKEN", + "state-corp-dev-exchange-token-v1", +) # JWT SIMPLE_JWT["SIGNING_KEY"] = SECRET_KEY diff --git a/src/settings/test.py b/src/settings/test.py index 7023c3c..5ea45ec 100644 --- a/src/settings/test.py +++ b/src/settings/test.py @@ -7,6 +7,7 @@ from .base import * SECRET_KEY = "django-insecure-test-key-only-for-testing" DEBUG = True STARTUP_CHECKS_ENABLED = False +EXCHANGE_SHARED_TOKEN = "state-corp-test-exchange-token" # JWT SIMPLE_JWT["SIGNING_KEY"] = SECRET_KEY diff --git a/src/templates/admin/exchange/exchangepackageimport/change_list.html b/src/templates/admin/exchange/exchangepackageimport/change_list.html new file mode 100644 index 0000000..216b5fa --- /dev/null +++ b/src/templates/admin/exchange/exchangepackageimport/change_list.html @@ -0,0 +1,9 @@ +{% extends "admin/change_list.html" %} +{% load admin_urls %} + +{% block object-tools-items %} + + Обмен реестров и данных организаций + + {{ block.super }} +{% endblock %} diff --git a/src/templates/admin/exchange/exchangepackageimport/upload_package.html b/src/templates/admin/exchange/exchangepackageimport/upload_package.html new file mode 100644 index 0000000..53d4fef --- /dev/null +++ b/src/templates/admin/exchange/exchangepackageimport/upload_package.html @@ -0,0 +1,54 @@ +{% extends "admin/base_site.html" %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content_title %}Обмен реестров и данных организаций{% endblock %} + +{% block content %} +
+
+
+
+

+ Загрузите единый exchange-пакет с реестрами и данными организаций. + Импорт выполняется синхронно и использует тот же pipeline, что API и CLI. +

+

+ Всего импортов: {{ imports_count }}, успешных: {{ successful_imports_count }}. + Организаций в справочнике: {{ organizations_count }}. +

+ +
+ {% csrf_token %} +
+ + + + Поддерживаются архивы .zip и контейнеры .bin. + Для расшифровки используется EXCHANGE_SHARED_TOKEN. + +
+ +
+ + Отмена +
+
+
+
+
+
+{% endblock %} diff --git a/tests/apps/core/test_background_jobs.py b/tests/apps/core/test_background_jobs.py index 756c4d9..7e3f7b3 100644 --- a/tests/apps/core/test_background_jobs.py +++ b/tests/apps/core/test_background_jobs.py @@ -1,9 +1,10 @@ """Тесты для BackgroundJob.""" +from django.test import TestCase +from faker import Faker + from apps.core.models import BackgroundJob, JobStatus from apps.core.services import BackgroundJobService -from django.test import TestCase -from faker import Faker fake = Faker() diff --git a/tests/apps/core/test_bulk_operations.py b/tests/apps/core/test_bulk_operations.py index 5227ed4..5c9c34c 100644 --- a/tests/apps/core/test_bulk_operations.py +++ b/tests/apps/core/test_bulk_operations.py @@ -1,12 +1,13 @@ """Тесты для BulkOperationsMixin и QueryOptimizerMixin.""" +from django.test import TestCase +from faker import Faker + from apps.core.models import BackgroundJob from apps.core.services import ( BulkOperationsMixin, QueryOptimizerMixin, ) -from django.test import TestCase -from faker import Faker fake = Faker() diff --git a/tests/apps/core/test_cache.py b/tests/apps/core/test_cache.py index ed8ca70..5287d45 100644 --- a/tests/apps/core/test_cache.py +++ b/tests/apps/core/test_cache.py @@ -1,13 +1,14 @@ """Tests for core cache utilities""" +from django.core.cache import cache +from django.test import TestCase + from apps.core.cache import ( CacheManager, _build_cache_key, cache_method, cache_result, ) -from django.core.cache import cache -from django.test import TestCase class CacheResultDecoratorTest(TestCase): diff --git a/tests/apps/core/test_excel.py b/tests/apps/core/test_excel.py index 36423ba..f0602cc 100644 --- a/tests/apps/core/test_excel.py +++ b/tests/apps/core/test_excel.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock +from django.test import TestCase + from apps.core.excel import ( BaseExcelParser, ColumnMapping, @@ -14,7 +16,6 @@ from apps.core.excel import ( validate_ogrn, validate_okpo, ) -from django.test import TestCase class ValidatorsTest(TestCase): diff --git a/tests/apps/core/test_exceptions.py b/tests/apps/core/test_exceptions.py index f862d1a..89d764f 100644 --- a/tests/apps/core/test_exceptions.py +++ b/tests/apps/core/test_exceptions.py @@ -1,5 +1,7 @@ """Tests for core exceptions and exception handler""" +from django.test import TestCase + from apps.core.exceptions import ( AuthenticationError, BadRequestError, @@ -16,7 +18,6 @@ from apps.core.exceptions import ( ServiceUnavailableError, ValidationError, ) -from django.test import TestCase class BaseAPIExceptionTest(TestCase): diff --git a/tests/apps/core/test_filters.py b/tests/apps/core/test_filters.py index 609761f..9d7bb87 100644 --- a/tests/apps/core/test_filters.py +++ b/tests/apps/core/test_filters.py @@ -1,5 +1,9 @@ """Tests for core filter utilities""" +from django.test import TestCase +from django_filters import rest_framework as filters +from rest_framework.filters import OrderingFilter, SearchFilter + from apps.core.filters import ( BaseFilterSet, FilterMixin, @@ -7,9 +11,6 @@ from apps.core.filters import ( StandardSearchFilter, get_filter_backends, ) -from django.test import TestCase -from django_filters import rest_framework as filters -from rest_framework.filters import OrderingFilter, SearchFilter class BaseFilterSetTest(TestCase): diff --git a/tests/apps/core/test_generate_test_reports_command.py b/tests/apps/core/test_generate_test_reports_command.py index 144d96f..f81517c 100644 --- a/tests/apps/core/test_generate_test_reports_command.py +++ b/tests/apps/core/test_generate_test_reports_command.py @@ -2,6 +2,15 @@ from io import StringIO +from django.core.management import call_command +from django.test import TestCase + +from apps.external_data.models import ( + ArbitrationCase, + IndustrialProduct, + ProsecutorCheck, + PublicProcurement, +) from apps.form_1.models import FormF1Record from apps.form_2.models import FormF2Record from apps.form_3.models import FormF3Record @@ -10,8 +19,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 RegisterUpload, RegistryMembershipPeriod -from django.core.management import call_command -from django.test import TestCase class GenerateTestReportsCommandTest(TestCase): @@ -28,7 +35,9 @@ class GenerateTestReportsCommandTest(TestCase): ) self.assertEqual( - Organization.objects.filter(name__startswith="Автотест организация").count(), + Organization.objects.filter( + name__startswith="Автотест организация" + ).count(), 3, ) self.assertEqual(FormF1Record.objects.count(), 12) @@ -38,16 +47,74 @@ class GenerateTestReportsCommandTest(TestCase): self.assertEqual(FormF5Record.objects.count(), 12) self.assertEqual(FormF6Record.objects.count(), 12) self.assertEqual(FormF1Record.objects.filter(is_active_version=True).count(), 9) - self.assertEqual(FormF1Record.objects.filter(is_active_version=False).count(), 3) + self.assertEqual( + FormF1Record.objects.filter(is_active_version=False).count(), 3 + ) self.assertEqual( RegistryMembershipPeriod.objects.filter(ended_at__isnull=True).count(), 3, ) self.assertGreaterEqual(RegisterUpload.objects.count(), 1) + self.assertEqual(IndustrialProduct.objects.count(), 3) + self.assertEqual(ProsecutorCheck.objects.count(), 3) + self.assertEqual(PublicProcurement.objects.count(), 3) + self.assertEqual(ArbitrationCase.objects.count(), 3) - self.assertIn("Ф-1: создано 12 записей, активных 9, архивных 3", stdout.getvalue()) - self.assertIn("Ф-6: создано 12 записей, активных 9, архивных 3", stdout.getvalue()) + self.assertIn( + "Ф-1: создано 12 записей, активных 9, архивных 3", stdout.getvalue() + ) + self.assertIn( + "Ф-6: создано 12 записей, активных 9, архивных 3", stdout.getvalue() + ) self.assertIn("Реестры: актуальных участий создано 3", stdout.getvalue()) + self.assertIn( + "Внешние данные: products=3, checks=3, procurements=3, arbitration=3", + stdout.getvalue(), + ) + + def test_generate_test_reports_clears_previous_dataset_before_regeneration(self): + call_command( + "generate_test_reports", + count=3, + prefix="Первый прогон", + ) + + stdout = StringIO() + call_command( + "generate_test_reports", + count=2, + prefix="Второй прогон", + stdout=stdout, + ) + + self.assertEqual( + Organization.objects.filter(name__startswith="Второй прогон").count(), + 2, + ) + self.assertFalse( + Organization.objects.filter(name__startswith="Первый прогон").exists() + ) + self.assertEqual(Organization.objects.count(), 2) + self.assertEqual(FormF1Record.objects.count(), 8) + self.assertEqual(FormF2Record.objects.count(), 8) + self.assertEqual(FormF3Record.objects.count(), 8) + self.assertEqual(FormF4Record.objects.count(), 8) + self.assertEqual(FormF5Record.objects.count(), 8) + self.assertEqual(FormF6Record.objects.count(), 8) + self.assertEqual(FormF1Record.objects.filter(is_active_version=True).count(), 6) + self.assertEqual( + FormF1Record.objects.filter(is_active_version=False).count(), 2 + ) + self.assertEqual( + RegistryMembershipPeriod.objects.filter(ended_at__isnull=True).count(), + 2, + ) + self.assertEqual(IndustrialProduct.objects.count(), 2) + self.assertEqual(ProsecutorCheck.objects.count(), 2) + self.assertEqual(PublicProcurement.objects.count(), 2) + self.assertEqual(ArbitrationCase.objects.count(), 2) + + self.assertIn("Очистка старых данных: организаций 3", stdout.getvalue()) def test_generate_test_reports_dry_run_rolls_back_changes(self): stdout = StringIO() @@ -71,5 +138,9 @@ class GenerateTestReportsCommandTest(TestCase): self.assertEqual(FormF6Record.objects.count(), 0) self.assertEqual(RegisterUpload.objects.count(), 0) self.assertEqual(RegistryMembershipPeriod.objects.count(), 0) + self.assertEqual(IndustrialProduct.objects.count(), 0) + self.assertEqual(ProsecutorCheck.objects.count(), 0) + self.assertEqual(PublicProcurement.objects.count(), 0) + self.assertEqual(ArbitrationCase.objects.count(), 0) self.assertIn("Dry-run: транзакция откачена", stdout.getvalue()) diff --git a/tests/apps/core/test_logging.py b/tests/apps/core/test_logging.py index 394085c..b5230e2 100644 --- a/tests/apps/core/test_logging.py +++ b/tests/apps/core/test_logging.py @@ -4,12 +4,13 @@ import json import logging from io import StringIO +from django.test import TestCase + from apps.core.logging import ( ContextLogger, JSONFormatter, get_json_logging_config, ) -from django.test import TestCase class JSONFormatterTest(TestCase): diff --git a/tests/apps/core/test_management_commands.py b/tests/apps/core/test_management_commands.py index 9288da3..ffd421c 100644 --- a/tests/apps/core/test_management_commands.py +++ b/tests/apps/core/test_management_commands.py @@ -2,10 +2,11 @@ from io import StringIO -from apps.core.management.commands.base import BaseAppCommand from django.core.management.base import CommandError from django.test import TestCase +from apps.core.management.commands.base import BaseAppCommand + class TestCommand(BaseAppCommand): """Тестовая команда для проверки BaseAppCommand.""" diff --git a/tests/apps/core/test_mixins.py b/tests/apps/core/test_mixins.py index f92d2b7..2be3fb1 100644 --- a/tests/apps/core/test_mixins.py +++ b/tests/apps/core/test_mixins.py @@ -1,11 +1,12 @@ """Тесты для Model Mixins.""" +from django.test import TestCase + from apps.core.mixins import ( OrderableMixin, SoftDeleteMixin, StatusMixin, ) -from django.test import TestCase class TimestampMixinTest(TestCase): diff --git a/tests/apps/core/test_openapi.py b/tests/apps/core/test_openapi.py index 9bc164f..5a5cdf5 100644 --- a/tests/apps/core/test_openapi.py +++ b/tests/apps/core/test_openapi.py @@ -1,5 +1,9 @@ """Tests for core OpenAPI utilities""" +from django.test import TestCase +from drf_yasg import openapi +from rest_framework import serializers + from apps.core.openapi import ( CommonParameters, CommonResponses, @@ -7,9 +11,6 @@ from apps.core.openapi import ( api_docs, paginated_response, ) -from django.test import TestCase -from drf_yasg import openapi -from rest_framework import serializers class DummySerializer(serializers.Serializer): diff --git a/tests/apps/core/test_permissions.py b/tests/apps/core/test_permissions.py index 10d7dea..e382b33 100644 --- a/tests/apps/core/test_permissions.py +++ b/tests/apps/core/test_permissions.py @@ -1,5 +1,9 @@ """Tests for core permissions""" +from django.contrib.auth import get_user_model +from django.test import RequestFactory, TestCase +from rest_framework.views import APIView + from apps.core.permissions import ( IsAdmin, IsAdminOrReadOnly, @@ -9,10 +13,6 @@ from apps.core.permissions import ( IsSuperuser, IsVerified, ) -from django.contrib.auth import get_user_model -from django.test import RequestFactory, TestCase -from rest_framework.views import APIView - from tests.apps.user.factories import UserFactory User = get_user_model() diff --git a/tests/apps/core/test_response.py b/tests/apps/core/test_response.py index 4368625..fa26543 100644 --- a/tests/apps/core/test_response.py +++ b/tests/apps/core/test_response.py @@ -1,5 +1,8 @@ """Tests for core response wrapper""" +from django.test import TestCase +from rest_framework import status + from apps.core.response import ( api_created_response, api_error_response, @@ -7,8 +10,6 @@ from apps.core.response import ( api_paginated_response, api_response, ) -from django.test import TestCase -from rest_framework import status class APIResponseTest(TestCase): diff --git a/tests/apps/core/test_services.py b/tests/apps/core/test_services.py index e7b2c2e..4c41ae2 100644 --- a/tests/apps/core/test_services.py +++ b/tests/apps/core/test_services.py @@ -1,9 +1,10 @@ """Tests for core services""" +from django.contrib.auth import get_user_model +from django.test import TestCase + from apps.core.exceptions import NotFoundError from apps.core.services import BaseService -from django.contrib.auth import get_user_model -from django.test import TestCase User = get_user_model() diff --git a/tests/apps/core/test_signals.py b/tests/apps/core/test_signals.py index 4444401..dd776dd 100644 --- a/tests/apps/core/test_signals.py +++ b/tests/apps/core/test_signals.py @@ -1,5 +1,8 @@ """Tests for core signals utilities""" +from django.contrib.auth import get_user_model +from django.db.models.signals import post_save, pre_save +from django.test import TestCase from apps.core.signals import ( SignalDispatcher, @@ -12,10 +15,6 @@ from apps.core.signals import ( user_registered, user_verified, ) -from django.contrib.auth import get_user_model -from django.db.models.signals import post_save, pre_save -from django.test import TestCase - from tests.apps.user.factories import UserFactory User = get_user_model() diff --git a/tests/apps/core/test_tasks.py b/tests/apps/core/test_tasks.py index a50b51c..c7477f5 100644 --- a/tests/apps/core/test_tasks.py +++ b/tests/apps/core/test_tasks.py @@ -1,5 +1,7 @@ """Tests for core Celery tasks""" +from celery import Task +from django.test import TestCase from apps.core.tasks import ( BaseTask, @@ -8,8 +10,6 @@ from apps.core.tasks import ( TimedTask, TransactionalTask, ) -from celery import Task -from django.test import TestCase class BaseTaskTest(TestCase): diff --git a/tests/apps/core/test_viewsets.py b/tests/apps/core/test_viewsets.py index 21cb965..717ca63 100644 --- a/tests/apps/core/test_viewsets.py +++ b/tests/apps/core/test_viewsets.py @@ -4,6 +4,14 @@ from __future__ import annotations from typing import Any +from django.test import TestCase, override_settings +from django.urls import include, path +from rest_framework import serializers, status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.routers import DefaultRouter +from rest_framework.test import APITestCase + from apps.core.pagination import StandardPagination from apps.core.viewsets import ( BaseViewSet, @@ -13,14 +21,6 @@ from apps.core.viewsets import ( ) from apps.organization.models import Organization from apps.user.models import Profile, User -from django.test import TestCase, override_settings -from django.urls import include, path -from rest_framework import serializers, status, viewsets -from rest_framework.decorators import action -from rest_framework.permissions import IsAuthenticated -from rest_framework.routers import DefaultRouter -from rest_framework.test import APITestCase - from tests.apps.organization.factories import OrganizationFactory, fake from tests.apps.user.factories import ProfileFactory, UserFactory diff --git a/tests/apps/exchange/__init__.py b/tests/apps/exchange/__init__.py new file mode 100644 index 0000000..671fe7a --- /dev/null +++ b/tests/apps/exchange/__init__.py @@ -0,0 +1 @@ +"""Tests for exchange app.""" diff --git a/tests/apps/exchange/test_admin.py b/tests/apps/exchange/test_admin.py new file mode 100644 index 0000000..46b50b2 --- /dev/null +++ b/tests/apps/exchange/test_admin.py @@ -0,0 +1,47 @@ +"""Tests for exchange package admin upload flow.""" + +from __future__ import annotations + +from django.test import TestCase +from django.urls import reverse + +from apps.exchange.models import ExchangeDeliveryChannel, ExchangePackageImport +from apps.organization.models import Organization +from tests.apps.exchange.test_api import build_exchange_archive, build_exchange_payload +from tests.apps.user.factories import UserFactory + + +class ExchangePackageAdminTest(TestCase): + """Verify manual package import entrypoints in Django admin.""" + + def setUp(self): + self.superuser = UserFactory.create_superuser() + self.client.force_login(self.superuser) + self.upload_url = reverse("admin:exchange_exchangepackageimport_upload_package") + self.changelist_url = reverse("admin:exchange_exchangepackageimport_changelist") + + def test_upload_package_view_imports_exchange_archive(self): + archive = build_exchange_archive( + package_id="pkg-admin-001", + data=build_exchange_payload(), + ) + + response = self.client.post( + self.upload_url, + {"file": archive}, + follow=True, + ) + + self.assertRedirects(response, self.changelist_url) + self.assertContains(response, "Импорт пакета обмена завершён") + imported = ExchangePackageImport.objects.get(package_id="pkg-admin-001") + self.assertEqual(imported.delivery_channel, ExchangeDeliveryChannel.ADMIN) + self.assertEqual(imported.imported_by, self.superuser) + self.assertEqual(Organization.objects.count(), 2) + + def test_admin_dashboard_contains_exchange_action(self): + response = self.client.get(reverse("admin:index")) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Обмен данными") + self.assertContains(response, self.upload_url) diff --git a/tests/apps/exchange/test_api.py b/tests/apps/exchange/test_api.py new file mode 100644 index 0000000..63b6452 --- /dev/null +++ b/tests/apps/exchange/test_api.py @@ -0,0 +1,313 @@ +"""Tests for encrypted exchange package upload and import.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import struct +import tempfile +import zlib +from datetime import date +from io import BytesIO +from zipfile import ZIP_DEFLATED, ZipFile + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from django.conf import settings +from django.core.files.uploadedfile import SimpleUploadedFile +from django.core.management import call_command +from django.urls import reverse +from django.utils.crypto import get_random_string +from rest_framework import status +from rest_framework.test import APITestCase + +from apps.exchange.models import ExchangeDeliveryChannel, ExchangePackageImport +from apps.exchange.services import ExchangePackageImportService +from apps.external_data.models import ( + ArbitrationCase, + IndustrialProduct, + ProsecutorCheck, + PublicProcurement, +) +from apps.organization.models import Organization +from apps.user.models import User + +TEST_TOKEN = settings.EXCHANGE_SHARED_TOKEN + + +def _b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") + + +def build_exchange_archive( + *, + package_id: str = "pkg-20260407-001", + archive_name: str = "exchange_package_20260407.zip", + bin_name: str = "exchange_package_20260407.bin", + data: dict[str, list[dict[str, object]]] | None = None, +) -> SimpleUploadedFile: + """Build encrypted exchange archive compatible with import service.""" + payload = { + "format": ExchangePackageImportService.PAYLOAD_FORMAT, + "schema_version": 1, + "manifest": { + "package_id": package_id, + "source_system": "mostovik-dev", + "produced_at": "2026-04-07T12:00:00+00:00", + "sections": list((data or {}).keys()), + }, + "data": data or {}, + } + payload_bytes = json.dumps( + payload, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + compressed_payload = zlib.compress(payload_bytes, level=9) + nonce = b"sc-exch-0001" + aad = ExchangePackageImportService.AAD + raw_key = hashlib.sha256(TEST_TOKEN.encode("utf-8")).digest() + encrypted_payload = AESGCM(raw_key).encrypt(nonce, compressed_payload, aad) + + header = { + "format": ExchangePackageImportService.BIN_FORMAT, + "version": 1, + "key_id": "test-shared-token", + "nonce": _b64url(nonce), + "aad": _b64url(aad), + "package_id": package_id, + "schema_version": 1, + "plaintext_sha256": hashlib.sha256(payload_bytes).hexdigest(), + "compressed_sha256": hashlib.sha256(compressed_payload).hexdigest(), + "ciphertext_sha256": hashlib.sha256(encrypted_payload).hexdigest(), + } + header_bytes = json.dumps( + header, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + bin_bytes = ( + ExchangePackageImportService.MAGIC + + bytes([1]) + + struct.pack(">I", len(header_bytes)) + + header_bytes + + encrypted_payload + ) + + archive_bytes = BytesIO() + with ZipFile(archive_bytes, "w", compression=ZIP_DEFLATED) as archive: + archive.writestr(bin_name, bin_bytes) + archive.writestr( + f"{bin_name}.sha256", + f"{hashlib.sha256(bin_bytes).hexdigest()} {bin_name}\n", + ) + + return SimpleUploadedFile( + archive_name, + archive_bytes.getvalue(), + content_type="application/zip", + ) + + +def build_exchange_payload() -> dict[str, list[dict[str, object]]]: + """Create a representative payload for import tests.""" + return { + "organizations": [ + { + "inn": "7707083893", + "name": "АО Альфа Обновленная", + "short_name": "АО Альфа", + "organization_type": "ao", + "cluster": "radioelectronics", + "ogrn": "1027700132195", + "kpp": "770701001", + "okpo": "12345678", + "registration_date": "2024-02-15", + "legal_address": "г. Москва, ул. Тверская, д. 1", + "activity_type": "Производство электронных компонентов", + "founder_name": "Госкорпорация Пример", + "ownership_type": "Федеральная собственность", + "legal_form": "Акционерное общество", + "charter_capital_amount": "1500000.50", + "general_director_name": "Иванов Иван Иванович", + "general_director_inn": "123456789012", + "general_director_appointment_date": "2025-01-10", + "executors_count": 175, + "financial_reports_available": True, + "tax_reports_available": True, + "in_defense_unreliable_suppliers_registry": False, + "in_275_fz_registry": True, + "bankruptcy_messages_found": False, + }, + { + "inn": "7707083894", + "name": "АО Бета", + "short_name": "АО Бета", + "organization_type": "pao", + "cluster": "space", + "ogrn": "1027700132196", + "kpp": "770701002", + "okpo": "12345679", + "registration_date": "2023-09-01", + }, + ], + "industrial_products": [ + { + "organization_inn": "7707083893", + "product_name": "Система связи М-1", + "product_class": "Связь", + "okpd2_code": "26.30.11", + "tnved_code": "8517620000", + "registry_number": "prod-001", + } + ], + "prosecutor_checks": [ + { + "organization_inn": "7707083893", + "registration_number": "check-001", + "law_type": "294-ФЗ", + "control_authority": "Минпромторг", + "prosecutor_office": "Прокуратура г. Москвы", + "start_date": "2026-03-10", + "status": "active", + } + ], + "public_procurements": [ + { + "organization_inn": "7707083893", + "purchase_number": "purchase-001", + "law_type": "223-ФЗ", + "status": "executing", + "contract_amount": "4500000.75", + "contract_date": "2026-02-15", + "execution_start_date": "2026-02-20", + "execution_end_date": "2026-11-30", + "purchase_name": "Поставка специализированного оборудования", + } + ], + "arbitration_cases": [ + { + "organization_inn": "7707083893", + "case_number": "А40-12345/2026", + "court_name": "Арбитражный суд города Москвы", + "party_role": "ответчик", + "status": "in_progress", + "decision_date": "2026-03-25", + } + ], + } + + +class ExchangePackageApiTest(APITestCase): + """Integration tests for exchange API and CLI import.""" + + def setUp(self): + self.url = reverse("api_v1:exchange:package-upload") + password = get_random_string(16) + self.user = User.objects.create_user( + username="exchange-admin", + email="exchange@example.com", + password=password, + ) + + def test_upload_imports_package_and_upserts_models(self): + Organization.objects.create( + inn="7707083893", + name="АО Альфа", + ogrn="1027700132000", + kpp="770701000", + ) + archive = build_exchange_archive(data=build_exchange_payload()) + + response = self.client.post( + self.url, + {"file": archive}, + format="multipart", + HTTP_X_EXCHANGE_TOKEN=TEST_TOKEN, + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertFalse(response.data["result"]["duplicate"]) + self.assertEqual(response.data["result"]["organizations"]["created"], 1) + self.assertEqual(response.data["result"]["organizations"]["updated"], 1) + self.assertEqual(Organization.objects.count(), 2) + self.assertEqual(IndustrialProduct.objects.count(), 1) + self.assertEqual(ProsecutorCheck.objects.count(), 1) + self.assertEqual(PublicProcurement.objects.count(), 1) + self.assertEqual(ArbitrationCase.objects.count(), 1) + + organization = Organization.objects.get(inn="7707083893") + self.assertEqual(organization.name, "АО Альфа Обновленная") + self.assertEqual(organization.short_name, "АО Альфа") + self.assertEqual(organization.organization_type, "ao") + self.assertEqual(organization.cluster, "radioelectronics") + self.assertEqual(organization.registration_date, date(2024, 2, 15)) + self.assertEqual(organization.executors_count, 175) + self.assertTrue(organization.tax_reports_available) + self.assertTrue(organization.in_275_fz_registry) + + package_import = ExchangePackageImport.objects.get() + self.assertEqual(package_import.delivery_channel, ExchangeDeliveryChannel.API) + self.assertEqual(package_import.status, "success") + + def test_upload_rejects_invalid_exchange_token(self): + archive = build_exchange_archive(data=build_exchange_payload()) + invalid_token = get_random_string(24) + + response = self.client.post( + self.url, + {"file": archive}, + format="multipart", + HTTP_X_EXCHANGE_TOKEN=invalid_token, + ) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(ExchangePackageImport.objects.count(), 0) + self.assertEqual(Organization.objects.count(), 0) + + def test_upload_is_idempotent_for_duplicate_package(self): + archive = build_exchange_archive( + package_id="pkg-duplicate-001", + data=build_exchange_payload(), + ) + first_response = self.client.post( + self.url, + {"file": archive}, + format="multipart", + HTTP_X_EXCHANGE_TOKEN=TEST_TOKEN, + ) + self.assertEqual(first_response.status_code, status.HTTP_201_CREATED) + + second_archive = build_exchange_archive( + package_id="pkg-duplicate-001", + data=build_exchange_payload(), + ) + second_response = self.client.post( + self.url, + {"file": second_archive}, + format="multipart", + HTTP_X_EXCHANGE_TOKEN=TEST_TOKEN, + ) + + self.assertEqual(second_response.status_code, status.HTTP_201_CREATED) + self.assertTrue(second_response.data["result"]["duplicate"]) + self.assertEqual(Organization.objects.count(), 2) + self.assertEqual(IndustrialProduct.objects.count(), 1) + self.assertEqual(ExchangePackageImport.objects.count(), 2) + duplicate_import = ExchangePackageImport.objects.order_by("-created_at").first() + self.assertIsNotNone(duplicate_import.duplicate_of) + + def test_cli_import_uses_same_pipeline(self): + archive = build_exchange_archive( + package_id="pkg-cli-001", + data=build_exchange_payload(), + ) + with tempfile.NamedTemporaryFile(suffix=".zip") as temp_file: + temp_file.write(archive.read()) + temp_file.flush() + + call_command("import_exchange_package", temp_file.name, "--channel=cli") + + imported = ExchangePackageImport.objects.get(package_id="pkg-cli-001") + self.assertEqual(imported.delivery_channel, ExchangeDeliveryChannel.CLI) + self.assertEqual(Organization.objects.count(), 2) diff --git a/tests/apps/external_data/__init__.py b/tests/apps/external_data/__init__.py new file mode 100644 index 0000000..2c208cc --- /dev/null +++ b/tests/apps/external_data/__init__.py @@ -0,0 +1 @@ +"""Tests for external data app.""" diff --git a/tests/apps/external_data/factories.py b/tests/apps/external_data/factories.py new file mode 100644 index 0000000..71bcda3 --- /dev/null +++ b/tests/apps/external_data/factories.py @@ -0,0 +1,70 @@ +"""Factories for external data models.""" + +import factory +from faker import Faker + +from apps.external_data.models import ( + ArbitrationCase, + IndustrialProduct, + ProsecutorCheck, + PublicProcurement, +) +from tests.apps.organization.factories import OrganizationFactory + +fake = Faker("ru_RU") + + +class IndustrialProductFactory(factory.django.DjangoModelFactory): + class Meta: + model = IndustrialProduct + + organization = factory.SubFactory(OrganizationFactory) + product_name = factory.LazyAttribute(lambda _: fake.sentence(nb_words=3)) + product_class = "electronics" + okpd2_code = "26.51.53" + tnved_code = "9015.10" + registry_number = factory.Sequence(lambda n: f"{1000 + n}/2026") + + +class ProsecutorCheckFactory(factory.django.DjangoModelFactory): + class Meta: + model = ProsecutorCheck + + organization = factory.SubFactory(OrganizationFactory) + registration_number = factory.Sequence(lambda n: f"{2_900_000_000 + n}") + law_type = "294_fz" + control_authority = "Центральное управление Ростехнадзора" + prosecutor_office = "Московская городская прокуратура" + start_date = factory.LazyAttribute(lambda _: fake.date_this_decade()) + status = "planned" + + +class PublicProcurementFactory(factory.django.DjangoModelFactory): + class Meta: + model = PublicProcurement + + organization = factory.SubFactory(OrganizationFactory) + purchase_number = factory.Sequence(lambda n: f"{37_310_000_000_000_0000 + n:019d}") + law_type = "44_fz" + status = "signing" + contract_amount = factory.LazyAttribute( + lambda _: fake.pydecimal(left_digits=8, right_digits=2, positive=True) + ) + contract_date = factory.LazyAttribute(lambda _: fake.date_this_year()) + execution_start_date = factory.LazyAttribute(lambda _: fake.date_this_year()) + execution_end_date = factory.LazyAttribute( + lambda _: fake.date_between(start_date="+30d", end_date="+365d") + ) + purchase_name = factory.LazyAttribute(lambda _: fake.sentence(nb_words=6)) + + +class ArbitrationCaseFactory(factory.django.DjangoModelFactory): + class Meta: + model = ArbitrationCase + + organization = factory.SubFactory(OrganizationFactory) + case_number = factory.Sequence(lambda n: f"А40-{16_000 + n}/2026") + court_name = "Арбитражный суд города Москвы" + party_role = "defendant" + status = "hearing_scheduled" + decision_date = factory.LazyAttribute(lambda _: fake.date_this_year()) diff --git a/tests/apps/external_data/test_api.py b/tests/apps/external_data/test_api.py new file mode 100644 index 0000000..c16afec --- /dev/null +++ b/tests/apps/external_data/test_api.py @@ -0,0 +1,91 @@ +"""Tests for external data endpoints.""" + +from __future__ import annotations + +from datetime import date + +from django.test import override_settings +from rest_framework import status +from rest_framework.test import APITestCase + +from tests.apps.external_data.factories import ( + ArbitrationCaseFactory, + IndustrialProductFactory, + ProsecutorCheckFactory, + PublicProcurementFactory, +) +from tests.apps.organization.factories import OrganizationFactory +from tests.apps.user.factories import UserFactory + + +@override_settings(ROOT_URLCONF="core.urls") +class ExternalDataApiTest(APITestCase): + def setUp(self): + self.user = UserFactory.create_user() + self.client.force_authenticate(self.user) + self.organization = OrganizationFactory.create() + self.other_organization = OrganizationFactory.create() + + def test_industrial_products_endpoint_uses_classic_pagination(self): + IndustrialProductFactory.create( + organization=self.organization, + product_name="Лазерные датчики расстояния", + ) + IndustrialProductFactory.create(organization=self.other_organization) + + response = self.client.get( + f"/api/v1/industrial-products/?organization={self.organization.id}&search=датчики" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("results", response.data) + self.assertEqual(response.data["count"], 1) + self.assertEqual( + response.data["results"][0]["organization"], str(self.organization.id) + ) + + def test_prosecutor_checks_support_date_range_filters(self): + ProsecutorCheckFactory.create( + organization=self.organization, + law_type="294_fz", + start_date=date(2025, 1, 15), + ) + ProsecutorCheckFactory.create( + organization=self.organization, + law_type="294_fz", + start_date=date(2023, 1, 15), + ) + + response = self.client.get( + f"/api/v1/prosecutor-checks/?organization={self.organization.id}" + "&law_type=294_fz&start_date_from=2024-01-01&start_date_to=2025-12-31" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + + def test_public_procurements_and_arbitration_cases_filters(self): + PublicProcurementFactory.create( + organization=self.organization, + law_type="44_fz", + contract_date=date(2025, 1, 15), + ) + ArbitrationCaseFactory.create( + organization=self.organization, + party_role="defendant", + decision_date=date(2026, 1, 27), + ) + + procurement_response = self.client.get( + f"/api/v1/public-procurements/?organization={self.organization.id}" + "&law_type=44_fz&contract_date_from=2024-01-01&contract_date_to=2025-12-31" + ) + arbitration_response = self.client.get( + f"/api/v1/arbitration-cases/?organization={self.organization.id}" + "&party_role=defendant&decision_date_from=2025-01-01&decision_date_to=2026-12-31" + ) + + self.assertEqual(procurement_response.status_code, status.HTTP_200_OK) + self.assertEqual(procurement_response.data["count"], 1) + self.assertEqual(arbitration_response.status_code, status.HTTP_200_OK) + self.assertEqual(arbitration_response.data["count"], 1) diff --git a/tests/apps/form_1/factories.py b/tests/apps/form_1/factories.py index 2c7cc68..27dc11a 100644 --- a/tests/apps/form_1/factories.py +++ b/tests/apps/form_1/factories.py @@ -1,9 +1,9 @@ """Factories for form_1 app.""" import factory -from apps.form_1.models import FormF1Record from faker import Faker +from apps.form_1.models import FormF1Record from tests.apps.organization.factories import OrganizationFactory fake = Faker("ru_RU") diff --git a/tests/apps/form_1/test_services.py b/tests/apps/form_1/test_services.py index ec6f161..8b89bf4 100644 --- a/tests/apps/form_1/test_services.py +++ b/tests/apps/form_1/test_services.py @@ -1,9 +1,8 @@ """Tests for FormF1 services.""" - -from apps.form_1.services import FormF1Parser, FormF1Service from django.test import TestCase +from apps.form_1.services import FormF1Parser, FormF1Service from tests.apps.organization.factories import OrganizationFactory from .factories import FormF1RecordFactory diff --git a/tests/apps/form_2/factories.py b/tests/apps/form_2/factories.py index 1a75bcb..fc4fa38 100644 --- a/tests/apps/form_2/factories.py +++ b/tests/apps/form_2/factories.py @@ -1,9 +1,9 @@ """Factories for form_2 app.""" import factory -from apps.form_2.models import FormF2Record from faker import Faker +from apps.form_2.models import FormF2Record from tests.apps.organization.factories import OrganizationFactory fake = Faker("ru_RU") diff --git a/tests/apps/form_2/test_services.py b/tests/apps/form_2/test_services.py index 7974fde..cf4ed6b 100644 --- a/tests/apps/form_2/test_services.py +++ b/tests/apps/form_2/test_services.py @@ -1,8 +1,8 @@ """Tests for FormF2 services.""" -from apps.form_2.services import FormF2Parser, FormF2Service from django.test import TestCase +from apps.form_2.services import FormF2Parser, FormF2Service from tests.apps.organization.factories import OrganizationFactory from .factories import FormF2RecordFactory diff --git a/tests/apps/form_3/factories.py b/tests/apps/form_3/factories.py index e057a01..2e242c3 100644 --- a/tests/apps/form_3/factories.py +++ b/tests/apps/form_3/factories.py @@ -1,9 +1,9 @@ """Factories for form_3 app.""" import factory -from apps.form_3.models import FormF3Record from faker import Faker +from apps.form_3.models import FormF3Record from tests.apps.organization.factories import OrganizationFactory fake = Faker("ru_RU") diff --git a/tests/apps/form_3/test_services.py b/tests/apps/form_3/test_services.py index 87a8a3b..ef60c62 100644 --- a/tests/apps/form_3/test_services.py +++ b/tests/apps/form_3/test_services.py @@ -1,8 +1,8 @@ """Tests for FormF3 services.""" -from apps.form_3.services import FormF3Parser, FormF3Service from django.test import TestCase +from apps.form_3.services import FormF3Parser, FormF3Service from tests.apps.organization.factories import OrganizationFactory from .factories import FormF3RecordFactory diff --git a/tests/apps/form_4/factories.py b/tests/apps/form_4/factories.py index 1c030b8..2d34adc 100644 --- a/tests/apps/form_4/factories.py +++ b/tests/apps/form_4/factories.py @@ -1,9 +1,9 @@ """Factories for form_4 app.""" import factory -from apps.form_4.models import FormF4Record from faker import Faker +from apps.form_4.models import FormF4Record from tests.apps.organization.factories import OrganizationFactory fake = Faker("ru_RU") diff --git a/tests/apps/form_4/test_services.py b/tests/apps/form_4/test_services.py index f7b3b67..c06bdc2 100644 --- a/tests/apps/form_4/test_services.py +++ b/tests/apps/form_4/test_services.py @@ -1,8 +1,8 @@ """Tests for FormF4 services.""" -from apps.form_4.services import FormF4Parser, FormF4Service from django.test import TestCase +from apps.form_4.services import FormF4Parser, FormF4Service from tests.apps.organization.factories import OrganizationFactory from .factories import FormF4RecordFactory diff --git a/tests/apps/form_5/factories.py b/tests/apps/form_5/factories.py index 533eba4..4247939 100644 --- a/tests/apps/form_5/factories.py +++ b/tests/apps/form_5/factories.py @@ -1,9 +1,9 @@ """Factories for form_5 app.""" import factory -from apps.form_5.models import FormF5Record from faker import Faker +from apps.form_5.models import FormF5Record from tests.apps.organization.factories import OrganizationFactory fake = Faker("ru_RU") diff --git a/tests/apps/form_5/test_services.py b/tests/apps/form_5/test_services.py index 7023f5e..ca588a0 100644 --- a/tests/apps/form_5/test_services.py +++ b/tests/apps/form_5/test_services.py @@ -1,8 +1,8 @@ """Tests for FormF5 services.""" -from apps.form_5.services import FormF5Parser, FormF5Service from django.test import TestCase +from apps.form_5.services import FormF5Parser, FormF5Service from tests.apps.organization.factories import OrganizationFactory from .factories import FormF5RecordFactory diff --git a/tests/apps/form_6/factories.py b/tests/apps/form_6/factories.py index b816a0b..7b3f0e5 100644 --- a/tests/apps/form_6/factories.py +++ b/tests/apps/form_6/factories.py @@ -1,9 +1,9 @@ """Factories for form_6 app.""" import factory -from apps.form_6.models import FormF6Record from faker import Faker +from apps.form_6.models import FormF6Record from tests.apps.organization.factories import OrganizationFactory fake = Faker("ru_RU") diff --git a/tests/apps/form_6/test_services.py b/tests/apps/form_6/test_services.py index c7136d5..6347afa 100644 --- a/tests/apps/form_6/test_services.py +++ b/tests/apps/form_6/test_services.py @@ -1,8 +1,8 @@ """Tests for FormF6 services.""" -from apps.form_6.services import FormF6Parser, FormF6Service from django.test import TestCase +from apps.form_6.services import FormF6Parser, FormF6Service from tests.apps.organization.factories import OrganizationFactory from .factories import FormF6RecordFactory diff --git a/tests/apps/organization/factories.py b/tests/apps/organization/factories.py index afcca94..bfdf2d2 100644 --- a/tests/apps/organization/factories.py +++ b/tests/apps/organization/factories.py @@ -1,9 +1,10 @@ """Factories for organization app.""" import factory -from apps.organization.models import Organization from faker import Faker +from apps.organization.models import IndustryCluster, Organization, OrganizationType + fake = Faker("ru_RU") @@ -14,10 +15,47 @@ class OrganizationFactory(factory.django.DjangoModelFactory): model = Organization name = factory.LazyAttribute(lambda _: fake.company()) + short_name = factory.LazyAttribute(lambda _: f"АО «{fake.company()}»") + organization_type = factory.LazyAttribute( + lambda _: fake.random_element( + [OrganizationType.AO, OrganizationType.PAO, OrganizationType.FGUP] + ) + ) + cluster = factory.LazyAttribute( + lambda _: fake.random_element( + [ + IndustryCluster.RADIOELECTRONICS, + IndustryCluster.NUCLEAR, + IndustryCluster.SPACE, + ] + ) + ) inn = factory.LazyAttribute(lambda _: fake.numerify("##########")) ogrn = factory.LazyAttribute(lambda _: fake.numerify("#############")) kpp = factory.LazyAttribute(lambda _: fake.numerify("#########")) okpo = factory.LazyAttribute(lambda _: fake.numerify("########")) + registration_date = factory.LazyAttribute(lambda _: fake.date_this_century()) + legal_address = factory.LazyAttribute(lambda _: fake.address().replace("\n", ", ")) + activity_type = factory.LazyAttribute(lambda _: fake.job()) + founder_name = factory.LazyAttribute(lambda _: fake.company()) + ownership_type = "Собственность государственных корпораций" + legal_form = "Акционерное общество" + charter_capital_amount = factory.LazyAttribute( + lambda _: fake.pydecimal(left_digits=9, right_digits=2, positive=True) + ) + general_director_name = factory.LazyAttribute(lambda _: fake.name()) + general_director_inn = factory.LazyAttribute( + lambda _: fake.numerify("############") + ) + general_director_appointment_date = factory.LazyAttribute( + lambda _: fake.date_this_decade() + ) + executors_count = factory.LazyAttribute(lambda _: fake.random_int(min=20, max=500)) + financial_reports_available = True + tax_reports_available = True + in_defense_unreliable_suppliers_registry = False + in_275_fz_registry = False + bankruptcy_messages_found = False @classmethod def create_organization(cls, **kwargs): diff --git a/tests/apps/organization/test_analytics_api.py b/tests/apps/organization/test_analytics_api.py new file mode 100644 index 0000000..ce2a0aa --- /dev/null +++ b/tests/apps/organization/test_analytics_api.py @@ -0,0 +1,235 @@ +"""Tests for organization analytics endpoints.""" + +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +from django.test import override_settings +from rest_framework import status +from rest_framework.test import APITestCase + +from apps.registers.models import Register, RegisterUpload, RegistryMembershipPeriod +from tests.apps.form_1.factories import FormF1RecordFactory +from tests.apps.form_2.factories import FormF2RecordFactory +from tests.apps.form_3.factories import FormF3RecordFactory +from tests.apps.form_4.factories import FormF4RecordFactory +from tests.apps.form_5.factories import FormF5RecordFactory +from tests.apps.form_6.factories import FormF6RecordFactory +from tests.apps.organization.factories import OrganizationFactory +from tests.apps.user.factories import UserFactory + + +@override_settings(ROOT_URLCONF="core.urls") +class OrganizationAnalyticsApiTest(APITestCase): + def setUp(self): + self.user = UserFactory.create_user() + self.client.force_authenticate(self.user) + self.organization = OrganizationFactory.create( + cluster="radioelectronics", + executors_count=120, + financial_reports_available=True, + tax_reports_available=True, + bankruptcy_messages_found=False, + ) + register = Register.objects.create(name="Реестр госкорпорации Росатом ОПК") + upload = RegisterUpload.objects.create( + registry=register, + actual_date=date(2026, 4, 1), + file_name="analytics.xlsx", + file_hash="analytics-hash", + rows_count=1, + ) + RegistryMembershipPeriod.objects.create( + registry=register, + organization=self.organization, + started_at=date(2026, 4, 1), + started_by_upload=upload, + ) + + FormF1RecordFactory.create( + organization=self.organization, + report_year=2026, + report_quarter=1, + payroll_fund=Decimal("1000000.00"), + military_output_actual=Decimal("11000000.00"), + civilian_output_actual=Decimal("7000000.00"), + hightech_output_actual=Decimal("1500000.00"), + rd_volume_actual=Decimal("900000.00"), + military_domestic_actual=Decimal("9000000.00"), + military_export_actual=Decimal("2000000.00"), + civilian_domestic_actual=Decimal("5000000.00"), + civilian_export_actual=Decimal("2000000.00"), + ) + FormF1RecordFactory.create( + organization=self.organization, + report_year=2025, + report_quarter=1, + payroll_fund=Decimal("900000.00"), + military_output_actual=Decimal("9000000.00"), + civilian_output_actual=Decimal("6000000.00"), + hightech_output_actual=Decimal("1200000.00"), + rd_volume_actual=Decimal("700000.00"), + military_domestic_actual=Decimal("7000000.00"), + military_export_actual=Decimal("2000000.00"), + civilian_domestic_actual=Decimal("4500000.00"), + civilian_export_actual=Decimal("1500000.00"), + ) + FormF2RecordFactory.create( + organization=self.organization, + report_year=2026, + report_quarter=1, + revenue=Decimal("1100000000.00"), + revenue_prev=Decimal("760000000.00"), + net_profit=Decimal("-144600000.00"), + net_profit_prev=Decimal("500000000.00"), + income_tax=Decimal("18900000.00"), + ) + FormF2RecordFactory.create( + organization=self.organization, + report_year=2025, + report_quarter=1, + revenue=Decimal("760000000.00"), + net_profit=Decimal("500000000.00"), + income_tax=Decimal("6450000.00"), + ) + FormF3RecordFactory.create( + organization=self.organization, + report_year=2026, + avg_employees=1050, + production_workers=620, + engineering_workers=210, + administrative_workers=220, + workers_needed=35, + total_equipment=187, + domestic_equipment=91, + imported_equipment=96, + equipment_age_under_5=70, + equipment_age_5_10=41, + equipment_age_10_15=33, + equipment_age_15_20=22, + equipment_age_over_20=21, + physical_wear_percent=Decimal("32.00"), + utilization_rate=Decimal("92.00"), + avg_shift_work=Decimal("1.80"), + equipment_needed=14, + ) + FormF3RecordFactory.create( + organization=self.organization, + report_year=2025, + avg_employees=1020, + ) + FormF4RecordFactory.create( + organization=self.organization, + report_year=2026, + revenue_rsbu=Decimal("1100000000.00"), + net_profit_rsbu=Decimal("320000000.00"), + ebitda_rsbu=Decimal("480000000.00"), + gross_profit_rsbu=Decimal("520000000.00"), + operating_profit_rsbu=Decimal("300000000.00"), + net_debt_rsbu=Decimal("200000000.00"), + loans_rsbu=Decimal("300000000.00"), + total_assets_rsbu=Decimal("900000000.00"), + capex=Decimal("90000000.00"), + rd_expenses=Decimal("40000000.00"), + ros=Decimal("23.80"), + roa=Decimal("12.10"), + roe=Decimal("15.40"), + ) + FormF4RecordFactory.create( + organization=self.organization, + report_year=2025, + revenue_rsbu=Decimal("980000000.00"), + net_profit_rsbu=Decimal("250000000.00"), + ebitda_rsbu=Decimal("410000000.00"), + ros=Decimal("21.00"), + roa=Decimal("10.50"), + roe=Decimal("14.10"), + ) + FormF5RecordFactory.create( + organization=self.organization, + report_year=2026, + equipment_category="Станочное оборудование", + is_domestic=True, + physical_wear_percent=Decimal("28.40"), + ) + FormF6RecordFactory.create( + organization=self.organization, + report_year=2026, + category="Станочное оборудование", + total_equipment=54, + domestic_equipment=31, + imported_equipment=23, + age_under_5=70, + age_5_10=41, + age_10_15=33, + age_15_20=22, + age_over_20=21, + physical_wear_percent=Decimal("28.40"), + utilization_rate=Decimal("92.00"), + avg_shift_work=Decimal("1.80"), + ) + + def test_financial_summary_endpoint(self): + response = self.client.get( + f"/api/v1/organizations/{self.organization.id}/analytics/financial-summary/" + "?report_year=2026&report_quarter=1" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["revenue"]["amount"], 1100000000) + self.assertEqual(response.data["revenue"]["previous_amount"], 760000000) + self.assertEqual(response.data["taxes_paid"]["amount"], 18900000) + self.assertEqual(response.data["insurance_contributions"]["amount"], 302000) + + def test_personnel_and_equipment_endpoints(self): + personnel_response = self.client.get( + f"/api/v1/organizations/{self.organization.id}/analytics/personnel/" + "?report_year=2026&history_years=2" + ) + equipment_response = self.client.get( + f"/api/v1/organizations/{self.organization.id}/analytics/equipment/" + "?report_year=2026" + ) + + self.assertEqual(personnel_response.status_code, status.HTTP_200_OK) + self.assertEqual( + personnel_response.data["headcount"]["average_employees"], + 1050, + ) + self.assertEqual(len(personnel_response.data["history"]), 2) + + self.assertEqual(equipment_response.status_code, status.HTTP_200_OK) + self.assertEqual(equipment_response.data["summary"]["total_equipment"], 187) + self.assertEqual( + equipment_response.data["categories"][0]["category"], + "Станочное оборудование", + ) + + def test_products_and_risk_profile_endpoints(self): + products_response = self.client.get( + f"/api/v1/organizations/{self.organization.id}/analytics/products/" + "?frequency=quarterly&price_mode=actual&report_year=2026" + ) + risk_response = self.client.get( + f"/api/v1/organizations/{self.organization.id}/risk-profile/" + ) + + self.assertEqual(products_response.status_code, status.HTTP_200_OK) + self.assertEqual( + products_response.data["summary"]["military_output_amount"], + 11000000, + ) + self.assertEqual(risk_response.status_code, status.HTTP_200_OK) + self.assertEqual(risk_response.data["risk_level"], "low") + + def test_dashboard_endpoint(self): + response = self.client.get( + "/api/v1/analytics/dashboard/?corporation_scope=rosatom" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["corporation_scope"], "rosatom") + self.assertEqual( + response.data["distribution_by_cluster"][0]["cluster"], "radioelectronics" + ) diff --git a/tests/apps/organization/test_api.py b/tests/apps/organization/test_api.py index 73dfcf3..3e3e9fc 100644 --- a/tests/apps/organization/test_api.py +++ b/tests/apps/organization/test_api.py @@ -4,11 +4,11 @@ from __future__ import annotations from datetime import date -from apps.registers.models import Register, RegisterUpload, RegistryMembershipPeriod from django.test import override_settings from rest_framework import status from rest_framework.test import APITestCase +from apps.registers.models import Register, RegisterUpload, RegistryMembershipPeriod from tests.apps.organization.factories import OrganizationFactory from tests.apps.user.factories import UserFactory @@ -22,7 +22,11 @@ class OrganizationApiTest(APITestCase): self.client.force_authenticate(self.user) def test_list_includes_only_active_registry_names(self): - organization = OrganizationFactory.create(name="АО Альфа") + organization = OrganizationFactory.create( + name="АО Альфа", + short_name="АО «Альфа»", + organization_type="ao", + ) active_register = Register.objects.create(name="Реестр ОПК") closed_register = Register.objects.create(name="Архивный реестр") active_upload = RegisterUpload.objects.create( @@ -58,10 +62,20 @@ class OrganizationApiTest(APITestCase): response = self.client.get("/api/v1/organizations/") self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["data"][0]["active_registry_names"], ["Реестр ОПК"]) + self.assertEqual( + response.data["results"][0]["active_registry_names"], ["Реестр ОПК"] + ) + self.assertEqual(response.data["results"][0]["corporation_scope"], ["opk"]) + self.assertEqual(response.data["results"][0]["short_name"], "АО «Альфа»") def test_detail_includes_active_registries(self): - organization = OrganizationFactory.create() + organization = OrganizationFactory.create( + short_name="АО «Бета»", + general_director_name="Иванов Иван Иванович", + general_director_inn="123456789012", + financial_reports_available=True, + tax_reports_available=True, + ) register = Register.objects.create(name="Реестр Роскосмос") upload = RegisterUpload.objects.create( registry=register, @@ -81,13 +95,18 @@ class OrganizationApiTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual( - response.data["data"]["active_registry_names"], + response.data["summary"]["active_registry_names"], ["Реестр Роскосмос"], ) self.assertEqual( - response.data["data"]["active_registries"], + response.data["active_registries"], [{"id": str(register.id), "name": "Реестр Роскосмос"}], ) + self.assertEqual(response.data["corporation_scope"], ["roscosmos"]) + self.assertEqual( + response.data["general_director"]["full_name"], + "Иванов Иван Иванович", + ) def test_registry_filter_uses_only_active_memberships(self): active_organization = OrganizationFactory.create(name="АО Актив") @@ -119,5 +138,45 @@ class OrganizationApiTest(APITestCase): response = self.client.get(f"/api/v1/organizations/?registry={register.id}") self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data["data"]), 1) - self.assertEqual(response.data["data"][0]["id"], str(active_organization.id)) + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual(response.data["results"][0]["id"], str(active_organization.id)) + + def test_corporation_scope_filter_uses_active_registers(self): + rosatom_org = OrganizationFactory.create(name="АО Росатом") + roscosmos_org = OrganizationFactory.create(name="АО Роскосмос") + rosatom_register = Register.objects.create(name="Реестр госкорпорации Росатом") + roscosmos_register = Register.objects.create( + name="Реестр госкорпорации Роскосмос" + ) + rosatom_upload = RegisterUpload.objects.create( + registry=rosatom_register, + actual_date=date(2026, 4, 1), + file_name="rosatom.xlsx", + file_hash="rosatom-hash", + rows_count=1, + ) + roscosmos_upload = RegisterUpload.objects.create( + registry=roscosmos_register, + actual_date=date(2026, 4, 1), + file_name="roscosmos.xlsx", + file_hash="roscosmos-hash", + rows_count=1, + ) + RegistryMembershipPeriod.objects.create( + registry=rosatom_register, + organization=rosatom_org, + started_at=date(2026, 4, 1), + started_by_upload=rosatom_upload, + ) + RegistryMembershipPeriod.objects.create( + registry=roscosmos_register, + organization=roscosmos_org, + started_at=date(2026, 4, 1), + started_by_upload=roscosmos_upload, + ) + + response = self.client.get("/api/v1/organizations/?corporation_scope=rosatom") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual(response.data["results"][0]["id"], str(rosatom_org.id)) diff --git a/tests/apps/organization/test_models.py b/tests/apps/organization/test_models.py index f00eef5..3d7f0cf 100644 --- a/tests/apps/organization/test_models.py +++ b/tests/apps/organization/test_models.py @@ -2,9 +2,10 @@ from datetime import date -from apps.registers.models import Register, RegisterUpload, RegistryMembershipPeriod from django.test import TestCase +from apps.registers.models import Register, RegisterUpload, RegistryMembershipPeriod + from .factories import OrganizationFactory @@ -90,3 +91,40 @@ class OrganizationModelTest(TestCase): self.assertEqual(self.org.get_active_registry_names(), ["Активный реестр"]) self.assertEqual(self.org.active_registry_names_display(), "Активный реестр") + + def test_corporation_scopes_are_derived_from_active_registers(self): + rosatom_register = Register.objects.create(name="Реестр госкорпорации Росатом") + opk_register = Register.objects.create(name="Реестр предприятий ОПК") + upload = RegisterUpload.objects.create( + registry=rosatom_register, + actual_date=date(2026, 4, 1), + file_name="scopes.xlsx", + file_hash="scopes-hash", + rows_count=2, + ) + opk_upload = RegisterUpload.objects.create( + registry=opk_register, + actual_date=date(2026, 4, 1), + file_name="scopes-opk.xlsx", + file_hash="scopes-opk-hash", + rows_count=2, + ) + + RegistryMembershipPeriod.objects.create( + registry=rosatom_register, + organization=self.org, + started_at=date(2026, 4, 1), + started_by_upload=upload, + ) + RegistryMembershipPeriod.objects.create( + registry=opk_register, + organization=self.org, + started_at=date(2026, 4, 1), + started_by_upload=opk_upload, + ) + + self.assertEqual(self.org.get_corporation_scopes(), ["rosatom", "opk"]) + self.assertEqual( + self.org.get_corporation_scope_labels(), + ["Госкорпорация «Росатом»", "Организации ОПК"], + ) diff --git a/tests/apps/organization/test_services.py b/tests/apps/organization/test_services.py index 5697752..0ba100e 100644 --- a/tests/apps/organization/test_services.py +++ b/tests/apps/organization/test_services.py @@ -1,8 +1,9 @@ """Tests for Organization services.""" -from apps.organization.services import OrganizationService from django.test import TestCase +from apps.organization.services import OrganizationService + from .factories import OrganizationFactory diff --git a/tests/apps/registers/test_backup_import.py b/tests/apps/registers/test_backup_import.py index 851e512..e37fb72 100644 --- a/tests/apps/registers/test_backup_import.py +++ b/tests/apps/registers/test_backup_import.py @@ -11,15 +11,16 @@ from datetime import date from io import BytesIO from zipfile import ZIP_DEFLATED, ZipFile -from apps.organization.models import Organization -from apps.registers.models import Register, RegisterUpload, RegistryMembershipPeriod -from apps.registers.services import RegisterBackupImportService -from apps.user.models import User from cryptography.hazmat.primitives.ciphers.aead import AESGCM from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase, override_settings from django.utils.crypto import get_random_string +from apps.organization.models import Organization +from apps.registers.models import Register, RegisterUpload, RegistryMembershipPeriod +from apps.registers.services import RegisterBackupImportService +from apps.user.models import User + TEST_BACKUP_KEY = base64.urlsafe_b64encode(b"k" * 32).decode("ascii").rstrip("=") TEST_AAD = b"state-corp-backup-v1" diff --git a/tests/apps/registers/test_services.py b/tests/apps/registers/test_services.py index f436b03..e61815f 100644 --- a/tests/apps/registers/test_services.py +++ b/tests/apps/registers/test_services.py @@ -5,13 +5,14 @@ from __future__ import annotations import io from datetime import date -from apps.organization.models import Organization -from apps.registers.models import Register, RegisterUpload, RegistryMembershipPeriod -from apps.registers.services import RegisterImportService from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase from openpyxl import Workbook +from apps.organization.models import Organization +from apps.registers.models import Register, RegisterUpload, RegistryMembershipPeriod +from apps.registers.services import RegisterImportService + def build_registry_upload(name: str, rows: list[dict[str, str]]) -> SimpleUploadedFile: """Build an in-memory xlsx with register headers for snapshot import.""" diff --git a/tests/apps/user/factories.py b/tests/apps/user/factories.py index cd768d7..00dcfd8 100644 --- a/tests/apps/user/factories.py +++ b/tests/apps/user/factories.py @@ -1,9 +1,10 @@ """Фабрики для создания тестовых объектов с использованием factory_boy и faker""" import factory -from apps.user.models import Profile, User from faker import Faker +from apps.user.models import Profile, User + fake = Faker("ru_RU") diff --git a/tests/apps/user/test_serializers.py b/tests/apps/user/test_serializers.py index a1cc11b..e2e509e 100644 --- a/tests/apps/user/test_serializers.py +++ b/tests/apps/user/test_serializers.py @@ -1,5 +1,9 @@ """Tests for user serializers""" +from django.contrib.auth import get_user_model +from django.test import TestCase +from faker import Faker + from apps.user.serializers import ( LoginSerializer, PasswordChangeSerializer, @@ -9,9 +13,6 @@ from apps.user.serializers import ( UserSerializer, UserUpdateSerializer, ) -from django.contrib.auth import get_user_model -from django.test import TestCase -from faker import Faker from .factories import ProfileFactory, UserFactory @@ -183,7 +184,14 @@ class ProfileUpdateSerializerTest(TestCase): def test_fields_allowed(self): """Test only allowed fields can be updated""" serializer = ProfileUpdateSerializer() - allowed_fields = ["first_name", "mid_name", "last_name", "bio", "avatar", "date_of_birth"] + allowed_fields = [ + "first_name", + "mid_name", + "last_name", + "bio", + "avatar", + "date_of_birth", + ] self.assertEqual(set(serializer.Meta.fields), set(allowed_fields)) diff --git a/tests/apps/user/test_services.py b/tests/apps/user/test_services.py index a836541..08dfb27 100644 --- a/tests/apps/user/test_services.py +++ b/tests/apps/user/test_services.py @@ -1,12 +1,13 @@ """Tests for user services""" -from apps.core.exceptions import NotFoundError -from apps.user.services import ProfileService, UserService from django.contrib.auth import get_user_model from django.test import TestCase from faker import Faker from rest_framework_simplejwt.tokens import RefreshToken +from apps.core.exceptions import NotFoundError +from apps.user.services import ProfileService, UserService + from .factories import ProfileFactory, UserFactory User = get_user_model() diff --git a/tests/apps/user/test_views.py b/tests/apps/user/test_views.py index b9763ae..52ea323 100644 --- a/tests/apps/user/test_views.py +++ b/tests/apps/user/test_views.py @@ -1,13 +1,14 @@ """Tests for user DRF views""" -from apps.user.models import Profile -from apps.user.services import UserService from django.contrib.auth import get_user_model from django.urls import reverse from faker import Faker from rest_framework import status from rest_framework.test import APITestCase +from apps.user.models import Profile +from apps.user.services import UserService + from .factories import ProfileFactory, UserFactory User = get_user_model() @@ -136,6 +137,18 @@ class CurrentUserViewTest(APITestCase): self.assertEqual(response.data["id"], self.user.id) self.assertEqual(response.data["email"], self.user.email) self.assertIn("profile", response.data) + self.assertEqual(response.data["role"], "user") + self.assertEqual(response.data["capabilities"]["can_access_admin_page"], False) + + def test_get_current_user_returns_admin_role_for_staff_user(self): + self.user.is_staff = True + self.user.save(update_fields=["is_staff"]) + + response = self.client.get(self.current_user_url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["role"], "staff") + self.assertEqual(response.data["capabilities"]["can_access_admin_page"], True) def test_get_current_user_unauthenticated(self): """Test getting current user when unauthenticated"""