From f0c4f501a6b069c2c4d0411c3992861d52ef98d3 Mon Sep 17 00:00:00 2001 From: Aleksandr Meshchriakov Date: Tue, 14 Apr 2026 10:59:06 +0200 Subject: [PATCH] test(organization): add analytics pass 4 contract tests --- docs/backend-endpoints-implementation-plan.md | 14 +- tests/apps/organization/test_analytics_api.py | 181 ++++++++++++++++-- 2 files changed, 169 insertions(+), 26 deletions(-) diff --git a/docs/backend-endpoints-implementation-plan.md b/docs/backend-endpoints-implementation-plan.md index e5c0e53..c7d8b80 100644 --- a/docs/backend-endpoints-implementation-plan.md +++ b/docs/backend-endpoints-implementation-plan.md @@ -22,7 +22,7 @@ - [x] **Pass 1 — Discovery & контрактный каркас:** подготовка сериализаторов контрактов, уточнение форматов, матрица соответствий. - [x] **Pass 2 — Пользователи и аутентификация:** доработка `users/me` + user-management. - [x] **Pass 3 — Формы:** выравнивание upload F-2…F-6. -- [ ] **Pass 4 — Аналитика:** financial-summary / economics / personnel / equipment / products / risk / forecast. +- [x] **Pass 4 — Аналитика:** financial-summary / economics / personnel / equipment / products / risk / forecast. - [ ] **Pass 5 — Внешние контуры:** industrial/prosecutor/procurements/arbitration/security registries. - [ ] **Pass 6 — Финализация:** OpenAPI + массовое тестирование + smoke. @@ -45,6 +45,10 @@ - выровнен request/response контракт upload-эндпоинтов (sync/async, report-период, `upload_id`, `job_id`). - добавлена единая схема ошибок валидации multipart. - добавлены contract tests на upload endpoints F-2…F-6. +- **Pass 4 — Аналитика (2026-04-14): завершён** + - добавлены contract checks для всех analytics endpoint’ов: + - `financial-summary`, `economics`, `personnel`, `equipment`, `products`, `forecast`, `risk-profile`, `dashboard`. + - дополнена проверка query-валидации для invalid `economics`-запроса. --- @@ -235,10 +239,10 @@ - [x] Добавить общий error serializer для валидации multipart. ### Pass 4. Аналитика -- [ ] Финализировать `financial-summary` и добавить расчёты deltas/period. -- [ ] Вынести/довести `economics`, `personnel`, `equipment`, `products`. -- [ ] Проверить/документировать `risk-profile`, `forecast`. -- [ ] Добавить dashboard фильтрацию и стабильные `cluster` метрики. +- [x] Финализировать `financial-summary` и добавить расчёты deltas/period. (2026-04-14) +- [x] Вынести/довести `economics`, `personnel`, `equipment`, `products`. (2026-04-14) +- [x] Проверить/документировать `risk-profile`, `forecast`. (2026-04-14) +- [x] Добавить dashboard фильтрацию и стабильные `cluster` метрики. (2026-04-14) ### Pass 5. Внешние данные - [ ] Довести внешние реестры к единообразным фильтрам/ответам. diff --git a/tests/apps/organization/test_analytics_api.py b/tests/apps/organization/test_analytics_api.py index 663f51a..d0b8fa7 100644 --- a/tests/apps/organization/test_analytics_api.py +++ b/tests/apps/organization/test_analytics_api.py @@ -170,7 +170,7 @@ class OrganizationAnalyticsApiTest(APITestCase): avg_shift_work=Decimal("1.80"), ) - def test_financial_summary_endpoint(self): + def test_financial_summary_contract(self): response = self.client.get( f"/api/v1/organizations/{self.organization.id}/analytics/financial-summary/" "?report_year=2026&report_quarter=1" @@ -181,47 +181,160 @@ class OrganizationAnalyticsApiTest(APITestCase): self.assertEqual(response.data["revenue"]["previous_amount"], 760000000) self.assertEqual(response.data["taxes_paid"]["amount"], 18900000) self.assertEqual(response.data["insurance_contributions"]["amount"], 302000) + self.assertEqual(response.data["organization_id"], str(self.organization.id)) + self.assertEqual(response.data["report_period"], {"year": 2026, "quarter": 1}) + self.assertEqual(set(response.data["revenue"]), {"amount", "previous_amount", "delta_percent"}) + self.assertEqual(set(response.data["net_profit"]), {"amount", "previous_amount", "delta_percent"}) + self.assertEqual(set(response.data["taxes_paid"]), {"amount", "previous_amount", "delta_percent"}) + self.assertEqual( + set(response.data["insurance_contributions"]), + {"amount", "previous_amount", "delta_percent"}, + ) - def test_personnel_and_equipment_endpoints(self): + def test_economics_contract(self): + 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) + self.assertEqual(response.data["organization_id"], str(self.organization.id)) + self.assertEqual(response.data["group"], "efficiency") + self.assertEqual(response.data["periods"], [2025, 2026]) + self.assertEqual( + response.data["kpis"].keys(), + {"revenue", "ebitda", "net_profit"}, + ) + self.assertIn("series", response.data) + for series in response.data["series"]: + self.assertIn("metric", series) + self.assertIn("unit", series) + self.assertIn("points", series) + self.assertEqual(series["unit"], "rub_thousands") + self.assertEqual(len(series["points"]), 2) + self.assertEqual(set(series["points"][0]), {"period", "value"}) + + self.assertEqual(len(response.data["ratios"]), 2) + self.assertIn("ros", response.data["ratios"][0]) + self.assertIn("roa", response.data["ratios"][0]) + self.assertIn("roe", response.data["ratios"][0]) + + def test_personnel_contract(self): personnel_response = self.client.get( f"/api/v1/organizations/{self.organization.id}/analytics/personnel/" "?report_year=2026&history_years=2" ) - equipment_response = self.client.get( - f"/api/v1/organizations/{self.organization.id}/analytics/equipment/" - "?report_year=2026" - ) - self.assertEqual(personnel_response.status_code, status.HTTP_200_OK) + self.assertEqual(personnel_response.data["organization_id"], str(self.organization.id)) + self.assertEqual(personnel_response.data["report_year"], 2026) self.assertEqual( personnel_response.data["headcount"]["average_employees"], 1050, ) self.assertEqual(len(personnel_response.data["history"]), 2) - - self.assertEqual(equipment_response.status_code, status.HTTP_200_OK) - self.assertEqual(equipment_response.data["summary"]["total_equipment"], 187) + self.assertEqual(len(personnel_response.data["age_distribution"]), 3) self.assertEqual( - equipment_response.data["categories"][0]["category"], - "Станочное оборудование", + personnel_response.data["age_distribution"][0]["age_group"], + "under_30", + ) + self.assertIn("employees_count", personnel_response.data["age_distribution"][0]) + + def test_equipment_contract(self): + response = self.client.get( + f"/api/v1/organizations/{self.organization.id}/analytics/equipment/" + "?report_year=2026" ) - def test_products_and_risk_profile_endpoints(self): + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["organization_id"], str(self.organization.id)) + self.assertEqual(response.data["report_year"], 2026) + self.assertEqual( + set(response.data["summary"]), + { + "total_equipment", + "domestic_equipment", + "imported_equipment", + "physical_wear_percent", + "utilization_rate", + "avg_shift_work", + "equipment_needed", + }, + ) + self.assertEqual(response.data["summary"]["total_equipment"], 187) + self.assertEqual(response.data["summary"]["physical_wear_percent"], 32.0) + self.assertEqual(response.data["summary"]["utilization_rate"], 0.92) + 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( + response.data["categories"][0], + { + "category": "Станочное оборудование", + "total_equipment": 1, + "domestic_equipment": 1, + "imported_equipment": 0, + "physical_wear_percent": 28.4, + }, + ) + + def test_products_contract(self): products_response = self.client.get( f"/api/v1/organizations/{self.organization.id}/analytics/products/" "?frequency=quarterly&price_mode=actual&report_year=2026" ) - risk_response = self.client.get( + self.assertEqual(products_response.status_code, status.HTTP_200_OK) + self.assertEqual(products_response.data["organization_id"], str(self.organization.id)) + self.assertEqual(products_response.data["report_year"], 2026) + self.assertEqual(products_response.data["frequency"], "quarterly") + self.assertEqual(products_response.data["price_mode"], "actual") + self.assertEqual(products_response.data["summary"]["military_output_amount"], 11000000) + 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(len(products_response.data["production_series"]), 1) + self.assertEqual(len(products_response.data["sales_series"]), 1) + + first_production = products_response.data["production_series"][0] + first_sales = products_response.data["sales_series"][0] + self.assertEqual(first_production["period"], "2026-Q1") + self.assertEqual(first_production["military_output_amount"], 11000000) + self.assertEqual(first_sales["military_domestic_amount"], 9000000) + self.assertEqual(first_sales["civilian_export_amount"], 2000000) + + def test_forecast_contract(self): + response = self.client.get( + f"/api/v1/organizations/{self.organization.id}/analytics/forecast/" + "?scenario=base&horizon_years=3" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["organization_id"], str(self.organization.id)) + self.assertEqual(response.data["scenario"], "base") + self.assertEqual(response.data["horizon_years"], 3) + self.assertEqual(response.data["base_year"], 2026) + 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.assertGreaterEqual(len(response.data["risk_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]) + + def test_risk_profile_contract(self): + response = self.client.get( f"/api/v1/organizations/{self.organization.id}/risk-profile/" ) - self.assertEqual(products_response.status_code, status.HTTP_200_OK) - self.assertEqual( - products_response.data["summary"]["military_output_amount"], - 11000000, - ) - self.assertEqual(risk_response.status_code, status.HTTP_200_OK) - self.assertEqual(risk_response.data["risk_level"], "low") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["organization_id"], str(self.organization.id)) + self.assertTrue(response.data["financial_reports_available"]) + self.assertTrue(response.data["tax_reports_available"]) + self.assertIn("risk_level", response.data) + self.assertIn("updated_at", response.data) def test_dashboard_endpoint(self): response = self.client.get( @@ -230,6 +343,32 @@ class OrganizationAnalyticsApiTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["corporation_scope"], "rosatom") + self.assertIn("distribution_by_cluster", response.data) + self.assertIn("executors_by_cluster", response.data) + self.assertIn("headcount_growth_by_cluster", response.data) + self.assertIn("bankruptcy_free_share_by_cluster", response.data) self.assertEqual( response.data["distribution_by_cluster"][0]["cluster"], "radioelectronics" ) + self.assertEqual( + response.data["executors_by_cluster"][0]["cluster"], "radioelectronics" + ) + self.assertIn( + "executors_count", + response.data["executors_by_cluster"][0], + ) + self.assertIn("growth_percent", response.data["headcount_growth_by_cluster"][0]) + + def test_analytics_query_validation(self): + response = self.client.get( + f"/api/v1/organizations/{self.organization.id}/analytics/economics/" + "?group=efficiency&from_year=2026&to_year=2025" + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(response.data["success"]) + self.assertEqual(response.data["errors"][0]["code"], "validation_error") + self.assertEqual( + response.data["errors"][0]["message"], + "Validation failed", + )