const { Pool, Client } = require('pg'); const fs = require('fs'); const path = require('path'); const bcrypt = require('bcryptjs'); const DB_NAME = 'mkd_control_center'; // Учётная запись администратора портала по умолчанию (создаётся в новых/пустых БД) const DEFAULT_ADMIN_EMPLOYEE_ID = 'e-admin'; const DEFAULT_ADMIN_EMPLOYEE_NAME = 'iiEasy'; const DEFAULT_ADMIN_LOGIN = 'its'; const DEFAULT_ADMIN_PASSWORD = 'iiEasy348ax@'; const DEFAULT_ADMIN_ROLE = 'DIRECTOR'; // Демо-пользователь с теми же правами, что и its (DIRECTOR) const DEMO_EMPLOYEE_ID = 'e-demo'; const DEMO_EMPLOYEE_NAME = 'Demo'; const DEMO_LOGIN = 'demo'; const DEMO_PASSWORD = 'demo123'; const DEMO_ROLE = 'DIRECTOR'; /** * Инициализация базы данных PostgreSQL * Создает БД при первом запуске и проверяет целостность при последующих запусках */ async function initializeDatabase() { const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { console.warn('[dbInit] DATABASE_URL не задана, пропускаем инициализацию БД'); return { success: false, reason: 'DATABASE_URL not set' }; } try { // Парсим URL для получения параметров подключения const url = new URL(DATABASE_URL); const dbName = url.pathname.slice(1) || DB_NAME; // Подключаемся к postgres БД для создания новой БД const adminUrl = DATABASE_URL.replace(`/${dbName}`, '/postgres'); const adminClient = new Client({ connectionString: adminUrl }); await adminClient.connect(); console.log('[dbInit] Подключено к PostgreSQL'); // Проверяем существование БД const dbCheckResult = await adminClient.query( 'SELECT 1 FROM pg_database WHERE datname = $1', [dbName] ); if (dbCheckResult.rows.length === 0) { console.log(`[dbInit] База данных ${dbName} не найдена, создаем...`); await adminClient.query(`CREATE DATABASE ${dbName} WITH ENCODING 'UTF8'`); console.log(`[dbInit] База данных ${dbName} успешно создана`); } else { console.log(`[dbInit] База данных ${dbName} уже существует`); } await adminClient.end(); // Подключаемся к нашей БД для выполнения миграций const pool = new Pool({ connectionString: DATABASE_URL }); const client = await pool.connect(); try { // Проверяем целостность БД const integrityCheck = await checkDatabaseIntegrity(client); if (!integrityCheck.isInitialized) { console.log('[dbInit] База данных не инициализирована, выполняем миграции...'); // Удаляем проблемную таблицу meeting_bookings, если она существует с ошибкой try { await client.query('DROP TABLE IF EXISTS meeting_bookings CASCADE'); console.log('[dbInit] Удалена проблемная таблица meeting_bookings для пересоздания'); } catch (error) { // Игнорируем ошибки } await runMigrations(client); console.log('[dbInit] Миграции выполнены успешно'); await applyColumnMigrations(client); // Применяем миграцию добавления статуса 'deferred' в enum await applyDeferredStatusMigration(client); // Применяем миграцию для отслеживания производительности await applyPerformanceTrackingMigration(client); // Применяем миграцию модуля обучения await applyTrainingModuleMigration(client); // Применяем миграцию таблиц офиса await applyOfficeTablesMigration(client); // Применяем миграцию PR/NPS await applyPRNPSMigration(client); await applyNPSSurveysMigration(client); await applyNPSBuildingStatsMigration(client); await applyResidentReportDataMigration(client); // Применяем миграцию настроек компании await applyCompanySettingsMigration(client); // Применяем миграцию досудебной работы await applyPreTrialWorkMigration(client); // Применяем миграцию проверок контрагентов await applyCounterpartyChecksMigration(client); await applyPortalUsersRoleMigration(client); await applyPortalUsersAuthMigration(client); await applyUserProfileFieldsMigration(client); await applyPermissionTemplatesMigration(client); await applyIntegrationSettingsMigration(client); await applySecurityModuleMigration(client); // Применяем миграцию модуля развития await applyDevelopmentModuleMigration(client); // Применяем миграцию автоматизации воронки await applyPipelineAutomationMigration(client); // Применяем миграцию новых этапов воронки (9 пунктов) await applyDevelopmentPipelineNewStagesMigration(client); // Добавляем этап «Анализ» в воронку await applyDevelopmentPipelineAddAnalysisMigration(client); // Аудиты: статусы, индекс сложности, данные осмотра await applyDevelopmentAuditsStatusAndInspectionMigration(client); // ОСС: повестка и голоса по пунктам await applyDevelopmentOSSAgendaMigration(client); // Применяем миграцию юридического отдела (договоры, судебные дела) await applyLegalModuleMigration(client); // Применяем миграцию добавления inventory в districts await applyDistrictsInventoryMigration(client); // Таблицы ИИ-чата await applyAIChatMigration(client); // Таблица уведомлений (колокольчик) await applyNotificationsMigration(client); await applyEmployeeResponsibilityMigration(client); } else { console.log('[dbInit] База данных уже инициализирована'); // Применяем миграции даже для уже инициализированной БД (в т.ч. positions и др.) console.log('[dbInit] Применяем дополнительные миграции...'); await applyColumnMigrations(client); await applyDeferredStatusMigration(client); await applyPerformanceTrackingMigration(client); await applyDomaMappingsMigration(client); await applyPortalUsersRoleMigration(client); await applyPortalUsersAuthMigration(client); await applyUserProfileFieldsMigration(client); await applyPermissionTemplatesMigration(client); await applyIntegrationSettingsMigration(client); await applySecurityModuleMigration(client); await applyNotificationsMigration(client); await applyBuildingPersonalAccountsMigration(client); if (!integrityCheck.isHealthy) { console.warn('[dbInit] Обнаружены проблемы с целостностью БД:'); integrityCheck.issues.forEach(issue => console.warn(` - ${issue}`)); // Пытаемся исправить проблемы, выполнив миграции для недостающих таблиц console.log('[dbInit] Пытаемся исправить проблемы, выполняя миграции...'); try { await runMigrations(client); console.log('[dbInit] Миграции для исправления проблем выполнены'); await applyColumnMigrations(client); // Применяем миграцию таблиц офиса await applyOfficeTablesMigration(client); // Применяем миграцию настроек компании await applyCompanySettingsMigration(client); // Применяем миграцию досудебной работы await applyPreTrialWorkMigration(client); // Применяем миграцию модуля развития await applyDevelopmentModuleMigration(client); // Применяем миграцию автоматизации воронки await applyPipelineAutomationMigration(client); // Применяем миграцию новых этапов воронки (9 пунктов) await applyDevelopmentPipelineNewStagesMigration(client); // Добавляем этап «Анализ» в воронку await applyDevelopmentPipelineAddAnalysisMigration(client); await applyDevelopmentAuditsStatusAndInspectionMigration(client); await applyDevelopmentOSSAgendaMigration(client); // Повторно проверяем целостность const recheck = await checkDatabaseIntegrity(client); if (recheck.isHealthy) { console.log('[dbInit] Проблемы исправлены успешно'); } else { console.warn('[dbInit] Некоторые проблемы остались:'); recheck.issues.forEach(issue => console.warn(` - ${issue}`)); } } catch (migrationError) { console.error('[dbInit] Ошибка при попытке исправить проблемы:', migrationError.message); } } else { console.log('[dbInit] Проверка целостности пройдена успешно'); // Оптимизация: не выполнять все миграции при каждом старте — проверяем версию схемы let skipMigrations = false; try { await client.query('CREATE TABLE IF NOT EXISTS schema_version (version INT PRIMARY KEY)'); const verResult = await client.query('SELECT version FROM schema_version LIMIT 1'); if (verResult.rows.length > 0 && Number(verResult.rows[0].version) >= 1) { skipMigrations = true; console.log('[dbInit] Схема актуальна (schema_version=1), пропуск повторных миграций'); } } catch (e) { // таблица или запрос не сработали — выполняем миграции } if (!skipMigrations) { await applyColumnMigrations(client); await applyCandidateEventsMigration(client); await applyTrainingModuleMigration(client); await applyOfficeTablesMigration(client); await applyPRNPSMigration(client); await applyNPSSurveysMigration(client); await applyNPSBuildingStatsMigration(client); await applyResidentReportDataMigration(client); await applyCompanySettingsMigration(client); await applyPreTrialWorkMigration(client); await applyDistrictsInventoryMigration(client); await applyCounterpartyChecksMigration(client); await applyDevelopmentModuleMigration(client); await applyPipelineAutomationMigration(client); await applyDevelopmentPipelineNewStagesMigration(client); await applyDevelopmentPipelineAddAnalysisMigration(client); await applyDevelopmentAuditsStatusAndInspectionMigration(client); await applyDevelopmentOSSAgendaMigration(client); await applyLegalModuleMigration(client); await applyDistrictsInventoryMigration(client); await applyAIChatMigration(client); await applyNotificationsMigration(client); await applyEmployeeResponsibilityMigration(client); await client.query('INSERT INTO schema_version (version) VALUES (1) ON CONFLICT (version) DO UPDATE SET version = 1'); } } } // Администратор портала по умолчанию (логин its) — создаётся, если ещё нет await applyDefaultAdminSeed(client); // Демо-пользователь (demo / demo123) с правами DIRECTOR — создаётся при каждом старте, если ещё нет await applyDemoUserSeed(client); return { success: true, initialized: !integrityCheck.isInitialized, healthy: integrityCheck.isHealthy, issues: integrityCheck.issues }; } finally { client.release(); await pool.end(); } } catch (error) { console.error('[dbInit] Ошибка при инициализации БД:', error.message); return { success: false, error: error.message }; } } /** * Проверка целостности базы данных */ async function checkDatabaseIntegrity(client) { const issues = []; let isInitialized = false; let isHealthy = true; // Список обязательных таблиц const requiredTables = [ 'districts', 'buildings', 'applications', 'financial_reports', 'financial_report_mappings', 'building_financial_data', 'office_equipment', 'office_repair_requests', 'office_supply_requests', 'office_orders', 'office_order_items', 'office_order_quotes', 'office_inventory', 'office_documents', 'knowledge_base_categories', 'knowledge_base_articles', 'reviews', 'incidents', 'resident_reports', 'nps_surveys', 'nps_responses', 'work_photos', 'parsing_settings', 'meeting_rooms', 'meetings', 'meeting_bookings', 'employees', 'employee_messenger_logins', 'employee_passport_data', 'employee_labor_books', 'labor_book_entries', 'employee_certificates', 'employee_other_documents', 'employee_accounting_data', 'employee_contracts', 'employee_vacations', 'employee_sick_leaves', 'employee_terminations', 'employee_absences', 'portal_users', 'vacancies', 'candidates' ]; // Список обязательных типов const requiredTypes = [ 'doma_application_status', 'office_equipment_type', 'repair_request_status', 'employee_status', 'messenger_type', 'certificate_status', 'termination_status', 'vacancy_status', 'candidate_stage' ]; try { // Проверяем наличие таблиц for (const tableName of requiredTables) { const result = await client.query( `SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1 )`, [tableName] ); if (!result.rows[0].exists) { issues.push(`Таблица ${tableName} отсутствует`); isHealthy = false; } } // Проверяем наличие типов for (const typeName of requiredTypes) { const result = await client.query( `SELECT EXISTS ( SELECT FROM pg_type WHERE typname = $1 )`, [typeName] ); if (!result.rows[0].exists) { issues.push(`Тип ${typeName} отсутствует`); isHealthy = false; } } // Если все таблицы на месте, считаем БД инициализированной if (issues.length === 0) { isInitialized = true; } // Проверяем структуру таблиц (наличие обязательных колонок) if (isInitialized) { const tableChecks = [ { table: 'districts', columns: ['id', 'name', 'manager_name'] }, { table: 'buildings', columns: ['id', 'data'] }, { table: 'applications', columns: ['id', 'number', 'status', 'description', 'address', 'apartment', 'client_name', 'created_at', 'deadline_at'] }, { table: 'employees', columns: ['id', 'name', 'position', 'phone', 'status', 'salary', 'assigned_district_id', 'manager_id', 'birth_date', 'photo_url', 'registration_date', 'created_at', 'updated_at'] } ]; for (const check of tableChecks) { const result = await client.query( `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`, [check.table] ); const existingColumns = result.rows.map(r => r.column_name); const missingColumns = check.columns.filter(col => !existingColumns.includes(col)); if (missingColumns.length > 0) { issues.push(`Таблица ${check.table}: отсутствуют колонки ${missingColumns.join(', ')}`); isHealthy = false; } } } } catch (error) { issues.push(`Ошибка при проверке целостности: ${error.message}`); isHealthy = false; } return { isInitialized, isHealthy, issues }; } /** * Выполнение миграций из schema.sql */ async function runMigrations(client) { const schemaPath = path.join(__dirname, 'schema.sql'); if (!fs.existsSync(schemaPath)) { throw new Error(`Файл schema.sql не найден: ${schemaPath}`); } const schemaSQL = fs.readFileSync(schemaPath, 'utf8'); // Убираем комментарии CREATE DATABASE и строки с комментариями let cleanedSQL = schemaSQL .split('\n') .filter(line => { const trimmed = line.trim(); // Убираем строки с CREATE DATABASE и комментарии return !trimmed.startsWith('--') && !trimmed.toLowerCase().includes('create database') && trimmed.length > 0; }) .join('\n'); // Выполняем SQL в транзакции await client.query('BEGIN'); try { // Выполняем весь SQL файл целиком await client.query(cleanedSQL); await client.query('COMMIT'); console.log('[dbInit] Все миграции выполнены успешно'); } catch (error) { await client.query('ROLLBACK'); // Если ошибка связана с зависимостями или "уже существует", используем fallback if (error.message.includes('already exists') || error.message.includes('уже существует') || error.message.includes('duplicate key') || error.message.includes('does not exist') || error.code === '42P01') { // 42P01 = relation does not exist console.log('[dbInit] Обнаружена ошибка зависимостей или существующих объектов, выполняю по частям...'); // Пробуем выполнить по частям await executeStatementsIndividually(client, cleanedSQL); } else { console.error('[dbInit] Ошибка при выполнении миграций:', error.message); console.error('[dbInit] Детали ошибки:', error); throw error; } } } /** * Выполнение SQL по частям (fallback метод) */ async function executeStatementsIndividually(client, sql) { // Более умный парсинг - учитываем строковые литералы и dollar-quoted строки const statements = []; let currentStatement = ''; let inString = false; let stringChar = ''; let inDollarQuote = false; let dollarTag = ''; for (let i = 0; i < sql.length; i++) { const char = sql[i]; // Обработка dollar-quoted строк ($$ или $tag$) if (char === '$' && !inString) { // Проверяем, начинается ли dollar-quoted строка if (!inDollarQuote) { // Ищем начало: $ или $tag$ let tagEnd = i + 1; while (tagEnd < sql.length && sql[tagEnd] !== '$') { tagEnd++; } if (tagEnd < sql.length) { dollarTag = sql.substring(i, tagEnd + 1); // Включая оба $ inDollarQuote = true; currentStatement += char; continue; } } else { // Проверяем, заканчивается ли dollar-quoted строка // Проверяем, совпадает ли последовательность с открывающим тегом let matches = true; for (let k = 0; k < dollarTag.length && (i + k) < sql.length; k++) { if (sql[i + k] !== dollarTag[k]) { matches = false; break; } } if (matches && (i + dollarTag.length - 1) < sql.length) { // Найден закрывающий тег - добавляем все символы тега for (let k = 0; k < dollarTag.length; k++) { currentStatement += sql[i + k]; } // Пропускаем все символы тега (включая текущий $) i += dollarTag.length - 1; // -1 потому что цикл увеличит i еще на 1 inDollarQuote = false; dollarTag = ''; continue; // Пропускаем добавление char в конце цикла } } } // Обработка обычных строковых литералов (только если не в dollar-quote) if (!inDollarQuote && (char === "'" || char === '"') && (i === 0 || sql[i - 1] !== '\\')) { if (!inString) { inString = true; stringChar = char; } else if (char === stringChar) { inString = false; stringChar = ''; } } currentStatement += char; // Если точка с запятой вне строки и вне dollar-quote - конец команды if (char === ';' && !inString && !inDollarQuote) { const trimmed = currentStatement.trim(); if (trimmed.length > 0 && !trimmed.startsWith('--')) { statements.push(trimmed); } currentStatement = ''; } } // Добавляем последнюю команду, если она есть if (currentStatement.trim().length > 0) { statements.push(currentStatement.trim()); } console.log(`[dbInit] Выполняю ${statements.length} SQL команд по отдельности...`); // Выполняем каждую команду (без транзакции, т.к. она уже откатилась) let successCount = 0; let errorCount = 0; for (let i = 0; i < statements.length; i++) { const statement = statements[i]; if (statement.trim().length > 0 && !statement.startsWith('--')) { try { await client.query(statement); successCount++; if ((i + 1) % 10 === 0) { console.log(`[dbInit] Выполнено ${i + 1} из ${statements.length} команд (успешно: ${successCount}, ошибок: ${errorCount})...`); } } catch (error) { errorCount++; // Игнорируем ошибки "уже существует" и "does not exist" для зависимостей if (!error.message.includes('already exists') && !error.message.includes('уже существует') && !error.message.includes('duplicate key') && !error.message.includes('duplicate') && !(error.code === '42P01' && error.message.includes('does not exist'))) { console.warn(`[dbInit] Ошибка при выполнении команды ${i + 1}: ${error.message}`); console.warn(`[dbInit] Код ошибки: ${error.code}`); console.warn(`[dbInit] Проблемная команда (первые 200 символов): ${statement.substring(0, 200)}...`); } } } } console.log(`[dbInit] Все команды обработаны. Успешно: ${successCount}, ошибок (проигнорировано): ${errorCount}`); } /** * Лицевые счета домов — отдельная таблица (применяется и при уже инициализированной БД) */ async function applyBuildingPersonalAccountsMigration(client) { try { const check = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'building_personal_accounts'` ); if (check.rows.length === 0) { const bpaPath = path.join(__dirname, 'migrations', 'create_building_personal_accounts.sql'); if (fs.existsSync(bpaPath)) { const sql = fs.readFileSync(bpaPath, 'utf8'); await client.query(sql); console.log('[dbInit] Миграция create_building_personal_accounts.sql применена успешно'); } } } catch (err) { console.warn('[dbInit] Ошибка миграции building_personal_accounts:', err.message); } } /** * Применение миграций для добавления отсутствующих колонок */ async function applyColumnMigrations(client) { try { // Проверяем наличие колонки manager_id в таблице employees const checkResult = await client.query( `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'employees' AND column_name = 'manager_id'` ); if (checkResult.rows.length === 0) { console.log('[dbInit] Добавляем отсутствующую колонку manager_id в таблицу employees...'); await client.query(` ALTER TABLE employees ADD COLUMN IF NOT EXISTS manager_id VARCHAR(50) REFERENCES employees(id) ON DELETE SET NULL `); console.log('[dbInit] Колонка manager_id успешно добавлена'); // Создаем индекс, если его нет await client.query(` CREATE INDEX IF NOT EXISTS idx_employees_manager ON employees(manager_id) `); } // Проверяем другие важные колонки employees const employeeColumns = [ { name: 'assigned_district_id', type: 'VARCHAR(50) REFERENCES districts(id) ON DELETE SET NULL' }, { name: 'birth_date', type: 'DATE' }, { name: 'photo_url', type: 'TEXT' }, { name: 'registration_date', type: 'DATE' } ]; for (const col of employeeColumns) { const colCheck = await client.query( `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'employees' AND column_name = $1`, [col.name] ); if (colCheck.rows.length === 0) { console.log(`[dbInit] Добавляем отсутствующую колонку ${col.name} в таблицу employees...`); await client.query(` ALTER TABLE employees ADD COLUMN IF NOT EXISTS ${col.name} ${col.type} `); console.log(`[dbInit] Колонка ${col.name} успешно добавлена`); } } // Предварительная дата выхода для больничного (employee_sick_leaves) const sickLeaveColCheck = await client.query( `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'employee_sick_leaves' AND column_name = 'expected_return_date'` ); if (sickLeaveColCheck.rows.length === 0) { await client.query(` ALTER TABLE employee_sick_leaves ADD COLUMN IF NOT EXISTS expected_return_date DATE `); console.log('[dbInit] Колонка expected_return_date добавлена в employee_sick_leaves'); } // Отпуска: добавляем статус 'rejected' в CHECK (для отклонения отпуска руководителем) try { const vacConstraintCheck = await client.query( `SELECT conname FROM pg_constraint WHERE conrelid = 'public.employee_vacations'::regclass AND contype = 'c'` ); for (const row of vacConstraintCheck.rows || []) { const defRes = await client.query( `SELECT pg_get_constraintdef(oid) AS def FROM pg_constraint WHERE conname = $1`, [row.conname] ); const def = (defRes.rows[0] && defRes.rows[0].def) || ''; if (def.includes('status') && !def.includes("'rejected'")) { const conname = String(row.conname).replace(/"/g, '""'); await client.query(`ALTER TABLE employee_vacations DROP CONSTRAINT IF EXISTS "${conname}"`); await client.query(` ALTER TABLE employee_vacations ADD CONSTRAINT employee_vacations_status_check CHECK (status IN ('planned', 'approved', 'active', 'completed', 'canceled', 'rejected')) `); console.log('[dbInit] В employee_vacations добавлен статус rejected'); break; } } } catch (e) { console.warn('[dbInit] Миграция статуса rejected для отпусков:', e.message); } // Проверяем и создаем таблицу candidate_events, если её нет await applyCandidateEventsMigration(client); // Проверяем и создаем таблицы PR мероприятий (pr_events, pr_event_assignees, pr_event_photos) const prEventsCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'pr_events'` ); if (prEventsCheck.rows.length === 0) { console.log('[dbInit] Создаем таблицы pr_events, pr_event_assignees, pr_event_photos...'); const prEventsPath = path.join(__dirname, 'migrations', 'create_pr_events.sql'); if (fs.existsSync(prEventsPath)) { const prEventsSQL = fs.readFileSync(prEventsPath, 'utf8'); await client.query(prEventsSQL); console.log('[dbInit] Миграция create_pr_events.sql применена успешно'); } else { console.warn('[dbInit] Файл миграции create_pr_events.sql не найден'); } } // Добавить колонки места проведения для жителей (участок / дома) в pr_events (если таблица уже есть или только создана) const prEventsExistsNow = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'pr_events'` ); if (prEventsExistsNow.rows.length > 0) { const addLocationPlacePath = path.join(__dirname, 'migrations', 'add_pr_events_location_place.sql'); if (fs.existsSync(addLocationPlacePath)) { try { const addLocationPlaceSQL = fs.readFileSync(addLocationPlacePath, 'utf8'); await client.query(addLocationPlaceSQL); console.log('[dbInit] Миграция add_pr_events_location_place.sql применена успешно'); } catch (err) { if (!err.message.includes('already exists') && !err.message.includes('duplicate')) { console.warn('[dbInit] Ошибка при применении add_pr_events_location_place.sql:', err.message); } } } } // Таблица новостей компании (сводка + офис) const companyNewsCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'company_news'` ); if (companyNewsCheck.rows.length === 0) { console.log('[dbInit] Создаем таблицу company_news...'); const companyNewsPath = path.join(__dirname, 'migrations', 'create_company_news.sql'); if (fs.existsSync(companyNewsPath)) { const companyNewsSQL = fs.readFileSync(companyNewsPath, 'utf8'); await client.query(companyNewsSQL); console.log('[dbInit] Миграция create_company_news.sql применена успешно'); } else { console.warn('[dbInit] Файл миграции create_company_news.sql не найден'); } } // PR SMM: каналы и снимки подписчиков const prSmmChannelsCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'pr_smm_channels'` ); if (prSmmChannelsCheck.rows.length === 0) { console.log('[dbInit] Создаем таблицы pr_smm_channels, pr_smm_subscriber_snapshots...'); const prSmmPath = path.join(__dirname, 'migrations', 'create_pr_smm_channels.sql'); if (fs.existsSync(prSmmPath)) { const prSmmSQL = fs.readFileSync(prSmmPath, 'utf8'); await client.query(prSmmSQL); console.log('[dbInit] Миграция create_pr_smm_channels.sql применена успешно'); } else { console.warn('[dbInit] Файл миграции create_pr_smm_channels.sql не найден'); } } // PR Привлечение: pr_attraction_actions const prAttractionCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'pr_attraction_actions'` ); if (prAttractionCheck.rows.length === 0) { console.log('[dbInit] Создаем таблицу pr_attraction_actions...'); const prAttractionPath = path.join(__dirname, 'migrations', 'create_pr_attraction_actions.sql'); if (fs.existsSync(prAttractionPath)) { const prAttractionSQL = fs.readFileSync(prAttractionPath, 'utf8'); await client.query(prAttractionSQL); console.log('[dbInit] Миграция create_pr_attraction_actions.sql применена успешно'); } else { console.warn('[dbInit] Файл миграции create_pr_attraction_actions.sql не найден'); } } // PR Темы постов: pr_post_topics const prPostTopicsCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'pr_post_topics'` ); if (prPostTopicsCheck.rows.length === 0) { console.log('[dbInit] Создаем таблицу pr_post_topics...'); const prPostTopicsPath = path.join(__dirname, 'migrations', 'create_pr_post_topics.sql'); if (fs.existsSync(prPostTopicsPath)) { const prPostTopicsSQL = fs.readFileSync(prPostTopicsPath, 'utf8'); await client.query(prPostTopicsSQL); console.log('[dbInit] Миграция create_pr_post_topics.sql применена успешно'); } else { console.warn('[dbInit] Файл миграции create_pr_post_topics.sql не найден'); } } else { // Добавить scheduled_date если таблица уже существует const scheduledDateCheck = await client.query( `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'pr_post_topics' AND column_name = 'scheduled_date'` ); if (scheduledDateCheck.rows.length === 0) { console.log('[dbInit] Добавляем scheduled_date в pr_post_topics...'); const addScheduledDatePath = path.join(__dirname, 'migrations', 'add_scheduled_date_to_post_topics.sql'); if (fs.existsSync(addScheduledDatePath)) { const addScheduledDateSQL = fs.readFileSync(addScheduledDatePath, 'utf8'); await client.query(addScheduledDateSQL); console.log('[dbInit] Миграция add_scheduled_date_to_post_topics.sql применена успешно'); } } } // PR Отложенные посты: pr_scheduled_posts const prScheduledPostsCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'pr_scheduled_posts'` ); if (prScheduledPostsCheck.rows.length === 0) { console.log('[dbInit] Создаем таблицу pr_scheduled_posts...'); const prScheduledPostsPath = path.join(__dirname, 'migrations', 'create_pr_scheduled_posts.sql'); if (fs.existsSync(prScheduledPostsPath)) { const prScheduledPostsSQL = fs.readFileSync(prScheduledPostsPath, 'utf8'); await client.query(prScheduledPostsSQL); console.log('[dbInit] Миграция create_pr_scheduled_posts.sql применена успешно'); } else { console.warn('[dbInit] Файл миграции create_pr_scheduled_posts.sql не найден'); } } else { // Добавить image_url если таблица уже существует const imageUrlCheck = await client.query( `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'pr_scheduled_posts' AND column_name = 'image_url'` ); if (imageUrlCheck.rows.length === 0) { console.log('[dbInit] Добавляем image_url в pr_scheduled_posts...'); const addImagePath = path.join(__dirname, 'migrations', 'add_image_to_scheduled_posts.sql'); if (fs.existsSync(addImagePath)) { const addImageSQL = fs.readFileSync(addImagePath, 'utf8'); await client.query(addImageSQL); console.log('[dbInit] Миграция add_image_to_scheduled_posts.sql применена успешно'); } } } // Проверяем и создаем таблицы модуля обучения, если их нет await applyTrainingModuleMigration(client); // Проверяем и создаем таблицы офиса, если их нет await applyOfficeTablesMigration(client); // Проверяем наличие колонки report_type в таблице financial_reports const reportTypeCheck = await client.query( `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'financial_reports' AND column_name = 'report_type'` ); if (reportTypeCheck.rows.length === 0) { console.log('[dbInit] Добавляем отсутствующую колонку report_type в таблицу financial_reports...'); const migrationPath = path.join(__dirname, 'migrations', 'add_report_type.sql'); if (fs.existsSync(migrationPath)) { const migrationSQL = fs.readFileSync(migrationPath, 'utf8'); await client.query(migrationSQL); console.log('[dbInit] Миграция add_report_type.sql применена успешно'); } else { // Если файл миграции не найден, применяем SQL напрямую await client.query(` ALTER TABLE financial_reports ADD COLUMN IF NOT EXISTS report_type VARCHAR(50) DEFAULT 'other' CHECK (report_type IN ('debtors', 'balance_sheet', 'other')) `); await client.query(` CREATE INDEX IF NOT EXISTS idx_financial_reports_type ON financial_reports(report_type) `); console.log('[dbInit] Колонка report_type успешно добавлена'); } } // Миграции ОСВ 76: при каждом старте расширяем report_type и при необходимости создаём таблицы try { // Снимаем старый CHECK по report_type (имя может отличаться; схема — public) await client.query(`ALTER TABLE financial_reports DROP CONSTRAINT IF EXISTS financial_reports_report_type_check`); const { rows: checkConstraints } = await client.query(` SELECT c.conname FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid JOIN pg_namespace n ON n.oid = t.relnamespace WHERE n.nspname = 'public' AND t.relname = 'financial_reports' AND c.contype = 'c' AND (pg_get_constraintdef(c.oid) LIKE '%report_type%' OR c.conname LIKE '%report_type%') `); for (const row of checkConstraints) { const name = row.conname.replace(/"/g, '""'); await client.query(`ALTER TABLE financial_reports DROP CONSTRAINT IF EXISTS "${name}"`); } await client.query(` ALTER TABLE financial_reports ADD CONSTRAINT financial_reports_report_type_check CHECK (report_type IN ('debtors', 'balance_sheet', 'balance_sheet_76', 'other')); `); await client.query(` COMMENT ON COLUMN financial_reports.report_type IS 'Тип отчета: debtors, balance_sheet (ОСВ 20), balance_sheet_76 (ОСВ 76 — лицевые счета), other'; `); console.log('[dbInit] Миграция add_report_type_balance_sheet_76.sql применена успешно'); } catch (err) { const msg = (err.message || '').toLowerCase(); if (!msg.includes('already exists') && !msg.includes('does not exist') && !msg.includes('already has constraint')) { console.warn('[dbInit] Ошибка при применении add_report_type_balance_sheet_76.sql:', err.message); } } // Таблицы ОСВ 76: report_76_rows, building_personal_account_mappings — создаём при следующем старте, если ещё нет const report76TablesCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'report_76_rows'` ); if (report76TablesCheck.rows.length === 0) { const report76TablesPath = path.join(__dirname, 'migrations', 'create_report_76_tables.sql'); if (fs.existsSync(report76TablesPath)) { try { const report76TablesSQL = fs.readFileSync(report76TablesPath, 'utf8'); await client.query(report76TablesSQL); console.log('[dbInit] Миграция create_report_76_tables.sql применена успешно'); } catch (err) { console.warn('[dbInit] Ошибка при применении create_report_76_tables.sql:', err.message); } } } // Применяем миграцию обновления UNIQUE constraint для building_financial_data // Проверяем текущий constraint const constraintCheck = await client.query( `SELECT constraint_name FROM information_schema.table_constraints WHERE table_schema = 'public' AND table_name = 'building_financial_data' AND constraint_type = 'UNIQUE' AND constraint_name LIKE '%building_id%period%'` ); const uniqueConstraintPath = path.join(__dirname, 'migrations', 'update_building_financial_data_unique.sql'); if (fs.existsSync(uniqueConstraintPath)) { try { const uniqueConstraintSQL = fs.readFileSync(uniqueConstraintPath, 'utf8'); await client.query(uniqueConstraintSQL); console.log('[dbInit] Миграция update_building_financial_data_unique.sql применена успешно'); } catch (err) { // Игнорируем ошибки, если constraint уже изменен или не существует if (!err.message.includes('does not exist') && !err.message.includes('already exists')) { console.warn('[dbInit] Ошибка при применении миграции update_building_financial_data_unique.sql:', err.message); } } } // Применяем миграцию счетов на оплату const paymentInvoicesCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'payment_invoices'` ); if (paymentInvoicesCheck.rows.length === 0) { console.log('[dbInit] Создаем таблицу payment_invoices...'); const paymentInvoicesPath = path.join(__dirname, 'migrations', 'create_payment_invoices.sql'); if (fs.existsSync(paymentInvoicesPath)) { const paymentInvoicesSQL = fs.readFileSync(paymentInvoicesPath, 'utf8'); await client.query(paymentInvoicesSQL); console.log('[dbInit] Миграция create_payment_invoices.sql применена успешно'); } else { console.warn('[dbInit] Файл миграции create_payment_invoices.sql не найден'); } } // Типовые документы HR (для печати/шаблонов в разделе Кадры) const hrTemplatesCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'hr_template_documents'` ); if (hrTemplatesCheck.rows.length === 0) { console.log('[dbInit] Создаем таблицу hr_template_documents...'); await client.query(` CREATE TABLE IF NOT EXISTS hr_template_documents ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, file_path TEXT NOT NULL, original_filename TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_hr_template_documents_created ON hr_template_documents(created_at DESC)`); console.log('[dbInit] Таблица hr_template_documents создана'); } // Платежный календарь: справочник статей доходов/расходов const paymentCategoriesCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'payment_categories'` ); if (paymentCategoriesCheck.rows.length === 0) { const paymentCategoriesPath = path.join(__dirname, 'migrations', 'create_payment_categories.sql'); if (fs.existsSync(paymentCategoriesPath)) { const sql = fs.readFileSync(paymentCategoriesPath, 'utf8'); await client.query(sql); console.log('[dbInit] Миграция create_payment_categories.sql применена успешно'); } } // Платежный календарь: записи (расходы/поступления) const paymentCalendarEntriesCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'payment_calendar_entries'` ); if (paymentCalendarEntriesCheck.rows.length === 0) { const paymentCalendarPath = path.join(__dirname, 'migrations', 'create_payment_calendar_entries.sql'); if (fs.existsSync(paymentCalendarPath)) { const sql = fs.readFileSync(paymentCalendarPath, 'utf8'); await client.query(sql); console.log('[dbInit] Миграция create_payment_calendar_entries.sql применена успешно'); } } // Строки отчёта по задолженности (лицевые счета, долги) const debtorReportDataCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'debtor_report_data'` ); if (debtorReportDataCheck.rows.length === 0) { const debtorReportDataPath = path.join(__dirname, 'migrations', 'create_debtor_report_data.sql'); if (fs.existsSync(debtorReportDataPath)) { const sql = fs.readFileSync(debtorReportDataPath, 'utf8'); await client.query(sql); console.log('[dbInit] Миграция create_debtor_report_data.sql применена успешно'); } else { console.warn('[dbInit] Файл миграции create_debtor_report_data.sql не найден'); } } // Банки и кошельки (наличка) const financeAccountsCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'finance_accounts'` ); if (financeAccountsCheck.rows.length === 0) { const accountsPath = path.join(__dirname, 'migrations', 'create_finance_accounts.sql'); if (fs.existsSync(accountsPath)) { const sql = fs.readFileSync(accountsPath, 'utf8'); await client.query(sql); console.log('[dbInit] Миграция create_finance_accounts.sql применена успешно'); } } // Лицевые счета домов (отдельная таблица — защита от перезаписи при PUT /buildings/:id) const buildingPersonalAccountsCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'building_personal_accounts'` ); if (buildingPersonalAccountsCheck.rows.length === 0) { const bpaPath = path.join(__dirname, 'migrations', 'create_building_personal_accounts.sql'); if (fs.existsSync(bpaPath)) { const sql = fs.readFileSync(bpaPath, 'utf8'); await client.query(sql); console.log('[dbInit] Миграция create_building_personal_accounts.sql применена успешно'); } } // Справочник должностей (панель управления) const positionsCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'positions'` ); if (positionsCheck.rows.length === 0) { const positionsPath = path.join(__dirname, 'migrations', 'create_positions.sql'); if (fs.existsSync(positionsPath)) { const sql = fs.readFileSync(positionsPath, 'utf8'); await client.query(sql); console.log('[dbInit] Миграция create_positions.sql применена успешно'); } } // Таблица назначений сотрудников на участки (многие ко многим) const employeeDistrictsCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'employee_districts'` ); if (employeeDistrictsCheck.rows.length === 0) { const employeeDistrictsPath = path.join(__dirname, 'migrations', 'create_employee_districts.sql'); if (fs.existsSync(employeeDistrictsPath)) { const sql = fs.readFileSync(employeeDistrictsPath, 'utf8'); await client.query(sql); console.log('[dbInit] Миграция create_employee_districts.sql применена успешно'); } } // Применяем миграцию ролей пользователей const userRolesCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'user_roles'` ); if (userRolesCheck.rows.length === 0) { console.log('[dbInit] Создаем таблицу user_roles...'); const userRolesPath = path.join(__dirname, 'migrations', 'create_user_roles.sql'); if (fs.existsSync(userRolesPath)) { const userRolesSQL = fs.readFileSync(userRolesPath, 'utf8'); await client.query(userRolesSQL); console.log('[dbInit] Миграция create_user_roles.sql применена успешно'); } else { console.warn('[dbInit] Файл миграции create_user_roles.sql не найден'); } } // Применяем миграцию добавления item_type в payment_invoices (если таблица уже существует) const paymentInvoicesItemTypeCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'payment_invoices'` ); if (paymentInvoicesItemTypeCheck.rows.length > 0) { const itemTypeMigrationPath = path.join(__dirname, 'migrations', 'add_item_type_to_payment_invoices.sql'); if (fs.existsSync(itemTypeMigrationPath)) { try { const itemTypeMigrationSQL = fs.readFileSync(itemTypeMigrationPath, 'utf8'); await client.query(itemTypeMigrationSQL); console.log('[dbInit] Миграция add_item_type_to_payment_invoices.sql применена успешно'); } catch (err) { // Игнорируем ошибки, если колонка уже существует if (!err.message.includes('already exists') && !err.message.includes('duplicate')) { console.warn('[dbInit] Ошибка при применении миграции add_item_type_to_payment_invoices.sql:', err.message); } } } } // Применяем миграцию добавления файлов закрывающих документов в payment_invoices if (paymentInvoicesItemTypeCheck.rows.length > 0) { const closingDocsMigrationPath = path.join(__dirname, 'migrations', 'add_closing_docs_files_to_payment_invoices.sql'); if (fs.existsSync(closingDocsMigrationPath)) { try { const closingDocsSQL = fs.readFileSync(closingDocsMigrationPath, 'utf8'); await client.query(closingDocsSQL); console.log('[dbInit] Миграция add_closing_docs_files_to_payment_invoices.sql применена успешно'); } catch (err) { if (!err.message.includes('already exists') && !err.message.includes('duplicate')) { console.warn('[dbInit] Ошибка при применении миграции add_closing_docs_files_to_payment_invoices.sql:', err.message); } } } } // Применяем миграцию добавления purpose_type 'event' и purpose_event_id в payment_invoices const prEventsExistsCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'pr_events'` ); if (paymentInvoicesItemTypeCheck.rows.length > 0 && prEventsExistsCheck.rows.length > 0) { const addEventToInvoicesPath = path.join(__dirname, 'migrations', 'add_event_to_payment_invoices.sql'); if (fs.existsSync(addEventToInvoicesPath)) { try { const addEventSQL = fs.readFileSync(addEventToInvoicesPath, 'utf8'); await client.query(addEventSQL); console.log('[dbInit] Миграция add_event_to_payment_invoices.sql применена успешно'); } catch (err) { if (!err.message.includes('already exists') && !err.message.includes('duplicate')) { console.warn('[dbInit] Ошибка при применении миграции add_event_to_payment_invoices.sql:', err.message); } } } } // Применяем миграцию plan_item_id / plan_item_building_id в payment_invoices (план работ ↔ счета) if (paymentInvoicesItemTypeCheck.rows.length > 0) { const planItemMigrationPath = path.join(__dirname, 'migrations', 'add_plan_item_to_payment_invoices.sql'); if (fs.existsSync(planItemMigrationPath)) { try { const planItemMigrationSQL = fs.readFileSync(planItemMigrationPath, 'utf8'); await client.query(planItemMigrationSQL); console.log('[dbInit] Миграция add_plan_item_to_payment_invoices.sql применена успешно'); } catch (err) { if (!err.message.includes('already exists') && !err.message.includes('duplicate')) { console.warn('[dbInit] Ошибка при применении миграции add_plan_item_to_payment_invoices.sql:', err.message); } } } } // payment_ref, is_cash, postponed_date, cancel_reason в payment_invoices if (paymentInvoicesItemTypeCheck.rows.length > 0) { const paymentRefPath = path.join(__dirname, 'migrations', 'add_payment_ref_and_is_cash_to_payment_invoices.sql'); if (fs.existsSync(paymentRefPath)) { try { const sql = fs.readFileSync(paymentRefPath, 'utf8'); await client.query(sql); console.log('[dbInit] Миграция add_payment_ref_and_is_cash_to_payment_invoices.sql применена успешно'); } catch (err) { if (!err.message.includes('already exists') && !err.message.includes('duplicate')) { console.warn('[dbInit] Ошибка add_payment_ref_and_is_cash_to_payment_invoices.sql:', err.message); } } } } // Увеличиваем длину поля status в payment_invoices, если она слишком маленькая // (для значения 'pending_finance_manager_approval' нужно больше 30 символов) try { const statusLengthCheck = await client.query(` SELECT character_maximum_length FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'payment_invoices' AND column_name = 'status' `); if (statusLengthCheck.rows.length > 0) { const maxLen = statusLengthCheck.rows[0].character_maximum_length; if (maxLen !== null && maxLen < 40) { console.log('[dbInit] Увеличиваем длину payment_invoices.status до VARCHAR(50)...'); await client.query(` ALTER TABLE payment_invoices ALTER COLUMN status TYPE VARCHAR(50) `); console.log('[dbInit] Колонка payment_invoices.status успешно изменена на VARCHAR(50)'); } } } catch (err) { console.warn('[dbInit] Не удалось обновить длину payment_invoices.status:', err.message); } } catch (error) { console.warn('[dbInit] Ошибка при применении миграций колонок:', error.message); // Не прерываем выполнение, т.к. это не критично } } /** * Применение миграции для таблицы candidate_events */ async function applyCandidateEventsMigration(client) { try { // Проверяем существование таблицы candidate_events const tableCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'candidate_events'` ); if (tableCheck.rows.length === 0) { console.log('[dbInit] Таблица candidate_events не найдена, создаем...'); // Создаем типы ENUM если их нет await client.query(` DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'candidate_event_type') THEN CREATE TYPE candidate_event_type AS ENUM ( 'call', 'interview_1', 'interview_2', 'interview_3', 'test_task', 'offer', 'offer_accepted', 'offer_rejected', 'probation_start', 'hired', 'rejected', 'other' ); END IF; END$$; `); await client.query(` DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'candidate_event_result') THEN CREATE TYPE candidate_event_result AS ENUM ('success', 'failed', 'pending', 'cancelled'); END IF; END$$; `); // Создаем таблицу await client.query(` CREATE TABLE candidate_events ( id VARCHAR(50) PRIMARY KEY, candidate_id VARCHAR(50) NOT NULL REFERENCES candidates(id) ON DELETE CASCADE, event_type candidate_event_type NOT NULL, event_date TIMESTAMPTZ NOT NULL, notes TEXT, result candidate_event_result DEFAULT 'pending', interviewer TEXT, location TEXT, duration_minutes INTEGER, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); // Создаем индексы await client.query(` CREATE INDEX IF NOT EXISTS idx_candidate_events_candidate ON candidate_events(candidate_id) `); await client.query(` CREATE INDEX IF NOT EXISTS idx_candidate_events_type ON candidate_events(event_type) `); await client.query(` CREATE INDEX IF NOT EXISTS idx_candidate_events_date ON candidate_events(event_date DESC) `); console.log('[dbInit] Таблица candidate_events успешно создана'); } else { console.log('[dbInit] Таблица candidate_events уже существует'); } } catch (error) { console.warn('[dbInit] Ошибка при применении миграции candidate_events:', error.message); // Не прерываем выполнение, т.к. это не критично } } /** * Применение миграции для модуля обучения (инструктажи и курсы) */ async function applyTrainingModuleMigration(client) { try { const tableCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'training_programs'` ); if (tableCheck.rows.length === 0) { console.log('[dbInit] Таблицы модуля обучения не найдены, создаем...'); // Читаем SQL миграцию const migrationPath = path.join(__dirname, 'migrate_training_module.sql'); if (fs.existsSync(migrationPath)) { const migrationSQL = fs.readFileSync(migrationPath, 'utf8'); await client.query(migrationSQL); console.log('[dbInit] Таблицы модуля обучения успешно созданы'); } else { console.warn('[dbInit] Файл migrate_training_module.sql не найден, создаем таблицы вручную...'); // Создаем типы await client.query(` DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'training_type') THEN CREATE TYPE training_type AS ENUM ( 'instruction', 'course', 'certification', 'exam', 'other' ); END IF; END$$; `); await client.query(` DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'training_category') THEN CREATE TYPE training_category AS ENUM ( 'safety', 'fire_safety', 'electrical', 'first_aid', 'professional', 'compliance', 'other' ); END IF; END$$; `); await client.query(` DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'training_status') THEN CREATE TYPE training_status AS ENUM ( 'not_started', 'in_progress', 'completed', 'failed', 'expired', 'cancelled' ); END IF; END$$; `); // Создаем таблицы await client.query(` CREATE TABLE IF NOT EXISTS training_programs ( id VARCHAR(50) PRIMARY KEY, title TEXT NOT NULL, description TEXT, type training_type NOT NULL, category training_category NOT NULL, duration_hours NUMERIC(5, 2), validity_months INTEGER, is_required BOOLEAN DEFAULT false, required_for_positions TEXT[], instructor_name TEXT, materials_url TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(` CREATE TABLE IF NOT EXISTS employee_training ( id BIGSERIAL PRIMARY KEY, employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE, program_id VARCHAR(50) NOT NULL REFERENCES training_programs(id) ON DELETE CASCADE, status training_status NOT NULL DEFAULT 'not_started', start_date DATE, completion_date DATE, expiry_date DATE, score NUMERIC(5, 2), passed BOOLEAN, certificate_number TEXT, certificate_url TEXT, notes TEXT, instructor_name TEXT, location TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(employee_id, program_id) ) `); await client.query(` CREATE TABLE IF NOT EXISTS employee_training_history ( id BIGSERIAL PRIMARY KEY, employee_training_id BIGINT NOT NULL REFERENCES employee_training(id) ON DELETE CASCADE, status training_status NOT NULL, completion_date DATE, score NUMERIC(5, 2), passed BOOLEAN, certificate_number TEXT, notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); // Создаем индексы await client.query(`CREATE INDEX IF NOT EXISTS idx_training_programs_type ON training_programs(type)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_training_programs_category ON training_programs(category)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_employee_training_employee ON employee_training(employee_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_employee_training_program ON employee_training(program_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_employee_training_status ON employee_training(status)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_employee_training_expiry ON employee_training(expiry_date)`); console.log('[dbInit] Таблицы модуля обучения успешно созданы'); } } else { console.log('[dbInit] Таблицы модуля обучения уже существуют'); } } catch (error) { console.warn('[dbInit] Ошибка при применении миграции модуля обучения:', error.message); // Не прерываем выполнение, т.к. это не критично } } /** * Применение миграции для таблиц офиса (заявки на ТМЦ, склад, документооборот) */ async function applyOfficeTablesMigration(client) { try { // Проверяем и создаем таблицу office_supply_requests const supplyRequestsCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'office_supply_requests'` ); if (supplyRequestsCheck.rows.length === 0) { console.log('[dbInit] Таблица office_supply_requests не найдена, создаем...'); await client.query(` CREATE TABLE IF NOT EXISTS office_supply_requests ( id BIGSERIAL PRIMARY KEY, requester_name TEXT NOT NULL, category VARCHAR(50) NOT NULL, item_name TEXT NOT NULL, quantity INTEGER DEFAULT 1, issued_quantity INTEGER DEFAULT 0, unit VARCHAR(20) DEFAULT 'шт.', amount NUMERIC(10, 2) DEFAULT 0, priority VARCHAR(10) NOT NULL DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high', 'urgent')), status VARCHAR(20) NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'approved', 'ordered', 'received', 'canceled', 'archived', 'collected')), approved_by TEXT, approved_at TIMESTAMPTZ, ordered_at TIMESTAMPTZ, received_at TIMESTAMPTZ, notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_supply_requests_status ON office_supply_requests(status)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_supply_requests_requester ON office_supply_requests(requester_name)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_supply_requests_created ON office_supply_requests(created_at DESC)`); console.log('[dbInit] Таблица office_supply_requests успешно создана'); } else { // Проверяем наличие поля issued_quantity const columnCheck = await client.query(` SELECT column_name FROM information_schema.columns WHERE table_name = 'office_supply_requests' AND column_name = 'issued_quantity' `); if (columnCheck.rows.length === 0) { console.log('[dbInit] Добавляем поле issued_quantity в office_supply_requests...'); await client.query(` ALTER TABLE office_supply_requests ADD COLUMN IF NOT EXISTS issued_quantity INTEGER DEFAULT 0 `); console.log('[dbInit] Поле issued_quantity успешно добавлено'); } } // Проверяем и создаем таблицу office_inventory const inventoryCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'office_inventory'` ); if (inventoryCheck.rows.length === 0) { console.log('[dbInit] Таблица office_inventory не найдена, создаем...'); await client.query(` CREATE TABLE IF NOT EXISTS office_inventory ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, category VARCHAR(50), quantity NUMERIC(10, 2) NOT NULL DEFAULT 0, unit VARCHAR(20) NOT NULL DEFAULT 'шт.', min_threshold NUMERIC(10, 2) NOT NULL DEFAULT 0, last_restock DATE, last_restock_by TEXT, location TEXT, notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_office_inventory_category ON office_inventory(category)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_office_inventory_name ON office_inventory(name)`); console.log('[dbInit] Таблица office_inventory успешно создана'); } // Проверяем и создаем таблицу office_documents const documentsCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'office_documents'` ); if (documentsCheck.rows.length === 0) { console.log('[dbInit] Таблица office_documents не найдена, создаем...'); await client.query(` CREATE TABLE IF NOT EXISTS office_documents ( id BIGSERIAL PRIMARY KEY, reg_number TEXT NOT NULL UNIQUE, title TEXT NOT NULL, correspondent TEXT NOT NULL, document_type VARCHAR(20) NOT NULL CHECK (document_type IN ('incoming', 'outgoing')), letter_type VARCHAR(10) DEFAULT 'paper' CHECK (letter_type IN ('email', 'paper')), status VARCHAR(20) NOT NULL DEFAULT 'registered' CHECK (status IN ('registered', 'processed', 'sent', 'archived')), date DATE NOT NULL, assigned_to TEXT, tracking_number TEXT, file_url TEXT, notes TEXT, created_by TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_office_documents_reg_number ON office_documents(reg_number)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_office_documents_status ON office_documents(status)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_office_documents_type ON office_documents(document_type)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_office_documents_date ON office_documents(date DESC)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_office_documents_created_by ON office_documents(created_by)`); console.log('[dbInit] Таблица office_documents успешно создана'); } else { // Таблица существует, проверяем наличие поля letter_type try { const columnCheck = await client.query(` SELECT column_name FROM information_schema.columns WHERE table_name = 'office_documents' AND column_name = 'letter_type' `); if (columnCheck.rows.length === 0) { console.log('[dbInit] Добавляем поле letter_type в office_documents...'); await client.query(` ALTER TABLE office_documents ADD COLUMN letter_type VARCHAR(10) DEFAULT 'paper' CHECK (letter_type IN ('email', 'paper')) `); console.log('[dbInit] Поле letter_type успешно добавлено'); } } catch (err) { console.warn('[dbInit] Предупреждение при добавлении letter_type:', err.message); } } // Проверяем наличие поля next_maintenance_date в office_equipment const equipmentColumnCheck = await client.query( `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'office_equipment' AND column_name = 'next_maintenance_date'` ); if (equipmentColumnCheck.rows.length === 0) { console.log('[dbInit] Добавляем отсутствующую колонку next_maintenance_date в таблицу office_equipment...'); await client.query(` ALTER TABLE office_equipment ADD COLUMN IF NOT EXISTS next_maintenance_date DATE `); console.log('[dbInit] Колонка next_maintenance_date успешно добавлена'); } // Проверяем наличие поля conclusions в meetings const meetingsColumnCheck = await client.query( `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'meetings' AND column_name = 'conclusions'` ); if (meetingsColumnCheck.rows.length === 0) { console.log('[dbInit] Добавляем отсутствующую колонку conclusions в таблицу meetings...'); await client.query(` ALTER TABLE meetings ADD COLUMN IF NOT EXISTS conclusions TEXT `); console.log('[dbInit] Колонка conclusions успешно добавлена'); } // Проверяем и создаем таблицы для заказов const ordersCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'office_orders'` ); if (ordersCheck.rows.length === 0) { console.log('[dbInit] Таблицы заказов не найдены, создаем...'); await client.query(` CREATE TABLE IF NOT EXISTS office_orders ( id BIGSERIAL PRIMARY KEY, order_number TEXT UNIQUE NOT NULL, title TEXT NOT NULL, description TEXT, status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'waiting_quote', 'quotes_received', 'approved', 'ordered', 'received', 'canceled')), total_amount NUMERIC(10, 2) DEFAULT 0, supplier_name TEXT, supplier_contact TEXT, invoice_id BIGINT, invoice_url TEXT, expected_date DATE, received_date DATE, notes TEXT, created_by TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(` CREATE TABLE IF NOT EXISTS office_order_items ( id BIGSERIAL PRIMARY KEY, order_id BIGINT NOT NULL REFERENCES office_orders(id) ON DELETE CASCADE, request_id BIGINT NOT NULL REFERENCES office_supply_requests(id) ON DELETE CASCADE, quantity INTEGER DEFAULT 1, unit_price NUMERIC(10, 2) DEFAULT 0, total_price NUMERIC(10, 2) DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(order_id, request_id) ) `); await client.query(` CREATE TABLE IF NOT EXISTS office_order_quotes ( id BIGSERIAL PRIMARY KEY, order_id BIGINT NOT NULL REFERENCES office_orders(id) ON DELETE CASCADE, supplier_name TEXT NOT NULL, supplier_contact TEXT, total_amount NUMERIC(10, 2) NOT NULL, quote_file_url TEXT, notes TEXT, is_selected BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_orders_status ON office_orders(status)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_orders_created ON office_orders(created_at DESC)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_order_items_order ON office_order_items(order_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_order_items_request ON office_order_items(request_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_order_quotes_order ON office_order_quotes(order_id)`); console.log('[dbInit] Таблицы заказов успешно созданы'); } // Проверяем и добавляем поля для платного ремонта в office_repair_requests const repairRequestsCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'office_repair_requests'` ); if (repairRequestsCheck.rows.length > 0) { // Таблица существует, проверяем наличие полей для платного ремонта const costColumnCheck = await client.query(` SELECT column_name FROM information_schema.columns WHERE table_name = 'office_repair_requests' AND column_name = 'cost' `); if (costColumnCheck.rows.length === 0) { console.log('[dbInit] Добавляем поля для платного ремонта в office_repair_requests...'); await client.query(` ALTER TABLE office_repair_requests ADD COLUMN IF NOT EXISTS is_paid BOOLEAN DEFAULT FALSE, ADD COLUMN IF NOT EXISTS cost NUMERIC(10, 2) DEFAULT 0, ADD COLUMN IF NOT EXISTS cost_estimated BOOLEAN DEFAULT FALSE, ADD COLUMN IF NOT EXISTS invoice_id BIGINT, ADD COLUMN IF NOT EXISTS invoice_url TEXT `); console.log('[dbInit] Поля для платного ремонта успешно добавлены'); } // Поля для статусов: ожидание поставки, увезли на ремонт, договорились с подрядчиком const statusFieldsCheck = await client.query(` SELECT column_name FROM information_schema.columns WHERE table_name = 'office_repair_requests' AND column_name = 'taken_for_repair_deadline' `); if (statusFieldsCheck.rows.length === 0) { console.log('[dbInit] Добавляем поля статусов ремонта в office_repair_requests...'); await client.query(`ALTER TABLE office_repair_requests ADD COLUMN IF NOT EXISTS waiting_delivery_deadline TEXT`); await client.query(`ALTER TABLE office_repair_requests ADD COLUMN IF NOT EXISTS waiting_delivery_contacts TEXT`); await client.query(`ALTER TABLE office_repair_requests ADD COLUMN IF NOT EXISTS taken_for_repair_deadline TEXT`); await client.query(`ALTER TABLE office_repair_requests ADD COLUMN IF NOT EXISTS taken_for_repair_contacts TEXT`); await client.query(`ALTER TABLE office_repair_requests ADD COLUMN IF NOT EXISTS agreed_contractor_price NUMERIC(10, 2)`); console.log('[dbInit] Поля статусов ремонта успешно добавлены'); } // Добавляем недостающие значения в enum repair_request_status (если БД создана по старой схеме) const repairStatusEnumValues = ['agreed_with_contractor', 'search_contractor', 'waiting_delivery', 'taken_for_repair', 'self_repair']; for (const enumVal of repairStatusEnumValues) { try { await client.query(`ALTER TYPE repair_request_status ADD VALUE IF NOT EXISTS '${enumVal}'`); } catch (err) { if (!err.message || !err.message.includes('already exists')) { console.warn(`[dbInit] Предупреждение при добавлении значения ${enumVal} в repair_request_status:`, err.message); } } } // Таблица истории оборудования: создаём, если не существует const historyTableCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'office_equipment_history'` ); if (historyTableCheck.rows.length === 0) { console.log('[dbInit] Создаём таблицу office_equipment_history...'); const typeCheck = await client.query( `SELECT 1 FROM pg_type WHERE typname = 'office_equipment_history_type'` ); if (typeCheck.rows.length === 0) { await client.query(` CREATE TYPE office_equipment_history_type AS ENUM ('purchase', 'issue', 'transfer', 'repair', 'write_off') `); } await client.query(` CREATE TABLE IF NOT EXISTS office_equipment_history ( id BIGSERIAL PRIMARY KEY, equipment_id BIGINT NOT NULL REFERENCES office_equipment(id) ON DELETE CASCADE, event_type office_equipment_history_type NOT NULL, event_date DATE NOT NULL, assigned_to TEXT, assigned_from TEXT, reason TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_equipment_history_equipment ON office_equipment_history(equipment_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_equipment_history_date ON office_equipment_history(event_date DESC)`); console.log('[dbInit] Таблица office_equipment_history создана'); } else { const historyCols = await client.query( `SELECT column_name FROM information_schema.columns WHERE table_name = 'office_equipment_history' AND column_name IN ('assigned_from', 'assigned_to')` ); const hasFrom = historyCols.rows.some(r => r.column_name === 'assigned_from'); const hasTo = historyCols.rows.some(r => r.column_name === 'assigned_to'); if (!hasFrom) { await client.query(`ALTER TABLE office_equipment_history ADD COLUMN IF NOT EXISTS assigned_from TEXT`); console.log('[dbInit] Добавлена колонка assigned_from в office_equipment_history'); } if (!hasTo) { await client.query(`ALTER TABLE office_equipment_history ADD COLUMN IF NOT EXISTS assigned_to TEXT`); console.log('[dbInit] Добавлена колонка assigned_to в office_equipment_history'); } } } // Создать office_equipment_history при наличии office_equipment (если ещё не создали выше) const officeEquipmentCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'office_equipment'` ); if (officeEquipmentCheck.rows.length > 0) { const historyExists = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'office_equipment_history'` ); if (historyExists.rows.length === 0) { console.log('[dbInit] Создаём таблицу office_equipment_history (при наличии office_equipment)...'); try { const typeCheck = await client.query(`SELECT 1 FROM pg_type WHERE typname = 'office_equipment_history_type'`); if (typeCheck.rows.length === 0) { await client.query(`CREATE TYPE office_equipment_history_type AS ENUM ('purchase', 'issue', 'transfer', 'repair', 'write_off')`); } await client.query(` CREATE TABLE IF NOT EXISTS office_equipment_history ( id BIGSERIAL PRIMARY KEY, equipment_id BIGINT NOT NULL REFERENCES office_equipment(id) ON DELETE CASCADE, event_type office_equipment_history_type NOT NULL, event_date DATE NOT NULL, assigned_to TEXT, assigned_from TEXT, reason TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_equipment_history_equipment ON office_equipment_history(equipment_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_equipment_history_date ON office_equipment_history(event_date DESC)`); console.log('[dbInit] Таблица office_equipment_history создана'); } catch (histErr) { console.warn('[dbInit] Ошибка создания office_equipment_history:', histErr.message); } } } } catch (error) { console.warn('[dbInit] Ошибка при применении миграции таблиц офиса:', error.message); // Не прерываем выполнение, т.к. это не критично } } /** * Применение миграции PR и NPS модуля */ async function applyPRNPSMigration(client) { try { const migrationPath = path.join(__dirname, 'migrate_pr_nps.sql'); if (fs.existsSync(migrationPath)) { console.log('[dbInit] Применяем миграцию PR/NPS из migrate_pr_nps.sql...'); const migrationSQL = fs.readFileSync(migrationPath, 'utf8'); await client.query(migrationSQL); console.log('[dbInit] Миграция PR/NPS успешно применена'); } else { console.warn('[dbInit] Файл migrate_pr_nps.sql не найден, создаем таблицы вручную...'); // Создаем таблицы PR/NPS вручную await client.query(` CREATE TABLE IF NOT EXISTS reviews ( id BIGSERIAL PRIMARY KEY, building_id VARCHAR(50) REFERENCES buildings(id) ON DELETE CASCADE, source VARCHAR(20) NOT NULL CHECK (source IN ('yandex_maps', '2gis', 'internal', 'other')), source_url TEXT, author_name TEXT, text TEXT NOT NULL, rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 10), date DATE NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'processed', 'archived')), processed_at TIMESTAMPTZ, processed_by TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_reviews_building ON reviews(building_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_reviews_source ON reviews(source)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_reviews_status ON reviews(status)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_reviews_date ON reviews(date DESC)`); await client.query(` CREATE TABLE IF NOT EXISTS incidents ( id BIGSERIAL PRIMARY KEY, review_id BIGINT REFERENCES reviews(id) ON DELETE SET NULL, building_id VARCHAR(50) REFERENCES buildings(id) ON DELETE CASCADE, type VARCHAR(50) NOT NULL CHECK (type IN ('property_damage', 'debtor_complaint', 'service_quality', 'other')), title TEXT NOT NULL, description TEXT NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'in_progress', 'resolved', 'closed')), priority VARCHAR(10) NOT NULL DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high', 'urgent')), assigned_to TEXT, created_by TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), resolved_at TIMESTAMPTZ, resolution_notes TEXT, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_incidents_building ON incidents(building_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_incidents_status ON incidents(status)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_incidents_type ON incidents(type)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_incidents_assigned ON incidents(assigned_to)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_incidents_review ON incidents(review_id)`); await client.query(` CREATE TABLE IF NOT EXISTS resident_reports ( id BIGSERIAL PRIMARY KEY, building_id VARCHAR(50) NOT NULL REFERENCES buildings(id) ON DELETE CASCADE, month VARCHAR(20) NOT NULL, period_start DATE NOT NULL, period_end DATE NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published')), published_at TIMESTAMPTZ, content JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_resident_reports_building ON resident_reports(building_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_resident_reports_status ON resident_reports(status)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_resident_reports_period ON resident_reports(period_start)`); await client.query(` CREATE TABLE IF NOT EXISTS work_photos ( id BIGSERIAL PRIMARY KEY, building_id VARCHAR(50) NOT NULL REFERENCES buildings(id) ON DELETE CASCADE, resident_report_id BIGINT REFERENCES resident_reports(id) ON DELETE SET NULL, task_id VARCHAR(50), work_name TEXT NOT NULL, work_date DATE NOT NULL, description TEXT, photo_before_url TEXT, photo_after_url TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_work_photos_building ON work_photos(building_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_work_photos_report ON work_photos(resident_report_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_work_photos_date ON work_photos(work_date DESC)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_work_photos_task ON work_photos(task_id)`); await client.query(` CREATE TABLE IF NOT EXISTS parsing_settings ( id BIGSERIAL PRIMARY KEY, source VARCHAR(20) NOT NULL UNIQUE CHECK (source IN ('yandex_maps', '2gis')), enabled BOOLEAN NOT NULL DEFAULT TRUE, url_template TEXT, api_key TEXT, parsing_interval_hours INTEGER DEFAULT 24, last_parsed_at TIMESTAMPTZ, settings JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_parsing_settings_source ON parsing_settings(source)`); // Инициализируем настройки парсинга, если их нет const existingSettings = await client.query('SELECT COUNT(*) as count FROM parsing_settings'); if (parseInt(existingSettings.rows[0].count) === 0) { await client.query(` INSERT INTO parsing_settings (source, enabled, url_template, parsing_interval_hours) VALUES ('yandex_maps', FALSE, '', 24), ('2gis', FALSE, '', 24) `); console.log('[dbInit] Настройки парсинга инициализированы'); } console.log('[dbInit] Таблицы PR/NPS успешно созданы'); } } catch (error) { console.warn('[dbInit] Ошибка при применении миграции PR/NPS:', error.message); // Не прерываем выполнение, т.к. это не критично } } /** * Применение миграции для таблиц NPS опросов */ async function applyNPSSurveysMigration(client) { try { const migrationPath = path.join(__dirname, 'migrate_nps_surveys.sql'); if (fs.existsSync(migrationPath)) { console.log('[dbInit] Применяем миграцию NPS опросов из migrate_nps_surveys.sql...'); const migrationSQL = fs.readFileSync(migrationPath, 'utf8'); await client.query(migrationSQL); console.log('[dbInit] Миграция NPS опросов успешно применена'); } else { console.warn('[dbInit] Файл migrate_nps_surveys.sql не найден, создаем таблицы вручную...'); // Создаем таблицы NPS опросов вручную await client.query(` CREATE TABLE IF NOT EXISTS nps_surveys ( id BIGSERIAL PRIMARY KEY, building_id VARCHAR(50) NOT NULL REFERENCES buildings(id) ON DELETE CASCADE, title TEXT NOT NULL DEFAULT 'Опрос удовлетворенности', description TEXT, status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'closed')), access_key TEXT NOT NULL UNIQUE, published_at TIMESTAMPTZ, expires_at TIMESTAMPTZ, created_by TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_nps_surveys_building ON nps_surveys(building_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_nps_surveys_status ON nps_surveys(status)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_nps_surveys_access_key ON nps_surveys(access_key)`); await client.query(` CREATE TABLE IF NOT EXISTS nps_responses ( id BIGSERIAL PRIMARY KEY, survey_id BIGINT NOT NULL REFERENCES nps_surveys(id) ON DELETE CASCADE, building_id VARCHAR(50) NOT NULL REFERENCES buildings(id) ON DELETE CASCADE, score INTEGER NOT NULL CHECK (score >= 0 AND score <= 10), comment TEXT, respondent_name TEXT, apartment TEXT, phone TEXT, email TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_nps_responses_survey ON nps_responses(survey_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_nps_responses_building ON nps_responses(building_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_nps_responses_score ON nps_responses(score)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_nps_responses_created ON nps_responses(created_at DESC)`); console.log('[dbInit] Таблицы NPS опросов успешно созданы'); } } catch (error) { console.warn('[dbInit] Ошибка при применении миграции NPS опросов:', error.message); // Не прерываем выполнение, т.к. это не критично } } /** * Применение миграции для таблицы агрегированных показателей NPS по дому */ async function applyNPSBuildingStatsMigration(client) { try { const migrationPath = path.join(__dirname, 'migrate_nps_building_stats.sql'); if (fs.existsSync(migrationPath)) { console.log('[dbInit] Применяем миграцию nps_building_stats...'); const migrationSQL = fs.readFileSync(migrationPath, 'utf8'); await client.query(migrationSQL); console.log('[dbInit] Таблица nps_building_stats успешно создана'); } else { await client.query(` CREATE TABLE IF NOT EXISTS nps_building_stats ( id BIGSERIAL PRIMARY KEY, building_id VARCHAR(50) NOT NULL REFERENCES buildings(id) ON DELETE CASCADE, period_start DATE NOT NULL, period_end DATE NOT NULL, total_responses INTEGER NOT NULL DEFAULT 0, nps_score INTEGER NOT NULL DEFAULT 0, avg_score NUMERIC(4,2) NOT NULL DEFAULT 0, promoters INTEGER NOT NULL DEFAULT 0, passives INTEGER NOT NULL DEFAULT 0, detractors INTEGER NOT NULL DEFAULT 0, promoter_percent NUMERIC(5,2) DEFAULT 0, detractor_percent NUMERIC(5,2) DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(building_id, period_start, period_end) ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_nps_building_stats_building ON nps_building_stats(building_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_nps_building_stats_period ON nps_building_stats(period_start, period_end)`); console.log('[dbInit] Таблица nps_building_stats успешно создана'); } } catch (error) { console.warn('[dbInit] Ошибка при применении миграции nps_building_stats:', error.message); } } /** * Применение миграции для таблицы данных отчетов для жителей (дом + период) */ async function applyResidentReportDataMigration(client) { try { const migrationPath = path.join(__dirname, 'migrate_resident_report_data.sql'); if (fs.existsSync(migrationPath)) { console.log('[dbInit] Применяем миграцию resident_report_data...'); const migrationSQL = fs.readFileSync(migrationPath, 'utf8'); await client.query(migrationSQL); console.log('[dbInit] Таблица resident_report_data успешно создана'); } else { await client.query(` CREATE TABLE IF NOT EXISTS resident_report_data ( id BIGSERIAL PRIMARY KEY, building_id VARCHAR(50) NOT NULL REFERENCES buildings(id) ON DELETE CASCADE, period_start DATE NOT NULL, period_end DATE NOT NULL, period_month INTEGER NOT NULL, period_year INTEGER NOT NULL, nps_score INTEGER NOT NULL DEFAULT 0, nps_total_responses INTEGER NOT NULL DEFAULT 0, nps_avg_score NUMERIC(4,2) DEFAULT 0, nps_promoters INTEGER NOT NULL DEFAULT 0, nps_passives INTEGER NOT NULL DEFAULT 0, nps_detractors INTEGER NOT NULL DEFAULT 0, apps_total INTEGER NOT NULL DEFAULT 0, apps_completed INTEGER NOT NULL DEFAULT 0, apps_quality INTEGER NOT NULL DEFAULT 0, tasks_total INTEGER NOT NULL DEFAULT 0, tasks_completed INTEGER NOT NULL DEFAULT 0, funds_collected NUMERIC(14,2) NOT NULL DEFAULT 0, funds_spent NUMERIC(14,2) NOT NULL DEFAULT 0, funds_balance NUMERIC(14,2) NOT NULL DEFAULT 0, debt_cases_won INTEGER NOT NULL DEFAULT 0, debt_collected NUMERIC(14,2) NOT NULL DEFAULT 0, expenses_total NUMERIC(14,2) NOT NULL DEFAULT 0, expenses_by_category JSONB, events JSONB, work_photos JSONB, plan_items JSONB, snapshot JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(building_id, period_start, period_end) ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_resident_report_data_building ON resident_report_data(building_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_resident_report_data_period ON resident_report_data(period_year, period_month)`); console.log('[dbInit] Таблица resident_report_data успешно создана'); } } catch (error) { console.warn('[dbInit] Ошибка при применении миграции resident_report_data:', error.message); } } /** * Применение миграции для настроек компании */ async function applyCompanySettingsMigration(client) { try { const migrationPath = path.join(__dirname, 'migrate_company_settings.sql'); if (fs.existsSync(migrationPath)) { console.log('[dbInit] Применяем миграцию настроек компании из migrate_company_settings.sql...'); const migrationSQL = fs.readFileSync(migrationPath, 'utf8'); await client.query(migrationSQL); console.log('[dbInit] Миграция настроек компании успешно применена'); } else { console.warn('[dbInit] Файл migrate_company_settings.sql не найден, создаем таблицу вручную...'); await client.query(` CREATE TABLE IF NOT EXISTS company_settings ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL DEFAULT 'Управляющая компания', full_name TEXT, address TEXT, phone TEXT, email TEXT, website TEXT, license_number TEXT, license_valid_until DATE, logo_url TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); // Вставляем дефолтные данные await client.query(` INSERT INTO company_settings (id, name, full_name, address, phone, email, license_number) VALUES (1, 'Управляющая компания "Дружба"', 'ООО "Управляющая компания Дружба"', 'г. Уфа, ул. Ленина, 1', '+7 (347) 123-45-67', 'info@uk-druzhba.ru', 'Лицензия №12345') ON CONFLICT (id) DO NOTHING `); console.log('[dbInit] Таблица company_settings успешно создана'); } } catch (error) { console.warn('[dbInit] Ошибка при применении миграции настроек компании:', error.message); // Не прерываем выполнение, т.к. это не критично } } /** * Применение миграции для таблиц досудебной работы */ async function applyPreTrialWorkMigration(client) { try { // Проверяем существование таблицы legal_debtors const debtorsCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'legal_debtors'` ); if (debtorsCheck.rows.length === 0) { console.log('[dbInit] Таблицы досудебной работы не найдены, создаем...'); // Читаем SQL миграцию const migrationPath = path.join(__dirname, 'migrate_pre_trial_work.sql'); if (fs.existsSync(migrationPath)) { const migrationSQL = fs.readFileSync(migrationPath, 'utf8'); await client.query(migrationSQL); console.log('[dbInit] Таблицы досудебной работы успешно созданы'); } else { console.warn('[dbInit] Файл migrate_pre_trial_work.sql не найден, создаем таблицы вручную...'); // Создаем таблицы вручную, если файл не найден await client.query(` CREATE TABLE IF NOT EXISTS legal_debtors ( id BIGSERIAL PRIMARY KEY, building_id VARCHAR(50) REFERENCES buildings(id) ON DELETE SET NULL, apartment TEXT NOT NULL, debtor_name TEXT, phone TEXT, email TEXT, address TEXT NOT NULL, debt_amount NUMERIC(10, 2) NOT NULL, debt_months INTEGER NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'in_progress', 'promised_payment', 'transferred_to_court', 'resolved')), transferred_from_finance BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_legal_debtors_building ON legal_debtors(building_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_legal_debtors_status ON legal_debtors(status)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_legal_debtors_apartment ON legal_debtors(apartment)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_legal_debtors_created ON legal_debtors(created_at DESC)`); await client.query(` CREATE TABLE IF NOT EXISTS pre_trial_work ( id BIGSERIAL PRIMARY KEY, debtor_id BIGINT NOT NULL REFERENCES legal_debtors(id) ON DELETE CASCADE, assigned_to TEXT, status VARCHAR(20) NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'in_progress', 'promised_payment', 'transferred_to_court', 'resolved')), promised_payment_date DATE, promised_payment_amount NUMERIC(10, 2), transferred_to_court BOOLEAN DEFAULT FALSE, court_case_id VARCHAR(50), notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_pre_trial_work_debtor ON pre_trial_work(debtor_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_pre_trial_work_status ON pre_trial_work(status)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_pre_trial_work_assigned ON pre_trial_work(assigned_to)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_pre_trial_work_court_case ON pre_trial_work(court_case_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_pre_trial_work_promised_date ON pre_trial_work(promised_payment_date)`); await client.query(` CREATE TABLE IF NOT EXISTS pre_trial_actions ( id BIGSERIAL PRIMARY KEY, work_id BIGINT NOT NULL REFERENCES pre_trial_work(id) ON DELETE CASCADE, action_type VARCHAR(20) NOT NULL CHECK (action_type IN ('call', 'letter', 'visit')), action_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), performed_by TEXT NOT NULL, result TEXT, notes TEXT, attachments TEXT[], created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_pre_trial_actions_work ON pre_trial_actions(work_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_pre_trial_actions_type ON pre_trial_actions(action_type)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_pre_trial_actions_date ON pre_trial_actions(action_date DESC)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_pre_trial_actions_performed ON pre_trial_actions(performed_by)`); await client.query(` CREATE TABLE IF NOT EXISTS promised_payments ( id BIGSERIAL PRIMARY KEY, work_id BIGINT NOT NULL REFERENCES pre_trial_work(id) ON DELETE CASCADE, promised_date DATE NOT NULL, promised_amount NUMERIC(10, 2) NOT NULL, actual_payment_date DATE, actual_payment_amount NUMERIC(10, 2), is_paid BOOLEAN DEFAULT FALSE, reminder_sent BOOLEAN DEFAULT FALSE, reminder_date DATE, notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_promised_payments_work ON promised_payments(work_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_promised_payments_date ON promised_payments(promised_date)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_promised_payments_paid ON promised_payments(is_paid)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_promised_payments_reminder ON promised_payments(reminder_sent, reminder_date)`); console.log('[dbInit] Таблицы досудебной работы успешно созданы вручную'); } } else { console.log('[dbInit] Таблицы досудебной работы уже существуют'); } } catch (error) { console.warn('[dbInit] Ошибка при применении миграции досудебной работы:', error.message); // Не прерываем выполнение, т.к. это не критично } } /** * Применение миграции для таблиц проверок контрагентов */ async function applyCounterpartyChecksMigration(client) { try { // Проверяем существование таблицы counterparty_checks const checksCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'counterparty_checks'` ); if (checksCheck.rows.length === 0) { console.log('[dbInit] Таблицы проверок контрагентов не найдены, создаем...'); // Читаем SQL миграцию const migrationPath = path.join(__dirname, 'migrate_counterparty_checks.sql'); if (fs.existsSync(migrationPath)) { const migrationSQL = fs.readFileSync(migrationPath, 'utf8'); await client.query(migrationSQL); console.log('[dbInit] Таблицы проверок контрагентов успешно созданы'); } else { console.warn('[dbInit] Файл migrate_counterparty_checks.sql не найден, создаем таблицы вручную...'); await client.query(` CREATE TABLE IF NOT EXISTS counterparty_checks ( id BIGSERIAL PRIMARY KEY, inn TEXT NOT NULL, kpp TEXT, ogrn TEXT, name TEXT NOT NULL, short_name TEXT, type VARCHAR(20) CHECK (type IN ('LEGAL', 'INDIVIDUAL')), status VARCHAR(20), registration_date DATE, liquidation_date DATE, address TEXT, okved TEXT, okveds JSONB, management_name TEXT, management_post TEXT, finance_data JSONB, authorities_data JSONB, phones JSONB, emails JSONB, employee_count INTEGER, risk_level VARCHAR(10) NOT NULL CHECK (risk_level IN ('low', 'medium', 'high')), risk_reasons TEXT[], raw_data JSONB, checked_by TEXT, checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_counterparty_checks_inn ON counterparty_checks(inn)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_counterparty_checks_risk_level ON counterparty_checks(risk_level)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_counterparty_checks_checked_at ON counterparty_checks(checked_at DESC)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_counterparty_checks_name ON counterparty_checks(name)`); console.log('[dbInit] Таблицы проверок контрагентов успешно созданы вручную'); } } else { console.log('[dbInit] Таблицы проверок контрагентов уже существуют'); } } catch (error) { console.warn('[dbInit] Ошибка при применении миграции проверок контрагентов:', error.message); // Не прерываем выполнение, т.к. это не критично } } /** * Применение миграции: колонка role в portal_users (роль пользователя портала) */ async function applyPortalUsersRoleMigration(client) { try { const colCheck = await client.query( `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'portal_users' AND column_name = 'role'` ); if (colCheck.rows.length === 0) { await client.query( `ALTER TABLE portal_users ADD COLUMN role VARCHAR(30) NOT NULL DEFAULT 'ENGINEER'` ); console.log('[dbInit] В portal_users добавлена колонка role'); } } catch (error) { console.warn('[dbInit] Ошибка при применении миграции portal_users.role:', error.message); } } /** * Миграция: password_hash и is_active в portal_users для авторизации */ async function applyPortalUsersAuthMigration(client) { try { const cols = await client.query( `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'portal_users' AND column_name IN ('password_hash', 'is_active')` ); const names = cols.rows.map(r => r.column_name); if (!names.includes('password_hash')) { await client.query(`ALTER TABLE portal_users ADD COLUMN IF NOT EXISTS password_hash TEXT`); console.log('[dbInit] В portal_users добавлена колонка password_hash'); } if (!names.includes('is_active')) { await client.query(`ALTER TABLE portal_users ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true`); console.log('[dbInit] В portal_users добавлена колонка is_active'); } } catch (error) { console.warn('[dbInit] Ошибка при применении миграции portal_users auth:', error.message); } } /** * Миграция: поля профиля пользователя и таблица user_preferences */ async function applyUserProfileFieldsMigration(client) { try { const migrationPath = path.join(__dirname, 'migrations', 'add_user_profile_fields.sql'); if (fs.existsSync(migrationPath)) { const sql = fs.readFileSync(migrationPath, 'utf8'); await client.query(sql); console.log('[dbInit] Миграция add_user_profile_fields.sql применена успешно'); } } catch (error) { if (!error.message?.includes('already exists') && !error.message?.includes('duplicate')) { console.warn('[dbInit] Ошибка при применении миграции add_user_profile_fields.sql:', error.message); } } } async function applyPermissionTemplatesMigration(client) { try { const tableCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'permission_templates'` ); if (tableCheck.rows.length === 0) { await client.query(` CREATE TABLE IF NOT EXISTS permission_templates ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, description TEXT, permissions JSONB NOT NULL DEFAULT '[]', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); console.log('[dbInit] Таблица permission_templates создана'); } const colCheck = await client.query( `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'portal_users' AND column_name = 'permissions'` ); if (colCheck.rows.length === 0) { await client.query(`ALTER TABLE portal_users ADD COLUMN IF NOT EXISTS permissions JSONB`); console.log('[dbInit] В portal_users добавлена колонка permissions'); } // scope и for_position в permission_templates; scope в portal_users const ptCols = await client.query( `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'permission_templates'` ); const ptColNames = (ptCols.rows || []).map((r) => r.column_name); if (!ptColNames.includes('scope')) { await client.query(`ALTER TABLE permission_templates ADD COLUMN IF NOT EXISTS scope VARCHAR(20) NOT NULL DEFAULT 'all'`); console.log('[dbInit] В permission_templates добавлена колонка scope'); } if (!ptColNames.includes('for_position')) { await client.query(`ALTER TABLE permission_templates ADD COLUMN IF NOT EXISTS for_position TEXT`); console.log('[dbInit] В permission_templates добавлена колонка for_position'); } if (!ptColNames.includes('suggested_role')) { await client.query(`ALTER TABLE permission_templates ADD COLUMN IF NOT EXISTS suggested_role VARCHAR(30)`); console.log('[dbInit] В permission_templates добавлена колонка suggested_role'); } const puScopeCheck = await client.query( `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'portal_users' AND column_name = 'scope'` ); if (puScopeCheck.rows.length === 0) { await client.query(`ALTER TABLE portal_users ADD COLUMN IF NOT EXISTS scope VARCHAR(20) NOT NULL DEFAULT 'all'`); console.log('[dbInit] В portal_users добавлена колонка scope'); } // Сид: шаблон «Мастер участка (расширенный)» — участки, заявки, сводка (производство), финансы (счета :own, календарь), офис (закупки, ремонты), PR (мероприятия, фото) const masterExtendedName = 'Мастер участка (расширенный)'; const existingMasterExtended = await client.query( `SELECT id FROM permission_templates WHERE name = $1`, [masterExtendedName] ); if (existingMasterExtended.rows.length === 0) { const masterExtendedPermissions = [ 'objects', 'requests', 'dashboard_production', 'finance_invoices:own', 'finance_calendar', 'office_supply', 'office_repair', 'pr_events', 'pr_photos', ]; await client.query( `INSERT INTO permission_templates (name, description, permissions, scope, for_position, suggested_role) VALUES ($1, $2, $3, 'own_district', $4, 'MASTER')`, [ masterExtendedName, 'Мастер: участки и заявки по своим участкам; сводка (производство); реестр счетов (только свои), календарь оплат; закупки ТМЦ и ремонт техники; мероприятия и фото отчёты по своим домам.', JSON.stringify(masterExtendedPermissions), 'Мастер участка', ] ); console.log('[dbInit] Добавлен шаблон прав: Мастер участка (расширенный)'); } } catch (error) { console.warn('[dbInit] Ошибка при применении миграции permission_templates:', error.message); } } /** * Миграция: зоны ответственности — привязка сотрудников к подразделам модулей (для уведомлений и эффективности). */ async function applyEmployeeResponsibilityMigration(client) { try { const check = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'employee_responsibility'` ); if (check.rows.length === 0) { await client.query(` CREATE TABLE employee_responsibility ( id BIGSERIAL PRIMARY KEY, employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE, section VARCHAR(50) NOT NULL, sub_id VARCHAR(50) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(employee_id, section, sub_id) ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_employee_responsibility_employee ON employee_responsibility(employee_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_employee_responsibility_zone ON employee_responsibility(section, sub_id)`); console.log('[dbInit] Таблица employee_responsibility создана'); } } catch (error) { console.warn('[dbInit] Ошибка при применении миграции employee_responsibility:', error.message); } } /** * Создание администратора портала по умолчанию (сотрудник iiEasy, логин its). * Выполняется при каждой инициализации: если пользователя с логином 'its' нет — создаётся сотрудник и пользователь. */ async function applyDefaultAdminSeed(client) { try { const existingUser = await client.query( 'SELECT id FROM portal_users WHERE login = $1', [DEFAULT_ADMIN_LOGIN] ); if (existingUser.rows.length > 0) { return; } const employeeExists = await client.query( 'SELECT id FROM employees WHERE id = $1', [DEFAULT_ADMIN_EMPLOYEE_ID] ); if (employeeExists.rows.length === 0) { await client.query( `INSERT INTO employees (id, name, position, phone, status, salary, assigned_district_id) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id) DO NOTHING`, [ DEFAULT_ADMIN_EMPLOYEE_ID, DEFAULT_ADMIN_EMPLOYEE_NAME, 'Администратор портала', '+70000000000', 'active', 0, null, ] ); console.log('[dbInit] Создан сотрудник по умолчанию:', DEFAULT_ADMIN_EMPLOYEE_NAME); } const passwordHash = await bcrypt.hash(DEFAULT_ADMIN_PASSWORD, 10); await client.query( `INSERT INTO portal_users (employee_id, login, role, password_hash, is_active) VALUES ($1, $2, $3, $4, true) ON CONFLICT (login) DO NOTHING`, [DEFAULT_ADMIN_EMPLOYEE_ID, DEFAULT_ADMIN_LOGIN, DEFAULT_ADMIN_ROLE, passwordHash] ); const check = await client.query('SELECT id FROM portal_users WHERE login = $1', [DEFAULT_ADMIN_LOGIN]); if (check.rows.length > 0) { console.log('[dbInit] Создан администратор портала по умолчанию: логин', DEFAULT_ADMIN_LOGIN); } } catch (error) { console.warn('[dbInit] Ошибка при создании администратора по умолчанию:', error.message); } } /** * Демо-пользователь портала (логин demo, пароль demo123) с правами DIRECTOR, как у its. */ async function applyDemoUserSeed(client) { try { const existingUser = await client.query( 'SELECT id FROM portal_users WHERE login = $1', [DEMO_LOGIN] ); if (existingUser.rows.length > 0) { return; } const employeeExists = await client.query( 'SELECT id FROM employees WHERE id = $1', [DEMO_EMPLOYEE_ID] ); if (employeeExists.rows.length === 0) { await client.query( `INSERT INTO employees (id, name, position, phone, status, salary, assigned_district_id) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id) DO NOTHING`, [ DEMO_EMPLOYEE_ID, DEMO_EMPLOYEE_NAME, 'Демо-пользователь', '+70000000001', 'active', 0, null, ] ); console.log('[dbInit] Создан сотрудник для демо:', DEMO_EMPLOYEE_NAME); } const passwordHash = await bcrypt.hash(DEMO_PASSWORD, 10); await client.query( `INSERT INTO portal_users (employee_id, login, role, password_hash, is_active) VALUES ($1, $2, $3, $4, true) ON CONFLICT (login) DO NOTHING`, [DEMO_EMPLOYEE_ID, DEMO_LOGIN, DEMO_ROLE, passwordHash] ); const check = await client.query('SELECT id FROM portal_users WHERE login = $1', [DEMO_LOGIN]); if (check.rows.length > 0) { console.log('[dbInit] Создан демо-пользователь портала: логин', DEMO_LOGIN, ', роль', DEMO_ROLE); } } catch (error) { console.warn('[dbInit] Ошибка при создании демо-пользователя:', error.message); } } /** * Таблица настроек интеграций (DaData и др.) */ async function applyIntegrationSettingsMigration(client) { try { const tableCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'integration_settings'` ); if (tableCheck.rows.length === 0) { await client.query(` CREATE TABLE IF NOT EXISTS integration_settings ( id BIGSERIAL PRIMARY KEY, key VARCHAR(50) NOT NULL UNIQUE, name TEXT, enabled BOOLEAN NOT NULL DEFAULT true, config JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_integration_settings_key ON integration_settings(key)`); await client.query(` INSERT INTO integration_settings (key, name, enabled, config) VALUES ('dadata', 'DaData (проверка контрагентов по ИНН)', true, '{"apiKey":"","secret":""}') ON CONFLICT (key) DO NOTHING `); await client.query(` INSERT INTO integration_settings (key, name, enabled, config) VALUES ('ai_chat', 'ИИ-помощник (чат)', false, '{"url":"","apiKey":""}') ON CONFLICT (key) DO NOTHING `); await client.query(` INSERT INTO integration_settings (key, name, enabled, config) VALUES ('doma', 'Дома.АИ (заявки, API)', true, '{"apiUrl":"","token":""}') ON CONFLICT (key) DO NOTHING `); console.log('[dbInit] Таблица integration_settings создана'); } } catch (error) { console.warn('[dbInit] Ошибка при применении миграции integration_settings:', error.message); } } /** * Миграция: таблицы безопасности (логи входа, чёрный список) */ async function applySecurityModuleMigration(client) { try { const authLogsCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'auth_logs'` ); if (authLogsCheck.rows.length === 0) { await client.query(` CREATE TABLE IF NOT EXISTS auth_logs ( id BIGSERIAL PRIMARY KEY, ip TEXT NOT NULL, login_masked TEXT NOT NULL, success BOOLEAN NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_auth_logs_created_at ON auth_logs(created_at DESC)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_auth_logs_success ON auth_logs(success)`); console.log('[dbInit] Таблица auth_logs создана'); } const blacklistCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'security_blacklist'` ); if (blacklistCheck.rows.length === 0) { await client.query(` CREATE TABLE IF NOT EXISTS security_blacklist ( id BIGSERIAL PRIMARY KEY, type TEXT NOT NULL CHECK (type IN ('ip', 'login')), value TEXT NOT NULL, reason TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_security_blacklist_type_value ON security_blacklist(type, value)`); console.log('[dbInit] Таблица security_blacklist создана'); } const captchaCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'security_captcha'` ); if (captchaCheck.rows.length === 0) { await client.query(` CREATE TABLE IF NOT EXISTS security_captcha ( id SERIAL PRIMARY KEY, site_key TEXT, secret_key TEXT, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`INSERT INTO security_captcha (id, site_key, secret_key) VALUES (1, NULL, NULL) ON CONFLICT (id) DO NOTHING`); console.log('[dbInit] Таблица security_captcha создана'); } } catch (error) { console.warn('[dbInit] Ошибка при применении миграции security:', error.message); } } /** * Применение миграции для модуля развития */ async function applyDevelopmentModuleMigration(client) { try { const migrationPath = path.join(__dirname, 'migrate_development_module.sql'); if (fs.existsSync(migrationPath)) { console.log('[dbInit] Применяем миграцию модуля развития из migrate_development_module.sql...'); const migrationSQL = fs.readFileSync(migrationPath, 'utf8'); await client.query(migrationSQL); console.log('[dbInit] Миграция модуля развития успешно применена'); } else { console.warn('[dbInit] Файл migrate_development_module.sql не найден, создаем таблицы вручную...'); // Создаем таблицы модуля развития вручную await client.query(` CREATE TABLE IF NOT EXISTS development_pipeline ( id VARCHAR(50) PRIMARY KEY, address TEXT NOT NULL, type VARCHAR(10) NOT NULL CHECK (type IN ('old', 'new')), floors INTEGER NOT NULL, area NUMERIC(10, 2) NOT NULL, apartments INTEGER NOT NULL, status VARCHAR(30) NOT NULL CHECK (status IN ('incoming', 'analysis', 'agenda_approval', 'in_person', 'absentee', 'protocol_formation', 'protocol_to_gzhi', 'gzhi_order', 'success', 'failure')), probability INTEGER NOT NULL CHECK (probability >= 0 AND probability <= 100), expected_revenue NUMERIC(15, 2) NOT NULL DEFAULT 0, manager TEXT NOT NULL, building_id VARCHAR(50) REFERENCES buildings(id) ON DELETE SET NULL, notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_dev_pipeline_status ON development_pipeline(status)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_dev_pipeline_building ON development_pipeline(building_id)`); await client.query(` CREATE TABLE IF NOT EXISTS development_oss_sessions ( id VARCHAR(50) PRIMARY KEY, building_id VARCHAR(50) REFERENCES buildings(id) ON DELETE CASCADE, address TEXT NOT NULL, start_date DATE NOT NULL, end_date DATE NOT NULL, quorum_current NUMERIC(10, 2) NOT NULL DEFAULT 0, quorum_total NUMERIC(10, 2) NOT NULL, status VARCHAR(20) NOT NULL CHECK (status IN ('active', 'planned', 'completed')), type VARCHAR(20) NOT NULL CHECK (type IN ('annual', 'extraordinary')), description TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_dev_oss_building ON development_oss_sessions(building_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_dev_oss_status ON development_oss_sessions(status)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_dev_oss_dates ON development_oss_sessions(start_date, end_date)`); await client.query(` CREATE TABLE IF NOT EXISTS development_audits ( id VARCHAR(50) PRIMARY KEY, building_id VARCHAR(50) REFERENCES buildings(id) ON DELETE CASCADE, address TEXT NOT NULL, wear_percent INTEGER NOT NULL CHECK (wear_percent >= 0 AND wear_percent <= 100), roof_condition VARCHAR(10) NOT NULL CHECK (roof_condition IN ('good', 'poor', 'fair')), basement_condition VARCHAR(10) NOT NULL CHECK (basement_condition IN ('good', 'poor', 'fair')), calculated_tariff NUMERIC(10, 2) NOT NULL, projected_margin NUMERIC(5, 2) NOT NULL, audit_date DATE NOT NULL DEFAULT CURRENT_DATE, auditor_name TEXT, defect_list_url TEXT, notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_dev_audits_building ON development_audits(building_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_dev_audits_date ON development_audits(audit_date DESC)`); await client.query(` CREATE TABLE IF NOT EXISTS development_marketing_activities ( id VARCHAR(50) PRIMARY KEY, building_id VARCHAR(50) REFERENCES buildings(id) ON DELETE CASCADE, address TEXT NOT NULL, activists_count INTEGER NOT NULL DEFAULT 0, meetings_held INTEGER NOT NULL DEFAULT 0, ads_distributed INTEGER NOT NULL DEFAULT 0, competitor TEXT, status VARCHAR(20) NOT NULL CHECK (status IN ('voting', 'my_house', 'competitor_house')), notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_dev_marketing_building ON development_marketing_activities(building_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_dev_marketing_status ON development_marketing_activities(status)`); await client.query(` CREATE TABLE IF NOT EXISTS development_building_locations ( id BIGSERIAL PRIMARY KEY, building_id VARCHAR(50) REFERENCES buildings(id) ON DELETE CASCADE, address TEXT NOT NULL, latitude NUMERIC(10, 7), longitude NUMERIC(10, 7), status VARCHAR(20) NOT NULL CHECK (status IN ('ours', 'voting', 'competitor')), competitor_name TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(building_id) ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_dev_locations_building ON development_building_locations(building_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_dev_locations_status ON development_building_locations(status)`); await client.query(` CREATE TABLE IF NOT EXISTS development_oss_registry ( id BIGSERIAL PRIMARY KEY, oss_session_id VARCHAR(50) NOT NULL REFERENCES development_oss_sessions(id) ON DELETE CASCADE, apartment TEXT NOT NULL, owner_name TEXT, area NUMERIC(10, 2) NOT NULL, ballot_submitted BOOLEAN DEFAULT FALSE, ballot_date DATE, vote_result VARCHAR(20) CHECK (vote_result IN ('for', 'against', 'abstain')), notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_dev_oss_registry_session ON development_oss_registry(oss_session_id)`); // Создаем функцию и триггеры для updated_at await client.query(` CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ language 'plpgsql'; `); // Создаем триггеры для каждой таблицы const tables = [ 'development_pipeline', 'development_oss_sessions', 'development_audits', 'development_marketing_activities', 'development_building_locations', 'development_oss_registry' ]; for (const table of tables) { await client.query(` DROP TRIGGER IF EXISTS update_${table}_updated_at ON ${table}; CREATE TRIGGER update_${table}_updated_at BEFORE UPDATE ON ${table} FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); `); } console.log('[dbInit] Таблицы модуля развития успешно созданы вручную'); } } catch (error) { console.warn('[dbInit] Ошибка при применении миграции модуля развития:', error.message); // Не прерываем выполнение, т.к. это не критично } } /** * Применение миграции для автоматизации воронки */ async function applyPipelineAutomationMigration(client) { try { const migrationPath = path.join(__dirname, 'migrate_pipeline_automation.sql'); if (fs.existsSync(migrationPath)) { console.log('[dbInit] Применяем миграцию автоматизации воронки из migrate_pipeline_automation.sql...'); const migrationSQL = fs.readFileSync(migrationPath, 'utf8'); await client.query(migrationSQL); console.log('[dbInit] Миграция автоматизации воронки успешно применена'); } else { console.warn('[dbInit] Файл migrate_pipeline_automation.sql не найден, создаем таблицы вручную...'); // Создаем таблицы автоматизации вручную await client.query(` CREATE TABLE IF NOT EXISTS development_pipeline_history ( id BIGSERIAL PRIMARY KEY, pipeline_id VARCHAR(50) NOT NULL REFERENCES development_pipeline(id) ON DELETE CASCADE, from_status VARCHAR(20), to_status VARCHAR(20) NOT NULL, reason TEXT, triggered_by VARCHAR(50) NOT NULL DEFAULT 'manual', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_pipeline_history_pipeline ON development_pipeline_history(pipeline_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_pipeline_history_status ON development_pipeline_history(to_status)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_pipeline_history_date ON development_pipeline_history(created_at DESC)`); await client.query(` CREATE TABLE IF NOT EXISTS development_automation_rules ( id BIGSERIAL PRIMARY KEY, rule_name VARCHAR(100) NOT NULL UNIQUE, from_status VARCHAR(20), to_status VARCHAR(20) NOT NULL, conditions JSONB, enabled BOOLEAN DEFAULT TRUE, priority INTEGER DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_automation_rules_status ON development_automation_rules(from_status, to_status)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_automation_rules_enabled ON development_automation_rules(enabled)`); // Добавляем поле last_auto_check если его нет await client.query(` DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'development_pipeline' AND column_name = 'last_auto_check') THEN ALTER TABLE development_pipeline ADD COLUMN last_auto_check TIMESTAMPTZ; END IF; END $$; `); await client.query(`CREATE INDEX IF NOT EXISTS idx_pipeline_last_check ON development_pipeline(last_auto_check)`); console.log('[dbInit] Таблицы автоматизации воронки успешно созданы вручную'); } } catch (error) { console.warn('[dbInit] Ошибка при применении миграции автоматизации воронки:', error.message); } } /** * Применение миграции новых этапов воронки развития (9 пунктов) * Обновляет CHECK по status и мигрирует старые статусы в новые. */ async function applyDevelopmentPipelineNewStagesMigration(client) { try { const migrationPath = path.join(__dirname, 'migrations', 'development_pipeline_new_stages.sql'); if (fs.existsSync(migrationPath)) { console.log('[dbInit] Применяем миграцию новых этапов воронки развития...'); const migrationSQL = fs.readFileSync(migrationPath, 'utf8'); await client.query(migrationSQL); console.log('[dbInit] Миграция новых этапов воронки успешно применена'); } else { // Если файла нет — выполняем логику напрямую (для уже существующей БД со старым CHECK) const tableCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'development_pipeline'` ); if (tableCheck.rows.length > 0) { await client.query(`ALTER TABLE development_pipeline DROP CONSTRAINT IF EXISTS development_pipeline_status_check`); await client.query(`UPDATE development_pipeline SET status = 'incoming' WHERE status = 'analysis'`); await client.query(`UPDATE development_pipeline SET status = 'agenda_approval' WHERE status = 'negotiation'`); await client.query(`UPDATE development_pipeline SET status = 'in_person' WHERE status = 'preparation'`); await client.query(`UPDATE development_pipeline SET status = 'absentee' WHERE status = 'voting'`); await client.query(`UPDATE development_pipeline SET status = 'success' WHERE status = 'transfer'`); await client.query(` ALTER TABLE development_pipeline ADD CONSTRAINT development_pipeline_status_check CHECK (status IN ('incoming', 'analysis', 'agenda_approval', 'in_person', 'absentee', 'protocol_formation', 'protocol_to_gzhi', 'gzhi_order', 'success', 'failure')) `); console.log('[dbInit] Новые этапы воронки применены (inline)'); } } } catch (error) { console.warn('[dbInit] Ошибка при применении миграции новых этапов воронки:', error.message); } } /** * Добавление этапа «Анализ» (analysis) в воронку развития */ async function applyDevelopmentPipelineAddAnalysisMigration(client) { try { const migrationPath = path.join(__dirname, 'migrations', 'development_pipeline_add_analysis.sql'); if (fs.existsSync(migrationPath)) { console.log('[dbInit] Применяем миграцию: этап «Анализ» в воронке развития...'); const migrationSQL = fs.readFileSync(migrationPath, 'utf8'); await client.query(migrationSQL); console.log('[dbInit] Этап «Анализ» успешно добавлен в воронку'); } else { const tableCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'development_pipeline'` ); if (tableCheck.rows.length > 0) { await client.query(`ALTER TABLE development_pipeline DROP CONSTRAINT IF EXISTS development_pipeline_status_check`); await client.query(` ALTER TABLE development_pipeline ADD CONSTRAINT development_pipeline_status_check CHECK (status IN ('incoming', 'analysis', 'agenda_approval', 'in_person', 'absentee', 'protocol_formation', 'protocol_to_gzhi', 'gzhi_order', 'success', 'failure')) `); console.log('[dbInit] Этап «Анализ» применён (inline)'); } } } catch (error) { console.warn('[dbInit] Ошибка при применении миграции этапа «Анализ»:', error.message); } } /** * Миграция аудитов: статусы, индекс сложности, inspection_data */ async function applyDevelopmentAuditsStatusAndInspectionMigration(client) { try { const migrationPath = path.join(__dirname, 'migrations', 'development_audits_status_and_inspection.sql'); if (fs.existsSync(migrationPath)) { console.log('[dbInit] Применяем миграцию аудитов (статусы, осмотр)...'); const migrationSQL = fs.readFileSync(migrationPath, 'utf8'); await client.query(migrationSQL); console.log('[dbInit] Миграция аудитов успешно применена'); } } catch (error) { console.warn('[dbInit] Ошибка при применении миграции аудитов:', error.message); } } /** * Миграция ОСС: повестка (agenda_items) и голоса по пунктам (votes_by_item) */ async function applyDevelopmentOSSAgendaMigration(client) { try { const migrationPath = path.join(__dirname, 'migrations', 'development_oss_agenda_and_votes.sql'); if (fs.existsSync(migrationPath)) { console.log('[dbInit] Применяем миграцию ОСС (повестка, голоса по пунктам)...'); const migrationSQL = fs.readFileSync(migrationPath, 'utf8'); await client.query(migrationSQL); console.log('[dbInit] Миграция ОСС успешно применена'); } } catch (error) { console.warn('[dbInit] Ошибка при применении миграции ОСС:', error.message); } } /** * Применение миграции для юридического отдела (договоры, судебные дела) */ async function applyLegalModuleMigration(client) { try { // Проверяем существование таблицы legal_contracts const contractsCheck = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'legal_contracts'` ); if (contractsCheck.rows.length === 0) { console.log('[dbInit] Таблицы юридического отдела не найдены, создаем...'); // Читаем SQL миграцию const migrationPath = path.join(__dirname, 'migrate_legal_module.sql'); if (fs.existsSync(migrationPath)) { const migrationSQL = fs.readFileSync(migrationPath, 'utf8'); await client.query(migrationSQL); console.log('[dbInit] Таблицы юридического отдела успешно созданы'); } else { console.warn('[dbInit] Файл migrate_legal_module.sql не найден, создаем таблицы вручную...'); // Создаем таблицы вручную await client.query(` CREATE TABLE IF NOT EXISTS legal_contracts ( id VARCHAR(50) PRIMARY KEY, number TEXT NOT NULL UNIQUE, type TEXT NOT NULL, counterparty TEXT NOT NULL, counterparty_inn TEXT, amount NUMERIC(15, 2) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'finance_approval', 'counterparty_approval', 'signing', 'active', 'archived')), start_date DATE NOT NULL, end_date DATE NOT NULL, auto_prolongation BOOLEAN DEFAULT FALSE, manager TEXT NOT NULL, has_disagreements BOOLEAN DEFAULT FALSE, contract_file_url TEXT, notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_legal_contracts_status ON legal_contracts(status)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_legal_contracts_counterparty ON legal_contracts(counterparty)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_legal_contracts_number ON legal_contracts(number)`); await client.query(` CREATE TABLE IF NOT EXISTS legal_court_cases ( id VARCHAR(50) PRIMARY KEY, case_number TEXT NOT NULL UNIQUE, type VARCHAR(20) NOT NULL CHECK (type IN ('arbitration', 'civil', 'debt_recovery')), role VARCHAR(20) NOT NULL CHECK (role IN ('plaintiff', 'defendant')), subject TEXT NOT NULL, debtor_name TEXT, address TEXT, amount NUMERIC(15, 2) NOT NULL, recovered_amount NUMERIC(15, 2) DEFAULT 0, amount_at_bailiffs NUMERIC(15, 2) DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'pre_trial' CHECK (status IN ('pre_trial', 'litigation', 'decision_received', 'enforcement', 'closed')), fssp_status TEXT, bailiff_name TEXT, fssp_last_action_date DATE, next_hearing_date DATE, judge TEXT, court_name TEXT, case_file_url TEXT, notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_legal_court_cases_status ON legal_court_cases(status)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_legal_court_cases_type ON legal_court_cases(type)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_legal_court_cases_number ON legal_court_cases(case_number)`); await client.query(` CREATE TABLE IF NOT EXISTS legal_powers_of_attorney ( id VARCHAR(50) PRIMARY KEY, number TEXT NOT NULL UNIQUE, issued_to TEXT NOT NULL, issue_date DATE NOT NULL, expiry_date DATE NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'revoked')), authority TEXT NOT NULL, document_file_url TEXT, notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_legal_poa_status ON legal_powers_of_attorney(status)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_legal_poa_expiry ON legal_powers_of_attorney(expiry_date)`); console.log('[dbInit] Таблицы юридического отдела успешно созданы вручную'); } } else { console.log('[dbInit] Таблицы юридического отдела уже существуют'); } // Миграция: этапы ФССП и ИП (доп. поля legal_court_cases) const fsspMigrationPath = path.join(__dirname, 'migrate_legal_fssp_enforcement.sql'); if (fs.existsSync(fsspMigrationPath)) { try { const fsspSQL = fs.readFileSync(fsspMigrationPath, 'utf8'); await client.query(fsspSQL); console.log('[dbInit] Миграция ФССП/ИП применена'); } catch (fsspErr) { console.warn('[dbInit] Ошибка миграции ФССП (колонки могут уже существовать):', fsspErr.message); } } const penaltiesMigrationPath = path.join(__dirname, 'migrate_legal_penalties.sql'); if (fs.existsSync(penaltiesMigrationPath)) { try { const penaltiesSQL = fs.readFileSync(penaltiesMigrationPath, 'utf8'); await client.query(penaltiesSQL); console.log('[dbInit] Миграция пени применена'); } catch (penErr) { console.warn('[dbInit] Ошибка миграции пени:', penErr.message); } } const caseDocsMigrationPath = path.join(__dirname, 'migrate_legal_case_documents.sql'); if (fs.existsSync(caseDocsMigrationPath)) { try { const caseDocsSQL = fs.readFileSync(caseDocsMigrationPath, 'utf8'); await client.query(caseDocsSQL); console.log('[dbInit] Миграция документов по делам применена'); } catch (docErr) { console.warn('[dbInit] Ошибка миграции документов по делам:', docErr.message); } } } catch (error) { console.warn('[dbInit] Ошибка при применении миграции юридического отдела:', error.message); // Не прерываем выполнение, т.к. это не критично } } /** * Применение миграции для добавления статуса 'deferred' в enum doma_application_status */ async function applyDeferredStatusMigration(client) { try { // Проверяем, существует ли уже значение 'deferred' в enum const enumCheck = await client.query(` SELECT 1 FROM pg_enum WHERE enumlabel = 'deferred' AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'doma_application_status') `); if (enumCheck.rows.length === 0) { console.log('[dbInit] Добавляем статус "deferred" в enum doma_application_status...'); try { // Пробуем добавить значение (может не работать в транзакции в некоторых версиях PostgreSQL) await client.query(`ALTER TYPE doma_application_status ADD VALUE 'deferred'`); console.log('[dbInit] Статус "deferred" успешно добавлен в enum doma_application_status'); } catch (error) { // Если ошибка "already exists" или "duplicate" - это нормально if (error.message.includes('already exists') || error.message.includes('duplicate') || error.message.includes('уже существует')) { console.log('[dbInit] Статус "deferred" уже существует в enum doma_application_status'); } else if (error.message.includes('cannot be executed inside a transaction block') || error.message.includes('не может быть выполнена внутри блока транзакции')) { // Если не может быть выполнено в транзакции, используем отдельное подключение console.log('[dbInit] ALTER TYPE не может быть выполнен в транзакции, используем отдельное подключение...'); const { Pool } = require('pg'); const tempPool = new Pool({ connectionString: process.env.DATABASE_URL }); try { const tempClient = await tempPool.connect(); await tempClient.query(`ALTER TYPE doma_application_status ADD VALUE 'deferred'`); tempClient.release(); console.log('[dbInit] Статус "deferred" успешно добавлен через отдельное подключение'); } catch (tempError) { if (tempError.message.includes('already exists') || tempError.message.includes('duplicate') || tempError.message.includes('уже существует')) { console.log('[dbInit] Статус "deferred" уже существует'); } else { console.warn('[dbInit] Ошибка при добавлении статуса через отдельное подключение:', tempError.message); } } finally { await tempPool.end(); } } else { console.warn('[dbInit] Неожиданная ошибка при добавлении статуса "deferred":', error.message); } } } else { console.log('[dbInit] Статус "deferred" уже существует в enum doma_application_status'); } } catch (error) { console.warn('[dbInit] Ошибка при применении миграции добавления статуса "deferred":', error.message); // Не прерываем выполнение, т.к. это не критично } } /** * Применение миграции для отслеживания производительности (поля в applications и таблицы статистики) */ async function applyDomaMappingsMigration(client) { try { console.log('[dbInit] Применяем миграцию для сопоставлений Doma.AI...'); const migrationSql = fs.readFileSync( path.join(__dirname, 'migrate_doma_mappings.sql'), 'utf8' ); await client.query(migrationSql); // Создаём функции нормализации, если их нет await client.query(` CREATE OR REPLACE FUNCTION normalize_address(addr TEXT) RETURNS TEXT AS $$ BEGIN RETURN lower(trim(regexp_replace(regexp_replace(regexp_replace(regexp_replace( regexp_replace(addr, 'г\\.', 'г ', 'g'), 'ул\\.', 'ул ', 'g'), 'д\\.', 'д ', 'g'), '[.,]', '', 'g'), '\\s+', ' ', 'g'))); END; $$ LANGUAGE plpgsql IMMUTABLE; `); await client.query(` CREATE OR REPLACE FUNCTION normalize_name(n TEXT) RETURNS TEXT AS $$ BEGIN RETURN lower(trim(regexp_replace(n, '\\s+', ' ', 'g'))); END; $$ LANGUAGE plpgsql IMMUTABLE; `); console.log('[dbInit] Миграция сопоставлений Doma.AI применена успешно'); } catch (err) { if (err.message.includes('already exists') || err.message.includes('duplicate')) { console.log('[dbInit] Таблицы сопоставлений Doma.AI уже существуют'); } else { console.error('[dbInit] Ошибка при применении миграции сопоставлений Doma.AI:', err.message); } } } async function applyPerformanceTrackingMigration(client) { try { // Проверяем наличие колонки building_id const buildingIdCheck = await client.query(` SELECT column_name FROM information_schema.columns WHERE table_name = 'applications' AND column_name = 'building_id' `); if (buildingIdCheck.rows.length === 0) { console.log('[dbInit] Добавляем поля для отслеживания производительности в таблицу applications...'); await client.query(` ALTER TABLE applications ADD COLUMN IF NOT EXISTS building_id VARCHAR(50) REFERENCES buildings(id) ON DELETE SET NULL `); await client.query(`CREATE INDEX IF NOT EXISTS idx_applications_building ON applications(building_id)`); console.log('[dbInit] Поле building_id добавлено'); } // Проверяем наличие колонки employee_id const employeeIdCheck = await client.query(` SELECT column_name FROM information_schema.columns WHERE table_name = 'applications' AND column_name = 'employee_id' `); if (employeeIdCheck.rows.length === 0) { await client.query(` ALTER TABLE applications ADD COLUMN IF NOT EXISTS employee_id VARCHAR(50) REFERENCES employees(id) ON DELETE SET NULL `); await client.query(`CREATE INDEX IF NOT EXISTS idx_applications_employee ON applications(employee_id)`); console.log('[dbInit] Поле employee_id добавлено'); } // Проверяем наличие колонки is_overdue const overdueCheck = await client.query(` SELECT column_name FROM information_schema.columns WHERE table_name = 'applications' AND column_name = 'is_overdue' `); if (overdueCheck.rows.length === 0) { await client.query(` ALTER TABLE applications ADD COLUMN IF NOT EXISTS is_overdue BOOLEAN NOT NULL DEFAULT FALSE `); await client.query(`CREATE INDEX IF NOT EXISTS idx_applications_overdue ON applications(is_overdue)`); console.log('[dbInit] Поле is_overdue добавлено'); } // Проверяем наличие колонки updated_at const updatedAtCheck = await client.query(` SELECT column_name FROM information_schema.columns WHERE table_name = 'applications' AND column_name = 'updated_at' `); if (updatedAtCheck.rows.length === 0) { await client.query(` ALTER TABLE applications ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() `); console.log('[dbInit] Поле updated_at добавлено'); } // Создаём дополнительные индексы await client.query(`CREATE INDEX IF NOT EXISTS idx_applications_performer ON applications(performer_name)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_applications_status ON applications(status)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_applications_deadline ON applications(deadline_at)`); // Создаём таблицы для статистики производительности const employeeStatsCheck = await client.query(` SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'employee_performance_stats' `); if (employeeStatsCheck.rows.length === 0) { console.log('[dbInit] Создаём таблицу employee_performance_stats...'); await client.query(` CREATE TABLE employee_performance_stats ( id BIGSERIAL PRIMARY KEY, employee_name TEXT NOT NULL, period_start DATE NOT NULL, period_end DATE NOT NULL, total_assigned INTEGER NOT NULL DEFAULT 0, total_completed INTEGER NOT NULL DEFAULT 0, total_overdue INTEGER NOT NULL DEFAULT 0, total_in_progress INTEGER NOT NULL DEFAULT 0, total_deferred INTEGER NOT NULL DEFAULT 0, completion_rate NUMERIC(5, 2) NOT NULL DEFAULT 0, overdue_rate NUMERIC(5, 2) NOT NULL DEFAULT 0, performance_score NUMERIC(5, 2) NOT NULL DEFAULT 0, district_id VARCHAR(50) REFERENCES districts(id) ON DELETE SET NULL, calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(employee_name, period_start, period_end) ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_performance_stats_employee ON employee_performance_stats(employee_name)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_performance_stats_district ON employee_performance_stats(district_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_performance_stats_period ON employee_performance_stats(period_start, period_end)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_performance_stats_score ON employee_performance_stats(performance_score DESC)`); console.log('[dbInit] Таблица employee_performance_stats создана'); } const districtStatsCheck = await client.query(` SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'district_performance_stats' `); if (districtStatsCheck.rows.length === 0) { console.log('[dbInit] Создаём таблицу district_performance_stats...'); await client.query(` CREATE TABLE district_performance_stats ( id BIGSERIAL PRIMARY KEY, district_id VARCHAR(50) NOT NULL REFERENCES districts(id) ON DELETE CASCADE, period_start DATE NOT NULL, period_end DATE NOT NULL, total_applications INTEGER NOT NULL DEFAULT 0, total_completed INTEGER NOT NULL DEFAULT 0, total_overdue INTEGER NOT NULL DEFAULT 0, total_in_progress INTEGER NOT NULL DEFAULT 0, completion_rate NUMERIC(5, 2) NOT NULL DEFAULT 0, overdue_rate NUMERIC(5, 2) NOT NULL DEFAULT 0, average_score NUMERIC(5, 2) NOT NULL DEFAULT 0, calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(district_id, period_start, period_end) ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_district_stats_district ON district_performance_stats(district_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_district_stats_period ON district_performance_stats(period_start, period_end)`); console.log('[dbInit] Таблица district_performance_stats создана'); } console.log('[dbInit] Миграция отслеживания производительности применена успешно'); } catch (error) { console.warn('[dbInit] Ошибка при применении миграции отслеживания производительности:', error.message); // Не прерываем выполнение, т.к. это не критично } } /** * Применение миграции для добавления поля inventory в таблицу districts */ async function applyDistrictsInventoryMigration(client) { try { // Проверяем наличие колонки inventory в таблице districts const columnCheck = await client.query( `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'districts' AND column_name = 'inventory'` ); if (columnCheck.rows.length === 0) { console.log('[dbInit] Добавляем поле inventory в таблицу districts...'); // Читаем SQL миграцию const migrationPath = path.join(__dirname, 'migrations', 'add_inventory_to_districts.sql'); if (fs.existsSync(migrationPath)) { const migrationSQL = fs.readFileSync(migrationPath, 'utf8'); await client.query(migrationSQL); console.log('[dbInit] Поле inventory успешно добавлено в districts'); } else { // Создаем поле вручную, если файл не найден await client.query(` ALTER TABLE districts ADD COLUMN IF NOT EXISTS inventory JSONB DEFAULT '[]'::jsonb `); console.log('[dbInit] Поле inventory добавлено в districts (вручную)'); } } else { console.log('[dbInit] Поле inventory уже существует в districts'); } } catch (error) { console.warn('[dbInit] Ошибка при применении миграции inventory для districts:', error.message); // Не прерываем выполнение, т.к. это не критично } // Миграция: task_id в work_photos (привязка фото отчёта до/после к задаче) try { const taskIdMigrationPath = path.join(__dirname, 'migrations', 'add_task_id_to_work_photos.sql'); if (fs.existsSync(taskIdMigrationPath)) { const migrationSQL = fs.readFileSync(taskIdMigrationPath, 'utf8'); await client.query(migrationSQL); console.log('[dbInit] Миграция add_task_id_to_work_photos.sql применена успешно'); } } catch (err) { if (!err.message.includes('already exists') && !err.message.includes('duplicate')) { console.warn('[dbInit] Ошибка при применении миграции add_task_id_to_work_photos.sql:', err.message); } } // Миграция: source и поля для ручной карточки заявки (диспетчерская) try { const appManualPath = path.join(__dirname, 'migrations', 'add_applications_manual_fields.sql'); if (fs.existsSync(appManualPath)) { const migrationSQL = fs.readFileSync(appManualPath, 'utf8'); await client.query(migrationSQL); console.log('[dbInit] Миграция add_applications_manual_fields.sql применена успешно'); } } catch (err) { if (!err.message.includes('already exists') && !err.message.includes('duplicate')) { console.warn('[dbInit] Ошибка при применении миграции add_applications_manual_fields.sql:', err.message); } } // Миграция: журнал отключений (outages) try { const outagesPath = path.join(__dirname, 'migrations', 'create_outages.sql'); if (fs.existsSync(outagesPath)) { const migrationSQL = fs.readFileSync(outagesPath, 'utf8'); await client.query(migrationSQL); console.log('[dbInit] Миграция create_outages.sql применена успешно'); } } catch (err) { if (!err.message.includes('already exists') && !err.message.includes('duplicate')) { console.warn('[dbInit] Ошибка при применении миграции create_outages.sql:', err.message); } } // Миграция: доп. поля отключений (категория, тип работ, resident_message) try { const addOutagePath = path.join(__dirname, 'migrations', 'add_outage_fields.sql'); if (fs.existsSync(addOutagePath)) { const migrationSQL = fs.readFileSync(addOutagePath, 'utf8'); await client.query(migrationSQL); console.log('[dbInit] Миграция add_outage_fields.sql применена успешно'); } } catch (err) { if (!err.message.includes('already exists') && !err.message.includes('duplicate')) { console.warn('[dbInit] Ошибка при применении миграции add_outage_fields.sql:', err.message); } } // Миграция: комментарии и история заявок try { const commentsHistoryPath = path.join(__dirname, 'migrations', 'create_application_comments_history.sql'); if (fs.existsSync(commentsHistoryPath)) { const migrationSQL = fs.readFileSync(commentsHistoryPath, 'utf8'); await client.query(migrationSQL); console.log('[dbInit] Миграция create_application_comments_history.sql применена успешно'); } } catch (err) { if (!err.message.includes('already exists') && !err.message.includes('duplicate')) { console.warn('[dbInit] Ошибка при применении миграции create_application_comments_history.sql:', err.message); } } } /** * Миграция: таблицы для ИИ-чата (диалоги и сообщения по пользователям) */ async function applyAIChatMigration(client) { try { const tableCheck = await client.query(` SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'ai_conversations' `); if (tableCheck.rows.length > 0) { console.log('[dbInit] Таблицы ИИ-чата уже существуют'); return; } console.log('[dbInit] Создаём таблицы ИИ-чата...'); await client.query(` CREATE TABLE ai_conversations ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES portal_users(id) ON DELETE CASCADE, title TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_ai_conversations_user ON ai_conversations(user_id); CREATE TABLE ai_messages ( id BIGSERIAL PRIMARY KEY, conversation_id BIGINT NOT NULL REFERENCES ai_conversations(id) ON DELETE CASCADE, role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')), content TEXT NOT NULL DEFAULT '', tool_calls_json JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_ai_messages_conversation ON ai_messages(conversation_id); `); console.log('[dbInit] Таблицы ИИ-чата созданы успешно'); } catch (err) { if (err.message.includes('already exists') || err.message.includes('duplicate')) { console.log('[dbInit] Таблицы ИИ-чата уже существуют'); } else { console.error('[dbInit] Ошибка миграции ИИ-чата:', err.message); throw err; } } } /** * Таблица уведомлений для колокольчика (только для затронутых пользователей) */ async function applyNotificationsMigration(client) { try { const check = await client.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'notifications'` ); if (check.rows.length > 0) { console.log('[dbInit] Таблица notifications уже существует'); return; } await client.query(` CREATE TABLE notifications ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES portal_users(id) ON DELETE CASCADE, type VARCHAR(80) NOT NULL, title TEXT NOT NULL, body TEXT, entity_type VARCHAR(80), entity_id TEXT, payload JSONB, read_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_notifications_user_read_created ON notifications(user_id, read_at, created_at DESC)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id)`); console.log('[dbInit] Таблица notifications создана'); } catch (err) { console.warn('[dbInit] Ошибка миграции notifications:', err.message); } } module.exports = { initializeDatabase, checkDatabaseIntegrity };