Files
iistwin/attached_assets/detailed-prompt-complete_1758010511902.md
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

519 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Детальный промт для создания мультитенантной системы управления рабочими процессами
---
## ЭТАП 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...**