procurement: fix department workflow UI bugs

This commit is contained in:
lizerk 2026-05-27 18:36:18 +03:00
parent ddeef4653c
commit b1a5ac34ef
9 changed files with 812 additions and 81 deletions

View File

@ -154,14 +154,10 @@ class CanManageProcurementRequest(permissions.BasePermission):
return Department.objects.filter(id=dept_id, head_id=user.id).exists()
def _is_dept_member(self, user, dept_id: int) -> bool:
"""Проверяет активную принадлежность пользователя к отделу."""
return (
EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=dept_id,
is_active=True,
).exists()
or self._is_dept_head(user, dept_id)
"""Проверяет участие пользователя в отделе, включая role-only."""
return ProcurementApprovalResolver.user_is_department_participant(
user,
dept_id,
)
def has_permission(self, request, view) -> bool:

View File

@ -367,6 +367,8 @@ class ProcurementRequestListSerializer(
comments_count = serializers.IntegerField(read_only=True, default=0)
can_current_user_approve = serializers.SerializerMethodField()
can_current_user_submit_for_approval = serializers.SerializerMethodField()
can_current_user_start_work = serializers.SerializerMethodField()
can_current_user_process_items = serializers.SerializerMethodField()
@extend_schema_field(serializers.BooleanField())
def get_can_current_user_approve(self, obj):
@ -383,6 +385,18 @@ class ProcurementRequestListSerializer(
obj,
)
@extend_schema_field(serializers.BooleanField())
def get_can_current_user_start_work(self, obj):
request = self.context.get("request")
user = getattr(request, "user", None)
return ProcurementApprovalResolver.user_can_start_work(user, obj)
@extend_schema_field(serializers.BooleanField())
def get_can_current_user_process_items(self, obj):
request = self.context.get("request")
user = getattr(request, "user", None)
return ProcurementApprovalResolver.user_can_process_items(user, obj)
class Meta:
model = ProcurementRequest
fields = [
@ -416,6 +430,8 @@ class ProcurementRequestListSerializer(
"comments_count",
"can_current_user_approve",
"can_current_user_submit_for_approval",
"can_current_user_start_work",
"can_current_user_process_items",
"items",
"created_at",
"submitted_at",
@ -447,6 +463,8 @@ class ProcurementRequestListSerializer(
"comments_count",
"can_current_user_approve",
"can_current_user_submit_for_approval",
"can_current_user_start_work",
"can_current_user_process_items",
]
def update(self, instance, validated_data):
@ -582,6 +600,8 @@ class ProcurementRequestDetailSerializer(
comments_count = serializers.IntegerField(read_only=True, default=0)
can_current_user_approve = serializers.SerializerMethodField()
can_current_user_submit_for_approval = serializers.SerializerMethodField()
can_current_user_start_work = serializers.SerializerMethodField()
can_current_user_process_items = serializers.SerializerMethodField()
@extend_schema_field(serializers.BooleanField())
def get_can_current_user_approve(self, obj):
@ -598,6 +618,18 @@ class ProcurementRequestDetailSerializer(
obj,
)
@extend_schema_field(serializers.BooleanField())
def get_can_current_user_start_work(self, obj):
request = self.context.get("request")
user = getattr(request, "user", None)
return ProcurementApprovalResolver.user_can_start_work(user, obj)
@extend_schema_field(serializers.BooleanField())
def get_can_current_user_process_items(self, obj):
request = self.context.get("request")
user = getattr(request, "user", None)
return ProcurementApprovalResolver.user_can_process_items(user, obj)
class Meta:
model = ProcurementRequest
fields = [
@ -636,6 +668,8 @@ class ProcurementRequestDetailSerializer(
"comments_count",
"can_current_user_approve",
"can_current_user_submit_for_approval",
"can_current_user_start_work",
"can_current_user_process_items",
"created_at",
"updated_at",
"submitted_at",
@ -666,6 +700,8 @@ class ProcurementRequestDetailSerializer(
"comments_count",
"can_current_user_approve",
"can_current_user_submit_for_approval",
"can_current_user_start_work",
"can_current_user_process_items",
]

View File

@ -146,6 +146,11 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
user = self.request.user
scope = self.request.query_params.get("scope", None)
period = self.request.query_params.get("period", None)
participant_department_ids = (
ProcurementApprovalResolver.get_user_department_participant_ids(
user,
)
)
# Обработка scope параметра
if scope == "mine":
@ -154,6 +159,11 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
elif scope == "department":
# Заявки моего отдела
queryset = queryset.filter(department__in=user.departments.all())
elif scope == "processing_department":
# Заявки, направленные в отделы, где пользователь участвует.
queryset = queryset.filter(
processing_department_id__in=participant_department_ids,
)
elif scope == "my_work":
# Заявки, которые я взял в работу (где я исполнитель)
queryset = queryset.filter(executor=user)
@ -174,7 +184,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
queryset = queryset.filter(
Q(
status=ProcurementStatus.WAITING,
processing_department__in=user.departments.all(),
processing_department_id__in=participant_department_ids,
)
| Q(
status=ProcurementStatus.APPROVED,
@ -201,7 +211,9 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
queryset = queryset.filter(
Q(requestor=user)
| Q(department__in=user.departments.all())
| Q(processing_department__in=user.departments.all())
| Q(
processing_department_id__in=participant_department_ids
)
| Q(approvals__approver=user)
| Q(
status=ProcurementStatus.APPROVED,
@ -453,8 +465,8 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
# Проверяем права
self.check_object_permissions(request, procurement_request)
# Находим согласование текущего пользователя
approval = ProcurementApprovalResolver.get_available_approval(
# Находим или создаем доступный этап текущего пользователя.
approval = ProcurementApprovalResolver.get_or_create_available_approval(
request.user,
procurement_request,
)
@ -510,8 +522,8 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
# Проверяем права
self.check_object_permissions(request, procurement_request)
# Находим согласование текущего пользователя
approval = ProcurementApprovalResolver.get_available_approval(
# Находим или создаем доступный этап текущего пользователя.
approval = ProcurementApprovalResolver.get_or_create_available_approval(
request.user,
procurement_request,
)
@ -522,6 +534,16 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
status=status.HTTP_403_FORBIDDEN,
)
# Вышестоящий согласующий закрывает предыдущие 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", "")
@ -554,13 +576,10 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
def pending_approvals(self, request):
"""Получить заявки, ожидающие согласования текущим польз."""
# Находим заявки где есть pending approval для текущего юзера
base_queryset = (
self.filter_queryset(self.get_queryset())
.filter(
approvals__approver=request.user,
approvals__status=ApprovalStatus.PENDING,
base_queryset = self.filter_queryset(
self.queryset.filter(
status=ProcurementStatus.PENDING,
)
.distinct()
)
available_ids = [
request_obj.id
@ -611,10 +630,15 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
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(
status=ProcurementStatus.WAITING,
processing_department__in=request.user.departments.all(),
processing_department_id__in=participant_department_ids,
)
| Q(
status=ProcurementStatus.APPROVED,
@ -728,18 +752,21 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
items_to_update = list(
procurement_request.items.exclude(
execution_status=ProcurementItemExecutionStatus.RECEIVED,
items_to_update = [
item
for item in procurement_request.items.all()
if (
item.execution_status
!= ProcurementItemExecutionStatus.RECEIVED
or item.ordered_quantity != item.quantity
or item.received_quantity != item.quantity
)
)
]
for item in items_to_update:
item.execution_status = ProcurementItemExecutionStatus.RECEIVED
if item.ordered_quantity is None:
item.ordered_quantity = item.quantity
if item.received_quantity is None:
item.received_quantity = item.quantity
item.ordered_quantity = item.quantity
item.received_quantity = item.quantity
ProcurementItem.objects.bulk_update(
items_to_update,
["execution_status", "ordered_quantity", "received_quantity"],
@ -856,15 +883,9 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
)
def _is_processing_department_member(self, user, procurement_request):
if not procurement_request.processing_department_id:
return False
return (
EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=procurement_request.processing_department_id,
is_active=True,
).exists()
or procurement_request.processing_department.head_id == user.id
return ProcurementApprovalResolver.user_is_processing_department_member(
user,
procurement_request,
)
def _can_process_request_items(self, user, procurement_request):

View File

@ -376,11 +376,13 @@ def notify_request_completed(request):
request: Объект ProcurementRequest
"""
notification_title, description = MessageTemplates.completed(request.title)
actor = _request_actor(request) or request.executor
notify_requestor(
request,
NotificationVerbs.COMPLETED,
notification_title,
description,
actor=actor,
extra_data={
"old_status": getattr(request, "_original_status", None),
"new_status": request.status,

View File

@ -95,19 +95,75 @@ class ProcurementApprovalResolver:
"""
@classmethod
def user_is_processing_department_member(cls, user, procurement_request):
if not procurement_request.processing_department_id:
def user_is_department_participant(cls, user, department_id):
"""Проверяет рабочее участие пользователя в отделе.
Участником считается физический сотрудник отдела, руководитель отдела
или сотрудник с активной ролью в этом отделе.
"""
if not department_id or not user or not user.is_authenticated:
return False
from employees.models import EmployeeDepartment
from employees.models import (
Department,
EmployeeDepartment,
RoleAssignment,
)
return (
EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=procurement_request.processing_department_id,
department_id=department_id,
is_active=True,
).exists()
or procurement_request.processing_department.head_id == user.id
or Department.objects.filter(
id=department_id,
head_id=user.id,
).exists()
or RoleAssignment.objects.filter(
employee_id=user.id,
role__department_id=department_id,
is_active=True,
).exists()
)
@classmethod
def get_user_department_participant_ids(cls, user):
"""Возвращает отделы, где пользователь участник или role-only участник."""
if not user or not user.is_authenticated:
return []
from employees.models import (
Department,
EmployeeDepartment,
RoleAssignment,
)
department_ids = set(
EmployeeDepartment.objects.filter(
employee_id=user.id,
is_active=True,
).values_list("department_id", flat=True)
)
department_ids.update(
Department.objects.filter(head_id=user.id).values_list(
"id",
flat=True,
)
)
department_ids.update(
RoleAssignment.objects.filter(
employee_id=user.id,
is_active=True,
).values_list("role__department_id", flat=True)
)
return sorted(department_ids)
@classmethod
def user_is_processing_department_member(cls, user, procurement_request):
return cls.user_is_department_participant(
user,
procurement_request.processing_department_id,
)
@classmethod
@ -120,6 +176,9 @@ class ProcurementApprovalResolver:
or procurement_request.approvals.filter(
status=ApprovalStatus.PENDING,
).exists()
or procurement_request.approvals.filter(
status=ApprovalStatus.APPROVED,
).exists()
):
return False
@ -148,6 +207,64 @@ class ProcurementApprovalResolver:
and procurement_request.is_editable
)
@classmethod
def user_can_start_work(cls, user, procurement_request) -> bool:
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 (
user.is_superuser
or user.is_staff
or user.has_perm("procurement.change_procurementrequest")
or user.has_perm("procurement.execute_procurement")
):
return True
if procurement_request.processing_department_id:
return cls.user_is_processing_department_member(
user,
procurement_request,
)
return True
@classmethod
def user_can_process_items(cls, user, procurement_request) -> bool:
if not user or not user.is_authenticated:
return False
if procurement_request.status not in {
ProcurementStatus.WAITING,
ProcurementStatus.IN_PROGRESS,
}:
return False
if procurement_request.executor_id == user.id:
return True
if (
user.is_superuser
or user.is_staff
or user.has_perm("procurement.change_procurementrequest")
or user.has_perm("procurement.execute_procurement")
):
return True
if procurement_request.processing_department_id:
return cls.user_is_processing_department_member(
user,
procurement_request,
)
return False
@classmethod
def resolve_approval_step(cls, procurement_request, route):
from .models import ApprovalRoute
@ -241,6 +358,9 @@ class ProcurementApprovalResolver:
if not user or not user.is_authenticated:
return None
if procurement_request.status != ProcurementStatus.PENDING:
return None
pending = procurement_request.approvals.filter(
status=ApprovalStatus.PENDING,
).order_by("priority", "created_at", "id")
@ -253,11 +373,80 @@ class ProcurementApprovalResolver:
priority__gte=first_pending.priority,
).first()
@classmethod
def get_available_higher_route(cls, user, procurement_request):
if not user or not user.is_authenticated:
return None
if procurement_request.status != ProcurementStatus.PENDING:
return None
first_pending = (
procurement_request.approvals.filter(
status=ApprovalStatus.PENDING,
)
.order_by("priority", "created_at", "id")
.first()
)
if first_pending is None:
return None
from .models import ApprovalRoute
existing_priorities = set(
procurement_request.approvals.values_list("priority", flat=True)
)
routes = (
ApprovalRoute.objects.select_related("employee")
.filter(priority__gt=first_pending.priority)
.order_by("priority", "id")
)
for route in routes:
if route.priority in existing_priorities:
continue
step = cls.resolve_approval_step(procurement_request, route)
if step and step[1].id == user.id:
return step
return None
@classmethod
def get_or_create_available_approval(cls, user, procurement_request):
approval = cls.get_available_approval(user, procurement_request)
if approval is not None:
return approval
step = cls.get_available_higher_route(user, procurement_request)
if step is None:
return None
from .models import Approval
priority, approver, step_name = step
approval, _ = Approval.objects.get_or_create(
request=procurement_request,
priority=priority,
defaults={
"approver": approver,
"step_name": step_name,
"status": ApprovalStatus.PENDING,
},
)
if (
approval.approver_id != user.id
or approval.status != ApprovalStatus.PENDING
):
return None
return approval
@classmethod
def user_can_approve(cls, user, procurement_request) -> bool:
if not user or not user.is_authenticated:
return False
return cls.get_available_approval(user, procurement_request) is not None
return (
cls.get_available_approval(user, procurement_request) is not None
or cls.get_available_higher_route(user, procurement_request)
is not None
)
class EquipmentService:

View File

@ -739,6 +739,29 @@ class TestProcessingDepartmentWorkflow:
)
return employee
@pytest.fixture
def supply_role_user(self, db, supply_department):
employee = Employee.objects.create_user(
email="supply-role@example.com",
password="testpass123",
phone_number="+79998888892",
first_name="Ролевой",
last_name="Исполнитель",
is_active=True,
email_verified=True,
send_activation_email=False,
)
role = DepartmentRole.objects.create(
department=supply_department,
name="Закупщик",
)
RoleAssignment.objects.create(
employee=employee,
role=role,
is_active=True,
)
return employee
@pytest.fixture
def outsider(self, db):
return Employee.objects.create_user(
@ -935,6 +958,82 @@ class TestProcessingDepartmentWorkflow:
ids = [item['id'] for item in response.data['results']]
assert processing_request.id in ids
def test_role_assignment_user_sees_processing_department_request(
self, api_client, supply_role_user, processing_request,
procurement_item_factory
):
procurement_item_factory(request=processing_request)
api_client.force_authenticate(user=supply_role_user)
available_url = reverse(
'api:v1:procurement:procurementrequest-available'
)
available_response = api_client.get(available_url)
assert available_response.status_code == status.HTTP_200_OK
available_ids = [
item['id'] for item in available_response.data['results']
]
assert processing_request.id in available_ids
list_url = reverse('api:v1:procurement:procurementrequest-list')
list_response = api_client.get(list_url)
assert list_response.status_code == status.HTTP_200_OK
list_ids = [item['id'] for item in list_response.data['results']]
assert processing_request.id in list_ids
def test_processing_department_scope_uses_executor_department_participants(
self, api_client, supply_role_user, user, processing_request,
procurement_item_factory
):
procurement_item_factory(request=processing_request)
list_url = reverse('api:v1:procurement:procurementrequest-list')
api_client.force_authenticate(user=supply_role_user)
processing_response = api_client.get(
f"{list_url}?scope=processing_department"
)
assert processing_response.status_code == status.HTTP_200_OK
processing_ids = [
item['id'] for item in processing_response.data['results']
]
assert processing_request.id in processing_ids
api_client.force_authenticate(user=user)
department_response = api_client.get(f"{list_url}?scope=department")
assert department_response.status_code == status.HTTP_200_OK
department_ids = [
item['id'] for item in department_response.data['results']
]
assert processing_request.id in department_ids
def test_processing_permission_flags_follow_executor_department(
self, api_client, user, supply_user, supply_role_user,
processing_request, procurement_item_factory
):
procurement_item_factory(request=processing_request)
detail_url = reverse(
'api:v1:procurement:procurementrequest-detail',
kwargs={'pk': processing_request.id},
)
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
assert author_response.data["can_current_user_process_items"] is False
api_client.force_authenticate(user=supply_user)
supply_response = api_client.get(detail_url)
assert supply_response.status_code == status.HTTP_200_OK
assert supply_response.data["can_current_user_start_work"] is True
assert supply_response.data["can_current_user_process_items"] is True
api_client.force_authenticate(user=supply_role_user)
role_response = api_client.get(detail_url)
assert role_response.status_code == status.HTTP_200_OK
assert role_response.data["can_current_user_start_work"] is True
assert role_response.data["can_current_user_process_items"] is True
def test_outsider_cannot_start_processing_department_request(
self, api_client, outsider, processing_request, procurement_item_factory
):
@ -982,6 +1081,63 @@ class TestProcessingDepartmentWorkflow:
)
assert edit_response.status_code == status.HTTP_403_FORBIDDEN
def test_processing_department_role_user_can_start_work(
self, api_client, supply_role_user, processing_request,
procurement_item_factory
):
procurement_item_factory(request=processing_request)
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_processing_department_role_user_can_manage_items(
self, api_client, supply_role_user, processing_request,
procurement_item_factory
):
item = procurement_item_factory(
request=processing_request,
quantity=2,
)
api_client.force_authenticate(user=supply_role_user)
item_url = reverse(
'api:v1:procurement:procurementitem-detail',
kwargs={'pk': item.id},
)
update_response = api_client.patch(
item_url,
{
'execution_status': ProcurementItemExecutionStatus.ORDERED,
'ordered_quantity': 1,
},
format='json',
)
assert update_response.status_code == status.HTTP_200_OK
item.refresh_from_db()
assert item.execution_status == ProcurementItemExecutionStatus.ORDERED
assert item.ordered_quantity == 1
mark_all_url = reverse(
'api:v1:procurement:procurementrequest-mark-all-received',
kwargs={'pk': processing_request.id},
)
mark_all_response = api_client.post(mark_all_url)
assert mark_all_response.status_code == status.HTTP_200_OK
item.refresh_from_db()
assert item.execution_status == ProcurementItemExecutionStatus.RECEIVED
assert item.received_quantity == item.quantity
def test_author_can_edit_waiting_request_with_items_before_work(
self, api_client, user, department, supply_department,
processing_request, procurement_item_factory
@ -1085,6 +1241,24 @@ class TestProcessingDepartmentWorkflow:
assert processing_request.approvals.count() == 1
assert response.data["can_current_user_submit_for_approval"] is False
def test_processing_department_role_user_can_submit_for_approval(
self, api_client, supply_role_user, processing_request,
procurement_item_factory, department_head_approval_route
):
procurement_item_factory(request=processing_request)
api_client.force_authenticate(user=supply_role_user)
url = reverse(
'api:v1:procurement:procurementrequest-submit',
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.status == ProcurementStatus.PENDING
assert processing_request.approvals.count() == 1
def test_processing_department_requestor_cannot_submit_for_approval(
self, api_client, user, processing_request,
procurement_item_factory, department_head_approval_route
@ -1127,6 +1301,39 @@ class TestProcessingDepartmentWorkflow:
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_approved_processing_request_cannot_be_submitted_again(
self, api_client, supply_user, processing_request,
procurement_item_factory, department_head_approval_route
):
"""После пройденного согласования кнопку submit больше не показываем."""
procurement_item_factory(request=processing_request)
processing_request.executor = supply_user
processing_request.status = ProcurementStatus.IN_PROGRESS
processing_request.save(update_fields=["executor", "status", "updated_at"])
Approval.objects.create(
request=processing_request,
approver=processing_request.department.head,
priority=HEAD_PRIORITY,
status=ApprovalStatus.APPROVED,
)
api_client.force_authenticate(user=supply_user)
detail_url = reverse(
'api:v1:procurement:procurementrequest-detail',
kwargs={'pk': processing_request.id},
)
submit_url = reverse(
'api:v1:procurement:procurementrequest-submit',
kwargs={'pk': processing_request.id},
)
detail_response = api_client.get(detail_url)
assert detail_response.status_code == status.HTTP_200_OK
assert detail_response.data["can_current_user_submit_for_approval"] is False
submit_response = api_client.post(submit_url)
assert submit_response.status_code == status.HTTP_403_FORBIDDEN
def test_final_approve_returns_processing_request_to_waiting_without_executor(
self, api_client, supply_user, department_head, processing_request,
procurement_item_factory, department_head_approval_route
@ -1306,6 +1513,47 @@ class TestProcessingDepartmentWorkflow:
assert item.ordered_quantity == item.quantity
assert item.received_quantity == item.quantity
def test_item_auto_completion_notification_uses_executor_actor(
self, api_client, supply_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,
)
item = procurement_item_factory(request=processing_request)
processing_request.executor = supply_user
processing_request.status = ProcurementStatus.IN_PROGRESS
processing_request.save()
sent.clear()
api_client.force_authenticate(user=supply_user)
item_url = reverse(
'api:v1:procurement:procurementitem-detail',
kwargs={'pk': item.id},
)
response = api_client.patch(
item_url,
{'received_quantity': item.quantity},
format='json',
)
assert response.status_code == status.HTTP_200_OK
completed_notifications = [
item for item in sent
if item["verb"] == "procurement_completed"
]
assert len(completed_notifications) == 1
assert completed_notifications[0]["sender"] == supply_user
assert completed_notifications[0]["data"]["actor_id"] == supply_user.id
def test_item_update_notifies_requestor(
self, api_client, supply_user, processing_request,
procurement_item_factory, monkeypatch
@ -1691,6 +1939,39 @@ class TestProcessingDepartmentWorkflow:
assert result["total_ordered_quantity"] == 6
assert result["total_received_quantity"] == 3
def test_list_expected_date_falls_back_to_latest_when_all_received(
self, api_client, supply_user, processing_request,
procurement_item_factory
):
procurement_item_factory(
request=processing_request,
quantity=1,
execution_status=ProcurementItemExecutionStatus.RECEIVED,
ordered_quantity=1,
received_quantity=1,
expected_delivery_date="2026-05-28",
)
procurement_item_factory(
request=processing_request,
name="Вторая полученная позиция",
quantity=1,
execution_status=ProcurementItemExecutionStatus.RECEIVED,
ordered_quantity=1,
received_quantity=1,
expected_delivery_date="2026-05-30",
)
api_client.force_authenticate(user=supply_user)
url = reverse('api:v1:procurement:procurementrequest-list')
response = api_client.get(url)
assert response.status_code == status.HTTP_200_OK
result = next(
item for item in response.data["results"]
if item["id"] == processing_request.id
)
assert result["next_expected_delivery_date"] == "2026-05-30"
def test_requestor_item_update_does_not_notify_self(
self, api_client, user, processing_request,
procurement_item_factory, monkeypatch
@ -1847,7 +2128,12 @@ class TestProcessingDepartmentWorkflow:
fake_notify_send,
)
item_1 = procurement_item_factory(request=processing_request)
item_1 = procurement_item_factory(
request=processing_request,
quantity=2,
execution_status=ProcurementItemExecutionStatus.ORDERED,
ordered_quantity=1,
)
item_2 = procurement_item_factory(
request=processing_request,
name="Лампы",
@ -2580,6 +2866,83 @@ class TestProcurementRequestWorkflow:
assert director_approval.comment == "Финальное согласование"
assert procurement_request.status == ProcurementStatus.APPROVED
def test_higher_approver_reject_closes_previous_stages(
self, api_client, user, department_head, procurement_request,
procurement_item, budget
):
"""Вышестоящее отклонение закрывает предыдущие pending-этапы."""
procurement_request.status = ProcurementStatus.PENDING
procurement_request.save()
head_approval = Approval.objects.create(
request=procurement_request,
approver=department_head,
priority=HEAD_PRIORITY,
status=ApprovalStatus.PENDING,
)
finance_approval = Approval.objects.create(
request=procurement_request,
approver=user,
priority=FINANCE_PRIORITY,
status=ApprovalStatus.PENDING,
)
api_client.force_authenticate(user=user)
url = reverse(
'api:v1:procurement:procurementrequest-reject',
kwargs={'pk': procurement_request.id}
)
response = api_client.post(url, {'comment': 'Отклонено сверху'})
assert response.status_code == status.HTTP_200_OK
head_approval.refresh_from_db()
finance_approval.refresh_from_db()
procurement_request.refresh_from_db()
assert head_approval.status == ApprovalStatus.REJECTED
assert "вышестоящим" in head_approval.comment
assert finance_approval.status == ApprovalStatus.REJECTED
assert finance_approval.comment == "Отклонено сверху"
assert procurement_request.status == ProcurementStatus.REJECTED
def test_pending_approval_does_not_allow_approve_when_request_not_pending(
self, api_client, department_head, procurement_request, procurement_item
):
"""Pending-запись не дает approve, если сама заявка уже не pending."""
procurement_request.status = ProcurementStatus.APPROVED
procurement_request.save()
Approval.objects.create(
request=procurement_request,
approver=department_head,
priority=HEAD_PRIORITY,
status=ApprovalStatus.PENDING,
)
list_url = reverse('api:v1:procurement:procurementrequest-list')
detail_url = reverse(
'api:v1:procurement:procurementrequest-detail',
kwargs={'pk': procurement_request.id}
)
approve_url = reverse(
'api:v1:procurement:procurementrequest-approve',
kwargs={'pk': procurement_request.id}
)
api_client.force_authenticate(user=department_head)
list_response = api_client.get(list_url)
assert list_response.status_code == status.HTTP_200_OK
assert list_response.data['results'][0]['can_current_user_approve'] is False
detail_response = api_client.get(detail_url)
assert detail_response.status_code == status.HTTP_200_OK
assert detail_response.data['can_current_user_approve'] is False
approve_response = api_client.post(approve_url, {'comment': 'Поздно'})
assert approve_response.status_code == status.HTTP_403_FORBIDDEN
def test_staff_without_pending_stage_cannot_approve(
self, api_client, staff_user, department_head, procurement_request, procurement_item
):
@ -2800,6 +3163,93 @@ class TestProcurementRequestWorkflow:
assert finance_response.status_code == status.HTTP_200_OK
assert finance_response.data['results'][0]['can_current_user_approve'] is True
def test_pending_approvals_includes_higher_approver(
self, api_client, user, department_head, procurement_request, procurement_item
):
"""Вкладка согласования должна показывать заявку вышестоящему этапу."""
procurement_request.status = ProcurementStatus.PENDING
procurement_request.save()
Approval.objects.create(
request=procurement_request,
approver=department_head,
priority=HEAD_PRIORITY,
status=ApprovalStatus.PENDING,
)
Approval.objects.create(
request=procurement_request,
approver=user,
priority=FINANCE_PRIORITY,
status=ApprovalStatus.PENDING,
)
url = reverse('api:v1:procurement:procurementrequest-pending-approvals')
api_client.force_authenticate(user=user)
response = api_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert [item['id'] for item in response.data['results']] == [procurement_request.id]
assert response.data['results'][0]['can_current_user_approve'] is True
def test_higher_route_can_approve_even_if_not_required_by_amount(
self, api_client, user, department
):
"""Вышестоящий маршрут может согласовать заявку ниже своего порога."""
procurement_request = ProcurementRequest.objects.create(
title="Низкая сумма для обхода сверху",
description="Проверка согласования верхним маршрутом",
department=department,
requestor=user,
status=ProcurementStatus.DRAFT,
urgency=UrgencyLevel.MEDIUM,
)
ProcurementItem.objects.create(
request=procurement_request,
name="Дешевая позиция",
quantity=1,
unit="шт",
estimated_unit_price=Decimal("12.00"),
)
api_client.force_authenticate(user=user)
submit_url = reverse(
'api:v1:procurement:procurementrequest-submit',
kwargs={'pk': procurement_request.id}
)
submit_response = api_client.post(submit_url)
assert submit_response.status_code == status.HTTP_200_OK
procurement_request.refresh_from_db()
assert list(
procurement_request.approvals.order_by('priority').values_list('priority', flat=True)
) == [HEAD_PRIORITY]
pending_url = reverse('api:v1:procurement:procurementrequest-pending-approvals')
pending_response = api_client.get(pending_url)
assert pending_response.status_code == status.HTTP_200_OK
assert [item['id'] for item in pending_response.data['results']] == [procurement_request.id]
assert pending_response.data['results'][0]['can_current_user_approve'] is True
approve_url = reverse(
'api:v1:procurement:procurementrequest-approve',
kwargs={'pk': procurement_request.id}
)
approve_response = api_client.post(approve_url, {'comment': 'Согласовано выше порога'})
assert approve_response.status_code == status.HTTP_200_OK
procurement_request.refresh_from_db()
approvals = procurement_request.approvals.order_by('priority')
assert list(approvals.values_list('priority', flat=True)) == [
HEAD_PRIORITY,
FINANCE_PRIORITY,
]
assert list(approvals.values_list('status', flat=True)) == [
ApprovalStatus.APPROVED,
ApprovalStatus.APPROVED,
]
assert procurement_request.status == ProcurementStatus.APPROVED
def test_detail_marks_next_stage_approver_after_previous_stage_complete(
self, api_client, user, department_head, procurement_request, procurement_item
):

View File

@ -74,11 +74,12 @@ const orderingOptions = [
{ value: "-urgency", label: "По срочности ↓" },
];
type ScopeTab = "all" | "mine" | "department" | "pending_approvals" | "my_work" | "available";
type ScopeTab = "all" | "mine" | "department" | "processing_department" | "pending_approvals" | "my_work" | "available";
const scopeTabs: { value: ScopeTab; label: string }[] = [
{ value: "all", label: "Все" },
{ value: "mine", label: "Мои" },
{ value: "department", label: "Мой отдел" },
{ value: "department", label: "Отдел-заказчик" },
{ value: "processing_department", label: "Отдел-исполнитель" },
{ value: "pending_approvals", label: "На согласование" },
{ value: "my_work", label: "В работе у меня" },
{ value: "available", label: "Доступные" },
@ -156,7 +157,7 @@ function ProcurementRequestActionButtons({
const status = String(request.status || "").toLowerCase();
const isDraft = status === "draft";
const isPending = status === "pending";
const canStartWork = (status === "approved" || status === "waiting") && !request.executor;
const canStartWork = Boolean(request.can_current_user_start_work);
const isInProgress = status === "in_progress";
const canApproveThis = Boolean(request.can_current_user_approve);
const canSubmitForApproval = Boolean(request.can_current_user_submit_for_approval);
@ -1051,7 +1052,7 @@ export default function ProcurementPage() {
currentUserId={user?.id}
request={resolvedDetail}
displayUserName={displayUserName}
canProcessItems={["waiting", "in_progress"].includes(st)}
canProcessItems={Boolean(resolvedDetail.can_current_user_process_items)}
busyKey={busyKey}
canDeleteAnyComment={Boolean(user?.auth?.is_staff || user?.auth?.is_superuser)}
onUpdateItem={handleUpdateItem}
@ -1066,13 +1067,12 @@ export default function ProcurementPage() {
onDeleteItemComment={handleDeleteItemComment}
footer={(
<ProcurementRequestActionButtons
request={req}
request={resolvedDetail}
busyKey={busyKey}
canManage={canManage}
isAuthor={isAuthor}
isExecutor={isExecutor}
isFinal={isFinal}
showApprovalActions={false}
showSecondaryActions={false}
showLabels
onSubmit={handleSubmit}
@ -1179,7 +1179,7 @@ export default function ProcurementPage() {
currentUserId={user?.id}
request={selectedRequest}
displayUserName={displayUserName}
canProcessItems={["waiting", "in_progress"].includes(String(selectedRequest.status || "").toLowerCase())}
canProcessItems={Boolean(selectedRequest.can_current_user_process_items)}
busyKey={busyKey}
canDeleteAnyComment={Boolean(user?.auth?.is_staff || user?.auth?.is_superuser)}
onUpdateItem={handleUpdateItem}

View File

@ -59,7 +59,7 @@ const emptyForm: FormState = {
items: [{ ...emptyItem }],
};
type ScopeTab = "all" | "mine" | "department" | "pending_approvals" | "my_work" | "available";
type ScopeTab = "all" | "mine" | "department" | "processing_department" | "pending_approvals" | "my_work" | "available";
type ProcurementSection = "requests" | "stats" | "suppliers";
type PaginatedLike<T> = {
@ -149,6 +149,7 @@ export function useProcurementPage(user: User | null) {
all: 0,
mine: 0,
department: 0,
processing_department: 0,
pending_approvals: 0,
my_work: 0,
available: 0,
@ -225,6 +226,7 @@ export function useProcurementPage(user: User | null) {
const params: Record<string, string | number> = { page };
if (scope === "mine") params.scope = "mine";
else if (scope === "department") params.scope = "department";
else if (scope === "processing_department") params.scope = "processing_department";
else if (scope === "my_work") params.scope = "my_work";
else if (scope === "available") params.scope = "available";
if (statusFilter.length === 1) params.status = statusFilter[0];
@ -243,6 +245,7 @@ export function useProcurementPage(user: User | null) {
const params: Record<string, string | number> = { page: 1 };
if (targetScope === "mine") params.scope = "mine";
else if (targetScope === "department") params.scope = "department";
else if (targetScope === "processing_department") params.scope = "processing_department";
else if (targetScope === "my_work") params.scope = "my_work";
else if (targetScope === "available") params.scope = "available";
if (statusFilter.length === 1) params.status = statusFilter[0];
@ -282,33 +285,44 @@ export function useProcurementPage(user: User | null) {
void loadPage1();
}, [loadPage1]);
const loadScopeCounts = useCallback(async () => {
try {
const scopes: ScopeTab[] = [
"all",
"mine",
"department",
"processing_department",
"pending_approvals",
"my_work",
"available",
];
const results = await Promise.all(
scopes.map(async (scopeKey) => {
const response: unknown = scopeKey === "pending_approvals"
? await apiClient.getPendingApprovals(buildScopeCountParams(scopeKey))
: await apiClient.getProcurementRequests(buildScopeCountParams(scopeKey));
return [scopeKey, getPaginatedCount(response)] as const;
}),
);
setScopeCounts(Object.fromEntries(results) as Record<ScopeTab, number>);
} catch (countsError) {
console.error("Load procurement scope counts error:", countsError);
}
}, [buildScopeCountParams]);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const scopes: ScopeTab[] = ["all", "mine", "department", "pending_approvals", "my_work", "available"];
const results = await Promise.all(
scopes.map(async (scopeKey) => {
const response: unknown = scopeKey === "pending_approvals"
? await apiClient.getPendingApprovals(buildScopeCountParams(scopeKey))
: await apiClient.getProcurementRequests(buildScopeCountParams(scopeKey));
return [scopeKey, getPaginatedCount(response)] as const;
}),
);
if (!cancelled) {
setScopeCounts(Object.fromEntries(results) as Record<ScopeTab, number>);
}
} catch (countsError) {
console.error("Load procurement scope counts error:", countsError);
}
if (cancelled) return;
await loadScopeCounts();
})();
return () => {
cancelled = true;
};
}, [buildScopeCountParams]);
}, [loadScopeCounts]);
useEffect(() => {
let cancelled = false;
@ -465,13 +479,33 @@ export function useProcurementPage(user: User | null) {
const refreshOne = useCallback(async (id: number) => {
try {
const updated = await apiClient.getProcurementRequest(id);
detailsCacheRef.current = {
...detailsCacheRef.current,
[id]: updated,
};
setRequests((previous) => previous.map((request) => (request.id === id ? updated : request)));
setDetailsCache((previous) => ({ ...previous, [id]: updated }));
setDetailsCache((previous) => {
const next = { ...previous, [id]: updated };
detailsCacheRef.current = next;
return next;
});
} catch {
await loadPage1();
}
}, [loadPage1]);
const refreshCurrentView = useCallback(async () => {
await Promise.all([
loadPage1(),
loadScopeCounts(),
]);
}, [loadPage1, loadScopeCounts]);
const refreshAfterMutation = useCallback(async (id: number) => {
await refreshOne(id);
await refreshCurrentView();
}, [refreshCurrentView, refreshOne]);
const ensureRequestDetail = useCallback(async (id: number) => {
const cached = detailsCacheRef.current[id];
if (cached) {
@ -559,13 +593,13 @@ export function useProcurementPage(user: User | null) {
}
resetForm();
await loadPage1();
await refreshCurrentView();
} catch (saveError) {
setActionError(getReadableError(saveError, "Ошибка сохранения"));
} finally {
setBusyKey(null);
}
}, [editingId, form, loadPage1, modalMode, resetForm]);
}, [editingId, form, modalMode, refreshCurrentView, resetForm]);
const handleSave = useCallback(() => saveRequest(), [saveRequest]);
@ -575,7 +609,7 @@ export function useProcurementPage(user: User | null) {
setActionError(null);
await action();
setActionSuccess(successMessage);
await refreshOne(id);
await refreshAfterMutation(id);
return true;
} catch (actionErrorValue) {
setActionError(getReadableError(actionErrorValue, "Ошибка"));
@ -583,7 +617,7 @@ export function useProcurementPage(user: User | null) {
} finally {
setBusyKey(null);
}
}, [refreshOne]);
}, [refreshAfterMutation]);
const handleLoadMore = useCallback(async () => {
if (!nextPage || loadingMore) return;
@ -858,7 +892,7 @@ export function useProcurementPage(user: User | null) {
}
await apiClient.updateProcurementItem(itemId, patch);
setActionSuccess("Позиция обновлена.");
await refreshOne(requestId);
await refreshAfterMutation(requestId);
return true;
} catch (updateError) {
setActionError(getReadableError(updateError, "Не удалось обновить позицию"));
@ -866,7 +900,7 @@ export function useProcurementPage(user: User | null) {
} finally {
setBusyKey(null);
}
}, [refreshOne]);
}, [refreshAfterMutation]);
const handleReportItemIssue = useCallback(async (requestId: number, itemId: number, text = "") => {
try {
@ -874,7 +908,7 @@ export function useProcurementPage(user: User | null) {
setActionError(null);
await apiClient.reportProcurementItemIssue(itemId, text);
setActionSuccess("Позиция отмечена как проблемная.");
await refreshOne(requestId);
await refreshAfterMutation(requestId);
return true;
} catch (issueError) {
setActionError(getReadableError(issueError, "Не удалось отметить проблему"));
@ -882,7 +916,7 @@ export function useProcurementPage(user: User | null) {
} finally {
setBusyKey(null);
}
}, [refreshOne]);
}, [refreshAfterMutation]);
const handleCancel = useCallback((id: number, reason = "") => {
return doAction(`cancel-${id}`, () => apiClient.cancelProcurementRequest(id, reason), id, "Заявка отменена.");
@ -901,6 +935,7 @@ export function useProcurementPage(user: User | null) {
return next;
});
setActionSuccess("Заявка удалена.");
await refreshCurrentView();
return true;
} catch (deleteError) {
setActionError(getReadableError(deleteError, "Не удалось удалить"));
@ -908,7 +943,7 @@ export function useProcurementPage(user: User | null) {
} finally {
setBusyKey(null);
}
}, []);
}, [refreshCurrentView]);
const addItemRow = useCallback(() => {
setForm((previous) => ({ ...previous, items: [{ ...emptyItem }, ...previous.items] }));

View File

@ -562,6 +562,8 @@ export interface ProcurementRequest {
comments_count?: number;
can_current_user_approve?: boolean;
can_current_user_submit_for_approval?: boolean;
can_current_user_start_work?: boolean;
can_current_user_process_items?: boolean;
created_at: string;
updated_at: string;
submitted_at?: string | null;