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