748 lines
24 KiB
Python
748 lines
24 KiB
Python
"""
|
||
Сериализаторы для модуля закупок.
|
||
"""
|
||
|
||
from rest_framework import serializers
|
||
|
||
from procurement.constants import get_default_approval_step_name
|
||
from procurement.models import (
|
||
Approval,
|
||
Equipment,
|
||
EquipmentCategory,
|
||
MaintenanceRecord,
|
||
ProcurementItem,
|
||
ProcurementRequest,
|
||
Supplier,
|
||
)
|
||
|
||
|
||
class ProcurementItemSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для позиций заявки."""
|
||
|
||
total_price = serializers.DecimalField(
|
||
max_digits=12,
|
||
decimal_places=2,
|
||
read_only=True
|
||
)
|
||
|
||
class Meta:
|
||
model = ProcurementItem
|
||
fields = [
|
||
'id',
|
||
'request',
|
||
'name',
|
||
'description',
|
||
'quantity',
|
||
'unit',
|
||
'estimated_unit_price',
|
||
'total_price',
|
||
'supplier_info',
|
||
'equipment',
|
||
'created_at',
|
||
]
|
||
read_only_fields = ['id', 'created_at', 'total_price']
|
||
|
||
|
||
class ProcurementItemCreateSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для создания позиций (без request)."""
|
||
|
||
class Meta:
|
||
model = ProcurementItem
|
||
fields = [
|
||
'name',
|
||
'description',
|
||
'quantity',
|
||
'unit',
|
||
'estimated_unit_price',
|
||
'supplier_info',
|
||
]
|
||
|
||
|
||
class ApprovalSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для согласований."""
|
||
|
||
approver_name = serializers.CharField(
|
||
source='approver.get_full_name',
|
||
read_only=True
|
||
)
|
||
status_display = serializers.CharField(
|
||
source='get_status_display',
|
||
read_only=True
|
||
)
|
||
step_label = serializers.SerializerMethodField()
|
||
|
||
def get_step_label(self, obj):
|
||
return obj.step_name or get_default_approval_step_name(obj.priority)
|
||
|
||
class Meta:
|
||
model = Approval
|
||
fields = [
|
||
'id',
|
||
'request',
|
||
'approver',
|
||
'approver_name',
|
||
'priority',
|
||
'step_name',
|
||
'step_label',
|
||
'status',
|
||
'status_display',
|
||
'comment',
|
||
'created_at',
|
||
'updated_at',
|
||
]
|
||
read_only_fields = [
|
||
'id',
|
||
'created_at',
|
||
'updated_at',
|
||
'approver_name',
|
||
'step_name',
|
||
'step_label',
|
||
'status_display',
|
||
]
|
||
|
||
|
||
class ProcurementRequestListSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для списка заявок (краткий)."""
|
||
|
||
department_name = serializers.CharField(
|
||
source='department.name',
|
||
read_only=True
|
||
)
|
||
requestor_name = serializers.CharField(
|
||
source='requestor.get_full_name',
|
||
read_only=True
|
||
)
|
||
status_display = serializers.CharField(
|
||
source='get_status_display',
|
||
read_only=True
|
||
)
|
||
urgency_display = serializers.CharField(
|
||
source='get_urgency_display',
|
||
read_only=True
|
||
)
|
||
items_count = serializers.IntegerField(read_only=True)
|
||
total_cost = serializers.DecimalField(
|
||
max_digits=12,
|
||
decimal_places=2,
|
||
read_only=True
|
||
)
|
||
executor_name = serializers.CharField(
|
||
source='executor.get_full_name',
|
||
read_only=True,
|
||
allow_null=True
|
||
)
|
||
|
||
class Meta:
|
||
model = ProcurementRequest
|
||
fields = [
|
||
'id',
|
||
'title',
|
||
'department',
|
||
'department_name',
|
||
'requestor',
|
||
'requestor_name',
|
||
'executor',
|
||
'executor_name',
|
||
'status',
|
||
'status_display',
|
||
'urgency',
|
||
'urgency_display',
|
||
'total_cost',
|
||
'items_count',
|
||
'created_at',
|
||
'submitted_at',
|
||
'started_at',
|
||
]
|
||
read_only_fields = [
|
||
'id',
|
||
'created_at',
|
||
'submitted_at',
|
||
'started_at',
|
||
'department_name',
|
||
'requestor_name',
|
||
'executor_name',
|
||
'status_display',
|
||
'urgency_display',
|
||
'items_count',
|
||
'total_cost',
|
||
]
|
||
|
||
def update(self, instance, validated_data):
|
||
"""
|
||
При обновлении заявки запрещаем изменение requestor и department.
|
||
Эти поля игнорируются при обновлении.
|
||
"""
|
||
# Игнорируем попытки изменить requestor
|
||
validated_data.pop('requestor', None)
|
||
# Игнорируем попытки изменить department
|
||
validated_data.pop('department', None)
|
||
return super().update(instance, validated_data)
|
||
|
||
|
||
class ProcurementRequestDetailSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для детальной информации о заявке."""
|
||
|
||
department_name = serializers.CharField(
|
||
source='department.name',
|
||
read_only=True
|
||
)
|
||
requestor_name = serializers.CharField(
|
||
source='requestor.get_full_name',
|
||
read_only=True
|
||
)
|
||
requestor_email = serializers.CharField(
|
||
source='requestor.email',
|
||
read_only=True
|
||
)
|
||
executor_name = serializers.CharField(
|
||
source='executor.get_full_name',
|
||
read_only=True,
|
||
allow_null=True
|
||
)
|
||
status_display = serializers.CharField(
|
||
source='get_status_display',
|
||
read_only=True
|
||
)
|
||
urgency_display = serializers.CharField(
|
||
source='get_urgency_display',
|
||
read_only=True
|
||
)
|
||
total_cost = serializers.DecimalField(
|
||
max_digits=12,
|
||
decimal_places=2,
|
||
read_only=True
|
||
)
|
||
items = ProcurementItemSerializer(many=True, read_only=True)
|
||
approvals = ApprovalSerializer(many=True, read_only=True)
|
||
required_approval_priorities = serializers.ListField(
|
||
source='get_required_approval_priorities',
|
||
read_only=True
|
||
)
|
||
is_editable = serializers.BooleanField(read_only=True)
|
||
|
||
class Meta:
|
||
model = ProcurementRequest
|
||
fields = [
|
||
'id',
|
||
'title',
|
||
'description',
|
||
'department',
|
||
'department_name',
|
||
'requestor',
|
||
'requestor_name',
|
||
'requestor_email',
|
||
'executor',
|
||
'executor_name',
|
||
'status',
|
||
'status_display',
|
||
'urgency',
|
||
'urgency_display',
|
||
'total_cost',
|
||
'actual_cost',
|
||
'items',
|
||
'approvals',
|
||
'required_approval_priorities',
|
||
'is_editable',
|
||
'created_at',
|
||
'updated_at',
|
||
'submitted_at',
|
||
'started_at',
|
||
'completed_at',
|
||
]
|
||
read_only_fields = [
|
||
'id',
|
||
'created_at',
|
||
'updated_at',
|
||
'submitted_at',
|
||
'started_at',
|
||
'completed_at',
|
||
'is_editable',
|
||
'total_cost',
|
||
'executor_name',
|
||
]
|
||
|
||
|
||
class ProcurementRequestCreateSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для создания заявки с позициями."""
|
||
|
||
items = ProcurementItemCreateSerializer(many=True, required=False)
|
||
|
||
class Meta:
|
||
model = ProcurementRequest
|
||
fields = [
|
||
'id',
|
||
'title',
|
||
'description',
|
||
'department',
|
||
'requestor',
|
||
'urgency',
|
||
'items',
|
||
'status',
|
||
'created_at',
|
||
]
|
||
read_only_fields = ['id', 'requestor', 'status', 'created_at']
|
||
|
||
def create(self, validated_data):
|
||
"""Создать заявку с позициями."""
|
||
items_data = validated_data.pop('items', [])
|
||
request = self.context['request']
|
||
|
||
# Создаем заявку
|
||
procurement_request = ProcurementRequest.objects.create(
|
||
requestor=request.user,
|
||
**validated_data
|
||
)
|
||
|
||
# Создаем позиции (если они переданы)
|
||
for item_data in items_data:
|
||
ProcurementItem.objects.create(
|
||
request=procurement_request,
|
||
**item_data
|
||
)
|
||
|
||
return procurement_request
|
||
|
||
|
||
class EquipmentCategorySerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для категорий оборудования."""
|
||
|
||
full_path = serializers.CharField(read_only=True)
|
||
children_count = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = EquipmentCategory
|
||
fields = [
|
||
'id',
|
||
'name',
|
||
'parent',
|
||
'description',
|
||
'icon',
|
||
'full_path',
|
||
'children_count',
|
||
'created_at',
|
||
]
|
||
read_only_fields = ['id', 'created_at', 'full_path']
|
||
|
||
def get_children_count(self, obj):
|
||
"""Количество подкатегорий."""
|
||
return obj.children.count()
|
||
|
||
|
||
class EquipmentListSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для списка оборудования."""
|
||
|
||
category_name = serializers.CharField(
|
||
source='category.name',
|
||
read_only=True
|
||
)
|
||
category_icon = serializers.CharField(
|
||
source='category.icon',
|
||
read_only=True,
|
||
default='bi-box-seam'
|
||
)
|
||
department_name = serializers.CharField(
|
||
source='department.name',
|
||
read_only=True
|
||
)
|
||
responsible_name = serializers.CharField(
|
||
source='responsible_person.get_full_name',
|
||
read_only=True
|
||
)
|
||
status_display = serializers.CharField(
|
||
source='get_status_display',
|
||
read_only=True
|
||
)
|
||
is_under_warranty = serializers.BooleanField(read_only=True)
|
||
comments_count = serializers.IntegerField(read_only=True, default=0)
|
||
|
||
class Meta:
|
||
model = Equipment
|
||
fields = [
|
||
'id',
|
||
'name',
|
||
'inventory_number',
|
||
'serial_number',
|
||
'category',
|
||
'category_name',
|
||
'category_icon',
|
||
'status',
|
||
'status_display',
|
||
'department',
|
||
'department_name',
|
||
'responsible_person',
|
||
'responsible_name',
|
||
'location',
|
||
'purchase_date',
|
||
'purchase_cost',
|
||
'notes',
|
||
'is_under_warranty',
|
||
'comments_count',
|
||
'created_at',
|
||
'updated_at',
|
||
]
|
||
read_only_fields = [
|
||
'id',
|
||
'inventory_number',
|
||
'category_name',
|
||
'category_icon',
|
||
'department_name',
|
||
'responsible_name',
|
||
'status_display',
|
||
'is_under_warranty',
|
||
'comments_count',
|
||
'created_at',
|
||
'updated_at',
|
||
]
|
||
|
||
def create(self, validated_data):
|
||
"""Автоматическая генерация инвентарного номера при создании."""
|
||
import re
|
||
from datetime import datetime
|
||
|
||
year = datetime.now().year
|
||
prefix = f"INV-{year}-"
|
||
|
||
# Находим максимальный номер с текущим префиксом
|
||
last_equipment = Equipment.objects.filter(
|
||
inventory_number__startswith=prefix
|
||
).order_by('-inventory_number').first()
|
||
|
||
if last_equipment:
|
||
match = re.search(r'(\d+)$', last_equipment.inventory_number)
|
||
if match:
|
||
next_num = int(match.group(1)) + 1
|
||
else:
|
||
next_num = 1
|
||
else:
|
||
next_num = 1
|
||
|
||
validated_data['inventory_number'] = f"{prefix}{next_num:04d}"
|
||
return super().create(validated_data)
|
||
|
||
def _get_user_permission_level(self, user):
|
||
"""Определяет уровень прав пользователя.
|
||
|
||
Возвращает:
|
||
'full' - админ/модельные права (свободный выбор отдела/ответственного)
|
||
'dept_head' - начальник отдела (свой отдел, выбор ответственного)
|
||
'scoped' - скоуп-право (свой отдел, ответственный = начальник)
|
||
None - нет прав на создание
|
||
"""
|
||
from api.v1.permissions import has_dept_perm
|
||
from employees.constants import DeptPerm
|
||
from employees.models import Department, EmployeeDepartment
|
||
|
||
if not user or not user.is_authenticated:
|
||
return None
|
||
|
||
# Админ или модельные права = полный доступ
|
||
if (user.is_staff or user.is_superuser or
|
||
user.has_perm('procurement.add_equipment')):
|
||
return 'full'
|
||
|
||
# Проверяем, является ли начальником какого-то отдела
|
||
headed_depts = Department.objects.filter(head_id=user.id)
|
||
if headed_depts.exists():
|
||
return 'dept_head'
|
||
|
||
# Проверяем скоуп-право в активных отделах
|
||
user_dept_links = EmployeeDepartment.objects.filter(
|
||
employee_id=user.id,
|
||
is_active=True
|
||
).select_related('department', 'role')
|
||
|
||
for link in user_dept_links:
|
||
if has_dept_perm(
|
||
user, link.department_id, DeptPerm.MANAGE_EQUIPMENT
|
||
):
|
||
return 'scoped'
|
||
|
||
return None
|
||
|
||
def _get_user_allowed_departments(self, user, perm_level):
|
||
"""Возвращает отделы, где пользователь может создать оборудование."""
|
||
from employees.models import Department, EmployeeDepartment
|
||
|
||
if perm_level == 'full':
|
||
return list(Department.objects.all())
|
||
|
||
if perm_level == 'dept_head':
|
||
return list(Department.objects.filter(head_id=user.id))
|
||
|
||
if perm_level == 'scoped':
|
||
from api.v1.permissions import has_dept_perm
|
||
from employees.constants import DeptPerm
|
||
|
||
result = []
|
||
user_dept_links = EmployeeDepartment.objects.filter(
|
||
employee_id=user.id,
|
||
is_active=True
|
||
).select_related('department')
|
||
|
||
for link in user_dept_links:
|
||
if has_dept_perm(
|
||
user, link.department_id, DeptPerm.MANAGE_EQUIPMENT
|
||
):
|
||
result.append(link.department)
|
||
return result
|
||
|
||
return []
|
||
|
||
def validate(self, attrs):
|
||
"""Проверка и автозаполнение полей в зависимости от прав.
|
||
|
||
Уровни прав:
|
||
- full (админ/модельные права): свободный выбор отдела и ответственного
|
||
- dept_head (начальник): только свой отдел, выбор ответственного из отдела
|
||
- scoped (скоуп-право): только свой отдел, ответственный = начальник
|
||
"""
|
||
from employees.models import EmployeeDepartment
|
||
|
||
request = self.context.get('request')
|
||
user = request.user if request else None
|
||
|
||
# При update (есть instance) используем облегчённую валидацию
|
||
if self.instance is not None:
|
||
return self._validate_update(attrs, user)
|
||
|
||
# Создание — полная проверка прав
|
||
perm_level = self._get_user_permission_level(user)
|
||
|
||
# Проверяем отдел
|
||
department = attrs.get('department')
|
||
|
||
if perm_level == 'full':
|
||
# Полный доступ — можно указать любой отдел
|
||
pass
|
||
|
||
elif perm_level == 'dept_head':
|
||
# Начальник — только свои отделы
|
||
allowed_depts = self._get_user_allowed_departments(
|
||
user, perm_level
|
||
)
|
||
allowed_ids = [d.id for d in allowed_depts]
|
||
|
||
if department and department.id not in allowed_ids:
|
||
raise serializers.ValidationError({
|
||
'department': 'Вы можете создавать оборудование '
|
||
'только в своём отделе.'
|
||
})
|
||
|
||
# Если отдел не указан — берём первый
|
||
if not department and allowed_depts:
|
||
attrs['department'] = allowed_depts[0]
|
||
department = allowed_depts[0]
|
||
|
||
# Ответственный по умолчанию — сам начальник
|
||
resp = attrs.get('responsible_person')
|
||
if resp is None:
|
||
attrs['responsible_person'] = user
|
||
|
||
elif perm_level == 'scoped':
|
||
# Скоуп-право — только свой отдел, ответственный = начальник
|
||
allowed_depts = self._get_user_allowed_departments(
|
||
user, perm_level
|
||
)
|
||
allowed_ids = [d.id for d in allowed_depts]
|
||
|
||
if department and department.id not in allowed_ids:
|
||
raise serializers.ValidationError({
|
||
'department': 'Вы можете создавать оборудование '
|
||
'только в своём отделе.'
|
||
})
|
||
|
||
# Если отдел не указан — берём первый
|
||
if not department and allowed_depts:
|
||
attrs['department'] = allowed_depts[0]
|
||
department = allowed_depts[0]
|
||
|
||
# Ответственный ВСЕГДА = начальник отдела (без выбора)
|
||
if department and department.head:
|
||
attrs['responsible_person'] = department.head
|
||
else:
|
||
attrs['responsible_person'] = None
|
||
|
||
else:
|
||
# Нет прав — ошибка
|
||
raise serializers.ValidationError({
|
||
'non_field_errors': 'У вас нет прав на создание оборудования.'
|
||
})
|
||
|
||
# Валидация ответственного
|
||
return self._validate_responsible_in_department(attrs)
|
||
|
||
def _validate_update(self, attrs, user):
|
||
"""Валидация при обновлении (облегчённая)."""
|
||
# При update не проверяем права создания,
|
||
# только валидацию ответственного если он меняется
|
||
department = attrs.get('department', self.instance.department)
|
||
responsible = attrs.get('responsible_person')
|
||
|
||
# Если меняется ответственный — проверяем принадлежность к отделу
|
||
if responsible is not None:
|
||
attrs['department'] = department # Для валидации
|
||
attrs['responsible_person'] = responsible
|
||
return self._validate_responsible_in_department(attrs)
|
||
|
||
return attrs
|
||
|
||
def _validate_responsible_in_department(self, attrs):
|
||
"""Проверяет, что ответственный состоит в отделе оборудования."""
|
||
from employees.models import EmployeeDepartment
|
||
|
||
department = attrs.get('department')
|
||
responsible = attrs.get('responsible_person')
|
||
|
||
if department and responsible:
|
||
# Проверяем, что ответственный в этом отделе
|
||
is_in_dept = (
|
||
# Он начальник отдела
|
||
department.head_id == responsible.id or
|
||
# Или состоит в отделе
|
||
EmployeeDepartment.objects.filter(
|
||
employee_id=responsible.id,
|
||
department_id=department.id,
|
||
is_active=True
|
||
).exists()
|
||
)
|
||
|
||
if not is_in_dept:
|
||
raise serializers.ValidationError({
|
||
'responsible_person': (
|
||
'Ответственный должен состоять в том же отделе, '
|
||
'что и оборудование.'
|
||
)
|
||
})
|
||
|
||
return attrs
|
||
|
||
|
||
class EquipmentDetailSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для детальной информации об оборудовании."""
|
||
|
||
category_name = serializers.CharField(
|
||
source='category.name',
|
||
read_only=True
|
||
)
|
||
department_name = serializers.CharField(
|
||
source='department.name',
|
||
read_only=True
|
||
)
|
||
responsible_name = serializers.CharField(
|
||
source='responsible_person.get_full_name',
|
||
read_only=True
|
||
)
|
||
status_display = serializers.CharField(
|
||
source='get_status_display',
|
||
read_only=True
|
||
)
|
||
is_under_warranty = serializers.BooleanField(read_only=True)
|
||
maintenance_count = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = Equipment
|
||
fields = [
|
||
'id',
|
||
'name',
|
||
'inventory_number',
|
||
'serial_number',
|
||
'category',
|
||
'category_name',
|
||
'status',
|
||
'status_display',
|
||
'department',
|
||
'department_name',
|
||
'responsible_person',
|
||
'responsible_name',
|
||
'location',
|
||
'purchase_date',
|
||
'warranty_until',
|
||
'is_under_warranty',
|
||
'purchase_cost',
|
||
'notes',
|
||
'maintenance_count',
|
||
'created_at',
|
||
'updated_at',
|
||
]
|
||
read_only_fields = [
|
||
'id',
|
||
'created_at',
|
||
'updated_at',
|
||
'is_under_warranty',
|
||
'maintenance_count',
|
||
]
|
||
|
||
def get_maintenance_count(self, obj):
|
||
"""Количество записей обслуживания."""
|
||
return obj.maintenance_history.count()
|
||
|
||
|
||
class MaintenanceRecordSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для записей обслуживания."""
|
||
|
||
equipment_name = serializers.CharField(
|
||
source='equipment.name',
|
||
read_only=True
|
||
)
|
||
equipment_inventory = serializers.CharField(
|
||
source='equipment.inventory_number',
|
||
read_only=True
|
||
)
|
||
performed_by_name = serializers.CharField(
|
||
source='performed_by.get_full_name',
|
||
read_only=True
|
||
)
|
||
type_display = serializers.CharField(
|
||
source='get_type_display',
|
||
read_only=True
|
||
)
|
||
|
||
class Meta:
|
||
model = MaintenanceRecord
|
||
fields = [
|
||
'id',
|
||
'equipment',
|
||
'equipment_name',
|
||
'equipment_inventory',
|
||
'date',
|
||
'type',
|
||
'type_display',
|
||
'description',
|
||
'cost',
|
||
'performed_by',
|
||
'performed_by_name',
|
||
'next_maintenance_date',
|
||
'created_at',
|
||
]
|
||
read_only_fields = [
|
||
'id',
|
||
'created_at',
|
||
'equipment_name',
|
||
'equipment_inventory',
|
||
'performed_by_name',
|
||
'type_display',
|
||
]
|
||
|
||
|
||
class SupplierSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для поставщиков."""
|
||
|
||
class Meta:
|
||
model = Supplier
|
||
fields = [
|
||
'id',
|
||
'name',
|
||
'contact_person',
|
||
'phone',
|
||
'email',
|
||
'address',
|
||
'website',
|
||
'inn',
|
||
'rating',
|
||
'is_active',
|
||
'notes',
|
||
'created_at',
|
||
'updated_at',
|
||
]
|
||
read_only_fields = ['id', 'created_at', 'updated_at']
|