diff --git a/backend/api/v1/employees/serializers/employee.py b/backend/api/v1/employees/serializers/employee.py index 2b42e093..d3feac7d 100644 --- a/backend/api/v1/employees/serializers/employee.py +++ b/backend/api/v1/employees/serializers/employee.py @@ -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) diff --git a/backend/api/v1/procurement/permissions.py b/backend/api/v1/procurement/permissions.py index d2a4e915..d6cb2fd3 100644 --- a/backend/api/v1/procurement/permissions.py +++ b/backend/api/v1/procurement/permissions.py @@ -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": diff --git a/backend/procurement/services.py b/backend/procurement/services.py index 8e677c4e..f3dc5138 100644 --- a/backend/procurement/services.py +++ b/backend/procurement/services.py @@ -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 diff --git a/backend/tests/api/v1/employees/test_employees_fields_in_list.py b/backend/tests/api/v1/employees/test_employees_fields_in_list.py index f2ca4e50..2594f527 100644 --- a/backend/tests/api/v1/employees/test_employees_fields_in_list.py +++ b/backend/tests/api/v1/employees/test_employees_fields_in_list.py @@ -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 diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index e5c534ac..e1875cc1 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -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 diff --git a/frontend/src/app/employees/page.tsx b/frontend/src/app/employees/page.tsx index 41604943..2d724a0c 100644 --- a/frontend/src/app/employees/page.tsx +++ b/frontend/src/app/employees/page.tsx @@ -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 (
- {fullName || "Сотрудник"} -
++ {fullName || "Сотрудник"} +
+ {personnelState ? ( + + {personnelState.label || personnelState.status} + + ) : null} +{position}
{employee.email ? (diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index ae499479..1df45703 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -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}