Add initial implementations for forms and organization apps with serializers, factories, and admin configurations
Some checks failed
CI/CD Pipeline / Code Quality Checks (push) Failing after 5m5s
CI/CD Pipeline / Run Tests (push) Failing after 5m5s
CI/CD Pipeline / Build Docker Images (push) Has been skipped
CI/CD Pipeline / Push to Gitea Registry (push) Has been skipped
CI/CD Pipeline / Deploy to Server (push) Has been skipped

This commit is contained in:
2026-02-17 09:26:08 +01:00
parent fd2adf9ab4
commit 8ed3e1175c
119 changed files with 9091 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Приложение формы Ф-6 (Возрастная структура оборудования)."""

39
src/apps/form_6/admin.py Normal file
View File

@@ -0,0 +1,39 @@
"""Админка формы Ф-6."""
from apps.form_6.models import FormF6Record
from django.contrib import admin
@admin.register(FormF6Record)
class FormF6RecordAdmin(admin.ModelAdmin):
"""Админка записей формы Ф-6."""
list_display = [
"organization", "load_batch", "row_code", "category",
"total_equipment", "physical_wear_percent", "created_at",
]
list_filter = ["load_batch", "created_at"]
search_fields = ["organization__name", "organization__inn", "row_code", "category"]
readonly_fields = ["id", "created_at", "updated_at"]
raw_id_fields = ["organization"]
ordering = ["-created_at"]
fieldsets = [
("Основная информация", {"fields": ["id", "organization", "load_batch"]}),
("Категоризация", {"fields": ["row_code", "category"]}),
("Общие данные", {
"fields": ["total_equipment", "domestic_equipment", "imported_equipment"],
}),
("Возрастная структура", {
"fields": ["age_under_5", "age_5_10", "age_10_15", "age_15_20", "age_over_20"],
}),
("С ЧПУ по возрасту", {
"fields": ["cnc_total", "cnc_under_5", "cnc_5_10", "cnc_10_15", "cnc_15_20", "cnc_over_20"],
"classes": ["collapse"],
}),
("Показатели использования", {
"fields": ["avg_shift_work", "utilization_rate", "physical_wear_percent"],
}),
("Потребности", {"fields": ["workplaces_without_equipment", "equipment_to_replace"]}),
("Системные поля", {"fields": ["created_at", "updated_at"], "classes": ["collapse"]}),
]

58
src/apps/form_6/api.py Normal file
View File

@@ -0,0 +1,58 @@
"""API формы Ф-6."""
import logging
from apps.core.viewsets import BaseViewSet
from apps.form_6.models import FormF6Record
from apps.form_6.serializers import (
FormF6ParseResultSerializer,
FormF6RecordListSerializer,
FormF6RecordSerializer,
FormF6UploadSerializer,
)
from apps.form_6.services import FormF6Service, parse_form_f6_file
from apps.form_6.tasks import process_form_f6_file
from rest_framework import status
from rest_framework.parsers import MultiPartParser
from rest_framework.response import Response
from rest_framework.views import APIView
logger = logging.getLogger(__name__)
BACKGROUND_THRESHOLD = 1024 * 1024
class FormF6UploadView(APIView):
parser_classes = [MultiPartParser]
def post(self, request):
serializer = FormF6UploadSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
file = serializer.validated_data["file"]
if file.size > BACKGROUND_THRESHOLD:
task = process_form_f6_file.delay(file.read(), file.name)
return Response(
{"success": True, "message": "Файл поставлен в очередь", "task_id": task.id},
status=status.HTTP_202_ACCEPTED,
)
try:
result = parse_form_f6_file(file)
return Response({"success": True, "data": FormF6ParseResultSerializer(result).data})
except Exception as e:
logger.exception("Ошибка обработки файла Ф-6")
return Response({"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
class FormF6RecordViewSet(BaseViewSet):
queryset = FormF6Record.objects.select_related("organization").all()
serializer_class = FormF6RecordSerializer
service_class = FormF6Service
def get_serializer_class(self):
return FormF6RecordListSerializer if self.action == "list" else FormF6RecordSerializer
def get_queryset(self):
qs = super().get_queryset()
batch_id = self.request.query_params.get("batch_id")
return qs.filter(load_batch=batch_id) if batch_id else qs

11
src/apps/form_6/apps.py Normal file
View File

@@ -0,0 +1,11 @@
"""Конфигурация приложения form_6."""
from django.apps import AppConfig
class Form6Config(AppConfig):
"""Конфигурация приложения формы Ф-6."""
default_auto_field = "django.db.models.BigAutoField"
name = "apps.form_6"
verbose_name = "Форма Ф-6 (Возрастная структура оборудования)"

View File

@@ -0,0 +1,65 @@
# Generated by Django 3.2.25 on 2026-02-06 12:49
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
('organization', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='FormF6Record',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время создания записи', verbose_name='создано')),
('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления', verbose_name='обновлено')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='ID')),
('load_batch', models.PositiveIntegerField(db_index=True, help_text='Идентификатор пакета загрузки', verbose_name='номер загрузки')),
('row_code', models.CharField(blank=True, default='', max_length=20, verbose_name='код строки')),
('category', models.CharField(blank=True, default='', max_length=200, verbose_name='категория оборудования')),
('total_equipment', models.PositiveIntegerField(blank=True, null=True, verbose_name='всего оборудования')),
('domestic_equipment', models.PositiveIntegerField(blank=True, null=True, verbose_name='отечественное оборудование')),
('imported_equipment', models.PositiveIntegerField(blank=True, null=True, verbose_name='импортное оборудование')),
('age_under_5', models.PositiveIntegerField(blank=True, null=True, verbose_name='до 5 лет')),
('age_5_10', models.PositiveIntegerField(blank=True, null=True, verbose_name='5-10 лет')),
('age_10_15', models.PositiveIntegerField(blank=True, null=True, verbose_name='10-15 лет')),
('age_15_20', models.PositiveIntegerField(blank=True, null=True, verbose_name='15-20 лет')),
('age_over_20', models.PositiveIntegerField(blank=True, null=True, verbose_name='свыше 20 лет')),
('cnc_total', models.PositiveIntegerField(blank=True, null=True, verbose_name='с ЧПУ всего')),
('cnc_under_5', models.PositiveIntegerField(blank=True, null=True, verbose_name='с ЧПУ до 5 лет')),
('cnc_5_10', models.PositiveIntegerField(blank=True, null=True, verbose_name='с ЧПУ 5-10 лет')),
('cnc_10_15', models.PositiveIntegerField(blank=True, null=True, verbose_name='с ЧПУ 10-15 лет')),
('cnc_15_20', models.PositiveIntegerField(blank=True, null=True, verbose_name='с ЧПУ 15-20 лет')),
('cnc_over_20', models.PositiveIntegerField(blank=True, null=True, verbose_name='с ЧПУ свыше 20 лет')),
('avg_shift_work', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='средняя сменность работы')),
('utilization_rate', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='коэффициент загрузки')),
('physical_wear_percent', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='физический износ, %')),
('workplaces_without_equipment', models.PositiveIntegerField(blank=True, null=True, verbose_name='рабочие места без оборудования')),
('equipment_to_replace', models.PositiveIntegerField(blank=True, null=True, verbose_name='оборудование к замене')),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='form_f6_records', to='organization.organization', verbose_name='организация')),
],
options={
'verbose_name': 'запись Ф-6',
'verbose_name_plural': 'записи Ф-6',
'ordering': ['-created_at'],
},
),
migrations.AddIndex(
model_name='formf6record',
index=models.Index(fields=['organization', 'load_batch'], name='form_6_form_organiz_2cf6ba_idx'),
),
migrations.AddIndex(
model_name='formf6record',
index=models.Index(fields=['load_batch'], name='form_6_form_load_ba_faecd4_idx'),
),
migrations.AddIndex(
model_name='formf6record',
index=models.Index(fields=['row_code'], name='form_6_form_row_cod_97ebc4_idx'),
),
]

View File

148
src/apps/form_6/models.py Normal file
View File

@@ -0,0 +1,148 @@
"""
Модели формы Ф-6 (Возрастная структура оборудования).
Содержит:
- FormF6Record - запись формы Ф-6 (сводная информация по возрастным группам)
"""
import uuid
from apps.core.mixins import TimestampMixin
from apps.organization.models import Organization
from django.db import models
from django.utils.translation import gettext_lazy as _
class FormF6Record(TimestampMixin, models.Model):
"""
Запись формы Ф-6 (Возрастная структура оборудования).
Сводные данные о возрастной структуре оборудования по категориям.
"""
id = models.UUIDField(
primary_key=True,
default=uuid.uuid4,
editable=False,
verbose_name=_("ID"),
)
organization = models.ForeignKey(
Organization,
on_delete=models.CASCADE,
related_name="form_f6_records",
verbose_name=_("организация"),
)
load_batch = models.PositiveIntegerField(
_("номер загрузки"),
db_index=True,
help_text=_("Идентификатор пакета загрузки"),
)
# === Категоризация ===
row_code = models.CharField(
_("код строки"),
max_length=20, blank=True, default="",
)
category = models.CharField(
_("категория оборудования"),
max_length=200, blank=True, default="",
)
# === Общие данные ===
total_equipment = models.PositiveIntegerField(
_("всего оборудования"),
null=True, blank=True,
)
domestic_equipment = models.PositiveIntegerField(
_("отечественное оборудование"),
null=True, blank=True,
)
imported_equipment = models.PositiveIntegerField(
_("импортное оборудование"),
null=True, blank=True,
)
# === Возрастная структура ===
age_under_5 = models.PositiveIntegerField(
_("до 5 лет"),
null=True, blank=True,
)
age_5_10 = models.PositiveIntegerField(
_("5-10 лет"),
null=True, blank=True,
)
age_10_15 = models.PositiveIntegerField(
_("10-15 лет"),
null=True, blank=True,
)
age_15_20 = models.PositiveIntegerField(
_("15-20 лет"),
null=True, blank=True,
)
age_over_20 = models.PositiveIntegerField(
_("свыше 20 лет"),
null=True, blank=True,
)
# === С ЧПУ по возрасту ===
cnc_total = models.PositiveIntegerField(
_("с ЧПУ всего"),
null=True, blank=True,
)
cnc_under_5 = models.PositiveIntegerField(
_("с ЧПУ до 5 лет"),
null=True, blank=True,
)
cnc_5_10 = models.PositiveIntegerField(
_("с ЧПУ 5-10 лет"),
null=True, blank=True,
)
cnc_10_15 = models.PositiveIntegerField(
_("с ЧПУ 10-15 лет"),
null=True, blank=True,
)
cnc_15_20 = models.PositiveIntegerField(
_("с ЧПУ 15-20 лет"),
null=True, blank=True,
)
cnc_over_20 = models.PositiveIntegerField(
_("с ЧПУ свыше 20 лет"),
null=True, blank=True,
)
# === Показатели использования ===
avg_shift_work = models.DecimalField(
_("средняя сменность работы"),
max_digits=5, decimal_places=2, null=True, blank=True,
)
utilization_rate = models.DecimalField(
_("коэффициент загрузки"),
max_digits=5, decimal_places=2, null=True, blank=True,
)
physical_wear_percent = models.DecimalField(
_("физический износ, %"),
max_digits=5, decimal_places=2, null=True, blank=True,
)
# === Потребности ===
workplaces_without_equipment = models.PositiveIntegerField(
_("рабочие места без оборудования"),
null=True, blank=True,
)
equipment_to_replace = models.PositiveIntegerField(
_("оборудование к замене"),
null=True, blank=True,
)
class Meta:
verbose_name = _("запись Ф-6")
verbose_name_plural = _("записи Ф-6")
ordering = ["-created_at"]
indexes = [
models.Index(fields=["organization", "load_batch"]),
models.Index(fields=["load_batch"]),
models.Index(fields=["row_code"]),
]
def __str__(self) -> str:
return f"Ф-6: {self.category or self.row_code} ({self.organization.name})"

View File

@@ -0,0 +1,58 @@
"""Сериализаторы формы Ф-6."""
from apps.form_6.models import FormF6Record
from apps.organization.serializers import OrganizationSerializer
from rest_framework import serializers
class FormF6RecordSerializer(serializers.ModelSerializer):
organization = OrganizationSerializer(read_only=True)
class Meta:
model = FormF6Record
fields = "__all__"
read_only_fields = ["id", "created_at", "updated_at"]
class FormF6RecordListSerializer(serializers.ModelSerializer):
organization_name = serializers.CharField(source="organization.name", read_only=True)
organization_inn = serializers.CharField(source="organization.inn", read_only=True)
class Meta:
model = FormF6Record
fields = [
"id", "organization_name", "organization_inn", "load_batch",
"row_code", "category", "total_equipment", "physical_wear_percent",
"created_at",
]
class FormF6UploadSerializer(serializers.Serializer):
file = serializers.FileField(help_text="Excel файл формы Ф-6 (.xlsx)")
def validate_file(self, value):
if not value.name.endswith((".xlsx", ".xls")):
raise serializers.ValidationError("Неподдерживаемый формат файла")
if value.size > 50 * 1024 * 1024:
raise serializers.ValidationError("Размер файла превышает 50MB")
return value
class FieldErrorSerializer(serializers.Serializer):
field = serializers.CharField()
message = serializers.CharField()
class RowValidationErrorSerializer(serializers.Serializer):
row = serializers.IntegerField()
inn = serializers.CharField(allow_null=True)
kpp = serializers.CharField(allow_null=True)
organization_name = serializers.CharField(allow_null=True)
errors = FieldErrorSerializer(many=True)
class FormF6ParseResultSerializer(serializers.Serializer):
batch_id = serializers.IntegerField()
loaded_count = serializers.IntegerField()
skipped_count = serializers.IntegerField()
errors = RowValidationErrorSerializer(many=True)

112
src/apps/form_6/services.py Normal file
View File

@@ -0,0 +1,112 @@
"""
Сервисы для работы с формой Ф-6.
Содержит:
- FormF6Service - CRUD операции
- FormF6Parser - парсинг Excel
"""
import logging
from typing import Any
from apps.core.excel import (
BaseExcelParser,
ColumnMapping,
ParseResult,
RowData,
)
from apps.core.services import BaseService, BulkOperationsMixin
from apps.form_6.models import FormF6Record
from apps.organization.services import OrganizationService
from django.db import transaction
from django.db.models import Count, Max
logger = logging.getLogger(__name__)
class FormF6Service(BulkOperationsMixin, BaseService[FormF6Record]):
"""Сервис для работы с записями формы Ф-6."""
model = FormF6Record
@classmethod
def get_by_batch(cls, batch_id: int):
return cls.get_queryset().filter(load_batch=batch_id)
@classmethod
def get_next_batch_id(cls) -> int:
max_batch = cls.model.objects.aggregate(max_batch=Max("load_batch"))
return (max_batch["max_batch"] or 0) + 1
@classmethod
def get_batches(cls) -> list[dict[str, Any]]:
return list(
cls.model.objects.values("load_batch")
.annotate(count=Count("id"), created_at=Max("created_at"))
.order_by("-load_batch")
)
class FormF6Parser(BaseExcelParser[FormF6Record]):
"""Парсер Excel файла формы Ф-6 (Возрастная структура оборудования)."""
ORG_NAME_COLUMN = 0
OKPO_COLUMN = 1
OGRN_COLUMN = 2
INN_COLUMN = 3
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(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", field_type="int"),
ColumnMapping(24, "Оборудование к замене", "equipment_to_replace", field_type="int"),
]
def get_next_batch_id(self) -> int:
return FormF6Service.get_next_batch_id()
@transaction.atomic
def create_record(self, row_data: RowData, batch_id: int) -> FormF6Record:
org, _ = OrganizationService.get_or_create_by_inn(
inn=row_data.inn,
defaults={
"name": row_data.organization_name,
"ogrn": row_data.ogrn or "",
"okpo": row_data.okpo or "",
"kpp": row_data.kpp or "",
},
)
return FormF6Record.objects.create(
organization=org,
load_batch=batch_id,
**row_data.fields,
)
def parse_form_f6_file(file) -> ParseResult:
parser = FormF6Parser()
return parser.parse(file)

19
src/apps/form_6/tasks.py Normal file
View File

@@ -0,0 +1,19 @@
"""Celery задачи для формы Ф-6."""
import logging
from io import BytesIO
from apps.core.tasks import TrackedTask
from apps.form_6.services import FormF6Parser
from celery import shared_task
logger = logging.getLogger(__name__)
@shared_task(bind=True, base=TrackedTask)
def process_form_f6_file(self, file_content: bytes, file_name: str) -> dict:
logger.info(f"Начало обработки файла Ф-6: {file_name}")
parser = FormF6Parser()
result = parser.parse(BytesIO(file_content))
logger.info(f"Обработка Ф-6 завершена: {result.loaded_count} загружено, {result.skipped_count} пропущено")
return result.to_dict()

13
src/apps/form_6/urls.py Normal file
View File

@@ -0,0 +1,13 @@
"""URL маршруты формы Ф-6."""
from apps.form_6.api import FormF6RecordViewSet, FormF6UploadView
from django.urls import include, path
from rest_framework.routers import DefaultRouter
router = DefaultRouter()
router.register("records", FormF6RecordViewSet, basename="form-f6-records")
urlpatterns = [
path("upload/", FormF6UploadView.as_view(), name="form-f6-upload"),
path("", include(router.urls)),
]