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,556 @@
import { storage } from "../storage";
import { webhookService } from "./webhook.service";
import { eventBus, resolveTaskFieldTitles } from "../routes/shared";
import { pushTaskUpdated } from "../utils/pushTaskUpdated";
import { tasksMinimalCache } from "../utils/cache";
import { notifyTaskAssigned } from "../utils/notifyAssignee";
import { indexTaskAsync } from "../routes/task-helpers";
import { fieldConditionsMatch, resolveFieldDisplayText, isFieldValueEmpty, SYSTEM_TASK_FIELDS, buildSystemFieldValuesForConditions } from "../utils/field-conditions";
import { validateRequiredFieldsDetailed } from "../utils/validate-required-fields";
export interface ExecuteTaskTransitionOptions {
taskId: number;
transitionId: number;
action?: 'approve' | 'reject';
user: any;
organizationId: number;
offlineEnqueuedAt?: Date | null;
}
export type TransitionResult =
| { success: true; task: any; message: string }
| { success: false; error: string; status: number; fields?: Array<{ fieldId: number; message: string }> };
export async function executeTaskTransition(options: ExecuteTaskTransitionOptions): Promise<TransitionResult> {
const { taskId, transitionId, action, user, organizationId, offlineEnqueuedAt } = options;
try {
if (!transitionId) {
return { success: false, error: 'Неверные параметры', status: 400 };
}
// Verify task exists and belongs to organization
const existingTask = await storage.getTask(taskId, organizationId);
if (!existingTask) {
return { success: false, error: 'Задача не найдена', status: 404 };
}
// Get transition details
const transitions = await storage.getStatusTransitions(existingTask.formId, organizationId);
const transition = transitions.find(t => t.id === transitionId);
if (!transition) {
return { success: false, error: 'Переход не найден', status: 404 };
}
// Verify transition is from current status
if (transition.fromStatusId !== existingTask.currentStatusId) {
return { success: false, error: 'Переход недоступен для текущего статуса', status: 400 };
}
// Determine target status and action text based on:
// 1. Explicit action parameter from request (new approach)
// 2. transition.actionType from config (legacy approach for backward compatibility)
let targetStatusId: number;
let actionText: string;
// If action is explicitly provided, use it
if (action === 'reject') {
// New approach: explicit reject action with rejectToStatusId
if (!transition.rejectToStatusId) {
return { success: false, error: 'Действие отклонения не настроено для этого перехода', status: 400 };
}
targetStatusId = transition.rejectToStatusId;
actionText = transition.rejectButtonText || 'Отклонено';
} else if (action === 'approve') {
// Explicit approve action
targetStatusId = transition.toStatusId;
if (transition.actionType === 'custom' && transition.actionName) {
actionText = transition.actionName;
} else {
actionText = transition.approveButtonText || 'Одобрено';
}
} else {
// No action provided - use legacy actionType for backward compatibility
if (transition.actionType === 'reject') {
// Legacy reject transition (separate transition with actionType='reject')
targetStatusId = transition.toStatusId;
actionText = transition.rejectButtonText || 'Отклонено';
} else if (transition.actionType === 'custom') {
// Custom transition
targetStatusId = transition.toStatusId;
actionText = transition.actionName || transition.approveButtonText || 'Выполнено';
} else {
// Default: approve
targetStatusId = transition.toStatusId;
actionText = transition.approveButtonText || 'Одобрено';
}
}
// Build context for resolving display text of fields in conditions
const [orgUsers, orgRoles, fieldValues, formFields, statuses] = await Promise.all([
storage.getUsersByOrganization(organizationId),
storage.getRoles(organizationId),
storage.getTaskFieldValues(taskId, organizationId),
storage.getFormFields(existingTask.formId, organizationId),
storage.getFormStatuses(existingTask.formId, organizationId),
]);
// Merge system fields (_assignee, _status, _due_date, title, etc.) so rules can reference them
const currentStatusName = statuses.find((s: any) => s.id === existingTask.currentStatusId)?.name;
const systemFieldValues = buildSystemFieldValuesForConditions(existingTask, { statusName: currentStatusName });
const allFields = [...formFields, ...SYSTEM_TASK_FIELDS];
const allFieldValues = [...fieldValues, ...systemFieldValues];
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);
}
}
const tableMap = new Map<number, { rows: Array<{ id: number; values: string[] }>; columns: Array<{ name: string }> }>();
for (const tableId of tableIds) {
const fullTable = await (storage as any).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 conditionContext = {
users: usersWithFullName,
roles: orgRoles,
taskTitleMap,
tableMap,
};
// Apply conditional routing: override targetStatusId based on task field values
const conditionsData = transition.conditions as Record<string, unknown>;
if (conditionsData) {
const isRejectAction = action === 'reject' || (!action && transition.actionType === 'reject');
const routingRules = isRejectAction ? conditionsData.rejectRoutingRules : conditionsData.routingRules;
if (routingRules && Array.isArray(routingRules) && routingRules.length > 0) {
let routingResolved = false;
for (const rule of routingRules) {
if (!rule.fieldCode || !rule.toStatusId) continue;
const field = allFields.find(f => f.code === (rule as Record<string, string>).fieldCode);
if (!field) continue;
const fv = allFieldValues.find((fv: any) => fv.fieldId === field!.id);
const operator = (rule as Record<string, unknown>).operator || 'contains';
if (operator === 'empty') {
if (isFieldValueEmpty(fv?.value, field.type)) {
targetStatusId = rule.toStatusId;
routingResolved = true;
break;
}
} else if (operator === 'filled') {
if (!isFieldValueEmpty(fv?.value, field.type)) {
targetStatusId = rule.toStatusId;
routingResolved = true;
break;
}
} else {
const displayText = resolveFieldDisplayText(fv?.value, field as any, conditionContext);
if (displayText.includes(String(rule.value ?? ''))) {
targetStatusId = rule.toStatusId;
routingResolved = true;
break;
}
}
}
if (!routingResolved) {
const defaultId = isRejectAction ? conditionsData.defaultRejectToStatusId : conditionsData.defaultToStatusId;
if (defaultId) targetStatusId = defaultId as number;
}
}
}
// Check if user is authorized to perform transition
let isAuthorized = false;
let resolvedRoleId: number | null = null; // track role to assign after transition
let resolvedAssigneeId: number | null = null; // old-format resolved user
let resolvedUserId: number | null = null; // new-format resolved user
// Check direct assigneeUserId (Type A simple — backward compat)
if (!isAuthorized && transition.assigneeUserId !== null && transition.assigneeUserId !== undefined) {
if (transition.assigneeUserId === user?.id) {
isAuthorized = true;
}
}
// Check assignee conditions if present
if (!isAuthorized && transition.assigneeConditions) {
const conditions = transition.assigneeConditions as Record<string, unknown>;
// NEW format: { assignees: [{ type: 'user'|'role'|'field_user'|'table_column_user', id?, fieldCode?, columnIndex?, rules: [{ fieldCode, value }] }] }
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 || [], allFieldValues, allFields, conditionContext);
if (!rulesMatch) continue;
if (assignee.type === 'user' && assignee.id === user?.id) {
resolvedUserId = assignee.id;
isAuthorized = true;
break;
}
if (assignee.type === 'role') {
const members = await storage.getRoleMembersByRole(assignee.id);
if (members.some((m: any) => m.userId === user?.id)) {
resolvedRoleId = assignee.id;
isAuthorized = true;
break;
}
}
if (assignee.type === 'field_user') {
let userId: number | undefined;
if (assignee.fieldCode === '_assignee') {
userId = existingTask.assignedTo ?? undefined;
} else if (assignee.fieldCode === '_author') {
userId = existingTask.createdBy ?? undefined;
} else {
const field = allFields.find(f => f.code === assignee.fieldCode);
const fieldValue = allFieldValues.find((fv: any) => fv.fieldId === field?.id);
userId = fieldValue?.value ? parseInt(String(fieldValue.value)) : undefined;
}
if (userId && userId > 0 && userId === user?.id) {
resolvedUserId = userId;
isAuthorized = true;
break;
}
}
if (assignee.type === 'table_column_user') {
const field = allFields.find(f => f.code === assignee.fieldCode);
const fieldValue = allFieldValues.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 && userId === user?.id) {
resolvedUserId = userId;
isAuthorized = true;
break;
}
}
}
}
}
}
} else if (conditions.rules && Array.isArray(conditions.rules)) {
// OLD format: { rules: [...], fallbackAssigneeUserId? }
// Rule types:
// A) { fieldCode, value, assigneeUserId }
// Direct: if field = value → assigneeUserId
// B) { fieldCode, value, tableId, tableKeyColumnIndex, tableUserColumnIndex }
// Lookup: if field = value → find справочник row where col[keyIdx]=value → user = col[userIdx]
// C) { fieldCode, tableUserColumnIndex } (no tableId, field.type = 'table')
// Table-field: task's table field stores a row ID → user = that row's col[userIdx]
// D) { fieldCode, value, assigneeSource: 'role', roleId }
// Role: if field contains value → check if current user is member of roleId
for (const rule of conditions.rules) {
const field = allFields.find(f => f.code === (rule as Record<string, string>).fieldCode);
if (!field) continue;
const fieldValue = allFieldValues.find((fv: any) => fv.fieldId === field!.id);
if (rule.assigneeSource === 'role' && rule.roleId !== undefined) {
// Type D: role-based assignee
const operator = (rule as Record<string, unknown>).operator || 'contains';
let fieldMatches = false;
if (operator === 'empty') {
fieldMatches = isFieldValueEmpty(fieldValue?.value, field.type);
} else if (operator === 'filled') {
fieldMatches = !isFieldValueEmpty(fieldValue?.value, field.type);
} else {
const ruleVal = String(rule.value ?? '');
const displayText = resolveFieldDisplayText(fieldValue?.value, field as any, conditionContext);
fieldMatches = !ruleVal || displayText.toLowerCase().includes(ruleVal.toLowerCase());
}
if (fieldMatches) {
const members = await storage.getRoleMembersByRole(rule.roleId);
if (members.some(m => m.userId === user?.id)) {
resolvedRoleId = rule.roleId;
isAuthorized = true;
break;
}
}
} else if (rule.tableId !== undefined && rule.tableUserColumnIndex !== undefined) {
// Type B: справочник lookup from any field
const taskVal = String(fieldValue?.value ?? '');
if (!taskVal) continue; // empty field — no match possible
const rows = await storage.getDataTableRows(rule.tableId, organizationId);
const keyIdx = rule.tableKeyColumnIndex as number;
const userIdx = rule.tableUserColumnIndex as number;
// If rule.value is set, only apply this rule when the task field equals that value
if (rule.value !== undefined && taskVal !== String(rule.value)) continue;
const matchRow = rows.find(r => String((r.values as unknown[])[keyIdx] ?? '') === taskVal);
if (!matchRow) continue;
const userId = parseInt(String((matchRow.values as unknown[])[userIdx] ?? ''));
if (!isNaN(userId)) { resolvedAssigneeId = userId; break; }
} else if ((rule as Record<string, unknown>).tableUserColumnIndex !== undefined && (field as any)?.options?.tableId) {
// Type C: task field is table-type, value = selected row ID
const tableId = (field as any).options.tableId as number;
const rowId = fieldValue?.value ? parseInt(String(fieldValue.value)) : NaN;
if (isNaN(rowId)) continue;
const row = await storage.getDataTableRow(rowId, tableId, organizationId);
if (!row) continue;
const userId = parseInt(String((row.values as unknown[])[rule.tableUserColumnIndex as number] ?? ''));
if (!isNaN(userId)) { resolvedAssigneeId = userId; break; }
} else if (rule.assigneeUserId !== undefined) {
// Type A: direct field → user (contains match)
const operator = (rule as Record<string, unknown>).operator || 'contains';
if (operator === 'empty') {
if (isFieldValueEmpty(fieldValue?.value, field.type)) {
resolvedAssigneeId = rule.assigneeUserId;
break;
}
} else if (operator === 'filled') {
if (!isFieldValueEmpty(fieldValue?.value, field.type)) {
resolvedAssigneeId = rule.assigneeUserId;
break;
}
} else {
const ruleVal = String(rule.value ?? '');
const displayText = resolveFieldDisplayText(fieldValue?.value, field as any, conditionContext);
if (ruleVal && displayText.toLowerCase().includes(ruleVal.toLowerCase())) {
resolvedAssigneeId = rule.assigneeUserId;
break;
}
}
}
}
if (resolvedAssigneeId === null && conditions.fallbackAssigneeUserId) {
resolvedAssigneeId = conditions.fallbackAssigneeUserId as number;
}
if (resolvedAssigneeId !== null && resolvedAssigneeId === user?.id) {
isAuthorized = true;
}
} else if (conditions.field && conditions.value && conditions.assigneeId) {
// Backward-compat: old format { field, value, assigneeId }
const operator = (conditions as Record<string, unknown>).operator || 'contains';
const field = allFields.find(f => f.code === String(conditions.field));
if (field) {
const fieldValue = allFieldValues.find((fv: any) => fv.fieldId === field!.id);
let matches = false;
if (operator === 'empty') {
matches = isFieldValueEmpty(fieldValue?.value, field.type);
} else if (operator === 'filled') {
matches = !isFieldValueEmpty(fieldValue?.value, field.type);
} else {
const condVal = String(conditions.value ?? '');
const displayText = resolveFieldDisplayText(fieldValue?.value, field as any, conditionContext);
matches = !!condVal && displayText.toLowerCase().includes(condVal.toLowerCase());
}
if (matches && conditions.assigneeId === user?.id) {
resolvedAssigneeId = conditions.assigneeId as number;
isAuthorized = true;
}
}
}
}
if (!isAuthorized) {
return { success: false, error: 'У вас нет прав для выполнения этого действия', status: 403 };
}
// Get status names for the message (already fetched earlier)
const fromStatus = statuses.find(s => s.id === transition.fromStatusId);
const toStatus = statuses.find(s => s.id === targetStatusId);
// Validate required fields for current status before leaving it.
// The rule "required in status X" must be satisfied when the task tries to exit X,
// not when it enters the target status.
const requiredFieldErrors = await validateRequiredFieldsDetailed({
task: existingTask,
formFields,
existingFieldValues: fieldValues,
organizationId,
storage,
statuses,
isCreation: false,
});
if (requiredFieldErrors.length > 0) {
return {
success: false,
error: `Перед выходом из статуса «${fromStatus?.name || transition.fromStatusId}» заполните обязательные поля:\n${requiredFieldErrors.map((e) => e.message).join('\n')}`,
status: 400,
fields: requiredFieldErrors,
};
}
// Check if target status is final
const isFinalStatus = toStatus?.isFinal || false;
// Determine assignee based on transition rules (priority: direct > old format > new format > preserve current)
let targetAssigneeId: number | null = null;
if (transition.assigneeUserId !== null && transition.assigneeUserId !== undefined) {
targetAssigneeId = transition.assigneeUserId;
} else if (resolvedAssigneeId !== null) {
targetAssigneeId = resolvedAssigneeId;
} else if (resolvedUserId !== null) {
targetAssigneeId = resolvedUserId;
} else {
// If the transition does not explicitly specify an assignee, keep the existing one
// instead of defaulting to the user who clicked the button.
targetAssigneeId = existingTask.assignedTo ?? null;
}
// Update task status
const updateData: Record<string, unknown> = {
currentStatusId: targetStatusId,
assignedTo: targetAssigneeId,
};
// If transitioning to final status, mark as completed; otherwise reset
if (isFinalStatus) {
updateData.isCompleted = true;
updateData.completedAt = new Date();
} else {
updateData.isCompleted = false;
updateData.completedAt = null;
}
const updatedTask = await storage.updateTask(taskId, organizationId, updateData);
tasksMinimalCache.invalidatePrefix(`tasks:${organizationId}:minimal:`);
// Sync the primary assignee into task_assignees so TaskDetail can display it
if (targetAssigneeId) {
storage.addTaskAssignee(taskId, targetAssigneeId, organizationId).catch(err => {
console.error('addTaskAssignee (transition target) error:', err);
});
}
// Уведомление новому ответственному, если он изменился
if (targetAssigneeId && targetAssigneeId !== existingTask.assignedTo) {
notifyTaskAssigned(updatedTask, targetAssigneeId, user?.id ?? null, organizationId)
.catch((err) => console.error('[Transition] notifyTaskAssigned error:', err));
}
// If a role was resolved as assignee — assign it to the task
if (resolvedRoleId !== null) {
await storage.addTaskRole(taskId, resolvedRoleId, organizationId).catch(err => {
console.error('addTaskRole error:', err);
});
}
// Add current user as task assignee (upsert — no duplicates)
if (user?.id) {
storage.addTaskAssignee(taskId, user.id, organizationId).catch(err => {
console.error('addTaskAssignee (transition) error:', err);
});
// Record transition access (grantedVia='transition', with statusId and roleId)
storage.recordTransitionAccess(taskId, user.id, organizationId, resolvedRoleId, targetStatusId).catch(err => {
console.error('recordTransitionAccess error:', err);
});
}
// Audit log: status changed
const statusChangerName = user
? (`${user.firstName || ''} ${user.middleName || ''} ${user.lastName || ''}`.trim() || user.email)
: 'API';
storage.addTaskAuditLog({
taskId,
organizationId,
action: 'status.changed',
fieldName: 'Статус',
oldValue: fromStatus?.name ?? String(transition.fromStatusId),
newValue: toStatus?.name ?? String(targetStatusId),
changedBy: user?.id ?? null,
changedByName: statusChangerName,
metadata: { transitionId: transition.id, actionText },
...(offlineEnqueuedAt ? { createdAt: offlineEnqueuedAt } : {}),
}).catch((auditErr: unknown) => { console.error('Audit log error:', auditErr); });
// Audit log: transition approved (for access tab and history display)
if (user?.id) {
let roleName: string | null = null;
if (resolvedRoleId !== null) {
try {
const role = await storage.getRole(resolvedRoleId, organizationId);
roleName = role?.name ?? null;
} catch { /* ignore */ }
}
storage.addTaskAuditLog({
taskId,
organizationId,
action: 'transition_approved',
fieldName: null,
oldValue: null,
newValue: null,
changedBy: user.id,
changedByName: statusChangerName,
metadata: {
transitionId: transition.id,
actionText,
statusId: targetStatusId,
statusName: toStatus?.name ?? String(targetStatusId),
roleId: resolvedRoleId,
roleName,
},
...(offlineEnqueuedAt ? { createdAt: offlineEnqueuedAt } : {}),
}).catch((auditErr: unknown) => { console.error('Audit log (transition_approved) error:', auditErr); });
}
// Publish task update event via SSE
eventBus.publishEvent({
type: 'task_updated',
data: {
taskId: taskId,
formId: existingTask.formId,
task: updatedTask
},
organizationId
});
// Web Push: notify all offline task participants (assignee, creator, access list)
pushTaskUpdated({
taskId,
organizationId,
formId: existingTask.formId,
taskTitle: updatedTask.title,
actorId: user!.id,
}).catch(() => {});
// Dispatch webhook event to subscribed bots
webhookService.dispatchStatusChange(
organizationId,
taskId,
existingTask.formId,
transition.fromStatusId,
targetStatusId,
transition.id,
user!.id
).catch(err => console.error('Webhook dispatch error:', err));
indexTaskAsync(taskId, organizationId).catch(() => {});
return {
success: true,
message: 'Переход выполнен успешно',
task: updatedTask
};
} catch (error) {
console.error('Execute transition error:', error);
return { success: false, error: 'Ошибка при выполнении перехода', status: 500 };
}
}