Add audited quick project assignment to procurement requests
This commit is contained in:
parent
b72ac5dd0d
commit
8be419c03b
@ -6,16 +6,19 @@ from .equipment import (
|
||||
)
|
||||
from .requests import (
|
||||
CanApproveProcurementRequest,
|
||||
CanAssignProcurementProjects,
|
||||
CanCreateProcurementRequest,
|
||||
CanEditOwnProcurementRequest,
|
||||
CanManageProcurementRequest,
|
||||
IsDepartmentHead,
|
||||
IsDirector,
|
||||
user_can_assign_procurement_projects,
|
||||
user_can_view_procurement_request,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CanApproveProcurementRequest",
|
||||
"CanAssignProcurementProjects",
|
||||
"CanCreateProcurementRequest",
|
||||
"CanEditOwnProcurementRequest",
|
||||
"CanManageEquipment",
|
||||
@ -26,4 +29,5 @@ __all__ = [
|
||||
"IsDirector",
|
||||
"IsResponsibleForEquipment",
|
||||
"user_can_view_procurement_request",
|
||||
"user_can_assign_procurement_projects",
|
||||
]
|
||||
|
||||
@ -22,6 +22,7 @@ def user_can_view_procurement_request(user, procurement_request) -> bool:
|
||||
"procurement.change_procurementrequest",
|
||||
"procurement.delete_procurementrequest",
|
||||
"procurement.execute_procurement",
|
||||
"procurement.assign_procurement_projects",
|
||||
)
|
||||
):
|
||||
return True
|
||||
@ -267,3 +268,30 @@ class CanManageProcurementRequest(permissions.BasePermission):
|
||||
|
||||
handler = self._action_handlers().get(action)
|
||||
return bool(handler and handler(user, obj))
|
||||
|
||||
|
||||
def user_can_assign_procurement_projects(user, procurement_request) -> bool:
|
||||
"""Independent project-only access, not blanket permission to edit a request."""
|
||||
if not (user and user.is_authenticated and user.is_active):
|
||||
return False
|
||||
if not user_can_view_procurement_request(user, procurement_request):
|
||||
return False
|
||||
return bool(
|
||||
user.is_staff
|
||||
or user.is_superuser
|
||||
or user.has_perm("procurement.assign_procurement_projects")
|
||||
or user.has_perm("procurement.execute_procurement")
|
||||
or procurement_request.executor_id == user.pk
|
||||
)
|
||||
|
||||
|
||||
class CanAssignProcurementProjects(permissions.BasePermission):
|
||||
"""Authorize just the project association action."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return bool(
|
||||
request.user and request.user.is_authenticated and request.user.is_active
|
||||
)
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
return user_can_assign_procurement_projects(request.user, obj)
|
||||
|
||||
@ -17,6 +17,7 @@ from .items import (
|
||||
ProcurementItemSerializer,
|
||||
)
|
||||
from .maintenance import MaintenanceRecordSerializer
|
||||
from .request_write import ProcurementProjectsSerializer
|
||||
from .requests import (
|
||||
ProcurementRequestCreateSerializer,
|
||||
ProcurementRequestDetailSerializer,
|
||||
@ -50,6 +51,7 @@ __all__ = [
|
||||
"ProcurementRequestActivitySerializer",
|
||||
"ProcurementRequestCreateOptionsSerializer",
|
||||
"ProcurementRequestCreateSerializer",
|
||||
"ProcurementProjectsSerializer",
|
||||
"ProcurementRequestDetailSerializer",
|
||||
"ProcurementRequestListSerializer",
|
||||
"ProcurementRequestPermissionFieldsMixin",
|
||||
|
||||
@ -7,6 +7,7 @@ from procurement.constants import ProcurementItemExecutionStatus
|
||||
from procurement.models import ProcurementRequest, ProcurementRequestView
|
||||
from procurement.services import ProcurementApprovalResolver
|
||||
|
||||
from ..permission_classes.requests import user_can_assign_procurement_projects
|
||||
from .common import (
|
||||
PROBLEM_ITEM_STATUSES,
|
||||
_effective_ordered_quantity,
|
||||
@ -22,6 +23,7 @@ class ProcurementRequestSummaryMixin(serializers.Serializer):
|
||||
is_viewed = serializers.SerializerMethodField()
|
||||
is_deferred = serializers.SerializerMethodField()
|
||||
project_names = serializers.SerializerMethodField()
|
||||
can_assign_projects = serializers.SerializerMethodField()
|
||||
last_arrival_notice_at = serializers.DateTimeField(
|
||||
read_only=True,
|
||||
allow_null=True,
|
||||
@ -79,6 +81,12 @@ class ProcurementRequestSummaryMixin(serializers.Serializer):
|
||||
def get_project_names(self, obj):
|
||||
return [project.name for project in obj.projects.all()]
|
||||
|
||||
@extend_schema_field(serializers.BooleanField())
|
||||
def get_can_assign_projects(self, obj):
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
return user_can_assign_procurement_projects(user, obj)
|
||||
|
||||
@extend_schema_field(serializers.DateField(allow_null=True))
|
||||
def get_next_expected_delivery_date(self, obj):
|
||||
items = _request_items(obj)
|
||||
|
||||
@ -81,6 +81,7 @@ class ProcurementRequestListSerializer(
|
||||
"request_type",
|
||||
"projects",
|
||||
"project_names",
|
||||
"can_assign_projects",
|
||||
"title",
|
||||
"description",
|
||||
"department",
|
||||
@ -273,6 +274,7 @@ class ProcurementRequestDetailSerializer(
|
||||
"request_type",
|
||||
"projects",
|
||||
"project_names",
|
||||
"can_assign_projects",
|
||||
"title",
|
||||
"description",
|
||||
"department",
|
||||
|
||||
@ -17,6 +17,21 @@ from .items import (
|
||||
)
|
||||
|
||||
|
||||
class ProcurementProjectsSerializer(serializers.Serializer):
|
||||
"""Only project IDs, never general request fields."""
|
||||
|
||||
projects = serializers.PrimaryKeyRelatedField(
|
||||
many=True,
|
||||
queryset=Project.objects.all(),
|
||||
allow_empty=True,
|
||||
)
|
||||
|
||||
def validate_projects(self, projects):
|
||||
if len(projects) > 50:
|
||||
raise serializers.ValidationError("Не более 50 проектов в одной заявке.")
|
||||
return projects
|
||||
|
||||
|
||||
class ProcurementRequestCreateSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор для создания заявки с позициями."""
|
||||
|
||||
|
||||
@ -141,6 +141,7 @@ class ProcurementRequestQueryMixin:
|
||||
or user.has_perm("procurement.change_procurementrequest")
|
||||
or user.has_perm("procurement.delete_procurementrequest")
|
||||
or user.has_perm("procurement.execute_procurement")
|
||||
or user.has_perm("procurement.assign_procurement_projects")
|
||||
)
|
||||
|
||||
def _available_queryset(self, queryset, user, department_ids):
|
||||
@ -157,7 +158,8 @@ class ProcurementRequestQueryMixin:
|
||||
@staticmethod
|
||||
def _default_visible_queryset(queryset, user, department_ids):
|
||||
return queryset.filter(
|
||||
Q(requestor=user)
|
||||
Q(executor=user)
|
||||
| Q(requestor=user)
|
||||
| Q(department_id__in=department_ids)
|
||||
| Q(processing_department_id__in=department_ids)
|
||||
| Q(approvals__approver=user)
|
||||
@ -253,7 +255,9 @@ class ProcurementRequestQueryMixin:
|
||||
"created_at__date__lte",
|
||||
)
|
||||
project_id = query_params.get("project")
|
||||
if project_id and project_id.isdigit():
|
||||
if project_id == "unassigned":
|
||||
queryset = queryset.filter(projects__isnull=True)
|
||||
elif project_id and project_id.isdigit():
|
||||
queryset = queryset.filter(projects__id=int(project_id))
|
||||
if query_params.get("deferred") in {"true", "1", "yes"}:
|
||||
queryset = queryset.filter(is_deferred_for_current_user=True)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"""ViewSet composition for procurement requests."""
|
||||
|
||||
from django.db import transaction
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import filters, permissions, viewsets
|
||||
from rest_framework.decorators import action
|
||||
@ -11,12 +12,15 @@ from procurement.models import (
|
||||
ProcurementActivityObjectKind,
|
||||
ProcurementRequest,
|
||||
)
|
||||
from projects.models import Project
|
||||
|
||||
from ..permissions import (
|
||||
CanApproveProcurementRequest,
|
||||
CanAssignProcurementProjects,
|
||||
CanManageProcurementRequest,
|
||||
)
|
||||
from ..serializers import (
|
||||
ProcurementProjectsSerializer,
|
||||
ProcurementRequestActivitySerializer,
|
||||
ProcurementRequestCreateSerializer,
|
||||
ProcurementRequestDetailSerializer,
|
||||
@ -86,6 +90,7 @@ class ProcurementRequestViewSet(
|
||||
"set_deferred",
|
||||
"approval_options",
|
||||
"activity",
|
||||
"projects",
|
||||
}:
|
||||
return ProcurementRequestDetailSerializer
|
||||
return ProcurementRequestListSerializer
|
||||
@ -100,6 +105,52 @@ class ProcurementRequestViewSet(
|
||||
serializer = ProcurementRequestActivitySerializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="project-options")
|
||||
def project_options(self, request, pk=None):
|
||||
"""Compact company project directory for authorized purchasing staff."""
|
||||
self.get_object()
|
||||
return Response(
|
||||
list(Project.objects.order_by("name", "pk").values("id", "name"))
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["patch"], url_path="projects")
|
||||
def projects(self, request, pk=None):
|
||||
"""Change only project associations; usable after approval or completion."""
|
||||
payload = ProcurementProjectsSerializer(
|
||||
data=request.data, context={"request": request}
|
||||
)
|
||||
payload.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
visible_request = self.get_object()
|
||||
procurement_request = ProcurementRequest.objects.select_for_update().get(
|
||||
pk=visible_request.pk
|
||||
)
|
||||
old_projects = list(
|
||||
procurement_request.projects.order_by("id").values("id", "name")
|
||||
)
|
||||
selected = payload.validated_data["projects"]
|
||||
next_ids = {project.pk for project in selected}
|
||||
if {project["id"] for project in old_projects} != next_ids:
|
||||
procurement_request.projects.set(selected)
|
||||
procurement_request.save(update_fields=["updated_at"])
|
||||
new_projects = list(
|
||||
procurement_request.projects.order_by("id").values("id", "name")
|
||||
)
|
||||
create_procurement_activity(
|
||||
procurement_request,
|
||||
request.user,
|
||||
ProcurementActivityAction.UPDATED,
|
||||
object_kind=ProcurementActivityObjectKind.REQUEST,
|
||||
object_id=procurement_request.pk,
|
||||
metadata={
|
||||
"fields": ["projects"],
|
||||
"previous_projects": old_projects,
|
||||
"new_projects": new_projects,
|
||||
},
|
||||
)
|
||||
result = self.get_serializer(procurement_request)
|
||||
return Response(result.data)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
tracked_fields = [
|
||||
"title",
|
||||
@ -112,26 +163,42 @@ class ProcurementRequestViewSet(
|
||||
before = {
|
||||
field: getattr(serializer.instance, field) for field in tracked_fields
|
||||
}
|
||||
old_projects = list(
|
||||
serializer.instance.projects.order_by("id").values("id", "name")
|
||||
)
|
||||
procurement_request = serializer.save()
|
||||
new_projects = list(
|
||||
procurement_request.projects.order_by("id").values("id", "name")
|
||||
)
|
||||
changed_fields = [
|
||||
field
|
||||
for field in tracked_fields
|
||||
if before[field] != getattr(procurement_request, field)
|
||||
]
|
||||
if old_projects != new_projects:
|
||||
changed_fields.append("projects")
|
||||
if changed_fields:
|
||||
metadata = {"fields": changed_fields}
|
||||
if old_projects != new_projects:
|
||||
metadata.update(
|
||||
previous_projects=old_projects,
|
||||
new_projects=new_projects,
|
||||
)
|
||||
create_procurement_activity(
|
||||
procurement_request,
|
||||
self.request.user,
|
||||
ProcurementActivityAction.UPDATED,
|
||||
object_kind=ProcurementActivityObjectKind.REQUEST,
|
||||
object_id=procurement_request.pk,
|
||||
metadata={"fields": changed_fields},
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def get_permissions(self):
|
||||
"""Выбрать права доступа в зависимости от действия."""
|
||||
if self.action in {"approve", "reject"}:
|
||||
permission_classes = [CanApproveProcurementRequest]
|
||||
elif self.action in {"projects", "project_options"}:
|
||||
permission_classes = [CanAssignProcurementProjects]
|
||||
elif self.action in {
|
||||
"submit",
|
||||
"set_viewed",
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.2.4 on 2026-11-10 15:23
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("procurement", "0022_procurement_projects_deferred_flag"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="procurementrequest",
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
"permissions": [
|
||||
("approve_procurementrequest", "Can approve procurement requests"),
|
||||
("view_all_requests", "Can view all department requests"),
|
||||
("execute_procurement", "Can execute approved requests"),
|
||||
(
|
||||
"assign_procurement_projects",
|
||||
"Can assign projects to procurement requests",
|
||||
),
|
||||
],
|
||||
"verbose_name": "Заявка на закупку",
|
||||
"verbose_name_plural": "Заявки на закупку",
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -259,6 +259,10 @@ class ProcurementRequest(models.Model):
|
||||
("approve_procurementrequest", "Can approve procurement requests"),
|
||||
("view_all_requests", "Can view all department requests"),
|
||||
("execute_procurement", "Can execute approved requests"),
|
||||
(
|
||||
"assign_procurement_projects",
|
||||
"Can assign projects to procurement requests",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
|
||||
196
backend/tests/api/v1/procurement/test_quick_request_projects.py
Normal file
196
backend/tests/api/v1/procurement/test_quick_request_projects.py
Normal file
@ -0,0 +1,196 @@
|
||||
"""Quick project reassignment without granting unrelated procurement mutations."""
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from employees.models import Department, Employee, EmployeeDepartment
|
||||
from procurement.models import (
|
||||
ProcurementActivityAction,
|
||||
ProcurementRequest,
|
||||
ProcurementRequestActivity,
|
||||
)
|
||||
from projects.models import Project
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def employee(number, *, staff=False):
|
||||
return Employee.objects.create_user(
|
||||
email=f"project-assignment-{number}@example.org",
|
||||
password="testpass123",
|
||||
phone_number=f"+7999888{number:04d}",
|
||||
first_name="Тест",
|
||||
last_name="Проект",
|
||||
is_active=True,
|
||||
is_staff=staff,
|
||||
email_verified=True,
|
||||
send_activation_email=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup():
|
||||
requester = employee(1)
|
||||
buyer = employee(2)
|
||||
outsider = employee(3)
|
||||
staff = employee(4, staff=True)
|
||||
department = Department.objects.create(name="Закупки тест")
|
||||
EmployeeDepartment.objects.create(
|
||||
employee=requester, department=department, is_active=True
|
||||
)
|
||||
project_a = Project.objects.create(
|
||||
name="Проект А", manager=requester, created_by=requester
|
||||
)
|
||||
project_b = Project.objects.create(
|
||||
name="Проект Б", manager=requester, created_by=requester
|
||||
)
|
||||
hidden = Project.objects.create(
|
||||
name="Секретный проект", manager=outsider, created_by=outsider
|
||||
)
|
||||
req = ProcurementRequest.objects.create(
|
||||
title="Комплектующие",
|
||||
department=department,
|
||||
requestor=requester,
|
||||
executor=buyer,
|
||||
status="completed",
|
||||
description="Закупка",
|
||||
)
|
||||
return requester, buyer, outsider, staff, project_a, project_b, hidden, req
|
||||
|
||||
|
||||
def endpoint(req):
|
||||
return reverse("api:v1:procurement:procurementrequest-projects", args=[req.pk])
|
||||
|
||||
|
||||
def client(user=None):
|
||||
api = APIClient()
|
||||
if user is not None:
|
||||
api.force_authenticate(user=user)
|
||||
return api
|
||||
|
||||
|
||||
def test_assigned_executor_may_fix_completed_request_and_audit_changes(setup):
|
||||
_, buyer, _, _, project_a, project_b, _, req = setup
|
||||
# The authorized buyer can use the compact project directory.
|
||||
options = client(buyer).get(
|
||||
reverse("api:v1:procurement:procurementrequest-project-options", args=[req.id])
|
||||
)
|
||||
assert options.status_code == 200
|
||||
assert {value["id"] for value in options.data} >= {project_a.pk, project_b.pk}
|
||||
response = client(buyer).patch(
|
||||
endpoint(req), {"projects": [project_a.id, project_b.id]}, format="json"
|
||||
)
|
||||
assert response.status_code == 200, response.data
|
||||
assert response.data["can_assign_projects"] is True
|
||||
assert sorted(response.data["projects"]) == sorted([project_a.pk, project_b.pk])
|
||||
req.refresh_from_db()
|
||||
assert req.status == "completed"
|
||||
changes = ProcurementRequestActivity.objects.filter(
|
||||
request=req, action=ProcurementActivityAction.UPDATED
|
||||
)
|
||||
assert changes.count() == 1
|
||||
entry = changes.get()
|
||||
assert entry.actor == buyer
|
||||
assert entry.metadata["fields"] == ["projects"]
|
||||
assert entry.metadata["previous_projects"] == []
|
||||
assert [value["name"] for value in entry.metadata["new_projects"]] == [
|
||||
"Проект А",
|
||||
"Проект Б",
|
||||
]
|
||||
|
||||
again = client(buyer).patch(
|
||||
endpoint(req), {"projects": [project_b.pk, project_a.pk]}, format="json"
|
||||
)
|
||||
assert again.status_code == 200
|
||||
assert changes.count() == 1
|
||||
|
||||
cleared = client(buyer).patch(endpoint(req), {"projects": []}, format="json")
|
||||
assert cleared.status_code == 200
|
||||
assert list(req.projects.all()) == []
|
||||
assert changes.count() == 2
|
||||
|
||||
|
||||
def test_ordinary_requester_cannot_assign_even_to_own_request(setup):
|
||||
requester, _, _, _, project_a, _, _, req = setup
|
||||
response = client(requester).patch(
|
||||
endpoint(req), {"projects": [project_a.pk]}, format="json"
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert req.projects.count() == 0
|
||||
assert (
|
||||
client(requester)
|
||||
.get(reverse("api:v1:procurement:procurementrequest-detail", args=[req.pk]))
|
||||
.data["can_assign_projects"]
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_dedicated_permission_allows_project_edit_but_not_other_edits(setup):
|
||||
requester, _, outsider, _, project_a, _, _, req = setup
|
||||
requester.user_permissions.add(
|
||||
Permission.objects.get(codename="assign_procurement_projects")
|
||||
)
|
||||
requester = Employee.objects.get(pk=requester.pk)
|
||||
success = client(requester).patch(
|
||||
endpoint(req), {"projects": [project_a.pk], "title": "Подмена"}, format="json"
|
||||
)
|
||||
assert success.status_code == 200, success.data
|
||||
req.refresh_from_db()
|
||||
assert req.title == "Комплектующие"
|
||||
assert list(req.projects.values_list("id", flat=True)) == [project_a.pk]
|
||||
forbidden = client(requester).patch(
|
||||
reverse("api:v1:procurement:procurementrequest-detail", args=[req.pk]),
|
||||
{"title": "Подмена"},
|
||||
format="json",
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
assert client(outsider).patch(
|
||||
endpoint(req), {"projects": []}, format="json"
|
||||
).status_code in (403, 404)
|
||||
|
||||
|
||||
def test_staff_cannot_assign_nonexistent_or_hidden_projects_without_error_leak(setup):
|
||||
requester, _, _, staff, project_a, _, hidden, req = setup
|
||||
assert client().patch(
|
||||
endpoint(req), {"projects": []}, format="json"
|
||||
).status_code in (401, 403)
|
||||
assert (
|
||||
client(staff)
|
||||
.patch(endpoint(req), {"projects": [999999]}, format="json")
|
||||
.status_code
|
||||
== 400
|
||||
)
|
||||
assert (
|
||||
client(staff)
|
||||
.patch(endpoint(req), {"projects": "not-a-list"}, format="json")
|
||||
.status_code
|
||||
== 400
|
||||
)
|
||||
req.projects.set([project_a])
|
||||
# An unauthorized project ID must not become a side-channel for requester.
|
||||
assert (
|
||||
client(requester)
|
||||
.patch(endpoint(req), {"projects": [hidden.pk]}, format="json")
|
||||
.status_code
|
||||
== 403
|
||||
)
|
||||
|
||||
|
||||
def test_unassigned_filter_matches_only_requests_without_projects(setup):
|
||||
requester, _, _, _, project_a, _, _, assigned_request = setup
|
||||
assigned_request.projects.add(project_a)
|
||||
unassigned = ProcurementRequest.objects.create(
|
||||
title="Заявка без проекта",
|
||||
department=assigned_request.department,
|
||||
requestor=requester,
|
||||
)
|
||||
response = client(requester).get(
|
||||
reverse("api:v1:procurement:procurementrequest-list"),
|
||||
{"project": "unassigned"},
|
||||
)
|
||||
assert response.status_code == 200, response.data
|
||||
rows = response.data["results"]
|
||||
assert unassigned.id in {row["id"] for row in rows}
|
||||
assert assigned_request.id not in {row["id"] for row in rows}
|
||||
@ -298,3 +298,33 @@ company scope with their content preserved. Authors use protected foreign
|
||||
keys and do not vanish when their account is deactivated. Company/board
|
||||
templates survive staff departure. Pending proposals remain snapshots even
|
||||
if the original personal template changes or is archived.
|
||||
|
||||
## Quick project assignment for procurement requests
|
||||
|
||||
A procurement request may reference zero, one, or several existing company
|
||||
projects. For buying staff, the API provides a **project-only action** independent
|
||||
from editing the whole request:
|
||||
|
||||
- `GET /api/v1/procurement/requests/{id}/project-options/` — compact
|
||||
`[{"id":1,"name":"Project"}]` directory of existing projects, available
|
||||
only to users allowed to assign projects on this request.
|
||||
- `PATCH /api/v1/procurement/requests/{id}/projects/` with
|
||||
`{"projects":[1,2]}` — replace project associations, including on
|
||||
approved/completed requests; `{"projects":[]}` clears them. Other request
|
||||
fields are not mutated by this endpoint. Repeating the same associations
|
||||
is idempotent and does not create duplicate history entries.
|
||||
- `GET /api/v1/procurement/requests/?project=unassigned` — find requests
|
||||
with no project. This works together with scope, status and period filters.
|
||||
|
||||
Access is granted to staff/superusers, users with the explicit Django model
|
||||
permission `procurement.assign_procurement_projects`, users with the
|
||||
existing `procurement.execute_procurement` permission, or the current request
|
||||
executor. Regular applicants do not acquire unrelated editing permissions.
|
||||
|
||||
Assignment audit records actor, time, previous project IDs/names and new
|
||||
project IDs/names in `ProcurementRequestActivity`. Edits through the normal
|
||||
request PATCH endpoint also record project-association changes. The frontend
|
||||
presents this before/after information in the request history.
|
||||
|
||||
The create/edit form shows a soft reminder when a project has not yet been
|
||||
selected, but still allows saving a request without it.
|
||||
|
||||
@ -15,6 +15,7 @@ import { ProjectSelectMenu } from "@/components/ui/ProjectSelectMenu";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import Link from "next/link";
|
||||
import { ProcurementRequestDetailContent } from "@/components/procurement/ProcurementRequestDetailContent";
|
||||
import ProcurementProjectsQuickEditor from "@/components/procurement/ProcurementProjectsQuickEditor";
|
||||
import { ProcurementTaskLinks } from "@/components/procurement/ProcurementTaskLinks";
|
||||
import ProcurementStatsPanel from "@/components/procurement/ProcurementStatsPanel";
|
||||
import ProcurementSuppliersPanel from "@/components/procurement/ProcurementSuppliersPanel";
|
||||
@ -582,6 +583,7 @@ export default function ProcurementPage() {
|
||||
isFinal,
|
||||
isModalOpen,
|
||||
loadPage1,
|
||||
refreshOne,
|
||||
loading,
|
||||
loadingMore,
|
||||
modalMode,
|
||||
@ -1474,6 +1476,7 @@ export default function ProcurementPage() {
|
||||
ariaLabel="Проект"
|
||||
options={[
|
||||
{ value: "", label: "Все проекты" },
|
||||
{ value: "unassigned", label: "Без проекта" },
|
||||
...projects.map((project) => ({
|
||||
value: String(project.id),
|
||||
label: project.name,
|
||||
@ -1727,11 +1730,13 @@ export default function ProcurementPage() {
|
||||
{getDeptName(req)}
|
||||
</span>
|
||||
</span>
|
||||
{(req.project_names || []).length > 0 ? (
|
||||
<span className="app-text-muted mt-1 block truncate text-[11px]">
|
||||
Проект: {req.project_names?.join(", ")}
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={`mt-1 block truncate text-[11px] ${(req.project_names || []).length > 0 ? "app-text-muted" : "text-amber-600 dark:text-amber-400"}`}
|
||||
>
|
||||
{(req.project_names || []).length > 0
|
||||
? `Проект: ${req.project_names?.join(", ")}`
|
||||
: "Без проекта"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 flex-col items-end gap-1">
|
||||
<span
|
||||
@ -1968,14 +1973,6 @@ export default function ProcurementPage() {
|
||||
>
|
||||
{getStatusMeta(selectedRequest.status).label}
|
||||
</span>
|
||||
{(selectedRequest.project_names || []).map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="app-badge inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
<span
|
||||
className={`shrink-0 text-[11px] font-medium ${urgencyMeta[selectedRequest.urgency]?.cls ?? urgencyMeta.medium.cls}`}
|
||||
>
|
||||
@ -2030,6 +2027,18 @@ export default function ProcurementPage() {
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<ProcurementProjectsQuickEditor
|
||||
key={selectedRequest.id}
|
||||
request={selectedRequest}
|
||||
onSaved={async () => {
|
||||
await Promise.all([
|
||||
refreshOne(selectedRequest.id),
|
||||
loadPage1(),
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="app-text-wrap mt-2 text-sm text-[var(--foreground)]">
|
||||
<span className="app-text-muted">Описание: </span>
|
||||
{selectedRequest.description || "—"}
|
||||
@ -3811,6 +3820,12 @@ export default function ProcurementPage() {
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{form.projects.length === 0 ? (
|
||||
<p className="app-text-muted text-xs">
|
||||
Проект пока не выбран. Заявку можно сохранить без него, но для
|
||||
точного учёта закупки проект желательно указать.
|
||||
</p>
|
||||
) : null}
|
||||
{canCreateProjects ? (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
|
||||
@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FolderKanban, Loader2, Pencil } from "lucide-react";
|
||||
|
||||
import { SearchableSelectMulti } from "@/components/shared/SearchableSelect";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type { ProcurementRequest } from "@/types/api";
|
||||
|
||||
type ProjectOption = { id: number; name: string };
|
||||
|
||||
type Props = {
|
||||
request: ProcurementRequest;
|
||||
onSaved: () => Promise<void>;
|
||||
};
|
||||
|
||||
export default function ProcurementProjectsQuickEditor({
|
||||
request,
|
||||
onSaved,
|
||||
}: Props) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [options, setOptions] = useState<ProjectOption[]>([]);
|
||||
const [selected, setSelected] = useState<number[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const openEditor = async () => {
|
||||
if (loading || busy) return;
|
||||
setEditing(true);
|
||||
setSelected(request.projects || []);
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
const available = await apiClient.getProcurementRequestProjectOptions(
|
||||
request.id,
|
||||
);
|
||||
setOptions(available);
|
||||
} catch (cause) {
|
||||
setError(
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: "Не удалось загрузить справочник проектов.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (busy || loading) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await apiClient.setProcurementRequestProjects(request.id, selected);
|
||||
await onSaved();
|
||||
setEditing(false);
|
||||
} catch (cause) {
|
||||
setError(
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: "Не удалось сохранить проекты закупки.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentIds = [...(request.projects || [])].sort((a, b) => a - b);
|
||||
const nextIds = [...selected].sort((a, b) => a - b);
|
||||
const unchanged =
|
||||
currentIds.length === nextIds.length &&
|
||||
currentIds.every((id, index) => id === nextIds[index]);
|
||||
const names = request.project_names || [];
|
||||
|
||||
return (
|
||||
<section className="space-y-2 rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2 text-sm font-semibold text-[var(--foreground)]">
|
||||
<FolderKanban className="app-text-muted" size={16} />
|
||||
Проекты закупки
|
||||
</span>
|
||||
{request.can_assign_projects ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (editing) {
|
||||
setEditing(false);
|
||||
setError("");
|
||||
} else {
|
||||
void openEditor();
|
||||
}
|
||||
}}
|
||||
disabled={busy}
|
||||
className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs disabled:opacity-50"
|
||||
aria-expanded={editing}
|
||||
>
|
||||
{editing ? null : <Pencil size={13} />}
|
||||
{editing ? "Закрыть" : "Изменить"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{!editing ? (
|
||||
names.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{names.map((name, index) => (
|
||||
<span
|
||||
key={`${request.projects?.[index] ?? index}-${name}`}
|
||||
className="app-badge inline-flex rounded-lg px-2.5 py-1 text-xs"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="app-text-muted text-xs">
|
||||
Проект не указан. Его можно назначить для корректного учёта закупки.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{loading ? (
|
||||
<p className="app-text-muted flex items-center gap-2 text-xs">
|
||||
<Loader2 size={15} className="animate-spin" /> Загрузка
|
||||
проектов...
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<SearchableSelectMulti
|
||||
label="Выберите проекты"
|
||||
placeholder="Поиск и выбор проектов"
|
||||
items={options}
|
||||
selectedIds={selected}
|
||||
onToggle={(id) => {
|
||||
if (busy) return;
|
||||
setSelected((current) =>
|
||||
current.includes(id)
|
||||
? current.filter((value) => value !== id)
|
||||
: [...current, id],
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<p className="app-text-muted text-xs">
|
||||
Можно выбрать несколько проектов или очистить список.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void save()}
|
||||
disabled={busy || unchanged || selected.length > 50}
|
||||
className="app-action-primary inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : null}
|
||||
Сохранить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setError("");
|
||||
}}
|
||||
className="app-action-secondary rounded-lg px-3 py-2 text-xs"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error ? (
|
||||
<p className="text-xs text-red-500" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -34,6 +34,7 @@ const fieldLabels: Record<string, string> = {
|
||||
actual_unit_price: "фактическую цену",
|
||||
executor_comment: "комментарий исполнителя",
|
||||
links: "ссылки",
|
||||
projects: "проекты закупки",
|
||||
};
|
||||
|
||||
function textMetadata(metadata: Record<string, unknown>, key: string): string {
|
||||
@ -49,6 +50,31 @@ function getActivityDetail(activity: ProcurementRequestActivity): string {
|
||||
);
|
||||
const itemName = textMetadata(metadata, "item_name");
|
||||
const prefix = itemName ? `${itemName}. ` : "";
|
||||
if (fields.includes("projects")) {
|
||||
const previous = Array.isArray(metadata.previous_projects)
|
||||
? metadata.previous_projects
|
||||
.map((item) =>
|
||||
item && typeof item === "object" && "name" in item
|
||||
? String(item.name)
|
||||
: "",
|
||||
)
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const next = Array.isArray(metadata.new_projects)
|
||||
? metadata.new_projects
|
||||
.map((item) =>
|
||||
item && typeof item === "object" && "name" in item
|
||||
? String(item.name)
|
||||
: "",
|
||||
)
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const summary = `Проекты: ${previous.join(", ") || "не указаны"} → ${next.join(", ") || "не указаны"}`;
|
||||
return [
|
||||
summary,
|
||||
...labels.filter((label) => label !== "проекты закупки"),
|
||||
].join(". ");
|
||||
}
|
||||
return labels.length > 0
|
||||
? `${prefix}Изменено: ${labels.join(", ")}`
|
||||
: itemName;
|
||||
|
||||
@ -39,6 +39,16 @@ export function createProcurementApi(
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
getProcurementRequestProjectOptions: (
|
||||
id: number,
|
||||
): Promise<{ id: number; name: string }[]> =>
|
||||
request(`/api/v1/procurement/requests/${id}/project-options/`),
|
||||
setProcurementRequestProjects: (id: number, projects: number[]) =>
|
||||
request(`/api/v1/procurement/requests/${id}/projects/`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ projects }),
|
||||
}),
|
||||
updateProcurementRequest: (id: number, data: Record<string, any>) =>
|
||||
request(`/api/v1/procurement/requests/${id}/`, {
|
||||
method: "PATCH",
|
||||
|
||||
@ -1659,6 +1659,7 @@ export interface ProcurementRequest {
|
||||
request_type?: "standard" | "subscription";
|
||||
projects?: number[];
|
||||
project_names?: string[];
|
||||
can_assign_projects?: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
department: number;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user