procurement: route requests through processing department
This commit is contained in:
parent
da986c0e29
commit
4a05618ed7
@ -348,6 +348,7 @@ 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()
|
||||
|
||||
@extend_schema_field(serializers.BooleanField())
|
||||
def get_can_current_user_approve(self, obj):
|
||||
@ -355,6 +356,15 @@ class ProcurementRequestListSerializer(
|
||||
user = getattr(request, "user", None)
|
||||
return ProcurementApprovalResolver.user_can_approve(user, obj)
|
||||
|
||||
@extend_schema_field(serializers.BooleanField())
|
||||
def get_can_current_user_submit_for_approval(self, obj):
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
return ProcurementApprovalResolver.user_can_submit_for_approval(
|
||||
user,
|
||||
obj,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ProcurementRequest
|
||||
fields = [
|
||||
@ -386,6 +396,7 @@ class ProcurementRequestListSerializer(
|
||||
"total_received_quantity",
|
||||
"comments_count",
|
||||
"can_current_user_approve",
|
||||
"can_current_user_submit_for_approval",
|
||||
"created_at",
|
||||
"submitted_at",
|
||||
"started_at",
|
||||
@ -415,6 +426,7 @@ class ProcurementRequestListSerializer(
|
||||
"total_received_quantity",
|
||||
"comments_count",
|
||||
"can_current_user_approve",
|
||||
"can_current_user_submit_for_approval",
|
||||
]
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
@ -470,6 +482,7 @@ class ProcurementRequestDetailSerializer(
|
||||
is_editable = serializers.BooleanField(read_only=True)
|
||||
comments_count = serializers.IntegerField(read_only=True, default=0)
|
||||
can_current_user_approve = serializers.SerializerMethodField()
|
||||
can_current_user_submit_for_approval = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.BooleanField())
|
||||
def get_can_current_user_approve(self, obj):
|
||||
@ -477,6 +490,15 @@ class ProcurementRequestDetailSerializer(
|
||||
user = getattr(request, "user", None)
|
||||
return ProcurementApprovalResolver.user_can_approve(user, obj)
|
||||
|
||||
@extend_schema_field(serializers.BooleanField())
|
||||
def get_can_current_user_submit_for_approval(self, obj):
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
return ProcurementApprovalResolver.user_can_submit_for_approval(
|
||||
user,
|
||||
obj,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ProcurementRequest
|
||||
fields = [
|
||||
@ -514,6 +536,7 @@ class ProcurementRequestDetailSerializer(
|
||||
"is_editable",
|
||||
"comments_count",
|
||||
"can_current_user_approve",
|
||||
"can_current_user_submit_for_approval",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"submitted_at",
|
||||
@ -543,6 +566,7 @@ class ProcurementRequestDetailSerializer(
|
||||
"fulfillment_status_display",
|
||||
"comments_count",
|
||||
"can_current_user_approve",
|
||||
"can_current_user_submit_for_approval",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -105,6 +105,8 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
"""Выбрать права доступа в зависимости от действия."""
|
||||
if self.action in ["approve", "reject"]:
|
||||
permission_classes = [CanApproveProcurementRequest]
|
||||
elif self.action == "submit":
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
else:
|
||||
# Для create, update, delete и остальных действий
|
||||
permission_classes = [CanManageProcurementRequest]
|
||||
@ -331,25 +333,30 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
|
||||
procurement_request = self.get_object()
|
||||
|
||||
if procurement_request.processing_department_id:
|
||||
if (
|
||||
procurement_request.status == ProcurementStatus.PENDING
|
||||
or procurement_request.approvals.filter(
|
||||
status=ApprovalStatus.PENDING,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": (
|
||||
"Заявка уже направлена в отдел-исполнитель "
|
||||
"и не требует согласования"
|
||||
)
|
||||
},
|
||||
{"error": "Заявка уже находится на согласовании"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Проверки
|
||||
if procurement_request.requestor != request.user:
|
||||
if not ProcurementApprovalResolver.user_can_submit_for_approval(
|
||||
request.user,
|
||||
procurement_request,
|
||||
):
|
||||
return Response(
|
||||
{"error": "Вы не можете отправить чужую заявку"},
|
||||
{"error": "Вы не можете отправить эту заявку на согласование"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
if not procurement_request.is_editable:
|
||||
if (
|
||||
not procurement_request.processing_department_id
|
||||
and not procurement_request.is_editable
|
||||
):
|
||||
return Response(
|
||||
{"error": "Заявка уже отправлена на согласование"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@ -479,12 +486,16 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
).count()
|
||||
|
||||
if pending_approvals == 0:
|
||||
# Все одобрили - меняем статус заявки
|
||||
# Сигнал post_save(ProcurementRequest) отправит уведомление
|
||||
# requestor'у
|
||||
procurement_request.status = ProcurementStatus.APPROVED
|
||||
if procurement_request.processing_department_id:
|
||||
procurement_request.status = (
|
||||
ProcurementStatus.IN_PROGRESS
|
||||
if procurement_request.executor_id
|
||||
else ProcurementStatus.WAITING
|
||||
)
|
||||
else:
|
||||
procurement_request.status = ProcurementStatus.APPROVED
|
||||
procurement_request._notification_actor = request.user
|
||||
procurement_request.save()
|
||||
procurement_request.save(update_fields=["status", "updated_at"])
|
||||
|
||||
procurement_request.refresh_from_db()
|
||||
|
||||
@ -930,6 +941,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
|
||||
user = self.request.user
|
||||
|
||||
if procurement_request.status in [
|
||||
ProcurementStatus.PENDING,
|
||||
ProcurementStatus.CANCELLED,
|
||||
ProcurementStatus.REJECTED,
|
||||
]:
|
||||
@ -970,6 +982,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
|
||||
procurement_request = item.request
|
||||
|
||||
if procurement_request.status in [
|
||||
ProcurementStatus.PENDING,
|
||||
ProcurementStatus.CANCELLED,
|
||||
ProcurementStatus.REJECTED,
|
||||
]:
|
||||
@ -1040,11 +1053,17 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
|
||||
if procurement_request.status in [
|
||||
ProcurementStatus.PENDING,
|
||||
ProcurementStatus.CANCELLED,
|
||||
ProcurementStatus.REJECTED,
|
||||
]:
|
||||
return Response(
|
||||
{"detail": "Нельзя изменить позицию в отменённой или отклонённой заявке"},
|
||||
{
|
||||
"detail": (
|
||||
"Нельзя изменить позицию в заявке на согласовании, "
|
||||
"в отменённой или отклонённой заявке"
|
||||
)
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
@ -36,6 +36,7 @@ from .handlers import (
|
||||
notify_request_rejected,
|
||||
notify_request_completed,
|
||||
notify_request_in_progress,
|
||||
notify_request_returned_to_processing,
|
||||
notify_request_cancelled,
|
||||
notify_stage_approved,
|
||||
notify_stage_rejected,
|
||||
@ -65,6 +66,7 @@ __all__ = [
|
||||
'notify_request_rejected',
|
||||
'notify_request_completed',
|
||||
'notify_request_in_progress',
|
||||
'notify_request_returned_to_processing',
|
||||
'notify_request_cancelled',
|
||||
'notify_stage_approved',
|
||||
'notify_stage_rejected',
|
||||
|
||||
@ -312,6 +312,39 @@ def notify_request_approved(request):
|
||||
)
|
||||
|
||||
|
||||
def notify_request_returned_to_processing(request):
|
||||
"""Уведомить исполнителя или отдел, что согласованная заявка вернулась."""
|
||||
if not request.processing_department_id:
|
||||
return
|
||||
|
||||
actor = _request_actor(request)
|
||||
title = "Заявка вернулась в обработку"
|
||||
description = (
|
||||
f'Заявка "{request.title}" согласована и снова доступна '
|
||||
f'для обработки.'
|
||||
)
|
||||
recipients = []
|
||||
if request.executor_id:
|
||||
recipients.append(request.executor)
|
||||
else:
|
||||
recipients.extend(_processing_department_recipients(request))
|
||||
|
||||
_notify_many(
|
||||
recipients,
|
||||
verb=NotificationVerbs.APPROVED,
|
||||
procurement_request=request,
|
||||
title=title,
|
||||
description=description,
|
||||
actor=actor,
|
||||
extra_data={
|
||||
"old_status": getattr(request, "_original_status", None),
|
||||
"new_status": request.status,
|
||||
"processing_department_id": request.processing_department_id,
|
||||
"executor_id": request.executor_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def notify_request_rejected(request):
|
||||
"""
|
||||
Уведомить об отклонении заявки.
|
||||
|
||||
@ -23,6 +23,7 @@ from .handlers import (
|
||||
notify_request_rejected,
|
||||
notify_request_completed,
|
||||
notify_request_in_progress,
|
||||
notify_request_returned_to_processing,
|
||||
notify_request_cancelled,
|
||||
notify_stage_approved,
|
||||
notify_stage_rejected,
|
||||
@ -85,6 +86,18 @@ def _handle_status_change(request, new_status):
|
||||
# Заявка отправлена на согласование
|
||||
notify_approvers(request)
|
||||
|
||||
elif (
|
||||
getattr(request, "_original_status", None) == ProcurementStatus.PENDING
|
||||
and request.processing_department_id
|
||||
and new_status in {
|
||||
ProcurementStatus.WAITING,
|
||||
ProcurementStatus.IN_PROGRESS,
|
||||
}
|
||||
):
|
||||
# Адресная заявка после согласования возвращается в тот же отдел.
|
||||
notify_request_approved(request)
|
||||
notify_request_returned_to_processing(request)
|
||||
|
||||
elif new_status == ProcurementStatus.APPROVED:
|
||||
# Заявка одобрена
|
||||
notify_request_approved(request)
|
||||
|
||||
@ -9,6 +9,7 @@ from django.utils import timezone
|
||||
|
||||
from procurement.constants import (
|
||||
ApprovalStatus,
|
||||
ProcurementStatus,
|
||||
get_default_approval_step_name,
|
||||
)
|
||||
|
||||
@ -93,6 +94,60 @@ class ProcurementApprovalResolver:
|
||||
через Department.head.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def user_is_processing_department_member(cls, user, procurement_request):
|
||||
if not procurement_request.processing_department_id:
|
||||
return False
|
||||
|
||||
from employees.models import EmployeeDepartment
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def user_can_submit_for_approval(cls, user, procurement_request) -> bool:
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
|
||||
if (
|
||||
procurement_request.status == ProcurementStatus.PENDING
|
||||
or procurement_request.approvals.filter(
|
||||
status=ApprovalStatus.PENDING,
|
||||
).exists()
|
||||
):
|
||||
return False
|
||||
|
||||
if procurement_request.processing_department_id:
|
||||
if procurement_request.status not in {
|
||||
ProcurementStatus.WAITING,
|
||||
ProcurementStatus.IN_PROGRESS,
|
||||
}:
|
||||
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.executor_id == user.id:
|
||||
return True
|
||||
return cls.user_is_processing_department_member(
|
||||
user,
|
||||
procurement_request,
|
||||
)
|
||||
|
||||
return (
|
||||
procurement_request.requestor_id == user.id
|
||||
and procurement_request.is_editable
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def resolve_approval_step(cls, procurement_request, route):
|
||||
from .models import ApprovalRoute
|
||||
|
||||
@ -982,6 +982,221 @@ class TestProcessingDepartmentWorkflow:
|
||||
)
|
||||
assert edit_response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
@pytest.fixture
|
||||
def department_head_approval_route(self, department_head):
|
||||
return ApprovalRoute.objects.create(
|
||||
priority=HEAD_PRIORITY,
|
||||
resolver_type=ApprovalRoute.ResolverType.DEPARTMENT_HEAD,
|
||||
)
|
||||
|
||||
def test_processing_department_user_can_submit_waiting_request_for_approval(
|
||||
self, api_client, supply_user, processing_request,
|
||||
procurement_item_factory, department_head_approval_route
|
||||
):
|
||||
procurement_item_factory(request=processing_request)
|
||||
api_client.force_authenticate(user=supply_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.processing_department_id is not None
|
||||
assert processing_request.approvals.count() == 1
|
||||
assert response.data["can_current_user_submit_for_approval"] is False
|
||||
|
||||
def test_processing_department_requestor_cannot_submit_for_approval(
|
||||
self, api_client, user, processing_request,
|
||||
procurement_item_factory, department_head_approval_route
|
||||
):
|
||||
procurement_item_factory(request=processing_request)
|
||||
api_client.force_authenticate(user=user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-submit',
|
||||
kwargs={'pk': processing_request.id},
|
||||
)
|
||||
|
||||
response = api_client.post(url)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
processing_request.refresh_from_db()
|
||||
assert processing_request.status == ProcurementStatus.WAITING
|
||||
assert processing_request.approvals.count() == 0
|
||||
|
||||
def test_processing_department_request_cannot_be_submitted_twice_while_pending(
|
||||
self, api_client, supply_user, processing_request,
|
||||
procurement_item_factory, department_head_approval_route
|
||||
):
|
||||
procurement_item_factory(request=processing_request)
|
||||
processing_request.status = ProcurementStatus.PENDING
|
||||
processing_request.save(update_fields=["status", "updated_at"])
|
||||
Approval.objects.create(
|
||||
request=processing_request,
|
||||
approver=processing_request.department.head,
|
||||
priority=HEAD_PRIORITY,
|
||||
status=ApprovalStatus.PENDING,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=supply_user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-submit',
|
||||
kwargs={'pk': processing_request.id},
|
||||
)
|
||||
|
||||
response = api_client.post(url)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
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
|
||||
):
|
||||
procurement_item_factory(request=processing_request)
|
||||
submit_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-submit',
|
||||
kwargs={'pk': processing_request.id},
|
||||
)
|
||||
approve_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-approve',
|
||||
kwargs={'pk': processing_request.id},
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=supply_user)
|
||||
submit_response = api_client.post(submit_url)
|
||||
assert submit_response.status_code == status.HTTP_200_OK
|
||||
|
||||
api_client.force_authenticate(user=department_head)
|
||||
approve_response = api_client.post(approve_url, {'comment': 'Можно'})
|
||||
|
||||
assert approve_response.status_code == status.HTTP_200_OK
|
||||
processing_request.refresh_from_db()
|
||||
assert processing_request.status == ProcurementStatus.WAITING
|
||||
assert processing_request.executor is None
|
||||
assert processing_request.processing_department is not None
|
||||
|
||||
def test_final_approve_returns_processing_request_to_executor_work(
|
||||
self, api_client, supply_user, department_head, processing_request,
|
||||
procurement_item_factory, department_head_approval_route
|
||||
):
|
||||
procurement_item_factory(request=processing_request)
|
||||
processing_request.executor = supply_user
|
||||
processing_request.status = ProcurementStatus.IN_PROGRESS
|
||||
processing_request.started_at = timezone.now()
|
||||
processing_request.save(
|
||||
update_fields=[
|
||||
"executor",
|
||||
"status",
|
||||
"started_at",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
submit_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-submit',
|
||||
kwargs={'pk': processing_request.id},
|
||||
)
|
||||
approve_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-approve',
|
||||
kwargs={'pk': processing_request.id},
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=supply_user)
|
||||
submit_response = api_client.post(submit_url)
|
||||
assert submit_response.status_code == status.HTTP_200_OK
|
||||
|
||||
api_client.force_authenticate(user=department_head)
|
||||
approve_response = api_client.post(approve_url, {'comment': 'Можно'})
|
||||
|
||||
assert approve_response.status_code == status.HTTP_200_OK
|
||||
processing_request.refresh_from_db()
|
||||
assert processing_request.status == ProcurementStatus.IN_PROGRESS
|
||||
assert processing_request.executor == supply_user
|
||||
assert processing_request.processing_department is not None
|
||||
|
||||
def test_reject_processing_request_stops_purchase(
|
||||
self, api_client, supply_user, department_head, processing_request,
|
||||
procurement_item_factory, department_head_approval_route
|
||||
):
|
||||
procurement_item_factory(request=processing_request)
|
||||
submit_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-submit',
|
||||
kwargs={'pk': processing_request.id},
|
||||
)
|
||||
reject_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-reject',
|
||||
kwargs={'pk': processing_request.id},
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=supply_user)
|
||||
submit_response = api_client.post(submit_url)
|
||||
assert submit_response.status_code == status.HTTP_200_OK
|
||||
|
||||
api_client.force_authenticate(user=department_head)
|
||||
reject_response = api_client.post(reject_url, {'comment': 'Нельзя'})
|
||||
|
||||
assert reject_response.status_code == status.HTTP_200_OK
|
||||
processing_request.refresh_from_db()
|
||||
assert processing_request.status == ProcurementStatus.REJECTED
|
||||
|
||||
def test_pending_processing_request_blocks_execution_actions(
|
||||
self, api_client, supply_user, processing_request,
|
||||
procurement_item_factory, department_head_approval_route
|
||||
):
|
||||
item = procurement_item_factory(request=processing_request)
|
||||
processing_request.status = ProcurementStatus.PENDING
|
||||
processing_request.save(update_fields=["status", "updated_at"])
|
||||
Approval.objects.create(
|
||||
request=processing_request,
|
||||
approver=processing_request.department.head,
|
||||
priority=HEAD_PRIORITY,
|
||||
status=ApprovalStatus.PENDING,
|
||||
)
|
||||
api_client.force_authenticate(user=supply_user)
|
||||
|
||||
item_url = reverse(
|
||||
'api:v1:procurement:procurementitem-detail',
|
||||
kwargs={'pk': item.id},
|
||||
)
|
||||
report_issue_url = reverse(
|
||||
'api:v1:procurement:procurementitem-report-issue',
|
||||
kwargs={'pk': item.id},
|
||||
)
|
||||
mark_all_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-mark-all-received',
|
||||
kwargs={'pk': processing_request.id},
|
||||
)
|
||||
complete_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-complete',
|
||||
kwargs={'pk': processing_request.id},
|
||||
)
|
||||
start_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-start-work',
|
||||
kwargs={'pk': processing_request.id},
|
||||
)
|
||||
|
||||
item_response = api_client.patch(
|
||||
item_url,
|
||||
{'execution_status': ProcurementItemExecutionStatus.RECEIVED},
|
||||
format='json',
|
||||
)
|
||||
issue_response = api_client.post(
|
||||
report_issue_url,
|
||||
{'text': 'Брак'},
|
||||
format='json',
|
||||
)
|
||||
mark_all_response = api_client.post(mark_all_url)
|
||||
complete_response = api_client.post(complete_url)
|
||||
start_response = api_client.post(start_url)
|
||||
|
||||
assert item_response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert issue_response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert mark_all_response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert complete_response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert start_response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_item_status_recalculates_request_fulfillment_status(
|
||||
self, api_client, supply_user, processing_request,
|
||||
procurement_item_factory
|
||||
|
||||
@ -155,6 +155,7 @@ function ProcurementRequestActionButtons({
|
||||
const canStartWork = (status === "approved" || status === "waiting") && !request.executor;
|
||||
const isInProgress = status === "in_progress";
|
||||
const canApproveThis = Boolean(request.can_current_user_approve);
|
||||
const canSubmitForApproval = Boolean(request.can_current_user_submit_for_approval);
|
||||
const buttonClass = (variantClass: string) =>
|
||||
`${variantClass} inline-flex h-9 items-center justify-center rounded-lg disabled:opacity-60 ${
|
||||
showLabels ? "gap-1.5 px-3 text-xs font-medium" : "w-9"
|
||||
@ -163,16 +164,16 @@ function ProcurementRequestActionButtons({
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
{isDraft && isAuthor ? (
|
||||
{canSubmitForApproval ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSubmit(request.id)}
|
||||
disabled={busyKey === `submit-${request.id}`}
|
||||
title="На согласование"
|
||||
title="Отправить на согласование"
|
||||
className={buttonClass("app-action-primary")}
|
||||
>
|
||||
<Send size={14} />
|
||||
{label("На согласование")}
|
||||
{label("Отправить на согласование")}
|
||||
</button>
|
||||
) : null}
|
||||
{showSecondaryActions && isDraft && isAuthor ? (
|
||||
@ -310,7 +311,6 @@ export default function ProcurementPage() {
|
||||
handleLoadMore,
|
||||
handleReject,
|
||||
handleSave,
|
||||
handleSaveAndSubmit,
|
||||
handleStart,
|
||||
handleSubmit,
|
||||
isFinal,
|
||||
@ -1247,22 +1247,10 @@ export default function ProcurementPage() {
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={busyKey === "save" || busyKey === "save-submit"}
|
||||
className={`${modalMode === "create" && form.requireApproval ? "app-action-secondary" : "app-action-primary"} rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-60`}
|
||||
className="app-action-primary rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-60"
|
||||
>
|
||||
{modalMode === "create"
|
||||
? form.requireApproval ? "Создать черновик" : "Создать и направить в отдел"
|
||||
: "Сохранить"}
|
||||
{modalMode === "create" ? "Создать заявку" : "Сохранить"}
|
||||
</button>
|
||||
{modalMode === "create" && form.requireApproval ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveAndSubmit}
|
||||
disabled={busyKey === "save" || busyKey === "save-submit"}
|
||||
className="app-action-primary rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-60"
|
||||
>
|
||||
Отправить на согласование
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
}>
|
||||
|
||||
@ -1283,36 +1271,17 @@ export default function ProcurementPage() {
|
||||
{/* Описание */}
|
||||
<div>
|
||||
<label className="app-text-muted mb-1 block text-xs font-medium">
|
||||
Описание и обоснование{form.requireApproval ? " *" : " (необязательно)"}
|
||||
Описание и обоснование (необязательно)
|
||||
</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
placeholder={form.requireApproval ? "Обоснуйте необходимость закупки..." : "Можно оставить пустым и описать детали в позициях..."}
|
||||
placeholder="Можно оставить пустым и описать детали в позициях..."
|
||||
rows={3}
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="app-surface-muted flex items-start gap-3 rounded-xl px-3 py-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.requireApproval}
|
||||
onChange={(e) => setForm((f) => ({
|
||||
...f,
|
||||
requireApproval: e.target.checked,
|
||||
processing_department: e.target.checked ? null : f.processing_department,
|
||||
}))}
|
||||
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-sky-600"
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium text-[var(--foreground)]">Требовать согласование</span>
|
||||
<span className="app-text-muted mt-0.5 block text-xs">
|
||||
Если выключено, заявка сразу попадет в очередь выбранного отдела.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Отдел + Срочность */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<SearchableSelectSingle
|
||||
@ -1337,16 +1306,14 @@ export default function ProcurementPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!form.requireApproval ? (
|
||||
<SearchableSelectSingle
|
||||
label="Отдел-исполнитель *"
|
||||
placeholder="Выберите отдел, который обработает заявку..."
|
||||
items={departments.map((d) => ({ id: d.id, name: d.name }))}
|
||||
selectedId={form.processing_department}
|
||||
onSelect={(id) => setForm((f) => ({ ...f, processing_department: id }))}
|
||||
disabled={Boolean(defaultProcessingDepartmentId)}
|
||||
/>
|
||||
) : null}
|
||||
<SearchableSelectSingle
|
||||
label="Отдел-исполнитель *"
|
||||
placeholder="Выберите отдел, который обработает заявку..."
|
||||
items={departments.map((d) => ({ id: d.id, name: d.name }))}
|
||||
selectedId={form.processing_department}
|
||||
onSelect={(id) => setForm((f) => ({ ...f, processing_department: id }))}
|
||||
disabled={modalMode === "create" && Boolean(defaultProcessingDepartmentId)}
|
||||
/>
|
||||
|
||||
{/* ── Items ── */}
|
||||
<div>
|
||||
|
||||
@ -523,10 +523,11 @@ export function ProcurementRequestDetailContent({
|
||||
footer,
|
||||
}: ProcurementRequestDetailContentProps) {
|
||||
const canEditItemProcessing = Boolean(canProcessItems && onUpdateItem);
|
||||
const isRequestPending = String(request.status || "").toLowerCase() === "pending";
|
||||
const requestorId = typeof request.requestor === "number" ? request.requestor : request.requestor?.id ?? null;
|
||||
const executorId = typeof request.executor === "number" ? request.executor : request.executor?.id ?? null;
|
||||
const canReportIssues = Boolean(
|
||||
onReportItemIssue && currentUserId && (
|
||||
!isRequestPending && onReportItemIssue && currentUserId && (
|
||||
canEditItemProcessing ||
|
||||
currentUserId === requestorId ||
|
||||
currentUserId === executorId
|
||||
|
||||
@ -51,7 +51,7 @@ const emptyForm: FormState = {
|
||||
title: "",
|
||||
description: "",
|
||||
department: null,
|
||||
requireApproval: true,
|
||||
requireApproval: false,
|
||||
processing_department: null,
|
||||
urgency: "medium",
|
||||
items: [{ ...emptyItem }],
|
||||
@ -335,7 +335,11 @@ export function useProcurementPage(user: User | null) {
|
||||
}, [departments]);
|
||||
|
||||
useEffect(() => {
|
||||
if (form.requireApproval || !defaultProcessingDepartmentId) {
|
||||
if (
|
||||
editingId !== null ||
|
||||
form.requireApproval ||
|
||||
!defaultProcessingDepartmentId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -351,7 +355,7 @@ export function useProcurementPage(user: User | null) {
|
||||
processing_department: defaultProcessingDepartmentId,
|
||||
};
|
||||
});
|
||||
}, [defaultProcessingDepartmentId, form.requireApproval]);
|
||||
}, [defaultProcessingDepartmentId, editingId, form.requireApproval]);
|
||||
|
||||
const filteredRequests = useMemo(() => {
|
||||
const urgencyRank: Record<string, number> = {
|
||||
@ -401,7 +405,7 @@ export function useProcurementPage(user: User | null) {
|
||||
title: detail.title || "",
|
||||
description: detail.description || "",
|
||||
department: detail.department ?? null,
|
||||
requireApproval: !detail.processing_department,
|
||||
requireApproval: false,
|
||||
processing_department: detail.processing_department ?? null,
|
||||
urgency: detail.urgency || "medium",
|
||||
items: detail.items && detail.items.length > 0
|
||||
@ -454,24 +458,20 @@ export function useProcurementPage(user: User | null) {
|
||||
return detail;
|
||||
}, []);
|
||||
|
||||
const saveRequest = useCallback(async (submitAfterCreate = false) => {
|
||||
const saveRequest = useCallback(async () => {
|
||||
try {
|
||||
setBusyKey(submitAfterCreate ? "save-submit" : "save");
|
||||
setBusyKey("save");
|
||||
setActionError(null);
|
||||
|
||||
if (!form.title.trim()) {
|
||||
setActionError("Укажите название заявки.");
|
||||
return;
|
||||
}
|
||||
if (form.requireApproval && !form.description.trim()) {
|
||||
setActionError("Укажите описание и обоснование.");
|
||||
return;
|
||||
}
|
||||
if (!form.department) {
|
||||
setActionError("Выберите отдел.");
|
||||
return;
|
||||
}
|
||||
if (!form.requireApproval && !form.processing_department) {
|
||||
if (!form.processing_department) {
|
||||
setActionError("Выберите отдел-исполнитель.");
|
||||
return;
|
||||
}
|
||||
@ -498,7 +498,7 @@ export function useProcurementPage(user: User | null) {
|
||||
title: form.title,
|
||||
description: form.description.trim(),
|
||||
department: form.department,
|
||||
processing_department: form.requireApproval ? null : form.processing_department,
|
||||
processing_department: form.processing_department,
|
||||
urgency: form.urgency,
|
||||
items: validItems.map((item) => ({
|
||||
name: item.name,
|
||||
@ -513,13 +513,8 @@ export function useProcurementPage(user: User | null) {
|
||||
};
|
||||
|
||||
if (modalMode === "create") {
|
||||
const created = await apiClient.createProcurementRequest(payload) as ProcurementRequest;
|
||||
if (submitAfterCreate && form.requireApproval && created.id) {
|
||||
await apiClient.submitProcurementRequest(created.id);
|
||||
setActionSuccess("Заявка создана и отправлена на согласование.");
|
||||
} else {
|
||||
setActionSuccess(form.requireApproval ? "Заявка создана (черновик)." : "Заявка направлена в отдел.");
|
||||
}
|
||||
await apiClient.createProcurementRequest(payload) as ProcurementRequest;
|
||||
setActionSuccess("Заявка создана и направлена в отдел.");
|
||||
setCreateOpen(false);
|
||||
} else if (editingId) {
|
||||
await apiClient.updateProcurementRequest(editingId, {
|
||||
@ -541,9 +536,7 @@ export function useProcurementPage(user: User | null) {
|
||||
}
|
||||
}, [editingId, form, loadPage1, modalMode, resetForm]);
|
||||
|
||||
const handleSave = useCallback(() => saveRequest(false), [saveRequest]);
|
||||
|
||||
const handleSaveAndSubmit = useCallback(() => saveRequest(true), [saveRequest]);
|
||||
const handleSave = useCallback(() => saveRequest(), [saveRequest]);
|
||||
|
||||
const doAction = useCallback(async (key: string, action: () => Promise<unknown>, id: number, successMessage: string) => {
|
||||
try {
|
||||
@ -957,7 +950,6 @@ export function useProcurementPage(user: User | null) {
|
||||
modalMode,
|
||||
isModalOpen,
|
||||
handleSave,
|
||||
handleSaveAndSubmit,
|
||||
handleLoadMore,
|
||||
toggleExpand,
|
||||
toggleComments,
|
||||
|
||||
@ -561,6 +561,7 @@ export interface ProcurementRequest {
|
||||
total_received_quantity?: number;
|
||||
comments_count?: number;
|
||||
can_current_user_approve?: boolean;
|
||||
can_current_user_submit_for_approval?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
submitted_at?: string | null;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user