From 8ead5ebadc744a280cd7a5c7b033d4858509b117 Mon Sep 17 00:00:00 2001 From: Aleksandr Meshchriakov Date: Sun, 19 Apr 2026 12:23:38 +0200 Subject: [PATCH] Extend organization analytics contract and fix test warnings --- src/apps/core/openapi.py | 1 + src/apps/organization/analytics_services.py | 654 ++++++++++++++---- src/apps/organization/analytics_views.py | 58 ++ src/apps/organization/api.py | 120 +++- src/apps/organization/contract_serializers.py | 347 ++++++++++ src/apps/organization/dictionary_views.py | 9 + src/apps/organization/query_serializers.py | 30 +- src/apps/organization/scope_utils.py | 79 +++ src/apps/organization/serializers.py | 22 +- tests/apps/core/test_management_commands.py | 2 +- tests/apps/organization/test_analytics_api.py | 99 ++- tests/apps/organization/test_api.py | 51 ++ tests/apps/user/test_serializers.py | 3 +- tests/apps/user/test_views.py | 3 +- 14 files changed, 1348 insertions(+), 130 deletions(-) create mode 100644 src/apps/organization/contract_serializers.py diff --git a/src/apps/core/openapi.py b/src/apps/core/openapi.py index 67a5a4e..f566821 100644 --- a/src/apps/core/openapi.py +++ b/src/apps/core/openapi.py @@ -124,6 +124,7 @@ OPENAPI_TAG_BY_PATH_PREFIX = OrderedDict( [ ("/health/", "Мониторинг"), ("/api/v1/analytics/", "Аналитика"), + ("/api/v1/dictionaries/", "Организации"), ("/api/v1/jobs/", "Фоновые задачи"), ("/api/v1/organizations/", "Организации"), ("/api/v1/exchange/", "Обмен данными"), diff --git a/src/apps/organization/analytics_services.py b/src/apps/organization/analytics_services.py index b93ad9f..4bdd093 100644 --- a/src/apps/organization/analytics_services.py +++ b/src/apps/organization/analytics_services.py @@ -16,11 +16,17 @@ 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 -from django.db.models import Avg, Case, Count, IntegerField, Sum, When +from django.db.models import Avg, Case, Count, IntegerField, Q, Sum, When ZERO = Decimal("0") THOUSAND = Decimal("1000") INSURANCE_RATE = Decimal("0.302") +ECONOMICS_RATIO_NORMATIVES = { + "ros": 15.0, + "roa": 8.0, + "roe": 12.0, + "ebitda_margin": 18.0, +} def _dec(value) -> Decimal: @@ -43,6 +49,13 @@ def _ratio(value) -> float: return round(float(_dec(value)), 1) +def _share_percent(numerator, denominator) -> float: + denominator_value = _dec(denominator) + if denominator_value == ZERO: + return 0.0 + return round(float((_dec(numerator) / denominator_value) * Decimal("100")), 1) + + def _delta_percent(current, previous) -> float: current_value = _dec(current) previous_value = _dec(previous) @@ -90,6 +103,44 @@ def _best_records_by_year( return resolved +def _weighted_average_age(age_distribution: list[dict[str, int]]) -> float: + bucket_midpoints = { + "under_30": 25, + "30_50": 40, + "over_50": 55, + "under_5_years": Decimal("2.5"), + "5_10_years": Decimal("7.5"), + "10_15_years": Decimal("12.5"), + "15_20_years": Decimal("17.5"), + "over_20_years": Decimal("25"), + } + total_units = sum( + int(item.get("employees_count") or item.get("units_count") or 0) + for item in age_distribution + ) + if total_units <= 0: + return 0.0 + + weighted_sum = ZERO + for item in age_distribution: + bucket = item.get("age_group") or item.get("bucket") + units = int(item.get("employees_count") or item.get("units_count") or 0) + midpoint = bucket_midpoints.get(bucket) + if midpoint is None or units <= 0: + continue + weighted_sum += _dec(midpoint) * units + + return round(float(weighted_sum / Decimal(str(total_units))), 1) + + +def _cagr(start_value, end_value, periods: int) -> float: + start = _dec(start_value) + end = _dec(end_value) + if start <= ZERO or periods <= 0: + return 0.0 + return round(float((((end / start) ** (Decimal("1") / periods)) - 1) * 100), 1) + + class OrganizationAnalyticsService: """Aggregated analytics over organization profiles and reporting forms.""" @@ -329,9 +380,22 @@ class OrganizationAnalyticsService: "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)), + "ebitda_margin": _share_percent( + cls._economics_metric_value( + "ebitda", + f2_by_year.get(report_year), + f4_by_year.get(report_year), + ), + cls._economics_metric_value( + "revenue", + f2_by_year.get(report_year), + f4_by_year.get(report_year), + ), + ), } for report_year in periods ], + "ratio_normatives": ECONOMICS_RATIO_NORMATIVES, } @classmethod @@ -347,6 +411,7 @@ class OrganizationAnalyticsService: _pick_record(f3_records, report_year), entity="Personnel", ) + current_f1 = _pick_record(cls._f1_records(organization), report_year) years = list(range(report_year - history_years + 1, report_year + 1)) history_records = _best_records_by_year(f3_records, years[0], years[-1]) @@ -355,21 +420,28 @@ class OrganizationAnalyticsService: from_30_to_50 = int(average_employees * 0.49) over_50 = max(0, average_employees - under_30 - from_30_to_50) + 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}, + ] + return { "organization_id": str(organization.id), "report_year": report_year, + "average_age": _weighted_average_age(age_distribution), "headcount": { "average_employees": average_employees, + "avg_payroll_employees": int( + _dec(getattr(current_f1, "avg_payroll_employees", ZERO)) + ), + "payroll_fund": _amount(getattr(current_f1, "payroll_fund", ZERO)), "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}, - ], + "age_distribution": age_distribution, "history": [ { "year": year, @@ -392,98 +464,148 @@ class OrganizationAnalyticsService: _pick_record(f3_records, report_year), entity="Equipment", ) - current_f6 = _pick_record( - FormF6Record.objects.filter( - organization=organization, is_active_version=True - ), - report_year, + f6_queryset = FormF6Record.objects.filter( + organization=organization, + is_active_version=True, + report_year=report_year, ) + f6_records = list(f6_queryset.order_by("category", "-created_at")) 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) - ) - ), - }, - ] + if f6_records: + age_distribution = [ + { + "bucket": "under_5_years", + "units_count": sum( + int(_dec(record.age_under_5)) for record in f6_records + ), + }, + { + "bucket": "5_10_years", + "units_count": sum( + int(_dec(record.age_5_10)) for record in f6_records + ), + }, + { + "bucket": "10_15_years", + "units_count": sum( + int(_dec(record.age_10_15)) for record in f6_records + ), + }, + { + "bucket": "15_20_years", + "units_count": sum( + int(_dec(record.age_15_20)) for record in f6_records + ), + }, + { + "bucket": "over_20_years", + "units_count": sum( + int(_dec(record.age_over_20)) for record in f6_records + ), + }, + ] + else: + age_distribution = [ + { + "bucket": "under_5_years", + "units_count": int( + _dec(getattr(current_f3, "equipment_age_under_5", ZERO)) + ), + }, + { + "bucket": "5_10_years", + "units_count": int( + _dec(getattr(current_f3, "equipment_age_5_10", ZERO)) + ), + }, + { + "bucket": "10_15_years", + "units_count": int( + _dec(getattr(current_f3, "equipment_age_10_15", ZERO)) + ), + }, + { + "bucket": "15_20_years", + "units_count": int( + _dec(getattr(current_f3, "equipment_age_15_20", ZERO)) + ), + }, + { + "bucket": "over_20_years", + "units_count": int( + _dec(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: + if f6_records: 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, + "equipment_category": record.category, + "total_equipment": record.total_equipment, + "domestic_equipment": record.domestic_equipment, + "imported_equipment": record.imported_equipment, + "physical_wear_percent": record.physical_wear_percent, + "utilization_rate": record.utilization_rate, + "lease_share_itn_percent": None, } + for record in f6_records ] + else: + 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"), + utilization_rate=Avg("utilization_rate"), + ) + .order_by("equipment_category") + ) + + f3_by_year = _best_records_by_year( + f3_records, + min(record.report_year for record in f3_records), + max(record.report_year for record in f3_records), + ) + dynamics_years = sorted(f3_by_year) + commissioned_by_year = { + year: FormF5Record.objects.filter( + organization=organization, + is_active_version=True, + report_year=year, + commissioning_date__year=year, + ).count() + for year in dynamics_years + } + decommissioned_by_year = { + year: FormF5Record.objects.filter( + organization=organization, + is_active_version=True, + report_year=year, + ) + .filter(Q(is_operational=False) | Q(requires_replacement=True)) + .count() + for year in dynamics_years + } return { "organization_id": str(organization.id), @@ -493,11 +615,15 @@ class OrganizationAnalyticsService: "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), + "weighted_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)), + "average_age_years": _weighted_average_age(age_distribution), + "commissioned_equipment": commissioned_by_year.get(report_year, 0), + "decommissioned_equipment": decommissioned_by_year.get(report_year, 0), }, "age_distribution": age_distribution, "categories": [ @@ -507,11 +633,171 @@ class OrganizationAnalyticsService: "domestic_equipment": int(_dec(row["domestic_equipment"])), "imported_equipment": int(_dec(row["imported_equipment"])), "physical_wear_percent": _ratio(row["physical_wear_percent"]), + "weighted_wear_percent": _ratio(row["physical_wear_percent"]), + "utilization_rate": round( + float(_dec(row.get("utilization_rate")) / Decimal("100")), 2 + ) + if row.get("utilization_rate") is not None + else None, + "lease_share_itn_percent": row.get("lease_share_itn_percent"), } for row in category_rows ], + "dynamics_series": [ + { + "metric": "total_equipment", + "label": "Всего единиц оборудования", + "points": [ + { + "period": year, + "value": int(_dec(f3_by_year[year].total_equipment)), + } + for year in dynamics_years + ], + }, + { + "metric": "commissioned_equipment", + "label": "Введено в эксплуатацию", + "points": [ + {"period": year, "value": commissioned_by_year.get(year, 0)} + for year in dynamics_years + ], + }, + { + "metric": "decommissioned_equipment", + "label": "Выведено из эксплуатации", + "points": [ + {"period": year, "value": decommissioned_by_year.get(year, 0)} + for year in dynamics_years + ], + }, + ], } + @classmethod + def _f1_metric_bundle(cls, record, *, suffix: str) -> dict[str, Decimal]: + return { + "military_output_amount": _dec( + getattr(record, f"military_output_{suffix}", ZERO) + ), + "civilian_output_amount": _dec( + getattr(record, f"civilian_output_{suffix}", ZERO) + ), + "hightech_output_amount": _dec( + getattr(record, f"hightech_output_{suffix}", ZERO) + ), + "rd_volume_amount": _dec(getattr(record, f"rd_volume_{suffix}", ZERO)), + "military_domestic_amount": _dec( + getattr(record, f"military_domestic_{suffix}", ZERO) + ), + "military_export_amount": _dec( + getattr(record, f"military_export_{suffix}", ZERO) + ), + "civilian_domestic_amount": _dec( + getattr(record, f"civilian_domestic_{suffix}", ZERO) + ), + "civilian_export_amount": _dec( + getattr(record, f"civilian_export_{suffix}", ZERO) + ), + } + + @staticmethod + def _empty_product_metrics() -> dict[str, Decimal]: + return { + "military_output_amount": ZERO, + "civilian_output_amount": ZERO, + "hightech_output_amount": ZERO, + "rd_volume_amount": ZERO, + "military_domestic_amount": ZERO, + "military_export_amount": ZERO, + "civilian_domestic_amount": ZERO, + "civilian_export_amount": ZERO, + } + + @classmethod + def _aggregate_product_metrics( + cls, rows: list[dict[str, object]], period: str + ) -> dict[str, object]: + metrics = cls._empty_product_metrics() + for row in rows: + row_metrics = row["metrics"] + for key in metrics: + metrics[key] += _dec(row_metrics[key]) + return {"period": period, "metrics": metrics} + + @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)) + ] + + 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 base_rows + + @staticmethod + def _product_row_shipped_goods_amount(row: dict[str, object]) -> int: + metrics = row["metrics"] + return _amount(metrics["military_domestic_amount"]) + _amount( + metrics["military_export_amount"] + ) + _amount(metrics["civilian_domestic_amount"]) + _amount( + metrics["civilian_export_amount"] + ) + @classmethod def get_products( cls, @@ -532,19 +818,12 @@ class OrganizationAnalyticsService: 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}" + frequency_rows = cls._build_product_frequency_rows( + records, suffix=suffix, frequency=frequency + ) + current = frequency_rows[-1] + current_metrics = current["metrics"] return { "organization_id": str(organization.id), @@ -552,35 +831,81 @@ class OrganizationAnalyticsService: "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")), + "military_output_amount": _amount( + current_metrics["military_output_amount"] + ), + "civilian_output_amount": _amount( + current_metrics["civilian_output_amount"] + ), + "hightech_output_amount": _amount( + current_metrics["hightech_output_amount"] + ), + "rd_volume_amount": _amount(current_metrics["rd_volume_amount"]), + "shipped_goods_amount": cls._product_row_shipped_goods_amount(current), }, "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")), + "period": row["period"], + "military_output_amount": _amount( + row["metrics"]["military_output_amount"] + ), + "civilian_output_amount": _amount( + row["metrics"]["civilian_output_amount"] + ), + "hightech_output_amount": _amount( + row["metrics"]["hightech_output_amount"] + ), } - for record in records - if record is not None + for row in frequency_rows ], "sales_series": [ { - "period": period_label(record), + "period": row["period"], "military_domestic_amount": _amount( - field(record, "military_domestic") + row["metrics"]["military_domestic_amount"] + ), + "military_export_amount": _amount( + row["metrics"]["military_export_amount"] ), - "military_export_amount": _amount(field(record, "military_export")), "civilian_domestic_amount": _amount( - field(record, "civilian_domestic") + row["metrics"]["civilian_domestic_amount"] + ), + "civilian_export_amount": _amount( + row["metrics"]["civilian_export_amount"] ), - "civilian_export_amount": _amount(field(record, "civilian_export")), } - for record in records - if record is not None + for row in frequency_rows + ], + "rd_volume_series": [ + { + "period": row["period"], + "rd_volume_amount": _amount(row["metrics"]["rd_volume_amount"]), + } + for row in frequency_rows + ], + "shipped_goods_series": [ + { + "period": row["period"], + "shipped_goods_amount": cls._product_row_shipped_goods_amount(row), + } + for row in frequency_rows + ], + "table": [ + { + "period": row["period"], + "military_output_amount": _amount( + row["metrics"]["military_output_amount"] + ), + "civilian_output_amount": _amount( + row["metrics"]["civilian_output_amount"] + ), + "hightech_output_amount": _amount( + row["metrics"]["hightech_output_amount"] + ), + "rd_volume_amount": _amount(row["metrics"]["rd_volume_amount"]), + "shipped_goods_amount": cls._product_row_shipped_goods_amount(row), + } + for row in frequency_rows ], } @@ -616,28 +941,81 @@ class OrganizationAnalyticsService: default=None, ) latest = cls._require_record(latest, entity="Forecast") + latest_f1 = _pick_record(cls._f1_records(organization), latest.report_year) + latest_f3 = _pick_record(cls._f3_records(organization), latest.report_year) + latest_f4 = _pick_record(cls._f4_records(organization), latest.report_year) scenario_growth = { "base": Decimal("0.08"), "optimistic": Decimal("0.13"), "conservative": Decimal("0.04"), }[scenario] + scenario_headcount_growth = { + "base": Decimal("0.02"), + "optimistic": Decimal("0.035"), + "conservative": Decimal("0.01"), + }[scenario] base_margin = _dec(latest.net_profit) / max(_dec(latest.revenue), Decimal("1")) forecast_rows = [] + productivity_series = [] + headcount_series = [] + investment_series = [] + rd_series = [] + table_rows = [] revenue = _dec(latest.revenue) + headcount = _dec( + getattr(latest_f3, "avg_employees", None) + or getattr(latest_f1, "avg_employees", ZERO) + ) + capex = _dec(getattr(latest_f4, "capex", ZERO)) + rd_volume = _dec(getattr(latest_f1, "rd_volume_actual", ZERO)) for year_index in range(1, horizon_years + 1): revenue *= Decimal("1.00") + scenario_growth + headcount *= Decimal("1.00") + scenario_headcount_growth + capex *= Decimal("1.00") + (scenario_growth * Decimal("0.65")) + rd_volume *= Decimal("1.00") + (scenario_growth * Decimal("0.75")) 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 + row = { + "year": year, + "revenue_amount": _amount(revenue), + "net_profit_amount": _amount(net_profit), + "margin_percent": round(float(margin * Decimal("100")), 1), + } + forecast_rows.append(row) + productivity_row = { + "year": year, + "revenue_per_employee_amount": _amount( + revenue / max(headcount, Decimal("1")) + ), + } + headcount_row = { + "year": year, + "average_employees": int(headcount), + } + investment_row = { + "year": year, + "capex_amount": _amount(capex), + } + rd_row = { + "year": year, + "rd_volume_amount": _amount(rd_volume), + } + productivity_series.append(productivity_row) + headcount_series.append(headcount_row) + investment_series.append(investment_row) + rd_series.append(rd_row) + table_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), + **row, + **productivity_row, + **headcount_row, + **investment_row, + **rd_row, } ) @@ -661,13 +1039,49 @@ class OrganizationAnalyticsService: } ) + opportunity_factors = [ + { + "code": "efficiency_program", + "name": "Рост производительности и загрузки мощностей", + "impact_level": "high" if scenario == "optimistic" else "medium", + "probability_level": "medium", + "comment": "Прогноз учитывает повышение выручки на одного работника.", + }, + { + "code": "investment_cycle", + "name": "Инвестиционный цикл модернизации", + "impact_level": "medium", + "probability_level": "high" if scenario != "conservative" else "medium", + "comment": "CAPEX и НИОКР растут пропорционально сценарию развития.", + }, + ] + horizon_row = forecast_rows[-1] + return { "organization_id": str(organization.id), "scenario": scenario, "horizon_years": horizon_years, "base_year": latest.report_year, + "summary": { + "revenue_cagr_percent": _cagr( + latest.revenue, horizon_row["revenue_amount"], horizon_years + ), + "net_profit_cagr_percent": _cagr( + latest.net_profit, + horizon_row["net_profit_amount"], + horizon_years, + ), + "horizon_margin_percent": horizon_row["margin_percent"], + "horizon_revenue_amount": horizon_row["revenue_amount"], + }, "forecast": forecast_rows, + "table": table_rows, + "productivity_series": productivity_series, + "headcount_series": headcount_series, + "investment_series": investment_series, + "rd_series": rd_series, "risk_factors": risk_factors, + "opportunity_factors": opportunity_factors, } diff --git a/src/apps/organization/analytics_views.py b/src/apps/organization/analytics_views.py index 975df94..af38be5 100644 --- a/src/apps/organization/analytics_views.py +++ b/src/apps/organization/analytics_views.py @@ -6,6 +6,16 @@ from apps.organization.analytics_services import ( DashboardAnalyticsService, OrganizationAnalyticsService, ) +from apps.organization.contract_serializers import ( + DashboardResponseSerializer, + EconomicsResponseSerializer, + EquipmentResponseSerializer, + FinancialSummaryResponseSerializer, + ForecastResponseSerializer, + PersonnelResponseSerializer, + ProductsResponseSerializer, + RiskProfileResponseSerializer, +) from apps.organization.models import Organization from apps.organization.query_serializers import ( DashboardQuerySerializer, @@ -17,6 +27,7 @@ from apps.organization.query_serializers import ( ProductsQuerySerializer, ) from django.shortcuts import get_object_or_404 +from drf_yasg.utils import swagger_auto_schema from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -36,6 +47,12 @@ class AnalyticsDashboardView(APIView): permission_classes = [IsAuthenticated] + @swagger_auto_schema( + tags=["Аналитика"], + operation_summary="Сводная аналитика по корпорации", + query_serializer=DashboardQuerySerializer, + responses={200: DashboardResponseSerializer}, + ) def get(self, request): serializer = DashboardQuerySerializer(data=request.query_params) serializer.is_valid(raise_exception=True) @@ -46,6 +63,12 @@ class AnalyticsDashboardView(APIView): class OrganizationFinancialSummaryView(OrganizationAnalyticsBaseView): + @swagger_auto_schema( + tags=["Аналитика"], + operation_summary="Финансовая сводка организации", + query_serializer=FinancialSummaryQuerySerializer, + responses={200: FinancialSummaryResponseSerializer}, + ) def get(self, request, organization_id): serializer = FinancialSummaryQuerySerializer(data=request.query_params) serializer.is_valid(raise_exception=True) @@ -57,6 +80,12 @@ class OrganizationFinancialSummaryView(OrganizationAnalyticsBaseView): class OrganizationEconomicsView(OrganizationAnalyticsBaseView): + @swagger_auto_schema( + tags=["Аналитика"], + operation_summary="Экономическая аналитика организации", + query_serializer=EconomicsQuerySerializer, + responses={200: EconomicsResponseSerializer}, + ) def get(self, request, organization_id): serializer = EconomicsQuerySerializer(data=request.query_params) serializer.is_valid(raise_exception=True) @@ -68,6 +97,12 @@ class OrganizationEconomicsView(OrganizationAnalyticsBaseView): class OrganizationPersonnelView(OrganizationAnalyticsBaseView): + @swagger_auto_schema( + tags=["Аналитика"], + operation_summary="Кадровая аналитика организации", + query_serializer=PersonnelQuerySerializer, + responses={200: PersonnelResponseSerializer}, + ) def get(self, request, organization_id): serializer = PersonnelQuerySerializer(data=request.query_params) serializer.is_valid(raise_exception=True) @@ -79,6 +114,12 @@ class OrganizationPersonnelView(OrganizationAnalyticsBaseView): class OrganizationEquipmentView(OrganizationAnalyticsBaseView): + @swagger_auto_schema( + tags=["Аналитика"], + operation_summary="Аналитика по оборудованию", + query_serializer=EquipmentQuerySerializer, + responses={200: EquipmentResponseSerializer}, + ) def get(self, request, organization_id): serializer = EquipmentQuerySerializer(data=request.query_params) serializer.is_valid(raise_exception=True) @@ -90,6 +131,12 @@ class OrganizationEquipmentView(OrganizationAnalyticsBaseView): class OrganizationProductsView(OrganizationAnalyticsBaseView): + @swagger_auto_schema( + tags=["Аналитика"], + operation_summary="Аналитика по продукции", + query_serializer=ProductsQuerySerializer, + responses={200: ProductsResponseSerializer}, + ) def get(self, request, organization_id): serializer = ProductsQuerySerializer(data=request.query_params) serializer.is_valid(raise_exception=True) @@ -101,6 +148,11 @@ class OrganizationProductsView(OrganizationAnalyticsBaseView): class OrganizationRiskProfileView(OrganizationAnalyticsBaseView): + @swagger_auto_schema( + tags=["Аналитика"], + operation_summary="Риск-профиль организации", + responses={200: RiskProfileResponseSerializer}, + ) def get(self, request, organization_id): payload = OrganizationAnalyticsService.get_risk_profile( organization=self.get_organization(organization_id) @@ -109,6 +161,12 @@ class OrganizationRiskProfileView(OrganizationAnalyticsBaseView): class OrganizationForecastView(OrganizationAnalyticsBaseView): + @swagger_auto_schema( + tags=["Аналитика"], + operation_summary="Прогнозная аналитика организации", + query_serializer=ForecastQuerySerializer, + responses={200: ForecastResponseSerializer}, + ) def get(self, request, organization_id): serializer = ForecastQuerySerializer(data=request.query_params) serializer.is_valid(raise_exception=True) diff --git a/src/apps/organization/api.py b/src/apps/organization/api.py index 42ce81a..89887bc 100644 --- a/src/apps/organization/api.py +++ b/src/apps/organization/api.py @@ -6,8 +6,14 @@ API ViewSets для организаций. """ from apps.core.viewsets import ClassicReadOnlyViewSet +from apps.organization.contract_serializers import ( + OrganizationCatalogListResponseSerializer, +) from apps.organization.models import Organization -from apps.organization.scope_utils import filter_queryset_by_scopes +from apps.organization.scope_utils import ( + filter_queryset_by_registry_categories, + filter_queryset_by_scopes, +) from apps.organization.serializers import ( OrganizationCatalogDetailSerializer, OrganizationCatalogListSerializer, @@ -15,6 +21,8 @@ from apps.organization.serializers import ( from apps.registers.models import RegistryMembershipPeriod from django.db.models import Prefetch from django_filters import rest_framework as filters +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema from rest_framework.permissions import IsAuthenticated @@ -26,6 +34,8 @@ class OrganizationFilter(filters.FilterSet): ogrn = filters.CharFilter(lookup_expr="exact") registry = filters.UUIDFilter(method="filter_registry") corporation_scope = filters.CharFilter(method="filter_corporation_scope") + registry_category = filters.CharFilter(method="filter_registry_category") + registryCategory = filters.CharFilter(method="filter_registry_category") organization_type = filters.CharFilter(lookup_expr="exact") @staticmethod @@ -44,6 +54,15 @@ class OrganizationFilter(filters.FilterSet): return queryset return filter_queryset_by_scopes(queryset, requested_scopes) + @staticmethod + def filter_registry_category(queryset, _name, value): + requested_categories = [ + item.strip().lower() for item in str(value).split(",") if item.strip() + ] + if not requested_categories: + return queryset + return filter_queryset_by_registry_categories(queryset, requested_categories) + class Meta: model = Organization fields = [ @@ -52,6 +71,7 @@ class OrganizationFilter(filters.FilterSet): "ogrn", "registry", "corporation_scope", + "registry_category", "organization_type", ] @@ -79,6 +99,104 @@ class OrganizationViewSet(ClassicReadOnlyViewSet[Organization]): "list": OrganizationCatalogListSerializer, } + @swagger_auto_schema( + operation_summary="Список организаций", + operation_description=( + "Возвращает основной реестр организаций в paginated-форме " + "`count / next / previous / results`." + ), + tags=["Организации"], + manual_parameters=[ + openapi.Parameter( + "name", + openapi.IN_QUERY, + description="Фильтр по наименованию организации", + type=openapi.TYPE_STRING, + ), + openapi.Parameter( + "inn", + openapi.IN_QUERY, + description="Фильтр по ИНН", + type=openapi.TYPE_STRING, + ), + openapi.Parameter( + "ogrn", + openapi.IN_QUERY, + description="Фильтр по ОГРН", + type=openapi.TYPE_STRING, + ), + openapi.Parameter( + "registry", + openapi.IN_QUERY, + description="ID активного реестра", + type=openapi.TYPE_STRING, + ), + openapi.Parameter( + "corporation_scope", + openapi.IN_QUERY, + description="Код корпорации (`rosatom`, `roscosmos`, `opk`, `other`)", + type=openapi.TYPE_STRING, + ), + openapi.Parameter( + "organization_type", + openapi.IN_QUERY, + description="Тип организации", + type=openapi.TYPE_STRING, + ), + openapi.Parameter( + "registry_category", + openapi.IN_QUERY, + description=( + "Категория активного реестра для сайдбара " + "(`opk`, `goz`, `other`). Поддерживает список через запятую." + ), + type=openapi.TYPE_STRING, + ), + openapi.Parameter( + "registryCategory", + openapi.IN_QUERY, + description="CamelCase alias для `registry_category`.", + type=openapi.TYPE_STRING, + ), + openapi.Parameter( + "search", + openapi.IN_QUERY, + description="Полнотекстовый поиск по name/short_name/inn/ogrn/okpo", + type=openapi.TYPE_STRING, + ), + openapi.Parameter( + "ordering", + openapi.IN_QUERY, + description="Сортировка по `name`, `short_name`, `inn`, `created_at`", + type=openapi.TYPE_STRING, + ), + openapi.Parameter( + "page", + openapi.IN_QUERY, + description="Номер страницы", + type=openapi.TYPE_INTEGER, + ), + openapi.Parameter( + "page_size", + openapi.IN_QUERY, + description="Размер страницы", + type=openapi.TYPE_INTEGER, + ), + ], + responses={200: OrganizationCatalogListResponseSerializer}, + ) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + + @swagger_auto_schema( + operation_summary="Карточка организации", + operation_description="Возвращает детальную карточку организации.", + tags=["Организации"], + responses={200: OrganizationCatalogDetailSerializer}, + ) + def retrieve(self, request, *args, **kwargs): + return super().retrieve(request, *args, **kwargs) + def get_queryset(self): return ( super() diff --git a/src/apps/organization/contract_serializers.py b/src/apps/organization/contract_serializers.py new file mode 100644 index 0000000..6e083fd --- /dev/null +++ b/src/apps/organization/contract_serializers.py @@ -0,0 +1,347 @@ +"""Serializer contracts for OpenAPI documentation.""" + +from rest_framework import serializers + +from apps.organization.serializers import ( + CorporationScopeDictionarySerializer, + OrganizationCatalogDetailSerializer, + OrganizationCatalogListSerializer, +) + + +class OrganizationCatalogListResponseSerializer(serializers.Serializer): + count = serializers.IntegerField() + next = serializers.CharField(allow_null=True) + previous = serializers.CharField(allow_null=True) + results = OrganizationCatalogListSerializer(many=True) + + +class CorporationScopeDictionaryResponseSerializer(serializers.Serializer): + results = CorporationScopeDictionarySerializer(many=True) + + +class FinancialSummaryMetricSerializer(serializers.Serializer): + amount = serializers.IntegerField() + previous_amount = serializers.IntegerField() + delta_percent = serializers.FloatField() + + +class FinancialSummaryReportPeriodSerializer(serializers.Serializer): + year = serializers.IntegerField() + quarter = serializers.IntegerField(allow_null=True) + + +class FinancialSummaryResponseSerializer(serializers.Serializer): + organization_id = serializers.UUIDField() + report_period = FinancialSummaryReportPeriodSerializer() + revenue = FinancialSummaryMetricSerializer() + net_profit = FinancialSummaryMetricSerializer() + taxes_paid = FinancialSummaryMetricSerializer() + insurance_contributions = FinancialSummaryMetricSerializer() + + +class EconomicsKpiSerializer(serializers.Serializer): + value = serializers.IntegerField() + unit = serializers.CharField() + + +class EconomicsKpisSerializer(serializers.Serializer): + revenue = EconomicsKpiSerializer(required=False) + ebitda = EconomicsKpiSerializer(required=False) + net_profit = EconomicsKpiSerializer(required=False) + gross_profit = EconomicsKpiSerializer(required=False) + operating_profit = EconomicsKpiSerializer(required=False) + net_debt = EconomicsKpiSerializer(required=False) + loans = EconomicsKpiSerializer(required=False) + assets = EconomicsKpiSerializer(required=False) + capex = EconomicsKpiSerializer(required=False) + rd_expenses = EconomicsKpiSerializer(required=False) + + +class EconomicsPointSerializer(serializers.Serializer): + period = serializers.IntegerField() + value = serializers.IntegerField() + + +class EconomicsMetricSeriesSerializer(serializers.Serializer): + metric = serializers.CharField() + unit = serializers.CharField() + points = EconomicsPointSerializer(many=True) + + +class EconomicsRatioSerializer(serializers.Serializer): + period = serializers.IntegerField() + ros = serializers.FloatField() + roa = serializers.FloatField() + roe = serializers.FloatField() + ebitda_margin = serializers.FloatField() + + +class EconomicsRatioNormativesSerializer(serializers.Serializer): + ros = serializers.FloatField(allow_null=True) + roa = serializers.FloatField(allow_null=True) + roe = serializers.FloatField(allow_null=True) + ebitda_margin = serializers.FloatField(allow_null=True) + + +class EconomicsResponseSerializer(serializers.Serializer): + organization_id = serializers.UUIDField() + group = serializers.CharField() + periods = serializers.ListField(child=serializers.IntegerField()) + kpis = EconomicsKpisSerializer() + series = EconomicsMetricSeriesSerializer(many=True) + ratios = EconomicsRatioSerializer(many=True) + ratio_normatives = EconomicsRatioNormativesSerializer() + + +class PersonnelHeadcountSerializer(serializers.Serializer): + average_employees = serializers.IntegerField() + avg_payroll_employees = serializers.IntegerField() + payroll_fund = serializers.IntegerField() + production_workers = serializers.IntegerField() + engineering_workers = serializers.IntegerField() + administrative_workers = serializers.IntegerField() + workers_needed = serializers.IntegerField() + + +class PersonnelAgeDistributionSerializer(serializers.Serializer): + age_group = serializers.CharField() + employees_count = serializers.IntegerField() + + +class PersonnelHistorySerializer(serializers.Serializer): + year = serializers.IntegerField() + average_employees = serializers.IntegerField() + + +class PersonnelResponseSerializer(serializers.Serializer): + organization_id = serializers.UUIDField() + report_year = serializers.IntegerField() + average_age = serializers.FloatField() + headcount = PersonnelHeadcountSerializer() + age_distribution = PersonnelAgeDistributionSerializer(many=True) + history = PersonnelHistorySerializer(many=True) + + +class EquipmentSummarySerializer(serializers.Serializer): + total_equipment = serializers.IntegerField() + domestic_equipment = serializers.IntegerField() + imported_equipment = serializers.IntegerField() + physical_wear_percent = serializers.FloatField() + weighted_wear_percent = serializers.FloatField() + utilization_rate = serializers.FloatField() + avg_shift_work = serializers.FloatField() + equipment_needed = serializers.IntegerField() + average_age_years = serializers.FloatField() + commissioned_equipment = serializers.IntegerField() + decommissioned_equipment = serializers.IntegerField() + + +class EquipmentAgeDistributionSerializer(serializers.Serializer): + bucket = serializers.CharField() + units_count = serializers.IntegerField() + + +class EquipmentCategorySerializer(serializers.Serializer): + category = serializers.CharField() + total_equipment = serializers.IntegerField() + domestic_equipment = serializers.IntegerField() + imported_equipment = serializers.IntegerField() + physical_wear_percent = serializers.FloatField() + weighted_wear_percent = serializers.FloatField() + utilization_rate = serializers.FloatField(allow_null=True) + lease_share_itn_percent = serializers.FloatField(allow_null=True) + + +class EquipmentDynamicsPointSerializer(serializers.Serializer): + period = serializers.IntegerField() + value = serializers.IntegerField() + + +class EquipmentDynamicsSeriesSerializer(serializers.Serializer): + metric = serializers.CharField() + label = serializers.CharField() + points = EquipmentDynamicsPointSerializer(many=True) + + +class EquipmentResponseSerializer(serializers.Serializer): + organization_id = serializers.UUIDField() + report_year = serializers.IntegerField() + summary = EquipmentSummarySerializer() + age_distribution = EquipmentAgeDistributionSerializer(many=True) + categories = EquipmentCategorySerializer(many=True) + dynamics_series = EquipmentDynamicsSeriesSerializer(many=True) + + +class ProductsSummarySerializer(serializers.Serializer): + military_output_amount = serializers.IntegerField() + civilian_output_amount = serializers.IntegerField() + hightech_output_amount = serializers.IntegerField() + rd_volume_amount = serializers.IntegerField() + shipped_goods_amount = serializers.IntegerField() + + +class ProductsProductionSeriesSerializer(serializers.Serializer): + period = serializers.CharField() + military_output_amount = serializers.IntegerField() + civilian_output_amount = serializers.IntegerField() + hightech_output_amount = serializers.IntegerField() + + +class ProductsSalesSeriesSerializer(serializers.Serializer): + period = serializers.CharField() + military_domestic_amount = serializers.IntegerField() + military_export_amount = serializers.IntegerField() + civilian_domestic_amount = serializers.IntegerField() + civilian_export_amount = serializers.IntegerField() + + +class ProductsRdVolumeSeriesSerializer(serializers.Serializer): + period = serializers.CharField() + rd_volume_amount = serializers.IntegerField() + + +class ProductsShippedGoodsSeriesSerializer(serializers.Serializer): + period = serializers.CharField() + shipped_goods_amount = serializers.IntegerField() + + +class ProductsTableRowSerializer(serializers.Serializer): + period = serializers.CharField() + military_output_amount = serializers.IntegerField() + civilian_output_amount = serializers.IntegerField() + hightech_output_amount = serializers.IntegerField() + rd_volume_amount = serializers.IntegerField() + shipped_goods_amount = serializers.IntegerField() + + +class ProductsResponseSerializer(serializers.Serializer): + organization_id = serializers.UUIDField() + report_year = serializers.IntegerField() + frequency = serializers.CharField() + price_mode = serializers.CharField() + summary = ProductsSummarySerializer() + production_series = ProductsProductionSeriesSerializer(many=True) + sales_series = ProductsSalesSeriesSerializer(many=True) + rd_volume_series = ProductsRdVolumeSeriesSerializer(many=True) + shipped_goods_series = ProductsShippedGoodsSeriesSerializer(many=True) + table = ProductsTableRowSerializer(many=True) + + +class RiskProfileResponseSerializer(serializers.Serializer): + organization_id = serializers.UUIDField() + financial_reports_available = serializers.BooleanField() + tax_reports_available = serializers.BooleanField() + in_defense_unreliable_suppliers_registry = serializers.BooleanField() + in_275_fz_registry = serializers.BooleanField() + bankruptcy_messages_found = serializers.BooleanField() + risk_level = serializers.CharField() + updated_at = serializers.CharField() + + +class ForecastRowSerializer(serializers.Serializer): + year = serializers.IntegerField() + revenue_amount = serializers.IntegerField() + net_profit_amount = serializers.IntegerField() + margin_percent = serializers.FloatField() + + +class ForecastSummarySerializer(serializers.Serializer): + revenue_cagr_percent = serializers.FloatField() + net_profit_cagr_percent = serializers.FloatField() + horizon_margin_percent = serializers.FloatField() + horizon_revenue_amount = serializers.IntegerField() + + +class ForecastProductivityRowSerializer(serializers.Serializer): + year = serializers.IntegerField() + revenue_per_employee_amount = serializers.IntegerField() + + +class ForecastHeadcountRowSerializer(serializers.Serializer): + year = serializers.IntegerField() + average_employees = serializers.IntegerField() + + +class ForecastInvestmentRowSerializer(serializers.Serializer): + year = serializers.IntegerField() + capex_amount = serializers.IntegerField() + + +class ForecastRdRowSerializer(serializers.Serializer): + year = serializers.IntegerField() + rd_volume_amount = serializers.IntegerField() + + +class ForecastTableRowSerializer(serializers.Serializer): + year = serializers.IntegerField() + revenue_amount = serializers.IntegerField() + net_profit_amount = serializers.IntegerField() + margin_percent = serializers.FloatField() + revenue_per_employee_amount = serializers.IntegerField() + average_employees = serializers.IntegerField() + capex_amount = serializers.IntegerField() + rd_volume_amount = serializers.IntegerField() + + +class ForecastRiskFactorSerializer(serializers.Serializer): + code = serializers.CharField() + name = serializers.CharField() + impact_level = serializers.CharField() + probability_level = serializers.CharField() + comment = serializers.CharField() + + +class ForecastResponseSerializer(serializers.Serializer): + organization_id = serializers.UUIDField() + scenario = serializers.CharField() + horizon_years = serializers.IntegerField() + base_year = serializers.IntegerField() + summary = ForecastSummarySerializer() + forecast = ForecastRowSerializer(many=True) + table = ForecastTableRowSerializer(many=True) + productivity_series = ForecastProductivityRowSerializer(many=True) + headcount_series = ForecastHeadcountRowSerializer(many=True) + investment_series = ForecastInvestmentRowSerializer(many=True) + rd_series = ForecastRdRowSerializer(many=True) + risk_factors = ForecastRiskFactorSerializer(many=True) + opportunity_factors = ForecastRiskFactorSerializer(many=True) + + +class DashboardDistributionSerializer(serializers.Serializer): + cluster = serializers.CharField() + cluster_label = serializers.CharField(required=False) + organizations_share_percent = serializers.FloatField() + + +class DashboardExecutorsSerializer(serializers.Serializer): + cluster = serializers.CharField() + executors_count = serializers.IntegerField() + + +class DashboardHeadcountGrowthSerializer(serializers.Serializer): + cluster = serializers.CharField() + growth_percent = serializers.FloatField() + + +class DashboardResponseSerializer(serializers.Serializer): + corporation_scope = serializers.CharField(allow_null=True) + distribution_by_cluster = DashboardDistributionSerializer(many=True) + executors_by_cluster = DashboardExecutorsSerializer(many=True) + headcount_growth_by_cluster = DashboardHeadcountGrowthSerializer(many=True) + bankruptcy_free_share_by_cluster = DashboardDistributionSerializer(many=True) + + +__all__ = [ + "CorporationScopeDictionaryResponseSerializer", + "EconomicsResponseSerializer", + "EquipmentResponseSerializer", + "FinancialSummaryResponseSerializer", + "ForecastResponseSerializer", + "OrganizationCatalogDetailSerializer", + "OrganizationCatalogListResponseSerializer", + "PersonnelResponseSerializer", + "ProductsResponseSerializer", + "RiskProfileResponseSerializer", + "DashboardResponseSerializer", +] diff --git a/src/apps/organization/dictionary_views.py b/src/apps/organization/dictionary_views.py index 35bfb59..6411dac 100644 --- a/src/apps/organization/dictionary_views.py +++ b/src/apps/organization/dictionary_views.py @@ -1,7 +1,11 @@ """Dictionary endpoints for organization domain.""" +from apps.organization.contract_serializers import ( + CorporationScopeDictionaryResponseSerializer, +) from apps.organization.scope_utils import get_corporation_scope_dictionary from apps.organization.serializers import CorporationScopeDictionarySerializer +from drf_yasg.utils import swagger_auto_schema from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -12,6 +16,11 @@ class CorporationScopeDictionaryView(APIView): permission_classes = [IsAuthenticated] + @swagger_auto_schema( + tags=["Организации"], + operation_summary="Справочник корпораций", + responses={200: CorporationScopeDictionaryResponseSerializer}, + ) def get(self, request): # noqa: ARG002 items = get_corporation_scope_dictionary() serializer = CorporationScopeDictionarySerializer(items, many=True) diff --git a/src/apps/organization/query_serializers.py b/src/apps/organization/query_serializers.py index cbc860a..77f3628 100644 --- a/src/apps/organization/query_serializers.py +++ b/src/apps/organization/query_serializers.py @@ -4,6 +4,18 @@ from apps.organization.models import CorporationScope from rest_framework import serializers +class AliasChoiceField(serializers.ChoiceField): + def __init__(self, *args, aliases: dict[str, str] | None = None, **kwargs): + self.aliases = aliases or {} + super().__init__(*args, **kwargs) + + def to_internal_value(self, data): + normalized = str(data).strip().lower() + if normalized in self.aliases: + data = self.aliases[normalized] + return super().to_internal_value(data) + + class DashboardQuerySerializer(serializers.Serializer): corporation_scope = serializers.ChoiceField( choices=CorporationScope.choices, required=False @@ -51,8 +63,22 @@ class EquipmentQuerySerializer(serializers.Serializer): class ProductsQuerySerializer(serializers.Serializer): report_year = serializers.IntegerField(min_value=2000) - frequency = serializers.ChoiceField( - choices=(("quarterly", "quarterly"), ("annual", "annual")) + frequency = AliasChoiceField( + choices=( + ("quarterly", "quarterly"), + ("annual", "annual"), + ("semiannual", "semiannual"), + ("monthly", "monthly"), + ), + aliases={ + "semi-annual": "semiannual", + "half-year": "semiannual", + "half_year": "semiannual", + "halfyear": "semiannual", + "halfyearly": "semiannual", + "half_yearly": "semiannual", + "month": "monthly", + }, ) price_mode = serializers.ChoiceField( choices=(("actual", "actual"), ("fixed", "fixed")) diff --git a/src/apps/organization/scope_utils.py b/src/apps/organization/scope_utils.py index a52e7f0..aaf6ccd 100644 --- a/src/apps/organization/scope_utils.py +++ b/src/apps/organization/scope_utils.py @@ -33,6 +33,17 @@ SCOPE_SORT_ORDER: dict[str, int] = { "other": 90, } +REGISTRY_CATEGORY_KEYWORDS: dict[str, tuple[str, ...]] = { + "opk": ("ОПК",), + "goz": ("ГОЗ",), +} + +REGISTRY_CATEGORY_LABELS: dict[str, str] = { + "opk": "ОПК", + "goz": "ГОЗ", + "other": "Прочие", +} + def scopes_from_registry_names(registry_names: Iterable[str]) -> list[str]: normalized_names = [registry_name.casefold() for registry_name in registry_names] @@ -74,6 +85,29 @@ def get_corporation_scope_dictionary() -> list[dict[str, str | int]]: return items +def registry_categories_from_registry_names(registry_names: Iterable[str]) -> list[str]: + normalized_names = [registry_name.casefold() for registry_name in registry_names] + categories: list[str] = [] + + for category, keywords in REGISTRY_CATEGORY_KEYWORDS.items(): + if any( + keyword.casefold() in registry_name + for registry_name in normalized_names + for keyword in keywords + ): + categories.append(category) + + return categories or ["other"] + + +def primary_registry_category(registry_names: Iterable[str]) -> str: + return registry_categories_from_registry_names(registry_names)[0] + + +def registry_category_label(category_code: str) -> str: + return REGISTRY_CATEGORY_LABELS.get(category_code, "") + + def build_scope_query(scope_codes: Iterable[str]) -> Q: query = Q() for scope_code in scope_codes: @@ -93,3 +127,48 @@ def filter_queryset_by_scopes( if not scope_codes: return queryset.none() return queryset.filter(build_scope_query(scope_codes)).distinct() + + +def build_registry_category_query(category_codes: Iterable[str]) -> Q: + query = Q() + for category_code in category_codes: + if category_code == "opk": + query |= Q( + membership_periods__registry__name__icontains="ОПК", + membership_periods__ended_at__isnull=True, + ) + elif category_code == "goz": + query |= Q( + membership_periods__registry__name__icontains="ГОЗ", + membership_periods__ended_at__isnull=True, + ) + return query + + +def filter_queryset_by_registry_categories( + queryset: QuerySet, category_codes: Iterable[str] +) -> QuerySet: + category_codes = [ + code for code in category_codes if code in REGISTRY_CATEGORY_LABELS + ] + if not category_codes: + return queryset.none() + + direct_codes = [code for code in category_codes if code != "other"] + filtered_queryset = queryset.none() + + if direct_codes: + filtered_queryset = queryset.filter( + build_registry_category_query(direct_codes) + ).distinct() + + if "other" not in category_codes: + return filtered_queryset + + categorized_ids = queryset.filter( + build_registry_category_query(REGISTRY_CATEGORY_KEYWORDS) + ).values_list("id", flat=True) + other_queryset = queryset.exclude(id__in=categorized_ids) + if direct_codes: + return (filtered_queryset | other_queryset).distinct() + return other_queryset.distinct() diff --git a/src/apps/organization/serializers.py b/src/apps/organization/serializers.py index 90d3b17..1eadd5a 100644 --- a/src/apps/organization/serializers.py +++ b/src/apps/organization/serializers.py @@ -7,7 +7,11 @@ """ from apps.organization.models import Organization -from apps.organization.scope_utils import SCOPE_LABELS +from apps.organization.scope_utils import ( + SCOPE_LABELS, + primary_registry_category, + registry_category_label, +) from apps.registers.models import Register from rest_framework import serializers @@ -97,6 +101,8 @@ class OrganizationCatalogBaseSerializer(serializers.ModelSerializer): full_name = serializers.CharField(source="name", read_only=True) corporation_scope = serializers.SerializerMethodField() corporation_scope_label = serializers.SerializerMethodField() + registry_category = serializers.SerializerMethodField() + registry_category_label = serializers.SerializerMethodField() organization_type_label = serializers.CharField(read_only=True) active_registry_names = serializers.SerializerMethodField() @@ -108,6 +114,16 @@ class OrganizationCatalogBaseSerializer(serializers.ModelSerializer): def get_active_registry_names(obj: Organization) -> list[str]: return obj.get_active_registry_names() + @staticmethod + def get_registry_category(obj: Organization) -> str: + return primary_registry_category(obj.get_active_registry_names()) + + @staticmethod + def get_registry_category_label(obj: Organization) -> str: + return registry_category_label( + OrganizationCatalogBaseSerializer.get_registry_category(obj) + ) + @staticmethod def _primary_scope_or_default(obj: Organization) -> str: scopes = obj.get_corporation_scopes() @@ -141,6 +157,8 @@ class OrganizationCatalogListSerializer(OrganizationCatalogBaseSerializer): "full_name", "corporation_scope", "corporation_scope_label", + "registry_category", + "registry_category_label", "organization_type", "organization_type_label", "inn", @@ -188,6 +206,8 @@ class OrganizationCatalogDetailSerializer(OrganizationCatalogBaseSerializer): "full_name", "corporation_scope", "corporation_scope_label", + "registry_category", + "registry_category_label", "organization_type", "organization_type_label", "inn", diff --git a/tests/apps/core/test_management_commands.py b/tests/apps/core/test_management_commands.py index 9288da3..d1769a2 100644 --- a/tests/apps/core/test_management_commands.py +++ b/tests/apps/core/test_management_commands.py @@ -7,7 +7,7 @@ from django.core.management.base import CommandError from django.test import TestCase -class TestCommand(BaseAppCommand): +class SampleCommand(BaseAppCommand): """Тестовая команда для проверки BaseAppCommand.""" help = "Test command" diff --git a/tests/apps/organization/test_analytics_api.py b/tests/apps/organization/test_analytics_api.py index d0b8fa7..e6dff33 100644 --- a/tests/apps/organization/test_analytics_api.py +++ b/tests/apps/organization/test_analytics_api.py @@ -51,6 +51,7 @@ class OrganizationAnalyticsApiTest(APITestCase): organization=self.organization, report_year=2026, report_quarter=1, + avg_payroll_employees=995, payroll_fund=Decimal("1000000.00"), military_output_actual=Decimal("11000000.00"), civilian_output_actual=Decimal("7000000.00"), @@ -65,6 +66,7 @@ class OrganizationAnalyticsApiTest(APITestCase): organization=self.organization, report_year=2025, report_quarter=1, + avg_payroll_employees=970, payroll_fund=Decimal("900000.00"), military_output_actual=Decimal("9000000.00"), civilian_output_actual=Decimal("6000000.00"), @@ -151,6 +153,9 @@ class OrganizationAnalyticsApiTest(APITestCase): report_year=2026, equipment_category="Станочное оборудование", is_domestic=True, + commissioning_date=date(2026, 1, 15), + is_operational=False, + utilization_rate=Decimal("92.00"), physical_wear_percent=Decimal("28.40"), ) FormF6RecordFactory.create( @@ -218,6 +223,12 @@ class OrganizationAnalyticsApiTest(APITestCase): self.assertIn("ros", response.data["ratios"][0]) self.assertIn("roa", response.data["ratios"][0]) self.assertIn("roe", response.data["ratios"][0]) + self.assertIn("ebitda_margin", response.data["ratios"][0]) + self.assertEqual( + set(response.data["ratio_normatives"]), + {"ros", "roa", "roe", "ebitda_margin"}, + ) + self.assertTrue(all(value is not None for value in response.data["ratio_normatives"].values())) def test_personnel_contract(self): personnel_response = self.client.get( @@ -233,6 +244,11 @@ class OrganizationAnalyticsApiTest(APITestCase): ) self.assertEqual(len(personnel_response.data["history"]), 2) self.assertEqual(len(personnel_response.data["age_distribution"]), 3) + self.assertIn("average_age", personnel_response.data) + self.assertEqual( + personnel_response.data["headcount"]["avg_payroll_employees"], 995 + ) + self.assertEqual(personnel_response.data["headcount"]["payroll_fund"], 1000000) self.assertEqual( personnel_response.data["age_distribution"][0]["age_group"], "under_30", @@ -255,27 +271,38 @@ class OrganizationAnalyticsApiTest(APITestCase): "domestic_equipment", "imported_equipment", "physical_wear_percent", + "weighted_wear_percent", "utilization_rate", "avg_shift_work", "equipment_needed", + "average_age_years", + "commissioned_equipment", + "decommissioned_equipment", }, ) self.assertEqual(response.data["summary"]["total_equipment"], 187) self.assertEqual(response.data["summary"]["physical_wear_percent"], 32.0) + self.assertEqual(response.data["summary"]["weighted_wear_percent"], 32.0) self.assertEqual(response.data["summary"]["utilization_rate"], 0.92) + self.assertEqual(response.data["summary"]["commissioned_equipment"], 1) + self.assertEqual(response.data["summary"]["decommissioned_equipment"], 1) self.assertEqual( response.data["age_distribution"][0]["bucket"], "under_5_years" ) self.assertEqual(len(response.data["age_distribution"]), 5) self.assertGreaterEqual(len(response.data["categories"]), 1) + self.assertEqual(len(response.data["dynamics_series"]), 3) self.assertEqual( response.data["categories"][0], { "category": "Станочное оборудование", - "total_equipment": 1, - "domestic_equipment": 1, - "imported_equipment": 0, + "total_equipment": 54, + "domestic_equipment": 31, + "imported_equipment": 23, "physical_wear_percent": 28.4, + "weighted_wear_percent": 28.4, + "utilization_rate": 0.92, + "lease_share_itn_percent": None, }, ) @@ -293,8 +320,12 @@ class OrganizationAnalyticsApiTest(APITestCase): self.assertEqual(products_response.data["summary"]["civilian_output_amount"], 7000000) self.assertEqual(products_response.data["summary"]["hightech_output_amount"], 1500000) self.assertEqual(products_response.data["summary"]["rd_volume_amount"], 900000) + self.assertEqual(products_response.data["summary"]["shipped_goods_amount"], 18000000) self.assertEqual(len(products_response.data["production_series"]), 1) self.assertEqual(len(products_response.data["sales_series"]), 1) + self.assertEqual(len(products_response.data["rd_volume_series"]), 1) + self.assertEqual(len(products_response.data["shipped_goods_series"]), 1) + self.assertEqual(len(products_response.data["table"]), 1) first_production = products_response.data["production_series"][0] first_sales = products_response.data["sales_series"][0] @@ -302,6 +333,59 @@ class OrganizationAnalyticsApiTest(APITestCase): self.assertEqual(first_production["military_output_amount"], 11000000) self.assertEqual(first_sales["military_domestic_amount"], 9000000) self.assertEqual(first_sales["civilian_export_amount"], 2000000) + self.assertEqual( + products_response.data["rd_volume_series"][0]["rd_volume_amount"], 900000 + ) + self.assertEqual( + products_response.data["shipped_goods_series"][0]["shipped_goods_amount"], + 18000000, + ) + self.assertEqual( + products_response.data["table"][0]["shipped_goods_amount"], 18000000 + ) + + def test_products_supports_monthly_and_semiannual_frequency(self): + FormF1RecordFactory.create( + organization=self.organization, + report_year=2026, + report_quarter=2, + military_output_actual=Decimal("12000000.00"), + civilian_output_actual=Decimal("6000000.00"), + hightech_output_actual=Decimal("1800000.00"), + rd_volume_actual=Decimal("600000.00"), + military_domestic_actual=Decimal("9600000.00"), + military_export_actual=Decimal("2400000.00"), + civilian_domestic_actual=Decimal("4200000.00"), + civilian_export_actual=Decimal("1800000.00"), + ) + + semiannual_response = self.client.get( + f"/api/v1/organizations/{self.organization.id}/analytics/products/" + "?frequency=semiannual&price_mode=actual&report_year=2026" + ) + self.assertEqual(semiannual_response.status_code, status.HTTP_200_OK) + self.assertEqual(semiannual_response.data["frequency"], "semiannual") + self.assertEqual(len(semiannual_response.data["production_series"]), 1) + self.assertEqual( + semiannual_response.data["production_series"][0]["period"], "2026-H1" + ) + self.assertEqual( + semiannual_response.data["production_series"][0]["military_output_amount"], + 23000000, + ) + + monthly_response = self.client.get( + f"/api/v1/organizations/{self.organization.id}/analytics/products/" + "?frequency=monthly&price_mode=actual&report_year=2026" + ) + self.assertEqual(monthly_response.status_code, status.HTTP_200_OK) + self.assertEqual(monthly_response.data["frequency"], "monthly") + self.assertEqual(len(monthly_response.data["production_series"]), 6) + self.assertEqual(monthly_response.data["production_series"][0]["period"], "2026-01") + self.assertEqual( + monthly_response.data["production_series"][0]["military_output_amount"], + 3666666, + ) def test_forecast_contract(self): response = self.client.get( @@ -314,15 +398,24 @@ class OrganizationAnalyticsApiTest(APITestCase): self.assertEqual(response.data["scenario"], "base") self.assertEqual(response.data["horizon_years"], 3) self.assertEqual(response.data["base_year"], 2026) + self.assertIn("summary", response.data) self.assertEqual(len(response.data["forecast"]), 3) self.assertEqual(response.data["forecast"][0]["year"], 2027) self.assertIn("revenue_amount", response.data["forecast"][0]) self.assertIn("net_profit_amount", response.data["forecast"][0]) self.assertIn("margin_percent", response.data["forecast"][0]) + self.assertEqual(len(response.data["table"]), 3) + self.assertEqual(len(response.data["productivity_series"]), 3) + self.assertEqual(len(response.data["headcount_series"]), 3) + self.assertEqual(len(response.data["investment_series"]), 3) + self.assertEqual(len(response.data["rd_series"]), 3) self.assertGreaterEqual(len(response.data["risk_factors"]), 1) + self.assertGreaterEqual(len(response.data["opportunity_factors"]), 1) self.assertIn("code", response.data["risk_factors"][0]) self.assertIn("name", response.data["risk_factors"][0]) self.assertIn("impact_level", response.data["risk_factors"][0]) + self.assertIn("revenue_cagr_percent", response.data["summary"]) + self.assertIn("horizon_revenue_amount", response.data["summary"]) def test_risk_profile_contract(self): response = self.client.get( diff --git a/tests/apps/organization/test_api.py b/tests/apps/organization/test_api.py index d5e7c61..bab26b2 100644 --- a/tests/apps/organization/test_api.py +++ b/tests/apps/organization/test_api.py @@ -70,6 +70,8 @@ class OrganizationApiTest(APITestCase): response.data["results"][0]["corporation_scope_label"], "Организации ОПК", ) + self.assertEqual(response.data["results"][0]["registry_category"], "opk") + self.assertEqual(response.data["results"][0]["registry_category_label"], "ОПК") self.assertEqual(response.data["results"][0]["short_name"], "АО «Альфа»") def test_detail_includes_active_registries(self): @@ -111,6 +113,8 @@ class OrganizationApiTest(APITestCase): response.data["corporation_scope_label"], "Госкорпорация «Роскосмос»", ) + self.assertEqual(response.data["registry_category"], "other") + self.assertEqual(response.data["registry_category_label"], "Прочие") self.assertEqual( response.data["general_director"]["full_name"], "Иванов Иван Иванович", @@ -189,6 +193,53 @@ class OrganizationApiTest(APITestCase): self.assertEqual(len(response.data["results"]), 1) self.assertEqual(response.data["results"][0]["id"], str(rosatom_org.id)) + def test_registry_category_filters_support_snake_and_camel_case(self): + opk_org = OrganizationFactory.create(name="АО ОПК") + goz_org = OrganizationFactory.create(name="АО ГОЗ", in_275_fz_registry=True) + other_org = OrganizationFactory.create(name="АО Прочие") + + opk_register = Register.objects.create(name="Реестр предприятий ОПК") + goz_register = Register.objects.create(name="Реестр госкорпорации Росатом ГОЗ") + upload = RegisterUpload.objects.create( + registry=opk_register, + actual_date=date(2026, 4, 1), + file_name="registry-category.xlsx", + file_hash="registry-category-hash", + rows_count=2, + ) + goz_upload = RegisterUpload.objects.create( + registry=goz_register, + actual_date=date(2026, 4, 1), + file_name="registry-category-goz.xlsx", + file_hash="registry-category-goz-hash", + rows_count=1, + ) + + RegistryMembershipPeriod.objects.create( + registry=opk_register, + organization=opk_org, + started_at=date(2026, 4, 1), + started_by_upload=upload, + ) + RegistryMembershipPeriod.objects.create( + registry=goz_register, + organization=goz_org, + started_at=date(2026, 4, 1), + started_by_upload=goz_upload, + ) + + response = self.client.get("/api/v1/organizations/?registry_category=goz") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual([item["id"] for item in response.data["results"]], [str(goz_org.id)]) + + response = self.client.get("/api/v1/organizations/?registryCategory=opk") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual([item["id"] for item in response.data["results"]], [str(opk_org.id)]) + + response = self.client.get("/api/v1/organizations/?registry_category=other") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn(str(other_org.id), [item["id"] for item in response.data["results"]]) + class OrganizationDictionaryApiTest(APITestCase): """Tests for organization dictionaries endpoints.""" diff --git a/tests/apps/user/test_serializers.py b/tests/apps/user/test_serializers.py index 27d4280..fb69988 100644 --- a/tests/apps/user/test_serializers.py +++ b/tests/apps/user/test_serializers.py @@ -17,6 +17,7 @@ from apps.user.serializers import ( ) from django.contrib.auth import get_user_model from django.test import TestCase +from django.utils import timezone from faker import Faker from .factories import ProfileFactory, UserFactory @@ -179,7 +180,7 @@ class UserManagementSerializerTest(TestCase): self.assertEqual(serializer.data["last_name"], "") def test_metric_fields_are_derived_from_latest_job(self): - now = datetime(2026, 4, 14, 10, 0, 0) + now = timezone.make_aware(datetime(2026, 4, 14, 10, 0, 0)) latest_job = BackgroundJob.objects.create( task_id="admin-management-latest", task_name="apps.forms.process", diff --git a/tests/apps/user/test_views.py b/tests/apps/user/test_views.py index f2ebd41..abf8d7e 100644 --- a/tests/apps/user/test_views.py +++ b/tests/apps/user/test_views.py @@ -7,6 +7,7 @@ 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 django.utils import timezone from faker import Faker from rest_framework import status from rest_framework.test import APITestCase @@ -179,7 +180,7 @@ class AdminUsersManagementViewTest(APITestCase): last_name="Петров", ) - now = datetime(2026, 4, 14, 10, 0, 0) + now = timezone.make_aware(datetime(2026, 4, 14, 10, 0, 0)) completed_job = now + timedelta(minutes=3) failed_job = now - timedelta(minutes=10) BackgroundJob.objects.create(