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:
485
server/services/delegation.service.ts
Normal file
485
server/services/delegation.service.ts
Normal file
@@ -0,0 +1,485 @@
|
||||
import { db } from "../db";
|
||||
import {
|
||||
userDelegations,
|
||||
taskAssignees,
|
||||
tasks,
|
||||
users,
|
||||
forms,
|
||||
type UserDelegation,
|
||||
} from "@shared/schema";
|
||||
import { eq, and, or, isNull, isNotNull, sql, desc } from "drizzle-orm";
|
||||
import { storage } from "../storage";
|
||||
import { notifyTaskAssigned } from "../utils/notifyAssignee";
|
||||
import { logAudit } from "../utils/audit";
|
||||
|
||||
/**
|
||||
* Сервис замещения пользователей (delegation/substitution, референс — Pyrus).
|
||||
*
|
||||
* Модель:
|
||||
* - user_delegations — кто кого замещает, на какой период и (опционально) по какой форме;
|
||||
* form_id IS NULL — заместитель по умолчанию для всех форм.
|
||||
* - При назначении пользователя U исполнителем задачи активный заместитель D
|
||||
* добавляется в task_assignees рядом с U (строка с delegated_from_user_id = U).
|
||||
* - Контур ознакомления: после пересчёта исполнителей при переходе отсутствующий U
|
||||
* возвращается в task_assignees с pending_review = true, пока не подтвердит
|
||||
* ознакомление (acknowledgeDelegationReview).
|
||||
*/
|
||||
|
||||
function formatUserName(u: { firstName: string | null; lastName: string | null; middleName?: string | null; email: string } | null | undefined): string {
|
||||
if (!u) return "";
|
||||
return (`${u.firstName || ""} ${u.middleName || ""} ${u.lastName || ""}`.trim()) || u.email;
|
||||
}
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ищет активное на сегодня замещение пользователя.
|
||||
* Приоритет: точное совпадение form_id, затем запись по умолчанию (form_id IS NULL).
|
||||
*/
|
||||
async function findActiveDelegation(
|
||||
userId: number,
|
||||
formId: number | null,
|
||||
organizationId: number
|
||||
): Promise<UserDelegation | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(userDelegations)
|
||||
.where(
|
||||
and(
|
||||
eq(userDelegations.organizationId, organizationId),
|
||||
eq(userDelegations.userId, userId),
|
||||
eq(userDelegations.isActive, true),
|
||||
sql`${userDelegations.dateFrom} <= CURRENT_DATE`,
|
||||
sql`${userDelegations.dateTo} >= CURRENT_DATE`,
|
||||
formId
|
||||
? or(eq(userDelegations.formId, formId), isNull(userDelegations.formId))
|
||||
: isNull(userDelegations.formId)
|
||||
)
|
||||
)
|
||||
// Сначала точное совпадение формы (form_id IS NULL → false → первым)
|
||||
.orderBy(sql`(${userDelegations.formId} IS NULL) ASC`, sql`${userDelegations.dateTo} ASC`)
|
||||
.limit(1);
|
||||
const delegation = rows[0];
|
||||
// Защита от самоназначения
|
||||
if (!delegation || delegation.delegateUserId === userId) return null;
|
||||
return delegation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Резолвит активного заместителя пользователя с учётом цепочки
|
||||
* (1 дополнительный уровень: если у заместителя тоже есть замещение —
|
||||
* берём его заместителя) и защитой от циклов через visited-set.
|
||||
*/
|
||||
export async function getActiveDelegate(
|
||||
userId: number,
|
||||
formId: number | null,
|
||||
organizationId: number,
|
||||
visited: Set<number> = new Set()
|
||||
): Promise<number | null> {
|
||||
if (visited.has(userId)) return null;
|
||||
visited.add(userId);
|
||||
const delegation = await findActiveDelegation(userId, formId, organizationId);
|
||||
if (!delegation) return null;
|
||||
const delegateId = delegation.delegateUserId;
|
||||
if (delegateId === userId || visited.has(delegateId)) return null;
|
||||
// Цепочка: один дополнительный уровень вложенности
|
||||
const nested = await findActiveDelegation(delegateId, formId, organizationId);
|
||||
if (nested && nested.delegateUserId !== delegateId && !visited.has(nested.delegateUserId)) {
|
||||
return nested.delegateUserId;
|
||||
}
|
||||
return delegateId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Вызывается при назначении пользователя исполнителем задачи.
|
||||
* Резолвит заместителя и добавляет его в task_assignees рядом с назначенным
|
||||
* (tasks.assignedTo не меняется). Идемпотентно: дубликаты игнорируются.
|
||||
*/
|
||||
export async function applyDelegationsOnAssign(
|
||||
taskId: number,
|
||||
userId: number,
|
||||
organizationId: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const task = await storage.getTask(taskId, organizationId);
|
||||
if (!task) return;
|
||||
const delegateId = await getActiveDelegate(userId, task.formId, organizationId);
|
||||
if (!delegateId) return;
|
||||
|
||||
const existing = await db
|
||||
.select({ id: taskAssignees.id })
|
||||
.from(taskAssignees)
|
||||
.where(and(eq(taskAssignees.taskId, taskId), eq(taskAssignees.userId, delegateId)))
|
||||
.limit(1);
|
||||
if (existing.length > 0) return; // заместитель уже исполнитель — ничего не делаем
|
||||
|
||||
await storage.addTaskAssignee(taskId, delegateId, organizationId, { delegatedFromUserId: userId });
|
||||
|
||||
const [absentUser, delegateUser] = await Promise.all([
|
||||
storage.getUser(userId),
|
||||
storage.getUser(delegateId),
|
||||
]);
|
||||
const absentName = formatUserName(absentUser) || String(userId);
|
||||
const delegateName = formatUserName(delegateUser) || String(delegateId);
|
||||
|
||||
storage
|
||||
.addTaskAuditLog({
|
||||
taskId,
|
||||
organizationId,
|
||||
action: "task.updated",
|
||||
fieldName: "Ответственный добавлен",
|
||||
oldValue: null,
|
||||
newValue: `${delegateName} (замещение: ${absentName})`,
|
||||
changedBy: null,
|
||||
changedByName: "Замещение",
|
||||
metadata: { delegation: true, delegatedFrom: userId, delegateUserId: delegateId },
|
||||
})
|
||||
.catch((e: unknown) => console.error("[Delegation] audit log error:", e));
|
||||
|
||||
notifyTaskAssigned(task, delegateId, null, organizationId, { delegatedFromName: absentName }).catch(
|
||||
(err) => console.error("[Delegation] notifyTaskAssigned error:", err)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[Delegation] applyDelegationsOnAssign error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Контур ознакомления. Вызывается ПОСЛЕ пересчёта исполнителей при переходе.
|
||||
* Находит отсутствующих исполнителей U с активным замещением, чей заместитель
|
||||
* есть среди исполнителей задачи (строка delegated_from_user_id = U), и возвращает
|
||||
* U в task_assignees с pending_review = true (если строки нет — создаёт).
|
||||
*/
|
||||
export async function ensureReviewAssignees(taskId: number, organizationId: number): Promise<void> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(taskAssignees)
|
||||
.where(and(eq(taskAssignees.taskId, taskId), eq(taskAssignees.organizationId, organizationId)));
|
||||
|
||||
const delegatedFromIds = [
|
||||
...new Set(
|
||||
rows.filter((r) => r.delegatedFromUserId != null).map((r) => r.delegatedFromUserId as number)
|
||||
),
|
||||
];
|
||||
if (delegatedFromIds.length === 0) return;
|
||||
|
||||
const task = await storage.getTask(taskId, organizationId);
|
||||
if (!task) return;
|
||||
|
||||
const reviewNames: string[] = [];
|
||||
for (const absentId of delegatedFromIds) {
|
||||
// Возвращаем на ознакомление только пока замещение активно
|
||||
const active = await findActiveDelegation(absentId, task.formId, organizationId);
|
||||
if (!active) continue;
|
||||
|
||||
const existingRow = rows.find((r) => r.userId === absentId);
|
||||
if (existingRow) {
|
||||
if (existingRow.pendingReview) continue; // уже на ознакомлении
|
||||
await db
|
||||
.update(taskAssignees)
|
||||
.set({ pendingReview: true })
|
||||
.where(eq(taskAssignees.id, existingRow.id));
|
||||
} else {
|
||||
await storage.addTaskAssignee(taskId, absentId, organizationId, { pendingReview: true });
|
||||
}
|
||||
const u = await storage.getUser(absentId);
|
||||
reviewNames.push(formatUserName(u) || String(absentId));
|
||||
}
|
||||
|
||||
if (reviewNames.length > 0) {
|
||||
storage
|
||||
.addTaskAuditLog({
|
||||
taskId,
|
||||
organizationId,
|
||||
action: "task.updated",
|
||||
fieldName: "Замещение: на ознакомление",
|
||||
oldValue: null,
|
||||
newValue: reviewNames.join(", "),
|
||||
changedBy: null,
|
||||
changedByName: "Замещение",
|
||||
metadata: { delegation: true, pendingReview: true },
|
||||
})
|
||||
.catch((e: unknown) => console.error("[Delegation] audit log (review) error:", e));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Delegation] ensureReviewAssignees error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Подтверждение ознакомления: удаляет строку task_assignees пользователя,
|
||||
* если она помечена pending_review = true. Возвращает true, если строка была снята.
|
||||
*/
|
||||
export async function acknowledgeDelegationReview(
|
||||
taskId: number,
|
||||
userId: number,
|
||||
organizationId: number
|
||||
): Promise<boolean> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(taskAssignees)
|
||||
.where(
|
||||
and(
|
||||
eq(taskAssignees.taskId, taskId),
|
||||
eq(taskAssignees.userId, userId),
|
||||
eq(taskAssignees.organizationId, organizationId),
|
||||
eq(taskAssignees.pendingReview, true)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (rows.length === 0) return false;
|
||||
|
||||
await storage.removeTaskAssignee(taskId, userId, organizationId);
|
||||
|
||||
const u = await storage.getUser(userId);
|
||||
const name = formatUserName(u) || String(userId);
|
||||
storage
|
||||
.addTaskAuditLog({
|
||||
taskId,
|
||||
organizationId,
|
||||
action: "task.updated",
|
||||
fieldName: "Замещение: ознакомление подтверждено",
|
||||
oldValue: name,
|
||||
newValue: null,
|
||||
changedBy: userId,
|
||||
changedByName: name,
|
||||
metadata: { delegation: true },
|
||||
})
|
||||
.catch((e: unknown) => console.error("[Delegation] audit log (ack) error:", e));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Снимает строки заместителей, у которых замещение истекло/удалено/деактивировано
|
||||
* (Pyrus: «при изменении задачи заместитель удаляется из маршрута»).
|
||||
* Строки с delegated_from_user_id IS NULL (прямые назначения) не трогаем.
|
||||
*/
|
||||
export async function cleanupStaleDelegates(taskId: number, organizationId: number): Promise<void> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(taskAssignees)
|
||||
.where(
|
||||
and(
|
||||
eq(taskAssignees.taskId, taskId),
|
||||
eq(taskAssignees.organizationId, organizationId),
|
||||
isNotNull(taskAssignees.delegatedFromUserId)
|
||||
)
|
||||
);
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const task = await storage.getTask(taskId, organizationId);
|
||||
if (!task) return;
|
||||
|
||||
for (const row of rows) {
|
||||
// Проверяем через резолвер — так корректно обрабатываются цепочки замещений
|
||||
const activeDelegateId = await getActiveDelegate(row.delegatedFromUserId as number, task.formId, organizationId);
|
||||
if (activeDelegateId !== row.userId) {
|
||||
await db.delete(taskAssignees).where(eq(taskAssignees.id, row.id));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Delegation] cleanupStaleDelegates error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// CRUD замещений (REST API)
|
||||
// =====================
|
||||
|
||||
export async function listDelegations(userId: number, organizationId: number) {
|
||||
return db
|
||||
.select({
|
||||
id: userDelegations.id,
|
||||
userId: userDelegations.userId,
|
||||
delegateUserId: userDelegations.delegateUserId,
|
||||
formId: userDelegations.formId,
|
||||
dateFrom: userDelegations.dateFrom,
|
||||
dateTo: userDelegations.dateTo,
|
||||
isActive: userDelegations.isActive,
|
||||
createdBy: userDelegations.createdBy,
|
||||
createdAt: userDelegations.createdAt,
|
||||
delegateName: sql<string>`concat(${users.firstName}, ' ', ${users.lastName})`,
|
||||
formName: forms.name,
|
||||
})
|
||||
.from(userDelegations)
|
||||
.leftJoin(users, eq(users.id, userDelegations.delegateUserId))
|
||||
.leftJoin(forms, eq(forms.id, userDelegations.formId))
|
||||
.where(and(eq(userDelegations.userId, userId), eq(userDelegations.organizationId, organizationId)))
|
||||
.orderBy(desc(userDelegations.createdAt));
|
||||
}
|
||||
|
||||
export async function getDelegation(delegationId: number): Promise<UserDelegation | null> {
|
||||
const rows = await db.select().from(userDelegations).where(eq(userDelegations.id, delegationId)).limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создание замещения с проверкой пересечения периодов
|
||||
* для той же пары (user_id, delegate_user_id, form_id).
|
||||
*/
|
||||
export async function createDelegation(data: {
|
||||
organizationId: number;
|
||||
userId: number;
|
||||
delegateUserId: number;
|
||||
formId: number | null;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
createdBy: number | null;
|
||||
}): Promise<{ conflict: true } | { conflict: false; delegation: UserDelegation }> {
|
||||
const overlaps = await db
|
||||
.select({ id: userDelegations.id })
|
||||
.from(userDelegations)
|
||||
.where(
|
||||
and(
|
||||
eq(userDelegations.organizationId, data.organizationId),
|
||||
eq(userDelegations.userId, data.userId),
|
||||
eq(userDelegations.delegateUserId, data.delegateUserId),
|
||||
data.formId ? eq(userDelegations.formId, data.formId) : isNull(userDelegations.formId),
|
||||
eq(userDelegations.isActive, true),
|
||||
sql`${userDelegations.dateFrom} <= ${data.dateTo}::date`,
|
||||
sql`${userDelegations.dateTo} >= ${data.dateFrom}::date`
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (overlaps.length > 0) return { conflict: true };
|
||||
|
||||
const [delegation] = await db
|
||||
.insert(userDelegations)
|
||||
.values({
|
||||
organizationId: data.organizationId,
|
||||
userId: data.userId,
|
||||
delegateUserId: data.delegateUserId,
|
||||
formId: data.formId,
|
||||
dateFrom: data.dateFrom,
|
||||
dateTo: data.dateTo,
|
||||
isActive: true,
|
||||
createdBy: data.createdBy,
|
||||
})
|
||||
.returning();
|
||||
return { conflict: false, delegation };
|
||||
}
|
||||
|
||||
export async function updateDelegation(
|
||||
delegationId: number,
|
||||
userId: number,
|
||||
organizationId: number,
|
||||
patch: { isActive?: boolean; dateFrom?: string; dateTo?: string }
|
||||
): Promise<UserDelegation | null> {
|
||||
const [updated] = await db
|
||||
.update(userDelegations)
|
||||
.set(patch)
|
||||
.where(
|
||||
and(
|
||||
eq(userDelegations.id, delegationId),
|
||||
eq(userDelegations.userId, userId),
|
||||
eq(userDelegations.organizationId, organizationId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
export async function deleteDelegation(
|
||||
delegationId: number,
|
||||
userId: number,
|
||||
organizationId: number
|
||||
): Promise<boolean> {
|
||||
const deleted = await db
|
||||
.delete(userDelegations)
|
||||
.where(
|
||||
and(
|
||||
eq(userDelegations.id, delegationId),
|
||||
eq(userDelegations.userId, userId),
|
||||
eq(userDelegations.organizationId, organizationId)
|
||||
)
|
||||
)
|
||||
.returning({ id: userDelegations.id });
|
||||
return deleted.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Массовое до-назначение: заместитель D добавляется во все незавершённые задачи,
|
||||
* где отсутствующий U — исполнитель (assigned_to или task_assignees).
|
||||
* Если form_id записи NULL — все формы, иначе только эта форма.
|
||||
* Обработка чанками по 50 с паузой, чтобы не перегружать БД.
|
||||
*/
|
||||
export async function bulkAssignDelegate(delegationId: number): Promise<void> {
|
||||
try {
|
||||
const delegation = await getDelegation(delegationId);
|
||||
if (!delegation) return;
|
||||
|
||||
// До-назначаем только актуальное замещение (активно и сегодня внутри периода)
|
||||
const today = todayIso();
|
||||
if (!delegation.isActive || delegation.dateFrom > today || delegation.dateTo < today) return;
|
||||
|
||||
const tasksToUpdate = await db
|
||||
.select({ id: tasks.id, title: tasks.title, formId: tasks.formId })
|
||||
.from(tasks)
|
||||
.where(
|
||||
and(
|
||||
eq(tasks.organizationId, delegation.organizationId),
|
||||
eq(tasks.isCompleted, false),
|
||||
delegation.formId ? eq(tasks.formId, delegation.formId) : undefined,
|
||||
sql`(${tasks.assignedTo} = ${delegation.userId} OR EXISTS (
|
||||
SELECT 1 FROM task_assignees ta
|
||||
WHERE ta.task_id = ${tasks.id} AND ta.user_id = ${delegation.userId}
|
||||
))`
|
||||
)
|
||||
);
|
||||
|
||||
if (tasksToUpdate.length === 0) return;
|
||||
|
||||
const absentUser = await storage.getUser(delegation.userId);
|
||||
const absentName = formatUserName(absentUser) || String(delegation.userId);
|
||||
|
||||
const CHUNK_SIZE = 50;
|
||||
let affected = 0;
|
||||
for (let i = 0; i < tasksToUpdate.length; i += CHUNK_SIZE) {
|
||||
const chunk = tasksToUpdate.slice(i, i + CHUNK_SIZE);
|
||||
for (const t of chunk) {
|
||||
const existing = await db
|
||||
.select({ id: taskAssignees.id })
|
||||
.from(taskAssignees)
|
||||
.where(and(eq(taskAssignees.taskId, t.id), eq(taskAssignees.userId, delegation.delegateUserId)))
|
||||
.limit(1);
|
||||
if (existing.length > 0) continue;
|
||||
await storage
|
||||
.addTaskAssignee(t.id, delegation.delegateUserId, delegation.organizationId, {
|
||||
delegatedFromUserId: delegation.userId,
|
||||
})
|
||||
.catch(() => {});
|
||||
affected++;
|
||||
notifyTaskAssigned(
|
||||
{ id: t.id, title: t.title, formId: t.formId },
|
||||
delegation.delegateUserId,
|
||||
null,
|
||||
delegation.organizationId,
|
||||
{ delegatedFromName: absentName }
|
||||
).catch((err) => console.error("[Delegation] bulk notify error:", err));
|
||||
}
|
||||
// Пауза между чанками — не перегружаем БД и шину уведомлений
|
||||
if (i + CHUNK_SIZE < tasksToUpdate.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
logAudit({
|
||||
action: "delegation.bulk_assign",
|
||||
organizationId: delegation.organizationId,
|
||||
userId: delegation.userId,
|
||||
details: {
|
||||
delegationId,
|
||||
delegateUserId: delegation.delegateUserId,
|
||||
formId: delegation.formId,
|
||||
tasksScanned: tasksToUpdate.length,
|
||||
tasksAffected: affected,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[Delegation] bulkAssignDelegate error:", err);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { tasksMinimalCache } from "../utils/cache";
|
||||
import { indexTaskAsync } from "../routes/task-helpers";
|
||||
import { fieldConditionsMatch, resolveFieldDisplayText, isFieldValueEmpty, SYSTEM_TASK_FIELDS, buildSystemFieldValuesForConditions } from "../utils/field-conditions";
|
||||
import { validateRequiredFieldsDetailed } from "../utils/validate-required-fields";
|
||||
import { ensureReviewAssignees, cleanupStaleDelegates } from "./delegation.service";
|
||||
|
||||
export interface ExecuteTaskTransitionOptions {
|
||||
taskId: number;
|
||||
@@ -426,6 +427,11 @@ export async function executeTaskTransition(options: ExecuteTaskTransitionOption
|
||||
});
|
||||
}
|
||||
|
||||
// Замещение: вернуть отсутствующих исполнителей «на ознакомление»
|
||||
// и снять заместителей с истёкшим замещением
|
||||
await ensureReviewAssignees(taskId, organizationId);
|
||||
await cleanupStaleDelegates(taskId, organizationId);
|
||||
|
||||
// If a role was resolved as assignee — assign it to the task
|
||||
if (resolvedRoleId !== null) {
|
||||
await storage.addTaskRole(taskId, resolvedRoleId, organizationId).catch(err => {
|
||||
|
||||
Reference in New Issue
Block a user