diff --git a/src/apps/organization/analytics_services.py b/src/apps/organization/analytics_services.py index cee0a37..4e453c0 100644 --- a/src/apps/organization/analytics_services.py +++ b/src/apps/organization/analytics_services.py @@ -14,6 +14,11 @@ 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.availability import ( + has_financial_reports, + has_tax_reports, + risk_level_for_availability, +) from apps.organization.models import IndustryCluster, Organization from apps.organization.scope_utils import filter_queryset_by_scopes from django.db.models import Avg, Case, Count, IntegerField, Q, Sum, When @@ -103,6 +108,26 @@ def _best_records_by_year( return resolved +def _latest_report_year(records: Iterable) -> int | None: + years = [ + record.report_year + for record in records + if getattr(record, "report_year", None) is not None + ] + return max(years, default=None) + + +def _resolve_report_year(records: Iterable, requested_year: int) -> int: + years = { + record.report_year + for record in records + if getattr(record, "report_year", None) is not None + } + if requested_year in years: + return requested_year + return max(years, default=requested_year) + + def _weighted_average_age(age_distribution: list[dict[str, int]]) -> float: bucket_midpoints = { "under_30": 25, @@ -331,7 +356,26 @@ class OrganizationAnalyticsService: periods = sorted(set(f2_by_year) | set(f4_by_year)) if not periods: - raise NotFoundError(message="Economics data is not available") + latest_year = max( + filter( + None, + ( + _latest_report_year(cls._f2_records(organization)), + _latest_report_year(cls._f4_records(organization)), + ), + ), + default=None, + ) + if latest_year is None: + raise NotFoundError(message="Economics data is not available") + + f2_by_year = _best_records_by_year( + cls._f2_records(organization), latest_year, latest_year + ) + f4_by_year = _best_records_by_year( + cls._f4_records(organization), latest_year, latest_year + ) + periods = sorted(set(f2_by_year) | set(f4_by_year)) metric_units = cls._economics_metric_units() selected_metrics = cls._economics_metric_groups()[group] @@ -407,6 +451,7 @@ class OrganizationAnalyticsService: history_years: int, ) -> dict[str, object]: f3_records = cls._f3_records(organization) + report_year = _resolve_report_year(f3_records, report_year) current_f3 = cls._require_record( _pick_record(f3_records, report_year), entity="Personnel", @@ -460,6 +505,7 @@ class OrganizationAnalyticsService: report_year: int, ) -> dict[str, object]: f3_records = cls._f3_records(organization) + report_year = _resolve_report_year(f3_records, report_year) current_f3 = cls._require_record( _pick_record(f3_records, report_year), entity="Equipment", @@ -813,11 +859,9 @@ class OrganizationAnalyticsService: frequency: str, price_mode: str, ) -> dict[str, object]: - records = [ - record - for record in cls._f1_records(organization) - if record.report_year == report_year - ] + f1_records = cls._f1_records(organization) + report_year = _resolve_report_year(f1_records, report_year) + records = [record for record in f1_records if record.report_year == report_year] if not records: raise NotFoundError(message="Products data is not available") @@ -917,14 +961,21 @@ class OrganizationAnalyticsService: @classmethod def get_risk_profile(cls, *, organization: Organization) -> dict[str, object]: + financial_reports_available = has_financial_reports(organization) + tax_reports_available = has_tax_reports(organization) + return { "organization_id": str(organization.id), - "financial_reports_available": organization.financial_reports_available, - "tax_reports_available": organization.tax_reports_available, + "financial_reports_available": financial_reports_available, + "tax_reports_available": 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, + "risk_level": risk_level_for_availability( + organization, + financial_reports_available=financial_reports_available, + tax_reports_available=tax_reports_available, + ), "updated_at": organization.updated_at.isoformat(), } diff --git a/src/apps/organization/availability.py b/src/apps/organization/availability.py new file mode 100644 index 0000000..5b29c66 --- /dev/null +++ b/src/apps/organization/availability.py @@ -0,0 +1,48 @@ +"""Availability helpers for organization-facing contracts.""" + +from __future__ import annotations + +from apps.organization.models import Organization + + +def has_financial_reports(organization: Organization) -> bool: + """Return whether financial data is effectively available for an organization.""" + if organization.financial_reports_available: + return True + + return ( + organization.financial_reports.filter(status="success").exists() + or organization.form_f2_records.filter(is_active_version=True).exists() + ) + + +def has_tax_reports(organization: Organization) -> bool: + """Return whether tax-related report data is effectively available.""" + if organization.tax_reports_available: + return True + + return organization.form_f2_records.filter( + is_active_version=True, + income_tax__isnull=False, + ).exists() + + +def risk_level_for_availability( + organization: Organization, + *, + financial_reports_available: bool, + tax_reports_available: bool, +) -> str: + """Calculate risk level with effective availability flags.""" + risk_score = 0 + risk_score += 3 if organization.in_defense_unreliable_suppliers_registry else 0 + risk_score += 2 if organization.in_275_fz_registry else 0 + risk_score += 2 if organization.bankruptcy_messages_found else 0 + risk_score += 1 if not financial_reports_available else 0 + risk_score += 1 if not 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/serializers.py b/src/apps/organization/serializers.py index b37114b..5bb480e 100644 --- a/src/apps/organization/serializers.py +++ b/src/apps/organization/serializers.py @@ -6,6 +6,7 @@ - frontend-facing serializers for organization catalog endpoints. """ +from apps.organization.availability import has_financial_reports, has_tax_reports from apps.organization.models import Organization from apps.organization.scope_utils import ( SCOPE_LABELS, @@ -192,8 +193,8 @@ class OrganizationCatalogDetailSerializer(OrganizationCatalogBaseSerializer): @staticmethod def get_summary(obj: Organization) -> dict[str, object]: return { - "financial_reports_available": obj.financial_reports_available, - "tax_reports_available": obj.tax_reports_available, + "financial_reports_available": has_financial_reports(obj), + "tax_reports_available": has_tax_reports(obj), "active_registry_names": obj.get_active_registry_names(), } diff --git a/tests/apps/organization/test_analytics_api.py b/tests/apps/organization/test_analytics_api.py index c0a7e46..1a99541 100644 --- a/tests/apps/organization/test_analytics_api.py +++ b/tests/apps/organization/test_analytics_api.py @@ -10,6 +10,7 @@ from django.test import override_settings from rest_framework import status from rest_framework.test import APITestCase +from tests.apps.external_data.factories import FinancialReportFactory from tests.apps.form_1.factories import FormF1RecordFactory from tests.apps.form_2.factories import FormF2RecordFactory from tests.apps.form_3.factories import FormF3RecordFactory @@ -244,6 +245,16 @@ class OrganizationAnalyticsApiTest(APITestCase): ) ) + def test_economics_uses_latest_available_year_when_requested_range_is_empty(self): + response = self.client.get( + f"/api/v1/organizations/{self.organization.id}/analytics/economics/" + "?group=efficiency&from_year=2022&to_year=2024" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["periods"], [2026]) + self.assertEqual(response.data["series"][0]["points"][0]["period"], 2026) + def test_personnel_contract(self): personnel_response = self.client.get( f"/api/v1/organizations/{self.organization.id}/analytics/personnel/" @@ -271,6 +282,29 @@ class OrganizationAnalyticsApiTest(APITestCase): ) self.assertIn("employees_count", personnel_response.data["age_distribution"][0]) + def test_yearly_analytics_use_latest_available_year_when_requested_year_is_empty( + self, + ): + personnel_response = self.client.get( + f"/api/v1/organizations/{self.organization.id}/analytics/personnel/" + "?report_year=2024&history_years=2" + ) + equipment_response = self.client.get( + f"/api/v1/organizations/{self.organization.id}/analytics/equipment/" + "?report_year=2024" + ) + products_response = self.client.get( + f"/api/v1/organizations/{self.organization.id}/analytics/products/" + "?frequency=quarterly&price_mode=actual&report_year=2024" + ) + + self.assertEqual(personnel_response.status_code, status.HTTP_200_OK) + self.assertEqual(equipment_response.status_code, status.HTTP_200_OK) + self.assertEqual(products_response.status_code, status.HTTP_200_OK) + self.assertEqual(personnel_response.data["report_year"], 2026) + self.assertEqual(equipment_response.data["report_year"], 2026) + self.assertEqual(products_response.data["report_year"], 2026) + def test_equipment_contract(self): response = self.client.get( f"/api/v1/organizations/{self.organization.id}/analytics/equipment/" @@ -457,6 +491,23 @@ class OrganizationAnalyticsApiTest(APITestCase): self.assertIn("risk_level", response.data) self.assertIn("updated_at", response.data) + def test_risk_profile_uses_available_related_report_data(self): + organization = OrganizationFactory.create( + financial_reports_available=False, + tax_reports_available=False, + ) + FinancialReportFactory.create(organization=organization, status="success") + FormF2RecordFactory.create(organization=organization, income_tax="1000.00") + + response = self.client.get( + f"/api/v1/organizations/{organization.id}/risk-profile/" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data["financial_reports_available"]) + self.assertTrue(response.data["tax_reports_available"]) + self.assertEqual(response.data["risk_level"], "low") + def test_dashboard_endpoint(self): response = self.client.get( "/api/v1/analytics/dashboard/?corporation_scope=rosatom" diff --git a/tests/apps/organization/test_api.py b/tests/apps/organization/test_api.py index 9a7765a..064005c 100644 --- a/tests/apps/organization/test_api.py +++ b/tests/apps/organization/test_api.py @@ -9,6 +9,8 @@ from django.test import override_settings from rest_framework import status from rest_framework.test import APITestCase +from tests.apps.external_data.factories import FinancialReportFactory +from tests.apps.form_2.factories import FormF2RecordFactory from tests.apps.organization.factories import OrganizationFactory from tests.apps.user.factories import UserFactory @@ -120,6 +122,20 @@ class OrganizationApiTest(APITestCase): "Иванов Иван Иванович", ) + def test_detail_summary_uses_available_related_report_data(self): + organization = OrganizationFactory.create( + financial_reports_available=False, + tax_reports_available=False, + ) + FinancialReportFactory.create(organization=organization, status="success") + FormF2RecordFactory.create(organization=organization, income_tax="1200.00") + + response = self.client.get(f"/api/v1/organizations/{organization.id}/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data["summary"]["financial_reports_available"]) + self.assertTrue(response.data["summary"]["tax_reports_available"]) + def test_registry_filter_uses_only_active_memberships(self): active_organization = OrganizationFactory.create(name="АО Актив") closed_organization = OrganizationFactory.create(name="АО Закрыт")