fix: correct organization analytics aggregation
All checks were successful
All checks were successful
This commit is contained in:
@@ -60,6 +60,12 @@ def _share_percent(numerator, denominator) -> float:
|
|||||||
return round(float((_dec(numerator) / denominator_value) * Decimal("100")), 1)
|
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:
|
def _delta_percent(current, previous) -> float:
|
||||||
current_value = _dec(current)
|
current_value = _dec(current)
|
||||||
previous_value = _dec(previous)
|
previous_value = _dec(previous)
|
||||||
@@ -425,6 +431,7 @@ class OrganizationAnalyticsService:
|
|||||||
"series": [],
|
"series": [],
|
||||||
"ratios": [],
|
"ratios": [],
|
||||||
"ratio_normatives": ECONOMICS_RATIO_NORMATIVES,
|
"ratio_normatives": ECONOMICS_RATIO_NORMATIVES,
|
||||||
|
"ratios_report_period": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
metric_units = cls._economics_metric_units()
|
metric_units = cls._economics_metric_units()
|
||||||
@@ -442,6 +449,24 @@ class OrganizationAnalyticsService:
|
|||||||
f1_by_year.get(period),
|
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 {
|
return {
|
||||||
"organization_id": str(organization.id),
|
"organization_id": str(organization.id),
|
||||||
"group": group,
|
"group": group,
|
||||||
@@ -455,6 +480,15 @@ class OrganizationAnalyticsService:
|
|||||||
}
|
}
|
||||||
for year in periods
|
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,
|
"data_available": True,
|
||||||
"message": None,
|
"message": None,
|
||||||
"kpis": {
|
"kpis": {
|
||||||
@@ -504,8 +538,8 @@ class OrganizationAnalyticsService:
|
|||||||
f4_by_year.get(report_year),
|
f4_by_year.get(report_year),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
"roa": _ratio(getattr(f4_by_year.get(report_year), "roa", ZERO)),
|
"roa": profitability_ratio(report_year, "total_assets"),
|
||||||
"roe": _ratio(getattr(f4_by_year.get(report_year), "roe", ZERO)),
|
"roe": profitability_ratio(report_year, "total_equity"),
|
||||||
"ebitda_margin": _share_percent(
|
"ebitda_margin": _share_percent(
|
||||||
cls._economics_metric_value(
|
cls._economics_metric_value(
|
||||||
"ebitda",
|
"ebitda",
|
||||||
@@ -778,6 +812,11 @@ class OrganizationAnalyticsService:
|
|||||||
f"{bucket_field}_lease_share_itn_percent",
|
f"{bucket_field}_lease_share_itn_percent",
|
||||||
bucket_field,
|
bucket_field,
|
||||||
)
|
)
|
||||||
|
weighted_load_factor = weighted_metric(
|
||||||
|
f6_records,
|
||||||
|
f"{bucket_field}_weighted_load_factor",
|
||||||
|
bucket_field,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
imported_equipment = None
|
imported_equipment = None
|
||||||
utilization_rate = (
|
utilization_rate = (
|
||||||
@@ -786,6 +825,7 @@ class OrganizationAnalyticsService:
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
lease_share = None
|
lease_share = None
|
||||||
|
weighted_load_factor = None
|
||||||
item.update(
|
item.update(
|
||||||
{
|
{
|
||||||
"imported_equipment": imported_equipment,
|
"imported_equipment": imported_equipment,
|
||||||
@@ -798,6 +838,11 @@ class OrganizationAnalyticsService:
|
|||||||
_ratio(lease_share) if lease_share is not None else None
|
_ratio(lease_share) if lease_share is not None else None
|
||||||
),
|
),
|
||||||
"weighted_wear_percent": 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])
|
metrics[key] += _dec(row_metrics[key])
|
||||||
return {"period": period, "metrics": metrics}
|
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
|
@classmethod
|
||||||
def _build_product_frequency_rows(
|
def _build_product_frequency_rows(
|
||||||
cls, records: list[FormF1Record], *, suffix: str, frequency: str
|
cls, records: list[FormF1Record], *, suffix: str, frequency: str
|
||||||
) -> list[dict[str, object]]:
|
) -> list[dict[str, object]]:
|
||||||
base_rows = [
|
report_year = records[0].report_year
|
||||||
{
|
base_rows = cls._build_product_base_rows(records, suffix=suffix)
|
||||||
"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":
|
if frequency == "monthly":
|
||||||
monthly_rows: list[dict[str, object]] = []
|
return cls._build_monthly_product_rows(base_rows, report_year)
|
||||||
month_map = {1: (1, 2, 3), 2: (4, 5, 6), 3: (7, 8, 9), 4: (10, 11, 12)}
|
if frequency == "quarterly":
|
||||||
for row in base_rows:
|
return cls._build_quarterly_product_rows(base_rows, report_year)
|
||||||
quarter = row["quarter"]
|
if frequency == "semiannual":
|
||||||
if quarter is None:
|
return cls._build_semiannual_product_rows(base_rows, report_year)
|
||||||
monthly_rows.append(row)
|
if frequency == "annual":
|
||||||
continue
|
return [cls._aggregate_product_metrics(base_rows, str(report_year))]
|
||||||
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
|
return base_rows
|
||||||
|
|
||||||
@@ -1160,9 +1265,7 @@ class OrganizationAnalyticsService:
|
|||||||
if not records:
|
if not records:
|
||||||
raise NotFoundError(message="Products data is not available")
|
raise NotFoundError(message="Products data is not available")
|
||||||
|
|
||||||
records.sort(
|
records.sort(key=lambda record: (_period_rank(record), record.created_at))
|
||||||
key=lambda record: (_period_rank(record.report_quarter), record.created_at)
|
|
||||||
)
|
|
||||||
suffix = "actual" if price_mode == "actual" else "fixed"
|
suffix = "actual" if price_mode == "actual" else "fixed"
|
||||||
frequency_rows = cls._build_product_frequency_rows(
|
frequency_rows = cls._build_product_frequency_rows(
|
||||||
records, suffix=suffix, frequency=frequency
|
records, suffix=suffix, frequency=frequency
|
||||||
@@ -1175,6 +1278,7 @@ class OrganizationAnalyticsService:
|
|||||||
"report_year": report_year,
|
"report_year": report_year,
|
||||||
"frequency": frequency,
|
"frequency": frequency,
|
||||||
"price_mode": price_mode,
|
"price_mode": price_mode,
|
||||||
|
"summary_period": current["period"],
|
||||||
"summary": {
|
"summary": {
|
||||||
"military_output_amount": _amount(
|
"military_output_amount": _amount(
|
||||||
current_metrics["military_output_amount"]
|
current_metrics["military_output_amount"]
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ class EconomicsMetricSeriesSerializer(serializers.Serializer):
|
|||||||
class EconomicsRatioSerializer(serializers.Serializer):
|
class EconomicsRatioSerializer(serializers.Serializer):
|
||||||
period = serializers.IntegerField()
|
period = serializers.IntegerField()
|
||||||
ros = serializers.FloatField()
|
ros = serializers.FloatField()
|
||||||
roa = serializers.FloatField()
|
roa = serializers.FloatField(allow_null=True)
|
||||||
roe = serializers.FloatField()
|
roe = serializers.FloatField(allow_null=True)
|
||||||
ebitda_margin = serializers.FloatField()
|
ebitda_margin = serializers.FloatField()
|
||||||
|
|
||||||
|
|
||||||
@@ -95,11 +95,18 @@ class EconomicsReportPeriodSerializer(serializers.Serializer):
|
|||||||
report_half_year = serializers.IntegerField(allow_null=True)
|
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):
|
class EconomicsResponseSerializer(serializers.Serializer):
|
||||||
organization_id = serializers.UUIDField()
|
organization_id = serializers.UUIDField()
|
||||||
group = serializers.CharField()
|
group = serializers.CharField()
|
||||||
periods = serializers.ListField(child=serializers.IntegerField())
|
periods = serializers.ListField(child=serializers.IntegerField())
|
||||||
report_periods = EconomicsReportPeriodSerializer(many=True)
|
report_periods = EconomicsReportPeriodSerializer(many=True)
|
||||||
|
ratios_report_period = EconomicsRatiosReportPeriodSerializer(allow_null=True)
|
||||||
data_available = serializers.BooleanField()
|
data_available = serializers.BooleanField()
|
||||||
message = serializers.CharField(allow_null=True)
|
message = serializers.CharField(allow_null=True)
|
||||||
kpis = EconomicsKpisSerializer()
|
kpis = EconomicsKpisSerializer()
|
||||||
@@ -164,6 +171,7 @@ class EquipmentAgeDistributionSerializer(serializers.Serializer):
|
|||||||
utilization_rate = serializers.FloatField(allow_null=True)
|
utilization_rate = serializers.FloatField(allow_null=True)
|
||||||
lease_share_itn_percent = serializers.FloatField(allow_null=True)
|
lease_share_itn_percent = serializers.FloatField(allow_null=True)
|
||||||
weighted_wear_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):
|
class EquipmentCategorySerializer(serializers.Serializer):
|
||||||
@@ -244,6 +252,7 @@ class ProductsResponseSerializer(serializers.Serializer):
|
|||||||
report_year = serializers.IntegerField()
|
report_year = serializers.IntegerField()
|
||||||
frequency = serializers.CharField()
|
frequency = serializers.CharField()
|
||||||
price_mode = serializers.CharField()
|
price_mode = serializers.CharField()
|
||||||
|
summary_period = serializers.CharField()
|
||||||
summary = ProductsSummarySerializer()
|
summary = ProductsSummarySerializer()
|
||||||
production_series = ProductsProductionSeriesSerializer(many=True)
|
production_series = ProductsProductionSeriesSerializer(many=True)
|
||||||
sales_series = ProductsSalesSeriesSerializer(many=True)
|
sales_series = ProductsSalesSeriesSerializer(many=True)
|
||||||
|
|||||||
@@ -176,22 +176,27 @@ class OrganizationAnalyticsApiTest(APITestCase):
|
|||||||
age_under_5_imported=35,
|
age_under_5_imported=35,
|
||||||
age_under_5_utilization_rate=Decimal("92.00"),
|
age_under_5_utilization_rate=Decimal("92.00"),
|
||||||
age_under_5_lease_share_itn_percent=Decimal("20.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=41,
|
||||||
age_5_10_imported=21,
|
age_5_10_imported=21,
|
||||||
age_5_10_utilization_rate=Decimal("92.00"),
|
age_5_10_utilization_rate=Decimal("92.00"),
|
||||||
age_5_10_lease_share_itn_percent=Decimal("20.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=33,
|
||||||
age_10_15_imported=17,
|
age_10_15_imported=17,
|
||||||
age_10_15_utilization_rate=Decimal("92.00"),
|
age_10_15_utilization_rate=Decimal("92.00"),
|
||||||
age_10_15_lease_share_itn_percent=Decimal("20.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=22,
|
||||||
age_15_20_imported=12,
|
age_15_20_imported=12,
|
||||||
age_15_20_utilization_rate=Decimal("92.00"),
|
age_15_20_utilization_rate=Decimal("92.00"),
|
||||||
age_15_20_lease_share_itn_percent=Decimal("20.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=21,
|
||||||
age_over_20_imported=11,
|
age_over_20_imported=11,
|
||||||
age_over_20_utilization_rate=Decimal("92.00"),
|
age_over_20_utilization_rate=Decimal("92.00"),
|
||||||
age_over_20_lease_share_itn_percent=Decimal("20.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"),
|
physical_wear_percent=Decimal("28.40"),
|
||||||
utilization_rate=Decimal("92.00"),
|
utilization_rate=Decimal("92.00"),
|
||||||
avg_shift_work=Decimal("1.80"),
|
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):
|
def test_economics_respects_requested_range_when_it_is_empty(self):
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
f"/api/v1/organizations/{self.organization.id}/analytics/economics/"
|
f"/api/v1/organizations/{self.organization.id}/analytics/economics/"
|
||||||
@@ -480,8 +540,13 @@ class OrganizationAnalyticsApiTest(APITestCase):
|
|||||||
"utilization_rate",
|
"utilization_rate",
|
||||||
"lease_share_itn_percent",
|
"lease_share_itn_percent",
|
||||||
"weighted_wear_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.assertGreaterEqual(len(response.data["categories"]), 1)
|
||||||
self.assertEqual(len(response.data["dynamics_series"]), 3)
|
self.assertEqual(len(response.data["dynamics_series"]), 3)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -510,6 +575,7 @@ class OrganizationAnalyticsApiTest(APITestCase):
|
|||||||
self.assertEqual(products_response.data["report_year"], 2026)
|
self.assertEqual(products_response.data["report_year"], 2026)
|
||||||
self.assertEqual(products_response.data["frequency"], "quarterly")
|
self.assertEqual(products_response.data["frequency"], "quarterly")
|
||||||
self.assertEqual(products_response.data["price_mode"], "actual")
|
self.assertEqual(products_response.data["price_mode"], "actual")
|
||||||
|
self.assertEqual(products_response.data["summary_period"], "2026-Q1")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
products_response.data["summary"]["military_output_amount"], 11000000
|
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.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(semiannual_response.data["frequency"], "semiannual")
|
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(len(semiannual_response.data["production_series"]), 1)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
semiannual_response.data["production_series"][0]["period"], "2026-H1"
|
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.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(monthly_response.data["frequency"], "monthly")
|
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(len(monthly_response.data["production_series"]), 6)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
monthly_response.data["production_series"][0]["period"], "2026-01"
|
monthly_response.data["production_series"][0]["period"], "2026-01"
|
||||||
@@ -591,6 +659,104 @@ class OrganizationAnalyticsApiTest(APITestCase):
|
|||||||
3666666,
|
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):
|
def test_forecast_contract(self):
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
f"/api/v1/organizations/{self.organization.id}/analytics/forecast/"
|
f"/api/v1/organizations/{self.organization.id}/analytics/forecast/"
|
||||||
|
|||||||
Reference in New Issue
Block a user