Add initial implementations for forms and organization apps with serializers, factories, and admin configurations
Some checks failed
CI/CD Pipeline / Run Tests (push) Failing after 45s
CI/CD Pipeline / Code Quality Checks (push) Failing after 48s
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-03-28 18:23:06 +01:00
parent 8ed3e1175c
commit 345b1d0cc8
201 changed files with 15097 additions and 6691 deletions

View File

@@ -1,39 +1,157 @@
"""Админка формы Ф-6."""
from apps.core.admin_mixins import HiddenFromAdminIndexMixin
from apps.core.admin_paper_forms import PaperFormPreviewAdminMixin
from apps.form_6.models import FormF6Record
from django.contrib import admin
@admin.register(FormF6Record)
class FormF6RecordAdmin(admin.ModelAdmin):
class FormF6RecordAdmin(
HiddenFromAdminIndexMixin,
PaperFormPreviewAdminMixin,
admin.ModelAdmin,
):
"""Админка записей формы Ф-6."""
paper_form_title = "Форма Ф-6. Возрастная структура оборудования"
paper_form_sections = (
("Категоризация", ("row_code", "category")),
(
"Общие данные",
(
"total_equipment",
"domestic_equipment",
"imported_equipment",
),
),
(
"Возрастная структура",
(
"age_under_5",
"age_5_10",
"age_10_15",
"age_15_20",
"age_over_20",
),
),
(
"С ЧПУ по возрасту",
(
"cnc_total",
"cnc_under_5",
"cnc_5_10",
"cnc_10_15",
"cnc_15_20",
"cnc_over_20",
),
),
(
"Показатели использования",
(
"avg_shift_work",
"utilization_rate",
"physical_wear_percent",
),
),
("Потребности", ("workplaces_without_equipment", "equipment_to_replace")),
)
list_display = [
"organization", "load_batch", "row_code", "category",
"total_equipment", "physical_wear_percent", "created_at",
"organization",
"report_period_admin",
"version_status_admin",
"load_batch",
"row_code",
"category",
"total_equipment",
"physical_wear_percent",
"created_at",
]
list_filter = ["load_batch", "created_at"]
list_filter = ["report_year", "report_quarter", "is_active_version", "load_batch", "created_at"]
search_fields = ["organization__name", "organization__inn", "row_code", "category"]
readonly_fields = ["id", "created_at", "updated_at"]
readonly_fields = [
"id",
"created_at",
"updated_at",
"paper_form_preview",
"report_period_admin",
"version_status_admin",
"superseded_at",
"superseded_by_batch",
]
raw_id_fields = ["organization"]
ordering = ["-created_at"]
ordering = ["-is_active_version", "-report_year", "-report_quarter", "-created_at"]
fieldsets = [
("Основная информация", {"fields": ["id", "organization", "load_batch"]}),
(
"Основная информация",
{"fields": ["id", "organization", "load_batch", "report_year", "report_quarter"]},
),
(
"Версия записи",
{
"fields": [
"report_period_admin",
"version_status_admin",
"superseded_at",
"superseded_by_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"]}),
(
"Общие данные",
{
"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"]},
),
]

View File

