3608 lines
175 KiB
JavaScript
Executable File
3608 lines
175 KiB
JavaScript
Executable File
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';
|
||
|
||
/**
|
||
* Инициализация базы данных 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);
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Таблица настроек интеграций (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 };
|