procurement: fix department workflow UI bugs
This commit is contained in:
parent
ddeef4653c
commit
b1a5ac34ef
@ -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:
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
):
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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] }));
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user