Files
iistwin/server/utils/auto-transitions.ts
Ильяс Султанов 75ec0f25bb chore(repo): дедуп formatAmount, пины образов, дефолты MinIO, доля any
Шаги 2.3 и 2.4 плана production-готовности:
- lib/format.ts formatAmount вместо 2 локальных копий
- пины тегов minio/rclone/ollama/traccar/pgvector + osrm по digest
- minioadmin без дефолта (env required, dev-подсказки в .env.example)
- 28 any в auto-transitions/shared типизированы из @shared/schema
- удалён _tablefield_orig.txt
- check/lint/build чисто, vitest 104/104
2026-09-08 01:07:55 +03:00

380 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Движок автоматических переходов по статусам на основе entryConditions.
*
* При создании или редактировании задачи система проходит статусы формы
* по порядку position и проверяет entryConditions. Если условия статуса
* совпадают со значениями полей задачи — задача автоматически переходит
* в этот статус (с возможной цепочкой переходов).
*/
import { storage } from "../storage";
import type { FormField, FormStatus, SafeUser, StatusTransition, Task } from "@shared/schema";
// Тип роли с метаданными, как её возвращает storage.getRoles
type OrgRole = Awaited<ReturnType<typeof storage.getRoles>>[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<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: 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<number>();
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<number, { rows: Array<{ id: number; values: string[] }>; 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<Task> {
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<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) => 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<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) => [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;
}