Refine procurement notifications

This commit is contained in:
lizerk 2026-06-11 18:03:08 +03:00
parent 31b91fe15c
commit b16b74c060
11 changed files with 244 additions and 107 deletions

View File

@ -230,7 +230,7 @@ class CanManageProcurementRequest(permissions.BasePermission):
if action == "complete":
return obj.executor == user
if action == "mark_all_received":
if action in ("mark_all_received", "notify_arrival"):
if obj.executor == user:
return True
if obj.processing_department_id:

View File

@ -32,6 +32,7 @@ from procurement.notifications.handlers import (
notify_item_comment,
notify_item_issue_reported,
notify_item_updated,
notify_request_arrival,
notify_request_comment,
)
from communications import comments_helpers
@ -897,6 +898,31 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(procurement_request)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def notify_arrival(self, request, pk=None):
"""Вручную уведомить заказчика о поступлении по заявке."""
procurement_request = self.get_object()
self.check_object_permissions(request, procurement_request)
if procurement_request.status not in [
ProcurementStatus.WAITING,
ProcurementStatus.IN_PROGRESS,
]:
return Response(
{
"error": (
"Уведомить о поступлении можно только по ожидающей "
"или взятой в работу заявке"
)
},
status=status.HTTP_400_BAD_REQUEST,
)
notify_request_arrival(procurement_request, actor=request.user)
serializer = self.get_serializer(procurement_request)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def set_viewed(self, request, pk=None):
"""Установить отметку просмотра заявки."""

View File