@@ -2,7 +2,7 @@
import logging
from apps.core.viewsets import BaseViewSet
from apps.core.viewsets import ReadOnlyViewSet
from apps.form_6.models import FormF6Record
from apps.form_6.serializers import (
FormF6ParseResultSerializer,
@@ -10,10 +10,11 @@ from apps.form_6.serializers import (
FormF6RecordSerializer,
FormF6UploadSerializer,
)
from apps.form_6.services import FormF6Service, parse_form_f6_file
from apps.form_6.services import 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.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -28,31 +29,64 @@ class FormF6UploadView(APIView):
serializer = FormF6UploadSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
file = serializer.validated_data["file"]
report_year = serializer.validated_data["report_year"]
report_quarter = serializer.validated_data.get("report_quarter")
if file.size > BACKGROUND_THRESHOLD:
task = process_form_f6_file.delay(file.read(), file.name)
task = process_form_f6_file.delay(
file.read(),
file.name,
report_year,
report_quarter,
)
return Response(
{"success": True, "message": "Файл поставлен в очередь", "task_id": task.id},
{
"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})
result = parse_form_f6_file(
file,
report_year=report_year,
report_quarter=report_quarter,
)
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)
return Response(
{"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST
)
class FormF6RecordViewSet(BaseViewSet):
queryset = FormF6Record.objects.select_related("organization").all()
class FormF6RecordViewSet(ReadOnlyViewSet[FormF6Record]):
queryset = FormF6Record.objects.select_related("organization").filter(
is_active_version=True
)
serializer_class = FormF6RecordSerializer
service_class = FormF6Service
permission_classes = [IsAuthenticated]
def get_serializer_class(self):
return FormF6RecordListSerializer if self.action == "list" else FormF6RecordSerializer
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
report_year = self.request.query_params.get("report_year")
report_quarter = self.request.query_params.get("report_quarter")
if batch_id:
qs = qs.filter(load_batch=batch_id)
if report_year:
qs = qs.filter(report_year=report_year)
if report_quarter:
qs = qs.filter(report_quarter=report_quarter)
return qs

View File

@@ -0,0 +1,52 @@
# Generated by Django 3.2.25 on 2026-03-28 16:21
import apps.core.mixins
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('form_6', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='formf6record',
name='is_active_version',
field=models.BooleanField(db_index=True, default=True, help_text='Текущая версия записи за указанный период', verbose_name='актуальная версия'),
),
migrations.AddField(
model_name='formf6record',
name='report_quarter',
field=models.PositiveSmallIntegerField(blank=True, db_index=True, help_text='Квартал отчетности от 1 до 4. Пусто для годовой формы.', null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4)], verbose_name='отчетный квартал'),
),
migrations.AddField(
model_name='formf6record',
name='report_year',
field=models.PositiveSmallIntegerField(db_index=True, default=apps.core.mixins.current_report_year, help_text='Календарный год, к которому относится отчетность', verbose_name='отчетный год'),
),
migrations.AddField(
model_name='formf6record',
name='superseded_at',
field=models.DateTimeField(blank=True, help_text='Когда запись была заменена новой загрузкой', null=True, verbose_name='дата замены'),
),
migrations.AddField(
model_name='formf6record',
name='superseded_by_batch',
field=models.PositiveIntegerField(blank=True, db_index=True, help_text='Номер пакета загрузки, который заменил запись', null=True, verbose_name='заменено пакетом'),
),
migrations.AddIndex(
model_name='formf6record',
index=models.Index(fields=['organization', 'report_year', 'report_quarter', 'is_active_version'], name='form_6_form_organiz_48fa4c_idx'),
),
migrations.AddIndex(
model_name='formf6record',
index=models.Index(fields=['report_year', 'report_quarter', 'is_active_version'], name='form_6_form_report__bd33ea_idx'),
),
migrations.AddConstraint(
model_name='formf6record',
constraint=models.CheckConstraint(check=models.Q(('report_quarter__isnull', True), models.Q(('report_quarter__gte', 1), ('report_quarter__lte', 4)), _connector='OR'), name='form_6_f6_report_quarter_range'),
),
]

View File

