procurement: refine item fulfillment workflow

This commit is contained in:
lizerk 2026-05-28 14:13:14 +03:00
parent b1a5ac34ef
commit 520abbf4d0
10 changed files with 645 additions and 69 deletions

View File

@ -941,12 +941,73 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
"comments",
"delete_comment",
"report_issue",
"confirm_received",
"cancel_received",
"cancel_issue",
]:
permission_classes = [permissions.IsAuthenticated]
else:
permission_classes = [CanManageProcurementRequest]
return [permission() for permission in permission_classes]
def _can_use_item_quick_action(self, user, procurement_request):
return (
procurement_request.requestor_id == user.id
or self._can_process_request_items(user, procurement_request)
)
def _validate_item_quick_action(self, user, procurement_request):
if not self._can_use_item_quick_action(user, procurement_request):
return Response(
{"detail": "Нет прав изменить эту позицию"},
status=status.HTTP_403_FORBIDDEN,
)
if procurement_request.status in [
ProcurementStatus.PENDING,
ProcurementStatus.CANCELLED,
ProcurementStatus.REJECTED,
]:
return Response(
{
"detail": (
"Нельзя изменить позицию в заявке на согласовании, "
"в отменённой или отклонённой заявке"
)
},
status=status.HTTP_400_BAD_REQUEST,
)
return None
def _workflow_status_from_quantities(self, item):
received_quantity = item.received_quantity or 0
ordered_quantity = item.ordered_quantity or 0
if received_quantity > 0:
return (
ProcurementItemExecutionStatus.RECEIVED
if received_quantity >= item.quantity
else ProcurementItemExecutionStatus.ORDERED
)
if ordered_quantity > 0:
return ProcurementItemExecutionStatus.ORDERED
return ProcurementItemExecutionStatus.PENDING
def _reopen_completed_request(self, procurement_request, user):
if procurement_request.status != ProcurementStatus.COMPLETED:
return
procurement_request.status = (
ProcurementStatus.IN_PROGRESS
if procurement_request.executor_id
else ProcurementStatus.WAITING
)
procurement_request.completed_at = None
procurement_request._notification_actor = user
procurement_request.save(
update_fields=["status", "completed_at", "updated_at"]
)
def perform_create(self, serializer):
"""Проверяем права при создании позиции.
@ -1064,44 +1125,17 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
procurement_request = item.request
user = request.user
if not (
procurement_request.requestor_id == user.id
or self._can_process_request_items(user, procurement_request)
):
return Response(
{"detail": "Нет прав отметить проблему по этой позиции"},
status=status.HTTP_403_FORBIDDEN,
)
if procurement_request.status in [
ProcurementStatus.PENDING,
ProcurementStatus.CANCELLED,
ProcurementStatus.REJECTED,
]:
return Response(
{
"detail": (
"Нельзя изменить позицию в заявке на согласовании, "
"в отменённой или отклонённой заявке"
)
},
status=status.HTTP_400_BAD_REQUEST,
)
error_response = self._validate_item_quick_action(
user,
procurement_request,
)
if error_response is not None:
return error_response
item.execution_status = ProcurementItemExecutionStatus.DEFECTIVE
item.save(update_fields=["execution_status"])
if procurement_request.status == ProcurementStatus.COMPLETED:
procurement_request.status = (
ProcurementStatus.IN_PROGRESS
if procurement_request.executor_id
else ProcurementStatus.WAITING
)
procurement_request.completed_at = None
procurement_request._notification_actor = user
procurement_request.save(
update_fields=["status", "completed_at", "updated_at"]
)
self._reopen_completed_request(procurement_request, user)
text = request.data.get("text", "")
if isinstance(text, str):
@ -1130,6 +1164,110 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(item)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def cancel_received(self, request, pk=None):
"""Отменить подтверждение получения позиции."""
item = self.get_object()
procurement_request = item.request
user = request.user
error_response = self._validate_item_quick_action(
user,
procurement_request,
)
if error_response is not None:
return error_response
if (item.received_quantity or 0) <= 0:
return Response(
{"detail": "По позиции нет подтверждённого получения"},
status=status.HTTP_400_BAD_REQUEST,
)
item.received_quantity = 0
item.execution_status = self._workflow_status_from_quantities(item)
item.save(update_fields=["execution_status", "received_quantity"])
self._reopen_completed_request(procurement_request, user)
notify_item_updated(
item,
actor=user,
changed_fields=["execution_status", "received_quantity"],
)
item.refresh_from_db()
serializer = self.get_serializer(item)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def cancel_issue(self, request, pk=None):
"""Снять отметку брака/перезаказа с позиции."""
item = self.get_object()
procurement_request = item.request
user = request.user
error_response = self._validate_item_quick_action(
user,
procurement_request,
)
if error_response is not None:
return error_response
if item.execution_status != ProcurementItemExecutionStatus.DEFECTIVE:
return Response(
{"detail": "Позиция не отмечена как брак"},
status=status.HTTP_400_BAD_REQUEST,
)
item.execution_status = self._workflow_status_from_quantities(item)
item.save(update_fields=["execution_status"])
notify_item_updated(
item,
actor=user,
changed_fields=["execution_status"],
)
item.refresh_from_db()
serializer = self.get_serializer(item)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def confirm_received(self, request, pk=None):
"""Подтвердить получение позиции."""
item = self.get_object()
procurement_request = item.request
user = request.user
error_response = self._validate_item_quick_action(
user,
procurement_request,
)
if error_response is not None:
return error_response
item.execution_status = ProcurementItemExecutionStatus.RECEIVED
item.ordered_quantity = item.quantity
item.received_quantity = item.quantity
item.save(
update_fields=[
"execution_status",
"ordered_quantity",
"received_quantity",
]
)
notify_item_updated(
item,
actor=user,
changed_fields=[
"execution_status",
"ordered_quantity",
"received_quantity",
],
)
item.refresh_from_db()
serializer = self.get_serializer(item)
return Response(serializer.data)
def perform_destroy(self, instance):
"""Проверяем права при удалении позиции."""
self._check_item_edit_permission(instance)

View File

@ -1447,6 +1447,14 @@ class TestProcessingDepartmentWorkflow:
'api:v1:procurement:procurementitem-report-issue',
kwargs={'pk': item.id},
)
confirm_received_url = reverse(
'api:v1:procurement:procurementitem-confirm-received',
kwargs={'pk': item.id},
)
cancel_received_url = reverse(
'api:v1:procurement:procurementitem-cancel-received',
kwargs={'pk': item.id},
)
mark_all_url = reverse(
'api:v1:procurement:procurementrequest-mark-all-received',
kwargs={'pk': processing_request.id},
@ -1470,12 +1478,16 @@ class TestProcessingDepartmentWorkflow:
{'text': 'Брак'},
format='json',
)
confirm_received_response = api_client.post(confirm_received_url)
cancel_received_response = api_client.post(cancel_received_url)
mark_all_response = api_client.post(mark_all_url)
complete_response = api_client.post(complete_url)
start_response = api_client.post(start_url)
assert item_response.status_code == status.HTTP_403_FORBIDDEN
assert issue_response.status_code == status.HTTP_400_BAD_REQUEST
assert confirm_received_response.status_code == status.HTTP_400_BAD_REQUEST
assert cancel_received_response.status_code == status.HTTP_400_BAD_REQUEST
assert mark_all_response.status_code == status.HTTP_400_BAD_REQUEST
assert complete_response.status_code == status.HTTP_403_FORBIDDEN
assert start_response.status_code == status.HTTP_400_BAD_REQUEST
@ -1886,6 +1898,22 @@ class TestProcessingDepartmentWorkflow:
assert [comment.content for comment in get_comments(item)] == [
"Обнаружен брак"
]
api_client.force_authenticate(user=supply_user)
my_work_url = reverse(
'api:v1:procurement:procurementrequest-list'
)
my_work_response = api_client.get(
my_work_url,
{'scope': 'my_work'},
)
assert my_work_response.status_code == status.HTTP_200_OK
my_work_item = next(
item for item in my_work_response.data["results"]
if item["id"] == processing_request.id
)
assert my_work_item["status"] == ProcurementStatus.IN_PROGRESS
issue_recipients = [
notification["recipient"].email
for notification in sent
@ -1893,6 +1921,201 @@ class TestProcessingDepartmentWorkflow:
]
assert supply_user.email in issue_recipients
def test_executor_issue_reopens_request_for_requestor_and_notifies(
self, api_client, user, supply_user, processing_request,
procurement_item_factory, monkeypatch
):
sent = []
def fake_notify_send(**kwargs):
sent.append(kwargs)
monkeypatch.setattr(
"procurement.notifications.handlers.notify.send",
fake_notify_send,
)
item = procurement_item_factory(
request=processing_request,
execution_status=ProcurementItemExecutionStatus.RECEIVED,
ordered_quantity=1,
received_quantity=1,
)
processing_request.executor = supply_user
processing_request.status = ProcurementStatus.COMPLETED
processing_request.completed_at = timezone.now()
processing_request.fulfillment_status = (
ProcurementFulfillmentStatus.COMPLETED
)
processing_request.save()
sent.clear()
api_client.force_authenticate(user=supply_user)
url = reverse(
'api:v1:procurement:procurementitem-report-issue',
kwargs={'pk': item.id},
)
response = api_client.post(url, {'text': 'Брак'}, format='json')
assert response.status_code == status.HTTP_200_OK
item.refresh_from_db()
processing_request.refresh_from_db()
assert item.execution_status == ProcurementItemExecutionStatus.DEFECTIVE
assert processing_request.status == ProcurementStatus.IN_PROGRESS
assert processing_request.completed_at is None
api_client.force_authenticate(user=user)
mine_url = reverse('api:v1:procurement:procurementrequest-list')
mine_response = api_client.get(mine_url, {'scope': 'mine'})
assert mine_response.status_code == status.HTTP_200_OK
mine_item = next(
item for item in mine_response.data["results"]
if item["id"] == processing_request.id
)
assert mine_item["status"] == ProcurementStatus.IN_PROGRESS
item_updated_recipients = [
notification["recipient"].email
for notification in sent
if notification["verb"] == "procurement_item_updated"
]
in_progress_recipients = [
notification["recipient"].email
for notification in sent
if notification["verb"] == "procurement_in_progress"
]
assert user.email in item_updated_recipients
assert user.email in in_progress_recipients
def test_requestor_can_confirm_item_received(
self, api_client, user, supply_user, processing_request,
procurement_item_factory
):
item = procurement_item_factory(
request=processing_request,
quantity=2,
ordered_quantity=1,
received_quantity=0,
execution_status=ProcurementItemExecutionStatus.ORDERED,
)
processing_request.executor = supply_user
processing_request.status = ProcurementStatus.IN_PROGRESS
processing_request.save()
api_client.force_authenticate(user=user)
url = reverse(
'api:v1:procurement:procurementitem-confirm-received',
kwargs={'pk': item.id},
)
response = api_client.post(url, {}, format='json')
assert response.status_code == status.HTTP_200_OK
item.refresh_from_db()
processing_request.refresh_from_db()
assert item.execution_status == ProcurementItemExecutionStatus.RECEIVED
assert item.ordered_quantity == item.quantity
assert item.received_quantity == item.quantity
assert (
processing_request.fulfillment_status
== ProcurementFulfillmentStatus.COMPLETED
)
assert processing_request.status == ProcurementStatus.COMPLETED
assert processing_request.completed_at is not None
def test_requestor_can_cancel_item_issue(
self, api_client, user, supply_user, processing_request,
procurement_item_factory
):
item = procurement_item_factory(
request=processing_request,
quantity=1,
ordered_quantity=1,
received_quantity=1,
execution_status=ProcurementItemExecutionStatus.DEFECTIVE,
)
processing_request.executor = supply_user
processing_request.status = ProcurementStatus.IN_PROGRESS
processing_request.fulfillment_status = ProcurementFulfillmentStatus.ISSUES
processing_request.save()
api_client.force_authenticate(user=user)
url = reverse(
'api:v1:procurement:procurementitem-cancel-issue',
kwargs={'pk': item.id},
)
response = api_client.post(url, {}, format='json')
assert response.status_code == status.HTTP_200_OK
item.refresh_from_db()
processing_request.refresh_from_db()
assert item.execution_status == ProcurementItemExecutionStatus.RECEIVED
assert (
processing_request.fulfillment_status
== ProcurementFulfillmentStatus.COMPLETED
)
assert processing_request.status == ProcurementStatus.COMPLETED
def test_requestor_can_cancel_item_received_and_reopens_completed_request(
self, api_client, user, supply_user, processing_request,
procurement_item_factory
):
item = procurement_item_factory(
request=processing_request,
quantity=2,
ordered_quantity=2,
received_quantity=2,
execution_status=ProcurementItemExecutionStatus.RECEIVED,
)
processing_request.executor = supply_user
processing_request.status = ProcurementStatus.COMPLETED
processing_request.completed_at = timezone.now()
processing_request.fulfillment_status = ProcurementFulfillmentStatus.COMPLETED
processing_request.save()
api_client.force_authenticate(user=user)
url = reverse(
'api:v1:procurement:procurementitem-cancel-received',
kwargs={'pk': item.id},
)
response = api_client.post(url, {}, format='json')
assert response.status_code == status.HTTP_200_OK
item.refresh_from_db()
processing_request.refresh_from_db()
assert item.execution_status == ProcurementItemExecutionStatus.ORDERED
assert item.ordered_quantity == item.quantity
assert item.received_quantity == 0
assert (
processing_request.fulfillment_status
== ProcurementFulfillmentStatus.ORDERED
)
assert processing_request.status == ProcurementStatus.IN_PROGRESS
assert processing_request.completed_at is None
def test_outsider_cannot_confirm_item_received(
self, api_client, outsider, processing_request,
procurement_item_factory
):
item = procurement_item_factory(
request=processing_request,
execution_status=ProcurementItemExecutionStatus.ORDERED,
ordered_quantity=1,
)
api_client.force_authenticate(user=outsider)
url = reverse(
'api:v1:procurement:procurementitem-confirm-received',
kwargs={'pk': item.id},
)
response = api_client.post(url, {}, format='json')
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_list_exposes_procurement_progress_summary(
self, api_client, supply_user, processing_request,
procurement_item_factory

View File

@ -447,6 +447,17 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
border-color: color-mix(in srgb, var(--danger-foreground) 24%, var(--border-strong));
}
.app-action-approve {
border: 1px solid color-mix(in srgb, var(--danger-foreground) 18%, var(--border-strong));
background: var(--surface-elevated);
color: #10b981;
}
.app-action-approve:hover {
background: var(--success-soft);
border-color: color-mix(in srgb, #10b981 24%, var(--border-strong));
}
.app-selected {
border: 1px solid color-mix(in srgb, var(--accent-primary) 22%, var(--border-subtle));
background: color-mix(in srgb, var(--accent-primary) 12%, var(--surface-primary));

View File

@ -8,7 +8,7 @@ import Link from "next/link";
import { ProcurementRequestDetailContent } from "@/components/procurement/ProcurementRequestDetailContent";
import ProcurementStatsPanel from "@/components/procurement/ProcurementStatsPanel";
import ProcurementSuppliersPanel from "@/components/procurement/ProcurementSuppliersPanel";
import type { ProcurementRequest, ProcurementStatus, UrgencyLevel } from "@/types/api";
import type { ProcurementFulfillmentStatus, ProcurementRequest, ProcurementStatus, UrgencyLevel } from "@/types/api";
import {
AlertTriangle,
ArrowUpDown,
@ -59,6 +59,18 @@ const getStatusMeta = (status?: string | null) => (
statusMeta[String(status || "").toLowerCase() as ProcurementStatus] ?? defaultStatusMeta
);
const fulfillmentStatusClasses: Record<ProcurementFulfillmentStatus, string> = {
pending: "app-badge",
partially_ordered: "app-feedback-warning",
ordered: "app-feedback-success",
partially_received: "app-feedback-warning",
completed: "app-feedback-success",
issues: "app-feedback-warning",
};
const getFulfillmentStatusClass = (status?: string | null) => (
fulfillmentStatusClasses[String(status || "").toLowerCase() as ProcurementFulfillmentStatus] ?? "app-badge"
);
const urgencyMeta: Record<string, { label: string; cls: string }> = {
low: { label: "Низкая", cls: "text-gray-500" },
medium: { label: "Средняя", cls: "text-sky-600" },
@ -211,10 +223,10 @@ function ProcurementRequestActionButtons({
onClick={() => onApprove(request.id)}
disabled={busyKey === `approve-${request.id}`}
title="Одобрить"
className={buttonClass("app-feedback-success")}
className={buttonClass("app-action-approve")}
>
<ThumbsUp size={14} />
{label("Одобрить")}
<ThumbsUp size={14} className="text-emerald-500" />
{showLabels ? <span className="text-emerald-500">Одобрить</span> : null}
</button>
<button
type="button"
@ -311,6 +323,9 @@ export default function ProcurementPage() {
handleComplete,
handleMarkAllReceived,
handleReportItemIssue,
handleCancelItemIssue,
handleConfirmItemReceived,
handleCancelItemReceived,
handleUpdateItem,
handleDelete,
handleLoadMore,
@ -776,6 +791,7 @@ export default function ProcurementPage() {
const executorName = req.executor || req.executor_name ? displayUserName(req.executor, req.executor_name || undefined) : "";
const requestorLink = userLink(req.requestor);
const executorLink = userLink(req.executor);
const fulfillmentStatus = resolvedDetail.fulfillment_status ?? req.fulfillment_status;
const fulfillmentStatusDisplay = resolvedDetail.fulfillment_status_display ?? req.fulfillment_status_display;
const itemsCount = resolvedDetail.items?.length ?? resolvedDetail.items_count ?? req.items_count ?? 0;
const detailItems = resolvedDetail.items || req.items || [];
@ -930,7 +946,7 @@ export default function ProcurementPage() {
{(fulfillmentStatusDisplay || itemsTotalCount > 0 || approvalsCount > 0 || nextExpectedDeliveryDate) && (
<div className="col-span-2 flex flex-wrap items-center gap-2 pt-0.5">
{fulfillmentStatusDisplay ? (
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
<span className={`${getFulfillmentStatusClass(fulfillmentStatus)} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium`}>
{fulfillmentStatusDisplay}
</span>
) : null}
@ -986,9 +1002,9 @@ export default function ProcurementPage() {
title="Одобрить"
onClick={() => openRequestActionDialog("approve", req.id)}
disabled={busyKey === `approve-${req.id}`}
className="app-feedback-success inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"
className="app-action-approve inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"
>
<ThumbsUp size={18} />
<ThumbsUp size={18} className="text-emerald-500" />
</button>
<button
type="button"
@ -1057,6 +1073,9 @@ export default function ProcurementPage() {
canDeleteAnyComment={Boolean(user?.auth?.is_staff || user?.auth?.is_superuser)}
onUpdateItem={handleUpdateItem}
onReportItemIssue={handleReportItemIssue}
onCancelItemIssue={handleCancelItemIssue}
onConfirmItemReceived={handleConfirmItemReceived}
onCancelItemReceived={handleCancelItemReceived}
onMarkAllReceived={handleMarkAllReceived}
itemCommentsMap={itemCommentsMap}
itemCommentDrafts={itemCommentDrafts}
@ -1145,7 +1164,7 @@ export default function ProcurementPage() {
<span className="font-medium text-[var(--foreground)]">{money(getRequestAmount(selectedRequest))}</span>
) : null}
{selectedRequest.fulfillment_status_display ? (
<span className="app-badge rounded-full px-2 py-0.5 text-[11px] font-medium">
<span className={`${getFulfillmentStatusClass(selectedRequest.fulfillment_status)} rounded-full px-2 py-0.5 text-[11px] font-medium`}>
{selectedRequest.fulfillment_status_display}
</span>
) : null}
@ -1184,6 +1203,9 @@ export default function ProcurementPage() {
canDeleteAnyComment={Boolean(user?.auth?.is_staff || user?.auth?.is_superuser)}
onUpdateItem={handleUpdateItem}
onReportItemIssue={handleReportItemIssue}
onCancelItemIssue={handleCancelItemIssue}
onConfirmItemReceived={handleConfirmItemReceived}
onCancelItemReceived={handleCancelItemReceived}
onMarkAllReceived={handleMarkAllReceived}
itemCommentsMap={itemCommentsMap}
itemCommentDrafts={itemCommentDrafts}
@ -1457,10 +1479,38 @@ export default function ProcurementPage() {
<textarea
value={it.initial_comment}
onChange={(e) => updateItemRow(idx, { initial_comment: e.target.value })}
placeholder="Комментарий к позиции"
placeholder={modalMode === "edit" ? "Новый комментарий к позиции" : "Комментарий к позиции"}
rows={2}
className="app-input app-text-wrap w-full rounded-lg px-3 py-2 text-sm"
/>
{modalMode === "edit" && it.id && (itemCommentsMap[it.id] || []).length > 0 ? (
<div className="app-surface rounded-lg px-3 py-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="app-card-caption text-xs">Комментарии позиции</p>
<span className="app-badge px-2 py-0.5 text-[10px] font-semibold">
{(itemCommentsMap[it.id] || []).length}
</span>
</div>
<div className="max-h-40 space-y-2 overflow-y-auto pr-1">
{(itemCommentsMap[it.id] || []).map((comment) => (
<div
key={comment.id}
className="border-b border-[var(--border-subtle)] pb-2 text-xs last:border-b-0 last:pb-0"
>
<div className="mb-1 flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span className="font-medium text-[var(--foreground)]">
{displayUserName(comment.author)}
</span>
<span className="app-text-muted text-[11px]">{fmt(comment.created_at)}</span>
</div>
<p className="app-text-wrap whitespace-pre-line leading-5 text-[var(--foreground)]">
{comment.text}
</p>
</div>
))}
</div>
</div>
) : null}
<div className="grid grid-cols-4 gap-2">
<input
type="number"

View File

@ -23,6 +23,9 @@ interface ProcurementRequestDetailContentProps {
canDeleteAnyComment?: boolean;
onUpdateItem?: (requestId: number, itemId: number, patch: Record<string, unknown>) => void | Promise<unknown>;
onReportItemIssue?: (requestId: number, itemId: number, text?: string) => void | Promise<unknown>;
onCancelItemIssue?: (requestId: number, itemId: number) => void | Promise<unknown>;
onConfirmItemReceived?: (requestId: number, itemId: number) => void | Promise<unknown>;
onCancelItemReceived?: (requestId: number, itemId: number) => void | Promise<unknown>;
onMarkAllReceived?: (requestId: number) => void | Promise<unknown>;
itemCommentsMap?: Record<number, ProcurementItemComment[]>;
itemCommentDrafts?: Record<number, string>;
@ -115,11 +118,17 @@ interface ProcurementItemCardProps {
) => string;
canEditItemProcessing: boolean;
canReportIssue: boolean;
canCancelIssue: boolean;
canConfirmReceipt: boolean;
canCancelReceipt: boolean;
currentUserId?: number | null;
canDeleteAnyComment?: boolean;
busyKey?: string | null;
onUpdateItem?: (requestId: number, itemId: number, patch: Record<string, unknown>) => void | Promise<unknown>;
onReportItemIssue?: (requestId: number, itemId: number, text?: string) => void | Promise<unknown>;
onCancelItemIssue?: (requestId: number, itemId: number) => void | Promise<unknown>;
onConfirmItemReceived?: (requestId: number, itemId: number) => void | Promise<unknown>;
onCancelItemReceived?: (requestId: number, itemId: number) => void | Promise<unknown>;
comments?: ProcurementItemComment[];
commentDraft?: string;
commentsOpen?: boolean;
@ -135,11 +144,17 @@ function ProcurementItemCard({
displayUserName,
canEditItemProcessing,
canReportIssue,
canCancelIssue,
canConfirmReceipt,
canCancelReceipt,
currentUserId,
canDeleteAnyComment = false,
busyKey,
onUpdateItem,
onReportItemIssue,
onCancelItemIssue,
onConfirmItemReceived,
onCancelItemReceived,
comments = [],
commentDraft = "",
commentsOpen = false,
@ -157,7 +172,25 @@ function ProcurementItemCard({
const receivedQuantity = item.received_quantity ?? 0;
const status = item.execution_status || "pending";
const statusLabel = item.execution_status_display || executionStatusOptions.find((option) => option.value === status)?.label || "Не выполнено";
const canCancelReceived = canEditItemProcessing && status === "received";
const canCancelReceived = Boolean(
canCancelReceipt &&
onCancelItemReceived &&
receivedQuantity > 0
);
const canConfirmReceived = Boolean(
canConfirmReceipt &&
onConfirmItemReceived &&
requestedQuantity > 0 &&
(status === "ordered" || receivedQuantity > 0) &&
receivedQuantity < requestedQuantity &&
status !== "rejected" &&
status !== "defective"
);
const canCancelItemIssue = Boolean(
canCancelIssue &&
onCancelItemIssue &&
status === "defective"
);
const canMarkIssue = Boolean(
canReportIssue &&
onReportItemIssue &&
@ -189,14 +222,6 @@ function ProcurementItemCard({
});
};
const cancelReceived = () => {
const nextStatus: ProcurementItemExecutionStatus = Number(item.ordered_quantity || 0) > 0 ? "ordered" : "pending";
return onUpdateItem?.(requestId, item.id, {
execution_status: nextStatus,
received_quantity: 0,
});
};
return (
<div className="app-surface-muted rounded-lg px-3 py-3 text-xs">
<div className="flex flex-wrap items-start justify-between gap-3">
@ -320,24 +345,48 @@ function ProcurementItemCard({
{canCancelReceived ? (
<button
type="button"
onClick={() => void cancelReceived()}
disabled={busyKey === `item-${item.id}`}
className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium disabled:opacity-60"
onClick={() => void onCancelItemReceived?.(requestId, item.id)}
disabled={busyKey === `item-cancel-received-${item.id}`}
className="app-action-ghost inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium disabled:opacity-60"
>
<RotateCcw size={13} />
<span>Отменить получение</span>
</button>
) : null}
{canConfirmReceived ? (
<button
type="button"
onClick={() => void onConfirmItemReceived?.(requestId, item.id)}
disabled={busyKey === `item-confirm-received-${item.id}`}
className="app-action-ghost inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium disabled:opacity-60"
>
<CheckCircle2 size={13} className="text-emerald-500" />
<span className="text-emerald-500">Подтвердить получение</span>
</button>
) : null}
{canMarkIssue ? (
<button
type="button"
onClick={() => void onReportItemIssue?.(requestId, item.id)}
disabled={busyKey === `item-issue-${item.id}`}
className="app-feedback-warning inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium disabled:opacity-60"
className="app-action-ghost inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium disabled:opacity-60"
>
<AlertTriangle size={13} />
<span>Брак</span>
<AlertTriangle size={13} className="text-amber-500" />
<span className="text-amber-500">Брак</span>
</button>
) : null}
{canCancelItemIssue ? (
<button
type="button"
onClick={() => void onCancelItemIssue?.(requestId, item.id)}
disabled={busyKey === `item-cancel-issue-${item.id}`}
className="app-action-ghost inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium disabled:opacity-60"
>
<RotateCcw size={13} className="text-amber-500" />
<span className="text-amber-500">Отменить брак</span>
</button>
) : null}
</div>
@ -545,6 +594,9 @@ export function ProcurementRequestDetailContent({
canDeleteAnyComment = false,
onUpdateItem,
onReportItemIssue,
onCancelItemIssue,
onConfirmItemReceived,
onCancelItemReceived,
onMarkAllReceived,
itemCommentsMap = {},
itemCommentDrafts = {},
@ -557,6 +609,7 @@ export function ProcurementRequestDetailContent({
}: ProcurementRequestDetailContentProps) {
const canEditItemProcessing = Boolean(canProcessItems && onUpdateItem);
const isRequestPending = String(request.status || "").toLowerCase() === "pending";
const displayItems = request.items ? [...request.items].reverse() : [];
const requestorId = typeof request.requestor === "number" ? request.requestor : request.requestor?.id ?? null;
const executorId = typeof request.executor === "number" ? request.executor : request.executor?.id ?? null;
const canReportIssues = Boolean(
@ -566,6 +619,26 @@ export function ProcurementRequestDetailContent({
currentUserId === executorId
),
);
const canCancelIssues = Boolean(
!isRequestPending && onCancelItemIssue && currentUserId && (
canEditItemProcessing ||
currentUserId === requestorId ||
currentUserId === executorId
),
);
const canConfirmReceipt = Boolean(
!isRequestPending &&
onConfirmItemReceived &&
currentUserId &&
currentUserId === requestorId,
);
const canCancelReceipt = Boolean(
!isRequestPending && onCancelItemReceived && currentUserId && (
currentUserId === requestorId ||
currentUserId === executorId ||
canEditItemProcessing
),
);
return (
<div className="space-y-3">
@ -595,7 +668,7 @@ export function ProcurementRequestDetailContent({
) : null}
</div>
{request.items && request.items.length > 0 ? (
{displayItems.length > 0 ? (
<div className="app-surface rounded-xl p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold text-[var(--foreground)]">Позиции</p>
@ -611,7 +684,7 @@ export function ProcurementRequestDetailContent({
) : null}
</div>
<div className="space-y-2">
{request.items.map((item) => (
{displayItems.map((item) => (
<ProcurementItemCard
key={[
item.id,
@ -627,11 +700,17 @@ export function ProcurementRequestDetailContent({
displayUserName={displayUserName}
canEditItemProcessing={canEditItemProcessing}
canReportIssue={canReportIssues}
canCancelIssue={canCancelIssues}
canConfirmReceipt={canConfirmReceipt}
canCancelReceipt={canCancelReceipt}
currentUserId={currentUserId}
canDeleteAnyComment={canDeleteAnyComment}
busyKey={busyKey}
onUpdateItem={onUpdateItem}
onReportItemIssue={onReportItemIssue}
onCancelItemIssue={onCancelItemIssue}
onConfirmItemReceived={onConfirmItemReceived}
onCancelItemReceived={onCancelItemReceived}
comments={itemCommentsMap[item.id] || []}
commentDraft={itemCommentDrafts[item.id] || ""}
commentsOpen={Boolean(expandedItemComments[item.id])}

View File

@ -93,10 +93,10 @@ export function RequestDetailModal({
type="button"
onClick={() => void onApprove(request.id)}
disabled={busyKey === `approve-${request.id}`}
className="app-feedback-success inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-60"
className="app-action-approve inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-60"
>
<ThumbsUp size={16} />
Одобрить
<ThumbsUp size={16} className="text-emerald-500" />
<span className="text-emerald-500">Одобрить</span>
</button>
<button
type="button"

View File

@ -269,9 +269,9 @@ export function RequestListItem({
title="Одобрить"
onClick={() => void onApprove(request.id)}
disabled={busyKey === `approve-${request.id}`}
className="app-feedback-success inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"
className="app-action-approve inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"
>
<ThumbsUp size={18} />
<ThumbsUp size={18} className="text-emerald-500" />
</button>
<button
type="button"

View File

@ -379,10 +379,10 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo
type="button"
onClick={() => { setFlyOut("right"); setTimeout(() => performAction("approved"), 250); }}
disabled={busy}
className="app-feedback-success flex h-14 w-14 items-center justify-center rounded-full transition hover:shadow-md active:scale-95 disabled:opacity-50"
className="app-action-approve flex h-14 w-14 items-center justify-center rounded-full transition hover:shadow-md active:scale-95 disabled:opacity-50"
title="Одобрить (→)"
>
<ThumbsUp size={22} />
<ThumbsUp size={22} className="text-emerald-500" />
</button>
</div>

View File

@ -442,6 +442,27 @@ export function useProcurementPage(user: User | null) {
return;
}
const existingItems = detail.items || [];
const commentableItemIds = existingItems
.map((item) => item.id)
.filter((id): id is number => typeof id === "number");
if (commentableItemIds.length > 0) {
try {
const entries = await Promise.all(
commentableItemIds.map(async (itemId) => {
const comments = await apiClient.getProcurementItemComments(itemId);
return [itemId, Array.isArray(comments) ? comments : []] as const;
}),
);
setItemCommentsMap((previous) => ({
...previous,
...Object.fromEntries(entries),
}));
} catch (commentsError) {
console.error("Load procurement item comments for edit error:", commentsError);
}
}
setEditingId(request.id);
setForm({
title: detail.title || "",
@ -450,8 +471,8 @@ export function useProcurementPage(user: User | null) {
requireApproval: false,
processing_department: detail.processing_department ?? null,
urgency: detail.urgency || "medium",
items: detail.items && detail.items.length > 0
? detail.items.map((item) => ({
items: existingItems.length > 0
? existingItems.map((item) => ({
id: item.id,
name: item.name || "",
description: item.description || "",
@ -918,6 +939,54 @@ export function useProcurementPage(user: User | null) {
}
}, [refreshAfterMutation]);
const handleCancelItemIssue = useCallback(async (requestId: number, itemId: number) => {
try {
setBusyKey(`item-cancel-issue-${itemId}`);
setActionError(null);
await apiClient.cancelProcurementItemIssue(itemId);
setActionSuccess("Отметка брака снята.");
await refreshAfterMutation(requestId);
return true;
} catch (issueError) {
setActionError(getReadableError(issueError, "Не удалось снять отметку брака"));
return false;
} finally {
setBusyKey(null);
}
}, [refreshAfterMutation]);
const handleConfirmItemReceived = useCallback(async (requestId: number, itemId: number) => {
try {
setBusyKey(`item-confirm-received-${itemId}`);
setActionError(null);
await apiClient.confirmProcurementItemReceived(itemId);
setActionSuccess("Получение позиции подтверждено.");
await refreshAfterMutation(requestId);
return true;
} catch (confirmError) {
setActionError(getReadableError(confirmError, "Не удалось подтвердить получение"));
return false;
} finally {
setBusyKey(null);
}
}, [refreshAfterMutation]);
const handleCancelItemReceived = useCallback(async (requestId: number, itemId: number) => {
try {
setBusyKey(`item-cancel-received-${itemId}`);
setActionError(null);
await apiClient.cancelProcurementItemReceived(itemId);
setActionSuccess("Получение позиции отменено.");
await refreshAfterMutation(requestId);
return true;
} catch (cancelError) {
setActionError(getReadableError(cancelError, "Не удалось отменить получение"));
return false;
} finally {
setBusyKey(null);
}
}, [refreshAfterMutation]);
const handleCancel = useCallback((id: number, reason = "") => {
return doAction(`cancel-${id}`, () => apiClient.cancelProcurementRequest(id, reason), id, "Заявка отменена.");
}, [doAction]);
@ -1035,6 +1104,9 @@ export function useProcurementPage(user: User | null) {
handleMarkAllReceived,
handleUpdateItem,
handleReportItemIssue,
handleCancelItemIssue,
handleConfirmItemReceived,
handleCancelItemReceived,
handleCancel,
handleDelete,
handleAddComment,

View File

@ -31,6 +31,9 @@ export function createProcurementApi(request: RequestFn) {
updateProcurementItem: (id: number, data: Record<string, any>) => request(`/api/v1/procurement/items/${id}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
reportProcurementItemIssue: (id: number, text?: string) =>
request(`/api/v1/procurement/items/${id}/report_issue/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: text || '' }) }),
cancelProcurementItemIssue: (id: number) => request(`/api/v1/procurement/items/${id}/cancel_issue/`, { method: 'POST' }),
confirmProcurementItemReceived: (id: number) => request(`/api/v1/procurement/items/${id}/confirm_received/`, { method: 'POST' }),
cancelProcurementItemReceived: (id: number) => request(`/api/v1/procurement/items/${id}/cancel_received/`, { method: 'POST' }),
deleteProcurementItem: (id: number): Promise<void> => request(`/api/v1/procurement/items/${id}/`, { method: 'DELETE' }),
getProcurementItemComments: (itemId: number) => request(`/api/v1/procurement/items/${itemId}/comments/`),
addProcurementItemComment: (itemId: number, text: string) =>