fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
518
attached_assets/detailed-prompt-complete_1758010511902.md
Normal file
518
attached_assets/detailed-prompt-complete_1758010511902.md
Normal 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...**
|
||||
Reference in New Issue
Block a user