feat: refine procurement approval access and employee statuses
This commit is contained in:
parent
0b45888f5c
commit
0dbd817c60
@ -23,6 +23,24 @@ from .shared import (
|
||||
Employee = get_user_model()
|
||||
|
||||
|
||||
def _employee_personnel_state_payload(obj):
|
||||
prefetched = getattr(obj, "_prefetched_objects_cache", {})
|
||||
actions = prefetched.get("actions") if prefetched else None
|
||||
state = resolve_employee_personnel_state(
|
||||
obj,
|
||||
timezone.localdate(),
|
||||
actions=actions,
|
||||
)
|
||||
return {
|
||||
"status": state.status,
|
||||
"label": state.label or "Работает",
|
||||
"action_id": state.action_id,
|
||||
"date_from": state.date_from,
|
||||
"date_to": state.date_to,
|
||||
"expects_attendance": state.expects_attendance,
|
||||
}
|
||||
|
||||
|
||||
class EmployeeDepartmentSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField(allow_null=True)
|
||||
@ -275,15 +293,7 @@ class EmployeeSerializer(serializers.ModelSerializer):
|
||||
|
||||
@extend_schema_field(EmployeePersonnelStateSerializer())
|
||||
def get_personnel_state(self, obj):
|
||||
state = resolve_employee_personnel_state(obj, timezone.localdate())
|
||||
return {
|
||||
"status": state.status,
|
||||
"label": state.label or "Работает",
|
||||
"action_id": state.action_id,
|
||||
"date_from": state.date_from,
|
||||
"date_to": state.date_to,
|
||||
"expects_attendance": state.expects_attendance,
|
||||
}
|
||||
return _employee_personnel_state_payload(obj)
|
||||
|
||||
|
||||
class EmployeeBriefSerializer(serializers.ModelSerializer):
|
||||
@ -343,6 +353,7 @@ class EmployeeListSerializer(serializers.ModelSerializer):
|
||||
full_name = serializers.SerializerMethodField()
|
||||
skills = SkillSerializer(many=True, read_only=True)
|
||||
department_relation = serializers.SerializerMethodField()
|
||||
personnel_state = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Employee
|
||||
@ -361,8 +372,13 @@ class EmployeeListSerializer(serializers.ModelSerializer):
|
||||
"created_at",
|
||||
"display_name",
|
||||
"department_relation",
|
||||
"personnel_state",
|
||||
)
|
||||
|
||||
@extend_schema_field(EmployeePersonnelStateSerializer())
|
||||
def get_personnel_state(self, obj):
|
||||
return _employee_personnel_state_payload(obj)
|
||||
|
||||
@extend_schema_field(EmployeeDepartmentRelationSerializer(allow_null=True))
|
||||
def get_department_relation(self, obj):
|
||||
is_member = getattr(obj, "_is_dept_member", None)
|
||||
|
||||
@ -199,9 +199,13 @@ class CanManageProcurementRequest(permissions.BasePermission):
|
||||
|
||||
dept_id = obj.department_id
|
||||
|
||||
# Submit (отправка на согласование) - только владелец
|
||||
# Submit дополнительно валидируется resolver-ом: только сотрудник
|
||||
# отдела-исполнителя, не автор заявки.
|
||||
if action == "submit":
|
||||
return obj.requestor == user
|
||||
return ProcurementApprovalResolver.user_can_submit_for_approval(
|
||||
user,
|
||||
obj,
|
||||
)
|
||||
|
||||
# Cancel (отмена заявки) - только владелец
|
||||
if action == "cancel":
|
||||
|
||||
@ -239,29 +239,21 @@ class ProcurementApprovalResolver:
|
||||
):
|
||||
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,
|
||||
)
|
||||
if not procurement_request.processing_department_id:
|
||||
return False
|
||||
|
||||
return (
|
||||
procurement_request.requestor_id == user.id
|
||||
and procurement_request.is_editable
|
||||
if procurement_request.status not in {
|
||||
ProcurementStatus.WAITING,
|
||||
ProcurementStatus.IN_PROGRESS,
|
||||
}:
|
||||
return False
|
||||
|
||||
if procurement_request.requestor_id == user.id:
|
||||
return False
|
||||
|
||||
return cls.user_is_processing_department_member(
|
||||
user,
|
||||
procurement_request,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
||||
@ -4,8 +4,10 @@ import itertools
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from employees.constants import ACTION_WORKING
|
||||
from employees.models import (Department, DepartmentRole, EmployeeDepartment,
|
||||
Position)
|
||||
EmployeeAction, Position)
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from tests.conftest import _unique_phone
|
||||
@ -72,6 +74,17 @@ def test_list_includes_basic_fields(api):
|
||||
emp = make_user(
|
||||
"emp@example.com", first_name="Ivan", last_name="Ivanov", position=pos
|
||||
)
|
||||
EmployeeAction.objects.filter(employee=emp).delete()
|
||||
EmployeeAction.objects.create(
|
||||
employee=emp,
|
||||
action=ACTION_WORKING,
|
||||
date=timezone.now().replace(
|
||||
hour=23,
|
||||
minute=59,
|
||||
second=59,
|
||||
microsecond=0,
|
||||
),
|
||||
)
|
||||
|
||||
url = reverse("api:v1:employees-list")
|
||||
r = api.get(url)
|
||||
@ -84,6 +97,8 @@ def test_list_includes_basic_fields(api):
|
||||
assert row.get("first_name") == "Ivan"
|
||||
assert row.get("last_name") == "Ivanov"
|
||||
assert "avatar" in row, "Поле 'avatar' должно присутствовать (может быть пустым)"
|
||||
assert row.get("personnel_state", {}).get("status") == "working"
|
||||
assert row.get("personnel_state", {}).get("label") == "Работает"
|
||||
|
||||
if "position" in row and isinstance(row["position"], dict):
|
||||
assert row["position"].get("id") == pos.id
|
||||
|
||||
@ -52,6 +52,22 @@ FINANCE_PRIORITY = 2
|
||||
DIRECTOR_PRIORITY = 3
|
||||
|
||||
|
||||
def prepare_for_recipient_department_submit(
|
||||
procurement_request,
|
||||
recipient_department,
|
||||
):
|
||||
procurement_request.processing_department = recipient_department
|
||||
procurement_request.status = ProcurementStatus.WAITING
|
||||
procurement_request.save(
|
||||
update_fields=[
|
||||
"processing_department",
|
||||
"status",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
return procurement_request
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client():
|
||||
"""API клиент."""
|
||||
@ -67,6 +83,36 @@ def department(db):
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recipient_department(db):
|
||||
"""Отдел-получатель заявки на закупку."""
|
||||
return Department.objects.create(
|
||||
name="Снабжение",
|
||||
description="Отдел-получатель заявок на закупку",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recipient_user(db, recipient_department):
|
||||
"""Сотрудник отдела-получателя заявки."""
|
||||
employee = Employee.objects.create_user(
|
||||
email="recipient@example.com",
|
||||
password="testpass123",
|
||||
phone_number="+79990000001",
|
||||
first_name="Получатель",
|
||||
last_name="Заявки",
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
send_activation_email=False,
|
||||
)
|
||||
EmployeeDepartment.objects.create(
|
||||
employee=employee,
|
||||
department=recipient_department,
|
||||
is_active=True,
|
||||
)
|
||||
return employee
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(db, department):
|
||||
"""Обычный пользователь."""
|
||||
@ -1853,6 +1899,52 @@ class TestProcessingDepartmentWorkflow:
|
||||
'api:v1:procurement:procurementrequest-submit',
|
||||
kwargs={'pk': processing_request.id},
|
||||
)
|
||||
options_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-approval-options',
|
||||
kwargs={'pk': processing_request.id},
|
||||
)
|
||||
detail_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-detail',
|
||||
kwargs={'pk': processing_request.id},
|
||||
)
|
||||
|
||||
response = api_client.post(url)
|
||||
options_response = api_client.get(options_url)
|
||||
detail_response = api_client.get(detail_url)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert options_response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert detail_response.status_code == status.HTTP_200_OK
|
||||
assert detail_response.data["can_current_user_submit_for_approval"] is False
|
||||
processing_request.refresh_from_db()
|
||||
assert processing_request.status == ProcurementStatus.WAITING
|
||||
assert processing_request.approvals.count() == 0
|
||||
|
||||
def test_customer_department_user_cannot_submit_for_approval(
|
||||
self, api_client, department, processing_request,
|
||||
procurement_item_factory, department_head_approval_route
|
||||
):
|
||||
customer_user = Employee.objects.create_user(
|
||||
email="customer-colleague@example.com",
|
||||
password="testpass123",
|
||||
phone_number="+79998888895",
|
||||
first_name="Коллега",
|
||||
last_name="Заказчика",
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
send_activation_email=False,
|
||||
)
|
||||
EmployeeDepartment.objects.create(
|
||||
employee=customer_user,
|
||||
department=department,
|
||||
is_active=True,
|
||||
)
|
||||
procurement_item_factory(request=processing_request)
|
||||
api_client.force_authenticate(user=customer_user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-submit',
|
||||
kwargs={'pk': processing_request.id},
|
||||
)
|
||||
|
||||
response = api_client.post(url)
|
||||
|
||||
@ -1861,6 +1953,147 @@ class TestProcessingDepartmentWorkflow:
|
||||
assert processing_request.status == ProcurementStatus.WAITING
|
||||
assert processing_request.approvals.count() == 0
|
||||
|
||||
detail_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-detail',
|
||||
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
|
||||
|
||||
def test_customer_department_user_can_submit_when_processing_department_same(
|
||||
self, api_client, supply_department, supply_user,
|
||||
procurement_item_factory, department_head_approval_route
|
||||
):
|
||||
supply_head = Employee.objects.create_user(
|
||||
email="supply-head@example.com",
|
||||
password="testpass123",
|
||||
phone_number="+79998888896",
|
||||
first_name="Руководитель",
|
||||
last_name="Снабжения",
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
send_activation_email=False,
|
||||
)
|
||||
supply_department.head = supply_head
|
||||
supply_department.save(update_fields=["head"])
|
||||
|
||||
requestor = Employee.objects.create_user(
|
||||
email="same-department-requestor@example.com",
|
||||
password="testpass123",
|
||||
phone_number="+79998888897",
|
||||
first_name="Автор",
|
||||
last_name="Снабжения",
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
send_activation_email=False,
|
||||
)
|
||||
EmployeeDepartment.objects.create(
|
||||
employee=requestor,
|
||||
department=supply_department,
|
||||
is_active=True,
|
||||
)
|
||||
procurement_request = ProcurementRequest.objects.create(
|
||||
title="Внутренняя заявка снабжения",
|
||||
description="Заказчик и исполнитель совпадают",
|
||||
department=supply_department,
|
||||
processing_department=supply_department,
|
||||
requestor=requestor,
|
||||
status=ProcurementStatus.WAITING,
|
||||
urgency=UrgencyLevel.MEDIUM,
|
||||
)
|
||||
procurement_item_factory(request=procurement_request)
|
||||
detail_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-detail',
|
||||
kwargs={'pk': procurement_request.id},
|
||||
)
|
||||
options_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-approval-options',
|
||||
kwargs={'pk': procurement_request.id},
|
||||
)
|
||||
submit_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-submit',
|
||||
kwargs={'pk': procurement_request.id},
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=requestor)
|
||||
requestor_detail = api_client.get(detail_url)
|
||||
requestor_submit = api_client.post(submit_url)
|
||||
assert requestor_detail.status_code == status.HTTP_200_OK
|
||||
assert requestor_detail.data["can_current_user_submit_for_approval"] is False
|
||||
assert requestor_submit.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
api_client.force_authenticate(user=supply_user)
|
||||
submitter_detail = api_client.get(detail_url)
|
||||
options_response = api_client.get(options_url)
|
||||
submit_response = api_client.post(submit_url)
|
||||
|
||||
assert submitter_detail.status_code == status.HTTP_200_OK
|
||||
assert submitter_detail.data["can_current_user_submit_for_approval"] is True
|
||||
assert options_response.status_code == status.HTTP_200_OK
|
||||
assert submit_response.status_code == status.HTTP_200_OK
|
||||
procurement_request.refresh_from_db()
|
||||
assert procurement_request.status == ProcurementStatus.PENDING
|
||||
assert procurement_request.approvals.count() == 1
|
||||
|
||||
def test_requestor_in_processing_department_cannot_submit_for_approval(
|
||||
self, api_client, user, supply_department, processing_request,
|
||||
procurement_item_factory, department_head_approval_route
|
||||
):
|
||||
EmployeeDepartment.objects.create(
|
||||
employee=user,
|
||||
department=supply_department,
|
||||
is_active=True,
|
||||
)
|
||||
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_execute_permission_user_outside_processing_department_cannot_submit(
|
||||
self, api_client, procurement_execute_user, processing_request,
|
||||
procurement_item_factory, department_head_approval_route
|
||||
):
|
||||
procurement_item_factory(request=processing_request)
|
||||
api_client.force_authenticate(user=procurement_execute_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_request_without_processing_department_cannot_be_submitted(
|
||||
self, api_client, user, procurement_request, procurement_item,
|
||||
department_head_approval_route
|
||||
):
|
||||
api_client.force_authenticate(user=user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-submit',
|
||||
kwargs={'pk': procurement_request.id},
|
||||
)
|
||||
|
||||
response = api_client.post(url)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
procurement_request.refresh_from_db()
|
||||
assert procurement_request.status == ProcurementStatus.DRAFT
|
||||
assert procurement_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
|
||||
@ -3541,9 +3774,14 @@ class TestProcurementRequestWorkflow:
|
||||
return employee
|
||||
|
||||
def test_submit_notifies_only_current_stage_approver(
|
||||
self, api_client, user, procurement_request, procurement_item, budget, monkeypatch
|
||||
self, api_client, recipient_user, recipient_department,
|
||||
procurement_request, procurement_item, budget, monkeypatch
|
||||
):
|
||||
"""При submit уведомление должно уйти только текущему этапу."""
|
||||
prepare_for_recipient_department_submit(
|
||||
procurement_request,
|
||||
recipient_department,
|
||||
)
|
||||
sent = []
|
||||
|
||||
def fake_notify_send(**kwargs):
|
||||
@ -3554,7 +3792,7 @@ class TestProcurementRequestWorkflow:
|
||||
fake_notify_send,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
api_client.force_authenticate(user=recipient_user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-submit',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
@ -3571,9 +3809,13 @@ class TestProcurementRequestWorkflow:
|
||||
assert pending_recipients == ["head@example.com"]
|
||||
|
||||
def test_submit_notifies_department_head_and_role_approvers(
|
||||
self, api_client, user, department, procurement_request,
|
||||
procurement_item, monkeypatch
|
||||
self, api_client, recipient_user, recipient_department, department,
|
||||
procurement_request, procurement_item, monkeypatch
|
||||
):
|
||||
prepare_for_recipient_department_submit(
|
||||
procurement_request,
|
||||
recipient_department,
|
||||
)
|
||||
role_approver = self._create_department_approval_role_user(
|
||||
department,
|
||||
)
|
||||
@ -3587,7 +3829,7 @@ class TestProcurementRequestWorkflow:
|
||||
fake_notify_send,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
api_client.force_authenticate(user=recipient_user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-submit',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
@ -3651,7 +3893,8 @@ class TestProcurementRequestWorkflow:
|
||||
assert procurement_request.status == ProcurementStatus.APPROVED
|
||||
|
||||
def test_department_role_approver_allows_submit_without_head(
|
||||
self, api_client, user, department
|
||||
self, api_client, user, recipient_user, recipient_department,
|
||||
department
|
||||
):
|
||||
department.head = None
|
||||
department.save(update_fields=["head"])
|
||||
@ -3664,8 +3907,9 @@ class TestProcurementRequestWorkflow:
|
||||
title="Заявка без начальника, но с ролью",
|
||||
description="Проверка согласующей роли",
|
||||
department=department,
|
||||
processing_department=recipient_department,
|
||||
requestor=user,
|
||||
status=ProcurementStatus.DRAFT,
|
||||
status=ProcurementStatus.WAITING,
|
||||
urgency=UrgencyLevel.MEDIUM,
|
||||
)
|
||||
ProcurementItem.objects.create(
|
||||
@ -3676,7 +3920,7 @@ class TestProcurementRequestWorkflow:
|
||||
estimated_unit_price=Decimal("12.00"),
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
api_client.force_authenticate(user=recipient_user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-submit',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
@ -3689,9 +3933,14 @@ class TestProcurementRequestWorkflow:
|
||||
assert approval.approver_id == role_approver.id
|
||||
|
||||
def test_approval_options_include_manual_routes_outside_amount(
|
||||
self, api_client, user, procurement_request
|
||||
self, api_client, recipient_user, recipient_department,
|
||||
procurement_request
|
||||
):
|
||||
"""Предпросмотр показывает автоэтапы и ручные этапы вне суммы."""
|
||||
prepare_for_recipient_department_submit(
|
||||
procurement_request,
|
||||
recipient_department,
|
||||
)
|
||||
ProcurementItem.objects.create(
|
||||
request=procurement_request,
|
||||
name="Недорогая позиция",
|
||||
@ -3699,7 +3948,7 @@ class TestProcurementRequestWorkflow:
|
||||
unit="шт",
|
||||
estimated_unit_price=Decimal("100.00"),
|
||||
)
|
||||
api_client.force_authenticate(user=user)
|
||||
api_client.force_authenticate(user=recipient_user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-approval-options',
|
||||
kwargs={'pk': procurement_request.id},
|
||||
@ -3721,9 +3970,14 @@ class TestProcurementRequestWorkflow:
|
||||
assert available_by_priority[DIRECTOR_PRIORITY]["is_available"] is True
|
||||
|
||||
def test_submit_with_manual_approver_bypasses_amount_auto_routes(
|
||||
self, api_client, user, procurement_request
|
||||
self, api_client, recipient_user, recipient_department,
|
||||
procurement_request
|
||||
):
|
||||
"""Ручной список используется вместо автоподбора по сумме."""
|
||||
prepare_for_recipient_department_submit(
|
||||
procurement_request,
|
||||
recipient_department,
|
||||
)
|
||||
ProcurementItem.objects.create(
|
||||
request=procurement_request,
|
||||
name="Недорогая позиция",
|
||||
@ -3732,7 +3986,7 @@ class TestProcurementRequestWorkflow:
|
||||
estimated_unit_price=Decimal("100.00"),
|
||||
)
|
||||
director = Employee.objects.get(email="director@example.com")
|
||||
api_client.force_authenticate(user=user)
|
||||
api_client.force_authenticate(user=recipient_user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-submit',
|
||||
kwargs={'pk': procurement_request.id},
|
||||
@ -4069,10 +4323,15 @@ class TestProcurementRequestWorkflow:
|
||||
]
|
||||
|
||||
def test_submit_request(
|
||||
self, api_client, user, procurement_request, procurement_item, budget
|
||||
self, api_client, recipient_user, recipient_department,
|
||||
procurement_request, procurement_item, budget
|
||||
):
|
||||
"""Отправка заявки на согласование."""
|
||||
api_client.force_authenticate(user=user)
|
||||
prepare_for_recipient_department_submit(
|
||||
procurement_request,
|
||||
recipient_department,
|
||||
)
|
||||
api_client.force_authenticate(user=recipient_user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-submit',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
@ -4105,15 +4364,17 @@ class TestProcurementRequestWorkflow:
|
||||
],
|
||||
)
|
||||
def test_submit_request_applies_amount_thresholds(
|
||||
self, api_client, user, department, unit_price, expected_priorities
|
||||
self, api_client, user, recipient_user, recipient_department,
|
||||
department, unit_price, expected_priorities
|
||||
):
|
||||
"""Маршруты согласования включаются по порогам суммы заявки."""
|
||||
procurement_request = ProcurementRequest.objects.create(
|
||||
title="Пороговая заявка",
|
||||
description="Проверка маршрутов",
|
||||
department=department,
|
||||
processing_department=recipient_department,
|
||||
requestor=user,
|
||||
status=ProcurementStatus.DRAFT,
|
||||
status=ProcurementStatus.WAITING,
|
||||
urgency=UrgencyLevel.MEDIUM,
|
||||
)
|
||||
ProcurementItem.objects.create(
|
||||
@ -4124,7 +4385,7 @@ class TestProcurementRequestWorkflow:
|
||||
estimated_unit_price=unit_price,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
api_client.force_authenticate(user=recipient_user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-submit',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
@ -4139,15 +4400,17 @@ class TestProcurementRequestWorkflow:
|
||||
) == expected_priorities
|
||||
|
||||
def test_submit_request_applies_amount_thresholds_by_actual_price(
|
||||
self, api_client, user, department
|
||||
self, api_client, user, recipient_user, recipient_department,
|
||||
department
|
||||
):
|
||||
"""Если закупщик указал фактическую цену, пороги берутся по ней."""
|
||||
procurement_request = ProcurementRequest.objects.create(
|
||||
title="Уточненная пороговая заявка",
|
||||
description="Проверка маршрутов",
|
||||
department=department,
|
||||
processing_department=recipient_department,
|
||||
requestor=user,
|
||||
status=ProcurementStatus.DRAFT,
|
||||
status=ProcurementStatus.WAITING,
|
||||
urgency=UrgencyLevel.MEDIUM,
|
||||
)
|
||||
ProcurementItem.objects.create(
|
||||
@ -4159,7 +4422,7 @@ class TestProcurementRequestWorkflow:
|
||||
actual_unit_price=Decimal("120000.00"),
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
api_client.force_authenticate(user=recipient_user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-submit',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
@ -4175,7 +4438,8 @@ class TestProcurementRequestWorkflow:
|
||||
) == [HEAD_PRIORITY, FINANCE_PRIORITY, DIRECTOR_PRIORITY]
|
||||
|
||||
def test_submit_request_without_department_head_returns_explicit_error(
|
||||
self, api_client, user, department
|
||||
self, api_client, user, recipient_user, recipient_department,
|
||||
department
|
||||
):
|
||||
"""Если обязательный этап = руководитель отдела, ошибка должна быть явной."""
|
||||
department.head = None
|
||||
@ -4185,8 +4449,9 @@ class TestProcurementRequestWorkflow:
|
||||
title="Заявка без начальника отдела",
|
||||
description="Проверка понятной ошибки",
|
||||
department=department,
|
||||
processing_department=recipient_department,
|
||||
requestor=user,
|
||||
status=ProcurementStatus.DRAFT,
|
||||
status=ProcurementStatus.WAITING,
|
||||
urgency=UrgencyLevel.MEDIUM,
|
||||
)
|
||||
ProcurementItem.objects.create(
|
||||
@ -4197,7 +4462,7 @@ class TestProcurementRequestWorkflow:
|
||||
estimated_unit_price=Decimal("12.00"),
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
api_client.force_authenticate(user=recipient_user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-submit',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
@ -4212,10 +4477,15 @@ class TestProcurementRequestWorkflow:
|
||||
assert response.data["missing_routes"][0]["reason"] == "department_head_missing"
|
||||
|
||||
def test_submit_returns_manual_step_name(
|
||||
self, api_client, user, procurement_request, procurement_item, budget
|
||||
self, api_client, recipient_user, recipient_department,
|
||||
procurement_request, procurement_item, budget
|
||||
):
|
||||
"""Ручное название этапа сохраняется в согласовании и отдаётся в API."""
|
||||
api_client.force_authenticate(user=user)
|
||||
prepare_for_recipient_department_submit(
|
||||
procurement_request,
|
||||
recipient_department,
|
||||
)
|
||||
api_client.force_authenticate(user=recipient_user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-submit',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
@ -4231,10 +4501,15 @@ class TestProcurementRequestWorkflow:
|
||||
assert approvals[1]['step_label'] == 'Финансовый контроль'
|
||||
|
||||
def test_submit_without_items_fails(
|
||||
self, api_client, user, procurement_request
|
||||
self, api_client, recipient_user, recipient_department,
|
||||
procurement_request
|
||||
):
|
||||
"""Нельзя отправить заявку без позиций."""
|
||||
api_client.force_authenticate(user=user)
|
||||
prepare_for_recipient_department_submit(
|
||||
procurement_request,
|
||||
recipient_department,
|
||||
)
|
||||
api_client.force_authenticate(user=recipient_user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-submit',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
@ -4694,15 +4969,17 @@ class TestProcurementRequestWorkflow:
|
||||
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
|
||||
self, api_client, user, recipient_user, recipient_department,
|
||||
department
|
||||
):
|
||||
"""Вышестоящий маршрут может согласовать заявку ниже своего порога."""
|
||||
procurement_request = ProcurementRequest.objects.create(
|
||||
title="Низкая сумма для обхода сверху",
|
||||
description="Проверка согласования верхним маршрутом",
|
||||
department=department,
|
||||
processing_department=recipient_department,
|
||||
requestor=user,
|
||||
status=ProcurementStatus.DRAFT,
|
||||
status=ProcurementStatus.WAITING,
|
||||
urgency=UrgencyLevel.MEDIUM,
|
||||
)
|
||||
ProcurementItem.objects.create(
|
||||
@ -4713,7 +4990,7 @@ class TestProcurementRequestWorkflow:
|
||||
estimated_unit_price=Decimal("12.00"),
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
api_client.force_authenticate(user=recipient_user)
|
||||
submit_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-submit',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
@ -4726,6 +5003,7 @@ class TestProcurementRequestWorkflow:
|
||||
procurement_request.approvals.order_by('priority').values_list('priority', flat=True)
|
||||
) == [HEAD_PRIORITY]
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
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
|
||||
@ -4749,7 +5027,7 @@ class TestProcurementRequestWorkflow:
|
||||
ApprovalStatus.APPROVED,
|
||||
ApprovalStatus.APPROVED,
|
||||
]
|
||||
assert procurement_request.status == ProcurementStatus.APPROVED
|
||||
assert procurement_request.status == ProcurementStatus.WAITING
|
||||
|
||||
def test_detail_marks_next_stage_approver_after_previous_stage_complete(
|
||||
self, api_client, user, department_head, procurement_request, procurement_item
|
||||
|
||||
@ -10,6 +10,7 @@ import { AppShell } from "../../components/AppShell";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
import { getEmployeeActionTone } from "@/lib/users/userDetailUtils";
|
||||
import type { Chat, User } from "@/types/api";
|
||||
|
||||
type PrivateChat = Chat & { member_ids?: number[] };
|
||||
@ -196,6 +197,7 @@ export default function EmployeesPage() {
|
||||
const initials = getInitials(employee);
|
||||
const position = employee.position?.name || "Сотрудник";
|
||||
const isCurrentUser = currentUser?.id === employee.id;
|
||||
const personnelState = employee.personnel_state;
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -223,9 +225,18 @@ export default function EmployeesPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="app-text-wrap text-sm font-semibold text-[var(--foreground)]">
|
||||
{fullName || "Сотрудник"}
|
||||
</p>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<p className="app-text-wrap min-w-0 text-sm font-semibold text-[var(--foreground)]">
|
||||
{fullName || "Сотрудник"}
|
||||
</p>
|
||||
{personnelState ? (
|
||||
<span
|
||||
className={`app-status-pill shrink-0 ${getEmployeeActionTone(personnelState.status).badgeClass}`}
|
||||
>
|
||||
{personnelState.label || personnelState.status}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="app-text-muted text-xs">{position}</p>
|
||||
{employee.email ? (
|
||||
<p className="app-text-wrap app-text-muted mt-1 text-xs">
|
||||
|
||||
@ -180,6 +180,7 @@ function ProcurementRequestActionButtons({
|
||||
request,
|
||||
busyKey,
|
||||
canManage,
|
||||
canSubmitForApproval,
|
||||
isAuthor,
|
||||
isExecutor,
|
||||
isFinal,
|
||||
@ -199,6 +200,7 @@ function ProcurementRequestActionButtons({
|
||||
request: ProcurementRequest;
|
||||
busyKey: string | null;
|
||||
canManage: boolean;
|
||||
canSubmitForApproval: boolean;
|
||||
isAuthor: boolean;
|
||||
isExecutor: boolean;
|
||||
isFinal: (status?: string) => boolean;
|
||||
@ -226,7 +228,6 @@ function ProcurementRequestActionButtons({
|
||||
);
|
||||
const startWorkLabel = isInProgress ? "Забрать в работу" : "Взять в работу";
|
||||
const canApproveThis = Boolean(request.can_current_user_approve);
|
||||
const canSubmitForApproval = Boolean(request.can_current_user_submit_for_approval);
|
||||
const buttonClass = (variantClass: string) =>
|
||||
`${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"
|
||||
@ -457,6 +458,35 @@ export default function ProcurementPage() {
|
||||
const processingDepartmentOptions = processingDepartments.length > 0
|
||||
? processingDepartments
|
||||
: departments;
|
||||
const userDepartmentIds = useMemo(
|
||||
() => new Set((user?.departments || []).map((department) => Number(department.id))),
|
||||
[user?.departments],
|
||||
);
|
||||
const userDepartmentsKnown = Array.isArray(user?.departments);
|
||||
const canCurrentUserSubmitRequest = useCallback(
|
||||
(request: ProcurementRequest) => {
|
||||
const processingDepartmentId = Number(request.processing_department || 0);
|
||||
const requestorId = resolveUserId(request.requestor);
|
||||
const isProcessingDepartmentMember = (
|
||||
!userDepartmentsKnown ||
|
||||
userDepartmentIds.has(processingDepartmentId)
|
||||
);
|
||||
|
||||
return Boolean(
|
||||
request.can_current_user_submit_for_approval &&
|
||||
user?.id &&
|
||||
processingDepartmentId &&
|
||||
requestorId !== user.id &&
|
||||
isProcessingDepartmentMember
|
||||
);
|
||||
},
|
||||
[
|
||||
resolveUserId,
|
||||
user?.id,
|
||||
userDepartmentIds,
|
||||
userDepartmentsKnown,
|
||||
],
|
||||
);
|
||||
|
||||
const toggleStatusFilter = useCallback((status: ProcurementStatus) => {
|
||||
setStatusFilter((current) => (
|
||||
@ -549,6 +579,18 @@ export default function ProcurementPage() {
|
||||
}, []);
|
||||
|
||||
const openSubmitApprovalDialog = useCallback(async (requestId: number) => {
|
||||
const requestForSubmit = detailsCache[requestId] || requests.find((request) => request.id === requestId);
|
||||
if (requestForSubmit && !canCurrentUserSubmitRequest(requestForSubmit)) {
|
||||
setSubmitApprovalRequestId(requestId);
|
||||
setSubmitApprovalOptions(null);
|
||||
setSubmitApprovalLoading(false);
|
||||
setSubmitApprovalMode("auto");
|
||||
setManualApprovalPriorities([]);
|
||||
setManualApproverByPriority({});
|
||||
setSubmitApprovalError("Отправить на согласование может только сотрудник отдела-получателя, не автор заявки.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitApprovalRequestId(requestId);
|
||||
setSubmitApprovalOptions(null);
|
||||
setSubmitApprovalError(null);
|
||||
@ -573,7 +615,11 @@ export default function ProcurementPage() {
|
||||
} finally {
|
||||
setSubmitApprovalLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [
|
||||
canCurrentUserSubmitRequest,
|
||||
detailsCache,
|
||||
requests,
|
||||
]);
|
||||
|
||||
const toggleManualApprovalPriority = useCallback((priority: number) => {
|
||||
setSubmitApprovalMode("manual");
|
||||
@ -1077,11 +1123,12 @@ export default function ProcurementPage() {
|
||||
const detail = detailsCache[req.id];
|
||||
const resolvedDetail = detail || req;
|
||||
const canApproveThis = Boolean((resolvedDetail.can_current_user_approve ?? req.can_current_user_approve));
|
||||
const canSubmitThis = canCurrentUserSubmitRequest(resolvedDetail);
|
||||
const canEditThis = Boolean(isEditableStatus && isAuthor);
|
||||
const canDeleteThis = Boolean(isEditableStatus && (isAuthor || canManage));
|
||||
const canCancelThis = Boolean(isAuthor && !isFinal(st) && st !== "draft");
|
||||
const hasInlineRequestActions = Boolean(
|
||||
(resolvedDetail.can_current_user_submit_for_approval ?? req.can_current_user_submit_for_approval) ||
|
||||
canSubmitThis ||
|
||||
canApproveThis ||
|
||||
(resolvedDetail.can_current_user_start_work ?? req.can_current_user_start_work) ||
|
||||
(st === "in_progress" && isExecutor)
|
||||
@ -1350,6 +1397,7 @@ export default function ProcurementPage() {
|
||||
request={resolvedDetail}
|
||||
busyKey={busyKey}
|
||||
canManage={canManage}
|
||||
canSubmitForApproval={canSubmitThis}
|
||||
isAuthor={isAuthor}
|
||||
isExecutor={isExecutor}
|
||||
isFinal={isFinal}
|
||||
@ -1437,6 +1485,7 @@ export default function ProcurementPage() {
|
||||
request={resolvedDetail}
|
||||
busyKey={busyKey}
|
||||
canManage={canManage}
|
||||
canSubmitForApproval={canSubmitThis}
|
||||
isAuthor={isAuthor}
|
||||
isExecutor={isExecutor}
|
||||
isFinal={isFinal}
|
||||
@ -1487,6 +1536,7 @@ export default function ProcurementPage() {
|
||||
request={selectedRequest}
|
||||
busyKey={busyKey}
|
||||
canManage={canManage}
|
||||
canSubmitForApproval={canCurrentUserSubmitRequest(selectedRequest)}
|
||||
isAuthor={Boolean(resolveUserId(selectedRequest.requestor) && user?.id && resolveUserId(selectedRequest.requestor) === user.id)}
|
||||
isExecutor={Boolean(resolveUserId(selectedRequest.executor) && user?.id && resolveUserId(selectedRequest.executor) === user.id)}
|
||||
isFinal={isFinal}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user