2026-08-07 14:02:42 +03:00

3358 lines
126 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.

"""ViewSets для API модуля закупок."""
from datetime import date
from pathlib import Path
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.http import FileResponse, Http404
from drf_spectacular.utils import OpenApiParameter, extend_schema
from django.db.models import Prefetch, Q
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.response import Response
from employees.models import Department, EmployeeDepartment
from procurement.constants import (
ApprovalStatus,
ProcurementStatus,
EquipmentStatus,
ProcurementItemExecutionStatus,
get_default_approval_step_name,
)
from procurement.models import (
Approval,
ApprovalRoute,
Budget,
Equipment,
EquipmentCategory,
EquipmentTransferLog,
MaintenanceRecord,
ProcurementItem,
ProcurementItemAttachment,
ProcurementActivityAction,
ProcurementActivityObjectKind,
ProcurementRequest,
ProcurementRequestView,
ProcurementSettings,
Supplier,
)
from procurement.activity import create_procurement_activity
from procurement.notifications.handlers import (
notify_executor_reassigned,
notify_item_comment,
notify_item_issue_reported,
notify_item_updated,
notify_request_arrival,
notify_request_comment,
)
from procurement.notifications.config import NotificationVerbs
from procurement.notifications.read_service import (
ProcurementNotificationReadService,
)
from communications import comments_helpers
from communications.models import Message
from ..employees.serializers import EmployeeBriefSerializer
from procurement.services import ProcurementApprovalResolver, merge_comment_links
from .permissions import (
CanApproveProcurementRequest,
CanManageEquipment,
CanManageEquipmentCategory,
CanManageProcurementRequest,
CanManageSupplier,
)
from .serializers import (
BudgetSerializer,
EquipmentCategorySerializer,
EquipmentDetailSerializer,
EquipmentListSerializer,
MaintenanceRecordSerializer,
ProcurementDepartmentStatsSerializer,
ProcurementOverviewStatsSerializer,
ProcurementItemSerializer,
ProcurementItemAttachmentSerializer,
ProcurementManualApprovalStepsSerializer,
ProcurementNotificationReadResponseSerializer,
ProcurementRequestCreateOptionsSerializer,
ProcurementRequestCreateSerializer,
ProcurementRequestDetailSerializer,
ProcurementRequestActivitySerializer,
ProcurementRequestListSerializer,
SupplierSerializer,
)
class ProcurementRequestViewSet(viewsets.ModelViewSet):
"""ViewSet для заявок на закупку."""
queryset = ProcurementRequest.objects.select_related(
"department",
"processing_department",
"requestor",
"executor",
).prefetch_related(
"items__attachments__uploaded_by",
"approvals__approver",
)
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filterset_fields = {
"status": ["exact", "in"],
"urgency": ["exact"],
"department": ["exact"],
"processing_department": ["exact"],
"requestor": ["exact"],
"executor": ["exact"],
"fulfillment_status": ["exact"],
}
search_fields = ["title", "description"]
ordering_fields = ["created_at", "status"]
ordering = None
def get_serializer_class(self):
"""Выбрать сериализатор в зависимости от действия."""
if self.action == "create":
return ProcurementRequestCreateSerializer
elif self.action in [
"retrieve",
"submit",
"approve",
"reject",
"start_work",
"complete",
"cancel",
"mark_all_received",
"notify_arrival",
"set_viewed",
"approval_options",
"activity",
]:
return ProcurementRequestDetailSerializer
return ProcurementRequestListSerializer
@action(detail=True, methods=["get"], url_path="activity")
def activity(self, request, pk=None):
procurement_request = self.get_object()
queryset = procurement_request.activities.select_related("actor").order_by(
"-created_at",
"-id",
)
serializer = ProcurementRequestActivitySerializer(queryset, many=True)
return Response(serializer.data)
def perform_update(self, serializer):
tracked_fields = [
"title",
"description",
"department_id",
"processing_department_id",
"urgency",
"actual_cost",
]
before = {
field: getattr(serializer.instance, field)
for field in tracked_fields
}
procurement_request = serializer.save()
changed_fields = [
field
for field in tracked_fields
if before[field] != getattr(procurement_request, field)
]
if changed_fields:
create_procurement_activity(
procurement_request,
self.request.user,
ProcurementActivityAction.UPDATED,
object_kind=ProcurementActivityObjectKind.REQUEST,
object_id=procurement_request.pk,
metadata={"fields": changed_fields},
)
def get_permissions(self):
"""Выбрать права доступа в зависимости от действия."""
if self.action in ["approve", "reject"]:
permission_classes = [CanApproveProcurementRequest]
elif self.action in [
"submit",
"set_viewed",
"create_options",
"approval_options",
]:
permission_classes = [permissions.IsAuthenticated]
else:
# Для create, update, delete и остальных действий
permission_classes = [CanManageProcurementRequest]
return [permission() for permission in permission_classes]
def get_queryset(self):
"""Фильтровать заявки в зависимости от роли пользователя и scope."""
from datetime import timedelta
from django.contrib.contenttypes.models import ContentType
from django.db.models import (
BooleanField,
CharField,
Count,
DateTimeField,
Exists,
IntegerField,
OuterRef,
Subquery,
Value,
)
from django.db.models.functions import Cast, Coalesce
from django.utils.dateparse import parse_date
from django.utils import timezone
from communications.models import Chat
from notifications.models import Notification
ct = ContentType.objects.get_for_model(ProcurementRequest)
comments_sub = (
Chat.objects.filter(
type="comments",
context_content_type=ct,
context_object_id=OuterRef("pk"),
)
.annotate(
msg_count=Count(
"messages", filter=Q(messages__is_deleted=False)
)
)
.values("msg_count")[:1]
)
arrival_notice_sub = (
Notification.objects.filter(
verb=NotificationVerbs.ARRIVAL_NOTICE,
target_content_type=ct,
target_object_id=Cast(
OuterRef("pk"),
output_field=CharField(),
),
)
.order_by("-timestamp")
.values("timestamp")[:1]
)
last_defective_at_sub = (
ProcurementItem.objects.filter(
request_id=OuterRef("pk"),
defective_at__isnull=False,
)
.order_by("-defective_at")
.values("defective_at")[:1]
)
user = self.request.user
viewed_annotation = (
Exists(
ProcurementRequestView.objects.filter(
request_id=OuterRef("pk"),
user=user,
is_viewed=True,
)
)
if user and user.is_authenticated
else Value(False, output_field=BooleanField())
)
queryset = super().get_queryset().annotate(
comments_count=Coalesce(
Subquery(comments_sub, output_field=IntegerField()),
Value(0),
output_field=IntegerField(),
),
is_viewed_for_current_user=viewed_annotation,
last_arrival_notice_at=Subquery(
arrival_notice_sub,
output_field=DateTimeField(),
),
last_defective_at=Subquery(
last_defective_at_sub,
output_field=DateTimeField(),
),
).annotate(
default_sort_at=Coalesce(
"last_defective_at",
"created_at",
output_field=DateTimeField(),
),
)
scope = self.request.query_params.get("scope", None)
period = self.request.query_params.get("period", None)
date_from = self.request.query_params.get("date_from", None)
date_to = self.request.query_params.get("date_to", None)
participant_department_ids = (
ProcurementApprovalResolver.get_user_department_participant_ids(
user,
)
)
# Обработка scope параметра
if scope == "mine":
# Только мои заявки (где я автор)
queryset = queryset.filter(requestor=user)
elif scope == "department":
# Заявки отделов, где пользователь участник или role-only участник.
queryset = queryset.filter(department_id__in=participant_department_ids)
elif scope == "processing_department":
# Заявки, направленные в отделы, где пользователь участвует.
queryset = queryset.filter(
processing_department_id__in=participant_department_ids,
)
elif scope == "my_work":
# Заявки, которые я взял в работу (где я исполнитель)
queryset = queryset.filter(executor=user)
elif scope == "available":
queryset = queryset.filter(
status__in=ProcurementApprovalResolver.WORK_ASSIGNABLE_STATUSES,
).exclude(
executor=user,
)
if not (
user.is_superuser
or user.is_staff
or user.has_perm("procurement.change_procurementrequest")
or user.has_perm("procurement.execute_procurement")
):
queryset = queryset.filter(
Q(
processing_department_id__in=participant_department_ids,
)
| Q(
processing_department__isnull=True,
)
)
elif scope == "all":
# Все заявки - применяем стандартную логику прав
pass
else:
# Если scope не указан, применяем стандартную логику
# Админы и пользователи с модельными правами видят все
if user.is_superuser or user.is_staff:
pass
# Пользователи с любыми модельными правами на заявки видят все
elif (
user.has_perm("procurement.view_procurementrequest")
or user.has_perm("procurement.change_procurementrequest")
or user.has_perm("procurement.delete_procurementrequest")
or user.has_perm("procurement.execute_procurement")
):
pass
else:
# Показываем: свои заявки + заявки отдела + где я approver
queryset = queryset.filter(
Q(requestor=user)
| Q(department_id__in=participant_department_ids)
| Q(
processing_department_id__in=participant_department_ids
)
| Q(approvals__approver=user)
| Q(
status=ProcurementStatus.APPROVED,
processing_department__isnull=True,
)
)
# Фильтрация по периоду
if period:
now = timezone.now()
if period == "today":
start_date = now.replace(
hour=0, minute=0, second=0, microsecond=0
)
queryset = queryset.filter(created_at__gte=start_date)
elif period == "week":
start_date = now - timedelta(days=7)
queryset = queryset.filter(created_at__gte=start_date)
elif period == "month":
start_date = now - timedelta(days=30)
queryset = queryset.filter(created_at__gte=start_date)
elif period == "quarter":
start_date = now - timedelta(days=90)
queryset = queryset.filter(created_at__gte=start_date)
if date_from:
parsed_date_from = parse_date(date_from)
if parsed_date_from:
queryset = queryset.filter(created_at__date__gte=parsed_date_from)
if date_to:
parsed_date_to = parse_date(date_to)
if parsed_date_to:
queryset = queryset.filter(created_at__date__lte=parsed_date_to)
if not self.request.query_params.get("ordering"):
queryset = queryset.order_by("-default_sort_at", "-id")
return queryset.distinct()
def _attach_linked_task_payloads(
self,
requests: list[ProcurementRequest],
) -> None:
"""Предзагрузить компактные бейджи задач для списка закупок."""
if not requests:
return
user = getattr(self.request, "user", None)
request_ids = [request_obj.id for request_obj in requests]
mapping = {request_id: [] for request_id in request_ids}
if user and user.is_authenticated:
from tasks.access import task_board_access_q
from tasks.models import (
TaskBoard,
TaskLinkedObject,
TaskLinkedObjectKind,
)
request_ct = 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=request_ct,
object_id__in=request_ids,
task__board__in=accessible_boards,
)
.select_related("task", "task__board", "task__column")
.order_by("object_id", "task__title", "task_id")
)
for link in links:
mapping.setdefault(link.object_id, []).append(
{
"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 request_obj in requests:
request_obj._linked_task_payloads = mapping.get(request_obj.id, [])
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)
if page is not None:
self._attach_linked_task_payloads(list(page))
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
requests = list(queryset)
self._attach_linked_task_payloads(requests)
serializer = self.get_serializer(requests, many=True)
return Response(serializer.data)
@action(detail=False, methods=["get"], url_path="create-options")
def create_options(self, request):
"""Вернуть настройки выбора отделов для формы создания заявки."""
settings = ProcurementSettings.get_solo()
departments = settings.get_processing_departments_queryset().order_by(
"name"
)
default_department_id = settings.default_processing_department_id
if (
default_department_id
and not departments.filter(pk=default_department_id).exists()
):
default_department_id = None
serializer = ProcurementRequestCreateOptionsSerializer(
{
"processing_departments": departments,
"default_processing_department": default_department_id,
}
)
return Response(serializer.data)
def _approval_step_payload(
self,
*,
route,
step=None,
is_amount_applicable=False,
missing_reason=None,
):
priority, approver, step_name = step or (route.priority, None, "")
return {
"route_id": route.id,
"priority": priority,
"step_name": step_name
or route.name
or get_default_approval_step_name(
route.priority,
resolver_type=route.resolver_type,
),
"resolver_type": route.resolver_type,
"min_amount": route.min_amount,
"is_amount_applicable": is_amount_applicable,
"is_available": approver is not None,
"missing_reason": missing_reason,
"approver": (
EmployeeBriefSerializer(approver).data
if approver is not None
else None
),
}
def _build_auto_approval_payload(self, procurement_request):
applicable_priorities = set(
procurement_request.get_required_approval_routes().values_list(
"priority",
flat=True,
)
)
routes = ApprovalRoute.objects.select_related("employee").order_by(
"priority",
"id",
)
available_steps = []
auto_steps = []
missing_auto_steps = []
for route in routes:
step = ProcurementApprovalResolver.resolve_approval_step(
procurement_request,
route,
)
missing_reason = None
if step is None:
if (
route.resolver_type
== ApprovalRoute.ResolverType.DEPARTMENT_HEAD
):
missing_reason = "department_head_missing"
elif (
route.resolver_type
== ApprovalRoute.ResolverType.FIXED_EMPLOYEE
):
missing_reason = "approver_missing_or_inactive"
payload = self._approval_step_payload(
route=route,
step=step,
is_amount_applicable=route.priority in applicable_priorities,
missing_reason=missing_reason,
)
available_steps.append(payload)
if payload["is_amount_applicable"]:
if payload["is_available"]:
auto_steps.append(payload)
else:
missing_auto_steps.append(payload)
return {
"request_id": procurement_request.id,
"total_cost": procurement_request.total_cost,
"auto_steps": auto_steps,
"available_steps": available_steps,
"missing_auto_steps": missing_auto_steps,
}
@action(detail=True, methods=["get"], url_path="approval-options")
def approval_options(self, request, pk=None):
"""Вернуть автоматический и ручной наборы согласующих."""
procurement_request = self.get_object()
if not ProcurementApprovalResolver.user_can_submit_for_approval(
request.user,
procurement_request,
):
return Response(
{"error": "Вы не можете отправить эту заявку на согласование"},
status=status.HTTP_403_FORBIDDEN,
)
if procurement_request.items.count() == 0:
return Response(
{"error": "Добавьте хотя бы одну позицию в заявку"},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(self._build_auto_approval_payload(procurement_request))
@action(detail=True, methods=["get", "post"])
def comments(self, request, pk=None):
"""Список/создание комментариев для заявки на закупку."""
from api.v1.employees.serializers.employee import (
EmployeeBriefSerializer,
)
procurement_request = self.get_object()
if request.method in ("GET", "HEAD"):
messages = comments_helpers.get_comments(procurement_request)
comments_data = []
for msg in messages:
author_ser = EmployeeBriefSerializer(msg.author)
comments_data.append(
{
"id": msg.id,
"request": procurement_request.id,
"author": author_ser.data,
"text": msg.content,
"created_at": msg.created_at,
}
)
return Response(comments_data)
text = request.data.get("text", "").strip()
if not text:
return Response(
{"text": ["Это поле не может быть пустым."]},
status=status.HTTP_400_BAD_REQUEST,
)
message = comments_helpers.create_comment(
obj=procurement_request,
author=request.user,
content=text,
)
notify_request_comment(
procurement_request,
message,
actor=request.user,
)
create_procurement_activity(
procurement_request,
request.user,
ProcurementActivityAction.COMMENT_ADDED,
object_kind=ProcurementActivityObjectKind.COMMENT,
object_id=message.pk,
metadata={"comment_text": message.content},
)
author_ser = EmployeeBriefSerializer(message.author)
return Response(
{
"id": message.id,
"request": procurement_request.id,
"author": author_ser.data,
"text": message.content,
"created_at": message.created_at,
},
status=status.HTTP_201_CREATED,
)
@extend_schema(
parameters=[
OpenApiParameter(
name="comment_id",
type=int,
location=OpenApiParameter.PATH,
description="ID комментария в чате комментариев заявки.",
)
]
)
@action(
detail=True,
methods=["delete"],
url_path="comments/(?P<comment_id>[^/.]+)",
)
def delete_comment(self, request, pk=None, comment_id=None):
"""Удаление комментария к заявке на закупку."""
procurement_request = self.get_object()
chat = comments_helpers.get_or_create_comments_chat(procurement_request)
if isinstance(chat, tuple):
chat = chat[0]
try:
message = Message.objects.get(id=comment_id, chat=chat)
except Message.DoesNotExist:
return Response(
{"detail": "Комментарий не найден"},
status=status.HTTP_404_NOT_FOUND,
)
if not (request.user.is_staff or message.author == request.user):
return Response(
{"detail": "Нет прав на удаление"},
status=status.HTTP_403_FORBIDDEN,
)
comments_helpers.delete_comment(
message=message,
deleted_by=request.user,
soft_delete=True,
)
create_procurement_activity(
procurement_request,
request.user,
ProcurementActivityAction.COMMENT_REMOVED,
object_kind=ProcurementActivityObjectKind.COMMENT,
object_id=message.pk,
metadata={"comment_text": message.content},
)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["post"])
def submit(self, request, pk=None):
"""Отправить заявку на согласование."""
from django.db import transaction
procurement_request = self.get_object()
if (
procurement_request.status == ProcurementStatus.PENDING
or procurement_request.approvals.filter(
status=ApprovalStatus.PENDING,
).exists()
):
return Response(
{"error": "Заявка уже находится на согласовании"},
status=status.HTTP_400_BAD_REQUEST,
)
if not ProcurementApprovalResolver.user_can_submit_for_approval(
request.user,
procurement_request,
):
return Response(
{"error": "Вы не можете отправить эту заявку на согласование"},
status=status.HTTP_403_FORBIDDEN,
)
if (
not procurement_request.processing_department_id
and not procurement_request.is_editable
):
return Response(
{"error": "Заявка уже отправлена на согласование"},
status=status.HTTP_400_BAD_REQUEST,
)
if procurement_request.items.count() == 0:
return Response(
{"error": "Добавьте хотя бы одну позицию в заявку"},
status=status.HTTP_400_BAD_REQUEST,
)
manual_serializer = ProcurementManualApprovalStepsSerializer(
data=request.data or {},
)
if not manual_serializer.is_valid():
return Response(
manual_serializer.errors,
status=status.HTTP_400_BAD_REQUEST,
)
manual_steps = manual_serializer.validated_data.get(
"approval_steps",
) or []
if manual_steps:
routes_by_priority = {
route.priority: route
for route in ApprovalRoute.objects.filter(
priority__in=[
step["priority"] for step in manual_steps
]
)
}
resolved_approvals = []
for step in sorted(manual_steps, key=lambda item: item["priority"]):
route = routes_by_priority[step["priority"]]
step_name = (
step.get("step_name", "").strip()
or route.name
or get_default_approval_step_name(
route.priority,
resolver_type=route.resolver_type,
)
)
resolved_approvals.append(
(route.priority, step["approver"], step_name)
)
missing_routes = []
else:
required_priorities = (
procurement_request.get_required_approval_priorities()
)
if not required_priorities:
return Response(
{
"error": (
"Для этой суммы заявки не настроены "
"маршруты согласования"
)
},
status=status.HTTP_400_BAD_REQUEST,
)
resolved_approvals, missing_routes = (
ProcurementApprovalResolver.resolve_required_approvers_detailed(
procurement_request
)
)
if missing_routes:
missing_priorities = [
route["priority"] for route in missing_routes
]
missing_department_head = next(
(
route
for route in missing_routes
if route["reason"] == "department_head_missing"
),
None,
)
if missing_department_head:
return Response(
{
"error": (
"У выбранного отдела не назначен руководитель "
"и нет активной роли с правом согласования. "
"Заявку нельзя отправить на согласование, "
"пока не будет назначен начальник отдела "
"или согласующая роль."
),
"code": "department_head_missing",
"missing_priorities": missing_priorities,
"missing_routes": missing_routes,
},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(
{
"error": "Не настроены согласующие для обязательных этапов",
"code": "approval_routes_incomplete",
"missing_priorities": missing_priorities,
"missing_routes": missing_routes,
},
status=status.HTTP_400_BAD_REQUEST,
)
with transaction.atomic():
for priority, approver, step_name in resolved_approvals:
Approval.objects.create(
request=procurement_request,
approver=approver,
priority=priority,
step_name=step_name,
status=ApprovalStatus.PENDING,
)
procurement_request.status = ProcurementStatus.PENDING
procurement_request.submitted_at = timezone.now()
procurement_request._notification_actor = request.user
procurement_request.save(
update_fields=["status", "submitted_at", "updated_at"]
)
procurement_request.refresh_from_db()
serializer = self.get_serializer(procurement_request)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def approve(self, request, pk=None):
"""Одобрить заявку."""
procurement_request = self.get_object()
# Проверяем права
self.check_object_permissions(request, procurement_request)
# Находим или создаем доступный этап текущего пользователя.
approval = ProcurementApprovalResolver.get_or_create_available_approval(
request.user,
procurement_request,
)
if not approval:
return Response(
{"error": "У вас нет прав на согласование этой заявки"},
status=status.HTTP_403_FORBIDDEN,
)
if approval.approver_id != request.user.id:
approval.approver = request.user
# Вышестоящий согласующий закрывает все предыдущие pending-этапы.
procurement_request.approvals.filter(
status=ApprovalStatus.PENDING,
priority__lt=approval.priority,
).update(
status=ApprovalStatus.APPROVED,
comment="Автоматически одобрено вышестоящим согласующим",
updated_at=timezone.now(),
)
# Одобряем
approval.status = ApprovalStatus.APPROVED
approval.comment = request.data.get("comment", "")
approval.save() # Сигнал post_save(Approval) отправит уведомление
# Проверяем, все ли одобрили
pending_approvals = procurement_request.approvals.filter(
status=ApprovalStatus.PENDING
).count()
if pending_approvals == 0:
if procurement_request.processing_department_id:
procurement_request.status = (
ProcurementStatus.IN_PROGRESS
if procurement_request.executor_id
else ProcurementStatus.WAITING
)
else:
procurement_request.status = ProcurementStatus.APPROVED
procurement_request._notification_actor = request.user
procurement_request.save(update_fields=["status", "updated_at"])
procurement_request.refresh_from_db()
serializer = self.get_serializer(procurement_request)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def reject(self, request, pk=None):
"""Отклонить заявку."""
procurement_request = self.get_object()
# Проверяем права
self.check_object_permissions(request, procurement_request)
# Находим или создаем доступный этап текущего пользователя.
approval = ProcurementApprovalResolver.get_or_create_available_approval(
request.user,
procurement_request,
)
if not approval:
return Response(
{"error": "У вас нет прав на согласование этой заявки"},
status=status.HTTP_403_FORBIDDEN,
)
if approval.approver_id != request.user.id:
approval.approver = request.user
# Вышестоящий согласующий закрывает предыдущие pending-этапы.
procurement_request.approvals.filter(
status=ApprovalStatus.PENDING,
priority__lt=approval.priority,
).update(
status=ApprovalStatus.REJECTED,
comment="Автоматически закрыто вышестоящим отклонением",
updated_at=timezone.now(),
)
# Отклоняем
approval.status = ApprovalStatus.REJECTED
approval.comment = request.data.get("comment", "")
approval.save() # Сигнал post_save(Approval) отправит уведомление
# Меняем статус заявки; сигнал post_save(ProcurementRequest) уведомит
# requestor'а
procurement_request.status = ProcurementStatus.REJECTED
procurement_request._notification_actor = request.user
procurement_request._requestor_rejected_notified = True
procurement_request.save()
procurement_request.refresh_from_db()
serializer = self.get_serializer(procurement_request)
return Response(serializer.data)
@action(detail=False, methods=["get"])
def my_requests(self, request):
"""Получить заявки текущего пользователя."""
queryset = self.get_queryset().filter(requestor=request.user)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=["get"])
def pending_approvals(self, request):
"""Получить заявки, ожидающие согласования текущим польз."""
# Находим заявки где есть pending approval для текущего юзера
base_queryset = self.filter_queryset(
self.get_queryset().filter(
status=ProcurementStatus.PENDING,
)
)
available_ids = [
request_obj.id
for request_obj in base_queryset
if ProcurementApprovalResolver.user_can_approve(
request.user, request_obj
)
]
queryset = base_queryset.filter(id__in=available_ids)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=["get"])
def my_work(self, request):
"""Получить заявки, взятые текущим пользователем в работу."""
queryset = self.filter_queryset(
self.get_queryset().filter(executor=request.user)
)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = ProcurementRequestListSerializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = ProcurementRequestListSerializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=["get"])
def available(self, request):
"""Получить заявки, доступные для взятия в работу."""
queryset = self.filter_queryset(
self.get_queryset().filter(
status__in=ProcurementApprovalResolver.WORK_ASSIGNABLE_STATUSES,
).exclude(executor=request.user)
)
if not (
request.user.is_superuser
or request.user.is_staff
or request.user.has_perm("procurement.change_procurementrequest")
or request.user.has_perm("procurement.execute_procurement")
):
participant_department_ids = (
ProcurementApprovalResolver.get_user_department_participant_ids(
request.user,
)
)
queryset = queryset.filter(
Q(
processing_department_id__in=participant_department_ids,
)
| Q(
processing_department__isnull=True,
)
)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = ProcurementRequestListSerializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = ProcurementRequestListSerializer(queryset, many=True)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def start_work(self, request, pk=None):
"""Назначить себя исполнителем заявки.
Общий пул может взять любой авторизованный пользователь.
Адресную заявку может взять сотрудник отдела-исполнителя.
Другой доступный сотрудник может забрать уже назначенную заявку.
Во время согласования статус PENDING сохраняется.
"""
procurement_request = self.get_object()
self.check_object_permissions(request, procurement_request)
if procurement_request.executor_id == request.user.id:
return Response(
{"error": "Вы уже являетесь исполнителем заявки"},
status=status.HTTP_400_BAD_REQUEST,
)
can_start_work = ProcurementApprovalResolver.user_can_start_work(
request.user,
procurement_request,
)
if not can_start_work:
message = (
"Вы не можете забрать эту заявку в работу"
if procurement_request.executor_id
else "Вы не можете взять эту заявку в работу"
)
return Response(
{"error": message},
status=status.HTTP_403_FORBIDDEN,
)
if procurement_request.executor_id:
previous_executor = procurement_request.executor
procurement_request.executor = request.user
procurement_request._notification_actor = request.user
update_fields = ["executor", "updated_at"]
if procurement_request.started_at is None:
procurement_request.started_at = timezone.now()
update_fields.append("started_at")
procurement_request.save(update_fields=update_fields)
create_procurement_activity(
procurement_request,
request.user,
ProcurementActivityAction.EXECUTOR_REASSIGNED,
object_kind=ProcurementActivityObjectKind.REQUEST,
object_id=procurement_request.pk,
metadata={
"previous_executor": previous_executor.get_full_name(),
"previous_executor_id": previous_executor.pk,
"new_executor": request.user.get_full_name(),
"new_executor_id": request.user.pk,
},
)
notify_executor_reassigned(
procurement_request,
previous_executor,
request.user,
)
serializer = self.get_serializer(procurement_request)
return Response(serializer.data)
previous_status = procurement_request.status
procurement_request.executor = request.user
procurement_request.started_at = timezone.now()
update_fields = ["executor", "started_at", "updated_at"]
if previous_status != ProcurementStatus.PENDING:
procurement_request.status = ProcurementStatus.IN_PROGRESS
update_fields.append("status")
procurement_request._notification_actor = request.user
procurement_request.save(update_fields=update_fields)
if previous_status == ProcurementStatus.PENDING:
create_procurement_activity(
procurement_request,
request.user,
ProcurementActivityAction.STARTED,
object_kind=ProcurementActivityObjectKind.REQUEST,
object_id=procurement_request.pk,
metadata={
"old_status": previous_status,
"new_status": procurement_request.status,
},
)
serializer = self.get_serializer(procurement_request)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def complete(self, request, pk=None):
"""Завершить заявку (перевод в статус COMPLETED)."""
procurement_request = self.get_object()
# Только исполнитель может завершить заявку
if procurement_request.executor != request.user:
return Response(
{"error": "Только исполнитель заявки может завершить её"},
status=status.HTTP_403_FORBIDDEN,
)
# Проверяем текущий статус
if procurement_request.status != ProcurementStatus.IN_PROGRESS:
return Response(
{"error": "Только заявки в работе можно завершить"},
status=status.HTTP_400_BAD_REQUEST,
)
# Меняем статус
procurement_request.status = ProcurementStatus.COMPLETED
procurement_request.completed_at = timezone.now()
procurement_request._notification_actor = request.user
procurement_request.save()
# Сигнал post_save(ProcurementRequest) с COMPLETED уведомит автора
serializer = self.get_serializer(procurement_request)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def mark_all_received(self, request, pk=None):
"""Отметить все позиции заявки как полученные."""
procurement_request = self.get_object()
self.check_object_permissions(request, procurement_request)
if procurement_request.status not in [
ProcurementStatus.WAITING,
ProcurementStatus.IN_PROGRESS,
]:
return Response(
{
"error": (
"Отмечать позиции можно только в ожидающей "
"или взятой в работу заявке"
)
},
status=status.HTTP_400_BAD_REQUEST,
)
items_to_update = [
item
for item in procurement_request.items.all()
if (
item.execution_status
!= ProcurementItemExecutionStatus.RECEIVED
or item.ordered_quantity != item.fulfillment_target_quantity
or item.received_quantity != item.fulfillment_target_quantity
)
]
for item in items_to_update:
fulfillment_target_quantity = item.fulfillment_target_quantity
item.execution_status = ProcurementItemExecutionStatus.RECEIVED
item.ordered_quantity = fulfillment_target_quantity
item.received_quantity = fulfillment_target_quantity
ProcurementItem.objects.bulk_update(
items_to_update,
["execution_status", "ordered_quantity", "received_quantity"],
)
for item in items_to_update:
notify_item_updated(
item,
actor=request.user,
changed_fields=[
"execution_status",
"ordered_quantity",
"received_quantity",
],
)
procurement_request._notification_actor = request.user
procurement_request.recalculate_fulfillment_status(save=True)
procurement_request.refresh_from_db()
create_procurement_activity(
procurement_request,
request.user,
ProcurementActivityAction.ALL_ITEMS_RECEIVED,
object_kind=ProcurementActivityObjectKind.REQUEST,
object_id=procurement_request.pk,
metadata={"items_count": len(items_to_update)},
)
serializer = self.get_serializer(procurement_request)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def notify_arrival(self, request, pk=None):
"""Вручную уведомить заказчика о поступлении по заявке."""
procurement_request = self.get_object()
self.check_object_permissions(request, procurement_request)
if procurement_request.status not in [
ProcurementStatus.WAITING,
ProcurementStatus.IN_PROGRESS,
]:
return Response(
{
"error": (
"Уведомить о поступлении можно только по ожидающей "
"или взятой в работу заявке"
)
},
status=status.HTTP_400_BAD_REQUEST,
)
notify_request_arrival(procurement_request, actor=request.user)
create_procurement_activity(
procurement_request,
request.user,
ProcurementActivityAction.ARRIVAL_NOTIFIED,
object_kind=ProcurementActivityObjectKind.REQUEST,
object_id=procurement_request.pk,
)
from django.contrib.contenttypes.models import ContentType
from notifications.models import Notification
request_content_type = ContentType.objects.get_for_model(
ProcurementRequest,
)
latest_notice_at = (
Notification.objects.filter(
verb=NotificationVerbs.ARRIVAL_NOTICE,
target_content_type=request_content_type,
target_object_id=str(procurement_request.pk),
)
.order_by("-timestamp")
.values_list("timestamp", flat=True)
.first()
)
if latest_notice_at:
procurement_request.last_arrival_notice_at = latest_notice_at
serializer = self.get_serializer(procurement_request)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def set_viewed(self, request, pk=None):
"""Установить отметку просмотра заявки."""
procurement_request = self.get_object()
raw_value = request.data.get("is_viewed", True)
if isinstance(raw_value, bool):
is_viewed = raw_value
elif isinstance(raw_value, str):
normalized = raw_value.strip().lower()
if normalized in {"true", "1", "yes", "y", "on"}:
is_viewed = True
elif normalized in {"false", "0", "no", "n", "off"}:
is_viewed = False
else:
return Response(
{"is_viewed": "Ожидается булево значение."},
status=status.HTTP_400_BAD_REQUEST,
)
else:
return Response(
{"is_viewed": "Ожидается булево значение."},
status=status.HTTP_400_BAD_REQUEST,
)
view_state, _ = ProcurementRequestView.objects.get_or_create(
request=procurement_request,
user=request.user,
defaults={
"is_viewed": is_viewed,
"viewed_at": timezone.now() if is_viewed else None,
},
)
if view_state.is_viewed != is_viewed:
view_state.is_viewed = is_viewed
view_state.viewed_at = timezone.now() if is_viewed else None
view_state.save(update_fields=["is_viewed", "viewed_at", "updated_at"])
procurement_request.is_viewed_for_current_user = is_viewed
serializer = self.get_serializer(procurement_request)
return Response(serializer.data)
@extend_schema(
responses=ProcurementNotificationReadResponseSerializer,
summary="Отметить уведомления по заявке как прочитанные",
)
@action(detail=True, methods=["post"], url_path="notifications/read")
def mark_notifications_read(self, request, pk=None):
"""Отметить уведомления текущего пользователя по заявке как прочитанные."""
procurement_request = self.get_object()
result = ProcurementNotificationReadService().mark_request_notifications_as_read(
user=request.user,
procurement_request=procurement_request,
)
return Response(
{
"status": "success",
"count": result.count,
"notification_ids": result.notification_ids,
}
)
@action(detail=True, methods=["post"])
def cancel(self, request, pk=None):
"""Отменить заявку."""
procurement_request = self.get_object()
# Только автор заявки может отменить её
if procurement_request.requestor != request.user:
return Response(
{"error": "Только автор заявки может отменить её"},
status=status.HTTP_403_FORBIDDEN,
)
# Нельзя отменить уже завершённую или отменённую заявку
if procurement_request.status in [
ProcurementStatus.COMPLETED,
ProcurementStatus.CANCELLED,
]:
return Response(
{"error": "Эту заявку нельзя отменить"},
status=status.HTTP_400_BAD_REQUEST,
)
reason = request.data.get("reason", "")
# Сохраняем причину на экземпляре, чтобы сигнал мог её использовать
procurement_request.cancellation_reason = reason or "не указана"
procurement_request._notification_actor = request.user
# Меняем статус; сигнал post_save(ProcurementRequest) с CANCELLED
# уведомит согласующих с указанием причины
procurement_request.status = ProcurementStatus.CANCELLED
procurement_request.save()
serializer = self.get_serializer(procurement_request)
return Response(serializer.data)
class ProcurementItemViewSet(viewsets.ModelViewSet):
"""ViewSet для позиций заявок."""
EXECUTION_FIELDS = {
"actual_unit_price",
"execution_status",
"executor_comment",
"expected_delivery_dates",
"links",
"ordered_quantity",
"received_quantity",
}
NOTIFICATION_TRACKED_FIELDS = [
"name",
"quantity",
"unit",
"estimated_unit_price",
"ordered_quantity",
"received_quantity",
"links",
"expected_delivery_dates",
"actual_unit_price",
"execution_status",
"executor_comment",
]
queryset = ProcurementItem.objects.select_related(
"request",
"request__department",
"request__processing_department",
)
serializer_class = ProcurementItemSerializer
filter_backends = [DjangoFilterBackend, filters.SearchFilter]
filterset_fields = ["request"]
search_fields = ["name", "description"]
def get_queryset(self):
"""Annotate item comments count."""
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, IntegerField, OuterRef, Subquery, Value
from django.db.models.functions import Coalesce
from communications.models import Chat
ct = ContentType.objects.get_for_model(ProcurementItem)
comments_sub = (
Chat.objects.filter(
type="comments",
context_content_type=ct,
context_object_id=OuterRef("pk"),
)
.annotate(
msg_count=Count(
"messages", filter=Q(messages__is_deleted=False)
)
)
.values("msg_count")[:1]
)
return super().get_queryset().annotate(
comments_count=Coalesce(
Subquery(comments_sub, output_field=IntegerField()),
Value(0),
output_field=IntegerField(),
)
)
def _is_processing_department_member(self, user, procurement_request):
return ProcurementApprovalResolver.user_is_processing_department_member(
user,
procurement_request,
)
def _can_process_request_items(self, user, procurement_request):
if user.is_superuser or user.is_staff:
return True
if (
user.has_perm("procurement.change_procurementrequest")
or user.has_perm("procurement.execute_procurement")
):
return True
return self._is_processing_department_member(
user,
procurement_request,
)
def _can_access_item_comments(self, user, procurement_request):
if not user or not user.is_authenticated:
return False
if user.is_superuser or user.is_staff:
return True
if (
user.has_perm("procurement.view_procurementrequest")
or user.has_perm("procurement.change_procurementrequest")
or user.has_perm("procurement.execute_procurement")
):
return True
if procurement_request.requestor_id == user.id:
return True
if procurement_request.executor_id == user.id:
return True
if procurement_request.department.head_id == user.id:
return True
if EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=procurement_request.department_id,
is_active=True,
).exists():
return True
return self._is_processing_department_member(
user,
procurement_request,
)
def get_permissions(self):
"""Только создатель заявки может редактировать позиции."""
if self.action in [
"create",
"update",
"partial_update",
"destroy",
"create_equipment",
"link_equipment",
"comments",
"mark_comment_notifications_read",
"delete_comment",
"report_issue",
"confirm_ordered",
"confirm_received",
"cancel_received",
"cancel_issue",
"attachments",
"attachment_detail",
]:
permission_classes = [permissions.IsAuthenticated]
else:
permission_classes = [CanManageProcurementRequest]
return [permission() for permission in permission_classes]
def _check_item_read_permission(self, item):
from rest_framework.exceptions import PermissionDenied
if not self._can_access_item_comments(self.request.user, item.request):
raise PermissionDenied("Нет доступа к этой позиции")
@action(
detail=True,
methods=["get", "post"],
parser_classes=[MultiPartParser],
url_path="attachments",
)
def attachments(self, request, pk=None):
item = self.get_object()
self._check_item_read_permission(item)
if request.method == "GET":
serializer = ProcurementItemAttachmentSerializer(
item.attachments.select_related("uploaded_by").all(),
many=True,
context=self.get_serializer_context(),
)
return Response(serializer.data)
self._check_item_edit_permission(item)
files = request.FILES.getlist("files")
if not files:
return Response(
{"files": "Выберите хотя бы один файл."},
status=status.HTTP_400_BAD_REQUEST,
)
created = [
ProcurementItemAttachment.objects.create(
item=item,
file=uploaded_file,
file_name=Path(uploaded_file.name).name,
file_size=uploaded_file.size,
mime_type=uploaded_file.content_type or "",
uploaded_by=request.user,
)
for uploaded_file in files
]
for attachment in created:
create_procurement_activity(
item.request,
request.user,
ProcurementActivityAction.ATTACHMENT_ADDED,
object_kind=ProcurementActivityObjectKind.ATTACHMENT,
object_id=attachment.pk,
metadata={
"item_id": item.pk,
"item_name": item.name,
"file_name": attachment.file_name,
},
)
serializer = ProcurementItemAttachmentSerializer(
created,
many=True,
context=self.get_serializer_context(),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@action(
detail=True,
methods=["get", "delete"],
url_path=r"attachments/(?P<attachment_id>\d+)",
)
def attachment_detail(self, request, pk=None, attachment_id=None):
item = self.get_object()
self._check_item_read_permission(item)
try:
attachment = item.attachments.get(pk=attachment_id)
except ProcurementItemAttachment.DoesNotExist as exc:
raise Http404 from exc
if request.method == "DELETE":
self._check_item_edit_permission(item)
attachment_id_value = attachment.pk
file_name = attachment.file_name
attachment.delete()
create_procurement_activity(
item.request,
request.user,
ProcurementActivityAction.ATTACHMENT_REMOVED,
object_kind=ProcurementActivityObjectKind.ATTACHMENT,
object_id=attachment_id_value,
metadata={
"item_id": item.pk,
"item_name": item.name,
"file_name": file_name,
},
)
return Response(status=status.HTTP_204_NO_CONTENT)
if not attachment.file or not attachment.file.name:
raise Http404
try:
file_handle = attachment.file.open("rb")
except FileNotFoundError as exc:
raise Http404 from exc
return FileResponse(
file_handle,
as_attachment=True,
filename=attachment.file_name,
content_type=attachment.mime_type or "application/octet-stream",
)
def _can_use_item_quick_action(self, user, procurement_request):
return (
procurement_request.requestor_id == user.id
or self._can_process_request_items(user, procurement_request)
)
def _validate_item_quick_action(self, user, procurement_request):
if not self._can_use_item_quick_action(user, procurement_request):
return Response(
{"detail": "Нет прав изменить эту позицию"},
status=status.HTTP_403_FORBIDDEN,
)
if procurement_request.status in [
ProcurementStatus.PENDING,
ProcurementStatus.CANCELLED,
ProcurementStatus.REJECTED,
]:
return Response(
{
"detail": (
"Нельзя изменить позицию в заявке на согласовании, "
"в отменённой или отклонённой заявке"
)
},
status=status.HTTP_400_BAD_REQUEST,
)
return None
def _workflow_status_from_quantities(self, item):
received_quantity = item.received_quantity or 0
ordered_quantity = item.ordered_quantity or 0
if received_quantity > 0:
return (
ProcurementItemExecutionStatus.RECEIVED
if received_quantity >= max(item.quantity, ordered_quantity)
else ProcurementItemExecutionStatus.ORDERED
)
if ordered_quantity > 0:
return ProcurementItemExecutionStatus.ORDERED
return ProcurementItemExecutionStatus.PENDING
def _record_item_update(self, item, user, changed_fields):
create_procurement_activity(
item.request,
user,
ProcurementActivityAction.ITEM_UPDATED,
object_kind=ProcurementActivityObjectKind.ITEM,
object_id=item.pk,
metadata={
"item_name": item.name,
"fields": list(changed_fields),
},
)
def _reset_request_view_states(self, procurement_request):
ProcurementRequestView.objects.filter(
request_id=procurement_request.pk,
is_viewed=True,
).update(
is_viewed=False,
viewed_at=None,
updated_at=timezone.now(),
)
def _reopen_completed_request(self, procurement_request, user):
if procurement_request.status != ProcurementStatus.COMPLETED:
return
procurement_request.status = (
ProcurementStatus.IN_PROGRESS
if procurement_request.executor_id
else ProcurementStatus.WAITING
)
procurement_request.completed_at = None
procurement_request._notification_actor = user
procurement_request.save(
update_fields=["status", "completed_at", "updated_at"]
)
def perform_create(self, serializer):
"""Проверяем права при создании позиции.
Логика соответствует CanManageProcurementRequest:
- Админы/Staff могут добавлять в любые заявки в DRAFT
- Модельные права (change_procurementrequest) → любые заявки в DRAFT
- Автор заявки → свои заявки в DRAFT
- Начальник отдела → заявки своего отдела в DRAFT
"""
from rest_framework.exceptions import PermissionDenied
procurement_request = serializer.validated_data.get("request")
user = self.request.user
if procurement_request.status in [
ProcurementStatus.PENDING,
ProcurementStatus.CANCELLED,
ProcurementStatus.REJECTED,
]:
raise PermissionDenied(
"Нельзя добавлять позиции в заявку со статусом '{}'".format(
procurement_request.get_status_display()
)
)
if self._can_process_request_items(user, procurement_request):
serializer.save()
return
# Автор заявки
if procurement_request.requestor == user and procurement_request.is_editable:
serializer.save()
return
# Начальник отдела
if (
procurement_request.department.head == user
and procurement_request.is_editable
):
serializer.save()
return
# Нет прав
raise PermissionDenied("Вы не можете добавлять позиции в эту заявку")
def _check_item_edit_permission(self, item):
"""Проверка прав на редактирование позиции.
Вынесена в отдельный метод для переиспользования.
"""
from rest_framework.exceptions import PermissionDenied
user = self.request.user
procurement_request = item.request
if procurement_request.status in [
ProcurementStatus.PENDING,
ProcurementStatus.CANCELLED,
ProcurementStatus.REJECTED,
]:
raise PermissionDenied(
"Нельзя изменять позиции в заявке со статусом '{}'".format(
procurement_request.get_status_display()
)
)
if self._can_process_request_items(user, procurement_request):
return True
# Автор заявки
if procurement_request.requestor == user and procurement_request.is_editable:
return True
# Начальник отдела
if (
procurement_request.department.head == user
and procurement_request.is_editable
):
return True
# Нет прав
raise PermissionDenied("Вы не можете изменять позиции в этой заявке")
def _starts_execution(self, item, validated_data):
"""Проверить, меняются ли данные из блока исполнения позиции."""
return any(
field in validated_data
and getattr(item, field) != validated_data[field]
for field in self.EXECUTION_FIELDS
)
def _start_request_for_execution(self, procurement_request):
"""Назначить исполнителя при первом заполнении исполнения позиции."""
user = self.request.user
if (
procurement_request.status
not in {ProcurementStatus.APPROVED, ProcurementStatus.WAITING}
or procurement_request.executor_id
or not self._can_process_request_items(user, procurement_request)
or not ProcurementApprovalResolver.user_can_start_work(
user,
procurement_request,
)
):
return
procurement_request.executor = user
procurement_request.status = ProcurementStatus.IN_PROGRESS
if procurement_request.started_at is None:
procurement_request.started_at = timezone.now()
procurement_request._notification_actor = user
procurement_request.save(
update_fields=[
"executor",
"status",
"started_at",
"updated_at",
]
)
@transaction.atomic
def perform_update(self, serializer):
"""Проверяем права при обновлении позиции."""
item = self.get_object()
procurement_request = ProcurementRequest.objects.select_for_update().get(
pk=item.request_id,
)
item.request = procurement_request
serializer.instance.request = procurement_request
self._check_item_edit_permission(item)
before = {
field: getattr(item, field)
for field in self.NOTIFICATION_TRACKED_FIELDS
}
if self._starts_execution(item, serializer.validated_data):
self._start_request_for_execution(procurement_request)
procurement_request._notification_actor = self.request.user
updated_item = serializer.save()
after = {
field: getattr(updated_item, field)
for field in self.NOTIFICATION_TRACKED_FIELDS
}
changed_fields = [
field
for field in self.NOTIFICATION_TRACKED_FIELDS
if before[field] != after[field]
]
if changed_fields:
notify_item_updated(
updated_item,
actor=self.request.user,
changed_fields=changed_fields,
)
if (
before["execution_status"]
!= ProcurementItemExecutionStatus.DEFECTIVE
and after["execution_status"]
== ProcurementItemExecutionStatus.DEFECTIVE
):
self._reset_request_view_states(procurement_request)
notify_item_issue_reported(
updated_item,
actor=self.request.user,
)
self._record_item_update(
updated_item,
self.request.user,
changed_fields,
)
@action(detail=True, methods=["post"])
def report_issue(self, request, pk=None):
"""Отметить позицию как проблемную/бракованную."""
item = self.get_object()
procurement_request = item.request
user = request.user
error_response = self._validate_item_quick_action(
user,
procurement_request,
)
if error_response is not None:
return error_response
item.execution_status = ProcurementItemExecutionStatus.DEFECTIVE
item.save(update_fields=["execution_status"])
self._reset_request_view_states(procurement_request)
self._reopen_completed_request(procurement_request, user)
text = request.data.get("text", "")
if isinstance(text, str):
text = text.strip()
else:
text = ""
message = None
if text:
message = comments_helpers.create_comment(
obj=item,
author=user,
content=text,
)
notify_item_comment(item, message, actor=user)
notify_item_updated(
item,
actor=user,
changed_fields=["execution_status"],
)
self._record_item_update(item, user, ["execution_status"])
notify_item_issue_reported(item, actor=user)
item.refresh_from_db()
serializer = self.get_serializer(item)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def cancel_received(self, request, pk=None):
"""Отменить подтверждение получения позиции."""
item = self.get_object()
procurement_request = item.request
user = request.user
error_response = self._validate_item_quick_action(
user,
procurement_request,
)
if error_response is not None:
return error_response
current_received_quantity = item.effective_received_quantity
if current_received_quantity <= 0:
return Response(
{"detail": "По позиции нет подтверждённого получения"},
status=status.HTTP_400_BAD_REQUEST,
)
target_received_quantity = 0
if "cancel_quantity" in request.data:
raw_cancel_quantity = request.data.get("cancel_quantity")
try:
cancel_quantity = int(raw_cancel_quantity)
except (TypeError, ValueError):
return Response(
{"cancel_quantity": "Укажите целое число отменяемых единиц"},
status=status.HTTP_400_BAD_REQUEST,
)
if cancel_quantity < 1:
return Response(
{"cancel_quantity": "Количество должно быть больше 0"},
status=status.HTTP_400_BAD_REQUEST,
)
if cancel_quantity > current_received_quantity:
return Response(
{
"cancel_quantity": (
"Не может быть больше текущего полученного количества"
)
},
status=status.HTTP_400_BAD_REQUEST,
)
target_received_quantity = current_received_quantity - cancel_quantity
effective_ordered_quantity = item.effective_ordered_quantity
item.received_quantity = target_received_quantity
if item.ordered_quantity is None and effective_ordered_quantity > 0:
item.ordered_quantity = effective_ordered_quantity
item.execution_status = self._workflow_status_from_quantities(item)
item.save(
update_fields=[
"execution_status",
"ordered_quantity",
"received_quantity",
]
)
self._reopen_completed_request(procurement_request, user)
notify_item_updated(
item,
actor=user,
changed_fields=[
"execution_status",
"ordered_quantity",
"received_quantity",
],
)
self._record_item_update(
item,
user,
["execution_status", "ordered_quantity", "received_quantity"],
)
item.refresh_from_db()
serializer = self.get_serializer(item)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def cancel_issue(self, request, pk=None):
"""Снять отметку брака/перезаказа с позиции."""
item = self.get_object()
procurement_request = item.request
user = request.user
error_response = self._validate_item_quick_action(
user,
procurement_request,
)
if error_response is not None:
return error_response
if item.execution_status != ProcurementItemExecutionStatus.DEFECTIVE:
return Response(
{"detail": "Позиция не отмечена как брак"},
status=status.HTTP_400_BAD_REQUEST,
)
item.execution_status = self._workflow_status_from_quantities(item)
item.save(update_fields=["execution_status"])
notify_item_updated(
item,
actor=user,
changed_fields=["execution_status"],
)
self._record_item_update(item, user, ["execution_status"])
item.refresh_from_db()
serializer = self.get_serializer(item)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def confirm_ordered(self, request, pk=None):
"""Подтвердить заказ дополнительного количества позиции."""
item = self.get_object()
procurement_request = item.request
user = request.user
error_response = self._validate_item_quick_action(
user,
procurement_request,
)
if error_response is not None:
return error_response
current_ordered_quantity = item.effective_ordered_quantity
raw_ordered_quantity = request.data.get("ordered_quantity")
try:
target_ordered_quantity = int(raw_ordered_quantity)
except (TypeError, ValueError):
return Response(
{"ordered_quantity": "Укажите целое число заказанных единиц"},
status=status.HTTP_400_BAD_REQUEST,
)
if target_ordered_quantity <= current_ordered_quantity:
return Response(
{
"ordered_quantity": (
"Должно быть больше текущего заказанного количества"
)
},
status=status.HTTP_400_BAD_REQUEST,
)
expected_delivery_dates = list(item.expected_delivery_dates or [])
delivery_date_added = False
raw_delivery_date = request.data.get("expected_delivery_date")
if raw_delivery_date not in (None, ""):
try:
delivery_date = date.fromisoformat(str(raw_delivery_date))
except ValueError:
return Response(
{"expected_delivery_date": "Укажите корректную дату"},
status=status.HTTP_400_BAD_REQUEST,
)
delivery_date_value = delivery_date.isoformat()
if delivery_date_value not in expected_delivery_dates:
expected_delivery_dates.append(delivery_date_value)
delivery_date_added = True
item.ordered_quantity = target_ordered_quantity
item.expected_delivery_dates = expected_delivery_dates
item.execution_status = self._workflow_status_from_quantities(item)
changed_fields = ["execution_status", "ordered_quantity"]
update_fields = ["execution_status", "ordered_quantity"]
if delivery_date_added:
changed_fields.append("expected_delivery_dates")
update_fields.append("expected_delivery_dates")
item.save(update_fields=update_fields)
self._reopen_completed_request(procurement_request, user)
notify_item_updated(
item,
actor=user,
changed_fields=changed_fields,
)
self._record_item_update(item, user, changed_fields)
item.refresh_from_db()
serializer = self.get_serializer(item)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def confirm_received(self, request, pk=None):
"""Подтвердить получение позиции."""
item = self.get_object()
procurement_request = item.request
user = request.user
error_response = self._validate_item_quick_action(
user,
procurement_request,
)
if error_response is not None:
return error_response
current_received_quantity = item.effective_received_quantity
target_received_quantity = item.fulfillment_target_quantity
if "received_quantity" in request.data:
raw_received_quantity = request.data.get("received_quantity")
try:
target_received_quantity = int(raw_received_quantity)
except (TypeError, ValueError):
return Response(
{"received_quantity": "Укажите целое число полученных единиц"},
status=status.HTTP_400_BAD_REQUEST,
)
if target_received_quantity < 1:
return Response(
{"received_quantity": "Количество должно быть больше 0"},
status=status.HTTP_400_BAD_REQUEST,
)
if target_received_quantity <= current_received_quantity:
return Response(
{
"received_quantity": (
"Должно быть больше текущего полученного количества"
)
},
status=status.HTTP_400_BAD_REQUEST,
)
item.received_quantity = target_received_quantity
item.ordered_quantity = max(
item.effective_ordered_quantity,
target_received_quantity,
)
item.execution_status = self._workflow_status_from_quantities(item)
item.save(
update_fields=[
"execution_status",
"ordered_quantity",
"received_quantity",
]
)
notify_item_updated(
item,
actor=user,
changed_fields=[
"execution_status",
"ordered_quantity",
"received_quantity",
],
)
self._record_item_update(
item,
user,
["execution_status", "ordered_quantity", "received_quantity"],
)
item.refresh_from_db()
serializer = self.get_serializer(item)
return Response(serializer.data)
def perform_destroy(self, instance):
"""Проверяем права при удалении позиции."""
self._check_item_edit_permission(instance)
instance.delete()
def _item_comment_payload(self, item, message):
attachments = [
attachment
for attachment in message.procurement_item_attachments.all()
if attachment.item_id == item.id
]
return {
"id": message.id,
"item": item.id,
"request": item.request_id,
"author": EmployeeBriefSerializer(message.author).data,
"text": message.content,
"created_at": message.created_at,
"attachments": ProcurementItemAttachmentSerializer(
attachments,
many=True,
context=self.get_serializer_context(),
).data,
}
@action(
detail=True,
methods=["get", "post"],
parser_classes=[JSONParser, MultiPartParser, FormParser],
)
def comments(self, request, pk=None):
"""Список/создание комментариев для позиции заявки."""
item = self.get_object()
if not self._can_access_item_comments(request.user, item.request):
return Response(
{"detail": "Нет прав на комментарии этой позиции"},
status=status.HTTP_403_FORBIDDEN,
)
if request.method in ("GET", "HEAD"):
messages = comments_helpers.get_comments(item).prefetch_related(
Prefetch(
"procurement_item_attachments",
queryset=ProcurementItemAttachment.objects.filter(
item=item,
).select_related("uploaded_by"),
)
)
return Response([
self._item_comment_payload(item, message)
for message in messages
])
text = request.data.get("text", "").strip()
files = request.FILES.getlist("files")
if not files and request.FILES.get("file"):
files = [request.FILES["file"]]
if not text and not files:
return Response(
{"detail": "Введите комментарий или добавьте файл."},
status=status.HTTP_400_BAD_REQUEST,
)
with transaction.atomic():
message = comments_helpers.create_comment(
obj=item,
author=request.user,
content=text,
)
created_attachments = []
for uploaded_file in files:
attachment = ProcurementItemAttachment.objects.create(
item=item,
comment=message,
file=uploaded_file,
file_name=Path(uploaded_file.name.replace("\\", "/")).name,
file_size=uploaded_file.size,
mime_type=uploaded_file.content_type or "",
uploaded_by=request.user,
)
created_attachments.append(attachment)
create_procurement_activity(
item.request,
request.user,
ProcurementActivityAction.ATTACHMENT_ADDED,
object_kind=ProcurementActivityObjectKind.ATTACHMENT,
object_id=attachment.pk,
metadata={
"item_id": item.pk,
"item_name": item.name,
"file_name": attachment.file_name,
"comment_id": message.pk,
},
)
if created_attachments:
message.has_attachments = True
message.save(update_fields=["has_attachments"])
item_links, added_links = merge_comment_links(item.links, text)
if added_links:
item.links = item_links
item.save(update_fields=["links"])
notify_item_comment(
item,
message,
actor=request.user,
)
create_procurement_activity(
item.request,
request.user,
ProcurementActivityAction.COMMENT_ADDED,
object_kind=ProcurementActivityObjectKind.COMMENT,
object_id=message.pk,
metadata={
"item_id": item.pk,
"item_name": item.name,
"comment_text": message.content,
"added_links": added_links,
},
)
payload = self._item_comment_payload(item, message)
payload.update({
"item_links": item_links,
"added_links": added_links,
})
return Response(payload, status=status.HTTP_201_CREATED)
@extend_schema(
responses=ProcurementNotificationReadResponseSerializer,
summary="Отметить уведомления о комментариях позиции как прочитанные",
)
@action(
detail=True,
methods=["post"],
url_path="comment-notifications/read",
)
def mark_comment_notifications_read(self, request, pk=None):
item = self.get_object()
self._check_item_read_permission(item)
result = ProcurementNotificationReadService().mark_item_comment_notifications_as_read(
user=request.user,
item=item,
)
return Response(
{
"status": "success",
"count": result.count,
"notification_ids": result.notification_ids,
}
)
@extend_schema(
parameters=[
OpenApiParameter(
name="comment_id",
type=int,
location=OpenApiParameter.PATH,
description="ID комментария в чате комментариев позиции.",
)
]
)
@action(
detail=True,
methods=["delete"],
url_path="comments/(?P<comment_id>[^/.]+)",
)
def delete_comment(self, request, pk=None, comment_id=None):
"""Удаление комментария к позиции заявки."""
item = self.get_object()
if not self._can_access_item_comments(request.user, item.request):
return Response(
{"detail": "Нет прав на комментарии этой позиции"},
status=status.HTTP_403_FORBIDDEN,
)
chat = comments_helpers.get_or_create_comments_chat(item)
if isinstance(chat, tuple):
chat = chat[0]
try:
message = Message.objects.get(id=comment_id, chat=chat)
except Message.DoesNotExist:
return Response(
{"detail": "Комментарий не найден"},
status=status.HTTP_404_NOT_FOUND,
)
if not (request.user.is_staff or message.author == request.user):
return Response(
{"detail": "Нет прав на удаление"},
status=status.HTTP_403_FORBIDDEN,
)
comments_helpers.delete_comment(
message=message,
deleted_by=request.user,
soft_delete=True,
)
create_procurement_activity(
item.request,
request.user,
ProcurementActivityAction.COMMENT_REMOVED,
object_kind=ProcurementActivityObjectKind.COMMENT,
object_id=message.pk,
metadata={
"item_id": item.pk,
"item_name": item.name,
"comment_text": message.content,
},
)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["post"])
def create_equipment(self, request, pk=None):
"""Создать оборудование из позиции закупки.
Эндпоинт для ручного создания оборудования после получения товара.
Требуется: inventory_number, category, department.
Опционально: serial_number, location, warranty_until, responsible.
"""
item = self.get_object()
# Проверяем, что заявка завершена
if item.request.status != ProcurementStatus.COMPLETED:
return Response(
{
"error": "Оборудование можно создать только из "
"завершённой заявки"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Проверяем, что оборудование еще не создано
if item.equipment is not None:
return Response(
{"error": "Оборудование для этой позиции уже создано"},
status=status.HTTP_400_BAD_REQUEST,
)
# Получаем данные из запроса
inventory_number = request.data.get("inventory_number")
category_id = request.data.get("category")
department_id = request.data.get("department")
serial_number = request.data.get("serial_number", "")
location = request.data.get("location", "")
warranty_until = request.data.get("warranty_until")
responsible_person_id = request.data.get("responsible_person")
# Валидация обязательных полей
if not inventory_number:
return Response(
{"error": "Укажите инвентарный номер"},
status=status.HTTP_400_BAD_REQUEST,
)
if not category_id:
return Response(
{"error": "Укажите категорию оборудования"},
status=status.HTTP_400_BAD_REQUEST,
)
if not department_id:
return Response(
{"error": "Укажите отдел"}, status=status.HTTP_400_BAD_REQUEST
)
# Проверяем уникальность инвентарного номера
exists = Equipment.objects.filter(
inventory_number=inventory_number
).exists()
if exists:
return Response(
{
"error": f'Инвентарный номер "{inventory_number}" '
"уже используется"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Создаем оборудование
from django.utils import timezone
purchase_cost = item.actual_unit_price or item.estimated_unit_price
if purchase_cost is None:
return Response(
{
"error": (
"Для создания оборудования укажите фактическую "
"или ориентировочную цену позиции"
)
},
status=status.HTTP_400_BAD_REQUEST,
)
try:
equipment = Equipment.objects.create(
name=item.name,
inventory_number=inventory_number,
serial_number=serial_number,
category_id=category_id,
department_id=department_id,
status=EquipmentStatus.AVAILABLE,
responsible_person_id=responsible_person_id,
location=location,
purchase_date=timezone.now().date(),
warranty_until=warranty_until,
purchase_cost=purchase_cost,
notes=item.description, # description -> notes
)
except Exception as e:
return Response(
{"error": f"Ошибка создания оборудования: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
)
# Связываем оборудование с позицией закупки
item.equipment = equipment
item.save()
return Response(
{
"message": "Оборудование успешно создано",
"equipment": {
"id": equipment.id,
"name": equipment.name,
"inventory_number": equipment.inventory_number,
},
},
status=status.HTTP_201_CREATED,
)
@action(detail=True, methods=["post"])
def link_equipment(self, request, pk=None):
"""Связать существующее оборудование с позицией закупки.
Используется после создания оборудования через основной API.
Требуется: equipment_id.
"""
item = self.get_object()
# Проверяем, что заявка завершена
if item.request.status != ProcurementStatus.COMPLETED:
return Response(
{
"error": "Связывать оборудование можно только "
"с позициями завершённых заявок"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Проверяем, что оборудование ещё не связано
if item.equipment is not None:
return Response(
{"error": "Оборудование для этой позиции уже связано"},
status=status.HTTP_400_BAD_REQUEST,
)
equipment_id = request.data.get("equipment_id")
if not equipment_id:
return Response(
{"error": "Укажите ID оборудования"},
status=status.HTTP_400_BAD_REQUEST,
)
# Находим оборудование
try:
equipment = Equipment.objects.get(pk=equipment_id)
except Equipment.DoesNotExist:
return Response(
{"error": "Оборудование не найдено"},
status=status.HTTP_404_NOT_FOUND,
)
# Проверяем, что оборудование ещё не привязано к другой позиции
if (
hasattr(equipment, "procurement_item")
and equipment.procurement_item
):
return Response(
{"error": "Это оборудование уже связано с другой позицией"},
status=status.HTTP_400_BAD_REQUEST,
)
# Связываем
item.equipment = equipment
item.save()
return Response(
{
"message": "Оборудование успешно связано с позицией",
"equipment": {
"id": equipment.id,
"name": equipment.name,
"inventory_number": equipment.inventory_number,
},
},
status=status.HTTP_200_OK,
)
class EquipmentCategoryViewSet(viewsets.ModelViewSet):
"""ViewSet для категорий оборудования."""
queryset = EquipmentCategory.objects.prefetch_related("children")
serializer_class = EquipmentCategorySerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ["name", "description"]
ordering = ["name"]
def get_permissions(self):
"""Чтение доступно всем сотрудникам, изменение только уполномоченным."""
return [CanManageEquipmentCategory()]
@action(detail=True, methods=["get"])
def children(self, request, pk=None):
"""Получить подкатегории."""
category = self.get_object()
serializer = self.get_serializer(category.children.all(), many=True)
return Response(serializer.data)
@action(detail=False, methods=["get"])
def tree(self, request):
"""Получить дерево категорий."""
# Получаем только корневые категории
root_categories = self.get_queryset().filter(parent=None)
serializer = self.get_serializer(root_categories, many=True)
return Response(serializer.data)
class EquipmentViewSet(viewsets.ModelViewSet):
"""ViewSet для оборудования."""
queryset = Equipment.objects.select_related(
"category", "department", "responsible_person"
)
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filterset_fields = {
"status": ["exact"],
"category": ["exact"],
"department": ["exact"],
"responsible_person": ["exact"],
"purchase_date": ["gte", "lte"],
}
search_fields = [
"name",
"inventory_number",
"serial_number",
"location",
]
ordering_fields = ["purchase_date", "name", "created_at"]
ordering = ["-created_at"]
def get_queryset(self):
"""Annotate comments_count."""
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Subquery, OuterRef, IntegerField
from communications.models import Chat
ct = ContentType.objects.get_for_model(Equipment)
qs = super().get_queryset()
comments_sub = (
Chat.objects.filter(
type="comments",
context_content_type=ct,
context_object_id=OuterRef("pk"),
)
.annotate(
msg_count=Count(
"messages", filter=Q(messages__is_deleted=False)
)
)
.values("msg_count")[:1]
)
return qs.annotate(
comments_count=Subquery(
comments_sub, output_field=IntegerField(default=0)
)
)
def get_serializer_class(self):
"""Выбрать сериализатор."""
if self.action == "retrieve":
return EquipmentDetailSerializer
return EquipmentListSerializer
def get_permissions(self):
"""Права доступа."""
permission_classes = [CanManageEquipment]
return [permission() for permission in permission_classes]
def create(self, request, *args, **kwargs):
"""Создание оборудования с поддержкой массового создания.
Если передан параметр quantity > 1, создаётся несколько единиц
с автоматически сгенерированными инвентарными номерами.
"""
try:
quantity = int(request.data.get("quantity", 1))
except (ValueError, TypeError):
quantity = 1
quantity = max(1, min(quantity, 100)) # Ограничение от 1 до 100
if quantity == 1:
# Стандартное создание одной единицы
return super().create(request, *args, **kwargs)
# Массовое создание
created_equipment = []
errors = []
for i in range(quantity):
serializer = self.get_serializer(data=request.data)
if serializer.is_valid():
self.perform_create(serializer)
created_equipment.append(serializer.data)
else:
errors.append({"index": i, "errors": serializer.errors})
if errors and not created_equipment:
return Response(
{"detail": "Не удалось создать оборудование", "errors": errors},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(
{
"created_count": len(created_equipment),
"equipment": created_equipment,
"errors": errors if errors else None,
},
status=status.HTTP_201_CREATED,
)
@action(detail=False, methods=["get"])
def my_equipment(self, request):
"""Оборудование, за которое отвечает текущий пользователь."""
queryset = self.get_queryset().filter(responsible_person=request.user)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=["get"])
def warranty_expiring(self, request):
"""Оборудование с истекающей гарантией (< 30 дней)."""
from datetime import date, timedelta
threshold = date.today() + timedelta(days=30)
queryset = self.get_queryset().filter(
warranty_until__lte=threshold, warranty_until__gte=date.today()
)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=["get"], url_path="generate-inventory-number")
def generate_inventory_number(self, request):
"""Генерация уникального инвентарного номера."""
from procurement.services import InventoryNumberGenerator
inventory_number = InventoryNumberGenerator.generate()
return Response({"inventory_number": inventory_number})
@action(detail=False, methods=["get"], url_path="create-options")
def create_options(self, request):
"""Возвращает доступные опции для создания оборудования.
Определяет уровень прав пользователя и возвращает:
- allowed_departments: список отделов для выбора
- can_choose_department: может ли пользователь выбирать отдел
- can_choose_responsible: может ли выбирать ответственного
- default_responsible: ответственный по умолчанию (если нет выбора)
"""
from api.v1.permissions import has_dept_perm
from employees.constants import DeptPerm
from employees.models import EmployeeDepartment
user = request.user
# Определяем уровень прав
if user.is_staff or user.is_superuser:
perm_level = "full"
elif user.has_perm("procurement.add_equipment"):
perm_level = "full"
elif Department.objects.filter(head_id=user.id).exists():
perm_level = "dept_head"
else:
# Проверяем скоуп-право
user_dept_links = EmployeeDepartment.objects.filter(
employee_id=user.id, is_active=True
).select_related("department")
has_scoped = False
for link in user_dept_links:
if has_dept_perm(
user, link.department_id, DeptPerm.MANAGE_EQUIPMENT
):
has_scoped = True
break
perm_level = "scoped" if has_scoped else None
# Формируем ответ в зависимости от уровня прав
if perm_level == "full":
# Полный доступ — все отделы
departments = Department.objects.all().values("id", "name")
return Response(
{
"allowed_departments": list(departments),
"can_choose_department": True,
"can_choose_responsible": True,
"default_responsible": None,
"permission_level": "full",
}
)
elif perm_level == "dept_head":
# Начальник — только свои отделы
departments = Department.objects.filter(head_id=user.id).values(
"id", "name"
)
return Response(
{
"allowed_departments": list(departments),
"can_choose_department": False,
"can_choose_responsible": True,
"default_responsible": {
"id": user.id,
"name": user.get_full_name(),
},
"permission_level": "dept_head",
}
)
elif perm_level == "scoped":
# Скоуп-право — отделы с правом, ответственный = начальник
allowed_depts = []
default_responsible = None
user_dept_links = EmployeeDepartment.objects.filter(
employee_id=user.id, is_active=True
).select_related("department", "department__head")
for link in user_dept_links:
if has_dept_perm(
user, link.department_id, DeptPerm.MANAGE_EQUIPMENT
):
dept = link.department
allowed_depts.append({"id": dept.id, "name": dept.name})
if default_responsible is None and dept.head:
default_responsible = {
"id": dept.head.id,
"name": dept.head.get_full_name(),
}
return Response(
{
"allowed_departments": allowed_depts,
"can_choose_department": False,
"can_choose_responsible": False,
"default_responsible": default_responsible,
"permission_level": "scoped",
}
)
else:
# Нет прав
return Response(
{
"allowed_departments": [],
"can_choose_department": False,
"can_choose_responsible": False,
"default_responsible": None,
"permission_level": None,
}
)
@action(detail=True, methods=["post"])
def transfer(self, request, pk=None):
"""Перевод оборудования в другой отдел или другому пользователю."""
equipment = self.get_object()
to_department_id = request.data.get("to_department")
to_person_id = request.data.get("to_person")
to_location = request.data.get("to_location")
reason = request.data.get("reason", "")
if not to_department_id and not to_person_id:
return Response(
{"error": "Укажите отдел или ответственного"},
status=status.HTTP_400_BAD_REQUEST,
)
# Сохраняем старые значения
from_department = equipment.department
from_person = equipment.responsible_person
# Обновляем оборудование
if to_department_id:
try:
to_department = Department.objects.get(pk=to_department_id)
equipment.department = to_department
except Department.DoesNotExist:
return Response(
{"error": "Отдел не найден"},
status=status.HTTP_404_NOT_FOUND,
)
else:
to_department = from_department
if to_person_id:
from django.contrib.auth import get_user_model
User = get_user_model()
try:
to_person = User.objects.get(pk=to_person_id)
equipment.responsible_person = to_person
except User.DoesNotExist:
return Response(
{"error": "Пользователь не найден"},
status=status.HTTP_404_NOT_FOUND,
)
else:
to_person = from_person
if to_location:
equipment.location = to_location
equipment.save()
# Создаём лог перевода
EquipmentTransferLog.objects.create(
equipment=equipment,
from_department=from_department,
to_department=to_department,
from_person=from_person,
to_person=to_person,
to_location=to_location or "",
reason=reason,
created_by=request.user,
)
return Response(
{
"status": "transferred",
"equipment_id": equipment.id,
"from_department": str(from_department),
"to_department": str(to_department),
}
)
@action(detail=True, methods=["post"])
def write_off(self, request, pk=None):
"""Списание оборудования."""
equipment = self.get_object()
reason = request.data.get("reason", "")
if equipment.status == EquipmentStatus.RETIRED:
return Response(
{"error": "Оборудование уже списано"},
status=status.HTTP_400_BAD_REQUEST,
)
equipment.status = EquipmentStatus.RETIRED
equipment.notes = (equipment.notes or "") + f"\n\nСписано: {reason}"
equipment.save()
return Response({"status": "written_off", "equipment_id": equipment.id})
@action(detail=True, methods=["post"])
def add_maintenance(self, request, pk=None):
"""Добавить запись об обслуживании."""
from datetime import date
equipment = self.get_object()
maintenance_type = request.data.get("type", "repair")
description = request.data.get("description", "")
cost = request.data.get("cost")
maintenance_date = request.data.get("date", date.today())
record = MaintenanceRecord.objects.create(
equipment=equipment,
type=maintenance_type,
description=description,
cost=cost,
date=maintenance_date,
performed_by=request.user,
)
return Response(
{
"status": "created",
"maintenance_id": record.id,
"equipment_id": equipment.id,
},
status=status.HTTP_201_CREATED,
)
@action(detail=True, methods=["get"])
def transfer_history(self, request, pk=None):
"""История переводов оборудования."""
equipment = self.get_object()
logs = EquipmentTransferLog.objects.filter(
equipment=equipment
).order_by("-created_at")
data = [
{
"id": log.id,
"from_department": str(log.from_department),
"to_department": str(log.to_department),
"from_person": (
str(log.from_person) if log.from_person else None
),
"to_person": (str(log.to_person) if log.to_person else None),
"reason": log.reason,
"created_by": str(log.created_by) if log.created_by else None,
"date": log.created_at.isoformat(),
}
for log in logs
]
return Response(data)
@action(detail=True, methods=["get", "post"])
def comments(self, request, pk=None):
"""Список/создание комментариев для оборудования."""
from api.v1.employees.serializers.employee import (
EmployeeBriefSerializer,
)
equipment = self.get_object()
if request.method in ("GET", "HEAD"):
messages = comments_helpers.get_comments(equipment)
comments_data = []
for msg in messages:
author_ser = EmployeeBriefSerializer(msg.author)
comments_data.append(
{
"id": msg.id,
"equipment": equipment.id,
"author": author_ser.data,
"text": msg.content,
"created_at": msg.created_at,
}
)
return Response(comments_data)
text = request.data.get("text", "").strip()
if not text:
return Response(
{"text": ["Это поле не может быть пустым."]},
status=status.HTTP_400_BAD_REQUEST,
)
message = comments_helpers.create_comment(
obj=equipment,
author=request.user,
content=text,
)
author_ser = EmployeeBriefSerializer(message.author)
return Response(
{
"id": message.id,
"equipment": equipment.id,
"author": author_ser.data,
"text": message.content,
"created_at": message.created_at,
},
status=status.HTTP_201_CREATED,
)
@extend_schema(
parameters=[
OpenApiParameter(
name="comment_id",
type=int,
location=OpenApiParameter.PATH,
description="ID комментария в чате комментариев оборудования.",
)
]
)
@action(
detail=True,
methods=["delete"],
url_path="comments/(?P<comment_id>[^/.]+)",
)
def delete_comment(self, request, pk=None, comment_id=None):
"""Удаление комментария к оборудованию."""
equipment = self.get_object()
chat = comments_helpers.get_or_create_comments_chat(equipment)
if isinstance(chat, tuple):
chat = chat[0]
try:
message = Message.objects.get(id=comment_id, chat=chat)
except Message.DoesNotExist:
return Response(
{"detail": "Комментарий не найден"},
status=status.HTTP_404_NOT_FOUND,
)
if not (request.user.is_staff or message.author == request.user):
return Response(
{"detail": "Нет прав на удаление"},
status=status.HTTP_403_FORBIDDEN,
)
comments_helpers.delete_comment(
message=message,
deleted_by=request.user,
soft_delete=True,
)
return Response(status=status.HTTP_204_NO_CONTENT)
class MaintenanceRecordViewSet(viewsets.ModelViewSet):
"""ViewSet для записей обслуживания."""
queryset = MaintenanceRecord.objects.select_related(
"equipment", "performed_by"
)
serializer_class = MaintenanceRecordSerializer
filter_backends = [
DjangoFilterBackend,
filters.OrderingFilter,
]
filterset_fields = ["equipment", "type", "performed_by"]
ordering_fields = ["date"]
ordering = ["-date"]
def perform_create(self, serializer):
"""При создании записи устанавливаем performed_by."""
serializer.save(performed_by=self.request.user)
class SupplierViewSet(viewsets.ModelViewSet):
"""ViewSet для поставщиков."""
queryset = Supplier.objects.all()
serializer_class = SupplierSerializer
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filterset_fields = ["is_active"]
search_fields = ["name", "contact_person", "inn"]
ordering_fields = ["name", "rating"]
ordering = ["name"]
def get_permissions(self):
"""Права доступа."""
permission_classes = [CanManageSupplier]
return [permission() for permission in permission_classes]
@action(detail=False, methods=["get"])
def top_rated(self, request):
"""Получить поставщиков с лучшим рейтингом."""
queryset = (
self.get_queryset()
.filter(is_active=True, rating__gte=4.0)
.order_by("-rating")[:10]
)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
class ProcurementStatsViewSet(viewsets.ViewSet):
"""ViewSet для статистики закупок."""
permission_classes = [permissions.IsAuthenticated]
@extend_schema(
tags=["Procurement"],
summary="Получить общую статистику закупок",
responses=ProcurementOverviewStatsSerializer,
)
@action(detail=False, methods=["get"])
def overview(self, request):
"""Общая статистика закупок."""
from django.db.models import Sum, Count
from django.utils import timezone
now = timezone.now()
month_start = now.replace(day=1, hour=0, minute=0, second=0)
year_start = now.replace(month=1, day=1, hour=0, minute=0, second=0)
user = request.user
# Базовый queryset зависит от прав пользователя
if user.is_superuser or user.is_staff:
base_qs = ProcurementRequest.objects.all()
else:
base_qs = ProcurementRequest.objects.filter(
Q(requestor=user) | Q(department__in=user.departments.all())
)
# Подсчёт по статусам
by_status = dict(
base_qs.values("status")
.annotate(count=Count("id"))
.values_list("status", "count")
)
# Подсчёт по срочности
by_urgency = dict(
base_qs.values("urgency")
.annotate(count=Count("id"))
.values_list("urgency", "count")
)
# Общие метрики
total = base_qs.count()
pending = base_qs.filter(status=ProcurementStatus.PENDING).count()
approved_month = base_qs.filter(
status=ProcurementStatus.APPROVED, updated_at__gte=month_start
).count()
completed_month = base_qs.filter(
status=ProcurementStatus.COMPLETED, completed_at__gte=month_start
).count()
# Сумма потраченного за год
spent_year = (
base_qs.filter(
status=ProcurementStatus.COMPLETED, completed_at__gte=year_start
).aggregate(total=Sum("actual_cost"))["total"]
or 0
)
return Response(
{
"total_requests": total,
"pending_requests": pending,
"approved_this_month": approved_month,
"completed_this_month": completed_month,
"total_spent_this_year": str(spent_year),
"by_status": by_status,
"by_urgency": by_urgency,
}
)
@extend_schema(
tags=["Procurement"],
summary="Получить статистику закупок по отделам",
responses=ProcurementDepartmentStatsSerializer(many=True),
)
@action(detail=False, methods=["get"], url_path="by-department")
def by_department(self, request):
"""Статистика по отделам."""
from django.db.models import Sum
user = request.user
# Только для staff/superuser или руководителей
if not (user.is_superuser or user.is_staff):
if not user.headed_departments.exists():
return Response(
{"detail": "Нет доступа к статистике по отделам."},
status=status.HTTP_403_FORBIDDEN,
)
departments = user.headed_departments.all()
else:
departments = Department.objects.all()
result = []
for dept in departments:
requests = ProcurementRequest.objects.filter(department=dept)
total = requests.count()
spent = (
requests.filter(status=ProcurementStatus.COMPLETED).aggregate(
total=Sum("actual_cost")
)["total"]
or 0
)
result.append(
{
"department": {"id": dept.id, "name": dept.name},
"total_requests": total,
"total_spent": str(spent),
}
)
return Response(result)
class BudgetViewSet(viewsets.ReadOnlyModelViewSet):
"""ViewSet для просмотра бюджетов отдела."""
queryset = Budget.objects.select_related("department")
serializer_class = BudgetSerializer
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ["department", "year", "quarter"]
ordering = ["-year", "-quarter", "department__name"]
def get_permissions(self):
return [permissions.IsAuthenticated()]
def get_queryset(self):
queryset = super().get_queryset()
user = self.request.user
if user.is_superuser or user.is_staff:
return queryset
headed_departments = user.headed_departments.all()
if headed_departments.exists():
return queryset.filter(department__in=headed_departments)
if self.action == "my_department":
return queryset
return queryset.none()
def list(self, request, *args, **kwargs):
user = request.user
if not (
user.is_superuser
or user.is_staff
or user.headed_departments.exists()
):
return Response(
{"detail": "Нет доступа к бюджетам отделов."},
status=status.HTTP_403_FORBIDDEN,
)
return super().list(request, *args, **kwargs)
@action(detail=False, methods=["get"], url_path="my-department")
def my_department(self, request):
"""Бюджет текущего отдела пользователя на текущий квартал."""
user_departments = request.user.departments.all()
department = user_departments.first()
if department is None:
return Response(
{"detail": "Пользователь не состоит ни в одном отделе."},
status=status.HTTP_404_NOT_FOUND,
)
now = timezone.now()
quarter = (now.month - 1) // 3 + 1
budget = (
self.get_queryset()
.filter(
department=department,
year=now.year,
quarter=quarter,
)
.first()
)
if budget is None:
return Response(
{"detail": "Бюджет на текущий квартал не найден."},
status=status.HTTP_404_NOT_FOUND,
)
serializer = self.get_serializer(budget)
return Response(serializer.data)