Corp/backend/api/v1/procurement/serializers.py

1634 lines
55 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Сериализаторы для модуля закупок.
"""
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()