From d6ca9f5399f99404aa92baf9e6a9dc96769e578f Mon Sep 17 00:00:00 2001 From: Aleksandr Meshchriakov Date: Tue, 28 Jul 2026 15:27:59 +0200 Subject: [PATCH] fix: correct organization analytics aggregation --- src/apps/organization/analytics_services.py | 234 +++++++++++++----- src/apps/organization/contract_serializers.py | 13 +- tests/apps/organization/test_analytics_api.py | 166 +++++++++++++ 3 files changed, 346 insertions(+), 67 deletions(-) diff --git a/src/apps/organization/analytics_services.py b/src/apps/organization/analytics_services.py index 233b332..af4293c 100644 --- a/src/apps/organization/analytics_services.py +++ b/src/apps/organization/analytics_services.py @@ -60,6 +60,12 @@ def _share_percent(numerator, denominator) -> float: return round(float((_dec(numerator) / denominator_value) * Decimal("100")), 1) +def _nullable_share_percent(numerator, denominator) -> float | None: + if numerator is None or denominator is None or _dec(denominator) == ZERO: + return None + return _share_percent(numerator, denominator) + + def _delta_percent(current, previous) -> float: current_value = _dec(current) previous_value = _dec(previous) @@ -425,6 +431,7 @@ class OrganizationAnalyticsService: "series": [], "ratios": [], "ratio_normatives": ECONOMICS_RATIO_NORMATIVES, + "ratios_report_period": None, } metric_units = cls._economics_metric_units() @@ -442,6 +449,24 @@ class OrganizationAnalyticsService: f1_by_year.get(period), ) + def profitability_ratio(report_year: int, denominator_field: str): + f4_record = f4_by_year.get(report_year) + f2_record = f2_by_year.get(report_year) + return _nullable_share_percent( + ( + f4_record.net_profit_rsbu + if f4_record is not None and f4_record.net_profit_rsbu is not None + else None + ), + ( + getattr(f2_record, denominator_field, None) + if f2_record is not None + else None + ), + ) + + latest_f2 = f2_by_year.get(last_period) + return { "organization_id": str(organization.id), "group": group, @@ -455,6 +480,15 @@ class OrganizationAnalyticsService: } for year in periods ], + "ratios_report_period": ( + { + "financial_form": "F-2", + "report_year": latest_f2.report_year, + "report_quarter": latest_f2.report_quarter, + } + if latest_f2 is not None + else None + ), "data_available": True, "message": None, "kpis": { @@ -504,8 +538,8 @@ class OrganizationAnalyticsService: f4_by_year.get(report_year), ), ), - "roa": _ratio(getattr(f4_by_year.get(report_year), "roa", ZERO)), - "roe": _ratio(getattr(f4_by_year.get(report_year), "roe", ZERO)), + "roa": profitability_ratio(report_year, "total_assets"), + "roe": profitability_ratio(report_year, "total_equity"), "ebitda_margin": _share_percent( cls._economics_metric_value( "ebitda", @@ -778,6 +812,11 @@ class OrganizationAnalyticsService: f"{bucket_field}_lease_share_itn_percent", bucket_field, ) + weighted_load_factor = weighted_metric( + f6_records, + f"{bucket_field}_weighted_load_factor", + bucket_field, + ) else: imported_equipment = None utilization_rate = ( @@ -786,6 +825,7 @@ class OrganizationAnalyticsService: else None ) lease_share = None + weighted_load_factor = None item.update( { "imported_equipment": imported_equipment, @@ -798,6 +838,11 @@ class OrganizationAnalyticsService: _ratio(lease_share) if lease_share is not None else None ), "weighted_wear_percent": None, + "weighted_load_factor": ( + round(float(weighted_load_factor), 2) + if weighted_load_factor is not None + else None + ), } ) @@ -1066,72 +1111,132 @@ class OrganizationAnalyticsService: metrics[key] += _dec(row_metrics[key]) return {"period": period, "metrics": metrics} + @classmethod + def _build_product_base_rows( + cls, records: list[FormF1Record], *, suffix: str + ) -> list[dict[str, object]]: + base_rows = [] + for record in records: + month = record.report_month + explicit_quarter = record.report_quarter + quarter = explicit_quarter + if quarter is None and month is not None: + quarter = ((month - 1) // 3) + 1 + base_rows.append( + { + "month": month, + "quarter": quarter, + "explicit_quarter": explicit_quarter, + "period": ( + f"{record.report_year}-{month:02d}" + if month is not None + else ( + f"{record.report_year}-Q{quarter}" + if quarter is not None + else str(record.report_year) + ) + ), + "metrics": cls._f1_metric_bundle(record, suffix=suffix), + } + ) + return base_rows + + @classmethod + def _build_quarterly_product_rows( + cls, base_rows: list[dict[str, object]], report_year: int + ) -> list[dict[str, object]]: + grouped_rows = [] + for quarter in range(1, 5): + quarter_rows = [row for row in base_rows if row["quarter"] == quarter] + if quarter_rows: + grouped_rows.append( + cls._aggregate_product_metrics( + quarter_rows, + f"{report_year}-Q{quarter}", + ) + ) + return grouped_rows or [ + cls._aggregate_product_metrics(base_rows, str(report_year)) + ] + + @classmethod + def _build_semiannual_product_rows( + cls, base_rows: list[dict[str, object]], report_year: int + ) -> list[dict[str, object]]: + grouped_rows = [] + periods = ( + (1, 2, f"{report_year}-H1"), + (3, 4, f"{report_year}-H2"), + ) + for quarter_start, quarter_end, period in periods: + half_rows = [ + row + for row in base_rows + if row["quarter"] is not None + and quarter_start <= row["quarter"] <= quarter_end + ] + if half_rows: + grouped_rows.append(cls._aggregate_product_metrics(half_rows, period)) + return grouped_rows or [ + cls._aggregate_product_metrics(base_rows, str(report_year)) + ] + + @classmethod + def _build_monthly_product_rows( + cls, base_rows: list[dict[str, object]], report_year: int + ) -> list[dict[str, object]]: + real_monthly_rows = [ + row + for row in base_rows + if row["month"] is not None and row["explicit_quarter"] is None + ] + if real_monthly_rows: + grouped_rows = [] + for month in range(1, 13): + month_rows = [row for row in real_monthly_rows if row["month"] == month] + if month_rows: + grouped_rows.append( + cls._aggregate_product_metrics( + month_rows, + f"{report_year}-{month:02d}", + ) + ) + return grouped_rows + + monthly_rows = [] + month_map = {1: (1, 2, 3), 2: (4, 5, 6), 3: (7, 8, 9), 4: (10, 11, 12)} + for row in base_rows: + quarter = row["quarter"] + if quarter is None: + monthly_rows.append(row) + continue + for month in month_map.get(quarter, ()): + month_metrics = { + key: value / Decimal("3") for key, value in row["metrics"].items() + } + monthly_rows.append( + { + "period": f"{report_year}-{month:02d}", + "metrics": month_metrics, + } + ) + return sorted(monthly_rows, key=lambda row: row["period"]) + @classmethod def _build_product_frequency_rows( cls, records: list[FormF1Record], *, suffix: str, frequency: str ) -> list[dict[str, object]]: - base_rows = [ - { - "quarter": record.report_quarter, - "period": ( - str(record.report_year) - if record.report_quarter is None - else f"{record.report_year}-Q{record.report_quarter}" - ), - "metrics": cls._f1_metric_bundle(record, suffix=suffix), - } - for record in records - ] - - if frequency == "quarterly": - return base_rows - - if frequency == "annual": - return [ - cls._aggregate_product_metrics(base_rows, str(records[0].report_year)) - ] - - if frequency == "semiannual": - grouped_rows: list[dict[str, object]] = [] - periods = ( - (1, 2, f"{records[0].report_year}-H1"), - (3, 4, f"{records[0].report_year}-H2"), - ) - for quarter_start, quarter_end, period in periods: - half_rows = [ - row - for row in base_rows - if row["quarter"] is not None - and quarter_start <= row["quarter"] <= quarter_end - ] - if half_rows: - grouped_rows.append( - cls._aggregate_product_metrics(half_rows, period) - ) - return grouped_rows or [ - cls._aggregate_product_metrics(base_rows, str(records[0].report_year)) - ] + report_year = records[0].report_year + base_rows = cls._build_product_base_rows(records, suffix=suffix) if frequency == "monthly": - monthly_rows: list[dict[str, object]] = [] - month_map = {1: (1, 2, 3), 2: (4, 5, 6), 3: (7, 8, 9), 4: (10, 11, 12)} - for row in base_rows: - quarter = row["quarter"] - if quarter is None: - monthly_rows.append(row) - continue - for month in month_map.get(quarter, ()): - month_metrics = { - key: value / Decimal("3") - for key, value in row["metrics"].items() - } - monthly_rows.append( - { - "period": f"{records[0].report_year}-{month:02d}", - "metrics": month_metrics, - } - ) - return monthly_rows + return cls._build_monthly_product_rows(base_rows, report_year) + if frequency == "quarterly": + return cls._build_quarterly_product_rows(base_rows, report_year) + if frequency == "semiannual": + return cls._build_semiannual_product_rows(base_rows, report_year) + if frequency == "annual": + return [cls._aggregate_product_metrics(base_rows, str(report_year))] return base_rows @@ -1160,9 +1265,7 @@ class OrganizationAnalyticsService: if not records: raise NotFoundError(message="Products data is not available") - records.sort( - key=lambda record: (_period_rank(record.report_quarter), record.created_at) - ) + records.sort(key=lambda record: (_period_rank(record), record.created_at)) suffix = "actual" if price_mode == "actual" else "fixed" frequency_rows = cls._build_product_frequency_rows( records, suffix=suffix, frequency=frequency @@ -1175,6 +1278,7 @@ class OrganizationAnalyticsService: "report_year": report_year, "frequency": frequency, "price_mode": price_mode, + "summary_period": current["period"], "summary": { "military_output_amount": _amount( current_metrics["military_output_amount"] diff --git a/src/apps/organization/contract_serializers.py b/src/apps/organization/contract_serializers.py index affa8de..1e9f0aa 100644 --- a/src/apps/organization/contract_serializers.py +++ b/src/apps/organization/contract_serializers.py @@ -78,8 +78,8 @@ class EconomicsMetricSeriesSerializer(serializers.Serializer): class EconomicsRatioSerializer(serializers.Serializer): period = serializers.IntegerField() ros = serializers.FloatField() - roa = serializers.FloatField() - roe = serializers.FloatField() + roa = serializers.FloatField(allow_null=True) + roe = serializers.FloatField(allow_null=True) ebitda_margin = serializers.FloatField() @@ -95,11 +95,18 @@ class EconomicsReportPeriodSerializer(serializers.Serializer): report_half_year = serializers.IntegerField(allow_null=True) +class EconomicsRatiosReportPeriodSerializer(serializers.Serializer): + financial_form = serializers.CharField() + report_year = serializers.IntegerField() + report_quarter = serializers.IntegerField(allow_null=True) + + class EconomicsResponseSerializer(serializers.Serializer): organization_id = serializers.UUIDField() group = serializers.CharField() periods = serializers.ListField(child=serializers.IntegerField()) report_periods = EconomicsReportPeriodSerializer(many=True) + ratios_report_period = EconomicsRatiosReportPeriodSerializer(allow_null=True) data_available = serializers.BooleanField() message = serializers.CharField(allow_null=True) kpis = EconomicsKpisSerializer() @@ -164,6 +171,7 @@ class EquipmentAgeDistributionSerializer(serializers.Serializer): utilization_rate = serializers.FloatField(allow_null=True) lease_share_itn_percent = serializers.FloatField(allow_null=True) weighted_wear_percent = serializers.FloatField(allow_null=True) + weighted_load_factor = serializers.FloatField(allow_null=True) class EquipmentCategorySerializer(serializers.Serializer): @@ -244,6 +252,7 @@ class ProductsResponseSerializer(serializers.Serializer): report_year = serializers.IntegerField() frequency = serializers.CharField() price_mode = serializers.CharField() + summary_period = serializers.CharField() summary = ProductsSummarySerializer() production_series = ProductsProductionSeriesSerializer(many=True) sales_series = ProductsSalesSeriesSerializer(many=True) diff --git a/tests/apps/organization/test_analytics_api.py b/tests/apps/organization/test_analytics_api.py index 9a042ab..07ef618 100644 --- a/tests/apps/organization/test_analytics_api.py +++ b/tests/apps/organization/test_analytics_api.py @@ -176,22 +176,27 @@ class OrganizationAnalyticsApiTest(APITestCase): age_under_5_imported=35, age_under_5_utilization_rate=Decimal("92.00"), age_under_5_lease_share_itn_percent=Decimal("20.00"), + age_under_5_weighted_load_factor=Decimal("0.78"), age_5_10=41, age_5_10_imported=21, age_5_10_utilization_rate=Decimal("92.00"), age_5_10_lease_share_itn_percent=Decimal("20.00"), + age_5_10_weighted_load_factor=Decimal("0.74"), age_10_15=33, age_10_15_imported=17, age_10_15_utilization_rate=Decimal("92.00"), age_10_15_lease_share_itn_percent=Decimal("20.00"), + age_10_15_weighted_load_factor=Decimal("0.69"), age_15_20=22, age_15_20_imported=12, age_15_20_utilization_rate=Decimal("92.00"), age_15_20_lease_share_itn_percent=Decimal("20.00"), + age_15_20_weighted_load_factor=Decimal("0.63"), age_over_20=21, age_over_20_imported=11, age_over_20_utilization_rate=Decimal("92.00"), age_over_20_lease_share_itn_percent=Decimal("20.00"), + age_over_20_weighted_load_factor=Decimal("0.57"), physical_wear_percent=Decimal("28.40"), utilization_rate=Decimal("92.00"), avg_shift_work=Decimal("1.80"), @@ -317,6 +322,61 @@ class OrganizationAnalyticsApiTest(APITestCase): ) ) + def test_economics_calculates_roa_and_roe_from_f4_and_latest_f2(self): + FormF2RecordFactory.create( + organization=self.organization, + report_year=2025, + report_quarter=4, + total_assets=Decimal("800000.00"), + total_equity=Decimal("400000.00"), + ) + FormF2RecordFactory.create( + organization=self.organization, + report_year=2026, + report_quarter=4, + total_assets=Decimal("884828.00"), + total_equity=Decimal("442414.00"), + ) + + response = self.client.get( + f"/api/v1/organizations/{self.organization.id}/analytics/economics/" + "?group=efficiency&from_year=2025&to_year=2026" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + ratios_by_period = {row["period"]: row for row in response.data["ratios"]} + self.assertEqual(ratios_by_period[2025]["roa"], 50.0) + self.assertEqual(ratios_by_period[2025]["roe"], 100.0) + self.assertEqual(ratios_by_period[2026]["roa"], 50.0) + self.assertEqual(ratios_by_period[2026]["roe"], 100.0) + self.assertEqual( + response.data["ratios_report_period"], + { + "financial_form": "F-2", + "report_year": 2026, + "report_quarter": 4, + }, + ) + + def test_economics_returns_null_roa_and_roe_without_f2_denominators(self): + organization = OrganizationFactory.create() + FormF4RecordFactory.create( + organization=organization, + report_year=2026, + report_half_year=2, + net_profit_rsbu=Decimal("1000.00"), + ) + + response = self.client.get( + f"/api/v1/organizations/{organization.id}/analytics/economics/" + "?group=efficiency&from_year=2026&to_year=2026" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsNone(response.data["ratios"][0]["roa"]) + self.assertIsNone(response.data["ratios"][0]["roe"]) + self.assertIsNone(response.data["ratios_report_period"]) + def test_economics_respects_requested_range_when_it_is_empty(self): response = self.client.get( f"/api/v1/organizations/{self.organization.id}/analytics/economics/" @@ -480,8 +540,13 @@ class OrganizationAnalyticsApiTest(APITestCase): "utilization_rate", "lease_share_itn_percent", "weighted_wear_percent", + "weighted_load_factor", }, ) + self.assertEqual( + response.data["age_distribution"][0]["weighted_load_factor"], + 0.78, + ) self.assertGreaterEqual(len(response.data["categories"]), 1) self.assertEqual(len(response.data["dynamics_series"]), 3) self.assertEqual( @@ -510,6 +575,7 @@ class OrganizationAnalyticsApiTest(APITestCase): self.assertEqual(products_response.data["report_year"], 2026) self.assertEqual(products_response.data["frequency"], "quarterly") self.assertEqual(products_response.data["price_mode"], "actual") + self.assertEqual(products_response.data["summary_period"], "2026-Q1") self.assertEqual( products_response.data["summary"]["military_output_amount"], 11000000 ) @@ -567,6 +633,7 @@ class OrganizationAnalyticsApiTest(APITestCase): ) self.assertEqual(semiannual_response.status_code, status.HTTP_200_OK) self.assertEqual(semiannual_response.data["frequency"], "semiannual") + self.assertEqual(semiannual_response.data["summary_period"], "2026-H1") self.assertEqual(len(semiannual_response.data["production_series"]), 1) self.assertEqual( semiannual_response.data["production_series"][0]["period"], "2026-H1" @@ -582,6 +649,7 @@ class OrganizationAnalyticsApiTest(APITestCase): ) self.assertEqual(monthly_response.status_code, status.HTTP_200_OK) self.assertEqual(monthly_response.data["frequency"], "monthly") + self.assertEqual(monthly_response.data["summary_period"], "2026-06") self.assertEqual(len(monthly_response.data["production_series"]), 6) self.assertEqual( monthly_response.data["production_series"][0]["period"], "2026-01" @@ -591,6 +659,104 @@ class OrganizationAnalyticsApiTest(APITestCase): 3666666, ) + def test_products_aggregates_real_monthly_f1_records_chronologically(self): + organization = OrganizationFactory.create() + for month in (7, 4, 5, 2, 1, 3, 6): + amount = Decimal(month) * Decimal("1000000.00") + FormF1RecordFactory.create( + organization=organization, + report_year=2026, + report_month=month, + report_quarter=None, + military_output_actual=amount, + civilian_output_actual=amount, + hightech_output_actual=amount, + rd_volume_actual=amount, + military_domestic_actual=amount, + military_export_actual=amount, + civilian_domestic_actual=amount, + civilian_export_actual=amount, + ) + + monthly_response = self.client.get( + f"/api/v1/organizations/{organization.id}/analytics/products/" + "?frequency=monthly&price_mode=actual&report_year=2026" + ) + quarterly_response = self.client.get( + f"/api/v1/organizations/{organization.id}/analytics/products/" + "?frequency=quarterly&price_mode=actual&report_year=2026" + ) + semiannual_response = self.client.get( + f"/api/v1/organizations/{organization.id}/analytics/products/" + "?frequency=semiannual&price_mode=actual&report_year=2026" + ) + annual_response = self.client.get( + f"/api/v1/organizations/{organization.id}/analytics/products/" + "?frequency=annual&price_mode=actual&report_year=2026" + ) + + for response in ( + monthly_response, + quarterly_response, + semiannual_response, + annual_response, + ): + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.assertEqual( + [row["period"] for row in monthly_response.data["production_series"]], + [ + "2026-01", + "2026-02", + "2026-03", + "2026-04", + "2026-05", + "2026-06", + "2026-07", + ], + ) + self.assertEqual(monthly_response.data["summary_period"], "2026-07") + self.assertEqual( + monthly_response.data["summary"]["military_output_amount"], + 7000000, + ) + + self.assertEqual( + [row["period"] for row in quarterly_response.data["production_series"]], + ["2026-Q1", "2026-Q2", "2026-Q3"], + ) + self.assertEqual( + [ + row["military_output_amount"] + for row in quarterly_response.data["production_series"] + ], + [6000000, 15000000, 7000000], + ) + self.assertEqual(quarterly_response.data["summary_period"], "2026-Q3") + + self.assertEqual( + [row["period"] for row in semiannual_response.data["production_series"]], + ["2026-H1", "2026-H2"], + ) + self.assertEqual( + [ + row["military_output_amount"] + for row in semiannual_response.data["production_series"] + ], + [21000000, 7000000], + ) + self.assertEqual(semiannual_response.data["summary_period"], "2026-H2") + + self.assertEqual( + [row["period"] for row in annual_response.data["production_series"]], + ["2026"], + ) + self.assertEqual( + annual_response.data["production_series"][0]["military_output_amount"], + 28000000, + ) + self.assertEqual(annual_response.data["summary_period"], "2026") + def test_forecast_contract(self): response = self.client.get( f"/api/v1/organizations/{self.organization.id}/analytics/forecast/"