3608 lines
175 KiB
JavaScript
3608 lines
175 KiB
JavaScript
|
|
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 };
|