From 8be419c03b7af6d40d2315008b8d42d0a4c331eb Mon Sep 17 00:00:00 2001 From: lizerk Date: Tue, 10 Nov 2026 18:38:07 +0300 Subject: [PATCH] Add audited quick project assignment to procurement requests --- .../permission_classes/__init__.py | 4 + .../permission_classes/requests.py | 28 +++ .../v1/procurement/serializers/__init__.py | 2 + .../procurement/serializers/request_mixins.py | 8 + .../procurement/serializers/request_read.py | 2 + .../procurement/serializers/request_write.py | 15 ++ .../viewsets/mixins/request_query.py | 8 +- .../api/v1/procurement/viewsets/requests.py | 69 +++++- .../0023_alter_procurementrequest_options.py | 29 +++ backend/procurement/models.py | 4 + .../test_quick_request_projects.py | 196 ++++++++++++++++++ docs/guides/API.md | 30 +++ frontend/src/app/procurement/page.tsx | 41 ++-- .../ProcurementProjectsQuickEditor.tsx | 179 ++++++++++++++++ .../procurement/ProcurementRequestHistory.tsx | 26 +++ frontend/src/lib/api/procurement.ts | 10 + frontend/src/types/api.ts | 1 + 17 files changed, 636 insertions(+), 16 deletions(-) create mode 100644 backend/procurement/migrations/0023_alter_procurementrequest_options.py create mode 100644 backend/tests/api/v1/procurement/test_quick_request_projects.py create mode 100644 frontend/src/components/procurement/ProcurementProjectsQuickEditor.tsx diff --git a/backend/api/v1/procurement/permission_classes/__init__.py b/backend/api/v1/procurement/permission_classes/__init__.py index 22f8027e..d028a1c5 100644 --- a/backend/api/v1/procurement/permission_classes/__init__.py +++ b/backend/api/v1/procurement/permission_classes/__init__.py @@ -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", ] diff --git a/backend/api/v1/procurement/permission_classes/requests.py b/backend/api/v1/procurement/permission_classes/requests.py index 99d5382b..add08d30 100644 --- a/backend/api/v1/procurement/permission_classes/requests.py +++ b/backend/api/v1/procurement/permission_classes/requests.py @@ -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) diff --git a/backend/api/v1/procurement/serializers/__init__.py b/backend/api/v1/procurement/serializers/__init__.py index f870bb9d..b9920ce9 100644 --- a/backend/api/v1/procurement/serializers/__init__.py +++ b/backend/api/v1/procurement/serializers/__init__.py @@ -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", diff --git a/backend/api/v1/procurement/serializers/request_mixins.py b/backend/api/v1/procurement/serializers/request_mixins.py index e44c14f0..0ba6571b 100644 --- a/backend/api/v1/procurement/serializers/request_mixins.py +++ b/backend/api/v1/procurement/serializers/request_mixins.py @@ -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) diff --git a/backend/api/v1/procurement/serializers/request_read.py b/backend/api/v1/procurement/serializers/request_read.py index e2f2d47a..a67330eb 100644 --- a/backend/api/v1/procurement/serializers/request_read.py +++ b/backend/api/v1/procurement/serializers/request_read.py @@ -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", diff --git a/backend/api/v1/procurement/serializers/request_write.py b/backend/api/v1/procurement/serializers/request_write.py index 75f0a2fa..4e0dc2f6 100644 --- a/backend/api/v1/procurement/serializers/request_write.py +++ b/backend/api/v1/procurement/serializers/request_write.py @@ -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): """Сериализатор для создания заявки с позициями.""" diff --git a/backend/api/v1/procurement/viewsets/mixins/request_query.py b/backend/api/v1/procurement/viewsets/mixins/request_query.py index 395c566e..5fb9601e 100644 --- a/backend/api/v1/procurement/viewsets/mixins/request_query.py +++ b/backend/api/v1/procurement/viewsets/mixins/request_query.py @@ -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) diff --git a/backend/api/v1/procurement/viewsets/requests.py b/backend/api/v1/procurement/viewsets/requests.py index 6066acfe..8b81208d 100644 --- a/backend/api/v1/procurement/viewsets/requests.py +++ b/backend/api/v1/procurement/viewsets/requests.py @@ -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", diff --git a/backend/procurement/migrations/0023_alter_procurementrequest_options.py b/backend/procurement/migrations/0023_alter_procurementrequest_options.py new file mode 100644 index 00000000..ea114ea3 --- /dev/null +++ b/backend/procurement/migrations/0023_alter_procurementrequest_options.py @@ -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": "Заявки на закупку", + }, + ), + ] diff --git a/backend/procurement/models.py b/backend/procurement/models.py index ca9a7632..92ff32ca 100644 --- a/backend/procurement/models.py +++ b/backend/procurement/models.py @@ -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): diff --git a/backend/tests/api/v1/procurement/test_quick_request_projects.py b/backend/tests/api/v1/procurement/test_quick_request_projects.py new file mode 100644 index 00000000..a9d421f3 --- /dev/null +++ b/backend/tests/api/v1/procurement/test_quick_request_projects.py @@ -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} diff --git a/docs/guides/API.md b/docs/guides/API.md index 58b8f1e2..7c2684f3 100644 --- a/docs/guides/API.md +++ b/docs/guides/API.md @@ -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. diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index e588ae7b..65192a15 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -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)} - {(req.project_names || []).length > 0 ? ( - - Проект: {req.project_names?.join(", ")} - - ) : null} + 0 ? "app-text-muted" : "text-amber-600 dark:text-amber-400"}`} + > + {(req.project_names || []).length > 0 + ? `Проект: ${req.project_names?.join(", ")}` + : "Без проекта"} + {getStatusMeta(selectedRequest.status).label} - {(selectedRequest.project_names || []).map((name) => ( - - {name} - - ))} @@ -2030,6 +2027,18 @@ export default function ProcurementPage() { ) : null} +
+ { + await Promise.all([ + refreshOne(selectedRequest.id), + loadPage1(), + ]); + }} + /> +

Описание: {selectedRequest.description || "—"} @@ -3811,6 +3820,12 @@ export default function ProcurementPage() { })) } /> + {form.projects.length === 0 ? ( +

+ Проект пока не выбран. Заявку можно сохранить без него, но для + точного учёта закупки проект желательно указать. +

+ ) : null} {canCreateProjects ? (
Promise; +}; + +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([]); + const [selected, setSelected] = useState([]); + 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 ( +
+
+ + + Проекты закупки + + {request.can_assign_projects ? ( + + ) : null} +
+ {!editing ? ( + names.length > 0 ? ( +
+ {names.map((name, index) => ( + + {name} + + ))} +
+ ) : ( +

+ Проект не указан. Его можно назначить для корректного учёта закупки. +

+ ) + ) : ( +
+ {loading ? ( +

+ Загрузка + проектов... +

+ ) : ( + <> + { + if (busy) return; + setSelected((current) => + current.includes(id) + ? current.filter((value) => value !== id) + : [...current, id], + ); + }} + /> +

+ Можно выбрать несколько проектов или очистить список. +

+
+ + +
+ + )} +
+ )} + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/frontend/src/components/procurement/ProcurementRequestHistory.tsx b/frontend/src/components/procurement/ProcurementRequestHistory.tsx index 82731658..d41fa6d7 100644 --- a/frontend/src/components/procurement/ProcurementRequestHistory.tsx +++ b/frontend/src/components/procurement/ProcurementRequestHistory.tsx @@ -34,6 +34,7 @@ const fieldLabels: Record = { actual_unit_price: "фактическую цену", executor_comment: "комментарий исполнителя", links: "ссылки", + projects: "проекты закупки", }; function textMetadata(metadata: Record, 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; diff --git a/frontend/src/lib/api/procurement.ts b/frontend/src/lib/api/procurement.ts index 81c7aa86..f1c9bb8b 100644 --- a/frontend/src/lib/api/procurement.ts +++ b/frontend/src/lib/api/procurement.ts @@ -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) => request(`/api/v1/procurement/requests/${id}/`, { method: "PATCH", diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index c60f4ebe..95db8ad2 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -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;