/** * Движок автоматических переходов по статусам на основе entryConditions. * * При создании или редактировании задачи система проходит статусы формы * по порядку position и проверяет entryConditions. Если условия статуса * совпадают со значениями полей задачи — задача автоматически переходит * в этот статус (с возможной цепочкой переходов). */ import { storage } from "../storage"; import type { FormField, FormStatus, SafeUser, StatusTransition, Task } from "@shared/schema"; // Тип роли с метаданными, как её возвращает storage.getRoles type OrgRole = Awaited>[number]; 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?: FormStatus[]; formFields?: FormField[]; users?: SafeUser[]; roles?: OrgRole[]; transitions?: StatusTransition[]; tableMap?: Map; 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 { const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH; const visited = new Set(); 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: Task = _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) => 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) => ({ ...u, fullName: `${u.firstName || ""} ${u.middleName || ""} ${u.lastName || ""}`.trim() || u.email, })); const valueByFieldId = new Map(fieldValues.map((v) => [v.fieldId, v.value])); const taskTitleMap = await resolveTaskFieldTitles(valueByFieldId, formFields, organizationId); const tableIds = new Set(); for (const f of formFields) { if (f.type === "table") { const tid = (f.options as { tableId?: number } | null)?.tableId; if (tid) tableIds.add(tid); } } let tableMap = options.preloaded?.tableMap; if (!tableMap) { tableMap = new Map; columns: Array<{ name: string }> }>(); 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) => 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: Task, targetStatusId: number, statuses: FormStatus[], // Поля формы + системные поля (_assignee, _status и т.п.) — структурный тип как в fieldConditionsMatch formFields: Array<{ id: number; code: string; type?: string; options?: unknown; autoProlongationLabel?: string | null; }>, // Значения полей + системные значения — структурный тип как в fieldConditionsMatch fieldValues: Array<{ fieldId: number; value: unknown }>, conditionContext: FieldConditionContext, organizationId: number, transitions: StatusTransition[], triggeredBy: number | null = null ): Promise { 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) => 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; 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) => 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) => 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) => fv.fieldId === field?.id); const rowId = fieldValue?.value ? parseInt(String(fieldValue.value)) : NaN; if (!isNaN(rowId) && rowId > 0) { const tableId = (field?.options as { tableId?: number } | null)?.tableId; 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 = { 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) => [f.code, f])); const valueByFieldId = new Map(savedValues.map((v) => [v.fieldId, v.value])); const usersMap = new Map(titleUsers.map((u) => [u.id, u])); const rolesMap = new Map(titleRoles.map((r) => [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; }