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,518 @@
# Детальный промт для создания мультитенантной системы управления рабочими процессами
---
## ЭТАП 2: СИСТЕМА ФОРМ И ЗАДАЧ
### 🎯 Цель этапа
Создать систему динамических форм с различными типами полей и систему задач на основе этих форм.
### 🗄️ Расширение схемы базы данных
```sql
-- Таблица форм
CREATE TABLE forms (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
description TEXT,
created_by INTEGER NOT NULL REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT true
);
-- Таблица полей форм
CREATE TABLE form_fields (
id SERIAL PRIMARY KEY,
form_id INTEGER NOT NULL REFERENCES forms(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
code VARCHAR(100) NOT NULL, -- уникальный код поля внутри формы
type VARCHAR(50) NOT NULL, -- text, number, date, select, checkbox, toggle, button, user, file, textarea, rich-text, datetime, history-number
is_required BOOLEAN DEFAULT false,
placeholder TEXT,
default_value TEXT,
validation_rules JSONB, -- правила валидации в формате JSON
options JSONB, -- опции для select полей
position INTEGER NOT NULL DEFAULT 0, -- порядок отображения
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(form_id, code)
);
-- Таблица статусов форм
CREATE TABLE form_statuses (
id SERIAL PRIMARY KEY,
form_id INTEGER NOT NULL REFERENCES forms(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
color VARCHAR(7) NOT NULL DEFAULT '#e5e7eb',
position INTEGER NOT NULL DEFAULT 0,
is_initial BOOLEAN DEFAULT false, -- начальный статус
is_final BOOLEAN DEFAULT false, -- финальный статус
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Таблица правил переходов между статусами
CREATE TABLE status_transitions (
id SERIAL PRIMARY KEY,
form_id INTEGER NOT NULL REFERENCES forms(id) ON DELETE CASCADE,
from_status_id INTEGER NOT NULL REFERENCES form_statuses(id),
to_status_id INTEGER NOT NULL REFERENCES form_statuses(id),
action_type VARCHAR(20) NOT NULL, -- 'approve', 'reject', 'custom'
action_name VARCHAR(100), -- название кнопки действия
conditions JSONB, -- условия для перехода
required_role VARCHAR(20), -- роль, необходимая для действия
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Таблица задач
CREATE TABLE tasks (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
form_id INTEGER NOT NULL REFERENCES forms(id),
title VARCHAR(500) NOT NULL,
description TEXT,
current_status_id INTEGER NOT NULL REFERENCES form_statuses(id),
assigned_to INTEGER REFERENCES users(id), -- ответственный
created_by INTEGER NOT NULL REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
due_date TIMESTAMP -- дедлайн
);
-- Таблица значений полей задач
CREATE TABLE task_field_values (
id SERIAL PRIMARY KEY,
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
field_id INTEGER NOT NULL REFERENCES form_fields(id),
value JSONB, -- значение поля в JSON формате
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(task_id, field_id)
);
-- Таблица истории изменений для history-number полей
CREATE TABLE field_history (
id SERIAL PRIMARY KEY,
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
field_id INTEGER NOT NULL REFERENCES form_fields(id),
old_value JSONB,
new_value JSONB,
changed_by INTEGER NOT NULL REFERENCES users(id),
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Индексы для производительности
CREATE INDEX idx_forms_organization ON forms(organization_id);
CREATE INDEX idx_form_fields_form ON form_fields(form_id);
CREATE INDEX idx_form_statuses_form ON form_statuses(form_id);
CREATE INDEX idx_tasks_organization ON tasks(organization_id);
CREATE INDEX idx_tasks_form ON tasks(form_id);
CREATE INDEX idx_tasks_status ON tasks(current_status_id);
CREATE INDEX idx_tasks_assigned ON tasks(assigned_to);
CREATE INDEX idx_task_field_values_task ON task_field_values(task_id);
CREATE INDEX idx_field_history_task_field ON field_history(task_id, field_id);
```
### 📝 Типы полей - Детальная реализация
**Валидация для каждого типа поля (utils/fieldValidation.js):**
```javascript
const fieldValidators = {
text: (value, rules = {}) => {
if (!value && rules.required) return 'Поле обязательно для заполнения';
if (value && rules.minLength && value.length < rules.minLength) {
return `Минимальная длина: ${rules.minLength} символов`;
}
if (value && rules.maxLength && value.length > rules.maxLength) {
return `Максимальная длина: ${rules.maxLength} символов`;
}
if (value && rules.pattern && !new RegExp(rules.pattern).test(value)) {
return rules.patternMessage || 'Неверный формат';
}
return null;
},
number: (value, rules = {}) => {
if (!value && rules.required) return 'Поле обязательно для заполнения';
if (value !== null && value !== undefined) {
const num = Number(value);
if (isNaN(num)) return 'Должно быть числом';
if (rules.min !== undefined && num < rules.min) {
return `Минимальное значение: ${rules.min}`;
}
if (rules.max !== undefined && num > rules.max) {
return `Максимальное значение: ${rules.max}`;
}
}
return null;
},
date: (value, rules = {}) => {
if (!value && rules.required) return 'Поле обязательно для заполнения';
if (value) {
const date = new Date(value);
if (isNaN(date.getTime())) return 'Неверный формат даты';
if (rules.minDate && date < new Date(rules.minDate)) {
return `Дата не может быть раньше ${rules.minDate}`;
}
if (rules.maxDate && date > new Date(rules.maxDate)) {
return `Дата не может быть позже ${rules.maxDate}`;
}
}
return null;
},
datetime: (value, rules = {}) => {
return fieldValidators.date(value, rules); // Та же валидация что и для date
},
select: (value, rules = {}, options = []) => {
if (!value && rules.required) return 'Поле обязательно для заполнения';
if (value && options.length > 0) {
const validOption = options.find(opt => opt.id === value);
if (!validOption) return 'Неверное значение';
}
return null;
},
checkbox: (value, rules = {}) => {
if (rules.required && !value) return 'Поле должно быть отмечено';
return null;
},
toggle: (value, rules = {}) => {
return fieldValidators.checkbox(value, rules);
},
user: (value, rules = {}) => {
if (!value && rules.required) return 'Необходимо выбрать пользователя';
if (value && !Number.isInteger(Number(value))) {
return 'Неверный ID пользователя';
}
return null;
},
file: (value, rules = {}) => {
if (!value && rules.required) return 'Необходимо загрузить файл';
if (value && rules.maxFileSize && value.size > rules.maxFileSize) {
return `Максимальный размер файла: ${rules.maxFileSize / 1024 / 1024} МБ`;
}
if (value && rules.allowedTypes && !rules.allowedTypes.includes(value.type)) {
return `Разрешенные типы файлов: ${rules.allowedTypes.join(', ')}`;
}
return null;
},
textarea: (value, rules = {}) => {
return fieldValidators.text(value, rules);
},
'rich-text': (value, rules = {}) => {
// Проверяем HTML контент
if (!value && rules.required) return 'Поле обязательно для заполнения';
if (value && rules.maxLength) {
// Удаляем HTML теги для подсчета длины текста
const textContent = value.replace(/<[^>]*>/g, '');
if (textContent.length > rules.maxLength) {
return `Максимальная длина текста: ${rules.maxLength} символов`;
}
}
return null;
},
'history-number': (value, rules = {}) => {
return fieldValidators.number(value, rules);
}
};
const validateFieldValue = (fieldType, value, validationRules, options) => {
const validator = fieldValidators[fieldType];
if (!validator) {
throw new Error(`Неизвестный тип поля: ${fieldType}`);
}
return validator(value, validationRules, options);
};
module.exports = { validateFieldValue, fieldValidators };
```
### 🔧 API для форм - Детальная реализация
**CRUD операции для форм (controllers/forms.controller.js):**
```javascript
const Form = require('../models/Form');
const FormField = require('../models/FormField');
const FormStatus = require('../models/FormStatus');
const { validateFieldValue } = require('../utils/fieldValidation');
// Получить все формы организации
const getForms = async (req, res) => {
try {
const { page = 1, limit = 20, search } = req.query;
const offset = (page - 1) * limit;
let whereClause = { organization_id: req.tenantId, is_active: true };
if (search) {
whereClause.name = { [Op.iLike]: `%${search}%` };
}
const { rows: forms, count } = await Form.findAndCountAll({
where: whereClause,
include: [
{
model: FormField,
as: 'fields',
order: [['position', 'ASC']]
},
{
model: FormStatus,
as: 'statuses',
order: [['position', 'ASC']]
}
],
limit: parseInt(limit),
offset: parseInt(offset),
order: [['created_at', 'DESC']]
});
res.json({
success: true,
data: forms,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total: count,
pages: Math.ceil(count / limit)
}
});
} catch (error) {
console.error('Ошибка получения форм:', error);
res.status(500).json({ success: false, error: 'Ошибка сервера' });
}
};
// Создать новую форму
const createForm = async (req, res) => {
const transaction = await sequelize.transaction();
try {
const { name, description, fields = [], statuses = [] } = req.body;
// Валидация входных данных
if (!name || name.trim().length === 0) {
return res.status(400).json({
success: false,
error: 'Название формы обязательно'
});
}
// Создаем форму
const form = await Form.create({
organization_id: req.tenantId,
name: name.trim(),
description: description?.trim(),
created_by: req.user.id
}, { transaction });
// Создаем поля формы
const createdFields = [];
for (let i = 0; i < fields.length; i++) {
const field = fields[i];
// Валидация поля
if (!field.name || !field.type) {
throw new Error(`Поле ${i + 1}: название и тип обязательны`);
}
const createdField = await FormField.create({
form_id: form.id,
name: field.name.trim(),
code: field.code || `field_${i + 1}`,
type: field.type,
is_required: field.is_required || false,
placeholder: field.placeholder,
default_value: field.default_value,
validation_rules: field.validation_rules || {},
options: field.options || [],
position: i
}, { transaction });
createdFields.push(createdField);
}
// Создаем статусы (если не указаны, создаем по умолчанию)
const defaultStatuses = statuses.length > 0 ? statuses : [
{ name: 'Создана', color: '#e5e7eb', is_initial: true },
{ name: 'В работе', color: '#bfdbfe' },
{ name: 'Завершена', color: '#d1fae5', is_final: true }
];
const createdStatuses = [];
for (let i = 0; i < defaultStatuses.length; i++) {
const status = defaultStatuses[i];
const createdStatus = await FormStatus.create({
form_id: form.id,
name: status.name,
color: status.color || '#e5e7eb',
position: i,
is_initial: i === 0 || status.is_initial,
is_final: i === defaultStatuses.length - 1 || status.is_final
}, { transaction });
createdStatuses.push(createdStatus);
}
await transaction.commit();
// Возвращаем созданную форму с полями и статусами
const fullForm = await Form.findByPk(form.id, {
include: [
{ model: FormField, as: 'fields', order: [['position', 'ASC']] },
{ model: FormStatus, as: 'statuses', order: [['position', 'ASC']] }
]
});
res.status(201).json({
success: true,
data: fullForm,
message: 'Форма успешно создана'
});
} catch (error) {
await transaction.rollback();
console.error('Ошибка создания формы:', error);
res.status(400).json({
success: false,
error: error.message || 'Ошибка создания формы'
});
}
};
// Обновить форму
const updateForm = async (req, res) => {
const transaction = await sequelize.transaction();
try {
const { id } = req.params;
const { name, description, fields, statuses } = req.body;
// Находим форму
const form = await Form.findOne({
where: { id, organization_id: req.tenantId }
});
if (!form) {
return res.status(404).json({
success: false,
error: 'Форма не найдена'
});
}
// Обновляем основную информацию формы
if (name) form.name = name.trim();
if (description !== undefined) form.description = description?.trim();
form.updated_at = new Date();
await form.save({ transaction });
// Обновляем поля, если переданы
if (fields && Array.isArray(fields)) {
// Удаляем старые поля
await FormField.destroy({
where: { form_id: form.id },
transaction
});
// Создаем новые поля
for (let i = 0; i < fields.length; i++) {
const field = fields[i];
await FormField.create({
form_id: form.id,
name: field.name.trim(),
code: field.code || `field_${i + 1}`,
type: field.type,
is_required: field.is_required || false,
placeholder: field.placeholder,
default_value: field.default_value,
validation_rules: field.validation_rules || {},
options: field.options || [],
position: i
}, { transaction });
}
}
// Обновляем статусы, если переданы
if (statuses && Array.isArray(statuses)) {
// Удаляем старые статусы
await FormStatus.destroy({
where: { form_id: form.id },
transaction
});
// Создаем новые статусы
for (let i = 0; i < statuses.length; i++) {
const status = statuses[i];
await FormStatus.create({
form_id: form.id,
name: status.name,
color: status.color || '#e5e7eb',
position: i,
is_initial: i === 0 || status.is_initial,
is_final: i === statuses.length - 1 || status.is_final
}, { transaction });
}
}
await transaction.commit();
// Возвращаем обновленную форму
const updatedForm = await Form.findByPk(form.id, {
include: [
{ model: FormField, as: 'fields', order: [['position', 'ASC']] },
{ model: FormStatus, as: 'statuses', order: [['position', 'ASC']] }
]
});
res.json({
success: true,
data: updatedForm,
message: 'Форма успешно обновлена'
});
} catch (error) {
await transaction.rollback();
console.error('Ошибка обновления формы:', error);
res.status(400).json({
success: false,
error: error.message || 'Ошибка обновления формы'
});
}
};
module.exports = {
getForms,
createForm,
updateForm,
// ... другие методы
};
```
### ✅ Критерии завершения Этапа 2
**Функциональные требования:**
1. ✅ Создание форм с различными типами полей
2. ✅ Валидация полей согласно их типам
3. ✅ Настройка статусов форм с цветами
4. ✅ Правила переходов между статусами
5. ✅ Создание задач на основе форм
6. ✅ Назначение ответственных за задачи
7. ✅ Заполнение и редактирование полей задач
8. ✅ История изменений для history-number полей
9. ✅ Пагинация для всех списков
10. ✅ Фильтрация и поиск форм/задач
**Продолжение следует в Этапе 3...**