@@ -7,13 +7,13 @@
import uuid
from apps.core.mixins import TimestampMixin
from apps.core.mixins import ReportingPeriodMixin, 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):
class FormF6Record(ReportingPeriodMixin, TimestampMixin, models.Model):
"""
Запись формы Ф-6 (Возрастная структура оборудования).
@@ -41,97 +41,126 @@ class FormF6Record(TimestampMixin, models.Model):
# === Категоризация ===
row_code = models.CharField(
_("код строки"),
max_length=20, blank=True, default="",
max_length=20,
blank=True,
default="",
)
category = models.CharField(
_("категория оборудования"),
max_length=200, blank=True, default="",
max_length=200,
blank=True,
default="",
)
# === Общие данные ===
total_equipment = models.PositiveIntegerField(
_("всего оборудования"),
null=True, blank=True,
null=True,
blank=True,
)
domestic_equipment = models.PositiveIntegerField(
_("отечественное оборудование"),
null=True, blank=True,
null=True,
blank=True,
)
imported_equipment = models.PositiveIntegerField(
_("импортное оборудование"),
null=True, blank=True,
null=True,
blank=True,
)
# === Возрастная структура ===
age_under_5 = models.PositiveIntegerField(
_("до 5 лет"),
null=True, blank=True,
null=True,
blank=True,
)
age_5_10 = models.PositiveIntegerField(
_("5-10 лет"),
null=True, blank=True,
null=True,
blank=True,
)
age_10_15 = models.PositiveIntegerField(
_("10-15 лет"),
null=True, blank=True,
null=True,
blank=True,
)
age_15_20 = models.PositiveIntegerField(
_("15-20 лет"),
null=True, blank=True,
null=True,
blank=True,
)
age_over_20 = models.PositiveIntegerField(
_("свыше 20 лет"),
null=True, blank=True,
null=True,
blank=True,
)
# === С ЧПУ по возрасту ===
cnc_total = models.PositiveIntegerField(
_("с ЧПУ всего"),
null=True, blank=True,
null=True,
blank=True,
)
cnc_under_5 = models.PositiveIntegerField(
_("с ЧПУ до 5 лет"),
null=True, blank=True,
null=True,
blank=True,
)
cnc_5_10 = models.PositiveIntegerField(
_("с ЧПУ 5-10 лет"),
null=True, blank=True,
null=True,
blank=True,
)
cnc_10_15 = models.PositiveIntegerField(
_("с ЧПУ 10-15 лет"),
null=True, blank=True,
null=True,
blank=True,
)
cnc_15_20 = models.PositiveIntegerField(
_("с ЧПУ 15-20 лет"),
null=True, blank=True,
null=True,
blank=True,
)
cnc_over_20 = models.PositiveIntegerField(
_("с ЧПУ свыше 20 лет"),
null=True, blank=True,
null=True,
blank=True,
)
# === Показатели использования ===
avg_shift_work = models.DecimalField(
_("средняя сменность работы"),
max_digits=5, decimal_places=2, null=True, blank=True,
max_digits=5,
decimal_places=2,
null=True,
blank=True,
)
utilization_rate = models.DecimalField(
_("коэффициент загрузки"),
max_digits=5, decimal_places=2, null=True, blank=True,
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,
max_digits=5,
decimal_places=2,
null=True,
blank=True,
)
# === Потребности ===
workplaces_without_equipment = models.PositiveIntegerField(
_("рабочие места без оборудования"),
null=True, blank=True,
null=True,
blank=True,
)
equipment_to_replace = models.PositiveIntegerField(
_("оборудование к замене"),
null=True, blank=True,
null=True,
blank=True,
)
class Meta:
@@ -142,6 +171,22 @@ class FormF6Record(TimestampMixin, models.Model):
models.Index(fields=["organization", "load_batch"]),
models.Index(fields=["load_batch"]),
models.Index(fields=["row_code"]),
models.Index(
fields=[
"organization",
"report_year",
"report_quarter",
"is_active_version",
]
),
models.Index(fields=["report_year", "report_quarter", "is_active_version"]),
]
constraints = [
models.CheckConstraint(
check=models.Q(report_quarter__isnull=True)
| models.Q(report_quarter__gte=1, report_quarter__lte=4),
name="form_6_f6_report_quarter_range",
),
]
def __str__(self) -> str:

View File

@@ -7,28 +7,49 @@ from rest_framework import serializers
class FormF6RecordSerializer(serializers.ModelSerializer):
organization = OrganizationSerializer(read_only=True)
report_period_display = serializers.CharField(read_only=True)
class Meta:
model = FormF6Record
fields = "__all__"
read_only_fields = ["id", "created_at", "updated_at"]
read_only_fields = ["id", "created_at", "updated_at", "report_period_display"]
class FormF6RecordListSerializer(serializers.ModelSerializer):
organization_name = serializers.CharField(source="organization.name", read_only=True)
organization_name = serializers.CharField(
source="organization.name", read_only=True
)
organization_inn = serializers.CharField(source="organization.inn", read_only=True)
report_period_display = serializers.CharField(read_only=True)
class Meta:
model = FormF6Record
fields = [
"id", "organization_name", "organization_inn", "load_batch",
"row_code", "category", "total_equipment", "physical_wear_percent",
"id",
"organization_name",
"organization_inn",
"load_batch",
"report_year",
"report_quarter",
"report_period_display",
"row_code",
"category",
"total_equipment",
"physical_wear_percent",
"created_at",
]
class FormF6UploadSerializer(serializers.Serializer):
file = serializers.FileField(help_text="Excel файл формы Ф-6 (.xlsx)")
report_year = serializers.IntegerField(min_value=2000, help_text="Отчетный год")
report_quarter = serializers.IntegerField(
min_value=1,
max_value=4,
required=False,
allow_null=True,
help_text="Отчетный квартал от 1 до 4. Пусто для годовой формы.",
)
def validate_file(self, value):
if not value.name.endswith((".xlsx", ".xls")):

View File

@@ -15,6 +15,7 @@ from apps.core.excel import (
ParseResult,
RowData,
)
from apps.core.reporting import ReportingPeriodParserMixin, VersionedReportServiceMixin
from apps.core.services import BaseService, BulkOperationsMixin
from apps.form_6.models import FormF6Record
from apps.organization.services import OrganizationService
@@ -24,15 +25,35 @@ from django.db.models import Count, Max
logger = logging.getLogger(__name__)
class FormF6Service(BulkOperationsMixin, BaseService[FormF6Record]):
class FormF6Service(
BulkOperationsMixin,
VersionedReportServiceMixin[FormF6Record],
BaseService[FormF6Record],
):
"""Сервис для работы с записями формы Ф-6."""
model = FormF6Record
@classmethod
def get_by_organization(cls, organization_id):
"""Получить записи организации."""
return cls.get_queryset().filter(organization_id=organization_id)
@classmethod
def get_by_batch(cls, batch_id: int):
return cls.get_queryset().filter(load_batch=batch_id)
@classmethod
def get_by_load_batch(cls, batch_id: int):
"""Совместимость со старым API сервиса."""
return cls.get_by_batch(batch_id)
@classmethod
def delete_by_load_batch(cls, batch_id: int) -> int:
"""Удалить записи по номеру загрузки."""
deleted_count, _ = cls.get_queryset().filter(load_batch=batch_id).delete()
return deleted_count
@classmethod
def get_next_batch_id(cls) -> int:
max_batch = cls.model.objects.aggregate(max_batch=Max("load_batch"))
@@ -47,7 +68,7 @@ class FormF6Service(BulkOperationsMixin, BaseService[FormF6Record]):
)
class FormF6Parser(BaseExcelParser[FormF6Record]):
class FormF6Parser(ReportingPeriodParserMixin, BaseExcelParser[FormF6Record]):
"""Парсер Excel файла формы Ф-6 (Возрастная структура оборудования)."""
ORG_NAME_COLUMN = 0
@@ -62,8 +83,12 @@ class FormF6Parser(BaseExcelParser[FormF6Record]):
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(
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"),
@@ -78,19 +103,38 @@ class FormF6Parser(BaseExcelParser[FormF6Record]):
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(
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"),
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:
def create_record(
self,
row_data: RowData | dict[str, Any],
batch_id: int | None = None,
) -> FormF6Record:
row_data = self._normalize_row_data(row_data)
batch_id = batch_id or getattr(self, "load_batch", self.get_next_batch_id())
org, _ = OrganizationService.get_or_create_by_inn(
inn=row_data.inn,
defaults={
@@ -100,13 +144,20 @@ class FormF6Parser(BaseExcelParser[FormF6Record]):
"kpp": row_data.kpp or "",
},
)
return FormF6Record.objects.create(
return FormF6Service.create_versioned_record(
organization=org,
load_batch=batch_id,
report_year=self.report_year,
report_quarter=self.report_quarter,
**row_data.fields,
)
def parse_form_f6_file(file) -> ParseResult:
parser = FormF6Parser()
def parse_form_f6_file(
file,
*,
report_year: int,
report_quarter: int | None = None,
) -> ParseResult:
parser = FormF6Parser(report_year=report_year, report_quarter=report_quarter)
return parser.parse(file)

View File

@@ -11,9 +11,17 @@ logger = logging.getLogger(__name__)
@shared_task(bind=True, base=TrackedTask)
def process_form_f6_file(self, file_content: bytes, file_name: str) -> dict:
def process_form_f6_file(
self,
file_content: bytes,
file_name: str,
report_year: int,
report_quarter: int | None = None,
) -> dict:
logger.info(f"Начало обработки файла Ф-6: {file_name}")
parser = FormF6Parser()
parser = FormF6Parser(report_year=report_year, report_quarter=report_quarter)
result = parser.parse(BytesIO(file_content))
logger.info(f"Обработка Ф-6 завершена: {result.loaded_count} загружено, {result.skipped_count} пропущено")
logger.info(
f"Обработка Ф-6 завершена: {result.loaded_count} загружено, {result.skipped_count} пропущено"
)
return result.to_dict()