Allow procurement executor reassignment

This commit is contained in:
lizerk 2026-06-09 12:43:48 +03:00
parent 91c9820109
commit 76799c453e
9 changed files with 511 additions and 23 deletions

View File

@ -129,6 +129,12 @@ class CanManageProcurementRequest(permissions.BasePermission):
perm = perm_map.get(action)
return perm and user.has_perm(perm)
def _has_procurement_execute_perm(self, user) -> bool:
return (
user.has_perm("procurement.change_procurementrequest")
or user.has_perm("procurement.execute_procurement")
)
def _is_dept_head(self, user, dept_id: int) -> bool:
"""Проверяет, является ли пользователь начальником отдела."""
return Department.objects.filter(id=dept_id, head_id=user.id).exists()
@ -211,6 +217,8 @@ class CanManageProcurementRequest(permissions.BasePermission):
# Start_work: общий пул может взять любой, адресную заявку - отдел.
if action == "start_work":
if self._has_procurement_execute_perm(user):
return True
if obj.processing_department_id:
return self._is_dept_member(
user,

View File

@ -28,6 +28,7 @@ from procurement.models import (
Supplier,
)
from procurement.notifications.handlers import (
notify_executor_reassigned,
notify_item_comment,
notify_item_issue_reported,
notify_item_updated,
@ -192,11 +193,18 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
queryset = queryset.filter(executor=user)
elif scope == "available":
queryset = queryset.filter(
status__in=[
ProcurementStatus.WAITING,
ProcurementStatus.APPROVED,
],
executor__isnull=True,
Q(
status__in=[
ProcurementStatus.WAITING,
ProcurementStatus.APPROVED,
],
executor__isnull=True,
)
| Q(
status=ProcurementStatus.IN_PROGRESS,
processing_department__isnull=False,
executor__isnull=False,
)
)
if not (
user.is_superuser
@ -213,6 +221,18 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
status=ProcurementStatus.APPROVED,
processing_department__isnull=True,
)
| (
Q(
status=ProcurementStatus.IN_PROGRESS,
processing_department_id__in=participant_department_ids,
)
& ~Q(executor=user)
)
)
else:
queryset = queryset.exclude(
status=ProcurementStatus.IN_PROGRESS,
executor=user,
)
elif scope == "all":
# Все заявки - применяем стандартную логику прав
@ -227,6 +247,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
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:
@ -639,11 +660,18 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
"""Получить заявки, доступные для взятия в работу."""
queryset = self.filter_queryset(
self.get_queryset().filter(
status__in=[
ProcurementStatus.WAITING,
ProcurementStatus.APPROVED,
],
executor__isnull=True,
Q(
status__in=[
ProcurementStatus.WAITING,
ProcurementStatus.APPROVED,
],
executor__isnull=True,
)
| Q(
status=ProcurementStatus.IN_PROGRESS,
processing_department__isnull=False,
executor__isnull=False,
)
)
)
@ -667,6 +695,18 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
status=ProcurementStatus.APPROVED,
processing_department__isnull=True,
)
| (
Q(
status=ProcurementStatus.IN_PROGRESS,
processing_department_id__in=participant_department_ids,
)
& ~Q(executor=request.user)
)
)
else:
queryset = queryset.exclude(
status=ProcurementStatus.IN_PROGRESS,
executor=request.user,
)
page = self.paginate_queryset(queryset)
@ -683,15 +723,52 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
Общий пул может взять любой авторизованный пользователь.
Адресную заявку может взять сотрудник отдела-исполнителя.
Адресную заявку в работе может забрать другой сотрудник отдела.
"""
procurement_request = self.get_object()
self.check_object_permissions(request, procurement_request)
# Проверяем текущий статус
if procurement_request.status not in [
is_reassignment = (
procurement_request.status == ProcurementStatus.IN_PROGRESS
and procurement_request.processing_department_id
and procurement_request.executor_id
)
if is_reassignment:
previous_executor = procurement_request.executor
if previous_executor == request.user:
return Response(
{"error": "Вы уже являетесь исполнителем заявки"},
status=status.HTTP_400_BAD_REQUEST,
)
if not ProcurementApprovalResolver.user_can_start_work(
request.user,
procurement_request,
):
return Response(
{"error": "Вы не можете забрать эту заявку в работу"},
status=status.HTTP_403_FORBIDDEN,
)
procurement_request.executor = request.user
procurement_request.started_at = timezone.now()
procurement_request.save(
update_fields=["executor", "started_at", "updated_at"],
)
notify_executor_reassigned(
procurement_request,
previous_executor,
request.user,
)
serializer = self.get_serializer(procurement_request)
return Response(serializer.data)
if procurement_request.status not in {
ProcurementStatus.APPROVED,
ProcurementStatus.WAITING,
]:
}:
return Response(
{
"error": (
@ -714,6 +791,15 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
if not ProcurementApprovalResolver.user_can_start_work(
request.user,
procurement_request,
):
return Response(
{"error": "Вы не можете взять эту заявку в работу"},
status=status.HTTP_403_FORBIDDEN,
)
# Назначаем исполнителя и меняем статус
# Сигнал post_save(ProcurementRequest) с IN_PROGRESS отправит
# уведомления

View File

@ -36,6 +36,7 @@ from .handlers import (
notify_request_rejected,
notify_request_completed,
notify_request_in_progress,
notify_executor_reassigned,
notify_request_returned_to_processing,
notify_request_cancelled,
notify_stage_approved,
@ -66,6 +67,7 @@ __all__ = [
'notify_request_rejected',
'notify_request_completed',
'notify_request_in_progress',
'notify_executor_reassigned',
'notify_request_returned_to_processing',
'notify_request_cancelled',
'notify_stage_approved',

View File

@ -22,6 +22,7 @@ class NotificationVerbs:
APPROVED = 'procurement_approved'
REJECTED = 'procurement_rejected'
IN_PROGRESS = 'procurement_in_progress'
EXECUTOR_REASSIGNED = 'procurement_executor_reassigned'
COMPLETED = 'procurement_completed'
CANCELLED = 'procurement_cancelled'
@ -141,6 +142,16 @@ class MessageTemplates:
)
return notification_title, description
@staticmethod
def executor_reassigned(
title: str, executor_name: str
) -> tuple[str, str]:
notification_title = 'Заявку забрал другой сотрудник'
description = (
f'{executor_name} взял в работу заявку "{title}".'
)
return notification_title, description
@staticmethod
def in_progress(
title: str, executor_name: str

View File

@ -420,6 +420,36 @@ def notify_request_in_progress(request, executor):
)
def notify_executor_reassigned(request, previous_executor, actor):
"""Уведомить прежнего исполнителя о перехвате адресной заявки."""
if not previous_executor:
return
if actor and previous_executor.id == actor.id:
return
notification_title, description = MessageTemplates.executor_reassigned(
request.title,
_actor_name(actor),
)
_send_procurement_notification(
recipient=previous_executor,
verb=NotificationVerbs.EXECUTOR_REASSIGNED,
procurement_request=request,
title=notification_title,
description=description,
actor=actor,
extra_data={
"old_executor_id": previous_executor.id,
"new_executor_id": actor.id if actor else None,
},
)
logger.info(
f"[Procurement] Исполнитель заявки #{request.id} изменён: "
f"{previous_executor.id} -> {_actor_id(actor)}"
)
def notify_request_cancelled(request):
"""
Уведомить об отмене заявки.

