3358 lines
126 KiB
Python
3358 lines
126 KiB
Python
"""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)
|