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,767 @@
import { Router } from "express";
import { storage } from "../storage";
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { buildSystemFieldValues, buildTableRowMap, deleteRemovedFiles, eventBus, formatFieldValueForTitle, resolveTaskFieldTitles } from "./shared";
import { evaluateAutoTransitions } from "../utils/auto-transitions";
import { tasksMinimalCache } from "../utils/cache";
import { indexTaskAsync, parseOfflineTimestamp } from "./task-helpers";
import { previewContractNumber } from "../services/contract-number.service";
import { validateRequiredFields } from "../utils/validate-required-fields";
import { validateImmutableFields } from "../utils/validate-immutable-fields";
import { shiftRelatedTasks, calculateDateShift } from "../services/gantt-shift.service";
import { normalizeFieldValueForStorage } from "../utils/normalize-field-value";
function extractUserId(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null;
const raw = String(value);
if (raw.startsWith('user:')) {
const uid = Number(raw.replace('user:', ''));
return isNaN(uid) ? null : uid;
}
const uid = Number(raw);
return isNaN(uid) ? null : uid;
}
export function registerTaskFieldRoutes(router: ReturnType<typeof import("express").Router>): void {
router.get('/api/tasks/:id/field-values',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const taskId = parseInt(req.params.id);
if (isNaN(taskId)) {
return res.status(400).json({
success: false,
error: 'Неверный ID задачи'
});
}
const existingTask = await storage.getTask(taskId, req.organizationId!);
if (!existingTask) {
return res.status(404).json({
success: false,
error: 'Задача не найдена'
});
}
const fieldValues = await storage.getTaskFieldValues(taskId, req.organizationId!);
res.json({
success: true,
fieldValues
});
} catch (error) {
console.error('Get task field values error:', error);
res.status(500).json({
success: false,
error: 'Ошибка при получении значений полей задачи'
});
}
});
// Create/Update task field values (bulk)
router.post('/api/tasks/:id/field-values',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const taskId = parseInt(req.params.id);
if (isNaN(taskId)) {
return res.status(400).json({ success: false, error: 'Неверный ID задачи' });
}
const existingTask = await storage.getTask(taskId, req.organizationId!);
if (!existingTask) {
return res.status(404).json({ success: false, error: 'Задача не найдена' });
}
{
const hasAccess = await storage.canUserAccessTask(
taskId, req.user!.id, req.organizationId!, req.user!.appRole
);
if (!hasAccess) {
return res.status(403).json({ success: false, error: 'Нет доступа к этой задаче' });
}
}
const { fieldValues } = req.body;
if (!Array.isArray(fieldValues)) {
return res.status(400).json({ success: false, error: 'Значения полей должны быть массивом' });
}
const existingValues = await storage.getTaskFieldValues(taskId, req.organizationId!);
const oldValueMap = new Map(existingValues.map(v => [v.fieldId, v.value]));
const formFields = await storage.getFormFields(existingTask.formId, req.organizationId!);
const fieldMap = new Map(formFields.map(f => [f.id, f]));
// Pre-load data for audit log display-text resolution (and reuse later for title template)
const [auditUsers, auditRoles] = await Promise.all([
storage.getUsersByOrganization(req.organizationId!),
storage.getRoles(req.organizationId!),
]);
const auditUsersMap = new Map(auditUsers.map(u => [u.id, u]));
const auditRolesMap = new Map(auditRoles.map(r => [r.id, r]));
const auditTableRowMap = await buildTableRowMap(formFields, req.organizationId!);
// Validate conditional required fields before applying changes
const simulatedFieldValuesPost: Array<{ fieldId: number; value: any }> = [...existingValues] as any;
for (const fv of fieldValues) {
const idx = simulatedFieldValuesPost.findIndex((v) => v.fieldId === fv.fieldId);
if (idx >= 0) {
simulatedFieldValuesPost[idx] = { ...simulatedFieldValuesPost[idx], value: fv.value };
} else {
simulatedFieldValuesPost.push({ fieldId: fv.fieldId, value: fv.value });
}
}
const auditTaskTitleMap = await resolveTaskFieldTitles(
new Map(simulatedFieldValuesPost.map(v => [v.fieldId, v.value])),
formFields,
req.organizationId!
);
const postFormStatuses = await storage.getFormStatuses(existingTask.formId, req.organizationId!);
const postValidationErrors = await validateRequiredFields({
task: existingTask,
formFields,
existingFieldValues: simulatedFieldValuesPost,
organizationId: req.organizationId!,
storage,
statuses: postFormStatuses,
});
if (postValidationErrors.length > 0) {
return res.status(400).json({ success: false, error: postValidationErrors.join('\n') });
}
const changedFieldIdsPost = fieldValues.map((fv: any) => fv.fieldId as number);
const immutableErrorsPost = await validateImmutableFields({
task: existingTask,
formFields,
changedFieldIds: changedFieldIdsPost,
existingFieldValues: simulatedFieldValuesPost,
organizationId: req.organizationId!,
storage,
statuses: postFormStatuses,
});
if (immutableErrorsPost.length > 0) {
return res.status(400).json({ success: false, error: immutableErrorsPost.join('\n') });
}
const replaceAll = req.query.replace === 'true';
if (replaceAll) {
// Legacy behavior: delete all existing values before creating new ones
for (const value of existingValues) {
await storage.deleteTaskRelationByField(value.fieldId, taskId, req.organizationId!);
await storage.deleteTaskFieldValue(taskId, value.fieldId, req.organizationId!);
}
}
const createdValues = [];
for (const fieldValue of fieldValues) {
const field = fieldMap.get(fieldValue.fieldId);
const normalizedValue = normalizeFieldValueForStorage(fieldValue.value, field?.type);
if (replaceAll) {
const valueData = {
taskId,
fieldId: fieldValue.fieldId,
formId: existingTask.formId,
value: normalizedValue
};
const createdValue = await storage.createTaskFieldValue(valueData);
createdValues.push(createdValue);
} else {
// Partial update: upsert only provided fields
const hasExisting = oldValueMap.has(fieldValue.fieldId);
// Clean up old task relation if this is a task field
if (field?.type === 'task' && hasExisting) {
await storage.deleteTaskRelationByField(fieldValue.fieldId, taskId, req.organizationId!);
}
if (normalizedValue === null || normalizedValue === undefined || normalizedValue === '') {
if (hasExisting) {
await storage.deleteTaskFieldValue(taskId, fieldValue.fieldId, req.organizationId!);
}
} else {
let savedValue;
if (hasExisting) {
savedValue = await storage.updateTaskFieldValue(taskId, fieldValue.fieldId, req.organizationId!, { value: normalizedValue });
} else {
const valueData = {
taskId,
fieldId: fieldValue.fieldId,
formId: existingTask.formId,
value: normalizedValue
};
savedValue = await storage.createTaskFieldValue(valueData);
}
createdValues.push(savedValue);
}
}
// Create task relation if applicable
if (field?.type === 'task' && field?.taskRelationType && fieldValue.value) {
const selectedTaskId = parseInt(String(fieldValue.value));
if (!isNaN(selectedTaskId)) {
if (field.taskRelationType === 'parent') {
await storage.upsertTaskRelation({
parentTaskId: selectedTaskId,
childTaskId: taskId,
fieldId: fieldValue.fieldId,
organizationId: req.organizationId!,
});
} else if (field.taskRelationType === 'child') {
await storage.upsertTaskRelation({
parentTaskId: taskId,
childTaskId: selectedTaskId,
fieldId: fieldValue.fieldId,
organizationId: req.organizationId!,
});
}
}
}
}
const fieldEditorName = req.user ? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email) : 'API';
const offlineEnqueuedAtPost = parseOfflineTimestamp(req);
for (const fieldValue of fieldValues) {
const field = fieldMap.get(fieldValue.fieldId);
if (!field) continue;
const oldVal = oldValueMap.has(fieldValue.fieldId) ? oldValueMap.get(fieldValue.fieldId) : undefined;
const newVal = normalizeFieldValueForStorage(fieldValue.value, field.type);
const oldStr = oldVal === null || oldVal === undefined ? '' : String(oldVal);
const newStr = newVal === null || newVal === undefined ? '' : String(newVal);
if (oldStr !== newStr) {
const titleCtx = { usersMap: auditUsersMap, rolesMap: auditRolesMap, taskTitleMap: auditTaskTitleMap, tableRowMap: auditTableRowMap };
const displayOld = formatFieldValueForTitle(oldVal, field.type, titleCtx, field.options);
const displayNew = formatFieldValueForTitle(newVal, field.type, titleCtx, field.options);
storage.addTaskAuditLog({
taskId,
organizationId: req.organizationId!,
action: 'field.changed',
fieldId: field.id,
fieldName: field.name,
oldValue: oldVal ?? null,
newValue: newVal ?? null,
changedBy: req.user?.id ?? null,
changedByName: fieldEditorName,
metadata: {
displayOldValue: displayOld || null,
displayNewValue: displayNew || null,
},
...(offlineEnqueuedAtPost ? { createdAt: offlineEnqueuedAtPost } : {}),
}).catch((auditErr: unknown) => { console.error('Audit log error:', auditErr); });
}
}
// Sync task_assignees from user fields so tasks appear in inbox
for (const createdValue of createdValues) {
const fld = fieldMap.get(createdValue.fieldId);
if (fld?.type === 'user') {
const userId = extractUserId(createdValue.value);
if (userId) {
await storage.addTaskAssignee(taskId, userId, req.organizationId!).catch(() => {});
}
}
}
const taskFormPost = await storage.getForm(existingTask.formId, req.organizationId!);
if (taskFormPost?.titleTemplate) {
const [allValsPost, postStatuses, postUsers, postRoles] = await Promise.all([
storage.getTaskFieldValues(taskId, req.organizationId!),
storage.getFormStatuses(existingTask.formId, req.organizationId!),
storage.getUsersByOrganization(req.organizationId!),
storage.getRoles(req.organizationId!),
]);
const valByFieldIdPost = new Map(allValsPost.map(v => [v.fieldId, v.value]));
const fieldCodeMapPost = new Map(formFields.map(f => [f.code, f]));
const postUsersMap = new Map(postUsers.map(u => [u.id, u]));
const postRolesMap = new Map(postRoles.map(r => [r.id, r]));
const sysValuesPost = await buildSystemFieldValues(existingTask, req.organizationId!, { statuses: postStatuses, usersMap: postUsersMap });
const taskTitleMapPost = await resolveTaskFieldTitles(valByFieldIdPost, formFields, req.organizationId!);
const tableRowMapPost = await buildTableRowMap(formFields, req.organizationId!);
const computedTitlePost = taskFormPost.titleTemplate.replace(/\{\{([^}]+)\}\}/g, (_, code: string) => {
if (sysValuesPost.has(code)) return sysValuesPost.get(code)!;
const f = fieldCodeMapPost.get(code);
if (!f) return '';
const val = valByFieldIdPost.get(f.id);
if (val === null || val === undefined) return '';
if (val === '__auto_prolongation__' && (f.type === 'date' || f.type === 'datetime')) {
return f.autoProlongationLabel || 'Автопролонгация';
}
return formatFieldValueForTitle(val, f.type, { usersMap: postUsersMap, rolesMap: postRolesMap, taskTitleMap: taskTitleMapPost, tableRowMap: tableRowMapPost }, f.options);
});
console.error('[TITLE DEBUG] POST field-values: usersMap size=', postUsersMap.size, 'computedTitle=', computedTitlePost);
await storage.updateTask(taskId, req.organizationId!, { title: computedTitlePost });
}
if (req.user?.id) {
storage.recordTaskInteractionAuto(taskId, req.user.id, req.organizationId!)
.catch((err: unknown) => { console.error('recordTaskInteraction error:', err); });
}
indexTaskAsync(taskId, req.organizationId!).catch(() => {});
res.status(201).json({ success: true, message: 'Значения полей сохранены', fieldValues: createdValues });
} catch (error) {
console.error('Save task field values error:', error);
res.status(500).json({ success: false, error: 'Ошибка при сохранении значений полей' });
}
}
);
// Preview contract number (without reserving counter)
router.post('/api/tasks/preview-contract-number',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const { formId, legalEntityTaskId, fieldId } = req.body;
if (!formId || !legalEntityTaskId || !fieldId) {
return res.status(400).json({
success: false,
error: 'Необходимы formId, legalEntityTaskId и fieldId'
});
}
const formField = await storage.getFormFieldById(fieldId, req.organizationId!);
if (!formField || formField.type !== 'contract-number') {
return res.status(400).json({
success: false,
error: 'Поле не найдено или не является полем номера договора'
});
}
const config = (formField.options as any)?.contractNumber;
if (!config) {
return res.status(400).json({
success: false,
error: 'Поле номера договора не настроено'
});
}
const result = await previewContractNumber({
organizationId: req.organizationId!,
formId,
legalEntityTaskId,
config,
});
res.json({ success: true, ...result });
} catch (error) {
console.error('Preview contract number error:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Ошибка при предпросмотре номера договора'
});
}
}
);
// Update single field value (inline editing)
router.patch('/api/tasks/:id/field-values/:fieldId',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const taskId = parseInt(req.params.id);
const fieldId = parseInt(req.params.fieldId);
if (isNaN(taskId) || isNaN(fieldId)) {
return res.status(400).json({ success: false, error: 'Неверный ID задачи или поля' });
}
const existingTask = await storage.getTask(taskId, req.organizationId!);
if (!existingTask) {
return res.status(404).json({ success: false, error: 'Задача не найдена' });
}
{
const hasAccess = await storage.canUserAccessTask(
taskId, req.user!.id, req.organizationId!, req.user!.appRole
);
if (!hasAccess) {
return res.status(403).json({ success: false, error: 'Нет доступа к этой задаче' });
}
}
const { value } = req.body;
const formFields = await storage.getFormFields(existingTask.formId, req.organizationId!);
const field = formFields.find(f => f.id === fieldId);
if (!field) {
return res.status(404).json({ success: false, error: 'Поле не найдено' });
}
const normalizedValue = normalizeFieldValueForStorage(value, field.type);
// Validate conditional required fields before applying the change
const existingValuesSingle = await storage.getTaskFieldValues(taskId, req.organizationId!);
const simulatedFieldValuesSingle = existingValuesSingle.map((v: any) =>
v.fieldId === fieldId ? { ...v, value: normalizedValue } : v
);
if (!simulatedFieldValuesSingle.some((v: any) => v.fieldId === fieldId)) {
simulatedFieldValuesSingle.push({ fieldId, value: normalizedValue });
}
const singleFormStatuses = await storage.getFormStatuses(existingTask.formId, req.organizationId!);
const singleValidationErrors = await validateRequiredFields({
task: existingTask,
formFields,
existingFieldValues: simulatedFieldValuesSingle,
organizationId: req.organizationId!,
storage,
statuses: singleFormStatuses,
fieldIdsToCheck: [fieldId],
});
if (singleValidationErrors.length > 0) {
return res.status(400).json({ success: false, error: singleValidationErrors.join('\n') });
}
const immutableErrorsSingle = await validateImmutableFields({
task: existingTask,
formFields,
changedFieldIds: [fieldId],
existingFieldValues: simulatedFieldValuesSingle,
organizationId: req.organizationId!,
storage,
statuses: singleFormStatuses,
});
if (immutableErrorsSingle.length > 0) {
return res.status(400).json({ success: false, error: immutableErrorsSingle.join('\n') });
}
const INLINE_EDITABLE_FIELD_TYPES = new Set(['text', 'number', 'textarea', 'date', 'datetime', 'select', 'radio-group', 'checkbox', 'toggle', 'user', 'file', 'geo', 'contract-number', 'table', 'checklist', 'contact']);
if (!INLINE_EDITABLE_FIELD_TYPES.has(field.type)) {
return res.status(400).json({ success: false, error: `Поле типа «${field.type}» не поддерживает inline-редактирование` });
}
if (field.type === 'file' && value != null) {
const newFiles = Array.isArray(value) ? value : [];
if (field.maxFileCount != null && newFiles.length > field.maxFileCount) {
return res.status(400).json({ success: false, error: `Превышено максимальное количество файлов (${field.maxFileCount})` });
}
if (field.maxFileSizeMB != null) {
const totalBytes = newFiles.reduce((sum: number, f: Record<string, unknown>) => sum + (Number(f.size) || 0), 0);
const limitBytes = field.maxFileSizeMB * 1024 * 1024;
if (totalBytes > limitBytes) {
return res.status(400).json({ success: false, error: `Суммарный размер файлов превышает лимит (${field.maxFileSizeMB} МБ)` });
}
}
}
const existingValues = await storage.getTaskFieldValues(taskId, req.organizationId!);
const existingValue = existingValues.find(v => v.fieldId === fieldId);
const oldVal = existingValue ? existingValue.value : undefined;
if (field.type === 'file' && oldVal) {
await deleteRemovedFiles(oldVal, normalizedValue);
}
let savedValue;
if (existingValue) {
savedValue = await storage.updateTaskFieldValue(taskId, fieldId, req.organizationId!, { value: normalizedValue });
} else {
savedValue = await storage.createTaskFieldValue({
taskId,
fieldId,
formId: existingTask.formId,
value: normalizedValue,
});
}
const editorName = req.user
? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email)
: 'API';
const offlineEnqueuedAtPatch = parseOfflineTimestamp(req);
const oldStr = oldVal === null || oldVal === undefined ? '' : String(oldVal);
const newStr = normalizedValue === null || normalizedValue === undefined ? '' : String(normalizedValue);
if (oldStr !== newStr) {
storage.addTaskAuditLog({
taskId,
organizationId: req.organizationId!,
action: 'field.changed',
fieldId: field.id,
fieldName: field.name,
oldValue: oldVal ?? null,
newValue: normalizedValue ?? null,
changedBy: req.user?.id ?? null,
changedByName: editorName,
...(offlineEnqueuedAtPatch ? { createdAt: offlineEnqueuedAtPatch } : {}),
}).catch((auditErr: unknown) => { console.error('Audit log error (inline edit):', auditErr); });
}
tasksMinimalCache.invalidatePrefix(`tasks:${req.organizationId}:minimal:`);
// Gantt cascade shift for start/end fields
if ((field.ganttRole === 'start' || field.ganttRole === 'end') && (field.type === 'date' || field.type === 'datetime')) {
const oldDate = oldVal ? new Date(String(oldVal)) : null;
const newDate = value ? new Date(String(value)) : null;
if (oldDate && newDate && !isNaN(oldDate.getTime()) && !isNaN(newDate.getTime())) {
const deltaMs = calculateDateShift(oldDate, newDate);
if (deltaMs !== 0) {
shiftRelatedTasks(taskId, deltaMs, req.organizationId!, { triggeredByUserId: req.user?.id })
.catch((err: any) => console.error('Gantt cascade shift error:', err));
}
}
}
// Sync task_assignees from user field so task appears in inbox
if (field.type === 'user') {
const newUserId = extractUserId(savedValue.value);
const oldUserId = extractUserId(oldVal);
if (newUserId && newUserId !== oldUserId) {
await storage.addTaskAssignee(taskId, newUserId, req.organizationId!).catch(() => {});
}
if (oldUserId && oldUserId !== newUserId) {
await storage.removeTaskAssignee(taskId, oldUserId, req.organizationId!).catch(() => {});
}
}
const taskFormSingle = await storage.getForm(existingTask.formId, req.organizationId!);
if (taskFormSingle?.titleTemplate) {
const [allVals, singleStatuses, singleUsers, singleRoles] = await Promise.all([
storage.getTaskFieldValues(taskId, req.organizationId!),
storage.getFormStatuses(existingTask.formId, req.organizationId!),
storage.getUsersByOrganization(req.organizationId!),
storage.getRoles(req.organizationId!),
]);
const valByFieldId = new Map(allVals.map(v => [v.fieldId, v.value]));
const fieldCodeMapSingle = new Map(formFields.map(f => [f.code, f]));
const singleUsersMap = new Map(singleUsers.map(u => [u.id, u]));
const singleRolesMap = new Map(singleRoles.map(r => [r.id, r]));
const sysValuesSingle = await buildSystemFieldValues(existingTask, req.organizationId!, { statuses: singleStatuses, usersMap: singleUsersMap });
const taskTitleMapSingle = await resolveTaskFieldTitles(valByFieldId, formFields, req.organizationId!);
const tableRowMapSingle = await buildTableRowMap(formFields, req.organizationId!);
const computedTitle = taskFormSingle.titleTemplate.replace(/\{\{([^}]+)\}\}/g, (_, code: string) => {
if (sysValuesSingle.has(code)) return sysValuesSingle.get(code)!;
const f = fieldCodeMapSingle.get(code);
if (!f) return '';
const val = valByFieldId.get(f.id);
if (val === null || val === undefined) return '';
if (val === '__auto_prolongation__' && (f.type === 'date' || f.type === 'datetime')) {
return f.autoProlongationLabel || 'Автопролонгация';
}
return formatFieldValueForTitle(val, f.type, { usersMap: singleUsersMap, rolesMap: singleRolesMap, taskTitleMap: taskTitleMapSingle, tableRowMap: tableRowMapSingle }, f.options);
});
console.error('[TITLE DEBUG] PATCH field-values/:fieldId: usersMap size=', singleUsersMap.size, 'computedTitle=', computedTitle);
await storage.updateTask(taskId, req.organizationId!, { title: computedTitle });
}
if (req.user?.id) {
storage.recordTaskInteractionAuto(taskId, req.user.id, req.organizationId!)
.catch((err: unknown) => { console.error('recordTaskInteraction error:', err); });
}
indexTaskAsync(taskId, req.organizationId!).catch(() => {});
const autoResult = await evaluateAutoTransitions(taskId, req.organizationId!, { triggeredBy: req.user?.id ?? null });
const freshTask = await storage.getTask(taskId, req.organizationId!);
eventBus.publishEvent({
type: 'task_updated',
organizationId: req.organizationId!,
data: { taskId, formId: existingTask.formId, task: freshTask, autoTransition: autoResult.changed }
});
res.json({ success: true, fieldValue: savedValue });
} catch (error) {
console.error('Inline field value update error:', error);
res.status(500).json({ success: false, error: 'Ошибка при обновлении поля' });
}
}
);
// Bulk update multiple field values atomically
router.patch('/api/tasks/:id/field-values',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const taskId = parseInt(req.params.id);
if (isNaN(taskId)) {
return res.status(400).json({ success: false, error: 'Неверный ID задачи' });
}
const existingTask = await storage.getTask(taskId, req.organizationId!);
if (!existingTask) {
return res.status(404).json({ success: false, error: 'Задача не найдена' });
}
const body = req.body;
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return res.status(400).json({ success: false, error: 'Тело запроса должно быть объектом { fieldId: value }' });
}
const entries = Object.entries(body as Record<string, unknown>);
if (entries.length === 0) {
return res.json({ success: true, fieldValues: [] });
}
const strictNumeric = /^\d+$/;
if (entries.some(([k]) => !strictNumeric.test(k))) {
return res.status(400).json({ success: false, error: 'Все ключи должны быть числовыми ID полей' });
}
const fieldIds = entries.map(([k]) => parseInt(k, 10));
const INLINE_EDITABLE_FIELD_TYPES = new Set(['text', 'number', 'textarea', 'date', 'datetime', 'select', 'radio-group', 'checkbox', 'toggle', 'user', 'file', 'geo', 'checklist', 'contact']);
const formFields = await storage.getFormFields(existingTask.formId, req.organizationId!);
const fieldMap = new Map(formFields.map(f => [f.id, f]));
const existingValues = await storage.getTaskFieldValues(taskId, req.organizationId!);
const existingValueMap = new Map(existingValues.map(v => [v.fieldId, v.value]));
const normalizedEntries = entries.map(([k, value]): [string, unknown] => {
const fid = parseInt(k, 10);
const fld = fieldMap.get(fid);
return [k, normalizeFieldValueForStorage(value, fld?.type)];
});
// Validate conditional required fields before applying bulk changes
const simulatedFieldValuesBulk: Array<{ fieldId: number; value: any }> = existingValues.map((v: any) => {
const newVal = normalizedEntries.find(([key]) => parseInt(key, 10) === v.fieldId)?.[1];
return newVal !== undefined ? { ...v, value: newVal } : v;
});
for (const [k, newVal] of normalizedEntries) {
const fid = parseInt(k, 10);
if (!simulatedFieldValuesBulk.some((v: any) => v.fieldId === fid)) {
simulatedFieldValuesBulk.push({ fieldId: fid, value: newVal });
}
}
const bulkFormStatuses = await storage.getFormStatuses(existingTask.formId, req.organizationId!);
const bulkValidationErrors = await validateRequiredFields({
task: existingTask,
formFields,
existingFieldValues: simulatedFieldValuesBulk,
organizationId: req.organizationId!,
storage,
statuses: bulkFormStatuses,
});
if (bulkValidationErrors.length > 0) {
return res.status(400).json({ success: false, error: bulkValidationErrors.join('\n') });
}
const immutableErrorsBulk = await validateImmutableFields({
task: existingTask,
formFields,
changedFieldIds: fieldIds,
existingFieldValues: simulatedFieldValuesBulk,
organizationId: req.organizationId!,
storage,
statuses: bulkFormStatuses,
});
if (immutableErrorsBulk.length > 0) {
return res.status(400).json({ success: false, error: immutableErrorsBulk.join('\n') });
}
for (const fieldId of fieldIds) {
const field = fieldMap.get(fieldId);
if (!field) {
return res.status(404).json({ success: false, error: `Поле ${fieldId} не найдено` });
}
if (!INLINE_EDITABLE_FIELD_TYPES.has(field.type)) {
return res.status(400).json({ success: false, error: `Поле типа «${field.type}» не поддерживает inline-редактирование` });
}
}
for (const [k, newVal] of normalizedEntries) {
const fid = parseInt(k, 10);
const fld = fieldMap.get(fid);
if (fld?.type === 'file') {
const oldFileVal = existingValueMap.get(fid);
if (oldFileVal) await deleteRemovedFiles(oldFileVal, newVal);
}
}
const updates = normalizedEntries.map(([k, value]) => ({ fieldId: parseInt(k, 10), value }));
const savedValues = await storage.bulkUpsertTaskFieldValues(taskId, existingTask.formId, updates);
const editorName = req.user
? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email)
: 'API';
const offlineEnqueuedAtBulk = parseOfflineTimestamp(req);
for (const { fieldId, value } of updates) {
const field = fieldMap.get(fieldId)!;
const oldVal = existingValueMap.get(fieldId);
const oldStr = oldVal === null || oldVal === undefined ? '' : String(oldVal);
const newStr = value === null || value === undefined ? '' : String(value);
if (oldStr !== newStr) {
storage.addTaskAuditLog({
taskId,
organizationId: req.organizationId!,
action: 'field.changed',
fieldId: field.id,
fieldName: field.name,
oldValue: oldVal ?? null,
newValue: value ?? null,
changedBy: req.user?.id ?? null,
changedByName: editorName,
...(offlineEnqueuedAtBulk ? { createdAt: offlineEnqueuedAtBulk } : {}),
}).catch((auditErr: unknown) => { console.error('Audit log error (bulk inline edit):', auditErr); });
}
}
tasksMinimalCache.invalidatePrefix(`tasks:${req.organizationId}:minimal:`);
// Sync task_assignees from user fields so tasks appear in inbox
for (const sv of savedValues) {
const fld = fieldMap.get(sv.fieldId);
if (fld?.type === 'user') {
const userId = extractUserId(sv.value);
if (userId) {
await storage.addTaskAssignee(taskId, userId, req.organizationId!).catch(() => {});
}
}
}
const taskForm = await storage.getForm(existingTask.formId, req.organizationId!);
if (taskForm?.titleTemplate) {
const [allValues, bulkStatuses, bulkUsers, bulkRoles] = await Promise.all([
storage.getTaskFieldValues(taskId, req.organizationId!),
storage.getFormStatuses(existingTask.formId, req.organizationId!),
storage.getUsersByOrganization(req.organizationId!),
storage.getRoles(req.organizationId!),
]);
const valueByFieldId = new Map(allValues.map(v => [v.fieldId, v.value]));
const fieldCodeMap = new Map(formFields.map(f => [f.code, f]));
const bulkUsersMap = new Map(bulkUsers.map(u => [u.id, u]));
const bulkRolesMap = new Map(bulkRoles.map(r => [r.id, r]));
const sysValues = await buildSystemFieldValues(existingTask, req.organizationId!, { statuses: bulkStatuses, usersMap: bulkUsersMap });
const taskTitleMapBulk = await resolveTaskFieldTitles(valueByFieldId, formFields, req.organizationId!);
const tableRowMapBulk = await buildTableRowMap(formFields, req.organizationId!);
const computedTitle = taskForm.titleTemplate.replace(/\{\{([^}]+)\}\}/g, (_, 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: bulkUsersMap, rolesMap: bulkRolesMap, taskTitleMap: taskTitleMapBulk, tableRowMap: tableRowMapBulk }, field.options);
});
console.error('[TITLE DEBUG] PATCH field-values bulk: usersMap size=', bulkUsersMap.size, 'computedTitle=', computedTitle);
await storage.updateTask(taskId, req.organizationId!, { title: computedTitle });
}
indexTaskAsync(taskId, req.organizationId!).catch(() => {});
const autoResult = await evaluateAutoTransitions(taskId, req.organizationId!, { triggeredBy: req.user?.id ?? null });
const freshTask = await storage.getTask(taskId, req.organizationId!);
eventBus.publishEvent({
type: 'task_updated',
organizationId: req.organizationId!,
data: { taskId, formId: existingTask.formId, task: freshTask, autoTransition: autoResult.changed }
});
res.json({ success: true, fieldValues: savedValues });
} catch (error) {
console.error('Bulk field value update error:', error);
res.status(500).json({ success: false, error: 'Ошибка при массовом обновлении полей' });
}
}
);
}