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,370 @@
import { Router } from "express";
import { storage } from "../storage";
import { requireFormAdminOrPermission, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { validateRequest } from "../middleware/validation.middleware";
import { insertFormSchema, type Form } from "@shared/schema";
import { formsCache } from "../utils/cache";
import { logAudit, getClientIp } from "../utils/audit";
import { formatUserName } from "../utils/formatUserName";
import { indexFormWithSummaryAsync } from "./task-helpers";
import { recalculateTaskTitles } from "./shared";
export function registerFormCrudRoutes(router: ReturnType<typeof import("express").Router>): void {
async function filterFormsByAccess(
forms: Form[],
userId: number,
organizationId: number,
appRole: string
): Promise<Form[]> {
const hasManageForms = await storage.hasAppRolePermission(appRole, ['forms.manage']);
if (hasManageForms) return forms;
const results = await Promise.all(
forms.map(async (f) => {
const canAccess = await storage.canUserAccessForm(userId, f.id, organizationId, 'participate');
return canAccess ? f : null;
})
);
return results.filter(Boolean) as Form[];
}
router.get('/api/forms', async (req: AuthenticatedRequest, res) => {
try {
let filtersObj: Record<string, string> = {};
try { filtersObj = req.query.filters ? JSON.parse(req.query.filters as string) : {}; } catch {}
const sortColumn = (req.query.sortColumn as string) || '';
const sortDirection = (req.query.sortDirection as string) === 'desc' ? 'desc' : 'asc';
const hasFilters = Object.keys(filtersObj).some(k => filtersObj[k]) || !!sortColumn;
const getFormColVal = (f: Form, col: string): string => {
if (col === 'name') return f.name || '';
if (col === 'status') return f.isActive ? 'Активна' : 'Неактивна';
if (col === 'createdAt') return f.createdAt ? new Date(f.createdAt).toLocaleDateString('ru-RU') : '';
return '';
};
const userId = req.user!.id;
const organizationId = req.organizationId!;
const appRole = req.user!.appRole;
const hasManageForms = await storage.hasAppRolePermission(appRole, ['forms.manage']);
if (!hasFilters) {
if (hasManageForms) {
const cacheKey = `forms:${organizationId}`;
const cached = formsCache.get<Form[]>(cacheKey);
if (cached) {
return res.json({ success: true, forms: cached });
}
const forms = await storage.getFormsByOrganization(organizationId);
formsCache.set(cacheKey, forms);
return res.json({ success: true, forms });
}
// Non-admins: load without cache and filter by access
const forms = await storage.getFormsByOrganization(organizationId);
const accessible = await filterFormsByAccess(forms, userId, organizationId, appRole);
return res.json({ success: true, forms: accessible });
}
let forms = await storage.getFormsByOrganization(organizationId);
Object.entries(filtersObj).forEach(([col, val]) => {
if (!val) return;
const vl = (val as string).toLowerCase();
forms = forms.filter(f => getFormColVal(f, col).toLowerCase().includes(vl));
});
if (sortColumn) {
forms = forms.sort((a, b) => {
if (sortColumn === 'createdAt' || sortColumn === 'updatedAt') {
const _av = (a as Record<string, unknown>)[sortColumn];
const _bv = (b as Record<string, unknown>)[sortColumn];
const av = _av ? new Date(String(_av)).getTime() : 0;
const bv = _bv ? new Date(String(_bv)).getTime() : 0;
return sortDirection === 'asc' ? av - bv : bv - av;
}
const av = getFormColVal(a, sortColumn).toLowerCase();
const bv = getFormColVal(b, sortColumn).toLowerCase();
const cmp = av.localeCompare(bv, 'ru');
return sortDirection === 'asc' ? cmp : -cmp;
});
}
if (!hasManageForms) {
forms = await filterFormsByAccess(forms, userId, organizationId, appRole);
}
res.json({ success: true, forms });
} catch (error) {
console.error('Get forms error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении форм' });
}
});
router.get('/api/forms/column-values',
async (req: AuthenticatedRequest, res) => {
try {
const column = (req.query.column as string) || '';
const search = ((req.query.search as string) || '').toLowerCase();
let context: Record<string, string> = {};
try { context = req.query.context ? JSON.parse(req.query.context as string) : {}; } catch {}
const organizationId = req.organizationId!;
let forms = await storage.getFormsByOrganization(organizationId);
const getFormColVal2 = (f: Form, col: string): string => {
if (col === 'name') return f.name || '';
if (col === 'status') return f.isActive ? 'Активна' : 'Неактивна';
if (col === 'createdAt') return f.createdAt ? new Date(f.createdAt).toLocaleDateString('ru-RU') : '';
return '';
};
Object.entries(context).forEach(([col, val]) => {
if (!val) return;
const vl = (val as string).toLowerCase();
forms = forms.filter(f => getFormColVal2(f, col).toLowerCase().includes(vl));
});
forms = await filterFormsByAccess(forms, req.user!.id, organizationId, req.user!.appRole);
const seen = new Set<string>();
const values: string[] = [];
for (const f of forms) {
const val = getFormColVal2(f, column);
if (!val || seen.has(val)) continue;
if (search && !val.toLowerCase().includes(search)) continue;
seen.add(val);
values.push(val);
if (values.length >= 20) break;
}
res.json({ values });
} catch (error) {
res.status(500).json({ error: 'Ошибка получения значений' });
}
}
);
/**
* @swagger
* /api/forms/{id}:
* get:
* tags: [Forms]
* summary: Получить форму
*/
router.get('/api/forms/:id(\\d+)', async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.id);
if (isNaN(formId)) {
return res.status(400).json({ success: false, error: 'Неверный ID формы' });
}
const form = await storage.getForm(formId, req.organizationId!);
if (!form) {
return res.status(404).json({ success: false, error: 'Форма не найдена' });
}
const hasManageForms = await storage.hasAppRolePermission(req.user!.appRole, ['forms.manage']);
if (!hasManageForms) {
const canAccess = await storage.canUserAccessForm(req.user!.id, formId, req.organizationId!, 'participate');
if (!canAccess) {
return res.status(403).json({ success: false, error: 'Нет доступа к этой форме' });
}
}
res.json({ success: true, form });
} catch (error) {
console.error('Get form error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении формы' });
}
});
router.get('/api/forms/:id/editor', async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.id);
if (isNaN(formId)) {
return res.status(400).json({ success: false, error: 'Неверный ID формы' });
}
const organizationId = req.organizationId!;
const [form, fields, statuses, transitions, tabs, rawUsers, orgRoles, canManageForm] = await Promise.all([
storage.getForm(formId, organizationId),
storage.getFormFields(formId, organizationId),
storage.getFormStatuses(formId, organizationId),
storage.getStatusTransitions(formId, organizationId),
storage.getFormTabs(formId, organizationId),
storage.getUsersByOrganization(organizationId),
storage.getRoles(organizationId),
storage.canUserAccessForm(req.user!.id, formId, organizationId, 'admin')
]);
if (!form) {
return res.status(404).json({ success: false, error: 'Форма не найдена' });
}
const users = rawUsers.map(user => ({
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
fullName: formatUserName(user),
position: user.position,
appRole: user.appRole,
isActive: user.isActive,
emailVerified: user.emailVerified,
lastLogin: user.lastLogin,
createdAt: user.createdAt
}));
res.json({
success: true,
form, fields, statuses, transitions, tabs, users,
roles: orgRoles.map(r => ({ id: r.id, name: r.name })),
canManageForm
});
} catch (error) {
console.error('Get form editor data error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении данных формы' });
}
});
router.get('/api/forms/:id/export',
requireFormAdminOrPermission('forms.manage'),
async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.id);
if (isNaN(formId)) {
return res.status(400).json({ success: false, error: 'Неверный ID формы' });
}
const organizationId = req.organizationId!;
const [form, fields, statuses, transitions, tabs] = await Promise.all([
storage.getForm(formId, organizationId),
storage.getFormFields(formId, organizationId),
storage.getFormStatuses(formId, organizationId),
storage.getStatusTransitions(formId, organizationId),
storage.getFormTabs(formId, organizationId),
]);
if (!form) {
return res.status(404).json({ success: false, error: 'Форма не найдена' });
}
logAudit({
action: 'export.form_config',
userId: req.user?.id ?? null,
organizationId,
details: { formId, formName: form.name },
ip: getClientIp(req),
userAgent: req.headers['user-agent'] ?? null,
});
res.json({
success: true,
form: {
id: form.id, name: form.name, description: form.description, isActive: form.isActive,
chatLayout: form.chatLayout, chatEnabled: form.chatEnabled, titleTemplate: form.titleTemplate,
},
tabs: tabs.map(tab => ({
id: tab.id, name: tab.name, code: tab.code, type: tab.type,
position: tab.position, tableColumns: tab.tableColumns,
})),
fields: fields.map(field => ({
id: field.id, tabId: field.tabId, name: field.name, code: field.code,
type: field.type, isRequired: field.isRequired, placeholder: field.placeholder,
defaultValue: field.defaultValue, options: field.options,
validationRules: field.validationRules, position: field.position,
linkedFormId: field.linkedFormId, taskRelationType: field.taskRelationType,
buttonActionType: field.buttonActionType, buttonUrl: field.buttonUrl,
buttonFormId: field.buttonFormId, companyAutofill: field.companyAutofill,
})),
statuses: statuses.map(status => ({
id: status.id, name: status.name, color: status.color,
position: status.position, isInitial: status.isInitial, isFinal: status.isFinal,
})),
transitions: transitions.map(tr => ({
id: tr.id, fromStatusId: tr.fromStatusId, toStatusId: tr.toStatusId,
rejectToStatusId: tr.rejectToStatusId, actionType: tr.actionType,
actionName: tr.actionName, requiredRole: tr.requiredRole,
approveButtonText: tr.approveButtonText, approveButtonColor: tr.approveButtonColor,
rejectButtonText: tr.rejectButtonText, rejectButtonColor: tr.rejectButtonColor,
})),
});
} catch (error) {
console.error('Export form error:', error);
res.status(500).json({ success: false, error: 'Ошибка при экспорте формы' });
}
}
);
router.post('/api/forms',
requirePermission('forms.manage'),
validateRequest(insertFormSchema.omit({ organizationId: true, createdBy: true })),
async (req: AuthenticatedRequest, res) => {
try {
const formData = {
...req.body,
organizationId: req.organizationId!,
createdBy: req.user!.id
};
const form = await storage.createForm(formData);
formsCache.invalidatePrefix(`forms:${req.organizationId}`);
indexFormWithSummaryAsync(form.id, req.organizationId!).catch(() => {});
res.status(201).json({ success: true, message: 'Форма создана', form });
} catch (error) {
console.error('Create form error:', error);
res.status(500).json({ success: false, error: 'Ошибка при создании формы' });
}
}
);
router.get('/api/forms/:id/tasks-count', 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 counts = await storage.getTasksCountByForm(formId);
res.json({ success: true, ...counts });
} catch (error) {
console.error('Get tasks count error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении количества задач' });
}
});
router.post('/api/forms/:id/recalculate-titles',
requireFormAdminOrPermission('forms.manage'),
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: 'Форма не найдена' });
}
if (!existingForm.titleTemplate) {
return res.status(400).json({ success: false, error: 'У формы нет шаблона названия' });
}
const result = await recalculateTaskTitles(formId, req.organizationId!);
res.json({ success: true, ...result });
} catch (error) {
console.error('Recalculate titles error:', error);
res.status(500).json({ success: false, error: 'Ошибка при пересчёте названий' });
}
}
);
router.delete('/api/forms/:id',
requireFormAdminOrPermission('forms.manage'),
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: 'Форма не найдена' });
}
await storage.deleteForm(formId, req.organizationId!);
formsCache.invalidatePrefix(`forms:${req.organizationId}`);
res.json({ success: true, message: 'Форма удалена' });
} catch (error) {
console.error('Delete form error:', error);
res.status(500).json({ success: false, error: 'Ошибка при удалении формы' });
}
}
);
}