feat: замещение пользователей (substitution) — заместители по умолчанию и по формам, контур ознакомления, inbox reasons

- таблица user_delegations (миграция 0067) + колонки delegated_from_user_id/pending_review в task_assignees
- delegation.service: резолвер с цепочкой и защитой от циклов, bulk-до-назначение чанками, чистка истёкших замещений
- центральный хук в addTaskAssignee: заместитель добавляется при любом назначении (ручном, автопереходе, user-поле, MCP)
- контур ознакомления: после перехода отсутствующий остаётся в исполнителях с pending_review, снимается кнопкой «Ознакомлен»
- inbox reasons delegation/delegation_review + фильтр «Замещение» на главной
- блок «Замещение» в профиле пользователя, бейдж «за <имя>» и баннер ознакомления в карточке задачи
This commit is contained in:
2026-08-05 10:44:55 +03:00
parent 17a8dc42f2
commit 667bddeac9
15 changed files with 1216 additions and 12 deletions

View File

@@ -16,6 +16,7 @@ import { indexTaskAsync } from "../routes/task-helpers";
import { webhookService } from "../services/webhook.service";
import { pushTaskUpdated } from "../utils/pushTaskUpdated";
import { notifyTaskAssigned } from "../utils/notifyAssignee";
import { ensureReviewAssignees, cleanupStaleDelegates } from "../services/delegation.service";
export interface EvaluateAutoTransitionsOptions {
triggeredBy?: number | null;
@@ -277,6 +278,11 @@ async function applyAutoTransition(
await storage.addTaskAssignee(task.id, targetAssigneeId, organizationId).catch(() => {});
}
// Замещение: вернуть отсутствующих исполнителей «на ознакомление»
// и снять заместителей с истёкшим замещением
await ensureReviewAssignees(task.id, organizationId);
await cleanupStaleDelegates(task.id, organizationId);
// Audit log — автопереход записывается от имени системы
storage
.addTaskAuditLog({

View File

@@ -17,8 +17,10 @@ export async function notifyTaskAssigned(
task: { id: number; title: string; formId: number },
assigneeUserId: number,
triggeredBy: number | null,
organizationId: number
organizationId: number,
options?: { delegatedFromName?: string }
): Promise<number[]> {
const delegationSuffix = options?.delegatedFromName ? ` (вы замещаете ${options.delegatedFromName})` : "";
const eventBase = {
type: EVENT_TYPES.TASK_ASSIGNED,
organizationId,
@@ -29,7 +31,10 @@ export async function notifyTaskAssigned(
taskTitle: task.title,
assignedTo: assigneeUserId,
authorName: triggeredBy ? undefined : "Система",
message: `Вас назначили ответственным по задаче "${task.title}"`,
message: `Вас назначили ответственным по задаче "${task.title}"${delegationSuffix}`,
...(options?.delegatedFromName
? { delegation: true, delegatedFromName: options.delegatedFromName }
: {}),
},
timestamp: new Date(),
};