diff --git a/input/demo-periods/Ф-3_2022.xlsx b/input/demo-periods/Ф-3_2022.xlsx index 51d935b..6d20907 100644 Binary files a/input/demo-periods/Ф-3_2022.xlsx and b/input/demo-periods/Ф-3_2022.xlsx differ diff --git a/input/demo-periods/Ф-3_2023.xlsx b/input/demo-periods/Ф-3_2023.xlsx index 9e96cfc..5f6e750 100644 Binary files a/input/demo-periods/Ф-3_2023.xlsx and b/input/demo-periods/Ф-3_2023.xlsx differ diff --git a/input/demo-periods/Ф-3_2024.xlsx b/input/demo-periods/Ф-3_2024.xlsx index be590ea..752b9cc 100644 Binary files a/input/demo-periods/Ф-3_2024.xlsx and b/input/demo-periods/Ф-3_2024.xlsx differ diff --git a/input/demo-periods/Ф-3_2025.xlsx b/input/demo-periods/Ф-3_2025.xlsx index 46bc5f8..297365f 100644 Binary files a/input/demo-periods/Ф-3_2025.xlsx and b/input/demo-periods/Ф-3_2025.xlsx differ diff --git a/input/demo-periods/Ф-3_2026.xlsx b/input/demo-periods/Ф-3_2026.xlsx new file mode 100644 index 0000000..b4489d1 Binary files /dev/null and b/input/demo-periods/Ф-3_2026.xlsx differ diff --git a/input/demo-periods/Ф-4_2024-H2.xlsx b/input/demo-periods/Ф-4_2024-H2.xlsx index e3faab5..4de29b0 100644 Binary files a/input/demo-periods/Ф-4_2024-H2.xlsx and b/input/demo-periods/Ф-4_2024-H2.xlsx differ diff --git a/input/demo-periods/Ф-4_2025-H1.xlsx b/input/demo-periods/Ф-4_2025-H1.xlsx index e5ec366..a791880 100644 Binary files a/input/demo-periods/Ф-4_2025-H1.xlsx and b/input/demo-periods/Ф-4_2025-H1.xlsx differ diff --git a/input/demo-periods/Ф-4_2025-H2.xlsx b/input/demo-periods/Ф-4_2025-H2.xlsx index 32ac433..ed96971 100644 Binary files a/input/demo-periods/Ф-4_2025-H2.xlsx and b/input/demo-periods/Ф-4_2025-H2.xlsx differ diff --git a/input/demo-periods/Ф-4_2026-H1.xlsx b/input/demo-periods/Ф-4_2026-H1.xlsx index 0004938..8d9a3c9 100644 Binary files a/input/demo-periods/Ф-4_2026-H1.xlsx and b/input/demo-periods/Ф-4_2026-H1.xlsx differ diff --git a/input/demo-periods/Ф-6_2022.xlsx b/input/demo-periods/Ф-6_2022.xlsx index dfb6910..e987131 100644 Binary files a/input/demo-periods/Ф-6_2022.xlsx and b/input/demo-periods/Ф-6_2022.xlsx differ diff --git a/input/demo-periods/Ф-6_2023.xlsx b/input/demo-periods/Ф-6_2023.xlsx index b204cfd..ee2f6f7 100644 Binary files a/input/demo-periods/Ф-6_2023.xlsx and b/input/demo-periods/Ф-6_2023.xlsx differ diff --git a/input/demo-periods/Ф-6_2024.xlsx b/input/demo-periods/Ф-6_2024.xlsx index 96b2c3e..f5d0529 100644 Binary files a/input/demo-periods/Ф-6_2024.xlsx and b/input/demo-periods/Ф-6_2024.xlsx differ diff --git a/input/demo-periods/Ф-6_2025.xlsx b/input/demo-periods/Ф-6_2025.xlsx index 60f7822..bc655bb 100644 Binary files a/input/demo-periods/Ф-6_2025.xlsx and b/input/demo-periods/Ф-6_2025.xlsx differ diff --git a/input/demo-periods/Ф-6_2026.xlsx b/input/demo-periods/Ф-6_2026.xlsx new file mode 100644 index 0000000..3b06e51 Binary files /dev/null and b/input/demo-periods/Ф-6_2026.xlsx differ diff --git a/src/apps/core/excel.py b/src/apps/core/excel.py index c725353..452d8d6 100644 --- a/src/apps/core/excel.py +++ b/src/apps/core/excel.py @@ -290,6 +290,54 @@ class BaseExcelParser(ABC, Generic[T]): """Возвращает следующий номер batch_id.""" raise NotImplementedError + def validate_workbook(self) -> None: + """Validate the loaded workbook schema before any records are created.""" + + @staticmethod + def _normalize_header(value: Any) -> str: + """Normalize whitespace and case for stable Excel header comparisons.""" + if value is None: + return "" + return " ".join(str(value).replace("\xa0", " ").split()).casefold() + + def _validate_mapped_headers( + self, + *, + header_rows: tuple[int, ...] = (1,), + ) -> None: + """Ensure each mapped column still has the expected semantic header.""" + errors: list[FieldError] = [] + for mapping in self._column_mappings or []: + actual = " ".join( + filter( + None, + ( + self._normalize_header( + self._get_cell_value(row, mapping.excel_column) + ) + for row in header_rows + ), + ) + ) + expected = self._normalize_header(mapping.excel_header) + if expected and expected not in actual: + errors.append( + FieldError( + field=mapping.model_field, + message=( + f"Ожидался заголовок «{mapping.excel_header}» " + f"в колонке {mapping.excel_column + 1}" + ), + value=actual or None, + ) + ) + + if errors: + raise ExcelValidationError( + "Схема Excel-файла не соответствует ожидаемому шаблону", + errors=errors, + ) + def parse(self, file: UploadedFile | BytesIO) -> ParseResult: """ Парсит Excel файл и сохраняет данные в БД. @@ -306,6 +354,7 @@ class BaseExcelParser(ABC, Generic[T]): try: self._load_workbook(file) self._column_mappings = self.get_column_mappings() + self.validate_workbook() for row_num in range(self.DATA_START_ROW, self._sheet.max_row + 1): row_data = self._parse_row(row_num) @@ -346,6 +395,8 @@ class BaseExcelParser(ABC, Generic[T]): ) result.skipped_count += 1 + except ExcelValidationError: + raise except Exception as e: logger.exception("Ошибка парсинга Excel файла") raise ExcelParseError(f"Ошибка парсинга файла: {e}") from e diff --git a/src/apps/core/management/commands/generate_test_reports.py b/src/apps/core/management/commands/generate_test_reports.py index d5886b8..1d928d5 100644 --- a/src/apps/core/management/commands/generate_test_reports.py +++ b/src/apps/core/management/commands/generate_test_reports.py @@ -1021,6 +1021,32 @@ class Command(BaseAppCommand): age_over_20 = max( 1, total_equipment - age_under_5 - age_5_10 - age_10_15 - age_15_20 ) + staff_buckets = [ + max(1, int(profile.staff_base * Decimal("0.04"))), + max(1, int(profile.staff_base * Decimal("0.18"))), + max(1, int(profile.staff_base * Decimal("0.28"))), + max(1, int(profile.staff_base * Decimal("0.26"))), + max(1, int(profile.staff_base * Decimal("0.17"))), + ] + staff_buckets.append(max(0, profile.staff_base - sum(staff_buckets))) + average_employee_age = self._rate( + sum( + Decimal(count) * midpoint + for count, midpoint in zip( + staff_buckets, + ( + Decimal("19"), + Decimal("25"), + Decimal("35"), + Decimal("45"), + Decimal("55"), + Decimal("65"), + ), + strict=True, + ) + ) + / Decimal(profile.staff_base) + ) return { "avg_employees": Decimal(profile.staff_base), "production_workers": Decimal(int(profile.staff_base * Decimal("0.64"))), @@ -1031,6 +1057,7 @@ class Command(BaseAppCommand): "total_equipment": total_equipment, "domestic_equipment": domestic_equipment, "imported_equipment": imported_equipment, + "machine_tools_and_equipment": total_equipment, "equipment_age_under_5": age_under_5, "equipment_age_5_10": age_5_10, "equipment_age_10_15": age_10_15, @@ -1043,6 +1070,13 @@ class Command(BaseAppCommand): ), "equipment_needed": max(0, total_equipment // 9), "workers_needed": max(0, profile.staff_base // 22), + "average_employee_age": average_employee_age, + "employees_under_20": staff_buckets[0], + "employees_20_29": staff_buckets[1], + "employees_30_39": staff_buckets[2], + "employees_40_49": staff_buckets[3], + "employees_50_59": staff_buckets[4], + "employees_over_60": staff_buckets[5], } def _build_f4_payload( @@ -1165,13 +1199,32 @@ class Command(BaseAppCommand): age_over_20 = max( 1, total_equipment - age_under_5 - age_5_10 - age_10_15 - age_15_20 ) + age_counts = [ + age_under_5, + age_5_10, + age_10_15, + age_15_20, + age_over_20, + ] + imported_by_age: list[int] = [] + cumulative_equipment = 0 + allocated_imported = 0 + for age_count in age_counts: + cumulative_equipment += age_count + cumulative_imported = ( + imported_equipment * cumulative_equipment // total_equipment + ) + imported_by_age.append(cumulative_imported - allocated_imported) + allocated_imported = cumulative_imported cnc_total = min(total_equipment, max(4, int(total_equipment * Decimal("0.31")))) - return { + payload = { "row_code": f"{100 + (profile.index % 60):03d}", "category": profile.f6_category, "total_equipment": total_equipment, "domestic_equipment": domestic_equipment, "imported_equipment": imported_equipment, + "commissioned_equipment": max(1, total_equipment // 12), + "decommissioned_equipment": max(0, total_equipment // 20), "age_under_5": age_under_5, "age_5_10": age_5_10, "age_10_15": age_10_15, @@ -1190,7 +1243,28 @@ class Command(BaseAppCommand): "physical_wear_percent": wear_percent, "workplaces_without_equipment": max(0, profile.index % 5), "equipment_to_replace": max(0, age_over_20 // 2), + "notes": "Согласованный набор синтетических демо-данных", } + for prefix, imported_count in zip( + ( + "age_under_5", + "age_5_10", + "age_10_15", + "age_15_20", + "age_over_20", + ), + imported_by_age, + strict=True, + ): + payload[f"{prefix}_imported"] = imported_count + payload[f"{prefix}_utilization_rate"] = utilization_rate + payload[f"{prefix}_lease_share_itn_percent"] = self._percent( + Decimal("12") + Decimal(profile.index % 7) + ) + payload[f"{prefix}_weighted_load_factor"] = self._rate( + utilization_rate / Decimal("100") + ) + return payload @staticmethod def _ensure_registers() -> list[Register]: diff --git a/src/apps/form_3/migrations/0003_reporting_analytics_fields.py b/src/apps/form_3/migrations/0003_reporting_analytics_fields.py new file mode 100644 index 0000000..ae28cf6 --- /dev/null +++ b/src/apps/form_3/migrations/0003_reporting_analytics_fields.py @@ -0,0 +1,73 @@ +# Generated by Django 3.2.25 on 2026-07-28 08:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('form_3', '0002_auto_20260328_1621'), + ] + + operations = [ + migrations.AddField( + model_name='formf3record', + name='average_employee_age', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='средний возраст сотрудников'), + ), + migrations.AddField( + model_name='formf3record', + name='employees_20_29', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='работники 20-29 лет'), + ), + migrations.AddField( + model_name='formf3record', + name='employees_30_39', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='работники 30-39 лет'), + ), + migrations.AddField( + model_name='formf3record', + name='employees_40_49', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='работники 40-49 лет'), + ), + migrations.AddField( + model_name='formf3record', + name='employees_50_59', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='работники 50-59 лет'), + ), + migrations.AddField( + model_name='formf3record', + name='employees_over_60', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='работники старше 60 лет'), + ), + migrations.AddField( + model_name='formf3record', + name='employees_under_20', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='работники до 20 лет'), + ), + migrations.AddField( + model_name='formf3record', + name='machine_tools_and_equipment', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='станочный парк и оборудование, всего'), + ), + migrations.AddField( + model_name='formf3record', + name='military_utilization_rate', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='уровень загрузки мощностей военного производства'), + ), + migrations.AddField( + model_name='formf3record', + name='shipped_goods_innovative', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=18, null=True, verbose_name='инновационные отгруженные товары, работы и услуги'), + ), + migrations.AddField( + model_name='formf3record', + name='shipped_goods_total', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=18, null=True, verbose_name='отгруженные товары, работы и услуги, всего'), + ), + migrations.AddField( + model_name='formf3record', + name='total_fixed_assets', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=18, null=True, verbose_name='основные фонды, всего'), + ), + ] diff --git a/src/apps/form_3/models.py b/src/apps/form_3/models.py index c7d7b27..1b972f6 100644 --- a/src/apps/form_3/models.py +++ b/src/apps/form_3/models.py @@ -67,8 +67,71 @@ class FormF3Record(ReportingPeriodMixin, TimestampMixin, models.Model): null=True, blank=True, ) + average_employee_age = models.DecimalField( + _("средний возраст сотрудников"), + max_digits=5, + decimal_places=2, + null=True, + blank=True, + ) + employees_under_20 = models.PositiveIntegerField( + _("работники до 20 лет"), + null=True, + blank=True, + ) + employees_20_29 = models.PositiveIntegerField( + _("работники 20-29 лет"), + null=True, + blank=True, + ) + employees_30_39 = models.PositiveIntegerField( + _("работники 30-39 лет"), + null=True, + blank=True, + ) + employees_40_49 = models.PositiveIntegerField( + _("работники 40-49 лет"), + null=True, + blank=True, + ) + employees_50_59 = models.PositiveIntegerField( + _("работники 50-59 лет"), + null=True, + blank=True, + ) + employees_over_60 = models.PositiveIntegerField( + _("работники старше 60 лет"), + null=True, + blank=True, + ) # === Оборудование - общие данные === + shipped_goods_total = models.DecimalField( + _("отгруженные товары, работы и услуги, всего"), + max_digits=18, + decimal_places=2, + null=True, + blank=True, + ) + shipped_goods_innovative = models.DecimalField( + _("инновационные отгруженные товары, работы и услуги"), + max_digits=18, + decimal_places=2, + null=True, + blank=True, + ) + total_fixed_assets = models.DecimalField( + _("основные фонды, всего"), + max_digits=18, + decimal_places=2, + null=True, + blank=True, + ) + machine_tools_and_equipment = models.PositiveIntegerField( + _("станочный парк и оборудование, всего"), + null=True, + blank=True, + ) total_equipment = models.PositiveIntegerField( _("всего оборудования"), null=True, @@ -127,6 +190,13 @@ class FormF3Record(ReportingPeriodMixin, TimestampMixin, models.Model): null=True, blank=True, ) + military_utilization_rate = models.DecimalField( + _("уровень загрузки мощностей военного производства"), + max_digits=5, + decimal_places=2, + null=True, + blank=True, + ) avg_shift_work = models.DecimalField( _("средняя сменность работы"), max_digits=5, diff --git a/src/apps/form_3/services.py b/src/apps/form_3/services.py index 386266b..1d496c5 100644 --- a/src/apps/form_3/services.py +++ b/src/apps/form_3/services.py @@ -97,81 +97,118 @@ class FormF3Parser(ReportingPeriodParserMixin, BaseExcelParser[FormF3Record]): OKPO_COLUMN = 1 OGRN_COLUMN = 2 INN_COLUMN = 3 + DATA_START_ROW = 3 def get_column_mappings(self) -> list[ColumnMapping]: """Маппинг колонок Excel на поля модели.""" return [ - # Кадры ColumnMapping( 4, - "Средняя численность работников", - "avg_employees", + "Отгруженные товары собственного производства", + "shipped_goods_total", field_type="decimal", ), ColumnMapping( 5, - "Производственный персонал", - "production_workers", + "из них инновационные товары", + "shipped_goods_innovative", field_type="decimal", ), ColumnMapping( 6, - "Инженерно-технические работники", - "engineering_workers", + "Всего основных фондов", + "total_fixed_assets", field_type="decimal", ), ColumnMapping( 7, - "Административный персонал", - "administrative_workers", + "Станочный парк и оборудование, всего", + "machine_tools_and_equipment", + field_type="int", + ), + ColumnMapping( + 8, + "Станочный парк и оборудование - импортное оборудование", + "imported_equipment", + field_type="int", + ), + ColumnMapping( + 9, + "Оборудование с возрастом от 5 до 10 лет", + "equipment_age_5_10", + field_type="int", + ), + ColumnMapping( + 10, + "Оборудование с возрастом до 5 лет", + "equipment_age_under_5", + field_type="int", + ), + ColumnMapping( + 11, + "Фактический (физический) износ оборудования", + "physical_wear_percent", field_type="decimal", ), - # Оборудование - общие данные - ColumnMapping(8, "Всего оборудования", "total_equipment", field_type="int"), ColumnMapping( - 9, "Отечественное оборудование", "domestic_equipment", field_type="int" + 12, + "Средний уровень загрузки производственных мощностей (всего)", + "utilization_rate", + field_type="decimal", ), ColumnMapping( - 10, "Импортное оборудование", "imported_equipment", field_type="int" - ), - # Оборудование по возрасту - ColumnMapping( - 11, "Оборудование до 5 лет", "equipment_age_under_5", field_type="int" + 13, + "Средний уровень загрузки производственных мощностей военного производства", + "military_utilization_rate", + field_type="decimal", ), ColumnMapping( - 12, "Оборудование 5-10 лет", "equipment_age_5_10", field_type="int" - ), - ColumnMapping( - 13, "Оборудование 10-15 лет", "equipment_age_10_15", field_type="int" - ), - ColumnMapping( - 14, "Оборудование 15-20 лет", "equipment_age_15_20", field_type="int" + 14, + "Средний возраст сотрудников", + "average_employee_age", + field_type="decimal", ), ColumnMapping( 15, - "Оборудование свыше 20 лет", - "equipment_age_over_20", + "Количество работников предприятия до 20 лет", + "employees_under_20", field_type="int", ), - # Износ и использование ColumnMapping( - 16, "Физический износ, %", "physical_wear_percent", field_type="decimal" + 16, + "Количество работников предприятия 20-29 лет", + "employees_20_29", + field_type="int", ), ColumnMapping( - 17, "Коэффициент загрузки", "utilization_rate", field_type="decimal" + 17, + "Количество работников предприятия 30-39 лет", + "employees_30_39", + field_type="int", ), ColumnMapping( - 18, "Средняя сменность работы", "avg_shift_work", field_type="decimal" - ), - # Потребности - ColumnMapping( - 19, "Потребность в оборудовании", "equipment_needed", field_type="int" + 18, + "Количество работников предприятия 40-49 лет", + "employees_40_49", + field_type="int", ), ColumnMapping( - 20, "Потребность в кадрах", "workers_needed", field_type="int" + 19, + "Количество работников предприятия 50-59 лет", + "employees_50_59", + field_type="int", + ), + ColumnMapping( + 20, + "Количество работников предприятия старше 60 лет", + "employees_over_60", + field_type="int", ), ] + def validate_workbook(self) -> None: + self._validate_mapped_headers(header_rows=(1,)) + def get_next_batch_id(self) -> int: """Получить следующий номер загрузки.""" return FormF3Service.get_next_batch_id() diff --git a/src/apps/form_4/services.py b/src/apps/form_4/services.py index 42c5a7f..f5cf420 100644 --- a/src/apps/form_4/services.py +++ b/src/apps/form_4/services.py @@ -75,86 +75,63 @@ class FormF4Parser(ReportingPeriodParserMixin, BaseExcelParser[FormF4Record]): OKPO_COLUMN = 1 OGRN_COLUMN = 2 INN_COLUMN = 3 + DATA_START_ROW = 3 def get_column_mappings(self) -> list[ColumnMapping]: return [ - # Выручка - ColumnMapping(4, "Выручка (РСБУ)", "revenue_rsbu", field_type="decimal"), - ColumnMapping(5, "Выручка (МСФО)", "revenue_ifrs", field_type="decimal"), + ColumnMapping( + 4, + "Выручка, в соответствии с РСБУ", + "revenue_rsbu", + field_type="decimal", + ), + ColumnMapping( + 5, + "Выручка, в соответствии с МСФО", + "revenue_ifrs", + field_type="decimal", + ), ColumnMapping( 6, - "Выручка прошлого года (РСБУ)", - "revenue_prev_rsbu", + "Чистая прибыль (убыток), в соответствии с РСБУ", + "net_profit_rsbu", field_type="decimal", ), ColumnMapping( 7, - "Выручка прошлого года (МСФО)", - "revenue_prev_ifrs", + "Чистая прибыль (убыток), в соответствии с МСФО", + "net_profit_ifrs", field_type="decimal", ), - # Прибыль ColumnMapping( - 8, "Чистая прибыль (РСБУ)", "net_profit_rsbu", field_type="decimal" + 8, + "Кредиты и займы, в соответствии с РСБУ", + "loans_rsbu", + field_type="decimal", ), ColumnMapping( - 9, "Чистая прибыль (МСФО)", "net_profit_ifrs", field_type="decimal" + 9, + "Кредиты и займы, в соответствии с МСФО", + "loans_ifrs", + field_type="decimal", ), ColumnMapping( - 10, "Валовая прибыль (РСБУ)", "gross_profit_rsbu", field_type="decimal" + 10, + "EBITDA, в соответствии с РСБУ", + "ebitda_rsbu", + field_type="decimal", ), ColumnMapping( 11, - "Операционная прибыль (РСБУ)", - "operating_profit_rsbu", + "EBITDA, в соответствии с МСФО", + "ebitda_ifrs", field_type="decimal", ), - # EBITDA - ColumnMapping(12, "EBITDA (РСБУ)", "ebitda_rsbu", field_type="decimal"), - ColumnMapping(13, "EBITDA (МСФО)", "ebitda_ifrs", field_type="decimal"), - # Долговая нагрузка - ColumnMapping( - 14, "Кредиты и займы (РСБУ)", "loans_rsbu", field_type="decimal" - ), - ColumnMapping( - 15, "Кредиты и займы (МСФО)", "loans_ifrs", field_type="decimal" - ), - ColumnMapping( - 16, "Чистый долг (РСБУ)", "net_debt_rsbu", field_type="decimal" - ), - ColumnMapping( - 17, "Чистый долг (МСФО)", "net_debt_ifrs", field_type="decimal" - ), - ColumnMapping(18, "Долг/EBITDA", "debt_to_ebitda", field_type="decimal"), - # Активы и капитал - ColumnMapping( - 19, "Активы (РСБУ)", "total_assets_rsbu", field_type="decimal" - ), - ColumnMapping( - 20, "Активы (МСФО)", "total_assets_ifrs", field_type="decimal" - ), - ColumnMapping( - 21, "Собственный капитал (РСБУ)", "equity_rsbu", field_type="decimal" - ), - ColumnMapping( - 22, "Собственный капитал (МСФО)", "equity_ifrs", field_type="decimal" - ), - # Рентабельность - ColumnMapping(23, "ROE", "roe", field_type="decimal"), - ColumnMapping(24, "ROA", "roa", field_type="decimal"), - ColumnMapping(25, "ROS", "ros", field_type="decimal"), - # Инвестиции - ColumnMapping(26, "CAPEX", "capex", field_type="decimal"), - ColumnMapping(27, "Затраты на НИОКР", "rd_expenses", field_type="decimal"), - # Дивиденды - ColumnMapping( - 28, "Выплаченные дивиденды", "dividends_paid", field_type="decimal" - ), - ColumnMapping( - 29, "Дивидендная доходность", "dividend_yield", field_type="decimal" - ), ] + def validate_workbook(self) -> None: + self._validate_mapped_headers(header_rows=(1,)) + def get_next_batch_id(self) -> int: return FormF4Service.get_next_batch_id() diff --git a/src/apps/form_6/migrations/0003_reporting_analytics_fields.py b/src/apps/form_6/migrations/0003_reporting_analytics_fields.py new file mode 100644 index 0000000..213bc32 --- /dev/null +++ b/src/apps/form_6/migrations/0003_reporting_analytics_fields.py @@ -0,0 +1,128 @@ +# Generated by Django 3.2.25 on 2026-07-28 08:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('form_6', '0002_auto_20260328_1621'), + ] + + operations = [ + migrations.AddField( + model_name='formf6record', + name='age_10_15_imported', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='импортное оборудование 10-15 лет'), + ), + migrations.AddField( + model_name='formf6record', + name='age_10_15_lease_share_itn_percent', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='доля времени работы для ПВН 10-15 лет, %'), + ), + migrations.AddField( + model_name='formf6record', + name='age_10_15_utilization_rate', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='использование оборудования 10-15 лет'), + ), + migrations.AddField( + model_name='formf6record', + name='age_10_15_weighted_load_factor', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='средневзвешенный коэффициент загрузки 10-15 лет'), + ), + migrations.AddField( + model_name='formf6record', + name='age_15_20_imported', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='импортное оборудование 15-20 лет'), + ), + migrations.AddField( + model_name='formf6record', + name='age_15_20_lease_share_itn_percent', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='доля времени работы для ПВН 15-20 лет, %'), + ), + migrations.AddField( + model_name='formf6record', + name='age_15_20_utilization_rate', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='использование оборудования 15-20 лет'), + ), + migrations.AddField( + model_name='formf6record', + name='age_15_20_weighted_load_factor', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='средневзвешенный коэффициент загрузки 15-20 лет'), + ), + migrations.AddField( + model_name='formf6record', + name='age_5_10_imported', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='импортное оборудование 5-10 лет'), + ), + migrations.AddField( + model_name='formf6record', + name='age_5_10_lease_share_itn_percent', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='доля времени работы для ПВН 5-10 лет, %'), + ), + migrations.AddField( + model_name='formf6record', + name='age_5_10_utilization_rate', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='использование оборудования 5-10 лет'), + ), + migrations.AddField( + model_name='formf6record', + name='age_5_10_weighted_load_factor', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='средневзвешенный коэффициент загрузки 5-10 лет'), + ), + migrations.AddField( + model_name='formf6record', + name='age_over_20_imported', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='импортное оборудование свыше 20 лет'), + ), + migrations.AddField( + model_name='formf6record', + name='age_over_20_lease_share_itn_percent', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='доля времени работы для ПВН свыше 20 лет, %'), + ), + migrations.AddField( + model_name='formf6record', + name='age_over_20_utilization_rate', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='использование оборудования свыше 20 лет'), + ), + migrations.AddField( + model_name='formf6record', + name='age_over_20_weighted_load_factor', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='средневзвешенный коэффициент загрузки свыше 20 лет'), + ), + migrations.AddField( + model_name='formf6record', + name='age_under_5_imported', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='импортное оборудование до 5 лет'), + ), + migrations.AddField( + model_name='formf6record', + name='age_under_5_lease_share_itn_percent', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='доля времени работы для ПВН до 5 лет, %'), + ), + migrations.AddField( + model_name='formf6record', + name='age_under_5_utilization_rate', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='использование оборудования до 5 лет'), + ), + migrations.AddField( + model_name='formf6record', + name='age_under_5_weighted_load_factor', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='средневзвешенный коэффициент загрузки до 5 лет'), + ), + migrations.AddField( + model_name='formf6record', + name='commissioned_equipment', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='введено в эксплуатацию'), + ), + migrations.AddField( + model_name='formf6record', + name='decommissioned_equipment', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='выведено из эксплуатации'), + ), + migrations.AddField( + model_name='formf6record', + name='notes', + field=models.TextField(blank=True, default='', verbose_name='примечание'), + ), + ] diff --git a/src/apps/form_6/models.py b/src/apps/form_6/models.py index 0c8e10e..16b48aa 100644 --- a/src/apps/form_6/models.py +++ b/src/apps/form_6/models.py @@ -68,6 +68,16 @@ class FormF6Record(ReportingPeriodMixin, TimestampMixin, models.Model): null=True, blank=True, ) + commissioned_equipment = models.PositiveIntegerField( + _("введено в эксплуатацию"), + null=True, + blank=True, + ) + decommissioned_equipment = models.PositiveIntegerField( + _("выведено из эксплуатации"), + null=True, + blank=True, + ) # === Возрастная структура === age_under_5 = models.PositiveIntegerField( @@ -95,6 +105,137 @@ class FormF6Record(ReportingPeriodMixin, TimestampMixin, models.Model): null=True, blank=True, ) + age_under_5_imported = models.PositiveIntegerField( + _("импортное оборудование до 5 лет"), + null=True, + blank=True, + ) + age_5_10_imported = models.PositiveIntegerField( + _("импортное оборудование 5-10 лет"), + null=True, + blank=True, + ) + age_10_15_imported = models.PositiveIntegerField( + _("импортное оборудование 10-15 лет"), + null=True, + blank=True, + ) + age_15_20_imported = models.PositiveIntegerField( + _("импортное оборудование 15-20 лет"), + null=True, + blank=True, + ) + age_over_20_imported = models.PositiveIntegerField( + _("импортное оборудование свыше 20 лет"), + null=True, + blank=True, + ) + + age_under_5_utilization_rate = models.DecimalField( + _("использование оборудования до 5 лет"), + max_digits=5, + decimal_places=2, + null=True, + blank=True, + ) + age_5_10_utilization_rate = models.DecimalField( + _("использование оборудования 5-10 лет"), + max_digits=5, + decimal_places=2, + null=True, + blank=True, + ) + age_10_15_utilization_rate = models.DecimalField( + _("использование оборудования 10-15 лет"), + max_digits=5, + decimal_places=2, + null=True, + blank=True, + ) + age_15_20_utilization_rate = models.DecimalField( + _("использование оборудования 15-20 лет"), + max_digits=5, + decimal_places=2, + null=True, + blank=True, + ) + age_over_20_utilization_rate = models.DecimalField( + _("использование оборудования свыше 20 лет"), + max_digits=5, + decimal_places=2, + null=True, + blank=True, + ) + age_under_5_lease_share_itn_percent = models.DecimalField( + _("доля времени работы для ПВН до 5 лет, %"), + max_digits=5, + decimal_places=2, + null=True, + blank=True, + ) + age_5_10_lease_share_itn_percent = models.DecimalField( + _("доля времени работы для ПВН 5-10 лет, %"), + max_digits=5, + decimal_places=2, + null=True, + blank=True, + ) + age_10_15_lease_share_itn_percent = models.DecimalField( + _("доля времени работы для ПВН 10-15 лет, %"), + max_digits=5, + decimal_places=2, + null=True, + blank=True, + ) + age_15_20_lease_share_itn_percent = models.DecimalField( + _("доля времени работы для ПВН 15-20 лет, %"), + max_digits=5, + decimal_places=2, + null=True, + blank=True, + ) + age_over_20_lease_share_itn_percent = models.DecimalField( + _("доля времени работы для ПВН свыше 20 лет, %"), + max_digits=5, + decimal_places=2, + null=True, + blank=True, + ) + age_under_5_weighted_load_factor = models.DecimalField( + _("средневзвешенный коэффициент загрузки до 5 лет"), + max_digits=5, + decimal_places=2, + null=True, + blank=True, + ) + age_5_10_weighted_load_factor = models.DecimalField( + _("средневзвешенный коэффициент загрузки 5-10 лет"), + max_digits=5, + decimal_places=2, + null=True, + blank=True, + ) + age_10_15_weighted_load_factor = models.DecimalField( + _("средневзвешенный коэффициент загрузки 10-15 лет"), + max_digits=5, + decimal_places=2, + null=True, + blank=True, + ) + age_15_20_weighted_load_factor = models.DecimalField( + _("средневзвешенный коэффициент загрузки 15-20 лет"), + max_digits=5, + decimal_places=2, + null=True, + blank=True, + ) + age_over_20_weighted_load_factor = models.DecimalField( + _("средневзвешенный коэффициент загрузки свыше 20 лет"), + max_digits=5, + decimal_places=2, + null=True, + blank=True, + ) # === С ЧПУ по возрасту === cnc_total = models.PositiveIntegerField( @@ -162,6 +303,7 @@ class FormF6Record(ReportingPeriodMixin, TimestampMixin, models.Model): null=True, blank=True, ) + notes = models.TextField(_("примечание"), blank=True, default="") class Meta: verbose_name = _("запись Ф-6") diff --git a/src/apps/form_6/services.py b/src/apps/form_6/services.py index 27d9e91..7088c3a 100644 --- a/src/apps/form_6/services.py +++ b/src/apps/form_6/services.py @@ -7,11 +7,14 @@ """ import logging +from decimal import Decimal, InvalidOperation from typing import Any from apps.core.excel import ( BaseExcelParser, ColumnMapping, + ExcelValidationError, + FieldError, ParseResult, RowData, ) @@ -75,55 +78,209 @@ class FormF6Parser(ReportingPeriodParserMixin, BaseExcelParser[FormF6Record]): OKPO_COLUMN = 1 OGRN_COLUMN = 2 INN_COLUMN = 3 + DATA_START_ROW = 5 def get_column_mappings(self) -> list[ColumnMapping]: return [ # Категоризация ColumnMapping(4, "Код строки", "row_code", field_type="str"), - ColumnMapping(5, "Категория оборудования", "category", field_type="str"), - # Общие данные - ColumnMapping(6, "Всего оборудования", "total_equipment", field_type="int"), + ColumnMapping(5, "Категория", "category", field_type="str"), ColumnMapping( - 7, "Отечественное оборудование", "domestic_equipment", field_type="int" - ), - ColumnMapping( - 8, "Импортное оборудование", "imported_equipment", field_type="int" - ), - # Возрастная структура - ColumnMapping(9, "До 5 лет", "age_under_5", field_type="int"), - ColumnMapping(10, "5-10 лет", "age_5_10", field_type="int"), - ColumnMapping(11, "10-15 лет", "age_10_15", field_type="int"), - ColumnMapping(12, "15-20 лет", "age_15_20", field_type="int"), - ColumnMapping(13, "Свыше 20 лет", "age_over_20", field_type="int"), - # С ЧПУ - ColumnMapping(14, "С ЧПУ всего", "cnc_total", field_type="int"), - ColumnMapping(15, "С ЧПУ до 5 лет", "cnc_under_5", field_type="int"), - ColumnMapping(16, "С ЧПУ 5-10 лет", "cnc_5_10", field_type="int"), - ColumnMapping(17, "С ЧПУ 10-15 лет", "cnc_10_15", field_type="int"), - ColumnMapping(18, "С ЧПУ 15-20 лет", "cnc_15_20", field_type="int"), - ColumnMapping(19, "С ЧПУ свыше 20 лет", "cnc_over_20", field_type="int"), - # Показатели - ColumnMapping( - 20, "Средняя сменность работы", "avg_shift_work", field_type="decimal" - ), - ColumnMapping( - 21, "Коэффициент загрузки", "utilization_rate", field_type="decimal" - ), - ColumnMapping( - 22, "Физический износ, %", "physical_wear_percent", field_type="decimal" - ), - # Потребности - ColumnMapping( - 23, - "Рабочие места без оборудования", - "workplaces_without_equipment", + 6, + "Количество оборудования на конец года ед. Всего", + "total_equipment", field_type="int", ), ColumnMapping( - 24, "Оборудование к замене", "equipment_to_replace", field_type="int" + 7, + "Введенное в эксплуатацию", + "commissioned_equipment", + field_type="int", + ), + ColumnMapping( + 8, + "Выведенное", + "decommissioned_equipment", + field_type="int", + ), + ColumnMapping( + 9, + "Импортное оборудование", + "imported_equipment", + field_type="int", + ), + *self._age_bucket_mappings(10, "До 5 лет", "age_under_5"), + *self._age_bucket_mappings(15, "От 5 до 10 лет", "age_5_10"), + *self._age_bucket_mappings(20, "От 10 до 15 лет", "age_10_15"), + *self._age_bucket_mappings(25, "От 15 до 20 лет", "age_15_20"), + *self._age_bucket_mappings(30, "Свыше 20 лет", "age_over_20"), + ColumnMapping( + 35, "Средняя сменность работы", "avg_shift_work", field_type="decimal" + ), + ColumnMapping( + 36, + "Количество рабочих мест без оборудования", + "workplaces_without_equipment", + field_type="int", + ), + ColumnMapping(37, "Примечание", "notes", field_type="str"), + ] + + @staticmethod + def _age_bucket_mappings( + start_column: int, + age_header: str, + field_prefix: str, + ) -> list[ColumnMapping]: + return [ + ColumnMapping( + start_column, + f"{age_header} Всего", + field_prefix, + field_type="int", + ), + ColumnMapping( + start_column + 1, + "Импортного оборудования", + f"{field_prefix}_imported", + field_type="int", + ), + ColumnMapping( + start_column + 2, + "Коэффициент использования оборудования", + f"{field_prefix}_utilization_rate", + field_type="decimal", + ), + ColumnMapping( + start_column + 3, + "Доля фактического времени работы оборудования", + f"{field_prefix}_lease_share_itn_percent", + field_type="decimal", + ), + ColumnMapping( + start_column + 4, + "Средневзвешенный коэффициент загрузки", + f"{field_prefix}_weighted_load_factor", + field_type="decimal", ), ] + def validate_workbook(self) -> None: + self._validate_mapped_headers(header_rows=(1, 2, 3, 4)) + errors: list[FieldError] = [] + for row_num in range(self.DATA_START_ROW, self._sheet.max_row + 1): + if not self._get_cell_value(row_num, self.ORG_NAME_COLUMN): + continue + self._validate_integer_counts(row_num, errors) + self._validate_equipment_totals(row_num, errors) + + if errors: + raise ExcelValidationError( + "Ф-6 содержит несовместимую схему или противоречивые количества", + errors=errors, + ) + + def _validate_integer_counts( + self, + row_num: int, + errors: list[FieldError], + ) -> None: + int_mappings = [ + mapping + for mapping in self._column_mappings or [] + if mapping.field_type == "int" + ] + for mapping in int_mappings: + raw_value = self._get_cell_value(row_num, mapping.excel_column) + if raw_value in (None, ""): + continue + try: + numeric = Decimal(str(raw_value).replace(",", ".").replace(" ", "")) + except (InvalidOperation, ValueError): + errors.append( + FieldError( + field=mapping.model_field, + message=f"Строка {row_num}: ожидается целое число", + value=raw_value, + ) + ) + continue + if not numeric.is_finite() or numeric != numeric.to_integral_value(): + errors.append( + FieldError( + field=mapping.model_field, + message=f"Строка {row_num}: количество должно быть целым", + value=raw_value, + ) + ) + + def _validate_equipment_totals( + self, + row_num: int, + errors: list[FieldError], + ) -> None: + age_prefixes = ( + "age_under_5", + "age_5_10", + "age_10_15", + "age_15_20", + "age_over_20", + ) + values = { + mapping.model_field: self._convert_value( + self._get_cell_value(row_num, mapping.excel_column), + mapping.field_type, + ) + for mapping in self._column_mappings or [] + } + total = values.get("total_equipment") + age_total = sum(values.get(prefix) or 0 for prefix in age_prefixes) + imported = values.get("imported_equipment") + age_imported = sum( + values.get(f"{prefix}_imported") or 0 for prefix in age_prefixes + ) + + if total is not None and age_total != total: + errors.append( + FieldError( + field="total_equipment", + message=( + f"Строка {row_num}: сумма возрастных групп " + "не равна общему количеству" + ), + value={"total": total, "age_total": age_total}, + ) + ) + if imported is not None and age_imported != imported: + errors.append( + FieldError( + field="imported_equipment", + message=( + f"Строка {row_num}: сумма импортного оборудования " + "по возрастам не равна итогу" + ), + value={"total": imported, "age_total": age_imported}, + ) + ) + for prefix in age_prefixes: + bucket_total = values.get(prefix) + bucket_imported = values.get(f"{prefix}_imported") + if ( + bucket_total is not None + and bucket_imported is not None + and bucket_imported > bucket_total + ): + errors.append( + FieldError( + field=f"{prefix}_imported", + message=( + f"Строка {row_num}: импортное оборудование " + "превышает количество в возрастной группе" + ), + value=bucket_imported, + ) + ) + def get_next_batch_id(self) -> int: return FormF6Service.get_next_batch_id() diff --git a/src/apps/organization/analytics_services.py b/src/apps/organization/analytics_services.py index 1a88986..233b332 100644 --- a/src/apps/organization/analytics_services.py +++ b/src/apps/organization/analytics_services.py @@ -49,10 +49,6 @@ def _amount(value) -> int: return int(_dec(value)) -def _amount_thousands(value) -> int: - return int(_dec(value) / THOUSAND) - - def _ratio(value) -> float: return round(float(_dec(value)), 1) @@ -87,8 +83,18 @@ def _direction(current, previous) -> str: return "unchanged" -def _period_rank(report_quarter: int | None) -> int: - return 5 if report_quarter is None else report_quarter +def _period_rank(record) -> int: + """Rank the reporting period represented by a record within its year.""" + report_month = getattr(record, "report_month", None) + if report_month is not None: + return int(report_month) + report_half_year = getattr(record, "report_half_year", None) + if report_half_year is not None: + return int(report_half_year) * 6 + report_quarter = getattr(record, "report_quarter", None) + if report_quarter is not None: + return int(report_quarter) * 3 + return 13 def _pick_record( @@ -106,7 +112,7 @@ def _pick_record( return max( year_records, - key=lambda record: (_period_rank(record.report_quarter), record.created_at), + key=lambda record: (_period_rank(record), record.created_at), ) @@ -331,7 +337,7 @@ class OrganizationAnalyticsService: "assets": "rub_thousands", "capex": "rub_thousands", "rd_expenses": "rub_thousands", - "revenue_per_employee": "rub_per_employee", + "revenue_per_employee": "rub_thousands_per_employee", } @staticmethod @@ -368,23 +374,24 @@ class OrganizationAnalyticsService: if metric == "loans": if f4 is not None and f4.loans_rsbu is not None: return _dec(f4.loans_rsbu) - return _dec(getattr(f2, "borrowings_non_current", ZERO)) + _dec( - getattr(f2, "borrowings_current", ZERO) - ) + return ( + _dec(getattr(f2, "borrowings_non_current", ZERO)) + + _dec(getattr(f2, "borrowings_current", ZERO)) + ) / THOUSAND primary, fallback = field_map.get(metric, (ZERO, ZERO)) - return _dec(primary or fallback) + if primary is not None: + return _dec(primary) + return _dec(fallback) / THOUSAND @classmethod def _economics_display_value(cls, metric: str, f2, f4, f1) -> int: if metric == "revenue_per_employee": - employees = _dec(getattr(f1, "avg_payroll_employees", ZERO)) - if employees == ZERO: - employees = _dec(getattr(f1, "avg_employees", ZERO)) + employees = _dec(getattr(f1, "avg_employees", ZERO)) if employees == ZERO: return 0 return _amount(cls._economics_metric_value("revenue", f2, f4) / employees) - return _amount_thousands(cls._economics_metric_value(metric, f2, f4)) + return _amount(cls._economics_metric_value(metric, f2, f4)) @classmethod def get_economics( @@ -405,12 +412,13 @@ class OrganizationAnalyticsService: cls._f1_records(organization), from_year, to_year ) - periods = sorted(set(f2_by_year) | set(f4_by_year) | set(f1_by_year)) + periods = sorted(set(f2_by_year) | set(f4_by_year)) if not periods: return { "organization_id": str(organization.id), "group": group, "periods": [], + "report_periods": [], "data_available": False, "message": "Данные отсутствуют", "kpis": {}, @@ -438,6 +446,15 @@ class OrganizationAnalyticsService: "organization_id": str(organization.id), "group": group, "periods": periods, + "report_periods": [ + { + "year": year, + "report_half_year": getattr( + f4_by_year.get(year), "report_half_year", None + ), + } + for year in periods + ], "data_available": True, "message": None, "kpis": { @@ -475,7 +492,18 @@ class OrganizationAnalyticsService: "ratios": [ { "period": report_year, - "ros": _ratio(getattr(f4_by_year.get(report_year), "ros", ZERO)), + "ros": _share_percent( + cls._economics_metric_value( + "net_profit", + 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), + ), + ), "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( @@ -504,95 +532,103 @@ class OrganizationAnalyticsService: report_year: int, history_years: int, ) -> dict[str, object]: + f1_records = cls._f1_records(organization) f3_records = cls._f3_records(organization) - report_year = _resolve_report_year(f3_records, report_year) - current_f3 = cls._require_record( - _pick_record(f3_records, report_year), + report_year = _resolve_report_year(f1_records, report_year) + current_f1 = cls._require_record( + _pick_record(f1_records, report_year), entity="Personnel", ) - current_f1 = _pick_record(cls._f1_records(organization), report_year) + current_f3 = _pick_record(f3_records, 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]) - history_f1_records = _best_records_by_year( - cls._f1_records(organization), years[0], years[-1] + history_f1_records = _best_records_by_year(f1_records, years[0], years[-1]) + history_f3_records = _best_records_by_year(f3_records, years[0], years[-1]) + + def optional_int(record, field_name: str) -> int | None: + value = getattr(record, field_name, None) if record is not None else None + return int(_dec(value)) if value is not None else None + + def age_distribution_for(record) -> list[dict[str, int]]: + return [ + { + "age_group": "under_30", + "employees_count": (optional_int(record, "employees_under_20") or 0) + + (optional_int(record, "employees_20_29") or 0), + }, + { + "age_group": "30_50", + "employees_count": (optional_int(record, "employees_30_39") or 0) + + (optional_int(record, "employees_40_49") or 0), + }, + { + "age_group": "over_50", + "employees_count": (optional_int(record, "employees_50_59") or 0) + + (optional_int(record, "employees_over_60") or 0), + }, + ] + + age_distribution = age_distribution_for(current_f3) + average_age = ( + _ratio(current_f3.average_employee_age) + if current_f3 is not None and current_f3.average_employee_age is not None + else None ) - average_employees = int(_dec(current_f3.avg_employees)) - under_30 = int(average_employees * 0.29) - 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), + "report_month": current_f1.report_month, + "average_age": average_age, "headcount": { - "average_employees": average_employees, + "average_employees": int(_dec(current_f1.avg_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)), + "payroll_fund": _amount( + _dec(getattr(current_f1, "payroll_fund", ZERO)) * THOUSAND + ), + "production_workers": optional_int(current_f3, "production_workers"), + "engineering_workers": optional_int(current_f3, "engineering_workers"), + "administrative_workers": optional_int( + current_f3, "administrative_workers" + ), + "workers_needed": optional_int(current_f3, "workers_needed"), }, "age_distribution": age_distribution, "history": [ { "year": year, - "average_employees": int(_dec(history_records[year].avg_employees)), + "report_month": history_f1_records[year].report_month, + "average_employees": int( + _dec(history_f1_records[year].avg_employees) + ), "avg_payroll_employees": int( _dec( getattr( - history_f1_records.get(year), + history_f1_records[year], "avg_payroll_employees", ZERO, ) ) ), - "average_age": _weighted_average_age( - [ - { - "age_group": "under_30", - "employees_count": int( - _dec(history_records[year].avg_employees) - * Decimal("0.29") - ), - }, - { - "age_group": "30_50", - "employees_count": int( - _dec(history_records[year].avg_employees) - * Decimal("0.49") - ), - }, - { - "age_group": "over_50", - "employees_count": int( - _dec(history_records[year].avg_employees) - * Decimal("0.22") - ), - }, - ] + "average_age": ( + _ratio(history_f3_records[year].average_employee_age) + if year in history_f3_records + and history_f3_records[year].average_employee_age is not None + else None ), "payroll_fund": _amount( - getattr(history_f1_records.get(year), "payroll_fund", ZERO) + _dec(getattr(history_f1_records[year], "payroll_fund", ZERO)) + * THOUSAND ), } for year in years - if year in history_records + if year in history_f1_records ], } @classmethod - def get_equipment( + def get_equipment( # noqa: C901 cls, *, organization: Organization, @@ -616,6 +652,40 @@ class OrganizationAnalyticsService: report_year=report_year, ) + def weighted_metric(records, value_field: str, weight_field: str): + weighted_sum = ZERO + total_weight = 0 + for record in records: + value = getattr(record, value_field, None) + weight = int(_dec(getattr(record, weight_field, ZERO))) + if value is None or weight <= 0: + continue + weighted_sum += _dec(value) * weight + total_weight += weight + if total_weight == 0: + return None + return weighted_sum / Decimal(str(total_weight)) + + def record_age_metric(record, suffix: str): + weighted_sum = ZERO + total_weight = 0 + for prefix in ( + "age_under_5", + "age_5_10", + "age_10_15", + "age_15_20", + "age_over_20", + ): + value = getattr(record, f"{prefix}_{suffix}", None) + weight = int(_dec(getattr(record, prefix, ZERO))) + if value is None or weight <= 0: + continue + weighted_sum += _dec(value) * weight + total_weight += weight + if total_weight == 0: + return None + return weighted_sum / Decimal(str(total_weight)) + if f6_records: age_distribution = [ { @@ -690,51 +760,44 @@ class OrganizationAnalyticsService: "15_20_years": "age_15_20", "over_20_years": "age_over_20", } - total_age_units = sum(item["units_count"] for item in age_distribution) for item in age_distribution: bucket = item["bucket"] if f6_records: - bucket_units_by_record = [ - int(_dec(getattr(record, bucket_fields[bucket], ZERO))) - for record in f6_records - ] + bucket_field = bucket_fields[bucket] imported_equipment = sum( - round( - int(_dec(record.imported_equipment)) - * units - / max(1, int(_dec(record.total_equipment))) - ) - for record, units in zip( - f6_records, bucket_units_by_record, strict=True - ) + int(_dec(getattr(record, f"{bucket_field}_imported", ZERO))) + for record in f6_records + ) + utilization_rate = weighted_metric( + f6_records, + f"{bucket_field}_utilization_rate", + bucket_field, + ) + lease_share = weighted_metric( + f6_records, + f"{bucket_field}_lease_share_itn_percent", + bucket_field, ) - weighted_units = sum(bucket_units_by_record) - utilization_rate = sum( - _dec(record.utilization_rate) * units - for record, units in zip( - f6_records, bucket_units_by_record, strict=True - ) - ) / max(1, weighted_units) - weighted_wear = sum( - _dec(record.physical_wear_percent) * units - for record, units in zip( - f6_records, bucket_units_by_record, strict=True - ) - ) / max(1, weighted_units) else: - imported_equipment = round( - int(_dec(current_f3.imported_equipment)) - * item["units_count"] - / max(1, total_age_units) + imported_equipment = None + utilization_rate = ( + _dec(current_f3.utilization_rate) + if current_f3.utilization_rate is not None + else None ) - utilization_rate = _dec(current_f3.utilization_rate) - weighted_wear = _dec(current_f3.physical_wear_percent) + lease_share = None item.update( { "imported_equipment": imported_equipment, - "utilization_rate": round(float(utilization_rate / 100), 2), - "lease_share_itn_percent": None, - "weighted_wear_percent": _ratio(weighted_wear), + "utilization_rate": ( + round(float(utilization_rate / 100), 2) + if utilization_rate is not None + else None + ), + "lease_share_itn_percent": ( + _ratio(lease_share) if lease_share is not None else None + ), + "weighted_wear_percent": None, } ) @@ -743,11 +806,13 @@ class OrganizationAnalyticsService: { "equipment_category": record.category, "total_equipment": record.total_equipment, - "domestic_equipment": record.domestic_equipment, + "domestic_equipment": None, "imported_equipment": record.imported_equipment, - "physical_wear_percent": record.physical_wear_percent, - "utilization_rate": record.utilization_rate, - "lease_share_itn_percent": None, + "physical_wear_percent": None, + "utilization_rate": record_age_metric(record, "utilization_rate"), + "lease_share_itn_percent": record_age_metric( + record, "lease_share_itn_percent" + ), } for record in f6_records ] @@ -776,47 +841,115 @@ class OrganizationAnalyticsService: .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( + all_f6_records = list( + FormF6Record.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 - } + ) + f6_by_year: dict[int, list[FormF6Record]] = defaultdict(list) + for record in all_f6_records: + f6_by_year[record.report_year].append(record) + + if f6_by_year: + dynamics_years = sorted(f6_by_year) + total_by_year = { + year: sum(int(_dec(record.total_equipment)) for record in year_records) + for year, year_records in f6_by_year.items() + } + commissioned_by_year = { + year: sum( + int(_dec(record.commissioned_equipment)) for record in year_records + ) + for year, year_records in f6_by_year.items() + } + decommissioned_by_year = { + year: sum( + int(_dec(record.decommissioned_equipment)) + for record in year_records + ) + for year, year_records in f6_by_year.items() + } + else: + 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) + total_by_year = { + year: int(_dec(f3_by_year[year].total_equipment)) + for year in dynamics_years + } + 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 + } + + total_equipment = ( + sum(int(_dec(record.total_equipment)) for record in f6_records) + if f6_records + else int(_dec(current_f3.total_equipment)) + ) + imported_equipment = ( + sum(int(_dec(record.imported_equipment)) for record in f6_records) + if f6_records + else int(_dec(current_f3.imported_equipment)) + ) + average_shift = ( + weighted_metric(f6_records, "avg_shift_work", "total_equipment") + if f6_records + else getattr(current_f3, "avg_shift_work", None) + ) return { "organization_id": str(organization.id), "report_year": report_year, "summary": { - "total_equipment": int(_dec(current_f3.total_equipment)), - "machine_tools_and_equipment": int(_dec(current_f3.total_equipment)), - "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 + "total_equipment": total_equipment, + "machine_tools_and_equipment": int( + _dec( + getattr(current_f3, "machine_tools_and_equipment", None) + or current_f3.total_equipment + ) + ), + "domestic_equipment": ( + None if f6_records else int(_dec(current_f3.domestic_equipment)) + ), + "imported_equipment": imported_equipment, + "physical_wear_percent": _ratio(current_f3.physical_wear_percent), + "weighted_wear_percent": None, + "utilization_rate": ( + round( + float(_dec(current_f3.utilization_rate) / Decimal("100")), + 2, + ) + if current_f3.utilization_rate is not None + else None + ), + "avg_shift_work": ( + _ratio(average_shift) if average_shift is not None else None + ), + "equipment_needed": ( + int(_dec(current_f3.equipment_needed)) + if current_f3.equipment_needed is not None + else None ), - "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), @@ -826,16 +959,28 @@ class OrganizationAnalyticsService: { "category": row["equipment_category"] or "Без категории", "total_equipment": int(_dec(row["total_equipment"])), - "domestic_equipment": int(_dec(row["domestic_equipment"])), + "domestic_equipment": ( + int(_dec(row["domestic_equipment"])) + if row["domestic_equipment"] is not None + else None + ), "imported_equipment": int(_dec(row["imported_equipment"])), - "physical_wear_percent": _ratio(row["physical_wear_percent"]), - "weighted_wear_percent": _ratio(row["physical_wear_percent"]), + "physical_wear_percent": ( + _ratio(row["physical_wear_percent"]) + if row["physical_wear_percent"] is not None + else None + ), + "weighted_wear_percent": None, "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"), + "lease_share_itn_percent": ( + _ratio(row["lease_share_itn_percent"]) + if row.get("lease_share_itn_percent") is not None + else None + ), } for row in category_rows ], @@ -846,7 +991,7 @@ class OrganizationAnalyticsService: "points": [ { "period": year, - "value": int(_dec(f3_by_year[year].total_equipment)), + "value": total_by_year[year], } for year in dynamics_years ], diff --git a/src/apps/organization/contract_serializers.py b/src/apps/organization/contract_serializers.py index 24311e1..affa8de 100644 --- a/src/apps/organization/contract_serializers.py +++ b/src/apps/organization/contract_serializers.py @@ -90,10 +90,16 @@ class EconomicsRatioNormativesSerializer(serializers.Serializer): ebitda_margin = serializers.FloatField(allow_null=True) +class EconomicsReportPeriodSerializer(serializers.Serializer): + year = serializers.IntegerField() + report_half_year = serializers.IntegerField(allow_null=True) + + class EconomicsResponseSerializer(serializers.Serializer): organization_id = serializers.UUIDField() group = serializers.CharField() periods = serializers.ListField(child=serializers.IntegerField()) + report_periods = EconomicsReportPeriodSerializer(many=True) data_available = serializers.BooleanField() message = serializers.CharField(allow_null=True) kpis = EconomicsKpisSerializer() @@ -106,10 +112,10 @@ 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() + production_workers = serializers.IntegerField(allow_null=True) + engineering_workers = serializers.IntegerField(allow_null=True) + administrative_workers = serializers.IntegerField(allow_null=True) + workers_needed = serializers.IntegerField(allow_null=True) class PersonnelAgeDistributionSerializer(serializers.Serializer): @@ -119,16 +125,18 @@ class PersonnelAgeDistributionSerializer(serializers.Serializer): class PersonnelHistorySerializer(serializers.Serializer): year = serializers.IntegerField() + report_month = serializers.IntegerField(allow_null=True) average_employees = serializers.IntegerField() avg_payroll_employees = serializers.IntegerField() - average_age = serializers.FloatField() + average_age = serializers.FloatField(allow_null=True) payroll_fund = serializers.IntegerField() class PersonnelResponseSerializer(serializers.Serializer): organization_id = serializers.UUIDField() report_year = serializers.IntegerField() - average_age = serializers.FloatField() + report_month = serializers.IntegerField(allow_null=True) + average_age = serializers.FloatField(allow_null=True) headcount = PersonnelHeadcountSerializer() age_distribution = PersonnelAgeDistributionSerializer(many=True) history = PersonnelHistorySerializer(many=True) @@ -137,13 +145,13 @@ class PersonnelResponseSerializer(serializers.Serializer): class EquipmentSummarySerializer(serializers.Serializer): total_equipment = serializers.IntegerField() machine_tools_and_equipment = serializers.IntegerField() - domestic_equipment = serializers.IntegerField() + domestic_equipment = serializers.IntegerField(allow_null=True) 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() + weighted_wear_percent = serializers.FloatField(allow_null=True) + utilization_rate = serializers.FloatField(allow_null=True) + avg_shift_work = serializers.FloatField(allow_null=True) + equipment_needed = serializers.IntegerField(allow_null=True) average_age_years = serializers.FloatField() commissioned_equipment = serializers.IntegerField() decommissioned_equipment = serializers.IntegerField() @@ -152,19 +160,19 @@ class EquipmentSummarySerializer(serializers.Serializer): class EquipmentAgeDistributionSerializer(serializers.Serializer): bucket = serializers.CharField() units_count = serializers.IntegerField() - imported_equipment = serializers.IntegerField() - utilization_rate = serializers.FloatField() + imported_equipment = serializers.IntegerField(allow_null=True) + utilization_rate = serializers.FloatField(allow_null=True) lease_share_itn_percent = serializers.FloatField(allow_null=True) - weighted_wear_percent = serializers.FloatField() + weighted_wear_percent = serializers.FloatField(allow_null=True) class EquipmentCategorySerializer(serializers.Serializer): category = serializers.CharField() total_equipment = serializers.IntegerField() - domestic_equipment = serializers.IntegerField() + domestic_equipment = serializers.IntegerField(allow_null=True) imported_equipment = serializers.IntegerField() - physical_wear_percent = serializers.FloatField() - weighted_wear_percent = serializers.FloatField() + physical_wear_percent = serializers.FloatField(allow_null=True) + weighted_wear_percent = serializers.FloatField(allow_null=True) utilization_rate = serializers.FloatField(allow_null=True) lease_share_itn_percent = serializers.FloatField(allow_null=True) diff --git a/tests/apps/core/test_demo_period_workbooks.py b/tests/apps/core/test_demo_period_workbooks.py new file mode 100644 index 0000000..7e78da0 --- /dev/null +++ b/tests/apps/core/test_demo_period_workbooks.py @@ -0,0 +1,41 @@ +"""Integration checks for the versioned Excel demo-period fixtures.""" + +import re +from pathlib import Path + +from apps.form_3.services import FormF3Parser +from apps.form_4.services import FormF4Parser +from apps.form_6.services import FormF6Parser +from django.test import TestCase + +DEMO_DIR = Path(__file__).resolve().parents[3] / "input" / "demo-periods" + + +class DemoPeriodWorkbooksTest(TestCase): + def test_form_f3_workbooks_match_current_schema(self): + for path in sorted(DEMO_DIR.glob("Ф-3_*.xlsx")): + year = int(re.search(r"_(\d{4})", path.name).group(1)) + with self.subTest(path=path.name), path.open("rb") as file: + result = FormF3Parser(report_year=year).parse(file) + self.assertEqual(result.loaded_count, 20) + self.assertEqual(result.skipped_count, 0) + + def test_form_f4_workbooks_match_current_schema(self): + for path in sorted(DEMO_DIR.glob("Ф-4_*.xlsx")): + match = re.search(r"_(\d{4})-H([12])", path.name) + year, half_year = map(int, match.groups()) + with self.subTest(path=path.name), path.open("rb") as file: + result = FormF4Parser( + report_year=year, + report_half_year=half_year, + ).parse(file) + self.assertEqual(result.loaded_count, 20) + self.assertEqual(result.skipped_count, 0) + + def test_form_f6_workbooks_are_structurally_consistent(self): + for path in sorted(DEMO_DIR.glob("Ф-6_*.xlsx")): + year = int(re.search(r"_(\d{4})", path.name).group(1)) + with self.subTest(path=path.name), path.open("rb") as file: + result = FormF6Parser(report_year=year).parse(file) + self.assertEqual(result.loaded_count, 20) + self.assertEqual(result.skipped_count, 0) diff --git a/tests/apps/core/test_generate_test_reports_command.py b/tests/apps/core/test_generate_test_reports_command.py index 828ac17..995af62 100644 --- a/tests/apps/core/test_generate_test_reports_command.py +++ b/tests/apps/core/test_generate_test_reports_command.py @@ -59,6 +59,51 @@ class GenerateTestReportsCommandTest(TestCase): self.assertEqual(PublicProcurement.objects.count(), 3) self.assertEqual(ArbitrationCase.objects.count(), 3) + for record in FormF3Record.objects.all(): + self.assertEqual( + sum( + ( + record.employees_under_20, + record.employees_20_29, + record.employees_30_39, + record.employees_40_49, + record.employees_50_59, + record.employees_over_60, + ) + ), + int(record.avg_employees), + ) + self.assertEqual( + record.machine_tools_and_equipment, + record.total_equipment, + ) + + for record in FormF6Record.objects.all(): + self.assertEqual( + sum( + ( + record.age_under_5, + record.age_5_10, + record.age_10_15, + record.age_15_20, + record.age_over_20, + ) + ), + record.total_equipment, + ) + self.assertEqual( + sum( + ( + record.age_under_5_imported, + record.age_5_10_imported, + record.age_10_15_imported, + record.age_15_20_imported, + record.age_over_20_imported, + ) + ), + record.imported_equipment, + ) + self.assertIn( "Ф-1: создано 12 записей, активных 9, архивных 3", stdout.getvalue() ) diff --git a/tests/apps/form_3/test_services.py b/tests/apps/form_3/test_services.py index bb1d3fc..f3fb144 100644 --- a/tests/apps/form_3/test_services.py +++ b/tests/apps/form_3/test_services.py @@ -1,7 +1,12 @@ """Tests for FormF3 services.""" +from decimal import Decimal +from io import BytesIO + +from apps.form_3.models import FormF3Record from apps.form_3.services import FormF3Parser, FormF3Service from django.test import TestCase +from openpyxl import Workbook from tests.apps.organization.factories import OrganizationFactory @@ -48,6 +53,72 @@ class FormF3ServiceTest(TestCase): class FormF3ParserTest(TestCase): """Tests for FormF3Parser.""" + @staticmethod + def _current_template_file() -> BytesIO: + headers = [ + "Наименование организации", + "ОКПО", + "ОГРН", + "ИНН", + "Отгруженные товары собственного производства, выполненные работы и услуги собственными силами в фактических ценах - всего", + "Отгруженные товары собственного производства, выполненные работы и услуги собственными силами в фактических ценах, из них инновационные товары, работы, услуги", + "Всего основных фондов (без незавершенных активов и не включая земельные участки и объекты природопользования)", + "Станочный парк и оборудование, всего по предприятию", + "Станочный парк и оборудование - импортное оборудование", + "Оборудование с возрастом от 5 до 10 лет", + "Оборудование с возрастом до 5 лет", + "Фактический (физический) износ оборудования, %", + "Средний уровень загрузки производственных мощностей (всего)", + "Средний уровень загрузки производственных мощностей военного производства", + "Средний возраст сотрудников", + "Количество работников предприятия до 20 лет", + "Количество работников предприятия 20-29 лет", + "Количество работников предприятия 30-39 лет", + "Количество работников предприятия 40-49 лет", + "Количество работников предприятия 50-59 лет", + "Количество работников предприятия старше 60 лет", + ] + workbook = Workbook() + sheet = workbook.active + sheet.append(headers) + sheet.append( + [None, None, None, None] + + ["тыс. руб."] * 3 + + ["ед."] * 4 + + ["%"] * 3 + + ["лет"] + + ["чел."] * 6 + ) + sheet.append( + [ + "Тестовая организация Ф-3", + "90000001", + "1267700000017", + "3234567890", + 352, + 222, + 85, + 46, + 17, + 13, + 14, + 21, + 79, + 65, + 42, + 4, + 18, + 28, + 26, + 17, + 7, + ] + ) + file = BytesIO() + workbook.save(file) + file.seek(0) + return file + def test_get_column_mappings_returns_mappings(self): """Test get_column_mappings returns correct mappings.""" parser = FormF3Parser(report_year=2026, report_quarter=1) @@ -57,8 +128,24 @@ class FormF3ParserTest(TestCase): self.assertTrue(len(mappings) > 0) field_names = [m.field_name for m in mappings] - self.assertIn("avg_employees", field_names) - self.assertIn("total_equipment", field_names) + self.assertIn("shipped_goods_total", field_names) + self.assertIn("machine_tools_and_equipment", field_names) + self.assertIn("average_employee_age", field_names) + self.assertNotIn("avg_employees", field_names) + + def test_parse_current_template_maps_equipment_and_personnel_columns(self): + OrganizationFactory.create(inn="3234567890") + parser = FormF3Parser(report_year=2026, report_quarter=1) + + result = parser.parse(self._current_template_file()) + + self.assertEqual(result.loaded_count, 1) + record = FormF3Record.objects.get() + self.assertEqual(record.shipped_goods_total, Decimal("352")) + self.assertEqual(record.machine_tools_and_equipment, 46) + self.assertEqual(record.imported_equipment, 17) + self.assertEqual(record.average_employee_age, Decimal("42")) + self.assertEqual(record.employees_30_39, 28) def test_create_record_uses_existing_organization(self): """Report imports reuse existing organizations when available.""" @@ -69,8 +156,8 @@ class FormF3ParserTest(TestCase): row_data = { "inn": "3234567890", "name": "Тестовая организация Ф-3", - "avg_employees": 100, - "total_equipment": 50, + "machine_tools_and_equipment": 50, + "average_employee_age": 42, } record = parser.create_record(row_data) diff --git a/tests/apps/form_4/test_services.py b/tests/apps/form_4/test_services.py index fafe7ad..4190ee7 100644 --- a/tests/apps/form_4/test_services.py +++ b/tests/apps/form_4/test_services.py @@ -1,7 +1,12 @@ """Tests for FormF4 services.""" +from decimal import Decimal +from io import BytesIO + +from apps.form_4.models import FormF4Record from apps.form_4.services import FormF4Parser, FormF4Service from django.test import TestCase +from openpyxl import Workbook from tests.apps.organization.factories import OrganizationFactory @@ -48,6 +53,47 @@ class FormF4ServiceTest(TestCase): class FormF4ParserTest(TestCase): """Tests for FormF4Parser.""" + @staticmethod + def _current_template_file() -> BytesIO: + headers = [ + "Наименование организации", + "ОКПО", + "ОГРН", + "ИНН", + "Выручка, в соответствии с РСБУ", + "Выручка, в соответствии с МСФО", + "Чистая прибыль (убыток), в соответствии с РСБУ", + "Чистая прибыль (убыток), в соответствии с МСФО", + "Кредиты и займы, в соответствии с РСБУ", + "Кредиты и займы, в соответствии с МСФО", + "EBITDA, в соответствии с РСБУ", + "EBITDA, в соответствии с МСФО", + ] + workbook = Workbook() + sheet = workbook.active + sheet.append(headers) + sheet.append([None, None, None, None] + ["тыс. руб."] * 8) + sheet.append( + [ + "Тестовая организация Ф-4", + "90000001", + "1267700000017", + "4234567890", + 477807, + 499308, + 442414, + 462322, + 42047, + 43729, + 138564, + 62115, + ] + ) + file = BytesIO() + workbook.save(file) + file.seek(0) + return file + def test_get_column_mappings_returns_mappings(self): """Test get_column_mappings returns correct mappings.""" parser = FormF4Parser(report_year=2026, report_half_year=1) @@ -59,6 +105,20 @@ class FormF4ParserTest(TestCase): field_names = [m.field_name for m in mappings] self.assertIn("revenue_rsbu", field_names) self.assertIn("net_profit_rsbu", field_names) + self.assertIn("ebitda_rsbu", field_names) + + def test_parse_current_template_maps_profit_loans_and_ebitda_columns(self): + OrganizationFactory.create(inn="4234567890") + parser = FormF4Parser(report_year=2025, report_half_year=2) + + result = parser.parse(self._current_template_file()) + + self.assertEqual(result.loaded_count, 1) + record = FormF4Record.objects.get() + self.assertEqual(record.revenue_rsbu, Decimal("477807")) + self.assertEqual(record.net_profit_rsbu, Decimal("442414")) + self.assertEqual(record.loans_rsbu, Decimal("42047")) + self.assertEqual(record.ebitda_rsbu, Decimal("138564")) def test_create_record_uses_existing_organization(self): """Report imports reuse existing organizations when available.""" diff --git a/tests/apps/form_6/test_services.py b/tests/apps/form_6/test_services.py index fad00db..bb922ef 100644 --- a/tests/apps/form_6/test_services.py +++ b/tests/apps/form_6/test_services.py @@ -1,7 +1,12 @@ """Tests for FormF6 services.""" +from io import BytesIO + +from apps.core.excel import ExcelValidationError +from apps.form_6.models import FormF6Record from apps.form_6.services import FormF6Parser, FormF6Service from django.test import TestCase +from openpyxl import Workbook from tests.apps.organization.factories import OrganizationFactory @@ -48,6 +53,82 @@ class FormF6ServiceTest(TestCase): class FormF6ParserTest(TestCase): """Tests for FormF6Parser.""" + @staticmethod + def _current_template_file( + *, + total_equipment: int = 100, + age_under_5: int | float = 20, + total_header: str = "Всего", + ) -> BytesIO: + workbook = Workbook() + sheet = workbook.active + row_1 = [None] * 38 + row_2 = [None] * 38 + row_3 = [None] * 38 + row_4 = [None] * 38 + row_1[:6] = [ + "Наименование организации", + "ОКПО", + "ОГРН", + "ИНН", + "Код строки", + "Категория", + ] + row_1[6] = "Количество оборудования на конец года ед." + row_1[10] = "Из установленного оборудования – оборудование в возрасте, единиц" + row_1[35:38] = [ + "Средняя сменность работы", + "Количество рабочих мест без оборудования", + "Примечание", + ] + row_2[10], row_2[15], row_2[20], row_2[25], row_2[30] = ( + "До 5 лет", + "От 5 до 10 лет", + "От 10 до 15 лет", + "От 15 до 20 лет", + "Свыше 20 лет", + ) + row_3[6:10] = [ + total_header, + "Введенное в эксплуатацию в отчетном году", + "Выведенное из эксплуатации в отчетном году", + "Импортное оборудование", + ] + for start in (10, 15, 20, 25, 30): + row_3[start : start + 5] = [ + "Всего", + "Импортного оборудования", + "Коэффициент использования оборудования в производстве", + "Доля фактического времени работы оборудования, использованного для производства ПВН, %", + "Средневзвешенный коэффициент загрузки", + ] + for row in (row_1, row_2, row_3, row_4): + sheet.append(row) + + ages = [age_under_5, 25, 20, 20, 15] + imported = [4, 5, 4, 4, 3] + values = [ + "Тестовая организация Ф-6", + "90000001", + "1267700000017", + "6234567890", + "101", + "Основное технологическое оборудование", + total_equipment, + 12, + 7, + sum(imported), + ] + for total, imported_total in zip(ages, imported, strict=True): + values.extend([total, imported_total, 75, 20, 0.8]) + values.extend([1.5, 4, "Тестовые согласованные данные"]) + sheet.append(values) + + file = BytesIO() + workbook.save(file) + file.seek(0) + return file + def test_get_column_mappings_returns_mappings(self): """Test get_column_mappings returns correct mappings.""" parser = FormF6Parser(report_year=2026, report_quarter=1) @@ -59,6 +140,41 @@ class FormF6ParserTest(TestCase): field_names = [m.field_name for m in mappings] self.assertIn("row_code", field_names) self.assertIn("total_equipment", field_names) + self.assertIn("commissioned_equipment", field_names) + self.assertIn("age_over_20_imported", field_names) + + def test_parse_current_template_maps_all_age_buckets(self): + OrganizationFactory.create(inn="6234567890") + parser = FormF6Parser(report_year=2026, report_quarter=1) + + result = parser.parse(self._current_template_file()) + + self.assertEqual(result.loaded_count, 1) + record = FormF6Record.objects.get() + self.assertEqual(record.total_equipment, 100) + self.assertEqual(record.commissioned_equipment, 12) + self.assertEqual(record.decommissioned_equipment, 7) + self.assertEqual(record.age_under_5, 20) + self.assertEqual(record.age_over_20, 15) + self.assertEqual(record.age_over_20_imported, 3) + + def test_rejects_non_integral_equipment_counts_before_creating_records(self): + OrganizationFactory.create(inn="6234567890") + parser = FormF6Parser(report_year=2026, report_quarter=1) + + with self.assertRaises(ExcelValidationError): + parser.parse(self._current_template_file(age_under_5=20.5)) + + self.assertFalse(FormF6Record.objects.exists()) + + def test_rejects_incompatible_template_before_creating_records(self): + OrganizationFactory.create(inn="6234567890") + parser = FormF6Parser(report_year=2026, report_quarter=1) + + with self.assertRaises(ExcelValidationError): + parser.parse(self._current_template_file(total_header="Импортное")) + + self.assertFalse(FormF6Record.objects.exists()) def test_create_record_uses_existing_organization(self): """Report imports reuse existing organizations when available.""" diff --git a/tests/apps/organization/test_analytics_api.py b/tests/apps/organization/test_analytics_api.py index 8f3ada8..9a042ab 100644 --- a/tests/apps/organization/test_analytics_api.py +++ b/tests/apps/organization/test_analytics_api.py @@ -41,6 +41,8 @@ class OrganizationAnalyticsApiTest(APITestCase): organization=self.organization, report_year=2026, report_quarter=1, + report_month=6, + avg_employees=1050, avg_payroll_employees=995, payroll_fund=Decimal("1000000.00"), military_output_actual=Decimal("11000000.00"), @@ -56,6 +58,8 @@ class OrganizationAnalyticsApiTest(APITestCase): organization=self.organization, report_year=2025, report_quarter=1, + report_month=12, + avg_employees=1020, avg_payroll_employees=970, payroll_fund=Decimal("900000.00"), military_output_actual=Decimal("9000000.00"), @@ -88,11 +92,19 @@ class OrganizationAnalyticsApiTest(APITestCase): FormF3RecordFactory.create( organization=self.organization, report_year=2026, - avg_employees=1050, - production_workers=620, - engineering_workers=210, - administrative_workers=220, - workers_needed=35, + avg_employees=None, + production_workers=None, + engineering_workers=None, + administrative_workers=None, + workers_needed=None, + average_employee_age=Decimal("42.00"), + employees_under_20=4, + employees_20_29=18, + employees_30_39=28, + employees_40_49=26, + employees_50_59=17, + employees_over_60=7, + machine_tools_and_equipment=46, total_equipment=187, domestic_equipment=91, imported_equipment=96, @@ -109,14 +121,16 @@ class OrganizationAnalyticsApiTest(APITestCase): FormF3RecordFactory.create( organization=self.organization, report_year=2025, - avg_employees=1020, + avg_employees=None, + average_employee_age=Decimal("41.00"), ) FormF4RecordFactory.create( organization=self.organization, report_year=2026, - revenue_rsbu=Decimal("1100000000.00"), - net_profit_rsbu=Decimal("320000000.00"), - ebitda_rsbu=Decimal("480000000.00"), + report_half_year=2, + revenue_rsbu=Decimal("477807.00"), + net_profit_rsbu=Decimal("442414.00"), + ebitda_rsbu=Decimal("138564.00"), gross_profit_rsbu=Decimal("520000000.00"), operating_profit_rsbu=Decimal("300000000.00"), net_debt_rsbu=Decimal("200000000.00"), @@ -131,9 +145,10 @@ class OrganizationAnalyticsApiTest(APITestCase): FormF4RecordFactory.create( organization=self.organization, report_year=2025, - revenue_rsbu=Decimal("980000000.00"), - net_profit_rsbu=Decimal("250000000.00"), - ebitda_rsbu=Decimal("410000000.00"), + report_half_year=2, + revenue_rsbu=Decimal("450000.00"), + net_profit_rsbu=Decimal("400000.00"), + ebitda_rsbu=Decimal("120000.00"), ros=Decimal("21.00"), roa=Decimal("10.50"), roe=Decimal("14.10"), @@ -152,14 +167,31 @@ class OrganizationAnalyticsApiTest(APITestCase): organization=self.organization, report_year=2026, category="Станочное оборудование", - total_equipment=54, - domestic_equipment=31, - imported_equipment=23, + total_equipment=187, + domestic_equipment=None, + imported_equipment=96, + commissioned_equipment=12, + decommissioned_equipment=7, age_under_5=70, + age_under_5_imported=35, + age_under_5_utilization_rate=Decimal("92.00"), + age_under_5_lease_share_itn_percent=Decimal("20.00"), age_5_10=41, + age_5_10_imported=21, + age_5_10_utilization_rate=Decimal("92.00"), + age_5_10_lease_share_itn_percent=Decimal("20.00"), age_10_15=33, + age_10_15_imported=17, + age_10_15_utilization_rate=Decimal("92.00"), + age_10_15_lease_share_itn_percent=Decimal("20.00"), age_15_20=22, + age_15_20_imported=12, + age_15_20_utilization_rate=Decimal("92.00"), + age_15_20_lease_share_itn_percent=Decimal("20.00"), age_over_20=21, + age_over_20_imported=11, + age_over_20_utilization_rate=Decimal("92.00"), + age_over_20_lease_share_itn_percent=Decimal("20.00"), physical_wear_percent=Decimal("28.40"), utilization_rate=Decimal("92.00"), avg_shift_work=Decimal("1.80"), @@ -231,14 +263,24 @@ class OrganizationAnalyticsApiTest(APITestCase): 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["report_periods"], + [ + {"year": 2025, "report_half_year": 2}, + {"year": 2026, "report_half_year": 2}, + ], + ) self.assertEqual( response.data["kpis"].keys(), {"revenue", "ebitda", "net_profit", "revenue_per_employee"}, ) - self.assertGreater(response.data["kpis"]["revenue_per_employee"]["value"], 0) + self.assertEqual(response.data["kpis"]["revenue"]["value"], 477807) + self.assertEqual(response.data["kpis"]["net_profit"]["value"], 442414) + self.assertEqual(response.data["kpis"]["ebitda"]["value"], 138564) + self.assertEqual(response.data["kpis"]["revenue_per_employee"]["value"], 455) self.assertEqual( response.data["kpis"]["revenue_per_employee"]["unit"], - "rub_per_employee", + "rub_thousands_per_employee", ) for kpi in response.data["kpis"].values(): self.assertIn("previous_value", kpi) @@ -251,7 +293,7 @@ class OrganizationAnalyticsApiTest(APITestCase): self.assertIn("unit", series) self.assertIn("points", series) expected_unit = ( - "rub_per_employee" + "rub_thousands_per_employee" if series["metric"] == "revenue_per_employee" else "rub_thousands" ) @@ -283,10 +325,43 @@ class OrganizationAnalyticsApiTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["periods"], []) + self.assertEqual(response.data["report_periods"], []) self.assertEqual(response.data["kpis"], {}) self.assertFalse(response.data["data_available"]) self.assertEqual(response.data["message"], "Данные отсутствуют") + def test_economics_normalizes_f2_rubles_to_thousands_without_f4(self): + organization = OrganizationFactory.create() + FormF1RecordFactory.create( + organization=organization, + report_year=2026, + report_month=6, + avg_employees=100, + ) + FormF2RecordFactory.create( + organization=organization, + report_year=2026, + revenue=Decimal("1200000.00"), + net_profit=Decimal("300000.00"), + ebitda=Decimal("480000.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.assertEqual(response.data["kpis"]["revenue"]["value"], 1200) + self.assertEqual( + response.data["kpis"]["revenue_per_employee"]["value"], + 12, + ) + self.assertEqual( + response.data["kpis"]["revenue"]["unit"], + "rub_thousands", + ) + def test_personnel_contract(self): personnel_response = self.client.get( f"/api/v1/organizations/{self.organization.id}/analytics/personnel/" @@ -297,6 +372,7 @@ class OrganizationAnalyticsApiTest(APITestCase): personnel_response.data["organization_id"], str(self.organization.id) ) self.assertEqual(personnel_response.data["report_year"], 2026) + self.assertEqual(personnel_response.data["report_month"], 6) self.assertEqual( personnel_response.data["headcount"]["average_employees"], 1050, @@ -306,6 +382,7 @@ class OrganizationAnalyticsApiTest(APITestCase): set(personnel_response.data["history"][0]), { "year", + "report_month", "average_employees", "avg_payroll_employees", "average_age", @@ -313,15 +390,22 @@ class OrganizationAnalyticsApiTest(APITestCase): }, ) self.assertEqual(len(personnel_response.data["age_distribution"]), 3) - self.assertIn("average_age", personnel_response.data) + self.assertEqual(personnel_response.data["average_age"], 42.0) self.assertEqual( personnel_response.data["headcount"]["avg_payroll_employees"], 995 ) - self.assertEqual(personnel_response.data["headcount"]["payroll_fund"], 1000000) + self.assertEqual( + personnel_response.data["headcount"]["payroll_fund"], 1000000000 + ) + self.assertIsNone(personnel_response.data["headcount"]["production_workers"]) self.assertEqual( personnel_response.data["age_distribution"][0]["age_group"], "under_30", ) + self.assertEqual( + personnel_response.data["age_distribution"][0]["employees_count"], + 22, + ) self.assertIn("employees_count", personnel_response.data["age_distribution"][0]) def test_yearly_analytics_use_latest_available_year_when_requested_year_is_empty( @@ -374,15 +458,19 @@ class OrganizationAnalyticsApiTest(APITestCase): }, ) self.assertEqual(response.data["summary"]["total_equipment"], 187) + self.assertEqual(response.data["summary"]["machine_tools_and_equipment"], 46) + self.assertIsNone(response.data["summary"]["domestic_equipment"]) + self.assertEqual(response.data["summary"]["imported_equipment"], 96) self.assertEqual(response.data["summary"]["physical_wear_percent"], 32.0) - self.assertEqual(response.data["summary"]["weighted_wear_percent"], 32.0) + self.assertIsNone(response.data["summary"]["weighted_wear_percent"]) 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["summary"]["commissioned_equipment"], 12) + self.assertEqual(response.data["summary"]["decommissioned_equipment"], 7) self.assertEqual( response.data["age_distribution"][0]["bucket"], "under_5_years" ) self.assertEqual(len(response.data["age_distribution"]), 5) + self.assertEqual(response.data["age_distribution"][0]["imported_equipment"], 35) self.assertEqual( set(response.data["age_distribution"][0]), { @@ -400,13 +488,13 @@ class OrganizationAnalyticsApiTest(APITestCase): response.data["categories"][0], { "category": "Станочное оборудование", - "total_equipment": 54, - "domestic_equipment": 31, - "imported_equipment": 23, - "physical_wear_percent": 28.4, - "weighted_wear_percent": 28.4, + "total_equipment": 187, + "domestic_equipment": None, + "imported_equipment": 96, + "physical_wear_percent": None, + "weighted_wear_percent": None, "utilization_rate": 0.92, - "lease_share_itn_percent": None, + "lease_share_itn_percent": 20.0, }, )