1634 lines
55 KiB
Python
1634 lines
55 KiB
Python
"""
|
||
Сериализаторы для модуля закупок.
|
||
"""
|
||
|
||
import re
|
||
from datetime import date
|
||
|
||
from drf_spectacular.utils import extend_schema_field
|
||
from django.contrib.auth import get_user_model
|
||
from django.db import transaction
|
||
from rest_framework import serializers
|
||
|
||
from communications import comments_helpers
|
||
from procurement.constants import (
|
||
ProcurementItemExecutionStatus,
|
||
ProcurementStatus,
|
||
get_default_approval_step_name,
|
||
)
|
||
from procurement.services import ProcurementApprovalResolver
|
||
from procurement.models import (
|
||
Approval,
|
||
Budget,
|
||
Equipment,
|
||
EquipmentCategory,
|
||
MaintenanceRecord,
|
||
ApprovalRoute,
|
||
ProcurementItem,
|
||
ProcurementItemAttachment,
|
||
ProcurementRequestActivity,
|
||
ProcurementRequest,
|
||
ProcurementRequestView,
|
||
ProcurementSettings,
|
||
Supplier,
|
||
)
|
||
from ..employees.serializers import DepartmentBriefSerializer, EmployeeBriefSerializer
|
||
|
||
User = get_user_model()
|
||
|
||
|
||
PROBLEM_ITEM_STATUSES = {
|
||
ProcurementItemExecutionStatus.REJECTED,
|
||
ProcurementItemExecutionStatus.COMPLETED_WITH_ISSUE,
|
||
ProcurementItemExecutionStatus.EDITED,
|
||
ProcurementItemExecutionStatus.DEFECTIVE,
|
||
}
|
||
|
||
|
||
class ProcurementRequestActivitySerializer(serializers.ModelSerializer):
|
||
actor = EmployeeBriefSerializer(read_only=True)
|
||
action_display = serializers.CharField(
|
||
source="get_action_display",
|
||
read_only=True,
|
||
)
|
||
|
||
class Meta:
|
||
model = ProcurementRequestActivity
|
||
fields = [
|
||
"id",
|
||
"action",
|
||
"action_display",
|
||
"actor",
|
||
"object_kind",
|
||
"object_id",
|
||
"metadata",
|
||
"created_at",
|
||
]
|
||
read_only_fields = fields
|
||
|
||
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||
UNIT_DISPLAY_LABELS = {
|
||
"шт": "штуки",
|
||
"компл": "комплекты",
|
||
"упак": "упаковки",
|
||
"короб": "короба",
|
||
"пачка": "пачки",
|
||
"кг": "килограммы",
|
||
"г": "граммы",
|
||
"л": "литры",
|
||
"мл": "миллилитры",
|
||
"м": "метры",
|
||
"кв. м": "кв. метры",
|
||
"куб. м": "куб. метры",
|
||
"рулон": "рулоны",
|
||
"пара": "пары",
|
||
"услуга": "услуги",
|
||
"час": "часы",
|
||
}
|
||
|
||
|
||
def _normalize_unit(value):
|
||
return re.sub(r"\s+", " ", str(value or "").strip()).lower()
|
||
|
||
|
||
def _unit_display_label(value):
|
||
unit = _normalize_unit(value)
|
||
if not unit:
|
||
return "единиц"
|
||
return UNIT_DISPLAY_LABELS.get(unit, unit)
|
||
|
||
|
||
def _validate_expected_delivery_dates(value):
|
||
if value in (None, ""):
|
||
return []
|
||
if not isinstance(value, list):
|
||
raise serializers.ValidationError("Ожидаемые даты должны быть списком.")
|
||
|
||
result = []
|
||
for raw in value:
|
||
item = str(raw or "").strip()
|
||
if not item:
|
||
continue
|
||
if not DATE_RE.fullmatch(item):
|
||
raise serializers.ValidationError(
|
||
"Каждая ожидаемая дата должна быть в формате YYYY-MM-DD."
|
||
)
|
||
try:
|
||
parsed = date.fromisoformat(item)
|
||
except ValueError as exc:
|
||
raise serializers.ValidationError(
|
||
"Каждая ожидаемая дата должна быть корректной календарной датой."
|
||
) from exc
|
||
result.append(parsed.isoformat())
|
||
return result
|
||
|
||
|
||
def _expected_delivery_date_values(item):
|
||
dates = []
|
||
for raw in item.expected_delivery_dates or []:
|
||
if not isinstance(raw, str) or not DATE_RE.fullmatch(raw):
|
||
continue
|
||
try:
|
||
dates.append(date.fromisoformat(raw))
|
||
except ValueError:
|
||
continue
|
||
return dates
|
||
|
||
|
||
def _validate_item_quantities(attrs, instance=None):
|
||
quantity = attrs.get("quantity", getattr(instance, "quantity", None))
|
||
for field in ("ordered_quantity", "received_quantity"):
|
||
value = attrs.get(field, getattr(instance, field, None))
|
||
if value is not None and quantity is not None and value > quantity:
|
||
raise serializers.ValidationError(
|
||
{field: "Не может быть больше количества позиции"}
|
||
)
|
||
return attrs
|
||
|
||
|
||
def _validate_processing_department_available(processing_department):
|
||
if not processing_department:
|
||
return
|
||
|
||
settings = ProcurementSettings.get_solo()
|
||
available_departments = settings.available_processing_departments.all()
|
||
if (
|
||
available_departments.exists()
|
||
and not available_departments.filter(pk=processing_department.pk).exists()
|
||
):
|
||
raise serializers.ValidationError(
|
||
{
|
||
"processing_department": (
|
||
"Выберите отдел-исполнитель из доступных для закупок."
|
||
)
|
||
}
|
||
)
|
||
|
||
|
||
def _request_items(obj):
|
||
return list(obj.items.all())
|
||
|
||
|
||
def _effective_ordered_quantity(item):
|
||
return item.effective_ordered_quantity
|
||
|
||
|
||
def _effective_received_quantity(item):
|
||
return item.effective_received_quantity
|
||
|
||
|
||
class ProcurementRequestSummaryMixin(serializers.Serializer):
|
||
is_viewed = serializers.SerializerMethodField()
|
||
last_arrival_notice_at = serializers.DateTimeField(
|
||
read_only=True,
|
||
allow_null=True,
|
||
)
|
||
next_expected_delivery_date = serializers.SerializerMethodField()
|
||
items_total_count = serializers.SerializerMethodField()
|
||
items_received_count = serializers.SerializerMethodField()
|
||
items_problem_count = serializers.SerializerMethodField()
|
||
items_pending_count = serializers.SerializerMethodField()
|
||
total_requested_quantity = serializers.SerializerMethodField()
|
||
total_ordered_quantity = serializers.SerializerMethodField()
|
||
total_received_quantity = serializers.SerializerMethodField()
|
||
quantity_unit_label = serializers.SerializerMethodField()
|
||
linked_tasks = serializers.SerializerMethodField()
|
||
|
||
@extend_schema_field(serializers.BooleanField())
|
||
def get_is_viewed(self, obj):
|
||
annotated_value = getattr(obj, "is_viewed_for_current_user", None)
|
||
if annotated_value is not None:
|
||
return bool(annotated_value)
|
||
|
||
request = self.context.get("request")
|
||
user = getattr(request, "user", None)
|
||
if not user or not user.is_authenticated:
|
||
return False
|
||
|
||
return ProcurementRequestView.objects.filter(
|
||
request=obj,
|
||
user=user,
|
||
is_viewed=True,
|
||
).exists()
|
||
|
||
@extend_schema_field(serializers.DateField(allow_null=True))
|
||
def get_next_expected_delivery_date(self, obj):
|
||
items = _request_items(obj)
|
||
all_dates = []
|
||
pending_dates = []
|
||
all_items_received = all(
|
||
item.execution_status == ProcurementItemExecutionStatus.RECEIVED
|
||
for item in items
|
||
)
|
||
for item in items:
|
||
item_dates = _expected_delivery_date_values(item)
|
||
if not item_dates:
|
||
continue
|
||
all_dates.extend(item_dates)
|
||
if item.execution_status != ProcurementItemExecutionStatus.RECEIVED:
|
||
pending_dates.extend(item_dates)
|
||
|
||
if not all_dates:
|
||
return None
|
||
|
||
if pending_dates:
|
||
return min(pending_dates).isoformat()
|
||
if all_items_received:
|
||
return max(all_dates).isoformat()
|
||
return None
|
||
|
||
@extend_schema_field(serializers.IntegerField())
|
||
def get_items_total_count(self, obj):
|
||
return len(_request_items(obj))
|
||
|
||
@extend_schema_field(serializers.IntegerField())
|
||
def get_items_received_count(self, obj):
|
||
return sum(
|
||
1 for item in _request_items(obj)
|
||
if item.execution_status == ProcurementItemExecutionStatus.RECEIVED
|
||
)
|
||
|
||
@extend_schema_field(serializers.IntegerField())
|
||
def get_items_problem_count(self, obj):
|
||
return sum(
|
||
1 for item in _request_items(obj)
|
||
if item.execution_status in PROBLEM_ITEM_STATUSES
|
||
)
|
||
|
||
@extend_schema_field(serializers.IntegerField())
|
||
def get_items_pending_count(self, obj):
|
||
return sum(
|
||
1 for item in _request_items(obj)
|
||
if item.execution_status == ProcurementItemExecutionStatus.PENDING
|
||
)
|
||
|
||
@extend_schema_field(serializers.IntegerField())
|
||
def get_total_requested_quantity(self, obj):
|
||
return sum(item.quantity for item in _request_items(obj))
|
||
|
||
@extend_schema_field(serializers.IntegerField())
|
||
def get_total_ordered_quantity(self, obj):
|
||
return sum(_effective_ordered_quantity(item) for item in _request_items(obj))
|
||
|
||
@extend_schema_field(serializers.IntegerField())
|
||
def get_total_received_quantity(self, obj):
|
||
return sum(_effective_received_quantity(item) for item in _request_items(obj))
|
||
|
||
@extend_schema_field(serializers.CharField())
|
||
def get_quantity_unit_label(self, obj):
|
||
units = {
|
||
_normalize_unit(item.unit)
|
||
for item in _request_items(obj)
|
||
if _normalize_unit(item.unit)
|
||
}
|
||
if len(units) == 1:
|
||
return _unit_display_label(next(iter(units)))
|
||
return "единиц"
|
||
|
||
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
|
||
def get_linked_tasks(self, obj: ProcurementRequest) -> list[dict]:
|
||
prefetched = getattr(obj, "_linked_task_payloads", None)
|
||
if prefetched is not None:
|
||
return prefetched
|
||
|
||
request = self.context.get("request")
|
||
user = getattr(request, "user", None)
|
||
if not user or not getattr(user, "is_authenticated", False):
|
||
return []
|
||
|
||
try:
|
||
from django.contrib.contenttypes.models import ContentType
|
||
from tasks.access import task_board_access_q
|
||
from tasks.models import (
|
||
TaskBoard,
|
||
TaskLinkedObject,
|
||
TaskLinkedObjectKind,
|
||
)
|
||
except Exception:
|
||
return []
|
||
|
||
content_type = ContentType.objects.get_for_model(ProcurementRequest)
|
||
accessible_boards = TaskBoard.objects.filter(
|
||
is_archived=False,
|
||
).filter(task_board_access_q(user))
|
||
|
||
links = (
|
||
TaskLinkedObject.objects.filter(
|
||
kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST,
|
||
content_type=content_type,
|
||
object_id=obj.id,
|
||
task__board__in=accessible_boards,
|
||
)
|
||
.select_related("task", "task__board", "task__column")
|
||
.order_by("task__title", "task_id")
|
||
)
|
||
|
||
return [
|
||
{
|
||
"link_id": link.id,
|
||
"id": link.task_id,
|
||
"title": link.task.title,
|
||
"board_id": link.task.board_id,
|
||
"board_name": link.task.board.name,
|
||
"column_id": link.task.column_id,
|
||
"column_name": link.task.column.name,
|
||
"column_color": link.task.column.color,
|
||
"priority": link.task.priority,
|
||
"priority_display": link.task.get_priority_display(),
|
||
}
|
||
for link in links
|
||
]
|
||
|
||
|
||
class ProcurementItemAttachmentSerializer(serializers.ModelSerializer):
|
||
uploaded_by_name = serializers.CharField(
|
||
source="uploaded_by.get_full_name",
|
||
read_only=True,
|
||
)
|
||
download_url = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = ProcurementItemAttachment
|
||
fields = [
|
||
"id",
|
||
"file_name",
|
||
"file_size",
|
||
"mime_type",
|
||
"uploaded_by",
|
||
"uploaded_by_name",
|
||
"created_at",
|
||
"download_url",
|
||
]
|
||
read_only_fields = fields
|
||
|
||
def get_download_url(self, obj):
|
||
return (
|
||
f"/api/v1/procurement/items/{obj.item_id}/"
|
||
f"attachments/{obj.id}/"
|
||
)
|
||
|
||
|
||
class ProcurementItemSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для позиций заявки."""
|
||
|
||
estimated_unit_price = serializers.DecimalField(
|
||
max_digits=12,
|
||
decimal_places=2,
|
||
required=False,
|
||
allow_null=True,
|
||
)
|
||
ordered_quantity = serializers.IntegerField(
|
||
required=False,
|
||
allow_null=True,
|
||
min_value=0,
|
||
)
|
||
received_quantity = serializers.IntegerField(
|
||
required=False,
|
||
allow_null=True,
|
||
min_value=0,
|
||
)
|
||
expected_delivery_dates = serializers.ListField(
|
||
child=serializers.CharField(allow_blank=False, trim_whitespace=True),
|
||
required=False,
|
||
allow_empty=True,
|
||
)
|
||
initial_comment = serializers.CharField(
|
||
write_only=True,
|
||
required=False,
|
||
allow_blank=True,
|
||
)
|
||
total_price = serializers.DecimalField(
|
||
max_digits=12, decimal_places=2, read_only=True
|
||
)
|
||
execution_status_display = serializers.SerializerMethodField()
|
||
comments_count = serializers.SerializerMethodField()
|
||
attachments = ProcurementItemAttachmentSerializer(many=True, read_only=True)
|
||
attachments_count = serializers.IntegerField(
|
||
source="attachments.count",
|
||
read_only=True,
|
||
)
|
||
|
||
@extend_schema_field(serializers.IntegerField())
|
||
def get_comments_count(self, obj):
|
||
annotated_count = getattr(obj, "comments_count", None)
|
||
if annotated_count is not None:
|
||
return annotated_count
|
||
|
||
from communications.comments_helpers import get_comment_count
|
||
|
||
return get_comment_count(obj)
|
||
|
||
@extend_schema_field(serializers.CharField())
|
||
def get_execution_status_display(self, obj):
|
||
return obj.execution_status_display
|
||
|
||
class Meta:
|
||
model = ProcurementItem
|
||
fields = [
|
||
"id",
|
||
"request",
|
||
"name",
|
||
"description",
|
||
"quantity",
|
||
"unit",
|
||
"estimated_unit_price",
|
||
"total_price",
|
||
"ordered_quantity",
|
||
"received_quantity",
|
||
"supplier_info",
|
||
"links",
|
||
"expected_delivery_dates",
|
||
"actual_unit_price",
|
||
"execution_status",
|
||
"execution_status_display",
|
||
"executor_comment",
|
||
"initial_comment",
|
||
"comments_count",
|
||
"attachments",
|
||
"attachments_count",
|
||
"equipment",
|
||
"created_at",
|
||
]
|
||
read_only_fields = [
|
||
"id",
|
||
"created_at",
|
||
"total_price",
|
||
"execution_status_display",
|
||
"comments_count",
|
||
"attachments",
|
||
"attachments_count",
|
||
]
|
||
|
||
def validate(self, attrs):
|
||
return _validate_item_quantities(attrs, self.instance)
|
||
|
||
def validate_expected_delivery_dates(self, value):
|
||
return _validate_expected_delivery_dates(value)
|
||
|
||
def create(self, validated_data):
|
||
initial_comment = validated_data.pop("initial_comment", "").strip()
|
||
item = super().create(validated_data)
|
||
request = self.context.get("request")
|
||
author = getattr(request, "user", None)
|
||
if initial_comment and author and author.is_authenticated:
|
||
comments_helpers.create_comment(
|
||
obj=item,
|
||
author=author,
|
||
content=initial_comment,
|
||
)
|
||
return item
|
||
|
||
|
||
class ProcurementItemCreateSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для создания позиций (без request)."""
|
||
|
||
estimated_unit_price = serializers.DecimalField(
|
||
max_digits=12,
|
||
decimal_places=2,
|
||
required=False,
|
||
allow_null=True,
|
||
)
|
||
ordered_quantity = serializers.IntegerField(
|
||
required=False,
|
||
allow_null=True,
|
||
min_value=0,
|
||
)
|
||
received_quantity = serializers.IntegerField(
|
||
required=False,
|
||
allow_null=True,
|
||
min_value=0,
|
||
)
|
||
expected_delivery_dates = serializers.ListField(
|
||
child=serializers.CharField(allow_blank=False, trim_whitespace=True),
|
||
required=False,
|
||
allow_empty=True,
|
||
)
|
||
initial_comment = serializers.CharField(
|
||
write_only=True,
|
||
required=False,
|
||
allow_blank=True,
|
||
)
|
||
|
||
class Meta:
|
||
model = ProcurementItem
|
||
fields = [
|
||
"id",
|
||
"name",
|
||
"description",
|
||
"quantity",
|
||
"unit",
|
||
"estimated_unit_price",
|
||
"ordered_quantity",
|
||
"received_quantity",
|
||
"supplier_info",
|
||
"links",
|
||
"expected_delivery_dates",
|
||
"actual_unit_price",
|
||
"execution_status",
|
||
"executor_comment",
|
||
"initial_comment",
|
||
]
|
||
read_only_fields = ["id"]
|
||
|
||
def validate(self, attrs):
|
||
return _validate_item_quantities(attrs, self.instance)
|
||
|
||
def validate_expected_delivery_dates(self, value):
|
||
return _validate_expected_delivery_dates(value)
|
||
|
||
|
||
class ProcurementItemEditSerializer(ProcurementItemCreateSerializer):
|
||
"""Сериализатор для редактирования позиций внутри заявки."""
|
||
|
||
id = serializers.IntegerField(required=False)
|
||
|
||
class Meta:
|
||
model = ProcurementItem
|
||
fields = [
|
||
"id",
|
||
"name",
|
||
"description",
|
||
"quantity",
|
||
"unit",
|
||
"estimated_unit_price",
|
||
"supplier_info",
|
||
"links",
|
||
"initial_comment",
|
||
]
|
||
|
||
|
||
class ProcurementRequestCreateOptionsSerializer(serializers.Serializer):
|
||
"""Опции формы создания заявки на закупку."""
|
||
|
||
processing_departments = DepartmentBriefSerializer(many=True)
|
||
default_processing_department = serializers.IntegerField(allow_null=True)
|
||
|
||
|
||
class ProcurementManualApprovalStepSerializer(serializers.Serializer):
|
||
"""Ручной этап согласования при отправке заявки."""
|
||
|
||
priority = serializers.IntegerField(min_value=1)
|
||
approver = serializers.PrimaryKeyRelatedField(
|
||
queryset=User.objects.filter(is_active=True),
|
||
)
|
||
step_name = serializers.CharField(
|
||
required=False,
|
||
allow_blank=True,
|
||
max_length=150,
|
||
)
|
||
|
||
def validate_priority(self, value):
|
||
if not ApprovalRoute.objects.filter(priority=value).exists():
|
||
raise serializers.ValidationError(
|
||
"Выберите настроенный этап согласования."
|
||
)
|
||
return value
|
||
|
||
|
||
class ProcurementManualApprovalStepsSerializer(serializers.Serializer):
|
||
"""Список ручных этапов согласования."""
|
||
|
||
approval_steps = ProcurementManualApprovalStepSerializer(
|
||
many=True,
|
||
required=False,
|
||
allow_empty=True,
|
||
)
|
||
|
||
def validate_approval_steps(self, value):
|
||
priorities = [item["priority"] for item in value]
|
||
duplicate_priorities = {
|
||
priority
|
||
for priority in priorities
|
||
if priorities.count(priority) > 1
|
||
}
|
||
if duplicate_priorities:
|
||
raise serializers.ValidationError(
|
||
"Каждый этап согласования можно выбрать только один раз."
|
||
)
|
||
return value
|
||
|
||
|
||
class ApprovalSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для согласований."""
|
||
|
||
approver = EmployeeBriefSerializer(read_only=True)
|
||
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()
|
||
|
||
@extend_schema_field(serializers.CharField())
|
||
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(
|
||
ProcurementRequestSummaryMixin,
|
||
serializers.ModelSerializer,
|
||
):
|
||
"""Сериализатор для списка заявок (краткий)."""
|
||
|
||
description = serializers.CharField(required=False, allow_blank=True)
|
||
items = ProcurementItemEditSerializer(
|
||
many=True,
|
||
required=False,
|
||
write_only=True,
|
||
)
|
||
department_name = serializers.CharField(
|
||
source="department.name", read_only=True
|
||
)
|
||
processing_department_name = serializers.CharField(
|
||
source="processing_department.name", read_only=True, allow_null=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
|
||
)
|
||
fulfillment_status_display = serializers.CharField(
|
||
source="get_fulfillment_status_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
|
||
)
|
||
comments_count = serializers.IntegerField(read_only=True, default=0)
|
||
can_current_user_approve = serializers.SerializerMethodField()
|
||
can_current_user_submit_for_approval = serializers.SerializerMethodField()
|
||
can_current_user_start_work = serializers.SerializerMethodField()
|
||
can_current_user_process_items = serializers.SerializerMethodField()
|
||
|
||
@extend_schema_field(serializers.BooleanField())
|
||
def get_can_current_user_approve(self, obj):
|
||
request = self.context.get("request")
|
||
user = getattr(request, "user", None)
|
||
return ProcurementApprovalResolver.user_can_approve(user, obj)
|
||
|
||
@extend_schema_field(serializers.BooleanField())
|
||
def get_can_current_user_submit_for_approval(self, obj):
|
||
request = self.context.get("request")
|
||
user = getattr(request, "user", None)
|
||
return ProcurementApprovalResolver.user_can_submit_for_approval(
|
||
user,
|
||
obj,
|
||
)
|
||
|
||
@extend_schema_field(serializers.BooleanField())
|
||
def get_can_current_user_start_work(self, obj):
|
||
request = self.context.get("request")
|
||
user = getattr(request, "user", None)
|
||
return ProcurementApprovalResolver.user_can_start_work(user, obj)
|
||
|
||
@extend_schema_field(serializers.BooleanField())
|
||
def get_can_current_user_process_items(self, obj):
|
||
request = self.context.get("request")
|
||
user = getattr(request, "user", None)
|
||
return ProcurementApprovalResolver.user_can_process_items(user, obj)
|
||
|
||
class Meta:
|
||
model = ProcurementRequest
|
||
fields = [
|
||
"id",
|
||
"title",
|
||
"description",
|
||
"department",
|
||
"department_name",
|
||
"processing_department",
|
||
"processing_department_name",
|
||
"requestor",
|
||
"requestor_name",
|
||
"executor",
|
||
"executor_name",
|
||
"status",
|
||
"status_display",
|
||
"urgency",
|
||
"urgency_display",
|
||
"fulfillment_status",
|
||
"fulfillment_status_display",
|
||
"is_viewed",
|
||
"last_arrival_notice_at",
|
||
"total_cost",
|
||
"items_count",
|
||
"next_expected_delivery_date",
|
||
"items_total_count",
|
||
"items_received_count",
|
||
"items_problem_count",
|
||
"items_pending_count",
|
||
"total_requested_quantity",
|
||
"total_ordered_quantity",
|
||
"total_received_quantity",
|
||
"quantity_unit_label",
|
||
"comments_count",
|
||
"linked_tasks",
|
||
"can_current_user_approve",
|
||
"can_current_user_submit_for_approval",
|
||
"can_current_user_start_work",
|
||
"can_current_user_process_items",
|
||
"items",
|
||
"created_at",
|
||
"submitted_at",
|
||
"started_at",
|
||
"completed_at",
|
||
]
|
||
read_only_fields = [
|
||
"id",
|
||
"created_at",
|
||
"submitted_at",
|
||
"started_at",
|
||
"completed_at",
|
||
"department_name",
|
||
"processing_department_name",
|
||
"requestor_name",
|
||
"executor_name",
|
||
"status_display",
|
||
"urgency_display",
|
||
"fulfillment_status",
|
||
"fulfillment_status_display",
|
||
"is_viewed",
|
||
"last_arrival_notice_at",
|
||
"items_count",
|
||
"total_cost",
|
||
"next_expected_delivery_date",
|
||
"items_total_count",
|
||
"items_received_count",
|
||
"items_problem_count",
|
||
"items_pending_count",
|
||
"total_requested_quantity",
|
||
"total_ordered_quantity",
|
||
"total_received_quantity",
|
||
"quantity_unit_label",
|
||
"comments_count",
|
||
"linked_tasks",
|
||
"can_current_user_approve",
|
||
"can_current_user_submit_for_approval",
|
||
"can_current_user_start_work",
|
||
"can_current_user_process_items",
|
||
]
|
||
|
||
def update(self, instance, validated_data):
|
||
"""
|
||
Обновить заявку и, если передан список, синхронизировать позиции.
|
||
"""
|
||
validated_data.pop("requestor", None)
|
||
validated_data.pop("department", None)
|
||
items_data = validated_data.pop("items", None)
|
||
|
||
if items_data is None:
|
||
instance = super().update(instance, validated_data)
|
||
return instance
|
||
|
||
if not items_data:
|
||
raise serializers.ValidationError(
|
||
{"items": "Добавьте хотя бы одну позицию."}
|
||
)
|
||
|
||
request = self.context.get("request")
|
||
author = getattr(request, "user", None)
|
||
with transaction.atomic():
|
||
instance = super().update(instance, validated_data)
|
||
existing_items = {item.id: item for item in instance.items.all()}
|
||
kept_item_ids = set()
|
||
|
||
for item_data in items_data:
|
||
item_id = item_data.pop("id", None)
|
||
initial_comment = item_data.pop("initial_comment", "").strip()
|
||
if item_id is not None:
|
||
item = existing_items.get(item_id)
|
||
if item is None:
|
||
raise serializers.ValidationError(
|
||
{"items": f"Позиция {item_id} не найдена в заявке."}
|
||
)
|
||
for field, value in item_data.items():
|
||
setattr(item, field, value)
|
||
item.save()
|
||
else:
|
||
item = ProcurementItem.objects.create(
|
||
request=instance,
|
||
**item_data,
|
||
)
|
||
|
||
kept_item_ids.add(item.id)
|
||
if initial_comment and author and author.is_authenticated:
|
||
comments_helpers.create_comment(
|
||
obj=item,
|
||
author=author,
|
||
content=initial_comment,
|
||
)
|
||
|
||
for item_id, item in existing_items.items():
|
||
if item_id not in kept_item_ids:
|
||
item.delete()
|
||
|
||
instance.recalculate_fulfillment_status(save=True)
|
||
return instance
|
||
|
||
def validate(self, attrs):
|
||
attrs = super().validate(attrs)
|
||
if "processing_department" in attrs:
|
||
_validate_processing_department_available(
|
||
attrs.get("processing_department")
|
||
)
|
||
items_data = attrs.get("items")
|
||
if items_data is None:
|
||
return attrs
|
||
if not items_data:
|
||
raise serializers.ValidationError(
|
||
{"items": "Добавьте хотя бы одну позицию."}
|
||
)
|
||
names = [item.get("name", "").strip() for item in items_data]
|
||
if not any(names):
|
||
raise serializers.ValidationError(
|
||
{"items": "Добавьте хотя бы одну позицию."}
|
||
)
|
||
if self.instance is not None:
|
||
valid_item_ids = set(
|
||
self.instance.items.values_list("id", flat=True)
|
||
)
|
||
incoming_item_ids = {
|
||
item["id"] for item in items_data if item.get("id") is not None
|
||
}
|
||
unknown_item_ids = incoming_item_ids - valid_item_ids
|
||
if unknown_item_ids:
|
||
raise serializers.ValidationError(
|
||
{
|
||
"items": (
|
||
"Позиция "
|
||
f"{min(unknown_item_ids)} не найдена в заявке."
|
||
)
|
||
}
|
||
)
|
||
return attrs
|
||
|
||
|
||
class ProcurementRequestDetailSerializer(
|
||
ProcurementRequestSummaryMixin,
|
||
serializers.ModelSerializer,
|
||
):
|
||
"""Сериализатор для детальной информации о заявке."""
|
||
|
||
department_name = serializers.CharField(
|
||
source="department.name", read_only=True
|
||
)
|
||
processing_department_name = serializers.CharField(
|
||
source="processing_department.name", read_only=True, allow_null=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
|
||
)
|
||
fulfillment_status_display = serializers.CharField(
|
||
source="get_fulfillment_status_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)
|
||
comments_count = serializers.IntegerField(read_only=True, default=0)
|
||
activity_count = serializers.SerializerMethodField()
|
||
can_current_user_approve = serializers.SerializerMethodField()
|
||
can_current_user_submit_for_approval = serializers.SerializerMethodField()
|
||
can_current_user_start_work = serializers.SerializerMethodField()
|
||
can_current_user_process_items = serializers.SerializerMethodField()
|
||
|
||
@extend_schema_field(serializers.BooleanField())
|
||
def get_can_current_user_approve(self, obj):
|
||
request = self.context.get("request")
|
||
user = getattr(request, "user", None)
|
||
return ProcurementApprovalResolver.user_can_approve(user, obj)
|
||
|
||
@extend_schema_field(serializers.BooleanField())
|
||
def get_can_current_user_submit_for_approval(self, obj):
|
||
request = self.context.get("request")
|
||
user = getattr(request, "user", None)
|
||
return ProcurementApprovalResolver.user_can_submit_for_approval(
|
||
user,
|
||
obj,
|
||
)
|
||
|
||
@extend_schema_field(serializers.BooleanField())
|
||
def get_can_current_user_start_work(self, obj):
|
||
request = self.context.get("request")
|
||
user = getattr(request, "user", None)
|
||
return ProcurementApprovalResolver.user_can_start_work(user, obj)
|
||
|
||
@extend_schema_field(serializers.BooleanField())
|
||
def get_can_current_user_process_items(self, obj):
|
||
request = self.context.get("request")
|
||
user = getattr(request, "user", None)
|
||
return ProcurementApprovalResolver.user_can_process_items(user, obj)
|
||
|
||
@extend_schema_field(serializers.IntegerField())
|
||
def get_activity_count(self, obj):
|
||
return obj.activities.count()
|
||
|
||
class Meta:
|
||
model = ProcurementRequest
|
||
fields = [
|
||
"id",
|
||
"title",
|
||
"description",
|
||
"department",
|
||
"department_name",
|
||
"processing_department",
|
||
"processing_department_name",
|
||
"requestor",
|
||
"requestor_name",
|
||
"requestor_email",
|
||
"executor",
|
||
"executor_name",
|
||
"status",
|
||
"status_display",
|
||
"urgency",
|
||
"urgency_display",
|
||
"fulfillment_status",
|
||
"fulfillment_status_display",
|
||
"is_viewed",
|
||
"last_arrival_notice_at",
|
||
"total_cost",
|
||
"actual_cost",
|
||
"next_expected_delivery_date",
|
||
"items_total_count",
|
||
"items_received_count",
|
||
"items_problem_count",
|
||
"items_pending_count",
|
||
"total_requested_quantity",
|
||
"total_ordered_quantity",
|
||
"total_received_quantity",
|
||
"quantity_unit_label",
|
||
"items",
|
||
"approvals",
|
||
"required_approval_priorities",
|
||
"is_editable",
|
||
"comments_count",
|
||
"activity_count",
|
||
"linked_tasks",
|
||
"can_current_user_approve",
|
||
"can_current_user_submit_for_approval",
|
||
"can_current_user_start_work",
|
||
"can_current_user_process_items",
|
||
"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",
|
||
"is_viewed",
|
||
"last_arrival_notice_at",
|
||
"total_cost",
|
||
"next_expected_delivery_date",
|
||
"items_total_count",
|
||
"items_received_count",
|
||
"items_problem_count",
|
||
"items_pending_count",
|
||
"total_requested_quantity",
|
||
"total_ordered_quantity",
|
||
"total_received_quantity",
|
||
"quantity_unit_label",
|
||
"executor_name",
|
||
"fulfillment_status",
|
||
"processing_department_name",
|
||
"fulfillment_status_display",
|
||
"comments_count",
|
||
"activity_count",
|
||
"linked_tasks",
|
||
"can_current_user_approve",
|
||
"can_current_user_submit_for_approval",
|
||
"can_current_user_start_work",
|
||
"can_current_user_process_items",
|
||
]
|
||
|
||
|
||
class ProcurementRequestCreateSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для создания заявки с позициями."""
|
||
|
||
description = serializers.CharField(
|
||
required=False,
|
||
allow_blank=True,
|
||
)
|
||
items = ProcurementItemCreateSerializer(many=True, required=False)
|
||
|
||
class Meta:
|
||
model = ProcurementRequest
|
||
fields = [
|
||
"id",
|
||
"title",
|
||
"description",
|
||
"department",
|
||
"processing_department",
|
||
"requestor",
|
||
"urgency",
|
||
"items",
|
||
"status",
|
||
"created_at",
|
||
]
|
||
read_only_fields = ["id", "requestor", "status", "created_at"]
|
||
|
||
def validate(self, attrs):
|
||
_validate_processing_department_available(
|
||
attrs.get("processing_department")
|
||
)
|
||
if (
|
||
not attrs.get("processing_department")
|
||
and not attrs.get("description", "").strip()
|
||
):
|
||
raise serializers.ValidationError(
|
||
{"description": "Укажите описание и обоснование."}
|
||
)
|
||
return attrs
|
||
|
||
def create(self, validated_data):
|
||
"""Создать заявку с позициями."""
|
||
items_data = validated_data.pop("items", [])
|
||
request = self.context["request"]
|
||
has_processing_department = bool(
|
||
validated_data.get("processing_department")
|
||
)
|
||
|
||
if has_processing_department:
|
||
validated_data["status"] = ProcurementStatus.WAITING
|
||
|
||
procurement_request = ProcurementRequest(
|
||
requestor=request.user,
|
||
**validated_data,
|
||
)
|
||
procurement_request._notification_actor = request.user
|
||
procurement_request.save()
|
||
|
||
# Создаем позиции (если они переданы)
|
||
for item_data in items_data:
|
||
initial_comment = item_data.pop("initial_comment", "").strip()
|
||
item = ProcurementItem.objects.create(
|
||
request=procurement_request, **item_data
|
||
)
|
||
if initial_comment:
|
||
comments_helpers.create_comment(
|
||
obj=item,
|
||
author=request.user,
|
||
content=initial_comment,
|
||
)
|
||
|
||
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"]
|
||
|
||
@extend_schema_field(serializers.IntegerField())
|
||
def get_children_count(self, obj):
|
||
"""Количество подкатегорий."""
|
||
return obj.children.count()
|
||
|
||
|
||
class BudgetSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор бюджета отдела."""
|
||
|
||
department_name = serializers.CharField(
|
||
source="department.name", read_only=True
|
||
)
|
||
remaining_amount = serializers.DecimalField(
|
||
max_digits=14, decimal_places=2, read_only=True
|
||
)
|
||
reserved_amount = serializers.DecimalField(
|
||
max_digits=14, decimal_places=2, read_only=True
|
||
)
|
||
available_amount = serializers.DecimalField(
|
||
max_digits=14, decimal_places=2, read_only=True
|
||
)
|
||
utilization_percentage = serializers.DecimalField(
|
||
max_digits=5, decimal_places=2, read_only=True
|
||
)
|
||
|
||
class Meta:
|
||
model = Budget
|
||
fields = [
|
||
"id",
|
||
"department",
|
||
"department_name",
|
||
"year",
|
||
"quarter",
|
||
"allocated_amount",
|
||
"spent_amount",
|
||
"remaining_amount",
|
||
"reserved_amount",
|
||
"available_amount",
|
||
"utilization_percentage",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
read_only_fields = fields
|
||
|
||
|
||
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 (скоуп-право): только свой отдел, ответственный = начальник
|
||
"""
|
||
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",
|
||
]
|
||
|
||
@extend_schema_field(serializers.IntegerField())
|
||
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"]
|
||
|
||
|
||
class ProcurementNotificationReadResponseSerializer(serializers.Serializer):
|
||
status = serializers.CharField()
|
||
count = serializers.IntegerField()
|
||
notification_ids = serializers.ListField(
|
||
child=serializers.IntegerField(),
|
||
)
|
||
|
||
|
||
class ProcurementOverviewStatsSerializer(serializers.Serializer):
|
||
total_requests = serializers.IntegerField()
|
||
pending_requests = serializers.IntegerField()
|
||
approved_this_month = serializers.IntegerField()
|
||
completed_this_month = serializers.IntegerField()
|
||
total_spent_this_year = serializers.CharField()
|
||
by_status = serializers.DictField(child=serializers.IntegerField())
|
||
by_urgency = serializers.DictField(child=serializers.IntegerField())
|
||
|
||
|
||
class ProcurementDepartmentStatsSerializer(serializers.Serializer):
|
||
department = serializers.DictField()
|
||
total_requests = serializers.IntegerField()
|
||
total_spent = serializers.CharField()
|