fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
150
server/routes/form.update.routes.ts
Normal file
150
server/routes/form.update.routes.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Router } from "express";
|
||||
import { storage } from "../storage";
|
||||
import { requireFormAdminOrPermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
||||
import { validateRequest } from "../middleware/validation.middleware";
|
||||
import { updateFormSchema } from "@shared/schema";
|
||||
import { formsCache } from "../utils/cache";
|
||||
import { formatUserName } from "../utils/formatUserName";
|
||||
import { indexFormAsync } from "./task-helpers";
|
||||
import { recalculateTaskTitles } from "./shared";
|
||||
|
||||
export function registerFormUpdateRoutes(router: ReturnType<typeof import("express").Router>): void {
|
||||
/**
|
||||
* @swagger
|
||||
* /api/forms/{id}:
|
||||
* put:
|
||||
* tags: [Forms]
|
||||
* summary: Обновить форму (admin)
|
||||
*/
|
||||
router.put('/api/forms/:id',
|
||||
requireFormAdminOrPermission('forms.manage'),
|
||||
validateRequest(updateFormSchema),
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const formId = parseInt(req.params.id);
|
||||
if (isNaN(formId)) {
|
||||
return res.status(400).json({ success: false, error: 'Неверный ID формы' });
|
||||
}
|
||||
|
||||
const existingForm = await storage.getForm(formId, req.organizationId!);
|
||||
if (!existingForm) {
|
||||
return res.status(404).json({ success: false, error: 'Форма не найдена' });
|
||||
}
|
||||
|
||||
const updateData = { ...req.body };
|
||||
|
||||
const incomingSummary: string | undefined = updateData.aiSummary;
|
||||
const existingSummary: string = existingForm.aiSummary ?? '';
|
||||
|
||||
if (incomingSummary === '' && existingSummary) {
|
||||
delete updateData.aiSummary;
|
||||
} else if (!incomingSummary && !existingSummary) {
|
||||
const [fields, statuses, transitions, orgUsers, orgRolesDesc] = await Promise.all([
|
||||
storage.getFormFields(formId, req.organizationId!),
|
||||
storage.getFormStatuses(formId, req.organizationId!),
|
||||
storage.getStatusTransitions(formId, req.organizationId!),
|
||||
storage.getUsersByOrganization(req.organizationId!),
|
||||
storage.getRoles(req.organizationId!),
|
||||
]);
|
||||
const formName = updateData.name ?? existingForm.name ?? '';
|
||||
const tmpl = (updateData.titleTemplate ?? existingForm.titleTemplate ?? '').trim();
|
||||
const fieldNames = fields.filter(f => f.name).map(f => f.name).join(', ');
|
||||
const statusMap = new Map(statuses.map(s => [s.id, s.name]));
|
||||
const statusNames = statuses.map(s => s.name).join(', ');
|
||||
const fieldMap = new Map(fields.map(f => [f.code, f.name]));
|
||||
const userMap = new Map(orgUsers.map(u => [u.id, formatUserName(u) || u.email]));
|
||||
const roleMap = new Map(orgRolesDesc.map(r => [r.id, r.name]));
|
||||
|
||||
const describeTransition = (t: typeof transitions[number]): string => {
|
||||
const from = statusMap.get(t.fromStatusId);
|
||||
const to = statusMap.get(t.toStatusId);
|
||||
if (!from || !to) return '';
|
||||
const details: string[] = [];
|
||||
if (t.actionName) details.push(`кнопка «${t.actionName}»`);
|
||||
if (t.requiredRole === 'admin') details.push('только администраторы');
|
||||
if (t.assigneeUserId) {
|
||||
const uname = userMap.get(t.assigneeUserId) || `пользователь #${t.assigneeUserId}`;
|
||||
details.push(`согласует ${uname}`);
|
||||
}
|
||||
const ac = t.assigneeConditions as Record<string, unknown>;
|
||||
if (ac) {
|
||||
const acRules = (ac.rules as Record<string, unknown>[] | undefined) || (ac.field && ac.assigneeId ? [{ fieldCode: ac.field, value: ac.value, assigneeUserId: ac.assigneeId }] : []);
|
||||
acRules.forEach((r: Record<string, unknown>) => {
|
||||
const fname = fieldMap.get(String(r.fieldCode)) || r.fieldCode;
|
||||
if (r.assigneeSource === 'role' && r.roleId) {
|
||||
const rname = roleMap.get(Number(r.roleId)) || `роль #${r.roleId}`;
|
||||
if (fname) details.push(`если поле «${fname}»="${r.value || '...'}" → согласует роль «${rname}»`);
|
||||
} else {
|
||||
const uname = r.assigneeUserId ? (userMap.get(Number(r.assigneeUserId)) || `пользователь #${r.assigneeUserId}`) : '';
|
||||
if (fname && uname) details.push(`если поле «${fname}»="${r.value || '...'}" → согласует ${uname}`);
|
||||
}
|
||||
});
|
||||
if (ac.fallbackAssigneeUserId) {
|
||||
const u = userMap.get(Number(ac.fallbackAssigneeUserId)) || `пользователь #${ac.fallbackAssigneeUserId}`;
|
||||
details.push(`по умолчанию согласует ${u}`);
|
||||
}
|
||||
}
|
||||
const cond = t.conditions as Record<string, unknown>;
|
||||
if (cond) {
|
||||
(cond.routingRules as Array<Record<string,unknown>> || []).forEach((r) => {
|
||||
const fname = fieldMap.get(r.fieldCode as unknown as string) || r.fieldCode;
|
||||
const sname = statusMap.get(Number(r.toStatusId)) || `статус #${r.toStatusId}`;
|
||||
if (fname) details.push(`если «${fname}»="${r.value}" → ${sname}`);
|
||||
});
|
||||
if (cond.defaultToStatusId) {
|
||||
const sname = statusMap.get(Number(cond.defaultToStatusId));
|
||||
if (sname) details.push(`по умолчанию → ${sname}`);
|
||||
}
|
||||
(cond.rejectRoutingRules as Array<Record<string,unknown>> || []).forEach((r) => {
|
||||
const fname = fieldMap.get(r.fieldCode as unknown as string) || r.fieldCode;
|
||||
const sname = statusMap.get(Number(r.toStatusId)) || `статус #${r.toStatusId}`;
|
||||
if (fname) details.push(`при отклонении если «${fname}»="${r.value}" → ${sname}`);
|
||||
});
|
||||
}
|
||||
if (t.rejectToStatusId) {
|
||||
const sname = statusMap.get(t.rejectToStatusId);
|
||||
if (sname) details.push(`при отклонении → ${sname}`);
|
||||
}
|
||||
const base = `${from} → ${to}`;
|
||||
return details.length ? `${base} (${details.join(', ')})` : base;
|
||||
};
|
||||
|
||||
const transitionLines = transitions.map(describeTransition).filter(Boolean).join('; ');
|
||||
const parts: string[] = [];
|
||||
if (formName) parts.push(`Форма: ${formName}`);
|
||||
if (tmpl) parts.push(`Шаблон названия: ${tmpl}`);
|
||||
if (fieldNames) parts.push(`Поля: ${fieldNames}`);
|
||||
if (statusNames) parts.push(`Статусы: ${statusNames}`);
|
||||
if (transitionLines) parts.push(`Переходы: ${transitionLines}`);
|
||||
if (parts.length) updateData.aiSummary = parts.join('. ') + '.';
|
||||
}
|
||||
|
||||
const updatedForm = await storage.updateForm(formId, req.organizationId!, updateData);
|
||||
formsCache.invalidatePrefix(`forms:${req.organizationId}`);
|
||||
indexFormAsync(formId, req.organizationId!).catch(() => {});
|
||||
|
||||
// If titleTemplate changed, recalculate all task titles for this form
|
||||
const oldTemplate = existingForm.titleTemplate ?? '';
|
||||
const newTemplate = updateData.titleTemplate ?? '';
|
||||
let recalculation = null;
|
||||
if (oldTemplate !== newTemplate && newTemplate) {
|
||||
recalculation = await recalculateTaskTitles(formId, req.organizationId!);
|
||||
}
|
||||
|
||||
let message = 'Форма обновлена';
|
||||
if (recalculation) {
|
||||
if (recalculation.errors.length > 0) {
|
||||
message += ` (названия обновлены: ${recalculation.updated} из ${recalculation.total}, ошибок: ${recalculation.errors.length})`;
|
||||
} else if (recalculation.updated > 0) {
|
||||
message += ` (названия обновлены: ${recalculation.updated} из ${recalculation.total})`;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, message, form: updatedForm, recalculation });
|
||||
} catch (error) {
|
||||
console.error('Update form error:', error);
|
||||
res.status(500).json({ success: false, error: 'Ошибка при обновлении формы' });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user