Files
iistwin/server/utils/auto-transitions.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

363 lines
15 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 { 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";
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(() => {});
}
// 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;
}