View File

@ -212,19 +212,37 @@ class ProcurementApprovalResolver:
if not user or not user.is_authenticated:
return False
if (
procurement_request.status
not in {ProcurementStatus.APPROVED, ProcurementStatus.WAITING}
or procurement_request.executor_id
):
return False
if (
has_execute_access = (
user.is_superuser
or user.is_staff
or user.has_perm("procurement.change_procurementrequest")
or user.has_perm("procurement.execute_procurement")
)
if (
procurement_request.status == ProcurementStatus.IN_PROGRESS
and procurement_request.processing_department_id
and procurement_request.executor_id
):
if procurement_request.executor_id == user.id:
return False
if has_execute_access:
return True
return cls.user_is_processing_department_member(
user,
procurement_request,
)
if procurement_request.status not in {
ProcurementStatus.APPROVED,
ProcurementStatus.WAITING,
}:
return False
if procurement_request.executor_id:
return False
if has_execute_access:
return True
if procurement_request.processing_department_id:

View File

@ -768,6 +768,42 @@ class TestProcessingDepartmentWorkflow:
)
return employee
@pytest.fixture
def supply_replacement_user(self, db, supply_department):
employee = Employee.objects.create_user(
email="supply-replacement@example.com",
password="testpass123",
phone_number="+79998888893",
first_name="Новый",
last_name="Исполнитель",
is_active=True,
email_verified=True,
send_activation_email=False,
)
EmployeeDepartment.objects.create(
employee=employee,
department=supply_department,
is_active=True,
)
return employee
@pytest.fixture
def procurement_execute_user(self, db):
employee = Employee.objects.create_user(
email="procurement-execute@example.com",
password="testpass123",
phone_number="+79998888894",
first_name="Права",
last_name="Закупок",
is_active=True,
email_verified=True,
send_activation_email=False,
)
employee.user_permissions.add(
Permission.objects.get(codename="execute_procurement")
)
return employee
@pytest.fixture
def outsider(self, db):
return Employee.objects.create_user(
@ -1053,6 +1089,42 @@ class TestProcessingDepartmentWorkflow:
is True
)
def test_processing_permission_flags_allow_reassignment_for_other_member(
self, api_client, user, supply_user, supply_replacement_user,
processing_request, procurement_item_factory
):
procurement_item_factory(request=processing_request)
processing_request.status = ProcurementStatus.IN_PROGRESS
processing_request.executor = supply_user
processing_request.started_at = timezone.now()
processing_request.save(
update_fields=[
"status",
"executor",
"started_at",
"updated_at",
],
)
detail_url = reverse(
'api:v1:procurement:procurementrequest-detail',
kwargs={'pk': processing_request.id},
)
api_client.force_authenticate(user=supply_user)
executor_response = api_client.get(detail_url)
assert executor_response.status_code == status.HTTP_200_OK
assert executor_response.data["can_current_user_start_work"] is False
api_client.force_authenticate(user=supply_replacement_user)
replacement_response = api_client.get(detail_url)
assert replacement_response.status_code == status.HTTP_200_OK
assert replacement_response.data["can_current_user_start_work"] is True
api_client.force_authenticate(user=user)
author_response = api_client.get(detail_url)
assert author_response.status_code == status.HTTP_200_OK
assert author_response.data["can_current_user_start_work"] is False
def test_outsider_cannot_start_processing_department_request(
self, api_client, outsider, processing_request, procurement_item_factory
):
@ -1118,6 +1190,264 @@ class TestProcessingDepartmentWorkflow:
assert processing_request.executor == supply_role_user
assert processing_request.status == ProcurementStatus.IN_PROGRESS
def test_processing_department_user_can_reassign_in_progress_request(
self, api_client, supply_user, supply_replacement_user,
processing_request, procurement_item_factory
):
procurement_item_factory(request=processing_request)
previous_started_at = timezone.now()
processing_request.status = ProcurementStatus.IN_PROGRESS
processing_request.executor = supply_user
processing_request.started_at = previous_started_at
processing_request.save(
update_fields=[
"status",
"executor",
"started_at",
"updated_at",
],
)
api_client.force_authenticate(user=supply_replacement_user)
url = reverse(
'api:v1:procurement:procurementrequest-start-work',
kwargs={'pk': processing_request.id},
)
response = api_client.post(url)
assert response.status_code == status.HTTP_200_OK
processing_request.refresh_from_db()
assert processing_request.executor == supply_replacement_user
assert processing_request.status == ProcurementStatus.IN_PROGRESS
assert processing_request.started_at > previous_started_at
assert response.data["executor"] == supply_replacement_user.id
assert response.data["executor_name"] == (
supply_replacement_user.get_full_name()
)
def test_available_scope_includes_reassignable_processing_request(
self, api_client, supply_user, supply_replacement_user,
processing_request, procurement_item_factory
):
procurement_item_factory(request=processing_request)
processing_request.status = ProcurementStatus.IN_PROGRESS
processing_request.executor = supply_user
processing_request.started_at = timezone.now()
processing_request.save(
update_fields=[
"status",
"executor",
"started_at",
"updated_at",
],
)
api_client.force_authenticate(user=supply_replacement_user)
url = reverse('api:v1:procurement:procurementrequest-list')
response = api_client.get(f"{url}?scope=available")
assert response.status_code == status.HTTP_200_OK
ids = [item['id'] for item in response.data['results']]
assert processing_request.id in ids
def test_procurement_execute_permission_can_reassign_addressed_request(
self, api_client, supply_user, procurement_execute_user,
processing_request, procurement_item_factory
):
procurement_item_factory(request=processing_request)
processing_request.status = ProcurementStatus.IN_PROGRESS
processing_request.executor = supply_user
processing_request.started_at = timezone.now()
processing_request.save(
update_fields=[
"status",
"executor",
"started_at",
"updated_at",
],
)
api_client.force_authenticate(user=procurement_execute_user)
url = reverse(
'api:v1:procurement:procurementrequest-start-work',
kwargs={'pk': processing_request.id},
)
response = api_client.post(url)
assert response.status_code == status.HTTP_200_OK
processing_request.refresh_from_db()
assert processing_request.executor == procurement_execute_user
def test_processing_department_role_user_can_reassign_in_progress_request(
self, api_client, supply_user, supply_role_user,
processing_request, procurement_item_factory
):
procurement_item_factory(request=processing_request)
processing_request.status = ProcurementStatus.IN_PROGRESS
processing_request.executor = supply_user
processing_request.started_at = timezone.now()
processing_request.save(
update_fields=[
"status",
"executor",
"started_at",
"updated_at",
],
)
api_client.force_authenticate(user=supply_role_user)
url = reverse(
'api:v1:procurement:procurementrequest-start-work',
kwargs={'pk': processing_request.id},
)
response = api_client.post(url)
assert response.status_code == status.HTTP_200_OK
processing_request.refresh_from_db()
assert processing_request.executor == supply_role_user
assert processing_request.status == ProcurementStatus.IN_PROGRESS
def test_reassign_in_progress_request_notifies_previous_executor_only(
self, api_client, user, supply_user, supply_replacement_user,
processing_request, procurement_item_factory, monkeypatch
):
sent = []
def fake_notify_send(**kwargs):
sent.append(kwargs)
monkeypatch.setattr(
"procurement.notifications.handlers.notify.send",
fake_notify_send,
)
procurement_item_factory(request=processing_request)
processing_request.status = ProcurementStatus.IN_PROGRESS
processing_request.executor = supply_user
processing_request.started_at = timezone.now()
processing_request.save(
update_fields=[
"status",
"executor",
"started_at",
"updated_at",
],
)
sent.clear()
api_client.force_authenticate(user=supply_replacement_user)
url = reverse(
'api:v1:procurement:procurementrequest-start-work',
kwargs={'pk': processing_request.id},
)
response = api_client.post(url)
assert response.status_code == status.HTTP_200_OK
reassignment_notifications = [
item for item in sent
if item["verb"] == "procurement_executor_reassigned"
]
assert len(reassignment_notifications) == 1
notification = reassignment_notifications[0]
assert notification["recipient"] == supply_user
assert notification["sender"] == supply_replacement_user
assert notification["data"]["title"] == (
"Заявку забрал другой сотрудник"
)
assert notification["description"] == (
f'{supply_replacement_user.get_full_name()} взял в работу заявку '
f'"{processing_request.title}".'
)
assert user not in [
item["recipient"] for item in reassignment_notifications
]
assert "procurement_in_progress" not in [
item["verb"] for item in sent
]
def test_current_executor_cannot_reassign_same_request(
self, api_client, supply_user, processing_request,
procurement_item_factory
):
procurement_item_factory(request=processing_request)
processing_request.status = ProcurementStatus.IN_PROGRESS
processing_request.executor = supply_user
processing_request.started_at = timezone.now()
processing_request.save(
update_fields=[
"status",
"executor",
"started_at",
"updated_at",
],
)
api_client.force_authenticate(user=supply_user)
url = reverse(
'api:v1:procurement:procurementrequest-start-work',
kwargs={'pk': processing_request.id},
)
response = api_client.post(url)
assert response.status_code == status.HTTP_400_BAD_REQUEST
processing_request.refresh_from_db()
assert processing_request.executor == supply_user
def test_outsider_cannot_reassign_in_progress_request(
self, api_client, outsider, supply_user, processing_request,
procurement_item_factory
):
procurement_item_factory(request=processing_request)
processing_request.status = ProcurementStatus.IN_PROGRESS
processing_request.executor = supply_user
processing_request.started_at = timezone.now()
processing_request.save(
update_fields=[
"status",
"executor",
"started_at",
"updated_at",
],
)
api_client.force_authenticate(user=outsider)
url = reverse(
'api:v1:procurement:procurementrequest-start-work',
kwargs={'pk': processing_request.id},
)
response = api_client.post(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
processing_request.refresh_from_db()
assert processing_request.executor == supply_user
def test_non_processing_in_progress_request_cannot_be_reassigned(
self, api_client, user, staff_user, procurement_request,
procurement_item
):
procurement_request.status = ProcurementStatus.IN_PROGRESS
procurement_request.executor = staff_user
procurement_request.started_at = timezone.now()
procurement_request.save(
update_fields=[
"status",
"executor",
"started_at",
"updated_at",
],
)
api_client.force_authenticate(user=user)
url = reverse(
'api:v1:procurement:procurementrequest-start-work',
kwargs={'pk': procurement_request.id},
)
response = api_client.post(url)
assert response.status_code == status.HTTP_400_BAD_REQUEST
procurement_request.refresh_from_db()
assert procurement_request.executor == staff_user
def test_processing_department_role_user_can_manage_items(
self, api_client, supply_role_user, processing_request,
procurement_item_factory

View File

@ -191,6 +191,7 @@ function ProcurementRequestActionButtons({
const isPending = status === "pending";
const canStartWork = Boolean(request.can_current_user_start_work);
const isInProgress = status === "in_progress";
const startWorkLabel = isInProgress ? "Забрать в работу" : "Взять в работу";
const canApproveThis = Boolean(request.can_current_user_approve);
const canSubmitForApproval = Boolean(request.can_current_user_submit_for_approval);
const buttonClass = (variantClass: string) =>
@ -266,11 +267,11 @@ function ProcurementRequestActionButtons({
type="button"
onClick={() => onStart(request.id)}
disabled={busyKey === `start-${request.id}`}
title="Взять в работу"
title={startWorkLabel}
className={buttonClass("app-action-primary")}
>
<Play size={14} />
{label("Взять в работу")}
{label(startWorkLabel)}
</button>
) : null}
{isInProgress && isExecutor ? (

View File

@ -44,6 +44,7 @@ export const VERB_CATEGORIES: Record<string, string> = {
procurement_approved: 'Закупки',
procurement_rejected: 'Закупки',
procurement_in_progress: 'Закупки',
procurement_executor_reassigned: 'Закупки',
procurement_completed: 'Закупки',
procurement_cancelled: 'Закупки',
procurement_item_updated: 'Закупки',
@ -99,6 +100,7 @@ export const VERB_NAMES: Record<string, string> = {
procurement_approved: 'Закупка одобрена',
procurement_rejected: 'Закупка отклонена',
procurement_in_progress: 'Закупка взята в работу',
procurement_executor_reassigned: 'Заявку забрал другой сотрудник',
procurement_completed: 'Закупка завершена',
procurement_cancelled: 'Закупка отменена',
procurement_item_updated: 'Позиция закупки изменена',