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

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

20 KiB
Raw Blame History

Детальный промт для создания мультитенантной системы управления рабочими процессами


ЭТАП 2: СИСТЕМА ФОРМ И ЗАДАЧ

🎯 Цель этапа

Создать систему динамических форм с различными типами полей и систему задач на основе этих форм.

🗄️ Расширение схемы базы данных

-- Таблица форм
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):

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):

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...