fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
362
server/utils/auto-transitions.ts
Normal file
362
server/utils/auto-transitions.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Движок автоматических переходов по статусам на основе 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;
|
||||
}
|
||||
Reference in New Issue
Block a user