@ -35,6 +35,7 @@ from .handlers import (
notify_request_approved,
notify_request_rejected,
notify_request_completed,
notify_request_arrival,
notify_request_in_progress,
notify_executor_reassigned,
notify_request_returned_to_processing,
@ -66,6 +67,7 @@ __all__ = [
'notify_request_approved',
'notify_request_rejected',
'notify_request_completed',
'notify_request_arrival',
'notify_request_in_progress',
'notify_executor_reassigned',
'notify_request_returned_to_processing',

View File

@ -23,6 +23,7 @@ class NotificationVerbs:
REJECTED = 'procurement_rejected'
IN_PROGRESS = 'procurement_in_progress'
EXECUTOR_REASSIGNED = 'procurement_executor_reassigned'
ARRIVAL_NOTICE = 'procurement_arrival_notice'
COMPLETED = 'procurement_completed'
CANCELLED = 'procurement_cancelled'
@ -210,6 +211,15 @@ class MessageTemplates:
description = f'Заявка "{title}" успешно завершена.'
return notification_title, description
@staticmethod
def arrival_notice(title: str) -> tuple[str, str]:
notification_title = 'Поступление по закупке'
description = (
f'По заявке "{title}" есть поступление. '
f'Проверьте позиции и подтвердите получение.'
)
return notification_title, description
@staticmethod
def cancelled(title: str, reason: str = 'не указана') -> tuple[str, str]:
"""

View File

@ -390,34 +390,36 @@ def notify_request_completed(request):
)
def notify_request_arrival(request, actor=None):
"""Вручную уведомить заказчика о поступлении по заявке."""
notification_title, description = MessageTemplates.arrival_notice(
request.title,
)
notify_requestor(
request,
NotificationVerbs.ARRIVAL_NOTICE,
notification_title,
description,
actor=actor,
extra_data={
"status": request.status,
"fulfillment_status": request.fulfillment_status,
},
)
def notify_request_in_progress(request, executor):
"""
Уведомить о взятии заявки в работу.
Отправляет уведомления:
- Создателю заявки (если он не сам взял в работу)
Заказчика не уведомляем: это промежуточный статус обработки.
Финальные статусы и комментарии остаются основными сигналами.
Args:
request: Объект ProcurementRequest
executor: Пользователь, взявший заявку в работу
"""
executor_name = executor.get_full_name() if executor else 'Сотрудник'
notification_title, description = MessageTemplates.in_progress_requestor(
request.title, executor_name
)
notify_requestor(
request,
NotificationVerbs.IN_PROGRESS,
notification_title,
description,
actor=executor,
extra_data={
"old_status": getattr(request, "_original_status", None),
"new_status": request.status,
"executor_id": executor.id if executor else None,
},
)
return
def notify_executor_reassigned(request, previous_executor, actor):
@ -488,22 +490,8 @@ def notify_stage_approved(approval):
Args:
approval: Объект Approval
"""
approver_name = approval.approver.get_full_name()
notification_title, description = MessageTemplates.stage_approved(
approver_name, approval.request.title
)
notify_requestor(
approval.request,
NotificationVerbs.STAGE_APPROVED,
notification_title,
description,
actor=approval.approver,
extra_data={
"approval_id": approval.id,
"approval_priority": approval.priority,
},
)
# Не уведомляем заказчика о каждом промежуточном этапе согласования.
# Следующий согласующий всё ещё должен получить pending-уведомление.
notify_approvers(approval.request)
@ -534,26 +522,8 @@ def notify_stage_rejected(approval):
def notify_item_updated(item, actor=None, changed_fields=None):
"""Уведомить автора заявки об изменении позиции."""
procurement_request = item.request
actor_name = _actor_name(actor)
notification_title, description = MessageTemplates.item_updated(
procurement_request.title,
item.name,
actor_name,
)
notify_requestor(
procurement_request,
NotificationVerbs.ITEM_UPDATED,
notification_title,
description,
actor=actor,
extra_data={
"item_id": item.id,
"item_name": item.name,
"changed_fields": changed_fields or [],
},
)
"""Не уведомлять заказчика о технических изменениях позиции."""
return
def notify_item_issue_reported(item, actor=None):

View File

@ -1915,7 +1915,7 @@ class TestProcessingDepartmentWorkflow:
assert completed_notifications[0]["sender"] == supply_user
assert completed_notifications[0]["data"]["actor_id"] == supply_user.id
def test_item_update_notifies_requestor(
def test_item_update_does_not_notify_requestor(
self, api_client, supply_user, processing_request,
procurement_item_factory, monkeypatch
):
@ -1959,19 +1959,10 @@ class TestProcessingDepartmentWorkflow:
item for item in sent
if item["verb"] == "procurement_item_updated"
]
assert len(item_notifications) == 1
assert item_notifications[0]["recipient"] == processing_request.requestor
assert item_notifications[0]["data"]["item_id"] == item.id
assert sorted(item_notifications[0]["data"]["changed_fields"]) == [
"actual_unit_price",
"execution_status",
"expected_delivery_dates",
"links",
"ordered_quantity",
]
assert item_notifications == []
@pytest.mark.django_db(transaction=True)
def test_item_order_update_routes_push_to_requestor(
def test_item_order_update_does_not_route_push_to_requestor(
self, api_client, supply_user, processing_request,
procurement_item_factory
):
@ -2016,16 +2007,12 @@ class TestProcessingDepartmentWorkflow:
)
assert response.status_code == status.HTTP_200_OK
notification = Notification.objects.get(
assert not Notification.objects.filter(
recipient=processing_request.requestor,
verb="procurement_item_updated",
data__item_id=item.id,
)
assert notification.data["changed_fields"] == [
"ordered_quantity",
"execution_status",
]
send_push.assert_called_once_with(notification.id)
).exists()
send_push.assert_not_called()
def test_item_expected_delivery_dates_must_be_valid_dates(
self, api_client, supply_user, processing_request,
@ -2381,7 +2368,7 @@ class TestProcessingDepartmentWorkflow:
]
assert supply_user.email in issue_recipients
def test_executor_issue_reopens_request_for_requestor_and_notifies(
def test_executor_issue_reopens_request_for_requestor_without_status_spam(
self, api_client, user, supply_user, processing_request,
procurement_item_factory, monkeypatch
):
@ -2445,8 +2432,8 @@ class TestProcessingDepartmentWorkflow:
for notification in sent
if notification["verb"] == "procurement_in_progress"
]
assert user.email in item_updated_recipients
assert user.email in in_progress_recipients
assert user.email not in item_updated_recipients
assert user.email not in in_progress_recipients
def test_requestor_can_confirm_item_received(
self, api_client, user, supply_user, processing_request,
@ -3079,10 +3066,7 @@ class TestProcessingDepartmentWorkflow:
for item in sent
if item["verb"] == "procurement_item_updated"
]
assert item_update_recipients == [
processing_request.requestor.email,
processing_request.requestor.email,
]
assert item_update_recipients == []
completed_recipients = [
item["recipient"].email
for item in sent
@ -3090,6 +3074,109 @@ class TestProcessingDepartmentWorkflow:
]
assert completed_recipients == [processing_request.requestor.email]
def test_processing_user_can_notify_requestor_about_arrival_multiple_times(
self, api_client, user, supply_user, processing_request, monkeypatch
):
sent = []
def fake_notify_send(**kwargs):
sent.append(kwargs)
monkeypatch.setattr(
"procurement.notifications.handlers.notify.send",
fake_notify_send,
)
processing_request.executor = supply_user
processing_request.status = ProcurementStatus.IN_PROGRESS
processing_request.save()
sent.clear()
api_client.force_authenticate(user=supply_user)
url = reverse(
'api:v1:procurement:procurementrequest-notify-arrival',
kwargs={'pk': processing_request.id},
)
first_response = api_client.post(url)
second_response = api_client.post(url)
assert first_response.status_code == status.HTTP_200_OK
assert second_response.status_code == status.HTTP_200_OK
arrival_notifications = [
item for item in sent
if item["verb"] == "procurement_arrival_notice"
]
assert len(arrival_notifications) == 2
assert [
item["recipient"].email for item in arrival_notifications
] == [user.email, user.email]
assert all(item["sender"] == supply_user for item in arrival_notifications)
assert all(
item["data"]["request_id"] == processing_request.id
for item in arrival_notifications
)
def test_notify_arrival_is_not_available_after_request_completed(
self, api_client, supply_user, processing_request, monkeypatch
):
sent = []
def fake_notify_send(**kwargs):
sent.append(kwargs)
monkeypatch.setattr(
"procurement.notifications.handlers.notify.send",
fake_notify_send,
)
processing_request.executor = supply_user
processing_request.status = ProcurementStatus.COMPLETED
processing_request.completed_at = timezone.now()
processing_request.save()
sent.clear()
api_client.force_authenticate(user=supply_user)
url = reverse(
'api:v1:procurement:procurementrequest-notify-arrival',
kwargs={'pk': processing_request.id},
)
response = api_client.post(url)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert [
item for item in sent
if item["verb"] == "procurement_arrival_notice"
] == []
def test_requestor_cannot_notify_self_about_arrival(
self, api_client, user, processing_request, monkeypatch
):
sent = []
def fake_notify_send(**kwargs):
sent.append(kwargs)
monkeypatch.setattr(
"procurement.notifications.handlers.notify.send",
fake_notify_send,
)
api_client.force_authenticate(user=user)
url = reverse(
'api:v1:procurement:procurementrequest-notify-arrival',
kwargs={'pk': processing_request.id},
)
response = api_client.post(url)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert [
item for item in sent
if item["verb"] == "procurement_arrival_notice"
] == []
# ==============================================================================
# ТЕСТЫ WORKFLOW ДЕЙСТВИЙ
@ -3217,12 +3304,11 @@ class TestProcurementRequestWorkflow:
if item["verb"] == "procurement_pending_approval"
]
assert pending_recipients == ["user@example.com"]
stage_recipients = [
item["recipient"].email
for item in sent
stage_notifications = [
item for item in sent
if item["verb"] == "procurement_stage_approved"
]
assert stage_recipients == [procurement_request.requestor.email]
assert stage_notifications == []
def test_final_approve_does_not_notify_future_approvers(
self, api_client, approver_with_permission, procurement_request, procurement_item, monkeypatch
@ -3368,10 +3454,10 @@ class TestProcurementRequestWorkflow:
]
assert cancelled_recipients == ["head@example.com"]
def test_start_work_notifies_requestor(
def test_start_work_does_not_notify_requestor(
self, api_client, user, staff_user, department_head, procurement_request, procurement_item, monkeypatch
):
"""При взятии в работу уведомляется автор заявки."""
"""При взятии в работу автор заявки не получает шумное уведомление."""
sent = []
def fake_notify_send(**kwargs):
@ -3416,9 +3502,7 @@ class TestProcurementRequestWorkflow:
for item in sent
if item["verb"] == "procurement_in_progress"
]
assert in_progress_recipients == [
procurement_request.requestor.email,
]
assert procurement_request.requestor.email not in in_progress_recipients
def test_complete_notifies_requestor(
self, api_client, user, staff_user, department_head, procurement_request, procurement_item, monkeypatch

View File

@ -449,6 +449,17 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
border-color: color-mix(in srgb, #f97316 40%, var(--border-strong));
}
.app-action-notify {
border: 1px solid color-mix(in srgb, #8b5cf6 28%, var(--border-strong));
background: color-mix(in srgb, #8b5cf6 16%, transparent);
color: #7c3aed;
}
.app-action-notify:hover {
background: color-mix(in srgb, #8b5cf6 22%, transparent);
border-color: color-mix(in srgb, #8b5cf6 40%, var(--border-strong));
}
.app-action-secondary {
border: 1px solid var(--border-strong);
background: var(--surface-elevated);

View File

@ -166,6 +166,7 @@ function ProcurementRequestActionButtons({
onReject,
onStart,
onComplete,
onNotifyArrival,
onCancel,
}: {
request: ProcurementRequest;
@ -184,6 +185,7 @@ function ProcurementRequestActionButtons({
onReject: (id: number) => void | Promise<unknown>;
onStart: (id: number) => void | Promise<unknown>;
onComplete: (id: number) => void | Promise<unknown>;
onNotifyArrival: (id: number) => void | Promise<unknown>;
onCancel: (id: number) => void | Promise<unknown>;
}) {
const status = String(request.status || "").toLowerCase();
@ -191,6 +193,10 @@ function ProcurementRequestActionButtons({
const isPending = status === "pending";
const canStartWork = Boolean(request.can_current_user_start_work);
const isInProgress = status === "in_progress";
const canNotifyArrival = Boolean(
request.can_current_user_process_items &&
(status === "waiting" || status === "in_progress")
);
const startWorkLabel = isInProgress ? "Забрать в работу" : "Взять в работу";
const canApproveThis = Boolean(request.can_current_user_approve);
const canSubmitForApproval = Boolean(request.can_current_user_submit_for_approval);
@ -286,6 +292,18 @@ function ProcurementRequestActionButtons({
{label("Закрыть заявку")}
</button>
) : null}
{canNotifyArrival ? (
<button
type="button"
onClick={() => onNotifyArrival(request.id)}
disabled={busyKey === `notify-arrival-${request.id}`}
title="Уведомить о поступлении"
className={buttonClass("app-action-notify")}
>
<Package size={14} />
{label("Уведомить о поступлении")}
</button>
) : null}
{showSecondaryActions && isAuthor && !isFinal(status) && status !== "draft" ? (
<button
type="button"
@ -312,7 +330,6 @@ export default function ProcurementPage() {
activeFilterCount,
activeSection,
actionError,
actionSuccess,
addItemRow,
busyKey,
canManage,
@ -344,6 +361,7 @@ export default function ProcurementPage() {
handleCancel,
handleComplete,
handleMarkAllReceived,
handleNotifyArrival,
handleToggleViewed,
handleReportItemIssue,
handleCancelItemIssue,
@ -635,7 +653,6 @@ export default function ProcurementPage() {
{/* ── alerts ── */}
{actionError && <p className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{actionError}</p>}
{actionSuccess && <p className="app-feedback-success mb-3 rounded-lg px-3 py-2 text-sm">{actionSuccess}</p>}
{activeSection === "stats" && <ProcurementStatsPanel />}
@ -1072,6 +1089,7 @@ export default function ProcurementPage() {
onReject={(id) => openRequestActionDialog("reject", id)}
onStart={handleStart}
onComplete={handleComplete}
onNotifyArrival={handleNotifyArrival}
onCancel={handleCancel}
/>
</div>
@ -1159,6 +1177,7 @@ export default function ProcurementPage() {
onReject={handleReject}
onStart={handleStart}
onComplete={handleComplete}
onNotifyArrival={handleNotifyArrival}
onCancel={handleCancel}
/>
)}
@ -1207,6 +1226,7 @@ export default function ProcurementPage() {
onReject={(id) => openRequestActionDialog("reject", id)}
onStart={handleStart}
onComplete={handleComplete}
onNotifyArrival={handleNotifyArrival}
onCancel={(id) => openRequestActionDialog("cancel", id)}
/>
</div>

View File

@ -5,6 +5,7 @@ import { apiClient } from "@/lib/api";
import { canManageRequests, canManageSupplier } from "@/lib/permissions";
import { cleanLinkRows, toLinkRows, validateLinkRows } from "@/lib/procurementLinks";
import { displayUserName, extractNextPage, loadAllPages } from "@/lib/shared";
import { toast } from "sonner";
import type {
Department,
ProcurementComment,
@ -142,7 +143,6 @@ export function useProcurementPage(user: User | null) {
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
const [busyKey, setBusyKey] = useState<string | null>(null);
const [nextPage, setNextPage] = useState<number | null>(null);
const [scopeCounts, setScopeCounts] = useState<Record<ScopeTab, number>>({
@ -412,14 +412,12 @@ export function useProcurementPage(user: User | null) {
setEditingId(null);
resetForm();
setActionError(null);
setActionSuccess(null);
setCreateOpen(true);
}, [resetForm]);
const openEdit = useCallback(async (request: ProcurementRequest) => {
setCreateOpen(false);
setActionError(null);
setActionSuccess(null);
let detail: ProcurementRequest;
try {
@ -614,7 +612,7 @@ export function useProcurementPage(user: User | null) {
if (modalMode === "create") {
await apiClient.createProcurementRequest(payload) as ProcurementRequest;
setActionSuccess("Заявка создана и направлена в отдел.");
toast.success("Заявка создана и направлена в отдел.");
setCreateOpen(false);
} else if (editingId) {
await apiClient.updateProcurementRequest(editingId, {
@ -624,7 +622,7 @@ export function useProcurementPage(user: User | null) {
processing_department: payload.processing_department,
items: payload.items,
});
setActionSuccess("Заявка обновлена.");
toast.success("Заявка обновлена.");
setEditingId(null);
}
@ -644,7 +642,7 @@ export function useProcurementPage(user: User | null) {
setBusyKey(key);
setActionError(null);
await action();
setActionSuccess(successMessage);
toast.success(successMessage);
await refreshAfterMutation(id);
return true;
} catch (actionErrorValue) {
@ -915,6 +913,13 @@ export function useProcurementPage(user: User | null) {
"Все позиции отмечены полученными.",
), [doAction]);
const handleNotifyArrival = useCallback((id: number) => doAction(
`notify-arrival-${id}`,
() => apiClient.notifyProcurementRequestArrival(id),
id,
"Заказчик уведомлён о поступлении.",
), [doAction]);
const handleToggleViewed = useCallback(async (id: number, isViewed: boolean) => {
try {
setBusyKey(`viewed-${id}`);
@ -958,7 +963,7 @@ export function useProcurementPage(user: User | null) {
}
}
await apiClient.updateProcurementItem(itemId, patch);
setActionSuccess("Позиция обновлена.");
toast.success("Позиция обновлена.");
await refreshAfterMutation(requestId);
return true;
} catch (updateError) {
@ -974,7 +979,7 @@ export function useProcurementPage(user: User | null) {
setBusyKey(`item-issue-${itemId}`);
setActionError(null);
await apiClient.reportProcurementItemIssue(itemId, text);
setActionSuccess("Позиция отмечена как проблемная.");
toast.success("Позиция отмечена как проблемная.");
await refreshAfterMutation(requestId);
return true;
} catch (issueError) {
@ -990,7 +995,7 @@ export function useProcurementPage(user: User | null) {
setBusyKey(`item-cancel-issue-${itemId}`);
setActionError(null);
await apiClient.cancelProcurementItemIssue(itemId);
setActionSuccess("Отметка брака снята.");
toast.success("Отметка брака снята.");
await refreshAfterMutation(requestId);
return true;
} catch (issueError) {
@ -1006,7 +1011,7 @@ export function useProcurementPage(user: User | null) {
setBusyKey(`item-confirm-received-${itemId}`);
setActionError(null);
await apiClient.confirmProcurementItemReceived(itemId, receivedQuantity);
setActionSuccess("Получение позиции подтверждено.");
toast.success("Получение позиции подтверждено.");
await refreshAfterMutation(requestId);
return true;
} catch (confirmError) {
@ -1022,7 +1027,7 @@ export function useProcurementPage(user: User | null) {
setBusyKey(`item-cancel-received-${itemId}`);
setActionError(null);
await apiClient.cancelProcurementItemReceived(itemId, cancelQuantity);
setActionSuccess("Получение позиции отменено.");
toast.success("Получение позиции отменено.");
await refreshAfterMutation(requestId);
return true;
} catch (cancelError) {
@ -1049,7 +1054,7 @@ export function useProcurementPage(user: User | null) {
detailsCacheRef.current = next;
return next;
});
setActionSuccess("Заявка удалена.");
toast.success("Заявка удалена.");
await refreshCurrentView();
return true;
} catch (deleteError) {
@ -1093,7 +1098,6 @@ export function useProcurementPage(user: User | null) {
loadingMore,
error,
actionError,
actionSuccess,
busyKey,
nextPage,
scopeCounts,
@ -1148,6 +1152,7 @@ export function useProcurementPage(user: User | null) {
handleStart,
handleComplete,
handleMarkAllReceived,
handleNotifyArrival,
handleToggleViewed,
handleUpdateItem,
handleReportItemIssue,

View File

@ -16,6 +16,7 @@ export function createProcurementApi(request: RequestFn) {
startWorkProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/start_work/`, { method: 'POST' }),
completeProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/complete/`, { method: 'POST' }),
markAllReceivedProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/mark_all_received/`, { method: 'POST' }),
notifyProcurementRequestArrival: (id: number) => request(`/api/v1/procurement/requests/${id}/notify_arrival/`, { method: 'POST' }),
setProcurementRequestViewed: (id: number, isViewed: boolean) =>
request(`/api/v1/procurement/requests/${id}/set_viewed/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_viewed: isViewed }) }),
cancelProcurementRequest: (id: number, reason?: string) =>

View File

@ -45,11 +45,14 @@ export const VERB_CATEGORIES: Record<string, string> = {
procurement_rejected: 'Закупки',
procurement_in_progress: 'Закупки',
procurement_executor_reassigned: 'Закупки',
procurement_arrival_notice: 'Закупки',
procurement_completed: 'Закупки',
procurement_cancelled: 'Закупки',
procurement_item_updated: 'Закупки',
procurement_request_commented: 'Закупки',
procurement_item_commented: 'Закупки',
equipment_transferred: 'Закупки',
equipment_maintenance: 'Закупки',
// Новости
feed_new_post: 'Новости',
@ -101,11 +104,14 @@ export const VERB_NAMES: Record<string, string> = {
procurement_rejected: 'Закупка отклонена',
procurement_in_progress: 'Закупка взята в работу',
procurement_executor_reassigned: 'Заявку забрал другой сотрудник',
procurement_arrival_notice: 'Поступление по закупке',
procurement_completed: 'Закупка завершена',
procurement_cancelled: 'Закупка отменена',
procurement_item_updated: 'Позиция закупки изменена',
procurement_request_commented: 'Комментарий к закупке',
procurement_item_commented: 'Комментарий к позиции закупки',
equipment_transferred: 'Оборудование передано',
equipment_maintenance: 'Обслуживание оборудования',
// Новости
feed_new_post: 'Новая публикация',
@ -121,7 +127,9 @@ export const VERB_NAMES: Record<string, string> = {
* Получить категорию для verb
*/
export function getVerbCategory(verb: string): string {
return VERB_CATEGORIES[verb] || 'Общее';
if (VERB_CATEGORIES[verb]) return VERB_CATEGORIES[verb];
if (verb.startsWith('procurement_') || verb.startsWith('equipment_')) return 'Закупки';
return 'Общее';
}
/**