fix(number-fields): избегаем потери точности длинных чисел

- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
2026-07-07 21:03:40 +03:00
commit 1f5ecb6da4
1089 changed files with 237546 additions and 0 deletions

View 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;
}