- таблица user_delegations (миграция 0067) + колонки delegated_from_user_id/pending_review в task_assignees - delegation.service: резолвер с цепочкой и защитой от циклов, bulk-до-назначение чанками, чистка истёкших замещений - центральный хук в addTaskAssignee: заместитель добавляется при любом назначении (ручном, автопереходе, user-поле, MCP) - контур ознакомления: после перехода отсутствующий остаётся в исполнителях с pending_review, снимается кнопкой «Ознакомлен» - inbox reasons delegation/delegation_review + фильтр «Замещение» на главной - блок «Замещение» в профиле пользователя, бейдж «за <имя>» и баннер ознакомления в карточке задачи
369 lines
16 KiB
TypeScript
369 lines
16 KiB
TypeScript
/**
|
||
* Движок автоматических переходов по статусам на основе entryConditions.
|
||
*
|
||
* При создании или редактировании задачи система проходит статусы формы
|
||
* по порядку position и проверяет entryConditions. Если условия статуса
|
||
* совпадают со значениями полей задачи — задача автоматически переходит
|
||
* в этот статус (с возможной цепочкой переходов).
|
||
*/
|
||
|
||
import { storage } from "../storage";
|
||
|
||
import { fieldConditionsMatch, resolveFieldDisplayText, type FieldConditionContext, SYSTEM_TASK_FIELDS, buildSystemFieldValuesForConditions } from "./field-conditions";
|
||
import { resolveTaskFieldTitles, buildTableRowMap, formatFieldValueForTitle, eventBus } from "../routes/shared";
|
||
import { tasksMinimalCache } from "./cache";
|
||
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;
|
||
maxDepth?: number;
|
||
preloaded?: {
|
||
statuses?: any[];
|
||
formFields?: any[];
|
||
users?: any[];
|
||
roles?: any[];
|
||
transitions?: any[];
|
||
tableMap?: Map<number, { rows: Array<{ id: number; values: string[] }>; columns: Array<{ name: string }> }>;
|
||
};
|
||
}
|
||
|
||
export interface AutoTransitionResult {
|
||
changed: boolean;
|
||
task: { id: number; currentStatusId: number; title: string; formId: number; assignedTo: number | null };
|
||
visitedStatusIds: number[];
|
||
}
|
||
|
||
const DEFAULT_MAX_DEPTH = 10;
|
||
|
||
/**
|
||
* Проверяет entryConditions всех статусов формы и выполняет автопереход
|
||
* в первый подходящий статус. Поддерживает цепочки (A → B → C).
|
||
*/
|
||
export async function evaluateAutoTransitions(
|
||
taskId: number,
|
||
organizationId: number,
|
||
options: EvaluateAutoTransitionsOptions = {}
|
||
): Promise<AutoTransitionResult> {
|
||
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
||
const visited = new Set<number>();
|
||
|
||
const _task = await storage.getTask(taskId, organizationId);
|
||
if (!_task) {
|
||
return { changed: false, task: { id: taskId, currentStatusId: 0, title: "", formId: 0, assignedTo: null }, visitedStatusIds: [] };
|
||
}
|
||
let task: any = _task;
|
||
const formId = task.formId;
|
||
|
||
const [statuses, formFields, fieldValues] = await Promise.all([
|
||
options.preloaded?.statuses ? Promise.resolve(options.preloaded.statuses) : storage.getFormStatuses(formId, organizationId),
|
||
options.preloaded?.formFields ? Promise.resolve(options.preloaded.formFields) : storage.getFormFields(formId, organizationId),
|
||
storage.getTaskFieldValues(taskId, organizationId),
|
||
]);
|
||
|
||
// Merge system fields so rules can reference _assignee, _status, _due_date, etc.
|
||
const currentStatusName = statuses.find((s: any) => s.id === task.currentStatusId)?.name;
|
||
const systemFieldValues = buildSystemFieldValuesForConditions(task, { statusName: currentStatusName });
|
||
const allFields = [...formFields, ...SYSTEM_TASK_FIELDS];
|
||
let allFieldValues = [...fieldValues, ...systemFieldValues];
|
||
|
||
// Build condition context for field matching
|
||
const orgUsers = options.preloaded?.users ? options.preloaded.users : await storage.getUsersByOrganization(organizationId);
|
||
const orgRoles = options.preloaded?.roles ? options.preloaded.roles : await storage.getRoles(organizationId);
|
||
|
||
const usersWithFullName = orgUsers.map((u: any) => ({
|
||
...u,
|
||
fullName: `${u.firstName || ""} ${u.middleName || ""} ${u.lastName || ""}`.trim() || u.email,
|
||
}));
|
||
|
||
const valueByFieldId = new Map(fieldValues.map((v: any) => [v.fieldId, v.value]));
|
||
const taskTitleMap = await resolveTaskFieldTitles(valueByFieldId, formFields, organizationId);
|
||
|
||
const tableIds = new Set<number>();
|
||
for (const f of formFields) {
|
||
if ((f as any).type === "table") {
|
||
const tid = ((f as any).options as Record<string, number> | undefined)?.tableId;
|
||
if (tid) tableIds.add(tid);
|
||
}
|
||
}
|
||
let tableMap = options.preloaded?.tableMap;
|
||
if (!tableMap) {
|
||
tableMap = new Map<number, { rows: Array<{ id: number; values: string[] }>; columns: Array<{ name: string }> }>();
|
||
const _storage = storage as any;
|
||
for (const tableId of tableIds) {
|
||
const fullTable = await _storage.getDataTableFull(tableId, organizationId);
|
||
if (fullTable) {
|
||
tableMap.set(tableId, {
|
||
rows: fullTable.rows as Array<{ id: number; values: string[] }>,
|
||
columns: fullTable.columns as Array<{ name: string }>,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
const transitions = options.preloaded?.transitions ? options.preloaded.transitions : await storage.getStatusTransitions(formId, organizationId);
|
||
|
||
const conditionContext: FieldConditionContext = {
|
||
users: usersWithFullName,
|
||
roles: orgRoles,
|
||
taskTitleMap,
|
||
tableMap,
|
||
};
|
||
|
||
// Try to chain auto-transitions
|
||
for (let depth = 0; depth < maxDepth; depth++) {
|
||
const currentStatusId = task.currentStatusId;
|
||
if (visited.has(currentStatusId)) break;
|
||
visited.add(currentStatusId);
|
||
|
||
const sortedStatuses = statuses.slice().sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||
const currentStatus = sortedStatuses.find((s) => s.id === currentStatusId);
|
||
const currentPosition = currentStatus?.position ?? 0;
|
||
|
||
let targetStatusId: number | null = null;
|
||
for (const status of sortedStatuses) {
|
||
if (status.id === currentStatusId) continue;
|
||
// Автопереходы должны двигать задачу только вперёд по workflow.
|
||
// Откат в статус с меньшим position (например, из «В работе» обратно в «Назначена»)
|
||
// ломает ручные переходы и скрывает/показывает поля по неправильным правилам.
|
||
if ((status.position ?? 0) <= currentPosition) continue;
|
||
if (!status.entryConditions || !Array.isArray(status.entryConditions) || status.entryConditions.length === 0) continue;
|
||
|
||
const matches = fieldConditionsMatch(
|
||
status.entryConditions as Array<{ fieldCode: string; value: string }>,
|
||
allFieldValues,
|
||
allFields,
|
||
conditionContext
|
||
);
|
||
|
||
if (matches) {
|
||
targetStatusId = status.id;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!targetStatusId || targetStatusId === currentStatusId) {
|
||
break;
|
||
}
|
||
|
||
// Apply the transition
|
||
task = await applyAutoTransition(task, targetStatusId, statuses, allFields, allFieldValues, conditionContext, organizationId, transitions, options.triggeredBy);
|
||
|
||
// Refresh system field values after transition so chained transitions evaluate against updated state
|
||
const newStatusName = statuses.find((s: any) => s.id === task.currentStatusId)?.name;
|
||
const newSystemFieldValues = buildSystemFieldValuesForConditions(task, { statusName: newStatusName });
|
||
allFieldValues = [...fieldValues, ...newSystemFieldValues];
|
||
}
|
||
|
||
const changed = visited.size > 1;
|
||
return {
|
||
changed,
|
||
task: { id: task.id, currentStatusId: task.currentStatusId, title: task.title, formId: task.formId, assignedTo: task.assignedTo },
|
||
visitedStatusIds: Array.from(visited),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Применяет один автопереход с учётом side effects.
|
||
*/
|
||
async function applyAutoTransition(
|
||
task: any,
|
||
targetStatusId: number,
|
||
statuses: any[],
|
||
formFields: any[],
|
||
fieldValues: any[],
|
||
conditionContext: FieldConditionContext,
|
||
organizationId: number,
|
||
transitions: any[],
|
||
triggeredBy: number | null = null
|
||
): Promise<any> {
|
||
const fromStatus = statuses.find((s) => s.id === task.currentStatusId);
|
||
const toStatus = statuses.find((s) => s.id === targetStatusId);
|
||
const isFinalStatus = toStatus?.isFinal || false;
|
||
|
||
// Determine assignee from transitions (if any transition from current → target has assignee rules)
|
||
let targetAssigneeId: number | null = task.assignedTo;
|
||
const matchingTransitions = transitions.filter(
|
||
(t: any) => t.fromStatusId === task.currentStatusId && t.toStatusId === targetStatusId
|
||
);
|
||
|
||
for (const transition of matchingTransitions) {
|
||
if (transition.assigneeUserId) {
|
||
targetAssigneeId = transition.assigneeUserId;
|
||
break;
|
||
}
|
||
if (transition.assigneeConditions) {
|
||
const conditions = transition.assigneeConditions as Record<string, unknown>;
|
||
if (conditions.assignees && Array.isArray(conditions.assignees)) {
|
||
for (const assignee of conditions.assignees) {
|
||
if ((assignee.type === 'user' || assignee.type === 'role') && !assignee.id) continue;
|
||
if ((assignee.type === 'field_user' || assignee.type === 'table_column_user') && !assignee.fieldCode) continue;
|
||
const rulesMatch = fieldConditionsMatch(assignee.rules || [], fieldValues, formFields, conditionContext);
|
||
if (!rulesMatch) continue;
|
||
if (assignee.type === "user") {
|
||
targetAssigneeId = assignee.id;
|
||
break;
|
||
}
|
||
if (assignee.type === "role") {
|
||
const members = await storage.getRoleMembersByRole(assignee.id);
|
||
const firstUser = members.find((m: any) => m.userId);
|
||
if (firstUser) {
|
||
targetAssigneeId = firstUser.userId;
|
||
break;
|
||
}
|
||
}
|
||
if (assignee.type === 'field_user') {
|
||
let userId: number | undefined;
|
||
if (assignee.fieldCode === '_assignee') {
|
||
userId = task.assignedTo ?? undefined;
|
||
} else if (assignee.fieldCode === '_author') {
|
||
userId = task.createdBy ?? undefined;
|
||
} else {
|
||
const field = formFields.find(f => f.code === assignee.fieldCode);
|
||
const fieldValue = fieldValues.find((fv: any) => fv.fieldId === field?.id);
|
||
userId = fieldValue?.value ? parseInt(String(fieldValue.value)) : undefined;
|
||
}
|
||
if (userId && userId > 0) {
|
||
targetAssigneeId = userId;
|
||
break;
|
||
}
|
||
}
|
||
if (assignee.type === 'table_column_user') {
|
||
const field = formFields.find(f => f.code === assignee.fieldCode);
|
||
const fieldValue = fieldValues.find((fv: any) => fv.fieldId === field?.id);
|
||
const rowId = fieldValue?.value ? parseInt(String(fieldValue.value)) : NaN;
|
||
if (!isNaN(rowId) && rowId > 0) {
|
||
const tableId = (field as any)?.options?.tableId as number;
|
||
if (tableId) {
|
||
const row = await storage.getDataTableRow(rowId, tableId, organizationId);
|
||
if (row) {
|
||
const userId = parseInt(String((row.values as unknown[])[assignee.columnIndex as number] ?? ''));
|
||
if (!isNaN(userId) && userId > 0) {
|
||
targetAssigneeId = userId;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (conditions.fallbackAssigneeUserId && targetAssigneeId === task.assignedTo) {
|
||
targetAssigneeId = conditions.fallbackAssigneeUserId as number;
|
||
}
|
||
if (targetAssigneeId !== task.assignedTo) break;
|
||
}
|
||
}
|
||
|
||
const updateData: Record<string, unknown> = {
|
||
currentStatusId: targetStatusId,
|
||
assignedTo: targetAssigneeId,
|
||
};
|
||
if (isFinalStatus) {
|
||
updateData.isCompleted = true;
|
||
updateData.completedAt = new Date();
|
||
} else {
|
||
updateData.isCompleted = false;
|
||
updateData.completedAt = null;
|
||
}
|
||
|
||
const updatedTask = await storage.updateTask(task.id, organizationId, updateData);
|
||
tasksMinimalCache.invalidatePrefix(`tasks:${organizationId}:minimal:`);
|
||
|
||
// Sync the primary assignee into task_assignees so TaskDetail can display it
|
||
if (targetAssigneeId) {
|
||
await storage.addTaskAssignee(task.id, targetAssigneeId, organizationId).catch(() => {});
|
||
}
|
||
|
||
// Замещение: вернуть отсутствующих исполнителей «на ознакомление»
|
||
// и снять заместителей с истёкшим замещением
|
||
await ensureReviewAssignees(task.id, organizationId);
|
||
await cleanupStaleDelegates(task.id, organizationId);
|
||
|
||
// Audit log — автопереход записывается от имени системы
|
||
storage
|
||
.addTaskAuditLog({
|
||
taskId: task.id,
|
||
organizationId,
|
||
action: "status.changed",
|
||
fieldName: "Статус",
|
||
oldValue: fromStatus?.name ?? String(task.currentStatusId),
|
||
newValue: toStatus?.name ?? String(targetStatusId),
|
||
changedBy: null,
|
||
changedByName: "Автопереход",
|
||
metadata: { autoTransition: true },
|
||
})
|
||
.catch(() => {});
|
||
|
||
// SSE
|
||
eventBus.publishEvent({
|
||
type: "task_updated",
|
||
data: { taskId: task.id, formId: task.formId, task: updatedTask },
|
||
organizationId,
|
||
});
|
||
|
||
// Web Push
|
||
pushTaskUpdated({
|
||
taskId: task.id,
|
||
organizationId,
|
||
formId: task.formId,
|
||
taskTitle: updatedTask.title,
|
||
actorId: triggeredBy ?? 0,
|
||
}).catch(() => {});
|
||
|
||
// Webhook
|
||
webhookService
|
||
.dispatchStatusChange(organizationId, task.id, task.formId, task.currentStatusId, targetStatusId, 0, triggeredBy ?? 0)
|
||
.catch(() => {});
|
||
|
||
// Notifications
|
||
if (targetAssigneeId && targetAssigneeId !== task.assignedTo) {
|
||
notifyTaskAssigned(updatedTask, targetAssigneeId, triggeredBy, organizationId).catch((err) => {
|
||
console.error("[AutoTransition] notifyTaskAssigned error:", err);
|
||
});
|
||
}
|
||
|
||
// Title recalc
|
||
const form = await storage.getForm(task.formId, organizationId);
|
||
if (form?.titleTemplate) {
|
||
const [allFormFields, savedValues, titleStatuses, titleUsers, titleRoles] = await Promise.all([
|
||
storage.getFormFields(task.formId, organizationId),
|
||
storage.getTaskFieldValues(task.id, organizationId),
|
||
storage.getFormStatuses(task.formId, organizationId),
|
||
storage.getUsersByOrganization(organizationId),
|
||
storage.getRoles(organizationId),
|
||
]);
|
||
const { buildSystemFieldValues } = await import("../routes/shared.js");
|
||
const fieldCodeMap = new Map(allFormFields.map((f: any) => [f.code, f]));
|
||
const valueByFieldId = new Map(savedValues.map((v: any) => [v.fieldId, v.value]));
|
||
const usersMap = new Map(titleUsers.map((u: any) => [u.id, u]));
|
||
const rolesMap = new Map(titleRoles.map((r: any) => [r.id, r]));
|
||
const sysValues = await buildSystemFieldValues(updatedTask, organizationId, { statuses: titleStatuses, usersMap });
|
||
const titleTaskTitleMap = await resolveTaskFieldTitles(valueByFieldId, allFormFields, organizationId);
|
||
const tableRowMap = await buildTableRowMap(allFormFields, organizationId);
|
||
const computedTitle = form.titleTemplate.replace(/\{\{([^}]+)\}\}/g, (_: string, code: string) => {
|
||
if (sysValues.has(code)) return sysValues.get(code)!;
|
||
const field = fieldCodeMap.get(code);
|
||
if (!field) return "";
|
||
const val = valueByFieldId.get(field.id);
|
||
if (val === null || val === undefined) return "";
|
||
if (val === "__auto_prolongation__" && (field.type === "date" || field.type === "datetime")) {
|
||
return field.autoProlongationLabel || "Автопролонгация";
|
||
}
|
||
return formatFieldValueForTitle(val, field.type, { usersMap, rolesMap, taskTitleMap: titleTaskTitleMap, tableRowMap }, field.options);
|
||
});
|
||
if (computedTitle !== updatedTask.title) {
|
||
await storage.updateTask(task.id, organizationId, { title: computedTitle });
|
||
updatedTask.title = computedTitle;
|
||
}
|
||
}
|
||
|
||
// RAG index
|
||
indexTaskAsync(task.id, organizationId).catch(() => {});
|
||
|
||
return updatedTask;
|
||
}
|