752 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""ViewSets для API модуля закупок."""
from datetime import date
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from drf_spectacular.utils import extend_schema
from django.db.models import 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.response import Response
from employees.models import EmployeeDepartment
from procurement.constants import (
ProcurementStatus,
ProcurementItemExecutionStatus,
)
from procurement.models import (
ProcurementItem,
ProcurementActivityAction,
ProcurementActivityObjectKind,
ProcurementRequest,
ProcurementRequestView,
)
from procurement.activity import create_procurement_activity
from procurement.notifications.handlers import (
notify_item_comment,
notify_item_issue_reported,
notify_item_updated,
)
from communications import comments_helpers
from procurement.services import ProcurementApprovalResolver
from ..permissions import (
CanManageProcurementRequest,
)
from ..serializers import (
ProcurementItemSerializer,
)
from .mixins import ItemAttachmentsMixin, ItemCommentsMixin, ItemEquipmentMixin
class ProcurementItemViewSet(
ItemAttachmentsMixin,
ItemCommentsMixin,
ItemEquipmentMixin,
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.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",
"attachment_thumbnail",
]:
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("Нет доступа к этой позиции")
@extend_schema(
methods=["GET"],
operation_id="procurement_items_attachments_list",
)
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()