From 94aa22ce35afaf10d1661a2f7e482cf90f627d2f Mon Sep 17 00:00:00 2001 From: Aleksandr Meshchriakov Date: Tue, 16 Jun 2026 00:00:14 +0200 Subject: [PATCH] fix: revive main dashboard analytics --- src/apps/organization/analytics_services.py | 71 ++++++++++++---- tests/apps/organization/test_analytics_api.py | 83 +++++++++++++++++++ 2 files changed, 136 insertions(+), 18 deletions(-) diff --git a/src/apps/organization/analytics_services.py b/src/apps/organization/analytics_services.py index 05f3b3b..e390696 100644 --- a/src/apps/organization/analytics_services.py +++ b/src/apps/organization/analytics_services.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections import defaultdict from collections.abc import Iterable -from datetime import date from decimal import Decimal from apps.core.exceptions import NotFoundError @@ -20,7 +19,11 @@ from apps.organization.availability import ( risk_level_for_availability, ) from apps.organization.models import IndustryCluster, Organization -from apps.organization.scope_utils import filter_queryset_by_scopes +from apps.organization.scope_utils import ( + SCOPE_LABELS, + filter_queryset_by_scopes, + scopes_from_organization_fields, +) from django.db.models import Avg, Case, Count, IntegerField, Q, Sum, When ZERO = Decimal("0") @@ -1145,6 +1148,37 @@ class OrganizationAnalyticsService: class DashboardAnalyticsService: """Cross-organization dashboard aggregations.""" + @staticmethod + def _resolve_dashboard_cluster(organization: Organization) -> str: + if organization.cluster: + return organization.cluster + + scope_codes = scopes_from_organization_fields( + gk_code=organization.gk_code, + gk_name=organization.gk_name, + opk_registry_membership=organization.opk_registry_membership, + ) + for scope_code in scope_codes: + if scope_code in {"rosatom", "roscosmos"}: + return scope_code + return scope_codes[0] if scope_codes else IndustryCluster.OTHER + + @staticmethod + def _dashboard_cluster_label(cluster_code: str) -> str: + industry_cluster_label = dict(IndustryCluster.choices).get(cluster_code) + if industry_cluster_label: + return industry_cluster_label + return SCOPE_LABELS.get(cluster_code, "Иная") + + @staticmethod + def _resolve_executors_total(organizations: list[Organization]) -> int: + executors_total = sum( + organization.executors_count for organization in organizations + ) + if executors_total: + return executors_total + return sum(1 for organization in organizations if organization.goz_participation) + @classmethod def get_dashboard( cls, *, corporation_scope: str | None = None @@ -1165,32 +1199,27 @@ class DashboardAnalyticsService: totals_by_cluster: dict[str, list[Organization]] = defaultdict(list) for organization in organizations: - cluster = organization.cluster or IndustryCluster.OTHER + cluster = cls._resolve_dashboard_cluster(organization) 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] = {} + f3_best_by_organization: dict[str, dict[int, FormF3Record]] = defaultdict(dict) 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, "Иная") + organization_key = str(record.organization_id) + f3_best_by_organization[organization_key].setdefault( + record.report_year, record + ) + total_organizations = len(organizations) distribution_by_cluster = [] executors_by_cluster = [] headcount_growth_by_cluster = [] @@ -1198,15 +1227,21 @@ class DashboardAnalyticsService: 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) + executors_total = cls._resolve_executors_total(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)) + organization_f3_by_year = f3_best_by_organization.get( + str(organization.id), {} + ) + report_years = sorted(organization_f3_by_year) + if len(report_years) < 2: + continue + previous_record = organization_f3_by_year[report_years[-2]] + current_record = organization_f3_by_year[report_years[-1]] if current_record is None or previous_record is None: continue growth_values.append( @@ -1218,7 +1253,7 @@ class DashboardAnalyticsService: distribution_by_cluster.append( { "cluster": cluster_code, - "cluster_label": cluster_label(cluster_code), + "cluster_label": cls._dashboard_cluster_label(cluster_code), "organizations_share_percent": round( (cluster_total / total_organizations) * 100, 1 ), diff --git a/tests/apps/organization/test_analytics_api.py b/tests/apps/organization/test_analytics_api.py index 7108c36..1a87c1b 100644 --- a/tests/apps/organization/test_analytics_api.py +++ b/tests/apps/organization/test_analytics_api.py @@ -528,6 +528,89 @@ class OrganizationAnalyticsApiTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["corporation_scope"], "roscosmos") + def test_dashboard_endpoint_groups_blank_clusters_by_corporation_scope(self): + OrganizationFactory.create( + cluster="", + executors_count=0, + gk_code="1", + gk_name='Госкорпорация "Роскосмос"', + ) + + response = self.client.get( + "/api/v1/analytics/dashboard/?corporation_scope=roskosmos" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data["distribution_by_cluster"][0]["cluster"], "roscosmos" + ) + self.assertEqual( + response.data["distribution_by_cluster"][0]["cluster_label"], + "Госкорпорация «Роскосмос»", + ) + + def test_dashboard_endpoint_counts_goz_organizations_when_executors_are_missing( + self, + ): + OrganizationFactory.create( + cluster="", + executors_count=0, + gk_code="1", + gk_name='Госкорпорация "Роскосмос"', + goz_participation=True, + ) + OrganizationFactory.create( + cluster="", + executors_count=0, + gk_code="1", + gk_name='Госкорпорация "Роскосмос"', + goz_participation=False, + ) + + response = self.client.get( + "/api/v1/analytics/dashboard/?corporation_scope=roskosmos" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data["executors_by_cluster"][0]["cluster"], "roscosmos" + ) + self.assertEqual( + response.data["executors_by_cluster"][0]["executors_count"], 1 + ) + + def test_dashboard_endpoint_uses_latest_available_f3_years_for_growth(self): + organization = OrganizationFactory.create( + cluster="space", + executors_count=0, + gk_code="1", + gk_name='Госкорпорация "Роскосмос"', + ) + FormF3RecordFactory.create( + organization=organization, + report_year=2023, + report_quarter=4, + avg_employees=100, + ) + FormF3RecordFactory.create( + organization=organization, + report_year=2024, + report_quarter=4, + avg_employees=150, + ) + + response = self.client.get( + "/api/v1/analytics/dashboard/?corporation_scope=roskosmos" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data["headcount_growth_by_cluster"][0]["cluster"], "space" + ) + self.assertEqual( + response.data["headcount_growth_by_cluster"][0]["growth_percent"], 50.0 + ) + def test_analytics_query_validation(self): response = self.client.get( f"/api/v1/organizations/{self.organization.id}/analytics/economics/"