const express = require('express'); const cors = require('cors'); const dotenv = require('dotenv'); const { Pool } = require('pg'); const { initializeDatabase } = require('./dbInit'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const { spawn } = require('child_process'); const { v4: uuidv4 } = require('uuid'); const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const FileProcessor = require('./fileProcessor'); const BalanceSheetProcessor = require('./balanceSheetProcessor'); const BalanceSheet76Processor = require('./balanceSheet76Processor'); const DebtorReportProcessor = require('./debtorReportProcessor'); const PipelineAutomation = require('./pipelineAutomation'); const paymentInvoiceWorkflow = require('./paymentInvoiceWorkflow'); const notificationService = require('./notificationService'); const { ROLE_ACCESS, SECTION_IDS } = require('./constants/roleAccess'); const axios = require('axios'); const aiChatService = require('./aiChatService'); dotenv.config(); const app = express(); app.use(cors()); app.use(express.json()); // Заголовки безопасности (E3: защита от XSS, clickjacking) app.use((req, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'SAMEORIGIN'); res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); next(); }); const PORT = process.env.PORT || 4000; const API_PREFIX = '/api'; const isProduction = process.env.NODE_ENV === 'production'; const JWT_SECRET = process.env.JWT_SECRET || (isProduction ? null : 'mkd-control-center-secret-change-in-production'); const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'; if (isProduction && !process.env.JWT_SECRET) { console.error('[backend] В production необходимо задать переменную окружения JWT_SECRET. Сервер не запущен.'); process.exit(1); } // Строка подключения к PostgreSQL. // Пример: postgres://user:password@localhost:5432/mkd_control_center const DATABASE_URL = process.env.DATABASE_URL; const DOMA_API_URL = process.env.DOMA_API_URL; const DOMA_API_TOKEN = process.env.DOMA_API_TOKEN; if (!DATABASE_URL) { console.warn( '[backend] Внимание: переменная окружения DATABASE_URL не задана. ' + 'Бэкенд не сможет подключиться к PostgreSQL, пока вы её не настроите.' ); } const pool = new Pool({ connectionString: DATABASE_URL, }); // Инициализация обработчиков файлов const fileProcessor = new FileProcessor(pool); const balanceSheetProcessor = new BalanceSheetProcessor(pool); const balanceSheet76Processor = new BalanceSheet76Processor(); const debtorReportProcessor = new DebtorReportProcessor(pool); const pipelineAutomation = new PipelineAutomation(pool); // Настройка multer для загрузки файлов const uploadDir = path.join(__dirname, 'uploads'); if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, uploadDir); }, filename: (req, file, cb) => { const uniqueName = `${uuidv4()}-${file.originalname}`; cb(null, uniqueName); } }); const upload = multer({ storage, fileFilter: (req, file, cb) => { const allowedTypes = ['.csv', '.xlsx', '.xls']; const ext = path.extname(file.originalname).toLowerCase(); if (allowedTypes.includes(ext)) { cb(null, true); } else { cb(new Error('Неподдерживаемый тип файла. Разрешены только CSV и XLSX')); } }, limits: { fileSize: 50 * 1024 * 1024 // 50MB } }); // Настройка multer для загрузки изображений (фото сотрудников) const photosDir = path.join(__dirname, 'uploads', 'photos'); if (!fs.existsSync(photosDir)) { fs.mkdirSync(photosDir, { recursive: true }); } const photoStorage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, photosDir); }, filename: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase(); const uniqueName = `${uuidv4()}${ext}`; cb(null, uniqueName); } }); const uploadPhoto = multer({ storage: photoStorage, fileFilter: (req, file, cb) => { const allowedTypes = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; const ext = path.extname(file.originalname).toLowerCase(); if (allowedTypes.includes(ext)) { cb(null, true); } else { cb(new Error('Неподдерживаемый тип файла. Разрешены только изображения (JPG, PNG, GIF, WEBP)')); } }, limits: { fileSize: 5 * 1024 * 1024 // 5MB } }); // Настройка multer для загрузки файлов базы знаний const knowledgeBaseDir = path.join(__dirname, 'uploads', 'knowledge-base'); if (!fs.existsSync(knowledgeBaseDir)) { fs.mkdirSync(knowledgeBaseDir, { recursive: true }); } const knowledgeBaseStorage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, knowledgeBaseDir); }, filename: (req, file, cb) => { const uniqueName = `${uuidv4()}-${file.originalname}`; cb(null, uniqueName); } }); const uploadKnowledgeBase = multer({ storage: knowledgeBaseStorage, fileFilter: (req, file, cb) => { const allowedTypes = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.pdf', '.doc', '.docx', '.txt', '.zip', '.rar']; const ext = path.extname(file.originalname).toLowerCase(); if (allowedTypes.includes(ext)) { cb(null, true); } else { cb(new Error('Неподдерживаемый тип файла')); } }, limits: { fileSize: 20 * 1024 * 1024 // 20MB } }); // Настройка multer для загрузки файлов документов корреспонденции const documentsDir = path.join(__dirname, 'uploads', 'documents'); if (!fs.existsSync(documentsDir)) { fs.mkdirSync(documentsDir, { recursive: true }); } const documentsStorage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, documentsDir); }, filename: (req, file, cb) => { // Исправляем кодировку имени файла let originalName = file.originalname; try { // Пробуем декодировать из latin1 в utf8, если имя содержит кракозябры const hasCyrillic = /[А-Яа-яЁё]/.test(originalName); const hasGarbled = /[ÐÑ]/i.test(originalName) || /[â€]/i.test(originalName); if (hasGarbled || (!hasCyrillic && /[^\x00-\x7F]/.test(originalName))) { try { const buffer = Buffer.from(originalName, 'latin1'); const decoded = buffer.toString('utf8'); if (/[А-Яа-яЁё]/.test(decoded)) { originalName = decoded; } } catch (decodeErr) { try { originalName = decodeURIComponent(escape(originalName)); } catch (e) { console.warn('Не удалось исправить кодировку имени файла'); } } } } catch (decodeErr) { console.warn('Ошибка декодирования имени файла, используем оригинал:', decodeErr); } // Сохраняем оригинальное имя в безопасном формате const ext = path.extname(originalName); const baseName = path.basename(originalName, ext); // Транслитерируем кириллицу для имени файла или используем только UUID const safeName = baseName.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 50); const uniqueName = `${uuidv4()}-${safeName}${ext}`; cb(null, uniqueName); } }); const uploadDocuments = multer({ storage: documentsStorage, fileFilter: (req, file, cb) => { const allowedTypes = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.pdf', '.doc', '.docx', '.txt']; const ext = path.extname(file.originalname).toLowerCase(); if (allowedTypes.includes(ext)) { cb(null, true); } else { cb(new Error('Неподдерживаемый тип файла. Разрешены: изображения, PDF, DOC, DOCX, TXT')); } }, limits: { fileSize: 20 * 1024 * 1024 // 20MB } }); // Настройка multer для типовых документов HR (кадры) const hrTemplatesDir = path.join(__dirname, 'uploads', 'hr-templates'); if (!fs.existsSync(hrTemplatesDir)) { fs.mkdirSync(hrTemplatesDir, { recursive: true }); } const hrTemplatesStorage = multer.diskStorage({ destination: (req, file, cb) => cb(null, hrTemplatesDir), filename: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase() || ''; cb(null, `${uuidv4()}${ext}`); } }); const uploadHrTemplate = multer({ storage: hrTemplatesStorage, fileFilter: (req, file, cb) => { const allowed = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.txt', '.odt']; const ext = path.extname(file.originalname).toLowerCase(); if (allowed.includes(ext)) { cb(null, true); } else { cb(new Error('Разрешены: PDF, DOC, DOCX, XLS, XLSX, TXT, ODT')); } }, limits: { fileSize: 15 * 1024 * 1024 } }); // Настройка multer для загрузки файлов счетов на оплату const invoicesDir = path.join(__dirname, 'uploads', 'payment-invoices'); if (!fs.existsSync(invoicesDir)) { fs.mkdirSync(invoicesDir, { recursive: true }); } const invoicesStorage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, invoicesDir); }, filename: (req, file, cb) => { // Исправляем кодировку имени файла let originalName = file.originalname; try { const hasCyrillic = /[А-Яа-яЁё]/.test(originalName); const hasGarbled = /[ÐÑ]/i.test(originalName) || /[â€]/i.test(originalName); if (hasGarbled || (!hasCyrillic && /[^\x00-\x7F]/.test(originalName))) { try { const buffer = Buffer.from(originalName, 'latin1'); const decoded = buffer.toString('utf8'); if (/[А-Яа-яЁё]/.test(decoded)) { originalName = decoded; } } catch (decodeErr) { try { originalName = decodeURIComponent(escape(originalName)); } catch (e) { console.warn('Не удалось исправить кодировку имени файла'); } } } } catch (decodeErr) { console.warn('Ошибка декодирования имени файла, используем оригинал:', decodeErr); } const ext = path.extname(originalName); const baseName = path.basename(originalName, ext); const safeName = baseName.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 50); const uniqueName = `${uuidv4()}-${safeName}${ext}`; cb(null, uniqueName); } }); const uploadInvoice = multer({ storage: invoicesStorage, fileFilter: (req, file, cb) => { const allowedTypes = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.txt', '.zip', '.rar']; const ext = path.extname(file.originalname).toLowerCase(); if (allowedTypes.includes(ext)) { cb(null, true); } else { cb(new Error('Неподдерживаемый тип файла. Разрешены: изображения, PDF, DOC, DOCX, XLS, XLSX, TXT, ZIP, RAR')); } }, limits: { fileSize: 50 * 1024 * 1024 // 50MB } }); // Настройка multer для загрузки файлов договоров (юридический отдел) const contractsDir = path.join(__dirname, 'uploads', 'contracts'); if (!fs.existsSync(contractsDir)) { fs.mkdirSync(contractsDir, { recursive: true }); } const contractsStorage = multer.diskStorage({ destination: (req, file, cb) => cb(null, contractsDir), filename: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase() || '.bin'; const safeName = path.basename(file.originalname, ext).replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 50); cb(null, `${uuidv4()}-${safeName}${ext}`); } }); const uploadContract = multer({ storage: contractsStorage, fileFilter: (req, file, cb) => { const allowedTypes = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.jpg', '.jpeg', '.png', '.txt', '.zip', '.rar']; const ext = path.extname(file.originalname).toLowerCase(); if (allowedTypes.includes(ext)) { cb(null, true); } else { cb(new Error('Неподдерживаемый тип файла. Разрешены: PDF, DOC, DOCX, XLS, XLSX, изображения, TXT, ZIP, RAR')); } }, limits: { fileSize: 50 * 1024 * 1024 } }); // Статическая раздача загруженных файлов app.use('/uploads', express.static(uploadDir)); // Инициализация БД и запуск сервера — listen только после готовности БД async function startServer() { if (DATABASE_URL) { try { const initResult = await initializeDatabase(); if (initResult.success) { console.log('[backend] База данных инициализирована успешно'); if (initResult.initialized) { console.log('[backend] База данных была создана и настроена'); } } else { console.error('[backend] Ошибка инициализации БД:', initResult.reason || initResult.error); process.exit(1); } // Таблица employee_districts создаётся до listen, чтобы быть готовой к первым запросам try { await ensureEmployeeDistrictsTable(); console.log('[backend] Таблица employee_districts проверена/создана'); } catch (err) { console.warn('[backend] employee_districts при старте:', err.message); } } catch (error) { console.error('[backend] Критическая ошибка при инициализации БД:', error); process.exit(1); } } app.listen(PORT, () => { console.log(`Backend API running on http://localhost:${PORT}${API_PREFIX}`); if (DATABASE_URL) { ensureEmployeeResponsibilityTable() .then(() => console.log('[backend] Таблица employee_responsibility проверена/создана')) .catch((err) => console.warn('[backend] employee_responsibility:', err.message)); ensureSickLeaveExpectedReturnDateColumn() .then(() => console.log('[backend] Колонка employee_sick_leaves.expected_return_date проверена')) .catch((err) => console.warn('[backend] employee_sick_leaves.expected_return_date:', err.message)); startDailyPipelineCheck(); console.log('[Cron] Pipeline automation scheduler started'); console.log('[Auto Reports] Автоматическое создание отчетов включено (1 число каждого месяца)'); } }); } // Хелпер для запросов к БД async function query(text, params) { const client = await pool.connect(); try { const res = await client.query(text, params); return res.rows; } finally { client.release(); } } /** Применяет миграцию report_type: добавляет balance_sheet_76 в CHECK (для ОСВ 76 без перезапуска). */ async function ensureBalanceSheet76ReportType() { const client = await pool.connect(); try { await client.query(`ALTER TABLE financial_reports DROP CONSTRAINT IF EXISTS financial_reports_report_type_check`); const { rows } = 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 rows) { const name = String(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')) `); } finally { client.release(); } } /** Создаёт таблицы report_76_rows и building_personal_account_mappings, если их ещё нет (для ОСВ 76 без перезапуска). */ async function ensureReport76Tables() { const exists = await query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'report_76_rows'` ); if (exists.length > 0) return; const client = await pool.connect(); try { await client.query(` CREATE TABLE IF NOT EXISTS report_76_rows ( id BIGSERIAL PRIMARY KEY, report_id BIGINT NOT NULL REFERENCES financial_reports(id) ON DELETE CASCADE, row_index INTEGER NOT NULL, account_label TEXT NOT NULL, account_ls TEXT, saldo_start_debet NUMERIC(15, 2) DEFAULT 0, turnover_debet NUMERIC(15, 2) DEFAULT 0, turnover_credit NUMERIC(15, 2) DEFAULT 0, saldo_end_debet NUMERIC(15, 2) DEFAULT 0, saldo_end_credit NUMERIC(15, 2) DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_report_76_rows_report_id ON report_76_rows(report_id)`); await client.query(` CREATE TABLE IF NOT EXISTS building_personal_account_mappings ( id BIGSERIAL PRIMARY KEY, building_id VARCHAR(50) NOT NULL REFERENCES buildings(id) ON DELETE CASCADE, account_ls TEXT NOT NULL, account_label TEXT, apartment TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(building_id, account_ls) ) `); await client.query(`CREATE INDEX IF NOT EXISTS idx_building_personal_account_mappings_building ON building_personal_account_mappings(building_id)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_building_personal_account_mappings_account_ls ON building_personal_account_mappings(account_ls)`); } finally { client.release(); } } /** Проверяет/создаёт таблицу employee_responsibility (зоны ответственности сотрудников). */ async function ensureEmployeeResponsibilityTable() { const rows = await query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'employee_responsibility'` ); if (rows.length > 0) return; await 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 query(`CREATE INDEX IF NOT EXISTS idx_employee_responsibility_employee ON employee_responsibility(employee_id)`); await query(`CREATE INDEX IF NOT EXISTS idx_employee_responsibility_zone ON employee_responsibility(section, sub_id)`); } /** Добавляет колонку expected_return_date в employee_sick_leaves, если её нет (предварительная дата выхода). */ async function ensureSickLeaveExpectedReturnDateColumn() { const rows = await 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 (rows.length > 0) return; await query(`ALTER TABLE employee_sick_leaves ADD COLUMN IF NOT EXISTS expected_return_date DATE`); } /** Создаёт таблицу employee_districts (связь сотрудник — участки, многие ко многим), если её нет. */ async function ensureEmployeeDistrictsTable() { const rows = await query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'employee_districts'` ); if (rows.length > 0) { return; } console.log('[backend] Создаём таблицу employee_districts...'); await query(` CREATE TABLE employee_districts ( employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE, district_id VARCHAR(50) NOT NULL REFERENCES districts(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (employee_id, district_id) ) `); await query(`CREATE INDEX IF NOT EXISTS idx_employee_districts_employee ON employee_districts(employee_id)`); await query(`CREATE INDEX IF NOT EXISTS idx_employee_districts_district ON employee_districts(district_id)`); try { await query(` INSERT INTO employee_districts (employee_id, district_id) SELECT id, assigned_district_id FROM employees WHERE assigned_district_id IS NOT NULL AND assigned_district_id <> '' AND assigned_district_id IN (SELECT id FROM districts) ON CONFLICT (employee_id, district_id) DO NOTHING `); } catch (insertErr) { console.warn('[backend] employee_districts: миграция данных пропущена:', insertErr.message); } } /** * Перенос ответственности с уволенного сотрудника на его руководителя. * Вызывается при создании увольнения (employees.status = 'inactive'). */ async function transferResponsibilityToManager(employeeId) { try { const empRows = await query( 'SELECT id, name, manager_id FROM employees WHERE id = $1', [employeeId] ); if (empRows.length === 0) return; const employeeName = empRows[0].name; const managerId = empRows[0].manager_id; if (!managerId) { console.warn(`[transferResponsibility] У сотрудника ${employeeId} (${employeeName}) нет руководителя (manager_id). Перенос ответственности пропущен.`); return; } const managerRows = await query('SELECT name FROM employees WHERE id = $1', [managerId]); if (managerRows.length === 0) { console.warn(`[transferResponsibility] Руководитель ${managerId} не найден. Перенос ответственности пропущен.`); return; } const managerName = managerRows[0].name; await query( `UPDATE applications SET executor_name = $1, responsible_name = $1 WHERE executor_name = $2 OR responsible_name = $2`, [managerName, employeeName] ); await query('UPDATE incidents SET assigned_to = $1 WHERE assigned_to = $2', [managerName, employeeName]); await query('UPDATE pre_trial_work SET assigned_to = $1 WHERE assigned_to = $2', [managerName, employeeName]); await query('UPDATE office_equipment SET assigned_to = $1 WHERE assigned_to = $2', [managerName, employeeName]); await query('UPDATE office_documents SET assigned_to = $1 WHERE assigned_to = $2', [managerName, employeeName]); await query('UPDATE pr_event_assignees SET employee_id = $1 WHERE employee_id = $2', [managerId, employeeId]); console.log(`[transferResponsibility] Ответственность сотрудника ${employeeId} (${employeeName}) перенесена на руководителя ${managerId} (${managerName}).`); } catch (err) { console.error('[transferResponsibility] Ошибка переноса ответственности:', err); } } // ----------- RATE LIMIT ДЛЯ ВХОДА (E1: защита от брутфорса) ----------- const LOGIN_RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; // 15 минут const LOGIN_RATE_LIMIT_MAX_ATTEMPTS = 10; const loginAttempts = new Map(); // key: IP (string), value: { count, resetAt } function getClientIp(req) { return req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket?.remoteAddress || 'unknown'; } function loginRateLimitMiddleware(req, res, next) { if (req.method !== 'POST' || req.path !== '/auth/login') return next(); const ip = getClientIp(req); const now = Date.now(); const rec = loginAttempts.get(ip); if (rec && now < rec.resetAt && rec.count >= LOGIN_RATE_LIMIT_MAX_ATTEMPTS) { return res.status(429).json({ error: 'Слишком много неудачных попыток входа. Попробуйте позже.' }); } if (rec && now >= rec.resetAt) { loginAttempts.delete(ip); } req._loginRateLimitIp = ip; next(); } function recordLoginFailure(ip) { const now = Date.now(); const rec = loginAttempts.get(ip) || { count: 0, resetAt: now + LOGIN_RATE_LIMIT_WINDOW_MS }; if (now >= rec.resetAt) { rec.count = 0; rec.resetAt = now + LOGIN_RATE_LIMIT_WINDOW_MS; } rec.count++; loginAttempts.set(ip, rec); } function clearLoginAttempts(ip) { loginAttempts.delete(ip); } function maskLogin(login) { if (!login || typeof login !== 'string') return '***'; const s = String(login).trim(); if (s.length <= 2) return '***'; return s.substring(0, 2) + '***'; } async function isBlacklisted(ip, login) { try { const ipRows = await query('SELECT 1 FROM security_blacklist WHERE type = $1 AND LOWER(TRIM(value)) = LOWER(TRIM($2))', ['ip', ip]); if (ipRows.length > 0) return true; const loginStr = login && String(login).trim(); if (loginStr) { const loginRows = await query('SELECT 1 FROM security_blacklist WHERE type = $1 AND LOWER(TRIM(value)) = LOWER(TRIM($2))', ['login', loginStr]); if (loginRows.length > 0) return true; } } catch (e) { // если таблицы ещё нет — не блокируем } return false; } async function writeAuthLog(ip, loginMasked, success) { try { await query('INSERT INTO auth_logs (ip, login_masked, success) VALUES ($1, $2, $3)', [ip, loginMasked, success]); } catch (e) { // игнорируем ошибки записи лога (таблица может отсутствовать) } } // ----------- АВТОРИЗАЦИЯ ----------- // ROLE_ACCESS и SECTION_IDS из единого источника (constants/roleAccess.js) function requireAuth(req, res, next) { const authHeader = req.headers.authorization; const token = authHeader && authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null; if (!token) { return res.status(401).json({ error: 'Требуется авторизация' }); } try { const decoded = jwt.verify(token, JWT_SECRET); // Гость без учётки — доступ только к публичным отчётам и NPS-опросам if (decoded.role === 'GUEST') { const reportOk = req.method === 'GET' && /^\/pr\/reports\/[^/]+(\/data)?$/.test(req.path); const npsGetOk = req.method === 'GET' && /^\/pr\/nps-surveys\/[^/]+$/.test(req.path); const npsPostOk = req.method === 'POST' && /^\/pr\/nps-surveys\/[^/]+\/responses$/.test(req.path); const allowed = reportOk || npsGetOk || npsPostOk; if (!allowed) { return res.status(403).json({ error: 'Доступ запрещён для гостя' }); } } req.user = decoded; next(); } catch (err) { return res.status(401).json({ error: 'Недействительный или истёкший токен' }); } } function authMiddleware(req, res, next) { if (req.method === 'POST' && req.path === '/auth/login') return next(); if (req.method === 'GET' && (req.path === '/health/db' || req.path === '/health')) return next(); // Файлы фото мероприятий — для отображения в img (без auth в заголовках) if (req.method === 'GET' && /^\/pr\/events\/\d+\/photos\/file\/[^/]+$/.test(req.path)) return next(); // Синхронизация Doma: без авторизации, если передан секрет (для cron). Секрет задаётся в DOMA_SYNC_SECRET. const domaSyncSecret = process.env.DOMA_SYNC_SECRET; if ((req.path === '/doma/sync-now') && domaSyncSecret && req.query && req.query.secret === domaSyncSecret) return next(); requireAuth(req, res, next); } app.use(API_PREFIX, loginRateLimitMiddleware); // POST /api/auth/login — без auth app.post(`${API_PREFIX}/auth/login`, async (req, res) => { const rateLimitIp = req._loginRateLimitIp; const clientIp = rateLimitIp || getClientIp(req); try { const { login, password, captchaToken } = req.body || {}; if (!login || !password) { return res.status(400).json({ error: 'Укажите логин и пароль' }); } if (await isBlacklisted(clientIp, login)) { await writeAuthLog(clientIp, maskLogin(login), false); return res.status(403).json({ error: 'Доступ запрещён' }); } let turnstileSecret = null; try { const captchaRows = await query('SELECT secret_key FROM security_captcha WHERE id = 1'); const fromDb = captchaRows[0] && captchaRows[0].secret_key && String(captchaRows[0].secret_key).trim(); turnstileSecret = fromDb || process.env.TURNSTILE_SECRET_KEY || null; } catch (e) { turnstileSecret = process.env.TURNSTILE_SECRET_KEY || null; } if (turnstileSecret) { if (!captchaToken || !String(captchaToken).trim()) { return res.status(400).json({ error: 'Пройдите проверку (капча)' }); } try { const verifyRes = await axios.post( 'https://challenges.cloudflare.com/turnstile/v0/siteverify', new URLSearchParams({ secret: turnstileSecret, response: String(captchaToken).trim(), }), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 10000 } ); if (!verifyRes.data || !verifyRes.data.success) { return res.status(400).json({ error: 'Капча не пройдена. Попробуйте ещё раз.' }); } } catch (verifyErr) { console.error('Turnstile verify error:', verifyErr.message); return res.status(400).json({ error: 'Ошибка проверки капчи' }); } } const rows = await query( `SELECT pu.id, pu.employee_id AS "employeeId", pu.login, pu.email, pu.role, pu.password_hash AS "passwordHash", pu.is_active AS "isActive", e.name AS "employeeName", e.position AS "employeePosition", e.photo_url AS "photoUrl" FROM portal_users pu JOIN employees e ON e.id = pu.employee_id WHERE pu.login = $1`, [String(login).trim()] ); if (rows.length === 0) { if (rateLimitIp) recordLoginFailure(rateLimitIp); await writeAuthLog(clientIp, maskLogin(login), false); return res.status(401).json({ error: 'Неверный логин или пароль' }); } const row = rows[0]; if (row.isActive === false) { if (rateLimitIp) recordLoginFailure(rateLimitIp); await writeAuthLog(clientIp, maskLogin(login), false); return res.status(401).json({ error: 'Учётная запись отключена' }); } if (!row.passwordHash) { if (rateLimitIp) recordLoginFailure(rateLimitIp); await writeAuthLog(clientIp, maskLogin(login), false); return res.status(401).json({ error: 'Пароль не задан. Обратитесь к администратору.' }); } const match = await bcrypt.compare(String(password), row.passwordHash); if (!match) { if (rateLimitIp) recordLoginFailure(rateLimitIp); await writeAuthLog(clientIp, maskLogin(login), false); return res.status(401).json({ error: 'Неверный логин или пароль' }); } if (rateLimitIp) clearLoginAttempts(rateLimitIp); await writeAuthLog(clientIp, maskLogin(login), true); const payload = { userId: row.id, employeeId: row.employeeId, role: row.role, login: row.login }; const token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); res.json({ token, user: { id: row.employeeId, userId: row.id, name: row.employeeName, role: row.role, avatar: row.photoUrl ? `/uploads/photos/${path.basename(row.photoUrl)}` : null } }); } catch (err) { if (rateLimitIp) recordLoginFailure(rateLimitIp); const login = (req.body && req.body.login) || ''; await writeAuthLog(clientIp, maskLogin(login), false); console.error('Error in auth/login:', err.message || err); res.status(500).json({ error: 'Ошибка входа' }); } }); // GET /api/auth/captcha-site-key — публичный ключ капчи для формы входа (без авторизации) app.get(`${API_PREFIX}/auth/captcha-site-key`, async (req, res) => { try { const rows = await query('SELECT site_key FROM security_captcha WHERE id = 1'); const siteKey = (rows[0] && rows[0].site_key && String(rows[0].site_key).trim()) || process.env.VITE_TURNSTILE_SITE_KEY || null; res.json({ siteKey }); } catch (err) { res.json({ siteKey: process.env.VITE_TURNSTILE_SITE_KEY || null }); } }); // POST /api/auth/guest-token — гостевой токен без учётки (для просмотра публичных отчётов) const GUEST_TOKEN_EXPIRES_IN = process.env.JWT_GUEST_EXPIRES_IN || '24h'; app.post(`${API_PREFIX}/auth/guest-token`, async (req, res) => { try { const token = jwt.sign( { role: 'GUEST' }, JWT_SECRET, { expiresIn: GUEST_TOKEN_EXPIRES_IN } ); res.json({ token }); } catch (err) { console.error('Error issuing guest token:', err); res.status(500).json({ error: 'Ошибка выдачи гостевого токена' }); } }); // Все остальные /api/* требуют авторизации app.use(API_PREFIX, authMiddleware); // Роутеры по доменам (разбиение монолита server.js) const createBuildingsRouter = require('./routes/buildings'); app.use(API_PREFIX, createBuildingsRouter({ query })); // allowedSections из детальных прав (SECTION_IDS из constants/roleAccess.js) function allowedSectionsFromPermissions(permissions) { if (!permissions || !Array.isArray(permissions) || permissions.length === 0) return null; if (permissions.includes('all')) return ['dashboard', 'objects', 'requests', 'pr', 'finance', 'legal', 'development', 'hr', 'office', 'admin']; const set = new Set(); for (const p of permissions) { if (SECTION_IDS.includes(p)) set.add(p); else if (typeof p === 'string' && p.includes('_')) { const section = p.split('_')[0]; if (SECTION_IDS.includes(section)) set.add(section); } } return Array.from(set); } // Проверка доступа к разделу: загружаем allowedSections по userId и возвращаем 403, если раздела нет function requireSection(section) { return async (req, res, next) => { if (!req.user || !req.user.userId) { return res.status(401).json({ error: 'Требуется авторизация' }); } try { const rows = await query( 'SELECT permissions, role FROM portal_users WHERE id = $1', [req.user.userId] ); if (rows.length === 0) { return res.status(403).json({ error: 'Доступ к разделу запрещён' }); } const row = rows[0]; const hasCustom = row.permissions && Array.isArray(row.permissions) && row.permissions.length > 0; let allowedSections; if (hasCustom) { allowedSections = allowedSectionsFromPermissions(row.permissions); } else { const allowed = ROLE_ACCESS[row.role]; allowedSections = allowed && allowed.includes('all') ? SECTION_IDS : (allowed || []); } if (!allowedSections || !allowedSections.includes(section)) { return res.status(403).json({ error: 'Доступ к разделу запрещён' }); } next(); } catch (err) { console.error('requireSection error:', err); res.status(500).json({ error: 'Ошибка проверки доступа' }); } }; } app.use(API_PREFIX + '/finance', requireSection('finance')); app.use(API_PREFIX + '/office', requireSection('office')); app.use(API_PREFIX + '/pr', requireSection('pr')); // GET /api/auth/me app.get(`${API_PREFIX}/auth/me`, async (req, res) => { try { const { userId, employeeId, role } = req.user; const rows = await query( `SELECT pu.id, pu.employee_id AS "employeeId", pu.login, pu.email, pu.role, pu.permissions, pu.scope, pu.phone AS "puPhone", pu.given_name AS "givenName", pu.family_name AS "familyName", pu.birth_date AS "puBirthDate", pu.language, pu.theme, pu.notification_email AS "notificationEmail", pu.notification_push AS "notificationPush", pu.email_verified AS "emailVerified", pu.last_login AS "lastLogin", pu.created_at AS "createdAt", pu.updated_at AS "updatedAt", e.name AS "employeeName", e.position AS "employeePosition", e.photo_url AS "photoUrl", e.phone AS "employeePhone", e.birth_date AS "employeeBirthDate", e.assigned_district_id AS "assignedDistrictId" FROM portal_users pu JOIN employees e ON e.id = pu.employee_id WHERE pu.id = $1`, [userId] ); if (rows.length === 0) { return res.status(401).json({ error: 'Пользователь не найден' }); } const row = rows[0]; let assignedDistrictIds = []; try { const edRows = await query('SELECT district_id AS "districtId" FROM employee_districts WHERE employee_id = $1 ORDER BY district_id', [row.employeeId]); assignedDistrictIds = edRows.map((r) => r.districtId); } catch (_) {} if (assignedDistrictIds.length === 0 && row.assignedDistrictId) { assignedDistrictIds = [row.assignedDistrictId]; } const hasCustomPermissions = row.permissions && Array.isArray(row.permissions) && row.permissions.length > 0; let allowedSections; if (hasCustomPermissions) { allowedSections = allowedSectionsFromPermissions(row.permissions); } else { const allowed = ROLE_ACCESS[row.role]; allowedSections = allowed && allowed.includes('all') ? SECTION_IDS : (allowed || []); } const scope = (row.scope === 'own_district') ? 'own_district' : 'all'; const avatarUrl = row.photoUrl ? `/uploads/photos/${path.basename(row.photoUrl)}` : null; // #region agent log try { const debugLogPath = path.join(__dirname, '..', '.cursor', 'debug.log'); fs.appendFileSync(debugLogPath, JSON.stringify({ location: 'server.js:GET/auth/me', message: 'user data', data: { userId: row.id, role: row.role, scope: row.scope, assignedDistrictId: row.assignedDistrictId }, timestamp: Date.now(), sessionId: 'debug-session', hypothesisId: 'F' }) + '\n'); } catch (_) {} // #endregion res.json({ id: row.employeeId, userId: row.id, name: row.employeeName, givenName: row.givenName || null, familyName: row.familyName || null, email: row.email || '', emailVerified: !!row.emailVerified, phone: row.puPhone || row.employeePhone || null, role: row.role, avatar: avatarUrl, birthDate: row.puBirthDate ? String(row.puBirthDate).slice(0, 10) : (row.employeeBirthDate ? String(row.employeeBirthDate).slice(0, 10) : null), position: row.employeePosition || null, assignedDistrictIds: assignedDistrictIds.length ? assignedDistrictIds : null, assignedDistrictId: assignedDistrictIds[0] || row.assignedDistrictId || null, language: row.language || 'ru', theme: row.theme || 'light', notificationEmail: row.notificationEmail !== false, notificationPush: row.notificationPush !== false, lastLogin: row.lastLogin || null, createdAt: row.createdAt || null, updatedAt: row.updatedAt || null, allowedSections, permissions: hasCustomPermissions ? row.permissions : null, scope, }); } catch (err) { console.error('Error in auth/me:', err); res.status(500).json({ error: 'Ошибка получения профиля' }); } }); // ----- Notifications (колокольчик): только свои уведомления ----- // GET /api/notifications — список для текущего пользователя (непрочитанные сначала, пагинация) app.get(`${API_PREFIX}/notifications`, async (req, res) => { try { const userId = req.user.userId; const limit = Math.min(parseInt(req.query.limit, 10) || 50, 100); const offset = parseInt(req.query.offset, 10) || 0; const typeFilter = req.query.type ? String(req.query.type).trim() : null; const params = [userId]; if (typeFilter) params.push(typeFilter); params.push(limit, offset); const whereClause = typeFilter ? ' AND type = $2' : ''; const limitPlace = typeFilter ? 3 : 2; const offsetPlace = typeFilter ? 4 : 3; const rows = await pool.query( `SELECT id, type, title, body, entity_type AS "entityType", entity_id AS "entityId", payload, read_at AS "readAt", created_at AS "createdAt" FROM notifications WHERE user_id = $1${whereClause} ORDER BY read_at ASC NULLS FIRST, created_at DESC LIMIT $${limitPlace} OFFSET $${offsetPlace}`, params ); const unreadCount = await pool.query( 'SELECT COUNT(*)::int AS c FROM notifications WHERE user_id = $1 AND read_at IS NULL', [userId] ); res.setHeader('X-Unread-Count', String(unreadCount.rows[0].c)); res.json(rows.rows); } catch (err) { console.error('Error GET /notifications:', err); res.status(500).json({ error: 'Ошибка загрузки уведомлений' }); } }); // GET /api/notifications/unread-count — только счётчик непрочитанных app.get(`${API_PREFIX}/notifications/unread-count`, async (req, res) => { try { const userId = req.user.userId; const r = await pool.query( 'SELECT COUNT(*)::int AS c FROM notifications WHERE user_id = $1 AND read_at IS NULL', [userId] ); res.json({ count: r.rows[0].c }); } catch (err) { console.error('Error GET /notifications/unread-count:', err); res.status(500).json({ count: 0 }); } }); // PATCH /api/notifications/:id — пометить прочитанным (только своё) app.patch(`${API_PREFIX}/notifications/:id`, async (req, res) => { try { const userId = req.user.userId; const id = parseInt(req.params.id, 10); if (Number.isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); const r = await pool.query( `UPDATE notifications SET read_at = NOW() WHERE id = $1 AND user_id = $2 RETURNING id`, [id, userId] ); if (r.rows.length === 0) return res.status(404).json({ error: 'Уведомление не найдено' }); res.json({ id: r.rows[0].id, readAt: new Date().toISOString() }); } catch (err) { console.error('Error PATCH /notifications/:id:', err); res.status(500).json({ error: 'Ошибка обновления уведомления' }); } }); // PATCH /api/notifications/read-all — пометить все прочитанными для текущего пользователя app.patch(`${API_PREFIX}/notifications/read-all`, async (req, res) => { try { const userId = req.user.userId; await pool.query( 'UPDATE notifications SET read_at = NOW() WHERE user_id = $1 AND read_at IS NULL', [userId] ); res.json({ success: true }); } catch (err) { console.error('Error PATCH /notifications/read-all:', err); res.status(500).json({ error: 'Ошибка обновления уведомлений' }); } }); // PUT /api/auth/profile — обновление профиля текущего пользователя app.put(`${API_PREFIX}/auth/profile`, async (req, res) => { try { const { userId, employeeId } = req.user; const body = req.body || {}; const updates = []; const values = []; let idx = 1; // Поля в employees: name, phone, birth_date if (body.name !== undefined) { updates.push(`name = $${idx++}`); values.push(String(body.name).trim()); } if (body.phone !== undefined) { updates.push(`phone = $${idx++}`); values.push(String(body.phone).trim() || null); } if (body.birthDate !== undefined) { updates.push(`birth_date = $${idx++}`); values.push(body.birthDate ? String(body.birthDate).trim() : null); } if (updates.length > 0) { values.push(employeeId); await query( `UPDATE employees SET ${updates.join(', ')}, updated_at = NOW() WHERE id = $${idx}`, values ); } // Поля в portal_users: email, given_name, family_name, language, theme, notification_email, notification_push const puUpdates = []; const puValues = []; let puIdx = 1; if (body.email !== undefined) { const email = String(body.email).trim() || null; if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return res.status(400).json({ error: 'Некорректный формат email' }); } puUpdates.push(`email = $${puIdx++}`); puValues.push(email); } if (body.givenName !== undefined) { puUpdates.push(`given_name = $${puIdx++}`); puValues.push(body.givenName ? String(body.givenName).trim() : null); } if (body.familyName !== undefined) { puUpdates.push(`family_name = $${puIdx++}`); puValues.push(body.familyName ? String(body.familyName).trim() : null); } if (body.language !== undefined) { puUpdates.push(`language = $${puIdx++}`); puValues.push(String(body.language).slice(0, 10) || 'ru'); } if (body.theme !== undefined) { puUpdates.push(`theme = $${puIdx++}`); puValues.push(['light', 'dark', 'system'].includes(body.theme) ? body.theme : 'light'); } if (body.notificationEmail !== undefined) { puUpdates.push(`notification_email = $${puIdx++}`); puValues.push(!!body.notificationEmail); } if (body.notificationPush !== undefined) { puUpdates.push(`notification_push = $${puIdx++}`); puValues.push(!!body.notificationPush); } if (puUpdates.length > 0) { puValues.push(userId); await query( `UPDATE portal_users SET ${puUpdates.join(', ')}, updated_at = NOW() WHERE id = $${puIdx}`, puValues ); } // Возвращаем обновлённый профиль через auth/me логику const rows = await query( `SELECT pu.id, pu.employee_id AS "employeeId", pu.login, pu.email, pu.role, pu.permissions, pu.scope, pu.phone AS "puPhone", pu.given_name AS "givenName", pu.family_name AS "familyName", pu.birth_date AS "puBirthDate", pu.language, pu.theme, pu.notification_email AS "notificationEmail", pu.notification_push AS "notificationPush", pu.email_verified AS "emailVerified", pu.last_login AS "lastLogin", pu.created_at AS "createdAt", pu.updated_at AS "updatedAt", e.name AS "employeeName", e.position AS "employeePosition", e.photo_url AS "photoUrl", e.phone AS "employeePhone", e.birth_date AS "employeeBirthDate", e.assigned_district_id AS "assignedDistrictId" FROM portal_users pu JOIN employees e ON e.id = pu.employee_id WHERE pu.id = $1`, [userId] ); if (rows.length === 0) { return res.status(401).json({ error: 'Пользователь не найден' }); } const row = rows[0]; const hasCustomPermissions = row.permissions && Array.isArray(row.permissions) && row.permissions.length > 0; let allowedSections; if (hasCustomPermissions) { allowedSections = allowedSectionsFromPermissions(row.permissions); } else { const allowed = ROLE_ACCESS[row.role]; allowedSections = allowed && allowed.includes('all') ? SECTION_IDS : (allowed || []); } const scope = (row.scope === 'own_district') ? 'own_district' : 'all'; const avatarUrl = row.photoUrl ? `/uploads/photos/${path.basename(row.photoUrl)}` : null; res.json({ id: row.employeeId, userId: row.id, name: row.employeeName, givenName: row.givenName || null, familyName: row.familyName || null, email: row.email || '', emailVerified: !!row.emailVerified, phone: row.puPhone || row.employeePhone || null, role: row.role, avatar: avatarUrl, birthDate: row.puBirthDate ? String(row.puBirthDate).slice(0, 10) : (row.employeeBirthDate ? String(row.employeeBirthDate).slice(0, 10) : null), position: row.employeePosition || null, assignedDistrictId: row.assignedDistrictId || null, language: row.language || 'ru', theme: row.theme || 'light', notificationEmail: row.notificationEmail !== false, notificationPush: row.notificationPush !== false, lastLogin: row.lastLogin || null, createdAt: row.createdAt || null, updatedAt: row.updatedAt || null, allowedSections, permissions: hasCustomPermissions ? row.permissions : null, scope, }); } catch (err) { console.error('Error in auth/profile:', err); res.status(500).json({ error: 'Ошибка обновления профиля' }); } }); // POST /api/auth/profile/photo — загрузка фото профиля текущего пользователя app.post(`${API_PREFIX}/auth/profile/photo`, uploadPhoto.single('photo'), async (req, res) => { try { const { employeeId } = req.user; if (!req.file) { return res.status(400).json({ error: 'Файл не загружен' }); } const existing = await query('SELECT id, photo_url FROM employees WHERE id = $1', [employeeId]); if (existing.length === 0) { fs.unlink(req.file.path, (err) => { if (err) console.error('Ошибка удаления файла:', err); }); return res.status(404).json({ error: 'Сотрудник не найден' }); } const oldPhotoUrl = existing[0].photo_url; if (oldPhotoUrl) { const oldPath = path.join(__dirname, oldPhotoUrl.replace(/^\//, '').replace('uploads/', 'uploads/')); if (fs.existsSync(oldPath)) { fs.unlink(oldPath, (err) => { if (err) console.error('Ошибка удаления старого фото:', err); }); } } const photoUrl = `/uploads/photos/${req.file.filename}`; await query('UPDATE employees SET photo_url = $1, updated_at = NOW() WHERE id = $2', [photoUrl, employeeId]); res.json({ success: true, photoUrl }); } catch (err) { console.error('Error uploading profile photo:', err); res.status(500).json({ error: 'Ошибка загрузки фото' }); } }); // DELETE /api/auth/profile/photo — удаление фото профиля app.delete(`${API_PREFIX}/auth/profile/photo`, async (req, res) => { try { const { employeeId } = req.user; const employee = await query('SELECT photo_url FROM employees WHERE id = $1', [employeeId]); if (employee.length === 0) { return res.status(404).json({ error: 'Сотрудник не найден' }); } if (employee[0].photo_url) { const filePath = path.join(__dirname, employee[0].photo_url.replace(/^\//, '').replace(/^uploads\//, 'uploads/')); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } } await query('UPDATE employees SET photo_url = NULL, updated_at = NOW() WHERE id = $1', [employeeId]); res.json({ success: true }); } catch (err) { console.error('Error deleting profile photo:', err); res.status(500).json({ error: 'Ошибка удаления фото' }); } }); // PUT /api/auth/change-password — смена пароля текущего пользователя app.put(`${API_PREFIX}/auth/change-password`, async (req, res) => { try { const { userId } = req.user; const { oldPassword, newPassword } = req.body || {}; if (!oldPassword || !newPassword) { return res.status(400).json({ error: 'Укажите старый и новый пароль' }); } if (String(newPassword).length < 8) { return res.status(400).json({ error: 'Новый пароль должен содержать не менее 8 символов' }); } if (!/^(?=.*[a-zA-Z])(?=.*\d)/.test(String(newPassword))) { return res.status(400).json({ error: 'Пароль должен содержать буквы и цифры' }); } const rows = await query('SELECT password_hash AS "passwordHash" FROM portal_users WHERE id = $1', [userId]); if (rows.length === 0) { return res.status(404).json({ error: 'Пользователь не найден' }); } const match = await bcrypt.compare(String(oldPassword), rows[0].passwordHash || ''); if (!match) { return res.status(401).json({ error: 'Неверный текущий пароль' }); } const passwordHash = await bcrypt.hash(String(newPassword), 10); await query( 'UPDATE portal_users SET password_hash = $1, password_changed_at = NOW(), updated_at = NOW() WHERE id = $2', [passwordHash, userId] ); res.json({ success: true, message: 'Пароль успешно изменён' }); } catch (err) { console.error('Error changing password:', err); res.status(500).json({ error: 'Ошибка смены пароля' }); } }); // GET /api/auth/preferences — настройки текущего пользователя app.get(`${API_PREFIX}/auth/preferences`, async (req, res) => { try { const { userId } = req.user; const rows = await query( `SELECT language, theme, notification_email AS "notificationEmail", notification_push AS "notificationPush" FROM portal_users WHERE id = $1`, [userId] ); if (rows.length === 0) { return res.status(404).json({ error: 'Пользователь не найден' }); } const r = rows[0]; res.json({ language: r.language || 'ru', theme: r.theme || 'light', notificationEmail: r.notificationEmail !== false, notificationPush: r.notificationPush !== false }); } catch (err) { console.error('Error fetching preferences:', err); res.status(500).json({ error: 'Ошибка получения настроек' }); } }); // PUT /api/auth/preferences — обновление настроек app.put(`${API_PREFIX}/auth/preferences`, async (req, res) => { try { const { userId } = req.user; const body = req.body || {}; const updates = []; const values = []; let idx = 1; if (body.language !== undefined) { updates.push(`language = $${idx++}`); values.push(String(body.language).slice(0, 10) || 'ru'); } if (body.theme !== undefined) { updates.push(`theme = $${idx++}`); values.push(['light', 'dark', 'system'].includes(body.theme) ? body.theme : 'light'); } if (body.notificationEmail !== undefined) { updates.push(`notification_email = $${idx++}`); values.push(!!body.notificationEmail); } if (body.notificationPush !== undefined) { updates.push(`notification_push = $${idx++}`); values.push(!!body.notificationPush); } if (updates.length === 0) { return res.status(400).json({ error: 'Нет данных для обновления' }); } values.push(userId); await query(`UPDATE portal_users SET ${updates.join(', ')}, updated_at = NOW() WHERE id = $${idx}`, values); const rows = await query( `SELECT language, theme, notification_email AS "notificationEmail", notification_push AS "notificationPush" FROM portal_users WHERE id = $1`, [userId] ); res.json({ language: rows[0]?.language || 'ru', theme: rows[0]?.theme || 'light', notificationEmail: rows[0]?.notificationEmail !== false, notificationPush: rows[0]?.notificationPush !== false }); } catch (err) { console.error('Error updating preferences:', err); res.status(500).json({ error: 'Ошибка обновления настроек' }); } }); // ========= ИИ-ЧАТ ========= // GET /api/ai/status — включён ли ИИ (для отображения кнопки в интерфейсе) app.get(`${API_PREFIX}/ai/status`, async (req, res) => { try { const rows = await query( 'SELECT enabled, config FROM integration_settings WHERE key = $1', ['ai_chat'] ); if (rows.length === 0) { return res.json({ enabled: false }); } const r = rows[0]; const hasUrl = r.config && r.config.url && String(r.config.url).trim(); res.json({ enabled: r.enabled !== false && !!hasUrl }); } catch (err) { res.json({ enabled: false }); } }); // GET /api/ai/conversations — список диалогов текущего пользователя app.get(`${API_PREFIX}/ai/conversations`, async (req, res) => { try { const userId = req.user.userId; const rows = await query( `SELECT id, title, created_at AS "createdAt" FROM ai_conversations WHERE user_id = $1 ORDER BY created_at DESC`, [userId] ); res.json(rows); } catch (err) { console.error('Error fetching AI conversations:', err); res.status(500).json({ error: 'Ошибка загрузки диалогов' }); } }); // POST /api/ai/conversations — создать диалог app.post(`${API_PREFIX}/ai/conversations`, async (req, res) => { try { const userId = req.user.userId; const { title } = req.body || {}; const result = await query( `INSERT INTO ai_conversations (user_id, title) VALUES ($1, $2) RETURNING id, title, created_at AS "createdAt"`, [userId, title || null] ); res.status(201).json(result[0]); } catch (err) { console.error('Error creating AI conversation:', err); res.status(500).json({ error: 'Ошибка создания диалога' }); } }); // GET /api/ai/conversations/:id/messages — сообщения диалога (только свой) app.get(`${API_PREFIX}/ai/conversations/:id/messages`, async (req, res) => { try { const userId = req.user.userId; const conversationId = parseInt(req.params.id, 10); if (isNaN(conversationId)) { return res.status(400).json({ error: 'Некорректный id диалога' }); } const conv = await query( 'SELECT id FROM ai_conversations WHERE id = $1 AND user_id = $2', [conversationId, userId] ); if (conv.length === 0) { return res.status(404).json({ error: 'Диалог не найден' }); } const rows = await query( `SELECT id, role, content, tool_calls_json AS "toolCallsJson", created_at AS "createdAt" FROM ai_messages WHERE conversation_id = $1 ORDER BY created_at ASC`, [conversationId] ); res.json(rows); } catch (err) { console.error('Error fetching AI messages:', err); res.status(500).json({ error: 'Ошибка загрузки сообщений' }); } }); // POST /api/ai/chat — отправить сообщение, получить ответ от ИИ (с опциональными tool_calls) app.post(`${API_PREFIX}/ai/chat`, async (req, res) => { try { const userId = req.user.userId; const { conversationId: rawConvId, message } = req.body || {}; if (!message || typeof message !== 'string' || !message.trim()) { return res.status(400).json({ error: 'Укажите текст сообщения' }); } let conversationId = rawConvId != null ? parseInt(rawConvId, 10) : null; if (conversationId != null && isNaN(conversationId)) { return res.status(400).json({ error: 'Некорректный id диалога' }); } if (conversationId == null) { const created = await query( 'INSERT INTO ai_conversations (user_id) VALUES ($1) RETURNING id', [userId] ); conversationId = created[0].id; } else { const own = await query( 'SELECT id FROM ai_conversations WHERE id = $1 AND user_id = $2', [conversationId, userId] ); if (own.length === 0) { return res.status(404).json({ error: 'Диалог не найден' }); } } await query( `INSERT INTO ai_messages (conversation_id, role, content) VALUES ($1, 'user', $2)`, [conversationId, message.trim()] ); let aiChatUrl = process.env.AI_CHAT_URL || 'https://ai.iieasy.ru/v1/chat/completions'; let aiApiKey = process.env.AI_API_KEY || ''; try { const aiRows = await query( 'SELECT enabled, config FROM integration_settings WHERE key = $1', ['ai_chat'] ); if (aiRows.length) { const row = aiRows[0]; if (row.enabled === false) { aiChatUrl = ''; } else if (row.config && row.config.url && String(row.config.url).trim()) { aiChatUrl = String(row.config.url).trim(); aiApiKey = row.config.apiKey != null ? String(row.config.apiKey) : ''; } } } catch (e) { // keep env defaults } const baseUrl = process.env.BACKEND_BASE_URL || `http://localhost:${PORT}`; const getTokenForUser = (u) => jwt.sign( { userId: u.userId, employeeId: u.employeeId, role: u.role }, JWT_SECRET, { expiresIn: '5m' } ); let userContext = { userName: 'Пользователь', role: '', allowedSections: [] }; try { const meRows = await query( `SELECT e.name, pu.role, pu.permissions FROM portal_users pu JOIN employees e ON e.id = pu.employee_id WHERE pu.id = $1`, [userId] ); if (meRows.length) { const r = meRows[0]; let allowed = r.permissions && Array.isArray(r.permissions) && r.permissions.length ? (r.permissions.includes('all') ? ['dashboard', 'objects', 'requests', 'pr', 'finance', 'legal', 'development', 'hr', 'office', 'admin'] : r.permissions) : (ROLE_ACCESS[r.role] || []); if (allowed && allowed.includes('all')) { allowed = ['dashboard', 'objects', 'requests', 'pr', 'finance', 'legal', 'development', 'hr', 'office', 'admin']; } userContext = { userName: r.name || 'Пользователь', role: r.role || '', allowedSections: allowed || [] }; } } catch (e) { // keep defaults } let assistantContent; let toolResults = []; const aiEnabled = !!aiChatUrl; if (aiEnabled) { try { const result = await aiChatService.getAIResponse({ query, conversationId, newUserMessage: message.trim(), user: req.user, userContext, runToolContext: { baseUrl, apiPrefix: API_PREFIX, getTokenForUser }, aiChatUrl, aiApiKey }); assistantContent = result.assistantMessage || 'Нет ответа.'; toolResults = result.toolResults || []; } catch (aiErr) { console.error('AI chat error:', aiErr.message || aiErr); assistantContent = 'ИИ временно недоступен. Попробуйте позже. Ваше сообщение сохранено.'; } } else { assistantContent = 'ИИ выключен или не настроен. Включите и укажите адрес в Панели управления → ИИ. Ваше сообщение сохранено.'; } await query( `INSERT INTO ai_messages (conversation_id, role, content) VALUES ($1, 'assistant', $2)`, [conversationId, assistantContent] ); res.json({ conversationId, assistantMessage: assistantContent, toolResults }); } catch (err) { console.error('Error in AI chat:', err); res.status(500).json({ error: 'Ошибка отправки сообщения' }); } }); // ----------- API МАРШРУТЫ ----------- // Кэш GET /api/districts (30 сек), инвалидация при изменении участков const DISTRICTS_CACHE_TTL_MS = 30 * 1000; let districtsCache = { data: null, expiresAt: 0 }; function invalidateDistrictsCache() { districtsCache = { data: null, expiresAt: 0 }; } // GET /api/districts -> список участков app.get(`${API_PREFIX}/districts`, async (req, res) => { try { const now = Date.now(); if (districtsCache.data !== null && now < districtsCache.expiresAt) { return res.json(districtsCache.data); } const rows = await query( `SELECT id, name, manager_name AS "managerName", COALESCE(inventory, '[]'::jsonb) AS inventory FROM districts ORDER BY id` ); // #region agent log try { const debugLogPath = path.join(__dirname, '..', '.cursor', 'debug.log'); fs.appendFileSync(debugLogPath, JSON.stringify({ location: 'server.js:GET/districts', message: 'districts list', data: { count: rows.length, districtIds: rows.map(r => r.id) }, timestamp: Date.now(), sessionId: 'debug-session', hypothesisId: 'G' }) + '\n'); } catch (_) {} // #endregion districtsCache = { data: rows, expiresAt: now + DISTRICTS_CACHE_TTL_MS }; res.json(rows); } catch (err) { console.error('Error fetching districts:', err); res.status(500).json({ error: 'Failed to fetch districts' }); } }); // Значение по умолчанию для «начальник участка не назначен» const DISTRICT_MANAGER_UNASSIGNED = 'Не назначен'; // POST /api/districts -> создание нового участка (managerName может быть пустым — тогда «Не назначен») app.post(`${API_PREFIX}/districts`, async (req, res) => { const { name, managerName, inventory } = req.body || {}; if (!name || typeof name !== 'string' || !name.trim()) { return res.status(400).json({ error: 'name обязателен' }); } const id = `d-${Date.now()}`; const manager = (managerName != null && String(managerName).trim()) ? String(managerName).trim() : DISTRICT_MANAGER_UNASSIGNED; try { await query( 'INSERT INTO districts (id, name, manager_name, inventory) VALUES ($1, $2, $3, $4)', [id, name.trim(), manager, JSON.stringify(inventory || [])] ); invalidateDistrictsCache(); res.status(201).json({ id, name: name.trim(), managerName: manager, inventory: inventory || [] }); } catch (err) { console.error('Error creating district:', err); res.status(500).json({ error: 'Failed to create district' }); } }); // PUT /api/districts/:id -> обновление участка (включая inventory) app.put(`${API_PREFIX}/districts/:id`, async (req, res) => { const { id } = req.params; const { name, managerName, inventory } = req.body || {}; try { const updates = []; const values = []; let paramIndex = 1; if (name !== undefined) { updates.push(`name = $${paramIndex++}`); values.push(name); } if (managerName !== undefined) { updates.push(`manager_name = $${paramIndex++}`); values.push(managerName); } if (inventory !== undefined) { updates.push(`inventory = $${paramIndex++}`); values.push(JSON.stringify(inventory)); } if (updates.length === 0) { return res.status(400).json({ error: 'Нет полей для обновления' }); } values.push(id); const queryText = `UPDATE districts SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING id, name, manager_name AS "managerName", COALESCE(inventory, '[]'::jsonb) AS inventory`; const result = await query(queryText, values); if (result.length === 0) { return res.status(404).json({ error: 'Участок не найден' }); } invalidateDistrictsCache(); res.json(result[0]); } catch (err) { console.error('Error updating district:', err); res.status(500).json({ error: 'Failed to update district' }); } }); // DELETE /api/districts/:id -> удаление участка app.delete(`${API_PREFIX}/districts/:id`, async (req, res) => { const { id } = req.params; try { // Проверяем, есть ли дома, привязанные к этому участку const buildingsResult = await query( `SELECT COUNT(*) as count FROM buildings WHERE data->>'districtId' = $1`, [id] ); const buildingCount = parseInt(buildingsResult[0].count); if (buildingCount > 0) { return res.status(400).json({ error: `Невозможно удалить участок: к нему привязано ${buildingCount} домов. Сначала переместите дома в другой участок.` }); } // Удаляем участок const deleteResult = await query( 'DELETE FROM districts WHERE id = $1 RETURNING id', [id] ); if (deleteResult.length === 0) { return res.status(404).json({ error: 'Участок не найден' }); } invalidateDistrictsCache(); res.json({ success: true, message: 'Участок удален' }); } catch (err) { console.error('Error deleting district:', err); res.status(500).json({ error: 'Failed to delete district' }); } }); // ----------- POSITIONS (справочник должностей) ----------- // GET /api/positions -> список должностей app.get(`${API_PREFIX}/positions`, async (req, res) => { try { const rows = await query( `SELECT id, name, is_managerial AS "isManagerial", sort_order AS "sortOrder", created_at AS "createdAt", updated_at AS "updatedAt" FROM positions ORDER BY sort_order ASC, name ASC` ); res.json(rows); } catch (err) { console.error('Error fetching positions:', err); res.status(500).json({ error: 'Failed to fetch positions' }); } }); // POST /api/positions -> создание должности app.post(`${API_PREFIX}/positions`, async (req, res) => { const { name, isManagerial, sortOrder } = req.body || {}; if (!name || typeof name !== 'string' || !name.trim()) { return res.status(400).json({ error: 'name обязателен' }); } const id = `pos-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; try { await query( 'INSERT INTO positions (id, name, is_managerial, sort_order) VALUES ($1, $2, $3, $4)', [id, name.trim(), !!isManagerial, typeof sortOrder === 'number' ? sortOrder : 0] ); const [row] = await query( `SELECT id, name, is_managerial AS "isManagerial", sort_order AS "sortOrder" FROM positions WHERE id = $1`, [id] ); res.status(201).json(row); } catch (err) { console.error('Error creating position:', err); res.status(500).json({ error: 'Failed to create position' }); } }); // PUT /api/positions/:id -> обновление должности app.put(`${API_PREFIX}/positions/:id`, async (req, res) => { const { id } = req.params; const { name, isManagerial, sortOrder } = req.body || {}; try { const updates = []; const values = []; let paramIndex = 1; if (name !== undefined) { updates.push(`name = $${paramIndex++}`); values.push(typeof name === 'string' ? name.trim() : name); } if (isManagerial !== undefined) { updates.push(`is_managerial = $${paramIndex++}`); values.push(!!isManagerial); } if (sortOrder !== undefined) { updates.push(`sort_order = $${paramIndex++}`); values.push(typeof sortOrder === 'number' ? sortOrder : 0); } if (updates.length === 0) { return res.status(400).json({ error: 'Нет полей для обновления' }); } updates.push(`updated_at = NOW()`); values.push(id); const result = await query( `UPDATE positions SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING id, name, is_managerial AS "isManagerial", sort_order AS "sortOrder"`, values ); if (result.length === 0) { return res.status(404).json({ error: 'Должность не найдена' }); } res.json(result[0]); } catch (err) { console.error('Error updating position:', err); res.status(500).json({ error: 'Failed to update position' }); } }); // DELETE /api/positions/:id -> удаление должности app.delete(`${API_PREFIX}/positions/:id`, async (req, res) => { const { id } = req.params; try { const result = await query('DELETE FROM positions WHERE id = $1 RETURNING id', [id]); if (result.length === 0) { return res.status(404).json({ error: 'Должность не найдена' }); } res.json({ success: true, message: 'Должность удалена' }); } catch (err) { console.error('Error deleting position:', err); res.status(500).json({ error: 'Failed to delete position' }); } }); // ----------- EMPLOYEES API ----------- // POST /api/employees/:id/photo -> загрузка фото сотрудника app.post(`${API_PREFIX}/employees/:id/photo`, uploadPhoto.single('photo'), async (req, res) => { try { const { id } = req.params; if (!req.file) { return res.status(400).json({ error: 'Файл не загружен' }); } // Проверяем существование сотрудника const existing = await query('SELECT id FROM employees WHERE id = $1', [id]); if (existing.length === 0) { // Удаляем загруженный файл, если сотрудник не найден fs.unlink(req.file.path, (err) => { if (err) console.error('Ошибка удаления файла:', err); }); return res.status(404).json({ error: 'Сотрудник не найден' }); } // Путь к файлу относительно папки uploads const photoUrl = `/uploads/photos/${req.file.filename}`; // Обновляем photo_url в таблице employees await query( 'UPDATE employees SET photo_url = $1, updated_at = NOW() WHERE id = $2', [photoUrl, id] ); res.json({ success: true, photoUrl }); } catch (err) { console.error('Error uploading photo:', err); res.status(500).json({ error: 'Failed to upload photo' }); } }); // DELETE /api/employees/:id/photo -> удаление фото сотрудника app.delete(`${API_PREFIX}/employees/:id/photo`, async (req, res) => { try { const { id } = req.params; // Получаем текущий photo_url const employee = await query('SELECT photo_url FROM employees WHERE id = $1', [id]); if (employee.length === 0) { return res.status(404).json({ error: 'Сотрудник не найден' }); } // Удаляем файл, если он существует if (employee[0].photo_url) { const filePath = path.join(__dirname, employee[0].photo_url.replace('/uploads/', 'uploads/')); if (fs.existsSync(filePath)) { fs.unlink(filePath, (err) => { if (err) console.error('Ошибка удаления файла:', err); }); } } // Обновляем photo_url в БД await query( 'UPDATE employees SET photo_url = NULL, updated_at = NOW() WHERE id = $1', [id] ); res.json({ success: true }); } catch (err) { console.error('Error deleting photo:', err); res.status(500).json({ error: 'Failed to delete photo' }); } }); // GET /api/employees/list -> лёгкий список id+name для выпадающих списков (участники совещаний и т.д.) app.get(`${API_PREFIX}/employees/list`, async (req, res) => { try { const rows = await query( `SELECT id, name FROM employees WHERE status = 'active' ORDER BY name` ); res.json(rows); } catch (err) { console.error('Error fetching employees list:', err); res.status(500).json({ error: 'Failed to fetch employees list' }); } }); // --- Зоны ответственности (разделы/подразделы модулей → сотрудники) --- // GET /api/responsibility -> список привязок { assignments: [ { employeeId, section, subId } ] }, опционально ?section=hr app.get(`${API_PREFIX}/responsibility`, async (req, res) => { try { await ensureEmployeeResponsibilityTable(); const { section } = req.query; let sql = `SELECT employee_id AS "employeeId", section, sub_id AS "subId" FROM employee_responsibility`; const params = []; if (section) { params.push(section); sql += ` WHERE section = $1`; } sql += ` ORDER BY section, sub_id, employee_id`; const rows = await query(sql, params); res.json({ assignments: rows }); } catch (err) { // Если таблицы нет (42P01) — создаём и возвращаем пустой список, чтобы UI загрузился if (err.code === '42P01' || (err.message && err.message.includes('employee_responsibility'))) { try { await ensureEmployeeResponsibilityTable(); const rows = await query(`SELECT employee_id AS "employeeId", section, sub_id AS "subId" FROM employee_responsibility ORDER BY section, sub_id, employee_id`, []); return res.json({ assignments: rows }); } catch (e2) { console.error('Error creating responsibility table:', e2); return res.json({ assignments: [] }); } } console.error('Error fetching responsibility:', err); res.status(500).json({ error: 'Failed to fetch responsibility' }); } }); // PUT /api/responsibility -> установить ответственных за зону (section + subId). body: { section, subId, employeeIds: string[] } app.put(`${API_PREFIX}/responsibility`, async (req, res) => { try { await ensureEmployeeResponsibilityTable(); const { section, subId, employeeIds } = req.body || {}; if (!section || !subId || !Array.isArray(employeeIds)) { return res.status(400).json({ error: 'Required: section, subId, employeeIds (array)' }); } await query('DELETE FROM employee_responsibility WHERE section = $1 AND sub_id = $2', [section, subId]); const ids = employeeIds.filter(Boolean); for (const employeeId of ids) { await query( `INSERT INTO employee_responsibility (employee_id, section, sub_id) VALUES ($1, $2, $3) ON CONFLICT (employee_id, section, sub_id) DO NOTHING`, [employeeId, section, subId] ); } res.json({ ok: true, section, subId, count: ids.length }); } catch (err) { console.error('Error updating responsibility:', err); res.status(500).json({ error: 'Failed to update responsibility' }); } }); // GET /api/employees -> список сотрудников app.get(`${API_PREFIX}/employees`, async (req, res) => { try { const rows = await query(` SELECT e.id, e.name, e.position, e.phone, e.status, e.salary, e.assigned_district_id AS "assignedDistrictId", e.manager_id AS "managerId", e.birth_date AS "birthDate", e.photo_url AS "employeePhotoUrl", COALESCE(e.photo_url, pu.photo_url) AS "photoUrl", e.registration_date AS "registrationDate", COALESCE( json_agg( DISTINCT jsonb_build_object( 'messenger', ml.messenger, 'login', ml.login ) ) FILTER (WHERE ml.id IS NOT NULL), '[]' ) AS "messengerLogins" FROM employees e LEFT JOIN employee_messenger_logins ml ON e.id = ml.employee_id LEFT JOIN portal_users pu ON e.id = pu.employee_id GROUP BY e.id, pu.photo_url ORDER BY e.name `); // Назначения сотрудников на участки (многие ко многим) let districtIdsByEmployee = {}; try { const edRows = await query('SELECT employee_id AS "employeeId", district_id AS "districtId" FROM employee_districts'); for (const r of edRows) { if (!districtIdsByEmployee[r.employeeId]) districtIdsByEmployee[r.employeeId] = []; districtIdsByEmployee[r.employeeId].push(r.districtId); } } catch (edErr) { // Таблица employee_districts может отсутствовать до миграции } // Получаем HR данные для каждого сотрудника const employeesWithHrData = await Promise.all(rows.map(async (emp) => { // Паспортные данные const passportRows = await query( 'SELECT series, number, issued_by AS "issuedBy", issued_date AS "issuedDate", registration_address AS "registrationAddress" FROM employee_passport_data WHERE employee_id = $1', [emp.id] ); // Трудовая книжка const laborBookRows = await query( 'SELECT number, series FROM employee_labor_books WHERE employee_id = $1', [emp.id] ); const laborBookEntries = await query( 'SELECT date, organization, position FROM labor_book_entries WHERE labor_book_id = (SELECT id FROM employee_labor_books WHERE employee_id = $1) ORDER BY date', [emp.id] ); // Справки const certificatesRows = await query( 'SELECT type, requested_date AS "requestedDate", issued_date AS "issuedDate", status FROM employee_certificates WHERE employee_id = $1', [emp.id] ); // Прочие документы const otherDocsRows = await query( 'SELECT name, type, date, file_url AS "fileUrl" FROM employee_other_documents WHERE employee_id = $1', [emp.id] ); // Бухгалтерская информация const accountingRows = await query( 'SELECT inn, snils, bank_name AS "bankName", bank_account AS "bankAccount", correspondent_account AS "correspondentAccount", bik, tax_id AS "taxId" FROM employee_accounting_data WHERE employee_id = $1', [emp.id] ); // Договоры const contractsRows = await query( 'SELECT id, contract_type AS "contractType", contract_number AS "contractNumber", start_date AS "startDate", end_date AS "endDate", probation_period_days AS "probationPeriodDays", work_schedule AS "workSchedule", work_mode AS "workMode", contract_terms AS "contractTerms" FROM employee_contracts WHERE employee_id = $1 ORDER BY start_date DESC', [emp.id] ); // Отпуска const vacationsRows = await query( `SELECT id, employee_id AS "employeeId", start_date AS "startDate", end_date AS "endDate", days_count AS "daysCount", vacation_type AS "vacationType", status, approved_by AS "approvedBy", approved_at AS "approvedAt", requires_approval AS "requiresApproval", approved_signature AS "approvedSignature", rejected_by AS "rejectedBy", rejected_at AS "rejectedAt", rejection_reason AS "rejectionReason", notes, created_at AS "createdAt", updated_at AS "updatedAt" FROM employee_vacations WHERE employee_id = $1 ORDER BY start_date DESC`, [emp.id] ); // Больничные const sickLeavesRows = await query( `SELECT id, employee_id AS "employeeId", start_date AS "startDate", end_date AS "endDate", days_count AS "daysCount", sick_leave_number AS "sickLeaveNumber", diagnosis, medical_institution AS "medicalInstitution", status, requires_approval AS "requiresApproval", approved_by AS "approvedBy", approved_at AS "approvedAt", approved_signature AS "approvedSignature", closed_at AS "closedAt", notes, file_url AS "fileUrl", expected_return_date AS "expectedReturnDate", created_at AS "createdAt", updated_at AS "updatedAt" FROM employee_sick_leaves WHERE employee_id = $1 ORDER BY start_date DESC`, [emp.id] ); // Увольнения const terminationsRows = await query( `SELECT id, employee_id AS "employeeId", termination_date AS "terminationDate", reason, initiated_by AS "initiatedBy", initiated_at AS "initiatedAt", status, termination_contract_number AS "terminationContractNumber", termination_contract_date AS "terminationContractDate", termination_contract_file_url AS "terminationContractFileUrl", final_settlement_amount AS "finalSettlementAmount", unused_vacation_days AS "unusedVacationDays", compensation_amount AS "compensationAmount", severance_pay AS "severancePay", other_payments AS "otherPayments", deductions, settlement_document_number AS "settlementDocumentNumber", settlement_document_date AS "settlementDocumentDate", settlement_document_file_url AS "settlementDocumentFileUrl", notes, completed_at AS "completedAt", created_at AS "createdAt", updated_at AS "updatedAt" FROM employee_terminations WHERE employee_id = $1 ORDER BY termination_date DESC`, [emp.id] ); // Отгулы и прогулы const absencesRows = await query( `SELECT id, employee_id AS "employeeId", absence_type AS "absenceType", start_date AS "startDate", end_date AS "endDate", start_time AS "startTime", end_time AS "endTime", days_count AS "daysCount", reason, requires_approval AS "requiresApproval", status, approved_by AS "approvedBy", approved_at AS "approvedAt", approved_signature AS "approvedSignature", rejected_by AS "rejectedBy", rejected_at AS "rejectedAt", rejection_reason AS "rejectionReason", notes, created_at AS "createdAt", updated_at AS "updatedAt" FROM employee_absences WHERE employee_id = $1 ORDER BY start_date DESC, created_at DESC`, [emp.id] ); const hrData = {}; if (passportRows.length > 0) { hrData.passportData = passportRows[0]; } if (laborBookRows.length > 0) { hrData.laborBook = { ...laborBookRows[0], entries: laborBookEntries }; } if (certificatesRows.length > 0) { hrData.certificates = certificatesRows; } if (otherDocsRows.length > 0) { hrData.otherDocuments = otherDocsRows; } if (accountingRows.length > 0) { hrData.accountingData = accountingRows[0]; } if (contractsRows.length > 0) { hrData.contracts = contractsRows; } if (vacationsRows.length > 0) { hrData.vacations = vacationsRows; } if (sickLeavesRows.length > 0) { hrData.sickLeaves = sickLeavesRows; } if (terminationsRows.length > 0) { hrData.terminations = terminationsRows; } if (absencesRows.length > 0) { hrData.absences = absencesRows; } // Обрабатываем messengerLogins - может быть строкой JSON или массивом let messengerLogins = []; if (emp.messengerLogins) { if (typeof emp.messengerLogins === 'string') { try { messengerLogins = JSON.parse(emp.messengerLogins); } catch (e) { messengerLogins = []; } } else if (Array.isArray(emp.messengerLogins)) { messengerLogins = emp.messengerLogins; } } const assignedDistrictIds = districtIdsByEmployee[emp.id] && districtIdsByEmployee[emp.id].length > 0 ? districtIdsByEmployee[emp.id] : (emp.assignedDistrictId ? [emp.assignedDistrictId] : []); return { ...emp, assignedDistrictIds, assignedDistrictId: assignedDistrictIds[0] || emp.assignedDistrictId || null, messengerLogins: messengerLogins, hrData: Object.keys(hrData).length > 0 ? hrData : undefined }; })); res.json(employeesWithHrData); } catch (err) { console.error('Error fetching employees:', err); // #region agent log try { const debugLogPath = path.join(__dirname, '..', '.cursor', 'debug.log'); fs.appendFileSync(debugLogPath, JSON.stringify({ location: 'server.js:GET/employees', message: 'Error fetching employees', data: { errMessage: err.message, errCode: err && err.code }, timestamp: Date.now(), sessionId: 'debug-session', hypothesisId: 'E' }) + '\n'); } catch (_) {} // #endregion res.status(500).json({ error: 'Failed to fetch employees' }); } }); // GET /api/employees/organizational-structure -> организационная структура в виде дерева app.get(`${API_PREFIX}/employees/organizational-structure`, async (req, res) => { try { const rows = await query(` SELECT e.id, e.name, e.position, e.phone, e.status, e.photo_url AS "photoUrl", e.manager_id AS "managerId" FROM employees e WHERE e.status = 'active' ORDER BY e.name `); // Создаем карту всех сотрудников const employeesMap = new Map(); rows.forEach(emp => { employeesMap.set(emp.id, { ...emp, subordinates: [] }); }); // Строим дерево: находим корневые элементы (без manager_id) и добавляем подчиненных const rootEmployees = []; rows.forEach(emp => { const employee = employeesMap.get(emp.id); if (emp.managerId && employeesMap.has(emp.managerId)) { // Добавляем к подчиненным руководителя const manager = employeesMap.get(emp.managerId); manager.subordinates.push(employee); } else { // Это корневой элемент (нет руководителя или руководитель не найден) rootEmployees.push(employee); } }); // Функция для рекурсивного подсчета подчиненных const countSubordinates = (emp) => { let count = emp.subordinates.length; emp.subordinates.forEach(sub => { count += countSubordinates(sub); }); return count; }; // Добавляем количество подчиненных к каждому сотруднику const addSubordinateCount = (emp) => { emp.subordinateCount = countSubordinates(emp); emp.subordinates.forEach(addSubordinateCount); }; rootEmployees.forEach(addSubordinateCount); res.json(rootEmployees); } catch (err) { console.error('Error fetching organizational structure:', err); res.status(500).json({ error: 'Failed to fetch organizational structure' }); } }); // POST /api/employees -> создание нового сотрудника app.post(`${API_PREFIX}/employees`, async (req, res) => { try { const { name, position, phone, status, salary, assignedDistrictId, assignedDistrictIds, managerId, birthDate, photoUrl, registrationDate, messengerLogins, hrData } = req.body; if (!name || !position || !phone || !salary) { return res.status(400).json({ error: 'name, position, phone и salary обязательны' }); } const id = `e-${Date.now()}`; const employeeStatus = status || 'active'; const districtIds = Array.isArray(assignedDistrictIds) ? assignedDistrictIds.filter(Boolean) : (assignedDistrictId ? [assignedDistrictId] : []); const firstDistrictId = districtIds.length > 0 ? districtIds[0] : (assignedDistrictId || null); // Создаем сотрудника await query( `INSERT INTO employees (id, name, position, phone, status, salary, assigned_district_id, manager_id, birth_date, photo_url, registration_date) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [id, name, position, phone, employeeStatus, salary, firstDistrictId, managerId || null, birthDate && birthDate.trim() !== '' ? birthDate : null, photoUrl || null, registrationDate && registrationDate.trim() !== '' ? registrationDate : null] ); // Назначения на участки (многие ко многим) try { for (const districtId of districtIds) { if (districtId && String(districtId).trim()) { await query('INSERT INTO employee_districts (employee_id, district_id) VALUES ($1, $2) ON CONFLICT (employee_id, district_id) DO NOTHING', [id, String(districtId).trim()]); } } } catch (edErr) { // Таблица employee_districts может отсутствовать } // Добавляем логины мессенджеров if (messengerLogins && Array.isArray(messengerLogins)) { for (const login of messengerLogins) { if (login.messenger && login.login) { await query( 'INSERT INTO employee_messenger_logins (employee_id, messenger, login) VALUES ($1, $2, $3)', [id, login.messenger, login.login] ); } } } // Добавляем HR данные если есть if (hrData) { if (hrData.passportData) { const { series, number, issuedBy, issuedDate, registrationAddress } = hrData.passportData; // Проверяем, что все обязательные поля заполнены if (!series || !series.trim()) { return res.status(400).json({ error: 'Серия паспорта обязательна для заполнения' }); } if (!number || !number.trim()) { return res.status(400).json({ error: 'Номер паспорта обязателен для заполнения' }); } if (!issuedBy || !issuedBy.trim()) { return res.status(400).json({ error: 'Кем выдан паспорт - обязательное поле для заполнения' }); } if (!issuedDate || !issuedDate.trim()) { return res.status(400).json({ error: 'Дата выдачи паспорта обязательна для заполнения' }); } if (!registrationAddress || !registrationAddress.trim()) { return res.status(400).json({ error: 'Адрес регистрации обязателен для заполнения' }); } await query( `INSERT INTO employee_passport_data (employee_id, series, number, issued_by, issued_date, registration_address) VALUES ($1, $2, $3, $4, $5, $6)`, [id, series.trim(), number.trim(), issuedBy.trim(), issuedDate.trim(), registrationAddress.trim()] ); } if (hrData.laborBook) { const { number: lbNumber, series: lbSeries } = hrData.laborBook; const lbResult = await query( 'INSERT INTO employee_labor_books (employee_id, number, series) VALUES ($1, $2, $3) RETURNING id', [id, lbNumber, lbSeries || null] ); const lbId = lbResult[0].id; if (hrData.laborBook.entries && Array.isArray(hrData.laborBook.entries)) { for (const entry of hrData.laborBook.entries) { await query( 'INSERT INTO labor_book_entries (labor_book_id, date, organization, position) VALUES ($1, $2, $3, $4)', [lbId, entry.date && entry.date.trim() !== '' ? entry.date : null, entry.organization, entry.position] ); } } } // Бухгалтерская информация if (hrData.accountingData) { const { inn, snils, bankName, bankAccount, correspondentAccount, bik, taxId } = hrData.accountingData; await query( `INSERT INTO employee_accounting_data (employee_id, inn, snils, bank_name, bank_account, correspondent_account, bik, tax_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [id, inn || null, snils || null, bankName || null, bankAccount || null, correspondentAccount || null, bik || null, taxId || null] ); } // Договоры if (hrData.contracts && Array.isArray(hrData.contracts)) { for (const contract of hrData.contracts) { const { contractType, contractNumber, startDate, endDate, probationPeriodDays, workSchedule, workMode, contractTerms } = contract; await query( `INSERT INTO employee_contracts (employee_id, contract_type, contract_number, start_date, end_date, probation_period_days, work_schedule, work_mode, contract_terms) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [id, contractType, contractNumber || null, startDate && startDate.trim() !== '' ? startDate : null, endDate && endDate.trim() !== '' ? endDate : null, probationPeriodDays || null, workSchedule || null, workMode || null, contractTerms || null] ); } } } // Получаем созданного сотрудника с полными данными const result = await query(` SELECT e.id, e.name, e.position, e.phone, e.status, e.salary, e.assigned_district_id AS "assignedDistrictId", e.birth_date AS "birthDate", e.photo_url AS "employeePhotoUrl", COALESCE(e.photo_url, pu.photo_url) AS "photoUrl", e.registration_date AS "registrationDate" FROM employees e LEFT JOIN portal_users pu ON e.id = pu.employee_id WHERE e.id = $1 `, [id]); const messengerLoginsResult = await query( 'SELECT messenger, login FROM employee_messenger_logins WHERE employee_id = $1', [id] ); // Получаем HR данные const passportRows = await query( 'SELECT series, number, issued_by AS "issuedBy", issued_date AS "issuedDate", registration_address AS "registrationAddress" FROM employee_passport_data WHERE employee_id = $1', [id] ); const laborBookRows = await query( 'SELECT number, series FROM employee_labor_books WHERE employee_id = $1', [id] ); const laborBookEntries = await query( 'SELECT date, organization, position FROM labor_book_entries WHERE labor_book_id = (SELECT id FROM employee_labor_books WHERE employee_id = $1) ORDER BY date', [id] ); const accountingRows = await query( 'SELECT inn, snils, bank_name AS "bankName", bank_account AS "bankAccount", correspondent_account AS "correspondentAccount", bik, tax_id AS "taxId" FROM employee_accounting_data WHERE employee_id = $1', [id] ); const contractsRows = await query( 'SELECT id, contract_type AS "contractType", contract_number AS "contractNumber", start_date AS "startDate", end_date AS "endDate", probation_period_days AS "probationPeriodDays", work_schedule AS "workSchedule", work_mode AS "workMode", contract_terms AS "contractTerms" FROM employee_contracts WHERE employee_id = $1 ORDER BY start_date DESC', [id] ); const responseHrData = {}; if (passportRows.length > 0) { responseHrData.passportData = passportRows[0]; } if (laborBookRows.length > 0) { responseHrData.laborBook = { ...laborBookRows[0], entries: laborBookEntries }; } if (accountingRows.length > 0) { responseHrData.accountingData = accountingRows[0]; } if (contractsRows.length > 0) { responseHrData.contracts = contractsRows; } res.status(201).json({ ...result[0], assignedDistrictIds: districtIds, assignedDistrictId: firstDistrictId, messengerLogins: messengerLoginsResult.map(ml => ({ messenger: ml.messenger, login: ml.login })), hrData: Object.keys(responseHrData).length > 0 ? responseHrData : undefined }); } catch (err) { console.error('Error creating employee:', err); res.status(500).json({ error: 'Failed to create employee' }); } }); // PUT /api/employees/:id -> обновление сотрудника app.put(`${API_PREFIX}/employees/:id`, async (req, res) => { try { const { id } = req.params; const { name, position, phone, status, salary, assignedDistrictId, assignedDistrictIds, managerId, birthDate, photoUrl, registrationDate, messengerLogins, hrData } = req.body; // Проверяем существование сотрудника const existing = await query('SELECT id FROM employees WHERE id = $1', [id]); if (existing.length === 0) { return res.status(404).json({ error: 'Сотрудник не найден' }); } // Нормализуем участки: массив или строка (например "id1,id2") из формы let districtIds = undefined; if (assignedDistrictIds !== undefined) { if (Array.isArray(assignedDistrictIds)) { districtIds = assignedDistrictIds.filter(Boolean).map((d) => String(d).trim()).filter(Boolean); } else if (typeof assignedDistrictIds === 'string' && assignedDistrictIds.trim()) { districtIds = assignedDistrictIds.split(/[,;\s]+/).map((d) => d.trim()).filter(Boolean); } else { districtIds = []; } } const firstDistrictId = districtIds !== undefined ? (districtIds.length > 0 ? districtIds[0] : null) : (assignedDistrictId !== undefined ? (assignedDistrictId || null) : undefined); // Обновляем основные данные const updateFields = []; const values = []; let paramIndex = 1; if (name !== undefined) { updateFields.push(`name = $${paramIndex++}`); values.push(name); } if (position !== undefined) { updateFields.push(`position = $${paramIndex++}`); values.push(position); } if (phone !== undefined) { updateFields.push(`phone = $${paramIndex++}`); values.push(phone); } if (status !== undefined) { updateFields.push(`status = $${paramIndex++}`); values.push(status); } if (salary !== undefined) { updateFields.push(`salary = $${paramIndex++}`); values.push(salary); } if (firstDistrictId !== undefined) { updateFields.push(`assigned_district_id = $${paramIndex++}`); values.push(firstDistrictId); } else if (assignedDistrictId !== undefined) { updateFields.push(`assigned_district_id = $${paramIndex++}`); values.push(assignedDistrictId || null); } if (managerId !== undefined) { updateFields.push(`manager_id = $${paramIndex++}`); values.push(managerId || null); } if (birthDate !== undefined) { updateFields.push(`birth_date = $${paramIndex++}`); values.push(birthDate && birthDate.trim() !== '' ? birthDate : null); } if (photoUrl !== undefined) { updateFields.push(`photo_url = $${paramIndex++}`); values.push(photoUrl || null); } if (registrationDate !== undefined) { updateFields.push(`registration_date = $${paramIndex++}`); values.push(registrationDate && registrationDate.trim() !== '' ? registrationDate : null); } if (updateFields.length > 0) { updateFields.push(`updated_at = NOW()`); values.push(id); await query( `UPDATE employees SET ${updateFields.join(', ')} WHERE id = $${paramIndex}`, values ); } // Назначения на участки (многие ко многим): при передаче assignedDistrictIds перезаписываем if (districtIds !== undefined) { try { await query('DELETE FROM employee_districts WHERE employee_id = $1', [id]); for (const districtId of districtIds) { if (districtId && String(districtId).trim()) { await query('INSERT INTO employee_districts (employee_id, district_id) VALUES ($1, $2) ON CONFLICT (employee_id, district_id) DO NOTHING', [id, String(districtId).trim()]); } } } catch (edErr) { console.error('[PUT /api/employees/:id] employee_districts update failed:', edErr.message); } } // Обновляем логины мессенджеров if (messengerLogins !== undefined) { // Удаляем старые await query('DELETE FROM employee_messenger_logins WHERE employee_id = $1', [id]); // Добавляем новые if (Array.isArray(messengerLogins)) { for (const login of messengerLogins) { if (login.messenger && login.login) { await query( 'INSERT INTO employee_messenger_logins (employee_id, messenger, login) VALUES ($1, $2, $3)', [id, login.messenger, login.login] ); } } } } // Обновляем HR данные if (hrData !== undefined) { if (hrData.passportData) { const { series, number, issuedBy, issuedDate, registrationAddress } = hrData.passportData; // Проверяем, что все обязательные поля заполнены if (!series || !series.trim()) { return res.status(400).json({ error: 'Серия паспорта обязательна для заполнения' }); } if (!number || !number.trim()) { return res.status(400).json({ error: 'Номер паспорта обязателен для заполнения' }); } if (!issuedBy || !issuedBy.trim()) { return res.status(400).json({ error: 'Кем выдан паспорт - обязательное поле для заполнения' }); } if (!issuedDate || !issuedDate.trim()) { return res.status(400).json({ error: 'Дата выдачи паспорта обязательна для заполнения' }); } if (!registrationAddress || !registrationAddress.trim()) { return res.status(400).json({ error: 'Адрес регистрации обязателен для заполнения' }); } // Удаляем старые данные await query('DELETE FROM employee_passport_data WHERE employee_id = $1', [id]); // Добавляем новые await query( `INSERT INTO employee_passport_data (employee_id, series, number, issued_by, issued_date, registration_address) VALUES ($1, $2, $3, $4, $5, $6)`, [id, series.trim(), number.trim(), issuedBy.trim(), issuedDate.trim(), registrationAddress.trim()] ); } if (hrData.laborBook) { // Удаляем старые записи const oldLb = await query('SELECT id FROM employee_labor_books WHERE employee_id = $1', [id]); if (oldLb.length > 0) { await query('DELETE FROM labor_book_entries WHERE labor_book_id = $1', [oldLb[0].id]); await query('DELETE FROM employee_labor_books WHERE employee_id = $1', [id]); } // Добавляем новые const { number: lbNumber, series: lbSeries } = hrData.laborBook; const lbResult = await query( 'INSERT INTO employee_labor_books (employee_id, number, series) VALUES ($1, $2, $3) RETURNING id', [id, lbNumber, lbSeries || null] ); const lbId = lbResult[0].id; if (hrData.laborBook.entries && Array.isArray(hrData.laborBook.entries)) { for (const entry of hrData.laborBook.entries) { await query( 'INSERT INTO labor_book_entries (labor_book_id, date, organization, position) VALUES ($1, $2, $3, $4)', [lbId, entry.date && entry.date.trim() !== '' ? entry.date : null, entry.organization, entry.position] ); } } } // Бухгалтерская информация if (hrData.accountingData !== undefined) { // Удаляем старые данные await query('DELETE FROM employee_accounting_data WHERE employee_id = $1', [id]); // Добавляем новые, если есть данные const { inn, snils, bankName, bankAccount, correspondentAccount, bik, taxId } = hrData.accountingData || {}; if (inn || snils || bankName || bankAccount || correspondentAccount || bik || taxId) { await query( `INSERT INTO employee_accounting_data (employee_id, inn, snils, bank_name, bank_account, correspondent_account, bik, tax_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [id, inn || null, snils || null, bankName || null, bankAccount || null, correspondentAccount || null, bik || null, taxId || null] ); } } // Договоры if (hrData.contracts !== undefined) { // Удаляем старые договоры await query('DELETE FROM employee_contracts WHERE employee_id = $1', [id]); // Добавляем новые, если есть if (Array.isArray(hrData.contracts) && hrData.contracts.length > 0) { for (const contract of hrData.contracts) { const { contractType, contractNumber, startDate, endDate, probationPeriodDays, workSchedule, workMode, contractTerms } = contract; await query( `INSERT INTO employee_contracts (employee_id, contract_type, contract_number, start_date, end_date, probation_period_days, work_schedule, work_mode, contract_terms) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [id, contractType, contractNumber || null, startDate && startDate.trim() !== '' ? startDate : null, endDate && endDate.trim() !== '' ? endDate : null, probationPeriodDays || null, workSchedule || null, workMode || null, contractTerms || null] ); } } } } // Получаем обновленного сотрудника с полными данными const result = await query(` SELECT e.id, e.name, e.position, e.phone, e.status, e.salary, e.assigned_district_id AS "assignedDistrictId", e.birth_date AS "birthDate", e.photo_url AS "employeePhotoUrl", COALESCE(e.photo_url, pu.photo_url) AS "photoUrl", e.registration_date AS "registrationDate" FROM employees e LEFT JOIN portal_users pu ON e.id = pu.employee_id WHERE e.id = $1 `, [id]); const messengerLoginsResult = await query( 'SELECT messenger, login FROM employee_messenger_logins WHERE employee_id = $1', [id] ); // Получаем HR данные const passportRows = await query( 'SELECT series, number, issued_by AS "issuedBy", issued_date AS "issuedDate", registration_address AS "registrationAddress" FROM employee_passport_data WHERE employee_id = $1', [id] ); const laborBookRows = await query( 'SELECT number, series FROM employee_labor_books WHERE employee_id = $1', [id] ); const laborBookEntries = await query( 'SELECT date, organization, position FROM labor_book_entries WHERE labor_book_id = (SELECT id FROM employee_labor_books WHERE employee_id = $1) ORDER BY date', [id] ); const accountingRows = await query( 'SELECT inn, snils, bank_name AS "bankName", bank_account AS "bankAccount", correspondent_account AS "correspondentAccount", bik, tax_id AS "taxId" FROM employee_accounting_data WHERE employee_id = $1', [id] ); const contractsRows = await query( 'SELECT id, contract_type AS "contractType", contract_number AS "contractNumber", start_date AS "startDate", end_date AS "endDate", probation_period_days AS "probationPeriodDays", work_schedule AS "workSchedule", work_mode AS "workMode", contract_terms AS "contractTerms" FROM employee_contracts WHERE employee_id = $1 ORDER BY start_date DESC', [id] ); // Отпуска const vacationsRows = await query( `SELECT id, employee_id AS "employeeId", start_date AS "startDate", end_date AS "endDate", days_count AS "daysCount", vacation_type AS "vacationType", status, approved_by AS "approvedBy", approved_at AS "approvedAt", requires_approval AS "requiresApproval", approved_signature AS "approvedSignature", rejected_by AS "rejectedBy", rejected_at AS "rejectedAt", rejection_reason AS "rejectionReason", notes, created_at AS "createdAt", updated_at AS "updatedAt" FROM employee_vacations WHERE employee_id = $1 ORDER BY start_date DESC`, [id] ); // Больничные const sickLeavesRows = await query( `SELECT id, employee_id AS "employeeId", start_date AS "startDate", end_date AS "endDate", days_count AS "daysCount", sick_leave_number AS "sickLeaveNumber", diagnosis, medical_institution AS "medicalInstitution", status, requires_approval AS "requiresApproval", approved_by AS "approvedBy", approved_at AS "approvedAt", approved_signature AS "approvedSignature", closed_at AS "closedAt", notes, file_url AS "fileUrl", expected_return_date AS "expectedReturnDate", created_at AS "createdAt", updated_at AS "updatedAt" FROM employee_sick_leaves WHERE employee_id = $1 ORDER BY start_date DESC`, [id] ); // Увольнения const terminationsRows = await query( `SELECT id, employee_id AS "employeeId", termination_date AS "terminationDate", reason, initiated_by AS "initiatedBy", initiated_at AS "initiatedAt", status, termination_contract_number AS "terminationContractNumber", termination_contract_date AS "terminationContractDate", termination_contract_file_url AS "terminationContractFileUrl", final_settlement_amount AS "finalSettlementAmount", unused_vacation_days AS "unusedVacationDays", compensation_amount AS "compensationAmount", severance_pay AS "severancePay", other_payments AS "otherPayments", deductions, settlement_document_number AS "settlementDocumentNumber", settlement_document_date AS "settlementDocumentDate", settlement_document_file_url AS "settlementDocumentFileUrl", notes, completed_at AS "completedAt", created_at AS "createdAt", updated_at AS "updatedAt" FROM employee_terminations WHERE employee_id = $1 ORDER BY termination_date DESC`, [id] ); // Отгулы и прогулы const absencesRows = await query( `SELECT id, employee_id AS "employeeId", absence_type AS "absenceType", start_date AS "startDate", end_date AS "endDate", start_time AS "startTime", end_time AS "endTime", days_count AS "daysCount", reason, requires_approval AS "requiresApproval", status, approved_by AS "approvedBy", approved_at AS "approvedAt", approved_signature AS "approvedSignature", rejected_by AS "rejectedBy", rejected_at AS "rejectedAt", rejection_reason AS "rejectionReason", notes, created_at AS "createdAt", updated_at AS "updatedAt" FROM employee_absences WHERE employee_id = $1 ORDER BY start_date DESC, created_at DESC`, [id] ); const responseHrData = {}; if (passportRows.length > 0) { responseHrData.passportData = passportRows[0]; } if (laborBookRows.length > 0) { responseHrData.laborBook = { ...laborBookRows[0], entries: laborBookEntries }; } if (accountingRows.length > 0) { responseHrData.accountingData = accountingRows[0]; } if (contractsRows.length > 0) { responseHrData.contracts = contractsRows; } if (vacationsRows.length > 0) { responseHrData.vacations = vacationsRows; } if (sickLeavesRows.length > 0) { responseHrData.sickLeaves = sickLeavesRows; } if (terminationsRows.length > 0) { responseHrData.terminations = terminationsRows; } if (absencesRows.length > 0) { responseHrData.absences = absencesRows; } let responseDistrictIds = []; try { const edRows = await query('SELECT district_id AS "districtId" FROM employee_districts WHERE employee_id = $1 ORDER BY district_id', [id]); responseDistrictIds = edRows.map(r => r.districtId); } catch (_) {} if (responseDistrictIds.length === 0 && result[0].assignedDistrictId) { responseDistrictIds = [result[0].assignedDistrictId]; } if (responseDistrictIds.length === 0 && districtIds && districtIds.length > 0) { responseDistrictIds = districtIds; } res.json({ ...result[0], assignedDistrictIds: responseDistrictIds, assignedDistrictId: responseDistrictIds[0] || result[0].assignedDistrictId || null, messengerLogins: messengerLoginsResult.map(ml => ({ messenger: ml.messenger, login: ml.login })), hrData: Object.keys(responseHrData).length > 0 ? responseHrData : undefined }); } catch (err) { console.error('Error updating employee:', err); res.status(500).json({ error: 'Failed to update employee' }); } }); // DELETE /api/employees/:id -> удаление сотрудника (каскадно удалит portal_users и связанные HR-данные) app.delete(`${API_PREFIX}/employees/:id`, async (req, res) => { try { const { id } = req.params; const result = await query('DELETE FROM employees WHERE id = $1 RETURNING id', [id]); if (result.length === 0) { return res.status(404).json({ error: 'Сотрудник не найден' }); } res.json({ success: true, message: 'Сотрудник удалён' }); } catch (err) { console.error('Error deleting employee:', err); res.status(500).json({ error: 'Ошибка при удалении сотрудника' }); } }); // ========= ОТПУСКА ========= // GET /api/employees/:id/vacations -> список отпусков сотрудника app.get(`${API_PREFIX}/employees/:id/vacations`, async (req, res) => { try { const { id } = req.params; const rows = await query( `SELECT id, employee_id AS "employeeId", start_date AS "startDate", end_date AS "endDate", days_count AS "daysCount", vacation_type AS "vacationType", status, approved_by AS "approvedBy", approved_at AS "approvedAt", requires_approval AS "requiresApproval", approved_signature AS "approvedSignature", rejected_by AS "rejectedBy", rejected_at AS "rejectedAt", rejection_reason AS "rejectionReason", notes, created_at AS "createdAt", updated_at AS "updatedAt" FROM employee_vacations WHERE employee_id = $1 ORDER BY start_date DESC`, [id] ); res.json(rows); } catch (err) { console.error('Error fetching vacations:', err); res.status(500).json({ error: 'Failed to fetch vacations' }); } }); // POST /api/employees/:id/vacations -> создание отпуска app.post(`${API_PREFIX}/employees/:id/vacations`, async (req, res) => { try { const { id } = req.params; const { startDate, endDate, daysCount, vacationType, status, approvedBy, notes, currentUserId, currentUserRole } = req.body; // Права: отпуск может оформить сотрудник себе, руководитель подчинённым, HR всем if (currentUserId && currentUserRole) { const isSelf = currentUserId === id; const isHrOrDirector = currentUserRole === 'HR_MANAGER' || currentUserRole === 'DIRECTOR'; let isManager = false; if (!isSelf && !isHrOrDirector) { const empRow = await query('SELECT manager_id FROM employees WHERE id = $1', [id]); if (empRow.length > 0 && empRow[0].manager_id === currentUserId) isManager = true; } if (!isSelf && !isHrOrDirector && !isManager) { return res.status(403).json({ error: 'Недостаточно прав для создания отпуска для этого сотрудника' }); } } if (!startDate || !endDate) { return res.status(400).json({ error: 'startDate и endDate обязательны' }); } const calculatedDays = daysCount || Math.ceil((new Date(endDate) - new Date(startDate)) / (1000 * 60 * 60 * 24)) + 1; const { requiresApproval, approvedSignature, rejectedBy, rejectedAt, rejectionReason } = req.body; const result = await query( `INSERT INTO employee_vacations (employee_id, start_date, end_date, days_count, vacation_type, status, approved_by, approved_at, requires_approval, approved_signature, rejected_by, rejected_at, rejection_reason, notes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, employee_id AS "employeeId", start_date AS "startDate", end_date AS "endDate", days_count AS "daysCount", vacation_type AS "vacationType", status, approved_by AS "approvedBy", approved_at AS "approvedAt", requires_approval AS "requiresApproval", approved_signature AS "approvedSignature", rejected_by AS "rejectedBy", rejected_at AS "rejectedAt", rejection_reason AS "rejectionReason", notes, created_at AS "createdAt", updated_at AS "updatedAt"`, [ id, startDate, endDate, calculatedDays, vacationType || 'annual', status || 'planned', approvedBy || null, approvedBy ? new Date().toISOString() : null, requiresApproval !== undefined ? requiresApproval : true, approvedSignature || null, rejectedBy || null, rejectedAt || null, rejectionReason || null, notes || null ] ); res.status(201).json(result[0]); } catch (err) { console.error('Error creating vacation:', err); res.status(500).json({ error: 'Failed to create vacation' }); } }); // PUT /api/employees/:id/vacations/:vacationId -> обновление отпуска app.put(`${API_PREFIX}/employees/:id/vacations/:vacationId`, async (req, res) => { try { const { id, vacationId } = req.params; const { startDate, endDate, daysCount, vacationType, status, approvedBy, notes, rejectedBy, rejectionReason } = req.body; const updateFields = []; const values = []; let paramIndex = 1; if (startDate !== undefined) { updateFields.push(`start_date = $${paramIndex++}`); values.push(startDate); } if (endDate !== undefined) { updateFields.push(`end_date = $${paramIndex++}`); values.push(endDate); } if (daysCount !== undefined) { updateFields.push(`days_count = $${paramIndex++}`); values.push(daysCount); } if (vacationType !== undefined) { updateFields.push(`vacation_type = $${paramIndex++}`); values.push(vacationType); } if (status !== undefined) { updateFields.push(`status = $${paramIndex++}`); values.push(status); if (status === 'approved' && approvedBy) { updateFields.push(`approved_by = $${paramIndex++}`); values.push(approvedBy); updateFields.push(`approved_at = NOW()`); } if (status === 'rejected' && rejectedBy) { updateFields.push(`rejected_by = $${paramIndex++}`); values.push(rejectedBy); updateFields.push(`rejected_at = NOW()`); if (rejectionReason !== undefined) { updateFields.push(`rejection_reason = $${paramIndex++}`); values.push(rejectionReason); } } } if (notes !== undefined) { updateFields.push(`notes = $${paramIndex++}`); values.push(notes); } if (updateFields.length === 0) { return res.status(400).json({ error: 'Нет полей для обновления' }); } updateFields.push(`updated_at = NOW()`); values.push(id, vacationId); const result = await query( `UPDATE employee_vacations SET ${updateFields.join(', ')} WHERE employee_id = $${paramIndex++} AND id = $${paramIndex} RETURNING id, employee_id AS "employeeId", start_date AS "startDate", end_date AS "endDate", days_count AS "daysCount", vacation_type AS "vacationType", status, approved_by AS "approvedBy", approved_at AS "approvedAt", notes, created_at AS "createdAt", updated_at AS "updatedAt"`, values ); if (result.length === 0) { return res.status(404).json({ error: 'Отпуск не найден' }); } res.json(result[0]); } catch (err) { console.error('Error updating vacation:', err); res.status(500).json({ error: 'Failed to update vacation' }); } }); // DELETE /api/employees/:id/vacations/:vacationId -> удаление отпуска app.delete(`${API_PREFIX}/employees/:id/vacations/:vacationId`, async (req, res) => { try { const { id, vacationId } = req.params; const result = await query( 'DELETE FROM employee_vacations WHERE employee_id = $1 AND id = $2 RETURNING id', [id, vacationId] ); if (result.length === 0) { return res.status(404).json({ error: 'Отпуск не найден' }); } res.json({ success: true }); } catch (err) { console.error('Error deleting vacation:', err); res.status(500).json({ error: 'Failed to delete vacation' }); } }); // ========= БОЛЬНИЧНЫЕ ========= // GET /api/employees/:id/sick-leaves -> список больничных сотрудника app.get(`${API_PREFIX}/employees/:id/sick-leaves`, async (req, res) => { try { const { id } = req.params; const rows = await query( `SELECT id, employee_id AS "employeeId", start_date AS "startDate", end_date AS "endDate", days_count AS "daysCount", sick_leave_number AS "sickLeaveNumber", diagnosis, medical_institution AS "medicalInstitution", status, requires_approval AS "requiresApproval", approved_by AS "approvedBy", approved_at AS "approvedAt", approved_signature AS "approvedSignature", closed_at AS "closedAt", notes, file_url AS "fileUrl", expected_return_date AS "expectedReturnDate", created_at AS "createdAt", updated_at AS "updatedAt" FROM employee_sick_leaves WHERE employee_id = $1 ORDER BY start_date DESC`, [id] ); res.json(rows); } catch (err) { console.error('Error fetching sick leaves:', err); res.status(500).json({ error: 'Failed to fetch sick leaves' }); } }); // POST /api/employees/:id/sick-leaves -> создание больничного app.post(`${API_PREFIX}/employees/:id/sick-leaves`, async (req, res) => { try { const { id } = req.params; const { startDate, endDate, sickLeaveNumber, diagnosis, medicalInstitution, notes, fileUrl, expectedReturnDate, currentUserId, currentUserRole } = req.body; // Права: больничный может оформить сотрудник себе, руководитель подчинённым, HR всем if (currentUserId && currentUserRole) { const isSelf = currentUserId === id; const isHrOrDirector = currentUserRole === 'HR_MANAGER' || currentUserRole === 'DIRECTOR'; let isManager = false; if (!isSelf && !isHrOrDirector) { const empRow = await query('SELECT manager_id FROM employees WHERE id = $1', [id]); if (empRow.length > 0 && empRow[0].manager_id === currentUserId) isManager = true; } if (!isSelf && !isHrOrDirector && !isManager) { return res.status(403).json({ error: 'Недостаточно прав для создания больничного для этого сотрудника' }); } } if (!startDate) { return res.status(400).json({ error: 'startDate обязателен' }); } let calculatedDays = null; if (endDate) { calculatedDays = Math.ceil((new Date(endDate) - new Date(startDate)) / (1000 * 60 * 60 * 24)) + 1; } const { requiresApproval, approvedBy, approvedAt, approvedSignature } = req.body; const result = await query( `INSERT INTO employee_sick_leaves (employee_id, start_date, end_date, days_count, sick_leave_number, diagnosis, medical_institution, status, requires_approval, approved_by, approved_at, approved_signature, notes, file_url, expected_return_date) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id, employee_id AS "employeeId", start_date AS "startDate", end_date AS "endDate", days_count AS "daysCount", sick_leave_number AS "sickLeaveNumber", diagnosis, medical_institution AS "medicalInstitution", status, requires_approval AS "requiresApproval", approved_by AS "approvedBy", approved_at AS "approvedAt", approved_signature AS "approvedSignature", closed_at AS "closedAt", notes, file_url AS "fileUrl", expected_return_date AS "expectedReturnDate", created_at AS "createdAt", updated_at AS "updatedAt"`, [ id, startDate, endDate || null, calculatedDays, sickLeaveNumber || null, diagnosis || null, medicalInstitution || null, 'active', requiresApproval !== undefined ? requiresApproval : false, approvedBy || null, approvedAt || null, approvedSignature || null, notes || null, fileUrl || null, expectedReturnDate || null ] ); res.status(201).json(result[0]); } catch (err) { console.error('Error creating sick leave:', err); res.status(500).json({ error: 'Failed to create sick leave' }); } }); // PUT /api/employees/:id/sick-leaves/:sickLeaveId -> обновление больничного app.put(`${API_PREFIX}/employees/:id/sick-leaves/:sickLeaveId`, async (req, res) => { try { const { id, sickLeaveId } = req.params; const { startDate, endDate, daysCount, sickLeaveNumber, diagnosis, medicalInstitution, status, notes, fileUrl, expectedReturnDate } = req.body; // При закрытии больничного номер больничного обязателен if (status === 'closed') { const current = await query('SELECT sick_leave_number FROM employee_sick_leaves WHERE id = $1 AND employee_id = $2', [sickLeaveId, id]); const hasNumber = (sickLeaveNumber && sickLeaveNumber.trim()) || (current[0] && current[0].sick_leave_number); if (!hasNumber) { return res.status(400).json({ error: 'При выходе с больничного номер больничного листа обязателен' }); } } const updateFields = []; const values = []; let paramIndex = 1; if (startDate !== undefined) { updateFields.push(`start_date = $${paramIndex++}`); values.push(startDate); } if (endDate !== undefined) { updateFields.push(`end_date = $${paramIndex++}`); values.push(endDate); const current = await query('SELECT start_date FROM employee_sick_leaves WHERE id = $1', [sickLeaveId]); const start = startDate !== undefined ? startDate : current[0]?.start_date; const end = endDate; if (start && end) { const calculatedDays = Math.ceil((new Date(end) - new Date(start)) / (1000 * 60 * 60 * 24)) + 1; updateFields.push(`days_count = $${paramIndex++}`); values.push(calculatedDays); } } if (daysCount !== undefined && endDate === undefined) { updateFields.push(`days_count = $${paramIndex++}`); values.push(daysCount); } if (sickLeaveNumber !== undefined) { updateFields.push(`sick_leave_number = $${paramIndex++}`); values.push(sickLeaveNumber); } if (diagnosis !== undefined) { updateFields.push(`diagnosis = $${paramIndex++}`); values.push(diagnosis); } if (medicalInstitution !== undefined) { updateFields.push(`medical_institution = $${paramIndex++}`); values.push(medicalInstitution); } if (expectedReturnDate !== undefined) { updateFields.push(`expected_return_date = $${paramIndex++}`); values.push(expectedReturnDate || null); } if (status !== undefined) { updateFields.push(`status = $${paramIndex++}`); values.push(status); if (status === 'closed') { updateFields.push(`closed_at = NOW()`); } } if (notes !== undefined) { updateFields.push(`notes = $${paramIndex++}`); values.push(notes); } if (fileUrl !== undefined) { updateFields.push(`file_url = $${paramIndex++}`); values.push(fileUrl); } if (updateFields.length === 0) { return res.status(400).json({ error: 'Нет полей для обновления' }); } updateFields.push(`updated_at = NOW()`); values.push(id, sickLeaveId); const result = await query( `UPDATE employee_sick_leaves SET ${updateFields.join(', ')} WHERE employee_id = $${paramIndex++} AND id = $${paramIndex} RETURNING id, employee_id AS "employeeId", start_date AS "startDate", end_date AS "endDate", days_count AS "daysCount", sick_leave_number AS "sickLeaveNumber", diagnosis, medical_institution AS "medicalInstitution", status, closed_at AS "closedAt", notes, file_url AS "fileUrl", expected_return_date AS "expectedReturnDate", created_at AS "createdAt", updated_at AS "updatedAt"`, values ); if (result.length === 0) { return res.status(404).json({ error: 'Больничный не найден' }); } res.json(result[0]); } catch (err) { console.error('Error updating sick leave:', err); res.status(500).json({ error: 'Failed to update sick leave' }); } }); // DELETE /api/employees/:id/sick-leaves/:sickLeaveId -> удаление больничного app.delete(`${API_PREFIX}/employees/:id/sick-leaves/:sickLeaveId`, async (req, res) => { try { const { id, sickLeaveId } = req.params; const result = await query( 'DELETE FROM employee_sick_leaves WHERE employee_id = $1 AND id = $2 RETURNING id', [id, sickLeaveId] ); if (result.length === 0) { return res.status(404).json({ error: 'Больничный не найден' }); } res.json({ success: true }); } catch (err) { console.error('Error deleting sick leave:', err); res.status(500).json({ error: 'Failed to delete sick leave' }); } }); // ========= ОТГУЛЫ И ПРОГУЛЫ ========= // GET /api/employees/:id/absences -> список отгулов и прогулов сотрудника app.get(`${API_PREFIX}/employees/:id/absences`, async (req, res) => { try { const { id } = req.params; const rows = await query( `SELECT id, employee_id AS "employeeId", absence_type AS "absenceType", start_date AS "startDate", end_date AS "endDate", start_time AS "startTime", end_time AS "endTime", days_count AS "daysCount", reason, requires_approval AS "requiresApproval", status, approved_by AS "approvedBy", approved_at AS "approvedAt", approved_signature AS "approvedSignature", rejected_by AS "rejectedBy", rejected_at AS "rejectedAt", rejection_reason AS "rejectionReason", notes, created_at AS "createdAt", updated_at AS "updatedAt" FROM employee_absences WHERE employee_id = $1 ORDER BY start_date DESC, created_at DESC`, [id] ); res.json(rows); } catch (err) { console.error('Error fetching absences:', err); res.status(500).json({ error: 'Failed to fetch absences' }); } }); // POST /api/employees/:id/absences -> создание отгула/прогула app.post(`${API_PREFIX}/employees/:id/absences`, async (req, res) => { try { const { id } = req.params; const { absenceType, startDate, endDate, startTime, endTime, daysCount, reason, requiresApproval, status, approvedBy, approvedAt, approvedSignature, notes, currentUserId, // ID текущего пользователя (сотрудника) currentUserRole // Роль текущего пользователя (DIRECTOR, HR_MANAGER, ENGINEER, MASTER и т.д.) } = req.body; if (!startDate) { return res.status(400).json({ error: 'startDate обязателен' }); } if (!absenceType || !['day_off', 'absence', 'late', 'early_leave'].includes(absenceType)) { return res.status(400).json({ error: 'Некорректный тип отсутствия' }); } // Проверка прав доступа: // Отгул (day_off): сотрудник себе, руководитель подчинённым, HR всем. // Прогул/опоздание/ранний уход (absence, late, early_leave): только руководитель подчинённым или HR всем. if (currentUserId && currentUserRole) { const isSelf = currentUserId === id; const isHrOrDirector = currentUserRole === 'HR_MANAGER' || currentUserRole === 'DIRECTOR'; let isManager = false; if (!isSelf && !isHrOrDirector) { const employeeResult = await query( 'SELECT manager_id FROM employees WHERE id = $1', [id] ); if (employeeResult.length > 0 && employeeResult[0].manager_id === currentUserId) { isManager = true; } } if (!isSelf && !isHrOrDirector && !isManager) { return res.status(403).json({ error: 'Недостаточно прав для создания отгула/пропуска для этого сотрудника' }); } // Прогул, опоздание, ранний уход — только руководитель или HR, не сам себе const onlyManagerOrHr = ['absence', 'late', 'early_leave'].includes(absenceType); if (onlyManagerOrHr && isSelf) { return res.status(403).json({ error: 'Прогул, опоздание и ранний уход может оформить только руководитель или HR' }); } } const result = await query( `INSERT INTO employee_absences (employee_id, absence_type, start_date, end_date, start_time, end_time, days_count, reason, requires_approval, status, approved_by, approved_at, approved_signature, notes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, employee_id AS "employeeId", absence_type AS "absenceType", start_date AS "startDate", end_date AS "endDate", start_time AS "startTime", end_time AS "endTime", days_count AS "daysCount", reason, requires_approval AS "requiresApproval", status, approved_by AS "approvedBy", approved_at AS "approvedAt", approved_signature AS "approvedSignature", rejected_by AS "rejectedBy", rejected_at AS "rejectedAt", rejection_reason AS "rejectionReason", notes, created_at AS "createdAt", updated_at AS "updatedAt"`, [ id, absenceType, startDate, endDate || null, startTime || null, endTime || null, daysCount || 1.0, reason || null, requiresApproval !== undefined ? requiresApproval : true, status || 'pending', approvedBy || null, approvedAt || null, approvedSignature || null, notes || null ] ); res.status(201).json(result[0]); } catch (err) { console.error('Error creating absence:', err); res.status(500).json({ error: 'Failed to create absence' }); } }); // PUT /api/employees/:id/absences/:absenceId -> обновление отгула/прогула app.put(`${API_PREFIX}/employees/:id/absences/:absenceId`, async (req, res) => { try { const { id, absenceId } = req.params; const { absenceType, startDate, endDate, startTime, endTime, daysCount, reason, requiresApproval, status, approvedBy, approvedAt, approvedSignature, rejectedBy, rejectedAt, rejectionReason, notes } = req.body; const updateFields = []; const values = []; let paramIndex = 1; if (absenceType !== undefined) { updateFields.push(`absence_type = $${paramIndex++}`); values.push(absenceType); } if (startDate !== undefined) { updateFields.push(`start_date = $${paramIndex++}`); values.push(startDate); } if (endDate !== undefined) { updateFields.push(`end_date = $${paramIndex++}`); values.push(endDate); } if (startTime !== undefined) { updateFields.push(`start_time = $${paramIndex++}`); values.push(startTime); } if (endTime !== undefined) { updateFields.push(`end_time = $${paramIndex++}`); values.push(endTime); } if (daysCount !== undefined) { updateFields.push(`days_count = $${paramIndex++}`); values.push(daysCount); } if (reason !== undefined) { updateFields.push(`reason = $${paramIndex++}`); values.push(reason); } if (requiresApproval !== undefined) { updateFields.push(`requires_approval = $${paramIndex++}`); values.push(requiresApproval); } if (status !== undefined) { updateFields.push(`status = $${paramIndex++}`); values.push(status); if (status === 'approved' && approvedBy) { updateFields.push(`approved_by = $${paramIndex++}`); values.push(approvedBy); updateFields.push(`approved_at = NOW()`); if (approvedSignature) { updateFields.push(`approved_signature = $${paramIndex++}`); values.push(approvedSignature); } } else if (status === 'rejected' && rejectedBy) { updateFields.push(`rejected_by = $${paramIndex++}`); values.push(rejectedBy); updateFields.push(`rejected_at = NOW()`); if (rejectionReason) { updateFields.push(`rejection_reason = $${paramIndex++}`); values.push(rejectionReason); } } } if (notes !== undefined) { updateFields.push(`notes = $${paramIndex++}`); values.push(notes); } if (updateFields.length === 0) { return res.status(400).json({ error: 'Нет полей для обновления' }); } updateFields.push(`updated_at = NOW()`); values.push(id, absenceId); const result = await query( `UPDATE employee_absences SET ${updateFields.join(', ')} WHERE employee_id = $${paramIndex++} AND id = $${paramIndex} RETURNING id, employee_id AS "employeeId", absence_type AS "absenceType", start_date AS "startDate", end_date AS "endDate", start_time AS "startTime", end_time AS "endTime", days_count AS "daysCount", reason, requires_approval AS "requiresApproval", status, approved_by AS "approvedBy", approved_at AS "approvedAt", approved_signature AS "approvedSignature", rejected_by AS "rejectedBy", rejected_at AS "rejectedAt", rejection_reason AS "rejectionReason", notes, created_at AS "createdAt", updated_at AS "updatedAt"`, values ); if (result.length === 0) { return res.status(404).json({ error: 'Отсутствие не найдено' }); } res.json(result[0]); } catch (err) { console.error('Error updating absence:', err); res.status(500).json({ error: 'Failed to update absence' }); } }); // DELETE /api/employees/:id/absences/:absenceId -> удаление отгула/прогула app.delete(`${API_PREFIX}/employees/:id/absences/:absenceId`, async (req, res) => { try { const { id, absenceId } = req.params; const result = await query( 'DELETE FROM employee_absences WHERE employee_id = $1 AND id = $2 RETURNING id', [id, absenceId] ); if (result.length === 0) { return res.status(404).json({ error: 'Отсутствие не найдено' }); } res.json({ success: true }); } catch (err) { console.error('Error deleting absence:', err); res.status(500).json({ error: 'Failed to delete absence' }); } }); // ========= УВОЛЬНЕНИЯ ========= // GET /api/employees/:id/terminations -> список увольнений сотрудника app.get(`${API_PREFIX}/employees/:id/terminations`, async (req, res) => { try { const { id } = req.params; const rows = await query( `SELECT id, employee_id AS "employeeId", termination_date AS "terminationDate", reason, initiated_by AS "initiatedBy", initiated_at AS "initiatedAt", status, termination_contract_number AS "terminationContractNumber", termination_contract_date AS "terminationContractDate", termination_contract_file_url AS "terminationContractFileUrl", final_settlement_amount AS "finalSettlementAmount", unused_vacation_days AS "unusedVacationDays", compensation_amount AS "compensationAmount", severance_pay AS "severancePay", other_payments AS "otherPayments", deductions, settlement_document_number AS "settlementDocumentNumber", settlement_document_date AS "settlementDocumentDate", settlement_document_file_url AS "settlementDocumentFileUrl", notes, completed_at AS "completedAt", created_at AS "createdAt", updated_at AS "updatedAt" FROM employee_terminations WHERE employee_id = $1 ORDER BY termination_date DESC`, [id] ); res.json(rows); } catch (err) { console.error('Error fetching terminations:', err); res.status(500).json({ error: 'Failed to fetch terminations' }); } }); // POST /api/employees/:id/terminations -> создание увольнения (с автоматическим созданием договора и расчетов) app.post(`${API_PREFIX}/employees/:id/terminations`, async (req, res) => { try { const { id } = req.params; const { terminationDate, reason, initiatedBy, terminationContractNumber, terminationContractDate, terminationContractFileUrl, finalSettlementAmount, unusedVacationDays, compensationAmount, severancePay, otherPayments, deductions, settlementDocumentNumber, settlementDocumentDate, settlementDocumentFileUrl, notes } = req.body; if (!terminationDate || !reason || !initiatedBy) { return res.status(400).json({ error: 'terminationDate, reason и initiatedBy обязательны' }); } // Проверяем существование сотрудника const employee = await query('SELECT id, name, salary FROM employees WHERE id = $1', [id]); if (employee.length === 0) { return res.status(404).json({ error: 'Сотрудник не найден' }); } // Автоматически рассчитываем компенсацию за неиспользованный отпуск, если не указана let calculatedCompensation = compensationAmount; if (!calculatedCompensation && unusedVacationDays) { // Примерный расчет: средний дневной заработок * количество дней const dailySalary = employee[0].salary / 30; // Упрощенный расчет calculatedCompensation = dailySalary * unusedVacationDays; } // Автоматически генерируем номер договора, если не указан const contractNumber = terminationContractNumber || `УВ-${Date.now()}`; const contractDate = terminationContractDate || terminationDate; // Автоматически генерируем номер документа расчета, если не указан const docNumber = settlementDocumentNumber || `РАС-${Date.now()}`; const docDate = settlementDocumentDate || terminationDate; const result = await query( `INSERT INTO employee_terminations ( employee_id, termination_date, reason, initiated_by, termination_contract_number, termination_contract_date, termination_contract_file_url, final_settlement_amount, unused_vacation_days, compensation_amount, severance_pay, other_payments, deductions, settlement_document_number, settlement_document_date, settlement_document_file_url, notes, status ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, 'initiated') RETURNING id, employee_id AS "employeeId", termination_date AS "terminationDate", reason, initiated_by AS "initiatedBy", initiated_at AS "initiatedAt", status, termination_contract_number AS "terminationContractNumber", termination_contract_date AS "terminationContractDate", termination_contract_file_url AS "terminationContractFileUrl", final_settlement_amount AS "finalSettlementAmount", unused_vacation_days AS "unusedVacationDays", compensation_amount AS "compensationAmount", severance_pay AS "severancePay", other_payments AS "otherPayments", deductions, settlement_document_number AS "settlementDocumentNumber", settlement_document_date AS "settlementDocumentDate", settlement_document_file_url AS "settlementDocumentFileUrl", notes, completed_at AS "completedAt", created_at AS "createdAt", updated_at AS "updatedAt"`, [ id, terminationDate, reason, initiatedBy, contractNumber, contractDate, terminationContractFileUrl || null, finalSettlementAmount || null, unusedVacationDays || null, calculatedCompensation || null, severancePay || null, otherPayments || null, deductions || null, docNumber, docDate, settlementDocumentFileUrl || null, notes || null ] ); // Обновляем статус сотрудника на 'inactive' await query('UPDATE employees SET status = $1, updated_at = NOW() WHERE id = $2', ['inactive', id]); // Переносим задачи и ответственность на руководителя await transferResponsibilityToManager(id); res.status(201).json(result[0]); } catch (err) { console.error('Error creating termination:', err); res.status(500).json({ error: 'Failed to create termination' }); } }); // PUT /api/employees/:id/terminations/:terminationId -> обновление увольнения app.put(`${API_PREFIX}/employees/:id/terminations/:terminationId`, async (req, res) => { try { const { id, terminationId } = req.params; const updateData = req.body; const updateFields = []; const values = []; let paramIndex = 1; const allowedFields = [ 'terminationDate', 'reason', 'status', 'terminationContractNumber', 'terminationContractDate', 'terminationContractFileUrl', 'finalSettlementAmount', 'unusedVacationDays', 'compensationAmount', 'severancePay', 'otherPayments', 'deductions', 'settlementDocumentNumber', 'settlementDocumentDate', 'settlementDocumentFileUrl', 'notes' ]; for (const field of allowedFields) { if (updateData[field] !== undefined) { const dbField = field.replace(/([A-Z])/g, '_$1').toLowerCase(); updateFields.push(`${dbField} = $${paramIndex++}`); values.push(updateData[field]); } } if (updateData.status === 'completed') { updateFields.push(`completed_at = NOW()`); } if (updateFields.length === 0) { return res.status(400).json({ error: 'Нет полей для обновления' }); } updateFields.push(`updated_at = NOW()`); values.push(id, terminationId); const result = await query( `UPDATE employee_terminations SET ${updateFields.join(', ')} WHERE employee_id = $${paramIndex++} AND id = $${paramIndex} RETURNING id, employee_id AS "employeeId", termination_date AS "terminationDate", reason, initiated_by AS "initiatedBy", initiated_at AS "initiatedAt", status, termination_contract_number AS "terminationContractNumber", termination_contract_date AS "terminationContractDate", termination_contract_file_url AS "terminationContractFileUrl", final_settlement_amount AS "finalSettlementAmount", unused_vacation_days AS "unusedVacationDays", compensation_amount AS "compensationAmount", severance_pay AS "severancePay", other_payments AS "otherPayments", deductions, settlement_document_number AS "settlementDocumentNumber", settlement_document_date AS "settlementDocumentDate", settlement_document_file_url AS "settlementDocumentFileUrl", notes, completed_at AS "completedAt", created_at AS "createdAt", updated_at AS "updatedAt"`, values ); if (result.length === 0) { return res.status(404).json({ error: 'Увольнение не найдено' }); } res.json(result[0]); } catch (err) { console.error('Error updating termination:', err); res.status(500).json({ error: 'Failed to update termination' }); } }); // DELETE /api/employees/:id/terminations/:terminationId -> удаление увольнения app.delete(`${API_PREFIX}/employees/:id/terminations/:terminationId`, async (req, res) => { try { const { id, terminationId } = req.params; const result = await query( 'DELETE FROM employee_terminations WHERE employee_id = $1 AND id = $2 RETURNING id', [id, terminationId] ); if (result.length === 0) { return res.status(404).json({ error: 'Увольнение не найдено' }); } res.json({ success: true }); } catch (err) { console.error('Error deleting termination:', err); res.status(500).json({ error: 'Failed to delete termination' }); } }); // ========= ПАНЕЛЬ УПРАВЛЕНИЯ: ПОРТАЛЬНЫЕ ПОЛЬЗОВАТЕЛИ ========= // GET /api/portal-users/me?login=xxx -> текущий пользователь по логину (роль для ROLE_ACCESS) app.get(`${API_PREFIX}/portal-users/me`, async (req, res) => { try { const login = (req.query.login || '').toString().trim(); if (!login) { return res.status(400).json({ error: 'Укажите login' }); } const rows = await query(` SELECT pu.id, pu.employee_id AS "employeeId", pu.login, pu.email, pu.role, e.name AS "employeeName", e.position AS "employeePosition" FROM portal_users pu JOIN employees e ON e.id = pu.employee_id WHERE pu.login = $1 `, [login]); if (rows.length === 0) { return res.status(404).json({ error: 'Пользователь не найден' }); } res.json(rows[0]); } catch (err) { console.error('Error fetching portal user by login:', err); res.status(500).json({ error: 'Failed to fetch user' }); } }); // GET /api/admin/portal-users -> список пользователей портала (сотрудник, логин, роль, permissions, scope) app.get(`${API_PREFIX}/admin/portal-users`, async (req, res) => { try { const rows = await query(` SELECT pu.id, pu.employee_id AS "employeeId", pu.login, pu.email, pu.role, pu.permissions, pu.scope, pu.created_at AS "createdAt", e.name AS "employeeName", e.position AS "employeePosition" FROM portal_users pu JOIN employees e ON e.id = pu.employee_id ORDER BY e.name `); res.json(rows.map(r => ({ ...r, scope: r.scope || 'all' }))); } catch (err) { console.error('Error fetching portal users:', err); res.status(500).json({ error: 'Failed to fetch portal users' }); } }); // POST /api/admin/portal-users -> создание пользователя (только привязанный к сотруднику) app.post(`${API_PREFIX}/admin/portal-users`, async (req, res) => { try { const { employeeId, login, email, role, password } = req.body; if (!employeeId || !login || !login.trim()) { return res.status(400).json({ error: 'employeeId и login обязательны', code: 'VALIDATION' }); } const employeeCheck = await query('SELECT id FROM employees WHERE id = $1', [employeeId]); if (employeeCheck.length === 0) { return res.status(400).json({ error: 'Сотрудник не найден. Создайте сначала сотрудника в разделе Кадры.', code: 'NO_EMPLOYEE' }); } const existingLogin = await query('SELECT id FROM portal_users WHERE login = $1', [login.trim()]); if (existingLogin.length > 0) { return res.status(400).json({ error: 'Пользователь с таким логином уже существует', code: 'LOGIN_EXISTS' }); } const existingForEmployee = await query('SELECT id FROM portal_users WHERE employee_id = $1', [employeeId]); if (existingForEmployee.length > 0) { return res.status(400).json({ error: 'У этого сотрудника уже есть пользователь портала', code: 'EMPLOYEE_HAS_USER' }); } const roleValue = (role && ['DIRECTOR', 'ENGINEER', 'MASTER', 'LAWYER', 'FINANCIER', 'HR_MANAGER', 'PR_MANAGER'].includes(role)) ? role : 'ENGINEER'; let passwordHash = null; if (password && String(password).trim()) { passwordHash = await bcrypt.hash(String(password).trim(), 10); } await query( 'INSERT INTO portal_users (employee_id, login, email, role, password_hash) VALUES ($1, $2, $3, $4, $5)', [employeeId, login.trim(), email && email.trim() ? email.trim() : null, roleValue, passwordHash] ); const created = await query(` SELECT pu.id, pu.employee_id AS "employeeId", pu.login, pu.email, pu.role, e.name AS "employeeName", e.position AS "employeePosition" FROM portal_users pu JOIN employees e ON e.id = pu.employee_id WHERE pu.employee_id = $1 `, [employeeId]); res.status(201).json(created[0] || { employeeId, login: login.trim(), email: email || null, role: roleValue }); } catch (err) { console.error('Error creating portal user:', err && err.message ? err.message : err); res.status(500).json({ error: 'Failed to create portal user' }); } }); // POST /api/admin/employees-with-user -> создать сотрудника и сразу пользователя портала app.post(`${API_PREFIX}/admin/employees-with-user`, async (req, res) => { try { const { name, position, phone, status, salary, assignedDistrictId, login, email, role } = req.body; if (!name || !position || !phone || !salary) { return res.status(400).json({ error: 'name, position, phone и salary обязательны' }); } if (!login || !login.trim()) { return res.status(400).json({ error: 'login обязателен для пользователя портала' }); } const existingLogin = await query('SELECT id FROM portal_users WHERE login = $1', [login.trim()]); if (existingLogin.length > 0) { return res.status(400).json({ error: 'Пользователь с таким логином уже существует' }); } const employeeId = `e-${Date.now()}`; const employeeStatus = status || 'active'; await query( `INSERT INTO employees (id, name, position, phone, status, salary, assigned_district_id) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [employeeId, name.trim(), position.trim(), phone.trim(), employeeStatus, Number(salary) || 0, assignedDistrictId && assignedDistrictId.trim() ? assignedDistrictId.trim() : null] ); const roleValue = (role && ['DIRECTOR', 'ENGINEER', 'MASTER', 'LAWYER', 'FINANCIER', 'HR_MANAGER', 'PR_MANAGER'].includes(role)) ? role : 'ENGINEER'; await query( 'INSERT INTO portal_users (employee_id, login, email, role) VALUES ($1, $2, $3, $4)', [employeeId, login.trim(), email && email.trim() ? email.trim() : null, roleValue] ); const portalUser = await query(` SELECT pu.id, pu.employee_id AS "employeeId", pu.login, pu.email, pu.role, e.name AS "employeeName", e.position AS "employeePosition" FROM portal_users pu JOIN employees e ON e.id = pu.employee_id WHERE pu.employee_id = $1 `, [employeeId]); res.status(201).json({ employee: { id: employeeId, name: name.trim(), position: position.trim(), phone: phone.trim(), status: employeeStatus, salary: Number(salary) || 0, assignedDistrictId: assignedDistrictId || null }, portalUser: portalUser[0], }); } catch (err) { console.error('Error creating employee with user:', err && err.message ? err.message : err); res.status(500).json({ error: 'Failed to create employee and user' }); } }); // PUT /api/admin/portal-users/:id -> обновление пользователя (логин, email, роль, permissions, scope) app.put(`${API_PREFIX}/admin/portal-users/:id`, async (req, res) => { try { const { id } = req.params; const { login, email, role, permissions, scope } = req.body; const existing = await query('SELECT id FROM portal_users WHERE id = $1', [id]); if (existing.length === 0) { return res.status(404).json({ error: 'Пользователь не найден' }); } const updates = []; const values = []; let idx = 1; if (login !== undefined) { const loginTrim = login && String(login).trim(); if (!loginTrim) { return res.status(400).json({ error: 'Логин не может быть пустым' }); } const dup = await query('SELECT id FROM portal_users WHERE login = $1 AND id != $2', [loginTrim, id]); if (dup.length > 0) { return res.status(400).json({ error: 'Пользователь с таким логином уже существует' }); } updates.push(`login = $${idx++}`); values.push(loginTrim); } if (email !== undefined) { updates.push(`email = $${idx++}`); values.push(email && String(email).trim() ? String(email).trim() : null); } if (role !== undefined) { const roleValue = ['DIRECTOR', 'ENGINEER', 'MASTER', 'LAWYER', 'FINANCIER', 'HR_MANAGER', 'PR_MANAGER'].includes(role) ? role : 'ENGINEER'; updates.push(`role = $${idx++}`); values.push(roleValue); } if (permissions !== undefined) { const perms = Array.isArray(permissions) ? permissions : null; updates.push(`permissions = $${idx++}`); values.push(perms ? JSON.stringify(perms) : null); } if (scope !== undefined) { const scopeVal = scope === 'own_district' ? 'own_district' : 'all'; updates.push(`scope = $${idx++}`); values.push(scopeVal); } if (updates.length === 0) { const row = await query(` SELECT pu.id, pu.employee_id AS "employeeId", pu.login, pu.email, pu.role, pu.permissions, pu.scope, e.name AS "employeeName", e.position AS "employeePosition" FROM portal_users pu JOIN employees e ON e.id = pu.employee_id WHERE pu.id = $1 `, [id]); return res.json({ ...row[0], scope: row[0].scope || 'all' }); } updates.push('updated_at = NOW()'); values.push(id); await query( `UPDATE portal_users SET ${updates.join(', ')} WHERE id = $${idx}`, values ); const row = await query(` SELECT pu.id, pu.employee_id AS "employeeId", pu.login, pu.email, pu.role, pu.permissions, pu.scope, e.name AS "employeeName", e.position AS "employeePosition" FROM portal_users pu JOIN employees e ON e.id = pu.employee_id WHERE pu.id = $1 `, [id]); res.json({ ...row[0], scope: row[0].scope || 'all' }); } catch (err) { console.error('Error updating portal user:', err && err.message ? err.message : err); res.status(500).json({ error: 'Failed to update portal user' }); } }); // DELETE /api/admin/portal-users/:id -> удаление пользователя портала app.delete(`${API_PREFIX}/admin/portal-users/:id`, async (req, res) => { try { const { id } = req.params; const result = await query('DELETE FROM portal_users WHERE id = $1 RETURNING id', [id]); if (result.length === 0) { return res.status(404).json({ error: 'Пользователь не найден' }); } res.json({ success: true }); } catch (err) { console.error('Error deleting portal user:', err); res.status(500).json({ error: 'Failed to delete portal user' }); } }); // ========= ПАНЕЛЬ УПРАВЛЕНИЯ: БЕЗОПАСНОСТЬ (дашборд) ========= // GET /api/admin/security/settings — настройки капчи (из БД или env) app.get(`${API_PREFIX}/admin/security/settings`, async (req, res) => { try { const rows = await query('SELECT site_key, secret_key FROM security_captcha WHERE id = 1'); const siteKey = (rows[0] && rows[0].site_key && String(rows[0].site_key).trim()) || null; const hasSecret = !!(rows[0] && rows[0].secret_key && String(rows[0].secret_key).trim()); const captchaEnabled = hasSecret || !!process.env.TURNSTILE_SECRET_KEY; res.json({ captchaEnabled, turnstileSiteKey: siteKey, turnstileSecretKeySet: hasSecret }); } catch (err) { res.status(500).json({ error: 'Ошибка загрузки настроек' }); } }); // PUT /api/admin/security/settings — сохранить ключи Turnstile (капча) app.put(`${API_PREFIX}/admin/security/settings`, async (req, res) => { try { const { turnstileSiteKey, turnstileSecretKey } = req.body || {}; const siteKey = turnstileSiteKey != null ? String(turnstileSiteKey).trim() || null : undefined; const secretKey = turnstileSecretKey !== undefined ? (String(turnstileSecretKey).trim() || null) : undefined; const existing = await query('SELECT site_key, secret_key FROM security_captcha WHERE id = 1'); if (existing.length === 0) { await query( 'INSERT INTO security_captcha (id, site_key, secret_key, updated_at) VALUES (1, $1, $2, NOW())', [siteKey !== undefined ? siteKey : null, secretKey !== undefined ? secretKey : null] ); } else { const newSiteKey = siteKey !== undefined ? siteKey : (existing[0].site_key && String(existing[0].site_key).trim()) || null; const newSecretKey = secretKey !== undefined ? secretKey : (existing[0].secret_key && String(existing[0].secret_key).trim()) || null; await query( 'UPDATE security_captcha SET site_key = $1, secret_key = $2, updated_at = NOW() WHERE id = 1', [newSiteKey, newSecretKey] ); } const rows = await query('SELECT site_key, secret_key FROM security_captcha WHERE id = 1'); const r = rows[0]; const hasSecret = !!(r && r.secret_key && String(r.secret_key).trim()); res.json({ captchaEnabled: hasSecret || !!process.env.TURNSTILE_SECRET_KEY, turnstileSiteKey: (r && r.site_key && String(r.site_key).trim()) || null, turnstileSecretKeySet: hasSecret }); } catch (err) { console.error('Error saving security settings:', err.message || err); res.status(500).json({ error: 'Ошибка сохранения настроек' }); } }); // GET /api/admin/security/logs — логи входа (пагинация, фильтр по успеху) app.get(`${API_PREFIX}/admin/security/logs`, async (req, res) => { try { const page = Math.max(1, parseInt(req.query.page, 10) || 1); const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 50)); const offset = (page - 1) * limit; const successFilter = req.query.success; const from = req.query.from; const to = req.query.to; let where = []; const params = []; let idx = 1; if (successFilter === 'true' || successFilter === 'false') { where.push(`success = $${idx++}`); params.push(successFilter === 'true'); } if (from) { where.push(`created_at >= $${idx++}`); params.push(from); } if (to) { where.push(`created_at <= $${idx++}`); params.push(to); } const whereClause = where.length ? ' WHERE ' + where.join(' AND ') : ''; const countResult = await query( `SELECT COUNT(*) AS total FROM auth_logs${whereClause}`, params ); const total = parseInt(countResult[0]?.total || 0, 10); params.push(limit, offset); const rows = await query( `SELECT id, ip, login_masked AS "loginMasked", success, created_at AS "createdAt" FROM auth_logs${whereClause} ORDER BY created_at DESC LIMIT $${idx++} OFFSET $${idx}`, params ); res.json({ items: rows, total, page, limit }); } catch (err) { console.error('Error fetching auth logs:', err.message || err); res.status(500).json({ error: 'Ошибка загрузки логов' }); } }); // GET /api/admin/security/stats — сводка для мониторинга app.get(`${API_PREFIX}/admin/security/stats`, async (req, res) => { try { const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); const failed = await query( 'SELECT COUNT(*) AS c FROM auth_logs WHERE success = false AND created_at >= $1', [since] ); const total = await query( 'SELECT COUNT(*) AS c FROM auth_logs WHERE created_at >= $1', [since] ); const blacklistCount = await query('SELECT COUNT(*) AS c FROM security_blacklist'); res.json({ failedLast24h: parseInt(failed[0]?.c || 0, 10), totalAttemptsLast24h: parseInt(total[0]?.c || 0, 10), blockedCount: parseInt(blacklistCount[0]?.c || 0, 10) }); } catch (err) { res.json({ failedLast24h: 0, totalAttemptsLast24h: 0, blockedCount: 0 }); } }); // GET /api/admin/security/blacklist — чёрный список app.get(`${API_PREFIX}/admin/security/blacklist`, async (req, res) => { try { const rows = await query( `SELECT id, type, value, reason, created_at AS "createdAt" FROM security_blacklist ORDER BY created_at DESC` ); res.json(rows); } catch (err) { console.error('Error fetching blacklist:', err.message || err); res.status(500).json({ error: 'Ошибка загрузки чёрного списка' }); } }); // POST /api/admin/security/blacklist — добавить в чёрный список app.post(`${API_PREFIX}/admin/security/blacklist`, async (req, res) => { try { const { type, value, reason } = req.body; if (!type || !value || !['ip', 'login'].includes(type)) { return res.status(400).json({ error: 'Укажите type (ip или login) и value' }); } const val = String(value).trim(); if (!val) return res.status(400).json({ error: 'value не может быть пустым' }); const rows = await query( `INSERT INTO security_blacklist (type, value, reason) VALUES ($1, $2, $3) RETURNING id, type, value, reason, created_at AS "createdAt"`, [type, val, reason && String(reason).trim() ? String(reason).trim() : null] ); res.status(201).json(rows[0]); } catch (err) { if (err.code === '23505') return res.status(400).json({ error: 'Такая запись уже есть в чёрном списке' }); console.error('Error adding to blacklist:', err.message || err); res.status(500).json({ error: 'Ошибка добавления' }); } }); // DELETE /api/admin/security/blacklist/:id — удалить из чёрного списка app.delete(`${API_PREFIX}/admin/security/blacklist/:id`, async (req, res) => { try { const { id } = req.params; const result = await query('DELETE FROM security_blacklist WHERE id = $1 RETURNING id', [id]); if (result.length === 0) return res.status(404).json({ error: 'Запись не найдена' }); res.json({ success: true }); } catch (err) { res.status(500).json({ error: 'Ошибка удаления' }); } }); // ========= ПАНЕЛЬ УПРАВЛЕНИЯ: ШАБЛОНЫ ПРАВ ========= const PERMISSION_SECTION_KEYS = ['dashboard', 'objects', 'requests', 'pr', 'finance', 'legal', 'development', 'hr', 'office', 'admin']; app.get(`${API_PREFIX}/admin/permission-templates`, async (req, res) => { try { const rows = await query(` SELECT id, name, description, permissions, scope, for_position AS "forPosition", suggested_role AS "suggestedRole", created_at AS "createdAt", updated_at AS "updatedAt" FROM permission_templates ORDER BY name `); res.json(rows.map(r => ({ ...r, permissions: r.permissions || [], scope: r.scope || 'all', forPosition: r.forPosition || null, suggestedRole: r.suggestedRole || null }))); } catch (err) { console.error('Error fetching permission templates:', err); res.status(500).json({ error: 'Failed to fetch permission templates' }); } }); app.post(`${API_PREFIX}/admin/permission-templates`, async (req, res) => { try { const { name, description, permissions, scope, forPosition, suggestedRole } = req.body; if (!name || !String(name).trim()) { return res.status(400).json({ error: 'Укажите название шаблона' }); } const perms = Array.isArray(permissions) ? permissions : []; const scopeVal = scope === 'own_district' ? 'own_district' : 'all'; const rows = await query( `INSERT INTO permission_templates (name, description, permissions, scope, for_position, suggested_role) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, name, description, permissions, scope, for_position AS "forPosition", suggested_role AS "suggestedRole", created_at AS "createdAt", updated_at AS "updatedAt"`, [ String(name).trim(), description && String(description).trim() ? String(description).trim() : null, JSON.stringify(perms), scopeVal, forPosition && String(forPosition).trim() ? String(forPosition).trim() : null, suggestedRole && ['DIRECTOR', 'ENGINEER', 'MASTER', 'LAWYER', 'FINANCIER', 'HR_MANAGER', 'PR_MANAGER'].includes(suggestedRole) ? suggestedRole : null ] ); const r = rows[0]; res.status(201).json({ ...r, permissions: r.permissions || [], scope: r.scope || 'all', forPosition: r.forPosition || null, suggestedRole: r.suggestedRole || null }); } catch (err) { console.error('Error creating permission template:', err); res.status(500).json({ error: 'Failed to create permission template' }); } }); app.put(`${API_PREFIX}/admin/permission-templates/:id`, async (req, res) => { try { const { id } = req.params; const { name, description, permissions, scope, forPosition, suggestedRole } = req.body; const existing = await query('SELECT id FROM permission_templates WHERE id = $1', [id]); if (existing.length === 0) { return res.status(404).json({ error: 'Шаблон не найден' }); } const updates = []; const values = []; let idx = 1; if (name !== undefined) { updates.push(`name = $${idx++}`); values.push(String(name).trim()); } if (description !== undefined) { updates.push(`description = $${idx++}`); values.push(description && String(description).trim() ? String(description).trim() : null); } if (permissions !== undefined) { updates.push(`permissions = $${idx++}`); values.push(JSON.stringify(Array.isArray(permissions) ? permissions : [])); } if (scope !== undefined) { updates.push(`scope = $${idx++}`); values.push(scope === 'own_district' ? 'own_district' : 'all'); } if (forPosition !== undefined) { updates.push(`for_position = $${idx++}`); values.push(forPosition && String(forPosition).trim() ? String(forPosition).trim() : null); } if (suggestedRole !== undefined) { updates.push(`suggested_role = $${idx++}`); values.push(suggestedRole && ['DIRECTOR', 'ENGINEER', 'MASTER', 'LAWYER', 'FINANCIER', 'HR_MANAGER', 'PR_MANAGER'].includes(suggestedRole) ? suggestedRole : null); } if (updates.length === 0) { const row = await query('SELECT id, name, description, permissions, scope, for_position AS "forPosition", suggested_role AS "suggestedRole", created_at AS "createdAt", updated_at AS "updatedAt" FROM permission_templates WHERE id = $1', [id]); const r = row[0]; return res.json({ ...r, permissions: r.permissions || [], scope: r.scope || 'all', forPosition: r.forPosition || null, suggestedRole: r.suggestedRole || null }); } updates.push('updated_at = NOW()'); values.push(id); await query(`UPDATE permission_templates SET ${updates.join(', ')} WHERE id = $${idx}`, values); const row = await query('SELECT id, name, description, permissions, scope, for_position AS "forPosition", suggested_role AS "suggestedRole", created_at AS "createdAt", updated_at AS "updatedAt" FROM permission_templates WHERE id = $1', [id]); const r = row[0]; res.json({ ...r, permissions: r.permissions || [], scope: r.scope || 'all', forPosition: r.forPosition || null, suggestedRole: r.suggestedRole || null }); } catch (err) { console.error('Error updating permission template:', err); res.status(500).json({ error: 'Failed to update permission template' }); } }); app.delete(`${API_PREFIX}/admin/permission-templates/:id`, async (req, res) => { try { const { id } = req.params; const result = await query('DELETE FROM permission_templates WHERE id = $1 RETURNING id', [id]); if (result.length === 0) { return res.status(404).json({ error: 'Шаблон не найден' }); } res.json({ success: true }); } catch (err) { console.error('Error deleting permission template:', err); res.status(500).json({ error: 'Failed to delete permission template' }); } }); // ========= ПАНЕЛЬ УПРАВЛЕНИЯ: НАСТРОЙКИ ИНТЕГРАЦИЙ ========= /** Получить настройки Дома.АИ из БД (integration_settings key=doma), при отсутствии — из env */ async function getDomaSettingsFromDb() { try { const rows = await query( 'SELECT enabled, config FROM integration_settings WHERE key = $1', ['doma'] ); if (rows.length > 0 && rows[0].config) { const c = rows[0].config; const apiUrl = (c.apiUrl && String(c.apiUrl).trim()) || process.env.DOMA_API_URL || ''; const token = (c.token && String(c.token).trim()) || process.env.DOMA_API_TOKEN || ''; if (apiUrl) return { apiUrl, token }; } } catch (err) { console.warn('[backend] getDomaSettingsFromDb:', err.message); } return { apiUrl: process.env.DOMA_API_URL || '', token: process.env.DOMA_API_TOKEN || '', }; } // GET /api/admin/integrations/doma -> настройки Дома.АИ (apiUrl, token из config) app.get(`${API_PREFIX}/admin/integrations/doma`, async (req, res) => { try { const rows = await query( 'SELECT enabled, config FROM integration_settings WHERE key = $1', ['doma'] ); if (rows.length === 0) { return res.json({ apiUrl: '', token: '' }); } const r = rows[0]; const config = r.config || {}; res.json({ apiUrl: config.apiUrl || '', token: config.token || '', }); } catch (err) { console.error('Error fetching doma settings:', err); res.status(500).json({ error: 'Failed to fetch doma settings' }); } }); // PUT /api/admin/integrations/doma -> сохранить настройки Дома.АИ app.put(`${API_PREFIX}/admin/integrations/doma`, async (req, res) => { try { const { apiUrl, token } = req.body; const config = { apiUrl: apiUrl != null ? String(apiUrl).trim() : '', token: token != null ? String(token).trim() : '', }; await query( `INSERT INTO integration_settings (key, name, enabled, config, updated_at) VALUES ('doma', 'Дома.АИ (заявки, API)', true, $1, NOW()) ON CONFLICT (key) DO UPDATE SET config = $1, updated_at = NOW()`, [JSON.stringify(config)] ); res.json({ success: true }); // Автоматически запускаем синхронизацию заявок после сохранения настроек (без ожидания ответа) if (config.apiUrl && config.token) { syncDomaApplications().catch((err) => { console.error('[backend] Ошибка автосинхронизации после сохранения настроек Doma AI:', err.message || err); }); } } catch (err) { console.error('Error saving doma settings:', err); res.status(500).json({ error: 'Failed to save doma settings' }); } }); // GET /api/admin/integrations/dadata -> настройки DaData (enabled, apiKey, secret из config) app.get(`${API_PREFIX}/admin/integrations/dadata`, async (req, res) => { try { const rows = await query( 'SELECT enabled, config FROM integration_settings WHERE key = $1', ['dadata'] ); if (rows.length === 0) { return res.json({ enabled: true, apiKey: '', secret: '' }); } const r = rows[0]; const config = r.config || {}; res.json({ enabled: r.enabled !== false, apiKey: config.apiKey || '', secret: config.secret || '', }); } catch (err) { console.error('Error fetching dadata settings:', err); res.status(500).json({ error: 'Failed to fetch dadata settings' }); } }); // PUT /api/admin/integrations/dadata -> сохранить настройки DaData app.put(`${API_PREFIX}/admin/integrations/dadata`, async (req, res) => { try { const { enabled, apiKey, secret } = req.body; const config = { apiKey: apiKey != null ? String(apiKey) : '', secret: secret != null ? String(secret) : '', }; await query( `INSERT INTO integration_settings (key, name, enabled, config, updated_at) VALUES ('dadata', 'DaData (проверка контрагентов по ИНН)', $1, $2, NOW()) ON CONFLICT (key) DO UPDATE SET enabled = $1, config = $2, updated_at = NOW()`, [enabled !== false, JSON.stringify(config)] ); res.json({ success: true }); } catch (err) { console.error('Error saving dadata settings:', err); res.status(500).json({ error: 'Failed to save dadata settings' }); } }); // GET /api/admin/integrations/ai-chat -> настройки ИИ-чата (enabled, url, apiKey из config) app.get(`${API_PREFIX}/admin/integrations/ai-chat`, async (req, res) => { try { const rows = await query( 'SELECT enabled, config FROM integration_settings WHERE key = $1', ['ai_chat'] ); if (rows.length === 0) { return res.json({ enabled: false, url: '', apiKey: '' }); } const r = rows[0]; const config = r.config || {}; res.json({ enabled: r.enabled !== false, url: config.url || '', apiKey: config.apiKey || '', }); } catch (err) { console.error('Error fetching ai-chat settings:', err); res.status(500).json({ error: 'Failed to fetch ai-chat settings' }); } }); // PUT /api/admin/integrations/ai-chat -> сохранить настройки ИИ-чата app.put(`${API_PREFIX}/admin/integrations/ai-chat`, async (req, res) => { try { const { enabled, url, apiKey } = req.body; const config = { url: url != null ? String(url).trim() : '', apiKey: apiKey != null ? String(apiKey) : '', }; await query( `INSERT INTO integration_settings (key, name, enabled, config, updated_at) VALUES ('ai_chat', 'ИИ-помощник (чат)', $1, $2, NOW()) ON CONFLICT (key) DO UPDATE SET enabled = $1, config = $2, updated_at = NOW()`, [enabled !== false, JSON.stringify(config)] ); res.json({ success: true }); } catch (err) { console.error('Error saving ai-chat settings:', err); res.status(500).json({ error: 'Failed to save ai-chat settings' }); } }); // ========= ПАНЕЛЬ УПРАВЛЕНИЯ: РЕЗЕРВНЫЕ КОПИИ БД ========= const backupDir = path.join(__dirname, 'backups'); if (!fs.existsSync(backupDir)) { fs.mkdirSync(backupDir, { recursive: true }); } function safeBackupFilename(name) { return /^backup-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.sql$/.test(name) ? name : null; } // POST /api/admin/backup -> создать дамп БД (pg_dump) app.post(`${API_PREFIX}/admin/backup`, async (req, res) => { try { if (!DATABASE_URL) { return res.status(503).json({ error: 'DATABASE_URL не настроен' }); } const now = new Date(); const filename = `backup-${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}-${String(now.getMinutes()).padStart(2, '0')}-${String(now.getSeconds()).padStart(2, '0')}.sql`; const filepath = path.join(backupDir, filename); const url = new URL(DATABASE_URL); const dbName = url.pathname.slice(1) || 'mkd_control_center'; const pgDump = spawn('pg_dump', [ '-h', url.hostname || 'localhost', '-p', url.port || '5432', '-U', url.username || 'postgres', '-d', dbName, '-F', 'p', '-f', filepath ], { env: { ...process.env, PGPASSWORD: url.password || '' } }); await new Promise((resolve, reject) => { pgDump.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`pg_dump exit ${code}`)))); pgDump.on('error', reject); }); res.json({ filename, createdAt: now.toISOString() }); } catch (err) { console.error('Error creating backup:', err); res.status(500).json({ error: 'Не удалось создать резервную копию. Убедитесь, что pg_dump доступен (PostgreSQL bin в PATH).', details: err.message }); } }); // GET /api/admin/backups -> список резервных копий app.get(`${API_PREFIX}/admin/backups`, async (req, res) => { try { const files = fs.readdirSync(backupDir) .filter((f) => f.endsWith('.sql')) .map((f) => { const stat = fs.statSync(path.join(backupDir, f)); return { filename: f, createdAt: stat.mtime.toISOString() }; }) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); res.json(files); } catch (err) { console.error('Error listing backups:', err); res.status(500).json({ error: 'Failed to list backups' }); } }); // GET /api/admin/backup/download/:filename -> скачать файл резервной копии app.get(`${API_PREFIX}/admin/backup/download/:filename`, (req, res) => { const filename = safeBackupFilename(req.params.filename); if (!filename) { return res.status(400).json({ error: 'Недопустимое имя файла' }); } const filepath = path.join(backupDir, filename); if (!fs.existsSync(filepath)) { return res.status(404).json({ error: 'Файл не найден' }); } res.download(filepath, filename); }); // ========= ПАНЕЛЬ УПРАВЛЕНИЯ: ЗАГРУЗКА ДАННЫХ ========= const templatesDir = path.join(__dirname, 'templates'); const ALLOWED_IMPORT_TYPES = ['districts', 'buildings', 'employees', 'accounts']; // GET /api/admin/import/templates/:type -> скачать шаблон CSV app.get(`${API_PREFIX}/admin/import/templates/:type`, (req, res) => { const type = req.params.type; if (!ALLOWED_IMPORT_TYPES.includes(type)) { return res.status(400).json({ error: 'Недопустимый тип: ' + type }); } const filepath = path.join(templatesDir, `${type}.csv`); if (!fs.existsSync(filepath)) { return res.status(404).json({ error: 'Шаблон не найден' }); } res.download(filepath, `${type}_template.csv`); }); // POST /api/admin/import/:type -> загрузка файла (CSV/XLSX) и импорт app.post(`${API_PREFIX}/admin/import/:type`, upload.single('file'), async (req, res) => { const type = req.params.type; if (!ALLOWED_IMPORT_TYPES.includes(type)) { return res.status(400).json({ error: 'Недопустимый тип: ' + type }); } if (!req.file || !req.file.path) { return res.status(400).json({ error: 'Файл не загружен' }); } const filePath = req.file.path; const ext = path.extname(req.file.originalname).toLowerCase(); const fileType = ext === '.csv' ? 'CSV' : ext === '.xlsx' || ext === '.xls' ? 'XLSX' : null; if (!fileType) { try { fs.unlinkSync(filePath); } catch (e) {} return res.status(400).json({ error: 'Поддерживаются только CSV и XLSX' }); } try { let rows = []; if (fileType === 'CSV') { rows = await fileProcessor.parseCSV(filePath, 'import'); } else { rows = await fileProcessor.parseXLSX(filePath, 'import'); } const errors = []; let created = 0; for (let i = 0; i < rows.length; i++) { const row = rows[i]; const rowNum = i + 2; try { if (type === 'districts') { const id = (row.id || row.ID || '').trim(); const name = (row.name || row.name_ru || '').trim(); const managerName = (row.manager_name || row.managerName || '').trim(); if (!id || !name || !managerName) { errors.push({ row: rowNum, message: 'Нужны id, name, manager_name', data: row }); continue; } await query( 'INSERT INTO districts (id, name, manager_name) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET name = $2, manager_name = $3', [id, name, managerName] ); created++; } else if (type === 'employees') { const id = (row.id || row.ID || `e-${Date.now()}-${i}`).trim(); const name = (row.name || row.name_ru || '').trim(); const position = (row.position || '').trim() || 'Сотрудник'; const phone = (row.phone || '').trim() || '+7'; const status = (row.status || 'active').trim(); const salary = parseFloat(row.salary) || 0; const assignedDistrictId = (row.assigned_district_id || row.assignedDistrictId || '').trim() || null; if (!name) { errors.push({ row: rowNum, message: 'Нужно имя (name)', data: row }); continue; } await 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 UPDATE SET name = $2, position = $3, phone = $4, status = $5, salary = $6, assigned_district_id = $7, updated_at = NOW()`, [id, name, position, phone, status, salary, assignedDistrictId] ); created++; } else if (type === 'buildings') { const id = (row.id || row.ID || `b-${Date.now()}-${i}`).trim(); const address = (row.address || '').trim(); const districtId = (row.district_id || row.districtId || '').trim() || null; if (!id || !address) { errors.push({ row: rowNum, message: 'Нужны id и address', data: row }); continue; } const buildingData = { id, passport: { address }, districtId: districtId || undefined, accounts: [], }; await query( 'INSERT INTO buildings (id, data) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET data = $2', [id, JSON.stringify(buildingData)] ); created++; } else if (type === 'accounts') { const buildingId = (row.building_id || row.buildingId || '').trim(); const apartmentNumber = (row.apartment_number || row.apartmentNumber || row.apartment || '').trim(); if (!buildingId || !apartmentNumber) { errors.push({ row: rowNum, message: 'Нужны building_id и apartment_number', data: row }); continue; } const buildingRows = await query('SELECT id FROM buildings WHERE id = $1', [buildingId]); if (buildingRows.length === 0) { errors.push({ row: rowNum, message: 'Дом не найден: ' + buildingId, data: row }); continue; } const existing = await query( `SELECT id FROM building_personal_accounts WHERE building_id = $1 AND data->>'apartmentNumber' = $2`, [buildingId, apartmentNumber] ); if (existing.length === 0) { const accountId = `acc-${Date.now()}-${i}`; const accountData = { id: accountId, apartmentNumber, accountNumber: `${buildingId.replace('b-', '')}00${rowNum}`, type: 'apartment', floor: 0, owners: [], registered: [], areaTotal: 0, areaLiving: 0, areaNonLiving: 0, meters: [], isMeterInstallationFeasible: false }; await query( 'INSERT INTO building_personal_accounts (id, building_id, data) VALUES ($1, $2, $3)', [accountId, buildingId, accountData] ); created++; } } } catch (err) { errors.push({ row: rowNum, message: err.message || 'Ошибка вставки', data: row }); } } try { fs.unlinkSync(filePath); } catch (e) {} res.json({ created, errors, total: rows.length }); } catch (err) { console.error('Error importing:', err); try { fs.unlinkSync(filePath); } catch (e) {} res.status(500).json({ error: 'Ошибка импорта: ' + (err.message || 'неизвестная ошибка') }); } }); // GET /api/applications -> список заявок app.get(`${API_PREFIX}/applications`, async (req, res) => { try { // Проверяем наличие колонки is_overdue const hasIsOverdue = await query(` SELECT column_name FROM information_schema.columns WHERE table_name = 'applications' AND column_name = 'is_overdue' `); const includeIsOverdue = hasIsOverdue.length > 0; const rows = await query( `SELECT id, number, status, description, address, apartment, client_name AS "clientName", created_at AS "createdAt", deadline_at AS "deadlineAt", performer_name AS "performerName" ${includeIsOverdue ? ', is_overdue AS "isOverdue"' : ''} , doma_id AS "domaId" FROM applications ORDER BY created_at DESC` ); const apps = rows.map((r) => ({ id: r.id, number: r.number, status: r.status, description: r.description, address: r.address, apartment: r.apartment, clientName: r.clientName, createdAt: r.createdAt, deadlineAt: r.deadlineAt, ...(r.performerName ? { performer: { name: r.performerName } } : {}), ...(includeIsOverdue && r.isOverdue !== undefined ? { isOverdue: r.isOverdue } : {}), ...(r.domaId ? { domaId: r.domaId } : {}), })); res.json(apps); } catch (err) { console.error('Error fetching applications:', err); res.status(500).json({ error: 'Failed to fetch applications' }); } }); // GET /api/applications/:id -> одна заявка (карточка) app.get(`${API_PREFIX}/applications/:id`, async (req, res) => { try { const { id } = req.params; const rows = await pool.query('SELECT * FROM applications WHERE id = $1 LIMIT 1', [id]); if (!rows.rows || rows.rows.length === 0) { return res.status(404).json({ error: 'Заявка не найдена' }); } const r = rows.rows[0]; const app = { id: r.id, number: r.number, status: r.status, description: r.description || '', address: r.address || '', apartment: r.apartment || '', clientName: r.client_name || '', createdAt: r.created_at, deadlineAt: r.deadline_at, ...(r.performer_name ? { performer: { name: r.performer_name } } : {}), ...(r.is_overdue !== undefined ? { isOverdue: r.is_overdue } : {}), ...(r.doma_id ? { domaId: r.doma_id } : {}), ...(r.source ? { source: r.source } : {}), ...(r.source_channel != null ? { sourceChannel: r.source_channel } : {}), ...(r.is_from_resident != null ? { isFromResident: r.is_from_resident } : {}), ...(r.contact_phone != null ? { contactPhone: r.contact_phone } : {}), ...(r.contact_name != null ? { contactName: r.contact_name } : {}), ...(r.place_incident != null ? { placeIncident: r.place_incident } : {}), ...(r.work_type != null ? { workType: r.work_type } : {}), ...(r.problem_detail != null ? { problemDetail: r.problem_detail } : {}), ...(r.is_emergency != null ? { isEmergency: r.is_emergency } : {}), ...(r.is_paid != null ? { isPaid: r.is_paid } : {}), ...(r.is_warranty != null ? { isWarranty: r.is_warranty } : {}), ...(r.executor_name != null ? { executorName: r.executor_name } : {}), ...(r.responsible_name != null ? { responsibleName: r.responsible_name } : {}), ...(r.observers_text != null ? { observersText: r.observers_text } : {}), ...(r.show_in_app != null ? { showInApp: r.show_in_app } : {}), ...(r.building_id ? { buildingId: r.building_id } : {}), ...(r.updated_at ? { updatedAt: r.updated_at } : {}), }; res.json(app); } catch (err) { console.error('Error fetching application:', err); res.status(500).json({ error: err.message || 'Failed to fetch application' }); } }); // PATCH /api/applications/:id -> обновление заявки (статус, срок, исполнитель и т.д.) + история app.patch(`${API_PREFIX}/applications/:id`, async (req, res) => { try { const { id } = req.params; const body = req.body || {}; const changedBy = body.changedBy || 'Администратор'; const current = await pool.query('SELECT * FROM applications WHERE id = $1 LIMIT 1', [id]); if (!current.rows || current.rows.length === 0) { return res.status(404).json({ error: 'Заявка не найдена' }); } const row = current.rows[0]; const logHistory = async (fieldName, oldVal, newVal) => { if (String(oldVal) === String(newVal)) return; try { await pool.query( `INSERT INTO application_history (application_id, changed_by, field_name, old_value, new_value) VALUES ($1, $2, $3, $4, $5)`, [id, changedBy, fieldName, oldVal != null ? String(oldVal) : null, newVal != null ? String(newVal) : null] ); } catch (e) { console.warn('[application_history]', e.message); } }; const updates = []; const values = []; let idx = 1; // Отмена и отложение — только с причиной; отложение — ещё и с новой датой const statusReason = (body.statusReason || body.status_reason || '').trim(); if (body.status !== undefined && body.status !== row.status) { if (body.status === 'canceled') { if (!statusReason) { return res.status(400).json({ error: 'Укажите причину отмены заявки' }); } updates.push(`status = $${idx}`); values.push(body.status); idx++; await logHistory('status_change', row.status, `Заявка отменена. Причина: ${statusReason}`); } else if (body.status === 'deferred') { if (!statusReason) { return res.status(400).json({ error: 'Укажите причину отложения заявки' }); } if (!body.deadlineAt) { return res.status(400).json({ error: 'Укажите новую дату переноса заявки' }); } const newDeadline = new Date(body.deadlineAt).toISOString(); const oldDeadlineStr = row.deadline_at ? new Date(row.deadline_at).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '—'; const newDeadlineStr = new Date(body.deadlineAt).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' }); updates.push(`status = $${idx}`); values.push(body.status); idx++; updates.push(`deadline_at = $${idx}`); values.push(body.deadlineAt); idx++; await logHistory('status_change', row.status, `Заявка отложена (с ${oldDeadlineStr} до ${newDeadlineStr}) по причине ${statusReason}`); } else { await logHistory('status', row.status, body.status); updates.push(`status = $${idx}`); values.push(body.status); idx++; } } if (body.deadlineAt !== undefined && body.status !== 'deferred') { const newDeadline = new Date(body.deadlineAt).toISOString(); const oldDeadline = row.deadline_at ? new Date(row.deadline_at).toISOString() : null; if (oldDeadline !== newDeadline) await logHistory('deadline_at', oldDeadline, newDeadline); updates.push(`deadline_at = $${idx}`); values.push(body.deadlineAt); idx++; } if (body.executorName !== undefined) { if (String(row.performer_name || '') !== String(body.executorName || '')) await logHistory('executor_name', row.performer_name, body.executorName); updates.push(`performer_name = $${idx}`); values.push(body.executorName || null); idx++; } if (body.responsibleName !== undefined) { if (row.responsible_name !== body.responsibleName) await logHistory('responsible_name', row.responsible_name, body.responsibleName); updates.push(`responsible_name = $${idx}`); values.push(body.responsibleName || null); idx++; } if (body.observersText !== undefined) { updates.push(`observers_text = $${idx}`); values.push(body.observersText || null); idx++; } if (body.address !== undefined) { updates.push(`address = $${idx}`); values.push(body.address); idx++; } if (body.buildingId !== undefined) { updates.push(`building_id = $${idx}`); values.push(body.buildingId || null); idx++; } if (body.apartment !== undefined) { updates.push(`apartment = $${idx}`); values.push(body.apartment || null); idx++; } if (body.contactName !== undefined) { updates.push(`contact_name = $${idx}`); values.push(body.contactName || null); idx++; } if (body.contactPhone !== undefined) { updates.push(`contact_phone = $${idx}`); values.push(body.contactPhone || null); idx++; } if (body.description !== undefined) { updates.push(`description = $${idx}`); values.push(body.description); idx++; } if (updates.length === 0) { const rows = await pool.query('SELECT * FROM applications WHERE id = $1 LIMIT 1', [id]); const r = rows.rows[0]; return res.json(mapApplicationRow(r)); } updates.push(`updated_at = NOW()`); values.push(id); await pool.query( `UPDATE applications SET ${updates.join(', ')} WHERE id = $${idx}`, values ); const updated = await pool.query('SELECT * FROM applications WHERE id = $1 LIMIT 1', [id]); const appRow = updated.rows[0]; try { const userIds = []; if (appRow.employee_id) { const uid = await notificationService.resolveEmployeeIdToUserId(pool, appRow.employee_id); if (uid) userIds.push(uid); } const names = [appRow.performer_name, appRow.responsible_name].filter(Boolean); if (names.length) { const byName = await notificationService.resolveEmployeeNamesToUserIds(pool, names); userIds.push(...byName); } const uniqueUserIds = [...new Set(userIds)]; const title = body.status !== undefined ? `Заявка №${appRow.number}: смена статуса` : body.deadlineAt ? `Заявка №${appRow.number}: изменён срок` : `Заявка №${appRow.number}: обновление`; const bodyText = body.status !== undefined ? `Статус: ${body.status}` : body.deadlineAt ? `Новый срок: ${new Date(body.deadlineAt).toLocaleDateString('ru-RU')}` : 'Данные заявки изменены'; const opts = { type: 'application_update', title, body: bodyText, entityType: 'application', entityId: String(id) }; if (uniqueUserIds.length > 0) { await notificationService.createNotificationForUserIds(pool, uniqueUserIds, opts); } else { await notificationService.createNotificationForResponsibleZone(pool, 'requests', 'registry', opts); } } catch (notifErr) { console.warn('[notifications] application PATCH:', notifErr.message); } res.json(mapApplicationRow(appRow)); } catch (err) { console.error('Error updating application:', err); res.status(500).json({ error: err.message || 'Failed to update application' }); } }); function mapApplicationRow(r) { if (!r) return null; return { id: r.id, number: r.number, status: r.status, description: r.description || '', address: r.address || '', apartment: r.apartment || '', clientName: r.client_name || '', createdAt: r.created_at, deadlineAt: r.deadline_at, ...(r.performer_name ? { performer: { name: r.performer_name } } : {}), ...(r.is_overdue !== undefined ? { isOverdue: r.is_overdue } : {}), ...(r.doma_id ? { domaId: r.doma_id } : {}), ...(r.source ? { source: r.source } : {}), ...(r.source_channel != null ? { sourceChannel: r.source_channel } : {}), ...(r.contact_phone != null ? { contactPhone: r.contact_phone } : {}), ...(r.contact_name != null ? { contactName: r.contact_name } : {}), ...(r.place_incident != null ? { placeIncident: r.place_incident } : {}), ...(r.work_type != null ? { workType: r.work_type } : {}), ...(r.problem_detail != null ? { problemDetail: r.problem_detail } : {}), ...(r.executor_name != null ? { executorName: r.executor_name } : {}), ...(r.responsible_name != null ? { responsibleName: r.responsible_name } : {}), ...(r.observers_text != null ? { observersText: r.observers_text } : {}), }; } // GET /api/applications/:id/history -> история изменений заявки app.get(`${API_PREFIX}/applications/:id/history`, async (req, res) => { try { const { id } = req.params; const rows = await pool.query( `SELECT id, application_id AS "applicationId", changed_by AS "changedBy", changed_at AS "changedAt", field_name AS "fieldName", old_value AS "oldValue", new_value AS "newValue" FROM application_history WHERE application_id = $1 ORDER BY changed_at DESC`, [id] ); res.json(rows.rows || []); } catch (err) { console.error('Error fetching application history:', err); res.status(500).json({ error: err.message || 'Failed to fetch history' }); } }); // GET /api/applications/:id/comments -> комментарии (type: internal | resident) app.get(`${API_PREFIX}/applications/:id/comments`, async (req, res) => { try { const { id } = req.params; const { type } = req.query; let sql = `SELECT id, application_id AS "applicationId", author_name AS "authorName", type, text, created_at AS "createdAt" FROM application_comments WHERE application_id = $1`; const params = [id]; if (type === 'internal' || type === 'resident') { sql += ' AND type = $2'; params.push(type); } sql += ' ORDER BY created_at DESC'; const rows = await pool.query(sql, params); res.json(rows.rows || []); } catch (err) { if (err.message && err.message.includes('application_comments')) { return res.json([]); } console.error('Error fetching comments:', err); res.status(500).json({ error: err.message || 'Failed to fetch comments' }); } }); // POST /api/applications/:id/comments -> добавить комментарий (internal — без жителя) app.post(`${API_PREFIX}/applications/:id/comments`, async (req, res) => { try { const { id } = req.params; const body = req.body || {}; const text = (body.text || '').trim(); const type = body.type === 'resident' ? 'resident' : 'internal'; const authorName = body.authorName || body.author_name || 'Администратор'; if (!text) { return res.status(400).json({ error: 'Текст комментария обязателен' }); } const rows = await pool.query( `INSERT INTO application_comments (application_id, author_name, type, text) VALUES ($1, $2, $3, $4) RETURNING id, application_id AS "applicationId", author_name AS "authorName", type, text, created_at AS "createdAt"`, [id, authorName, type, text] ); try { const appRows = await pool.query('SELECT number, employee_id, performer_name, responsible_name FROM applications WHERE id = $1', [id]); if (appRows.rows.length > 0) { const appRow = appRows.rows[0]; const userIds = []; if (appRow.employee_id) { const uid = await notificationService.resolveEmployeeIdToUserId(pool, appRow.employee_id); if (uid) userIds.push(uid); } const names = [appRow.performer_name, appRow.responsible_name].filter(Boolean); if (names.length) { const byName = await notificationService.resolveEmployeeNamesToUserIds(pool, names); userIds.push(...byName); } const uniqueUserIds = [...new Set(userIds)]; const opts = { type: 'application_comment', title: `Заявка №${appRow.number}: новый комментарий`, body: text.slice(0, 80) + (text.length > 80 ? '…' : ''), entityType: 'application', entityId: String(id) }; if (uniqueUserIds.length > 0) { await notificationService.createNotificationForUserIds(pool, uniqueUserIds, opts); } else { await notificationService.createNotificationForResponsibleZone(pool, 'requests', 'registry', opts); } } } catch (notifErr) { console.warn('[notifications] application comment:', notifErr.message); } res.status(201).json(rows.rows[0]); } catch (err) { if (err.message && err.message.includes('application_comments')) { return res.status(503).json({ error: 'Таблица комментариев ещё не создана. Перезапустите сервер.' }); } console.error('Error adding comment:', err); res.status(500).json({ error: err.message || 'Failed to add comment' }); } }); // POST /api/applications -> создание заявки вручную (диспетчерская) app.post(`${API_PREFIX}/applications`, async (req, res) => { try { const body = req.body || {}; const address = (body.address || '').trim(); const description = (body.description || '').trim(); if (!address || !description) { return res.status(400).json({ error: 'Обязательные поля: address, description' }); } const deadlineAt = body.deadlineAt ? new Date(body.deadlineAt) : (() => { const d = new Date(); d.setDate(d.getDate() + 8); return d; })(); const number = 'M-' + Date.now(); const clientName = (body.contactName || body.clientName || '').trim() || 'Не указан'; const apartment = (body.apartment || '').trim() || ''; const performerName = body.executorName || null; // Сначала пробуем полный INSERT (с source и полями карточки) let inserted = false; const fullParams = [ number, description, address, apartment, clientName, deadlineAt, performerName, body.sourceChannel || null, body.isFromResident !== false, body.contactPhone || null, body.contactName || null, body.placeIncident || null, body.workType || null, body.problemDetail || null, !!body.isEmergency, !!body.isPaid, !!body.isWarranty, body.executorName || null, body.responsibleName || null, body.observersText || null, !!body.showInApp, body.buildingId || null ]; try { const fullRes = await pool.query( `INSERT INTO applications ( number, status, description, address, apartment, client_name, created_at, deadline_at, performer_name, source, source_channel, is_from_resident, contact_phone, contact_name, place_incident, work_type, problem_detail, is_emergency, is_paid, is_warranty, executor_name, responsible_name, observers_text, show_in_app, building_id ) VALUES ( $1, 'new', $2, $3, $4, $5, NOW(), $6, $7, 'manual', $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22 ) RETURNING id, number, status, description, address, apartment, client_name AS "clientName", created_at AS "createdAt", deadline_at AS "deadlineAt", performer_name AS "performerName"`, fullParams ); if (fullRes.rows && fullRes.rows.length > 0) { inserted = true; const row = fullRes.rows[0]; try { const names = [performerName, body.responsibleName].filter(Boolean); const userIds = names.length ? await notificationService.resolveEmployeeNamesToUserIds(pool, names) : []; const opts = { type: 'application_new', title: `Новая заявка №${row.number}`, body: description.slice(0, 80) + (description.length > 80 ? '…' : ''), entityType: 'application', entityId: String(row.id) }; if (userIds.length > 0) { await notificationService.createNotificationForUserIds(pool, userIds, opts); } else { await notificationService.createNotificationForResponsibleZone(pool, 'requests', 'registry', opts); } } catch (notifErr) { console.warn('[notifications] application POST:', notifErr.message); } const app = { id: row.id, number: row.number, status: row.status, description: row.description, address: row.address, apartment: row.apartment, clientName: row.clientName, createdAt: row.createdAt, deadlineAt: row.deadlineAt, ...(row.performerName ? { performer: { name: row.performerName } } : {}), }; return res.status(201).json(app); } } catch (fullErr) { console.warn('[POST /applications] Full INSERT failed (missing columns?), fallback to minimal:', fullErr.message); } // Fallback: минимальный INSERT (только колонки из базовой схемы) if (!inserted) { const minRes = await pool.query( `INSERT INTO applications (number, status, description, address, apartment, client_name, created_at, deadline_at, performer_name) VALUES ($1, 'new', $2, $3, $4, $5, NOW(), $6, $7) RETURNING id, number, status, description, address, apartment, client_name AS "clientName", created_at AS "createdAt", deadline_at AS "deadlineAt", performer_name AS "performerName"`, [number, description, address, apartment, clientName, deadlineAt, performerName] ); if (!minRes.rows || minRes.rows.length === 0) { return res.status(500).json({ error: 'Не удалось создать заявку в БД' }); } const row = minRes.rows[0]; try { const names = [performerName].filter(Boolean); const userIds = names.length ? await notificationService.resolveEmployeeNamesToUserIds(pool, names) : []; const opts = { type: 'application_new', title: `Новая заявка №${row.number}`, body: description.slice(0, 80) + (description.length > 80 ? '…' : ''), entityType: 'application', entityId: String(row.id) }; if (userIds.length > 0) { await notificationService.createNotificationForUserIds(pool, userIds, opts); } else { await notificationService.createNotificationForResponsibleZone(pool, 'requests', 'registry', opts); } } catch (notifErr) { console.warn('[notifications] application POST:', notifErr.message); } const app = { id: row.id, number: row.number, status: row.status, description: row.description, address: row.address, apartment: row.apartment, clientName: row.clientName, createdAt: row.createdAt, deadlineAt: row.deadlineAt, ...(row.performerName ? { performer: { name: row.performerName } } : {}), }; return res.status(201).json(app); } } catch (err) { console.error('Error creating application:', err); res.status(500).json({ error: err.message || 'Failed to create application' }); } }); // ====== СИНХРОНИЗАЦИЯ ЗАЯВОК ИЗ DOMA AI ====== /** * Нормализация адреса для сравнения (убирает лишние пробелы, приводит к нижнему регистру, убирает сокращения) */ function normalizeAddress(address) { if (!address) return ''; return address .toLowerCase() .trim() .replace(/\s+/g, ' ') // Множественные пробелы в один .replace(/г\./g, 'г ') // г. -> г .replace(/ул\./g, 'ул ') // ул. -> ул .replace(/д\./g, 'д ') // д. -> д .replace(/пр\./g, 'пр ') // пр. -> пр .replace(/[.,]/g, '') // Убираем точки и запятые .trim(); } /** * Нормализация имени для сравнения */ function normalizeName(name) { if (!name) return ''; return name .toLowerCase() .trim() .replace(/\s+/g, ' ') // Множественные пробелы в один .trim(); } /** * Поиск похожего дома по нормализованному адресу */ async function findSimilarBuilding(pool, domaAddress) { const normalized = normalizeAddress(domaAddress); // Сначала проверяем сохранённые сопоставления const mappingResult = await pool.query( `SELECT building_id FROM doma_address_mappings WHERE LOWER(TRIM(doma_address)) = LOWER(TRIM($1)) LIMIT 1`, [domaAddress] ); if (mappingResult.rows.length > 0) { return mappingResult.rows[0].building_id; } // Ищем по точному совпадению нормализованного адреса try { const exactResult = await pool.query( `SELECT id, data->'passport'->>'address' as address FROM buildings WHERE normalize_address(COALESCE(data->'passport'->>'address', '')) = normalize_address($1) LIMIT 1`, [domaAddress] ); if (exactResult.rows.length > 0) { return exactResult.rows[0].id; } } catch (err) { // Если функция normalize_address не существует, используем простой поиск const exactResult = await pool.query( `SELECT id FROM buildings WHERE LOWER(TRIM(COALESCE(data->'passport'->>'address', ''))) = LOWER(TRIM($1)) LIMIT 1`, [domaAddress] ); if (exactResult.rows.length > 0) { return exactResult.rows[0].id; } } // Ищем по частичному совпадению (извлекаем ключевые слова: улица и номер дома) const addressParts = normalized.match(/(?:ул|улица|проспект|пр|проезд|переулок|пер)\s+([^,\s]+).*?(?:д|дом)\s*(\d+)/); if (addressParts && addressParts.length >= 3) { const street = addressParts[1]; const houseNumber = addressParts[2]; try { const similarResult = await pool.query( `SELECT id, data->'passport'->>'address' as address FROM buildings WHERE normalize_address(COALESCE(data->'passport'->>'address', '')) LIKE $1 AND normalize_address(COALESCE(data->'passport'->>'address', '')) LIKE $2 LIMIT 5`, [`%${street}%`, `%д ${houseNumber}%`] ); if (similarResult.rows.length > 0) { // Возвращаем первый результат (самый похожий) return similarResult.rows[0].id; } } catch (err) { // Fallback на простой поиск const similarResult = await pool.query( `SELECT id FROM buildings WHERE LOWER(COALESCE(data->'passport'->>'address', '')) LIKE $1 AND LOWER(COALESCE(data->'passport'->>'address', '')) LIKE $2 LIMIT 5`, [`%${street}%`, `%д ${houseNumber}%`] ); if (similarResult.rows.length > 0) { return similarResult.rows[0].id; } } } return null; } /** * Поиск похожего сотрудника по нормализованному имени */ async function findSimilarEmployee(pool, domaName) { const normalized = normalizeName(domaName); // Сначала проверяем сохранённые сопоставления const mappingResult = await pool.query( `SELECT employee_id FROM doma_employee_mappings WHERE LOWER(TRIM(doma_name)) = LOWER(TRIM($1)) LIMIT 1`, [domaName] ); if (mappingResult.rows.length > 0) { return mappingResult.rows[0].employee_id; } // Ищем по точному совпадению нормализованного имени try { const exactResult = await pool.query( `SELECT id FROM employees WHERE normalize_name(name) = normalize_name($1) LIMIT 1`, [domaName] ); if (exactResult.rows.length > 0) { return exactResult.rows[0].id; } } catch (err) { // Если функция normalize_name не существует, используем простой поиск const exactResult = await pool.query( `SELECT id FROM employees WHERE LOWER(TRIM(name)) = LOWER(TRIM($1)) LIMIT 1`, [domaName] ); if (exactResult.rows.length > 0) { return exactResult.rows[0].id; } } // Ищем по частичному совпадению (первые слова имени) const nameWords = normalized.split(' ').filter(w => w.length > 2); if (nameWords.length > 0) { const firstWord = nameWords[0]; const similarResult = await pool.query( `SELECT id, name FROM employees WHERE LOWER(name) LIKE $1 LIMIT 5`, [`%${firstWord}%`] ); if (similarResult.rows.length > 0) { // Возвращаем первый результат return similarResult.rows[0].id; } } return null; } /** * Маппинг статуса из Doma AI в наш enum doma_application_status * Ориентируемся на поле type (английские значения) */ function mapDomaStatusToLocal(type, name) { const statusType = (type || '').toString().trim().toLowerCase(); // Если ничего не пришло – считаем новой if (!statusType) { console.warn(`[mapDomaStatusToLocal] Пустой type, возвращаем 'new'. name: ${name}`); return 'new'; } // Маппинг по типу статуса из Doma AI (точные совпадения сначала) // canceled → canceled (проверяем первым, чтобы не попасть в другие категории) if (statusType === 'canceled' || statusType === 'cancelled') { return 'canceled'; } // completed → done if (statusType === 'completed') { return 'done'; } // closed → done if (statusType === 'closed') { return 'done'; } // processing → in_progress if (statusType === 'processing') { return 'in_progress'; } // deferred → deferred (отложена) if (statusType === 'deferred') { return 'deferred'; } // new_or_reopened → new (точное совпадение) if (statusType === 'new_or_reopened') { return 'new'; } // Fallback: если type содержит ключевые слова (более общие проверки) if (statusType.includes('cancel')) { return 'canceled'; } if (statusType.includes('done') || statusType.includes('complete')) { return 'done'; } if (statusType.includes('progress') || statusType.includes('work')) { return 'in_progress'; } if (statusType.includes('new')) { return 'new'; } // По умолчанию считаем "в работе", чтобы не терять активные console.warn(`[mapDomaStatusToLocal] Неизвестный тип статуса: "${type}" (name: "${name}"), возвращаем 'in_progress'`); return 'in_progress'; } /** * Синхронизация заявок из Doma AI в таблицу applications */ async function syncDomaApplications() { const { apiUrl, token } = await getDomaSettingsFromDb(); if (!apiUrl || !token) { console.warn('[backend] Настройки Дома.АИ (apiUrl/токен) не заданы в панели интеграций или в env, синхронизация пропущена'); return { synced: 0 }; } console.log('[backend] Запуск синхронизации заявок из Doma AI…'); const queryText = ` query GetTickets { objs: allTickets { id number status { id name type } details createdAt updatedAt deadline property { id address } client { id name } contact { id name email phone } assignee { id name } } } `; try { const response = await axios.post( apiUrl, { query: queryText, variables: {} }, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, } ); const data = response.data; if (data.errors && data.errors.length) { console.error('[backend] Ошибка Doma AI при получении заявок:', data.errors); throw new Error(data.errors[0].message || 'GraphQL error from Doma AI'); } const tickets = (data.data && data.data.objs) || []; console.log(`[backend] Получено ${tickets.length} заявок из Doma AI, начинаем запись в БД…`); await pool.query('BEGIN'); // Проверяем наличие новых колонок один раз перед циклом const hasNewColumns = await pool.query(` SELECT column_name FROM information_schema.columns WHERE table_name = 'applications' AND column_name IN ('building_id', 'employee_id', 'is_overdue', 'updated_at', 'doma_id') `); const hasBuildingId = hasNewColumns.rows.some(r => r.column_name === 'building_id'); const hasEmployeeId = hasNewColumns.rows.some(r => r.column_name === 'employee_id'); const hasIsOverdue = hasNewColumns.rows.some(r => r.column_name === 'is_overdue'); const hasUpdatedAt = hasNewColumns.rows.some(r => r.column_name === 'updated_at'); const hasDomaId = hasNewColumns.rows.some(r => r.column_name === 'doma_id'); // Добавляем колонку doma_id если её нет if (!hasDomaId) { try { await pool.query(`ALTER TABLE applications ADD COLUMN IF NOT EXISTS doma_id VARCHAR(255)`); console.log('[syncDomaApplications] Добавлена колонка doma_id'); } catch (err) { console.warn('[syncDomaApplications] Ошибка при добавлении колонки doma_id:', err.message); } } // Удаляем только заявки из Doma AI (doma_id IS NOT NULL), ручные заявки (source=manual, doma_id=NULL) не трогаем try { const hasDomaIdNow = await pool.query(` SELECT 1 FROM information_schema.columns WHERE table_name = 'applications' AND column_name = 'doma_id' `); if (hasDomaIdNow.rows.length > 0) { await pool.query(`DELETE FROM applications WHERE doma_id IS NOT NULL`); console.log('[syncDomaApplications] Удалены только заявки из Doma AI, ручные заявки сохранены'); } else { await pool.query('DELETE FROM applications'); } } catch (delErr) { await pool.query('DELETE FROM applications'); } const useNewColumns = hasBuildingId && hasEmployeeId && hasIsOverdue && hasUpdatedAt; if (!useNewColumns) { console.warn('[syncDomaApplications] Новые колонки (building_id, is_overdue, updated_at) не найдены. Используется старый формат. Выполните миграцию БД через dbInit.'); } const now = new Date(); let overdueCount = 0; let buildingsLinked = 0; let employeesLinked = 0; for (const ticket of tickets) { // Передаём type и name в функцию маппинга (ориентируемся на type) const localStatus = mapDomaStatusToLocal(ticket.status?.type, ticket.status?.name); // Логируем для отладки console.log(`[syncDomaApplications] Заявка #${ticket.number}: type="${ticket.status?.type}", name="${ticket.status?.name}" → localStatus="${localStatus}"`); const number = (ticket.number != null ? String(ticket.number) : ticket.id) || ''; const description = ticket.details || 'Без описания'; // Используем данные напрямую из ответа Doma AI const address = ticket.property?.address || 'Адрес не указан'; const apartment = '—'; // Используем contact.name если есть, иначе client.name const clientName = ticket.contact?.name || ticket.client?.name || 'Клиент не указан'; const domaId = ticket.id; // Сохраняем ID из Doma.AI для формирования ссылки const createdAt = ticket.createdAt || new Date().toISOString(); const deadlineAt = ticket.deadline || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); // Используем данные напрямую из ответа Doma AI: assignee.name const performerName = ticket.assignee?.name || null; // Ищем сотрудника по имени из assignee.name (данные из Doma AI) let employeeId = null; if (performerName && performerName !== 'Клиент не указан') { try { const performerTrimmed = performerName.trim(); // Используем функцию поиска похожих записей const similarEmployeeId = await findSimilarEmployee(pool, performerTrimmed); if (similarEmployeeId) { employeeId = similarEmployeeId; // Сохраняем сопоставление, если его ещё нет await pool.query( `INSERT INTO doma_employee_mappings (doma_name, employee_id) VALUES ($1, $2) ON CONFLICT (doma_name) DO NOTHING`, [performerTrimmed, employeeId] ); } else { // Не создаём сотрудника автоматически — добавляем в ожидающие сопоставления try { const existingPending = await pool.query( `SELECT id FROM pending_doma_mappings WHERE type = 'employee' AND doma_value = $1 AND resolved_at IS NULL LIMIT 1`, [performerTrimmed] ); if (existingPending.rows.length === 0) { await pool.query( `INSERT INTO pending_doma_mappings (type, doma_value) VALUES ('employee', $1)`, [performerTrimmed] ); console.log(`[syncDomaApplications] Добавлена ожидающая запись сопоставления сотрудника "${performerTrimmed}"`); } } catch (pendingErr) { console.warn( `[syncDomaApplications] Ошибка при добавлении ожидающего сопоставления сотрудника "${performerTrimmed}":`, pendingErr.message ); } } } catch (err) { console.warn(`[syncDomaApplications] Ошибка обработки сотрудника "${performerName}":`, err.message); } } // Проверяем просрочку: заявка просрочена, если deadline прошёл и статус не 'done' и не 'canceled' const deadlineDate = new Date(deadlineAt); const isOverdue = deadlineDate < now && localStatus !== 'done' && localStatus !== 'canceled'; if (isOverdue) { overdueCount++; } // Ищем дом по адресу из property.address (данные из Doma AI) let buildingId = null; if (address && address !== 'Адрес не указан') { try { const addressTrimmed = address.trim(); // Используем функцию поиска похожих записей const similarBuildingId = await findSimilarBuilding(pool, addressTrimmed); if (similarBuildingId) { buildingId = similarBuildingId; buildingsLinked++; // Сохраняем сопоставление, если его ещё нет await pool.query( `INSERT INTO doma_address_mappings (doma_address, building_id) VALUES ($1, $2) ON CONFLICT (doma_address) DO NOTHING`, [addressTrimmed, buildingId] ); } else { // Не создаём дом автоматически — добавляем в ожидающие сопоставления try { const existingPending = await pool.query( `SELECT id FROM pending_doma_mappings WHERE type = 'building' AND doma_value = $1 AND resolved_at IS NULL LIMIT 1`, [addressTrimmed] ); if (existingPending.rows.length === 0) { await pool.query( `INSERT INTO pending_doma_mappings (type, doma_value) VALUES ('building', $1)`, [addressTrimmed] ); console.log(`[syncDomaApplications] Добавлена ожидающая запись сопоставления дома "${addressTrimmed}"`); } } catch (pendingErr) { console.warn( `[syncDomaApplications] Ошибка при добавлении ожидающего сопоставления дома "${addressTrimmed}":`, pendingErr.message ); } } } catch (err) { console.warn(`[syncDomaApplications] Ошибка обработки дома "${address}":`, err.message); } } // Используем соответствующий формат INSERT в зависимости от наличия колонок if (useNewColumns) { await pool.query( `INSERT INTO applications (number, status, description, address, apartment, client_name, created_at, deadline_at, performer_name, employee_id, building_id, is_overdue, updated_at, doma_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, [ number, localStatus, description, address, apartment, clientName, createdAt, deadlineAt, performerName, employeeId, buildingId, isOverdue, now.toISOString(), domaId, ] ); } else { // Используем старый формат, но добавляем doma_id если колонка есть if (hasDomaId) { await pool.query( `INSERT INTO applications (number, status, description, address, apartment, client_name, created_at, deadline_at, performer_name, doma_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, [ number, localStatus, description, address, apartment, clientName, createdAt, deadlineAt, performerName, domaId, ] ); } else { await pool.query( `INSERT INTO applications (number, status, description, address, apartment, client_name, created_at, deadline_at, performer_name) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [ number, localStatus, description, address, apartment, clientName, createdAt, deadlineAt, performerName, ] ); } } } await pool.query('COMMIT'); console.log(`[backend] Синхронизация завершена: ${tickets.length} заявок, ${overdueCount} просрочено, ${buildingsLinked} связано с домами, ${employeesLinked} связано с сотрудниками`); console.log('[backend] Синхронизация заявок из Doma AI завершена успешно'); // После синхронизации пересчитываем статистику производительности try { await calculatePerformanceStats(); console.log('[backend] Статистика производительности пересчитана'); } catch (statsErr) { console.warn('[backend] Ошибка при расчёте статистики производительности:', statsErr.message); // Не прерываем выполнение, т.к. это не критично } return { synced: tickets.length, overdue: overdueCount, buildingsLinked, employeesLinked }; } catch (err) { await pool.query('ROLLBACK').catch(() => {}); console.error('[backend] Ошибка синхронизации заявок из Doma AI:', err.message || err); throw err; } } /** * Расчёт статистики производительности сотрудников и участков * Учитывает общий контекст для справедливого рейтинга */ async function calculatePerformanceStats() { const now = new Date(); const periodStart = new Date(now.getFullYear(), now.getMonth(), 1); // Начало текущего месяца const periodEnd = now; console.log('[calculatePerformanceStats] Начинаем расчёт статистики производительности...'); try { // Получаем общую статистику по всем заявкам за период const overallStats = await pool.query(` SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE status = 'done') as completed, COUNT(*) FILTER (WHERE is_overdue = true) as overdue, COUNT(*) FILTER (WHERE status IN ('in_progress', 'deferred')) as in_progress FROM applications WHERE created_at >= $1 AND created_at <= $2 `, [periodStart, periodEnd]); const overall = overallStats.rows[0]; const totalOverall = parseInt(overall.total) || 0; const completedOverall = parseInt(overall.completed) || 0; const overdueOverall = parseInt(overall.overdue) || 0; const overallCompletionRate = totalOverall > 0 ? (completedOverall / totalOverall) * 100 : 0; console.log(`[calculatePerformanceStats] Общая статистика: ${totalOverall} заявок, ${completedOverall} выполнено, ${overdueOverall} просрочено`); // Получаем статистику по каждому сотруднику // Используем employee_id если есть, иначе performer_name (для обратной совместимости) const employeeStats = await pool.query(` SELECT COALESCE(e.id, a.performer_name) as employee_id_or_name, COALESCE(e.name, a.performer_name) as employee_name, e.id as employee_id, e.assigned_district_id as district_id, COUNT(*) as total_assigned, COUNT(*) FILTER (WHERE a.status = 'done') as total_completed, COUNT(*) FILTER (WHERE a.is_overdue = true) as total_overdue, COUNT(*) FILTER (WHERE a.status = 'in_progress') as total_in_progress, COUNT(*) FILTER (WHERE a.status = 'deferred') as total_deferred FROM applications a LEFT JOIN employees e ON a.employee_id = e.id WHERE (a.employee_id IS NOT NULL OR a.performer_name IS NOT NULL) AND a.created_at >= $1 AND a.created_at <= $2 GROUP BY e.id, e.name, e.assigned_district_id, a.performer_name `, [periodStart, periodEnd]); await pool.query('BEGIN'); // Очищаем старую статистику за текущий период await pool.query(` DELETE FROM employee_performance_stats WHERE period_start = $1 AND period_end >= $2 `, [periodStart, periodStart]); // Сохраняем статистику по каждому сотруднику for (const emp of employeeStats.rows) { const employeeName = emp.employee_name || emp.employee_id_or_name; const employeeId = emp.employee_id; const totalAssigned = parseInt(emp.total_assigned) || 0; const totalCompleted = parseInt(emp.total_completed) || 0; const totalOverdue = parseInt(emp.total_overdue) || 0; const totalInProgress = parseInt(emp.total_in_progress) || 0; const totalDeferred = parseInt(emp.total_deferred) || 0; const districtId = emp.district_id; // Расчёт процента выполнения с учётом общего контекста // Если у сотрудника 3 задачи и все выполнены, но общий процент выполнения 50%, // то рейтинг сотрудника будет выше среднего, но не 100% let completionRate = 0; if (totalAssigned > 0) { const employeeCompletionRate = (totalCompleted / totalAssigned) * 100; // Учитываем общий контекст: если сотрудник выше среднего, получает бонус // Если ниже среднего, получает штраф const contextFactor = overallCompletionRate > 0 ? (employeeCompletionRate / overallCompletionRate) : 1; // Нормализуем: базовый рейтинг + бонус/штраф за контекст completionRate = Math.min(100, employeeCompletionRate * (0.7 + 0.3 * Math.min(contextFactor, 1.5))); } // Процент просрочек const overdueRate = totalAssigned > 0 ? (totalOverdue / totalAssigned) * 100 : 0; // Общий рейтинг производительности (0-100) // Формула: completion_rate * 0.7 - overdue_rate * 0.3 // Минус штраф за просрочки, плюс бонус за выполнение выше среднего let performanceScore = completionRate * 0.7 - overdueRate * 0.3; // Бонус за выполнение выше среднего if (totalAssigned > 0 && totalCompleted > 0) { const employeeCompletion = (totalCompleted / totalAssigned) * 100; if (employeeCompletion > overallCompletionRate) { const bonus = (employeeCompletion - overallCompletionRate) * 0.2; performanceScore += bonus; } } // Ограничиваем диапазон 0-100 performanceScore = Math.max(0, Math.min(100, performanceScore)); await pool.query(` INSERT INTO employee_performance_stats (employee_name, period_start, period_end, total_assigned, total_completed, total_overdue, total_in_progress, total_deferred, completion_rate, overdue_rate, performance_score, district_id, calculated_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) ON CONFLICT (employee_name, period_start, period_end) DO UPDATE SET total_assigned = EXCLUDED.total_assigned, total_completed = EXCLUDED.total_completed, total_overdue = EXCLUDED.total_overdue, total_in_progress = EXCLUDED.total_in_progress, total_deferred = EXCLUDED.total_deferred, completion_rate = EXCLUDED.completion_rate, overdue_rate = EXCLUDED.overdue_rate, performance_score = EXCLUDED.performance_score, district_id = EXCLUDED.district_id, updated_at = EXCLUDED.updated_at `, [ employeeName, periodStart, periodEnd, totalAssigned, totalCompleted, totalOverdue, totalInProgress, totalDeferred, completionRate, overdueRate, performanceScore, districtId, now, now, ]); } // Расчёт статистики по участкам // Используем employee_id если есть, иначе performer_name (для обратной совместимости) const districtStats = await pool.query(` SELECT d.id as district_id, COUNT(a.id) as total_applications, COUNT(a.id) FILTER (WHERE a.status = 'done') as total_completed, COUNT(a.id) FILTER (WHERE a.is_overdue = true) as total_overdue, COUNT(a.id) FILTER (WHERE a.status IN ('in_progress', 'deferred')) as total_in_progress FROM districts d LEFT JOIN employees e ON e.assigned_district_id = d.id LEFT JOIN applications a ON ( (a.employee_id = e.id) OR (a.employee_id IS NULL AND a.performer_name = e.name) ) AND a.created_at >= $1 AND a.created_at <= $2 GROUP BY d.id HAVING COUNT(a.id) > 0 `, [periodStart, periodEnd]); // Очищаем старую статистику по участкам await pool.query(` DELETE FROM district_performance_stats WHERE period_start = $1 AND period_end >= $2 `, [periodStart, periodStart]); // Сохраняем статистику по участкам for (const dist of districtStats.rows) { const districtId = dist.district_id; const totalApplications = parseInt(dist.total_applications) || 0; const totalCompleted = parseInt(dist.total_completed) || 0; const totalOverdue = parseInt(dist.total_overdue) || 0; const totalInProgress = parseInt(dist.total_in_progress) || 0; const completionRate = totalApplications > 0 ? (totalCompleted / totalApplications) * 100 : 0; const overdueRate = totalApplications > 0 ? (totalOverdue / totalApplications) * 100 : 0; // Средний рейтинг сотрудников участка const avgScoreResult = await pool.query(` SELECT AVG(performance_score) as avg_score FROM employee_performance_stats WHERE district_id = $1 AND period_start = $2 AND period_end >= $3 `, [districtId, periodStart, periodStart]); const averageScore = parseFloat(avgScoreResult.rows[0]?.avg_score) || 0; await pool.query(` INSERT INTO district_performance_stats (district_id, period_start, period_end, total_applications, total_completed, total_overdue, total_in_progress, completion_rate, overdue_rate, average_score, calculated_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (district_id, period_start, period_end) DO UPDATE SET total_applications = EXCLUDED.total_applications, total_completed = EXCLUDED.total_completed, total_overdue = EXCLUDED.total_overdue, total_in_progress = EXCLUDED.total_in_progress, completion_rate = EXCLUDED.completion_rate, overdue_rate = EXCLUDED.overdue_rate, average_score = EXCLUDED.average_score, updated_at = EXCLUDED.updated_at `, [ districtId, periodStart, periodEnd, totalApplications, totalCompleted, totalOverdue, totalInProgress, completionRate, overdueRate, averageScore, now, now, ]); } await pool.query('COMMIT'); console.log('[calculatePerformanceStats] Статистика производительности рассчитана успешно'); } catch (err) { await pool.query('ROLLBACK').catch(() => {}); console.error('[calculatePerformanceStats] Ошибка расчёта статистики:', err.message || err); throw err; } } // Ручной запуск синхронизации (поддерживаем и POST, и GET для удобства) const handleDomaSyncNow = async (req, res) => { try { const result = await syncDomaApplications(); res.json({ success: true, ...result }); } catch (err) { res.status(500).json({ success: false, error: err.message || 'Failed to sync Doma AI applications', }); } }; app.post(`${API_PREFIX}/doma/sync-now`, handleDomaSyncNow); app.get(`${API_PREFIX}/doma/sync-now`, handleDomaSyncNow); // API для обновления заявки в Doma.AI и локальной БД app.put(`${API_PREFIX}/applications/:number/status`, async (req, res) => { try { const { number } = req.params; const { status, comment, deferredUntil, domaApiUrl, domaToken } = req.body; if (!status) { return res.status(400).json({ success: false, error: 'Статус обязателен' }); } // Находим заявку в локальной БД по номеру const appResult = await pool.query( `SELECT id, number, status FROM applications WHERE number = $1 LIMIT 1`, [number] ); if (appResult.rows.length === 0) { return res.status(404).json({ success: false, error: 'Заявка не найдена' }); } const localApp = appResult.rows[0]; // Маппинг локальных статусов в статусы Doma.AI const statusTypeMap = { new: 'new_or_reopened', in_progress: 'processing', deferred: 'deferred', done: 'completed', canceled: 'canceled', }; const domaStatusType = statusTypeMap[status] || status; // Получаем настройки Doma.AI из запроса, БД (панель интеграций) или env let apiUrl = domaApiUrl || null; let token = domaToken || null; if (!apiUrl || !token) { const fromDb = await getDomaSettingsFromDb(); apiUrl = apiUrl || fromDb.apiUrl; token = token || fromDb.token; } if (!apiUrl || !token) { return res.status(400).json({ success: false, error: 'Настройки Doma.AI не настроены' }); } // Сначала находим заявку в Doma.AI по номеру, чтобы получить её ID и текущий статус // Пробуем искать как по строке, так и по числу const ticketNumber = isNaN(Number(number)) ? number : Number(number); // Используем allTickets вместо tickets (согласно ошибке GraphQL) const findTicketQuery = ` query FindTicket($where: TicketWhereInput, $first: Int) { objs: allTickets(where: $where, first: $first) { id number status { id type name } } } `; let findResponse; try { findResponse = await axios.post( apiUrl, { query: findTicketQuery, variables: { where: { number: ticketNumber, }, first: 1, }, }, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, } ); } catch (findErr) { console.error('[updateApplicationStatus] Ошибка при поиске заявки в Doma.AI:', findErr.response?.data || findErr.message); return res.status(400).json({ success: false, error: `Ошибка при поиске заявки в Doma.AI: ${findErr.response?.data?.errors?.[0]?.message || findErr.message}`, }); } if (findResponse.data.errors) { console.error('[updateApplicationStatus] Ошибки GraphQL при поиске:', findResponse.data.errors); return res.status(400).json({ success: false, error: `Ошибка GraphQL: ${findResponse.data.errors[0]?.message || 'Неизвестная ошибка'}`, }); } if (!findResponse.data.data?.objs?.[0]) { console.warn(`[updateApplicationStatus] Заявка с номером ${number} не найдена в Doma.AI`); return res.status(404).json({ success: false, error: 'Заявка не найдена в Doma.AI', }); } const domaTicket = findResponse.data.data.objs[0]; const domaTicketId = domaTicket.id; const currentStatus = domaTicket.status; console.log(`[updateApplicationStatus] Найдена заявка в Doma.AI: ID=${domaTicketId}, номер=${number}, текущий статус:`, currentStatus); // Обновляем заявку в Doma.AI через GraphQL // Формируем данные для обновления const updateData = {}; // Статус - нужно найти ID статуса по типу, так как TicketStatusRelateToOneInput принимает только id let statusId = null; // Если текущий статус имеет нужный тип, используем его ID if (currentStatus && currentStatus.type === domaStatusType && currentStatus.id) { statusId = currentStatus.id; console.log(`[updateApplicationStatus] Используем ID текущего статуса: ${statusId}`); } else { // Ищем статус по типу через запрос const statusQuery = ` query GetStatusByType($where: TicketStatusWhereInput) { objs: allTicketStatuses(where: $where, first: 1) { id type name } } `; try { const statusResponse = await axios.post( apiUrl, { query: statusQuery, variables: { where: { type: domaStatusType, }, }, }, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, } ); if (statusResponse.data.data?.objs?.[0]?.id) { statusId = statusResponse.data.data.objs[0].id; console.log(`[updateApplicationStatus] Найден ID статуса по типу ${domaStatusType}: ${statusId}`); } else { throw new Error(`Статус с типом "${domaStatusType}" не найден в Doma.AI`); } } catch (statusErr) { console.error('[updateApplicationStatus] Ошибка при поиске статуса:', statusErr.response?.data || statusErr.message); return res.status(400).json({ success: false, error: `Не удалось найти статус "${domaStatusType}" в Doma.AI: ${statusErr.response?.data?.errors?.[0]?.message || statusErr.message}`, }); } } // Используем connect для связи со статусом (формат Keystone.js) updateData.status = { connect: { id: statusId } }; // Комментарий (причина смены статуса) if (comment && comment.trim()) { updateData.statusReason = comment.trim(); updateData.lastCommentWithResidentTypeAt = new Date().toISOString(); } // Дата отложения (только для статуса deferred) if (deferredUntil && status === 'deferred') { const deferredDate = new Date(deferredUntil); if (!isNaN(deferredDate.getTime())) { updateData.deferredUntil = deferredDate.toISOString(); } } // Дата обновления статуса updateData.statusUpdatedAt = new Date().toISOString(); const updateMutation = ` mutation UpdateTicket($id: ID!, $data: TicketUpdateInput!) { obj: updateTicket(id: $id, data: $data) { id number status { id name type } updatedAt } } `; console.log(`[updateApplicationStatus] Обновление заявки ${domaTicketId} с данными:`, JSON.stringify(updateData, null, 2)); let updateResponse; try { updateResponse = await axios.post( apiUrl, { query: updateMutation, variables: { id: domaTicketId, data: updateData, }, }, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, } ); } catch (updateErr) { console.error('[updateApplicationStatus] Ошибка при обновлении заявки в Doma.AI:', updateErr.response?.data || updateErr.message); return res.status(400).json({ success: false, error: `Ошибка при обновлении заявки: ${updateErr.response?.data?.errors?.[0]?.message || updateErr.message}`, details: updateErr.response?.data, }); } if (updateResponse.data.errors) { console.error('[updateApplicationStatus] Ошибки GraphQL при обновлении:', updateResponse.data.errors); return res.status(400).json({ success: false, error: updateResponse.data.errors[0]?.message || 'Ошибка при обновлении заявки в Doma.AI', details: updateResponse.data.errors, }); } console.log(`[updateApplicationStatus] Заявка успешно обновлена в Doma.AI`); // Обновляем заявку в локальной БД const now = new Date(); await pool.query( `UPDATE applications SET status = $1, updated_at = $2 WHERE number = $3`, [status, now.toISOString(), number] ); res.json({ success: true, data: { id: localApp.id, number: localApp.number, status: status, }, }); } catch (err) { console.error('[updateApplicationStatus] Ошибка:', err); res.status(500).json({ success: false, error: err.message || 'Ошибка при обновлении статуса заявки', }); } }); // ====== ЖУРНАЛ ОТКЛЮЧЕНИЙ (OUTAGES) ====== // GET /api/outages -> список отключений (?buildingId=, ?active=true) app.get(`${API_PREFIX}/outages`, async (req, res) => { try { const { buildingId, active } = req.query; let sql = ` SELECT o.id, o.building_id AS "buildingId", o.start_at AS "startAt", o.end_at AS "endAt", o.type, o.description, o.active, o.created_at AS "createdAt", o.updated_at AS "updatedAt", o.author_name AS "authorName", o.category, o.problem_detail AS "problemDetail", o.work_type AS "workType", o.resident_message AS "residentMessage", o.generate_news AS "generateNews", b.data->'passport'->>'address' AS "buildingAddress" FROM outages o LEFT JOIN buildings b ON b.id = o.building_id WHERE 1=1 `; const params = []; let idx = 1; if (buildingId) { sql += ` AND o.building_id = $${idx}`; params.push(buildingId); idx++; } if (active === 'true' || active === 'false') { sql += ` AND o.active = $${idx}`; params.push(active === 'true'); idx++; } sql += ` ORDER BY o.start_at DESC`; const { rows } = await pool.query(sql, params); res.json(rows); } catch (err) { console.error('Error fetching outages:', err); res.status(500).json({ error: err.message || 'Failed to fetch outages' }); } }); // POST /api/outages -> создание отключения (один дом или несколько: buildingId или buildingIds) app.post(`${API_PREFIX}/outages`, async (req, res) => { try { const { buildingId, buildingIds, startAt, endAt, type, description, active, authorName, category, problemDetail, workType, residentMessage, generateNews } = req.body || {}; const ids = buildingIds && Array.isArray(buildingIds) && buildingIds.length > 0 ? buildingIds : (buildingId ? [buildingId] : []); if (ids.length === 0) { return res.status(400).json({ error: 'Укажите buildingId или buildingIds' }); } const start = startAt ? new Date(startAt) : new Date(); const created = []; for (const bid of ids) { const { rows } = await pool.query( `INSERT INTO outages (building_id, start_at, end_at, type, description, active, updated_at, author_name, category, problem_detail, work_type, resident_message, generate_news) VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7, $8, $9, $10, $11, $12) RETURNING id, building_id AS "buildingId", start_at AS "startAt", end_at AS "endAt", type, description, active, created_at AS "createdAt", updated_at AS "updatedAt", author_name AS "authorName", category, problem_detail AS "problemDetail", work_type AS "workType", resident_message AS "residentMessage", generate_news AS "generateNews"`, [bid, start, endAt ? new Date(endAt) : null, type || null, description || null, active !== false, authorName || null, category || null, problemDetail || null, workType || null, residentMessage || null, !!generateNews] ); created.push(rows[0]); } try { const firstId = created[0]?.id; await notificationService.createNotificationForResponsibleZone(pool, 'requests', 'outages', { type: 'outage', title: 'Новое отключение', body: description ? String(description).slice(0, 200) : 'Создано отключение в реестре', entityType: 'outage', entityId: firstId != null ? String(firstId) : null, }); } catch (notifErr) { console.warn('Notification (outage create):', notifErr.message); } res.status(201).json(created.length === 1 ? created[0] : { created: created.length, items: created }); } catch (err) { console.error('Error creating outage:', err); res.status(500).json({ error: err.message || 'Failed to create outage' }); } }); // GET /api/outages/:id -> одно отключение (для карточки) app.get(`${API_PREFIX}/outages/:id`, async (req, res) => { try { const { id } = req.params; const { rows } = await pool.query( `SELECT o.id, o.building_id AS "buildingId", o.start_at AS "startAt", o.end_at AS "endAt", o.type, o.description, o.active, o.created_at AS "createdAt", o.updated_at AS "updatedAt", o.author_name AS "authorName", o.category, o.problem_detail AS "problemDetail", o.work_type AS "workType", o.resident_message AS "residentMessage", o.generate_news AS "generateNews", b.data->'passport'->>'address' AS "buildingAddress" FROM outages o LEFT JOIN buildings b ON b.id = o.building_id WHERE o.id = $1`, [id] ); if (rows.length === 0) { return res.status(404).json({ error: 'Отключение не найдено' }); } res.json(rows[0]); } catch (err) { console.error('Error fetching outage:', err); res.status(500).json({ error: err.message || 'Failed to fetch outage' }); } }); // PATCH /api/outages/:id -> обновление отключения (завершить и т.д.) app.patch(`${API_PREFIX}/outages/:id`, async (req, res) => { try { const { id } = req.params; const { endAt, type, description, active, category, problemDetail, workType, residentMessage, generateNews } = req.body || {}; const updates = []; const values = []; let idx = 1; if (endAt !== undefined) { updates.push(`end_at = $${idx}`); values.push(new Date(endAt)); idx++; } if (type !== undefined) { updates.push(`type = $${idx}`); values.push(type); idx++; } if (description !== undefined) { updates.push(`description = $${idx}`); values.push(description); idx++; } if (active !== undefined) { updates.push(`active = $${idx}`); values.push(!!active); idx++; } if (category !== undefined) { updates.push(`category = $${idx}`); values.push(category); idx++; } if (problemDetail !== undefined) { updates.push(`problem_detail = $${idx}`); values.push(problemDetail); idx++; } if (workType !== undefined) { updates.push(`work_type = $${idx}`); values.push(workType); idx++; } if (residentMessage !== undefined) { updates.push(`resident_message = $${idx}`); values.push(residentMessage); idx++; } if (generateNews !== undefined) { updates.push(`generate_news = $${idx}`); values.push(!!generateNews); idx++; } if (updates.length === 0) { return res.status(400).json({ error: 'Нет полей для обновления' }); } updates.push('updated_at = NOW()'); values.push(id); const sql = `UPDATE outages SET ${updates.join(', ')} WHERE id = $${idx} RETURNING id, building_id AS "buildingId", start_at AS "startAt", end_at AS "endAt", type, description, active, created_at AS "createdAt", updated_at AS "updatedAt", author_name AS "authorName", category, problem_detail AS "problemDetail", work_type AS "workType", resident_message AS "residentMessage", generate_news AS "generateNews"`; const { rows } = await pool.query(sql, values); if (rows.length === 0) { return res.status(404).json({ error: 'Отключение не найдено' }); } try { await notificationService.createNotificationForResponsibleZone(pool, 'requests', 'outages', { type: 'outage', title: 'Изменение отключения', body: 'Обновлено отключение в реестре', entityType: 'outage', entityId: String(id), }); } catch (notifErr) { console.warn('Notification (outage update):', notifErr.message); } res.json(rows[0]); } catch (err) { console.error('Error updating outage:', err); res.status(500).json({ error: err.message || 'Failed to update outage' }); } }); // ====== API ДЛЯ УПРАВЛЕНИЯ СОПОСТАВЛЕНИЯМИ DOMA.AI ====== // GET /api/doma/mappings -> получить все сопоставления app.get(`${API_PREFIX}/doma/mappings`, async (req, res) => { try { const [addressMappings, employeeMappings] = await Promise.all([ pool.query(` SELECT dam.id, dam.doma_address, dam.building_id, b.data->'passport'->>'address' as building_address, dam.created_at FROM doma_address_mappings dam LEFT JOIN buildings b ON b.id = dam.building_id ORDER BY dam.created_at DESC `), pool.query(` SELECT dem.id, dem.doma_name, dem.employee_id, e.name as employee_name, dem.created_at FROM doma_employee_mappings dem LEFT JOIN employees e ON e.id = dem.employee_id ORDER BY dem.created_at DESC `), ]); res.json({ success: true, data: { addresses: addressMappings.rows.map(r => ({ id: r.id, domaAddress: r.doma_address, buildingId: r.building_id, buildingAddress: r.building_address, createdAt: r.created_at, })), employees: employeeMappings.rows.map(r => ({ id: r.id, domaName: r.doma_name, employeeId: r.employee_id, employeeName: r.employee_name, createdAt: r.created_at, })), }, }); } catch (err) { console.error('[getDomaMappings] Ошибка:', err); res.status(500).json({ success: false, error: err.message || 'Ошибка при получении сопоставлений' }); } }); // GET /api/doma/pending-mappings -> получить ожидающие сопоставления (неразрешённые) app.get(`${API_PREFIX}/doma/pending-mappings`, async (req, res) => { try { const result = await pool.query( `SELECT id, type, doma_value, suggested_id, suggested_name, created_at FROM pending_doma_mappings WHERE resolved_at IS NULL ORDER BY created_at ASC` ); const rows = result.rows || []; res.json({ success: true, data: { buildings: rows .filter(r => r.type === 'building') .map(r => ({ id: r.id, domaValue: r.doma_value, suggestedId: r.suggested_id, suggestedName: r.suggested_name, createdAt: r.created_at, })), employees: rows .filter(r => r.type === 'employee') .map(r => ({ id: r.id, domaValue: r.doma_value, suggestedId: r.suggested_id, suggestedName: r.suggested_name, createdAt: r.created_at, })), }, }); } catch (err) { console.error('[getPendingDomaMappings] Ошибка:', err); res .status(500) .json({ success: false, error: err.message || 'Ошибка при получении ожидающих сопоставлений' }); } }); // POST /api/doma/pending-mappings/:id/resolve -> вручную сопоставить и зафиксировать app.post(`${API_PREFIX}/doma/pending-mappings/:id/resolve`, async (req, res) => { const client = await pool.connect(); try { const { id } = req.params; const { targetId } = req.body || {}; if (!targetId) { return res.status(400).json({ success: false, error: 'targetId обязателен' }); } await client.query('BEGIN'); const pendingResult = await client.query( `SELECT * FROM pending_doma_mappings WHERE id = $1 FOR UPDATE`, [id] ); if (pendingResult.rows.length === 0) { await client.query('ROLLBACK'); return res.status(404).json({ success: false, error: 'Запись не найдена' }); } const pending = pendingResult.rows[0]; if (pending.type === 'employee') { await client.query( `INSERT INTO doma_employee_mappings (doma_name, employee_id) VALUES ($1, $2) ON CONFLICT (doma_name) DO UPDATE SET employee_id = EXCLUDED.employee_id, updated_at = NOW()`, [pending.doma_value, targetId] ); } else if (pending.type === 'building') { await client.query( `INSERT INTO doma_address_mappings (doma_address, building_id) VALUES ($1, $2) ON CONFLICT (doma_address) DO UPDATE SET building_id = EXCLUDED.building_id, updated_at = NOW()`, [pending.doma_value, targetId] ); } await client.query( `UPDATE pending_doma_mappings SET resolved_at = NOW(), resolved_by = $2, resolution = 'approved', resolved_id = $3 WHERE id = $1`, [id, 'manual', targetId] ); await client.query('COMMIT'); res.json({ success: true }); } catch (err) { await pool.query('ROLLBACK').catch(() => {}); console.error('[resolvePendingDomaMapping] Ошибка:', err); res .status(500) .json({ success: false, error: err.message || 'Ошибка при разрешении сопоставления' }); } finally { client.release(); } } ); // PUT /api/doma/mappings/address/:id -> обновить сопоставление адреса app.put(`${API_PREFIX}/doma/mappings/address/:id`, async (req, res) => { try { const { id } = req.params; const { buildingId } = req.body; if (!buildingId) { return res.status(400).json({ success: false, error: 'buildingId обязателен' }); } await pool.query( `UPDATE doma_address_mappings SET building_id = $1, updated_at = NOW() WHERE id = $2`, [buildingId, id] ); res.json({ success: true }); } catch (err) { console.error('[updateAddressMapping] Ошибка:', err); res.status(500).json({ success: false, error: err.message || 'Ошибка при обновлении сопоставления' }); } }); // PUT /api/doma/mappings/employee/:id -> обновить сопоставление сотрудника app.put(`${API_PREFIX}/doma/mappings/employee/:id`, async (req, res) => { try { const { id } = req.params; const { employeeId } = req.body; if (!employeeId) { return res.status(400).json({ success: false, error: 'employeeId обязателен' }); } await pool.query( `UPDATE doma_employee_mappings SET employee_id = $1, updated_at = NOW() WHERE id = $2`, [employeeId, id] ); res.json({ success: true }); } catch (err) { console.error('[updateEmployeeMapping] Ошибка:', err); res.status(500).json({ success: false, error: err.message || 'Ошибка при обновлении сопоставления' }); } }); // DELETE /api/doma/mappings/address/:id -> удалить сопоставление адреса app.delete(`${API_PREFIX}/doma/mappings/address/:id`, async (req, res) => { try { const { id } = req.params; await pool.query('DELETE FROM doma_address_mappings WHERE id = $1', [id]); res.json({ success: true }); } catch (err) { console.error('[deleteAddressMapping] Ошибка:', err); res.status(500).json({ success: false, error: err.message || 'Ошибка при удалении сопоставления' }); } }); // DELETE /api/doma/mappings/employee/:id -> удалить сопоставление сотрудника app.delete(`${API_PREFIX}/doma/mappings/employee/:id`, async (req, res) => { try { const { id } = req.params; await pool.query('DELETE FROM doma_employee_mappings WHERE id = $1', [id]); res.json({ success: true }); } catch (err) { console.error('[deleteEmployeeMapping] Ошибка:', err); res.status(500).json({ success: false, error: err.message || 'Ошибка при удалении сопоставления' }); } }); // API для получения статистики производительности app.get(`${API_PREFIX}/performance/overall`, async (req, res) => { try { const now = new Date(); const periodStart = new Date(now.getFullYear(), now.getMonth(), 1); const periodEnd = now; const stats = await pool.query(` SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE status = 'done') as completed, COUNT(*) FILTER (WHERE is_overdue = true) as overdue, COUNT(*) FILTER (WHERE status IN ('in_progress', 'deferred')) as in_progress FROM applications WHERE created_at >= $1 AND created_at <= $2 `, [periodStart, periodEnd]); const result = stats.rows[0]; const total = parseInt(result.total) || 0; const completed = parseInt(result.completed) || 0; const overdue = parseInt(result.overdue) || 0; const inProgress = parseInt(result.in_progress) || 0; res.json({ success: true, data: { total, completed, overdue, inProgress, completionRate: total > 0 ? (completed / total) * 100 : 0, overdueRate: total > 0 ? (overdue / total) * 100 : 0, periodStart, periodEnd, }, }); } catch (err) { res.status(500).json({ success: false, error: err.message || 'Failed to get overall performance stats' }); } }); app.get(`${API_PREFIX}/performance/employees`, async (req, res) => { try { const now = new Date(); const periodStart = new Date(now.getFullYear(), now.getMonth(), 1); const periodEnd = now; const stats = await pool.query(` SELECT eps.*, d.name as district_name FROM employee_performance_stats eps LEFT JOIN districts d ON d.id = eps.district_id WHERE eps.period_start = $1 AND eps.period_end >= $2 ORDER BY eps.performance_score DESC `, [periodStart, periodStart]); res.json({ success: true, data: stats.rows.map((row) => ({ employeeName: row.employee_name, districtId: row.district_id, districtName: row.district_name, totalAssigned: parseInt(row.total_assigned) || 0, totalCompleted: parseInt(row.total_completed) || 0, totalOverdue: parseInt(row.total_overdue) || 0, totalInProgress: parseInt(row.total_in_progress) || 0, totalDeferred: parseInt(row.total_deferred) || 0, completionRate: parseFloat(row.completion_rate) || 0, overdueRate: parseFloat(row.overdue_rate) || 0, performanceScore: parseFloat(row.performance_score) || 0, })), }); } catch (err) { res.status(500).json({ success: false, error: err.message || 'Failed to get employee performance stats' }); } }); app.get(`${API_PREFIX}/performance/districts`, async (req, res) => { try { const now = new Date(); const periodStart = new Date(now.getFullYear(), now.getMonth(), 1); const periodEnd = now; const stats = await pool.query(` SELECT dps.*, d.name as district_name, d.manager_name FROM district_performance_stats dps JOIN districts d ON d.id = dps.district_id WHERE dps.period_start = $1 AND dps.period_end >= $2 ORDER BY dps.completion_rate DESC `, [periodStart, periodStart]); res.json({ success: true, data: stats.rows.map((row) => ({ districtId: row.district_id, districtName: row.district_name, managerName: row.manager_name, totalApplications: parseInt(row.total_applications) || 0, totalCompleted: parseInt(row.total_completed) || 0, totalOverdue: parseInt(row.total_overdue) || 0, totalInProgress: parseInt(row.total_in_progress) || 0, completionRate: parseFloat(row.completion_rate) || 0, overdueRate: parseFloat(row.overdue_rate) || 0, averageScore: parseFloat(row.average_score) || 0, })), }); } catch (err) { res.status(500).json({ success: false, error: err.message || 'Failed to get district performance stats' }); } }); // API для получения метрик производства (состояние домов, график работ, SLA) app.get(`${API_PREFIX}/production/metrics`, async (req, res) => { try { // 1. Среднее состояние домов (износ) // Пробуем разные пути к данным износа const buildingsCondition = await pool.query(` SELECT AVG( COALESCE( (data->'passport'->'construction'->>'wearPercent')::numeric, (data->'passport'->'general'->>'wearPercent')::numeric, NULL ) ) as avg_wear_percent, COUNT(*) FILTER (WHERE data->'passport'->'construction'->>'wearPercent' IS NOT NULL OR data->'passport'->'general'->>'wearPercent' IS NOT NULL ) as buildings_with_wear, COUNT(*) as total_buildings FROM buildings `); const avgWearPercent = parseFloat(buildingsCondition.rows[0]?.avg_wear_percent) || 0; const buildingsWithWear = parseInt(buildingsCondition.rows[0]?.buildings_with_wear) || 0; const totalBuildings = parseInt(buildingsCondition.rows[0]?.total_buildings) || 0; // Оценка состояния на основе износа let buildingCondition = 'excellent'; // excellent, good, fair, poor let buildingConditionLabel = 'Отличное'; if (avgWearPercent >= 70) { buildingCondition = 'poor'; buildingConditionLabel = 'Плохое'; } else if (avgWearPercent >= 50) { buildingCondition = 'fair'; buildingConditionLabel = 'Удовлетворительное'; } else if (avgWearPercent >= 30) { buildingCondition = 'good'; buildingConditionLabel = 'Хорошее'; } // 2. График работ (соблюдение и опоздание) // Считаем выполненные заявки: в срок (deadline >= дата выполнения) и с опозданием const workSchedule = await pool.query(` SELECT COUNT(*) FILTER (WHERE status = 'done') as total_done, COUNT(*) FILTER (WHERE status = 'done' AND deadline_at >= updated_at) as on_time, COUNT(*) FILTER (WHERE status = 'done' AND deadline_at < updated_at) as late, COUNT(*) FILTER (WHERE is_overdue = true AND status != 'done') as overdue_active FROM applications WHERE status = 'done' OR (is_overdue = true AND status != 'done') `); const totalDone = parseInt(workSchedule.rows[0]?.total_done) || 0; const onTime = parseInt(workSchedule.rows[0]?.on_time) || 0; const late = parseInt(workSchedule.rows[0]?.late) || 0; const overdueActive = parseInt(workSchedule.rows[0]?.overdue_active) || 0; const onTimeRate = totalDone > 0 ? (onTime / totalDone) * 100 : 0; const lateRate = totalDone > 0 ? (late / totalDone) * 100 : 0; // 3. Рейтинг SLA (процент заявок, выполненных в срок от всех завершённых) const slaRating = await pool.query(` SELECT COUNT(*) FILTER (WHERE status = 'done') as total_done, COUNT(*) FILTER (WHERE status = 'done' AND deadline_at >= updated_at) as on_time FROM applications WHERE status = 'done' `); const slaTotal = parseInt(slaRating.rows[0]?.total_done) || 0; const slaOnTime = parseInt(slaRating.rows[0]?.on_time) || 0; const slaRatingPercent = slaTotal > 0 ? (slaOnTime / slaTotal) * 100 : 0; res.json({ success: true, data: { buildingCondition: { value: avgWearPercent, label: buildingConditionLabel, condition: buildingCondition, totalBuildings, buildingsWithWear, }, workSchedule: { onTime: onTime, late: late, overdue: overdueActive, onTimeRate: Math.round(onTimeRate), lateRate: Math.round(lateRate), }, slaRating: Math.round(slaRatingPercent), }, }); } catch (err) { console.error('[production/metrics] Ошибка:', err); res.status(500).json({ success: false, error: err.message || 'Failed to get production metrics' }); } }); // GET /api/production/dashboard — полный дашборд для продвинутой статистики производства app.get(`${API_PREFIX}/production/dashboard`, async (req, res) => { try { const now = new Date(); const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const weekStart = new Date(todayStart); weekStart.setDate(weekStart.getDate() - 7); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); const toDateStr = (d) => d.toISOString ? d.toISOString().slice(0, 10) : ''; // 1. Метрики как в production/metrics (состояние домов, график работ, SLA) const buildingsCondition = await pool.query(` SELECT AVG(COALESCE((data->'passport'->'construction'->>'wearPercent')::numeric, (data->'passport'->'general'->>'wearPercent')::numeric, NULL)) as avg_wear_percent, COUNT(*) FILTER (WHERE data->'passport'->'construction'->>'wearPercent' IS NOT NULL OR data->'passport'->'general'->>'wearPercent' IS NOT NULL) as buildings_with_wear, COUNT(*) as total_buildings FROM buildings `); const r0 = buildingsCondition.rows[0]; const avgWearPercent = parseFloat(r0?.avg_wear_percent) || 0; const buildingsWithWear = parseInt(r0?.buildings_with_wear) || 0; const totalBuildings = parseInt(r0?.total_buildings) || 0; let buildingCondition = 'excellent'; let buildingConditionLabel = 'Отличное'; if (avgWearPercent >= 70) { buildingCondition = 'poor'; buildingConditionLabel = 'Плохое'; } else if (avgWearPercent >= 50) { buildingCondition = 'fair'; buildingConditionLabel = 'Удовлетворительное'; } else if (avgWearPercent >= 30) { buildingCondition = 'good'; buildingConditionLabel = 'Хорошее'; } const workSchedule = await pool.query(` SELECT COUNT(*) FILTER (WHERE status = 'done') as total_done, COUNT(*) FILTER (WHERE status = 'done' AND deadline_at >= updated_at) as on_time, COUNT(*) FILTER (WHERE status = 'done' AND deadline_at < updated_at) as late, COUNT(*) FILTER (WHERE is_overdue = true AND status != 'done') as overdue_active FROM applications WHERE status = 'done' OR (is_overdue = true AND status != 'done') `); const ws = workSchedule.rows[0]; const totalDone = parseInt(ws?.total_done) || 0; const onTime = parseInt(ws?.on_time) || 0; const late = parseInt(ws?.late) || 0; const overdueActive = parseInt(ws?.overdue_active) || 0; const onTimeRate = totalDone > 0 ? (onTime / totalDone) * 100 : 0; const lateRate = totalDone > 0 ? (late / totalDone) * 100 : 0; const slaRatingQ = await pool.query(` SELECT COUNT(*) FILTER (WHERE status = 'done') as total_done, COUNT(*) FILTER (WHERE status = 'done' AND deadline_at >= updated_at) as on_time FROM applications WHERE status = 'done' `); const slaR = slaRatingQ.rows[0]; const slaTotal = parseInt(slaR?.total_done) || 0; const slaOnTime = parseInt(slaR?.on_time) || 0; const slaRatingPercent = slaTotal > 0 ? (slaOnTime / slaTotal) * 100 : 0; const buildingConditionData = { value: avgWearPercent, label: buildingConditionLabel, condition: buildingCondition, totalBuildings, buildingsWithWear, }; const workScheduleData = { onTime, late, overdue: overdueActive, onTimeRate: Math.round(onTimeRate), lateRate: Math.round(lateRate), }; const slaRating = Math.round(slaRatingPercent); // 2. Заявки — временные срезы (сегодня, неделя, месяц): создано, закрыто, просрочено const hasUpdatedAt = await pool.query(` SELECT column_name FROM information_schema.columns WHERE table_name = 'applications' AND column_name = 'updated_at' `); const useUpdatedAt = hasUpdatedAt.rows.length > 0; const todayStr = toDateStr(todayStart); const appsToday = await pool.query(` SELECT COUNT(*) FILTER (WHERE (created_at::date)::text = $1) as created, COUNT(*) FILTER (WHERE status = 'done' AND ${useUpdatedAt ? "(updated_at::date)::text = $1" : "(created_at::date)::text = $1"}) as closed, COUNT(*) FILTER (WHERE is_overdue = true AND status != 'done' AND status != 'canceled') as overdue_now FROM applications `, [todayStr]); const appsWeek = await pool.query(` SELECT COUNT(*) FILTER (WHERE created_at >= $1) as created, COUNT(*) FILTER (WHERE status = 'done' AND ${useUpdatedAt ? "updated_at >= $1" : "created_at >= $1"}) as closed, COUNT(*) FILTER (WHERE is_overdue = true AND status != 'done' AND status != 'canceled') as overdue_now FROM applications WHERE created_at >= $1 OR status = 'done' OR (is_overdue = true AND status != 'done') `, [weekStart]); const appsMonth = await pool.query(` SELECT COUNT(*) FILTER (WHERE created_at >= $1) as created, COUNT(*) FILTER (WHERE status = 'done' AND ${useUpdatedAt ? "updated_at >= $1" : "created_at >= $1"}) as closed, COUNT(*) FILTER (WHERE is_overdue = true AND status != 'done' AND status != 'canceled') as overdue_now FROM applications WHERE created_at >= $1 OR status = 'done' OR (is_overdue = true AND status != 'done') `, [monthStart]); const applicationsTimeSlices = { today: { created: parseInt(appsToday.rows[0]?.created) || 0, closed: parseInt(appsToday.rows[0]?.closed) || 0, overdueNow: parseInt(appsToday.rows[0]?.overdue_now) || 0, }, week: { created: parseInt(appsWeek.rows[0]?.created) || 0, closed: parseInt(appsWeek.rows[0]?.closed) || 0, overdueNow: parseInt(appsWeek.rows[0]?.overdue_now) || 0, }, month: { created: parseInt(appsMonth.rows[0]?.created) || 0, closed: parseInt(appsMonth.rows[0]?.closed) || 0, overdueNow: parseInt(appsMonth.rows[0]?.overdue_now) || 0, }, }; // 3. Сводка по статусам заявок const statusSummary = await pool.query(` SELECT COUNT(*) FILTER (WHERE status = 'new') as new, COUNT(*) FILTER (WHERE status = 'in_progress') as in_progress, COUNT(*) FILTER (WHERE status = 'deferred') as deferred, COUNT(*) FILTER (WHERE status = 'done') as done, COUNT(*) FILTER (WHERE is_overdue = true AND status != 'done' AND status != 'canceled') as overdue FROM applications WHERE status != 'canceled' `); const ss = statusSummary.rows[0]; const applicationsSummary = { new: parseInt(ss?.new) || 0, inProgress: parseInt(ss?.in_progress) || 0, deferred: parseInt(ss?.deferred) || 0, done: parseInt(ss?.done) || 0, overdue: parseInt(ss?.overdue) || 0, }; const totalActive = applicationsSummary.new + applicationsSummary.inProgress + applicationsSummary.deferred; const completionRate = applicationsSummary.done + totalActive > 0 ? Math.round((applicationsSummary.done / (applicationsSummary.done + totalActive)) * 100) : 0; // 4. Участки с метриками (performance/districts + кол-во домов) const districtsWithBuildings = await pool.query(` SELECT data->>'districtId' as district_id, COUNT(*) as building_count FROM buildings WHERE data->>'districtId' IS NOT NULL AND data->>'districtId' != '' GROUP BY data->>'districtId' `); const buildingCountByDistrict = {}; districtsWithBuildings.rows.forEach((row) => { buildingCountByDistrict[row.district_id] = parseInt(row.building_count) || 0; }); const nowPeriod = new Date(now.getFullYear(), now.getMonth(), 1); const districtStats = await pool.query(` SELECT dps.*, d.name as district_name, d.manager_name FROM district_performance_stats dps JOIN districts d ON d.id = dps.district_id WHERE dps.period_start = $1 AND dps.period_end >= $2 ORDER BY dps.completion_rate DESC `, [nowPeriod, nowPeriod]); const districts = districtStats.rows.map((row) => ({ districtId: row.district_id, districtName: row.district_name, managerName: row.manager_name, buildingCount: buildingCountByDistrict[row.district_id] ?? 0, totalApplications: parseInt(row.total_applications) || 0, totalCompleted: parseInt(row.total_completed) || 0, totalOverdue: parseInt(row.total_overdue) || 0, totalInProgress: parseInt(row.total_in_progress) || 0, completionRate: parseFloat(row.completion_rate) || 0, overdueRate: parseFloat(row.overdue_rate) || 0, averageScore: parseFloat(row.average_score) || 0, })); // Участки без статистики в district_performance_stats — показываем только дома const allDistricts = await pool.query('SELECT id, name, manager_name FROM districts ORDER BY id'); const districtIdsInStats = new Set(districts.map((d) => d.districtId)); allDistricts.rows.forEach((row) => { if (!districtIdsInStats.has(row.id)) { districts.push({ districtId: row.id, districtName: row.name, managerName: row.manager_name, buildingCount: buildingCountByDistrict[row.id] ?? 0, totalApplications: 0, totalCompleted: 0, totalOverdue: 0, totalInProgress: 0, completionRate: 0, overdueRate: 0, averageScore: 0, }); } }); // 5. Топ исполнителей (активные заявки: не done/canceled) const performers = await pool.query(` SELECT COALESCE(performer_name, 'Не назначен') as name, COUNT(*) as cnt FROM applications WHERE status NOT IN ('done', 'canceled') AND (performer_name IS NOT NULL AND performer_name != '') GROUP BY performer_name ORDER BY cnt DESC LIMIT 10 `); const topPerformers = performers.rows.map((row) => ({ name: row.name, activeCount: parseInt(row.cnt) || 0 })); // 6. Агрегаты из buildings.data: план работ, обходы, списания, обходы счетчиков const buildingsData = await pool.query(` SELECT id, data->>'districtId' as district_id, data->'annualPlan' as annual_plan, data->'inspectionHistory' as inspection_history, data->'writeOffHistory' as write_off_history, data->'meterCheckRounds' as meter_check_rounds FROM buildings `); const currentYear = now.getFullYear(); const currentMonthNames = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь']; const currentMonthName = currentMonthNames[now.getMonth()]; let planWorksTotal = 0; let planWorksCompleted = 0; let planWorksCurrent = 0; let planWorksCarriedOver = 0; let planWorksEstimatedCost = 0; const planWorksByDistrict = {}; let inspectionsCountMonth = 0; let inspectionsCountQuarter = 0; let lastInspectionDate = null; const inspectionsByDistrict = {}; const quarterStart = new Date(now.getFullYear(), Math.floor(now.getMonth() / 3) * 3, 1); let writeOffsCountToday = 0; let writeOffsCountWeek = 0; let writeOffsCountMonth = 0; let writeOffsSumMonth = 0; const writeOffsByDistrict = {}; let meterRoundsCountMonth = 0; let lastMeterRoundDate = null; const meterRoundsByDistrict = {}; buildingsData.rows.forEach((row) => { const districtId = row.district_id || 'unknown'; if (!planWorksByDistrict[districtId]) planWorksByDistrict[districtId] = { total: 0, completed: 0, buildingCount: 0 }; if (!inspectionsByDistrict[districtId]) inspectionsByDistrict[districtId] = { count: 0, lastDate: null }; if (!writeOffsByDistrict[districtId]) writeOffsByDistrict[districtId] = { count: 0, sum: 0 }; if (!meterRoundsByDistrict[districtId]) meterRoundsByDistrict[districtId] = { count: 0, lastDate: null }; const plan = row.annual_plan; let buildingHadPlanThisMonth = false; if (plan && Array.isArray(plan)) { plan.forEach((item) => { if (item.year !== currentYear) return; if (item.month !== currentMonthName) return; buildingHadPlanThisMonth = true; planWorksTotal++; planWorksEstimatedCost += Number(item.estimatedCost) || 0; if (item.status === 'completed') { planWorksCompleted++; planWorksByDistrict[districtId].completed++; } else if (item.status === 'current' || item.status === 'future') planWorksCurrent++; else if (item.status === 'carried_over') planWorksCarriedOver++; planWorksByDistrict[districtId].total++; }); if (buildingHadPlanThisMonth) planWorksByDistrict[districtId].buildingCount++; } const insp = row.inspection_history; if (insp && Array.isArray(insp)) { insp.forEach((act) => { const d = act.date ? act.date.slice(0, 10) : null; if (!d) return; const actDate = new Date(d); if (actDate >= monthStart) inspectionsCountMonth++; if (actDate >= quarterStart) inspectionsCountQuarter++; if (!lastInspectionDate || d > lastInspectionDate) lastInspectionDate = d; inspectionsByDistrict[districtId].count++; const distLast = inspectionsByDistrict[districtId].lastDate; if (!distLast || d > distLast) inspectionsByDistrict[districtId].lastDate = d; }); } const writeOff = row.write_off_history; if (writeOff && Array.isArray(writeOff)) { writeOff.forEach((act) => { const d = act.date ? act.date.slice(0, 10) : null; if (!d) return; const actDate = new Date(d); const amount = Number(act.totalAmount) || 0; if (d >= toDateStr(todayStart)) writeOffsCountToday++; if (actDate >= weekStart) writeOffsCountWeek++; if (actDate >= monthStart) { writeOffsCountMonth++; writeOffsSumMonth += amount; writeOffsByDistrict[districtId].count++; writeOffsByDistrict[districtId].sum += amount; } }); } const rounds = row.meter_check_rounds; if (rounds && Array.isArray(rounds)) { rounds.forEach((r) => { const d = r.date ? r.date.slice(0, 10) : null; if (!d) return; const roundDate = new Date(d); if (roundDate >= monthStart) { meterRoundsCountMonth++; meterRoundsByDistrict[districtId].count++; } if (!lastMeterRoundDate || d > lastMeterRoundDate) lastMeterRoundDate = d; if (!meterRoundsByDistrict[districtId].lastDate || d > meterRoundsByDistrict[districtId].lastDate) meterRoundsByDistrict[districtId].lastDate = d; }); } }); const planWorks = { total: planWorksTotal, completed: planWorksCompleted, current: planWorksCurrent, carriedOver: planWorksCarriedOver, estimatedCostMonth: Math.round(planWorksEstimatedCost), byDistrict: Object.entries(planWorksByDistrict).map(([did, v]) => ({ districtId: did, ...v })), }; const inspections = { countMonth: inspectionsCountMonth, countQuarter: inspectionsCountQuarter, lastDate: lastInspectionDate, byDistrict: Object.entries(inspectionsByDistrict).map(([did, v]) => ({ districtId: did, ...v })), }; const writeOffs = { countToday: writeOffsCountToday, countWeek: writeOffsCountWeek, countMonth: writeOffsCountMonth, sumMonth: Math.round(writeOffsSumMonth), byDistrict: Object.entries(writeOffsByDistrict).map(([did, v]) => ({ districtId: did, ...v })), }; const meterRounds = { countMonth: meterRoundsCountMonth, lastDate: lastMeterRoundDate, byDistrict: Object.entries(meterRoundsByDistrict).map(([did, v]) => ({ districtId: did, ...v })), }; // Остатки складов участков (districts.inventory — суммарное кол-во позиций) const districtsRows = await pool.query('SELECT id, name, manager_name, COALESCE(inventory, \'[]\'::jsonb) AS inventory FROM districts ORDER BY id'); const districtInventorySummary = districtsRows.rows.map((row) => { const inv = row.inventory; const items = Array.isArray(inv) ? inv : []; const totalItems = items.reduce((sum, it) => sum + (Number(it.quantity) || 0), 0); const totalValue = items.reduce((sum, it) => sum + (Number(it.quantity) || 0) * (Number(it.unitPrice) || 0), 0); return { districtId: row.id, districtName: row.name, totalQuantity: totalItems, totalValue: Math.round(totalValue) }; }); res.json({ success: true, data: { buildingCondition: buildingConditionData, workSchedule: workScheduleData, slaRating, applicationsTimeSlices, applicationsSummary: { ...applicationsSummary, completionRate }, districts, topPerformers, planWorks, inspections, writeOffs, meterRounds, districtInventorySummary, }, }); } catch (err) { console.error('[production/dashboard] Ошибка:', err); res.status(500).json({ success: false, error: err.message || 'Failed to get production dashboard' }); } }); // Автоматическая синхронизация заявок из Doma AI каждые 30 минут + первый запуск при старте const DOMA_SYNC_INTERVAL_MS = 30 * 60 * 1000; // Первый запуск после старта сервера (без блокировки старта) setTimeout(() => { syncDomaApplications().catch((err) => { console.error('[backend] Ошибка начальной синхронизации Doma AI:', err.message || err); }); }, 10 * 1000); // через 10 секунд после старта // Плановая синхронизация setInterval(() => { syncDomaApplications().catch((err) => { console.error('[backend] Ошибка плановой синхронизации Doma AI:', err.message || err); }); }, DOMA_SYNC_INTERVAL_MS); // POST /api/doma/graphql – прокси для Doma AI GraphQL (обход CORS) app.post(`${API_PREFIX}/doma/graphql`, async (req, res) => { const { query, variables, config } = req.body || {}; let apiUrl = (config && config.apiUrl) || null; let token = (config && config.token) || null; if (!apiUrl || !token) { const fromDb = await getDomaSettingsFromDb(); apiUrl = apiUrl || fromDb.apiUrl; token = token || fromDb.token; } if (!apiUrl) { return res.status(400).json({ error: 'Doma AI apiUrl is not configured' }); } try { const headers = { 'Content-Type': 'application/json', }; if (token) { headers['Authorization'] = `Bearer ${token}`; } const response = await axios.post( apiUrl, { query, variables: variables || {}, }, { headers } ); // Пробрасываем статус и тело как есть res.status(response.status).json(response.data); } catch (err) { const status = err.response?.status || 500; const data = err.response?.data || { message: err.message }; console.error('[backend] Error proxying Doma AI request:', status, data); res.status(status).json({ error: 'Failed to call Doma AI', details: data, }); } }); // Health check endpoint для проверки статуса БД app.get(`${API_PREFIX}/health/db`, async (req, res) => { try { const result = await query('SELECT NOW() as current_time, version() as pg_version'); res.json({ status: 'ok', database: 'connected', timestamp: result[0].current_time, pgVersion: result[0].pg_version.split(' ')[1] // Извлекаем версию }); } catch (err) { res.status(500).json({ status: 'error', database: 'disconnected', error: err.message }); } }); // ========= ФИНАНСОВЫЕ ENDPOINTS ========= // POST /api/finance/upload-report - загрузка файла app.post(`${API_PREFIX}/finance/upload-report`, upload.single('file'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: 'Файл не загружен' }); } // Правильно декодируем имя файла (исправляем кодировку кириллицы) let filename = req.file.originalname; try { // Multer может передавать имена файлов в неправильной кодировке // Пробуем декодировать из latin1 в utf8, если имя содержит кракозябры const hasCyrillic = /[А-Яа-яЁё]/.test(filename); const hasGarbled = /[ÐÑ]/i.test(filename) || /[â€]/i.test(filename); if (hasGarbled || (!hasCyrillic && /[^\x00-\x7F]/.test(filename))) { // Пробуем перекодировать try { const buffer = Buffer.from(filename, 'latin1'); const decoded = buffer.toString('utf8'); if (/[А-Яа-яЁё]/.test(decoded)) { filename = decoded; console.log('Исправлена кодировка имени файла:', filename); } } catch (decodeErr) { // Если не получилось, пробуем другой способ try { filename = decodeURIComponent(escape(filename)); } catch (e) { console.warn('Не удалось исправить кодировку имени файла'); } } } } catch (decodeErr) { console.warn('Ошибка декодирования имени файла, используем оригинал:', decodeErr); } const fileType = path.extname(filename).toLowerCase() === '.csv' ? 'CSV' : 'XLSX'; const uploadedBy = req.body.uploadedBy || 'System'; let reportType = req.body.reportType || 'other'; // debtors, balance_sheet, other // Автоматическое определение типа отчета по названию файла const filenameLower = filename.toLowerCase(); if (req.body.detailedReportType === 'balance_sheet_76') { reportType = 'balance_sheet_76'; } else if (filenameLower.includes('оборотн') || filenameLower.includes('сальд') || filenameLower.includes('ведомост')) { reportType = 'balance_sheet'; if (filenameLower.includes('76') || filenameLower.includes('76.06') || filenameLower.includes('счету 76')) { reportType = 'balance_sheet_76'; } } else if (filenameLower.includes('должник') || filenameLower.includes('задолженност')) { reportType = 'debtors'; } // Дополнительно: по содержимому (для файлов вроде "Новый.csv") if (reportType === 'other' && req.file.path) { try { const head = fs.readFileSync(req.file.path, { encoding: 'utf8', flag: 'r' }).slice(0, 2048); if (head.includes('Отчет по задолженности')) { reportType = 'debtors'; } } catch (e) { // игнорируем ошибку чтения } } // Создаем запись об отчете в БД let reportResult; try { reportResult = await query( `INSERT INTO financial_reports (filename, file_type, uploaded_by, status, report_type) VALUES ($1, $2, $3, 'processing', $4) RETURNING id`, [filename, fileType, uploadedBy, reportType] ); } catch (insertErr) { if (insertErr.code === '23514' && reportType === 'balance_sheet_76') { try { await ensureBalanceSheet76ReportType(); reportResult = await query( `INSERT INTO financial_reports (filename, file_type, uploaded_by, status, report_type) VALUES ($1, $2, $3, 'processing', $4) RETURNING id`, [filename, fileType, uploadedBy, reportType] ); } catch (migrateErr) { console.warn('[upload-report] Миграция ОСВ 76 не применена:', migrateErr.message); return res.status(400).json({ error: 'Тип отчёта «ОСВ по счёту 76» не поддерживается текущей базой данных.', code: 'REPORT_TYPE_NOT_SUPPORTED', hint: 'Примените миграцию add_report_type_balance_sheet_76.sql и create_report_76_tables.sql (или перезапустите приложение).' }); } } else { throw insertErr; } } const reportId = reportResult[0].id; // Если это ОСВ по счёту 76.06 (лицевые счета жителей) if (reportType === 'balance_sheet_76') { try { await ensureReport76Tables(); const rows = await balanceSheet76Processor.parseBalanceSheet76(req.file.path, fileType); const client = await pool.connect(); try { await client.query('BEGIN'); for (let i = 0; i < rows.length; i++) { const r = rows[i]; await client.query( `INSERT INTO report_76_rows (report_id, row_index, account_label, account_ls, saldo_start_debet, turnover_debet, turnover_credit, saldo_end_debet, saldo_end_credit) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [ reportId, r.row_index, r.account_label, r.account_ls || null, r.saldo_start_debet, r.turnover_debet, r.turnover_credit, r.saldo_end_debet, r.saldo_end_credit ] ); } await client.query( `UPDATE financial_reports SET status = $1, processed_rows = $2, total_rows = $3 WHERE id = $4`, ['completed', rows.length, rows.length, reportId] ); await client.query('COMMIT'); } catch (err) { await client.query('ROLLBACK'); throw err; } finally { client.release(); } fs.unlink(req.file.path, (err) => { if (err) console.error('Ошибка удаления файла:', err); }); return res.json({ success: true, reportId, message: 'ОСВ по счёту 76 (лицевые счета) обработана успешно', rowsProcessed: rows.length }); } catch (error) { console.error('Ошибка обработки ОСВ 76:', error); await query( 'UPDATE financial_reports SET status = $1, error_log = $2 WHERE id = $3', ['failed', JSON.stringify([{ message: error.message }]), reportId] ); const isMissingTable = error.code === '42P01' || (error.message && String(error.message).includes('report_76_rows')); return res.status(isMissingTable ? 400 : 500).json({ error: isMissingTable ? 'Таблица для ОСВ 76 не найдена в базе данных.' : 'Ошибка обработки ОСВ 76', details: error.message, ...(isMissingTable && { hint: 'Примените миграцию create_report_76_tables.sql или перезапустите приложение.' }) }); } } // Если это оборотно-сальдовая ведомость по счёту 20, используем специальный процессор if (reportType === 'balance_sheet') { try { // Парсим ведомость const buildings = await balanceSheetProcessor.parseBalanceSheet(req.file.path, fileType); // Извлекаем период из названия файла const period = balanceSheetProcessor.extractPeriodFromFilename(filename); // Преобразуем в формат для БД const financialData = balanceSheetProcessor.convertToBuildingFinancialData( buildings, reportId, period.start, period.end ); // Сохраняем данные в БД const client = await pool.connect(); try { await client.query('BEGIN'); let processedCount = 0; let notFoundAddresses = []; console.log(`[balance-sheet] Обработка ${financialData.length} домов из ведомости`); for (const data of financialData) { console.log(`[balance-sheet] Поиск дома по адресу: "${data.address}" (totalExpenses=${data.totalExpenses})`); let buildingResult = { rows: [] }; const normalizedForMapping = (data.address || '').trim().toLowerCase().replace(/\s+/g, ' '); // 1. Сначала пробуем найти по таблице маппинга адресов (адрес из 1С/ОСВ → building_id) const mappingResult = await client.query( `SELECT building_id FROM doma_address_mappings WHERE LOWER(TRIM(doma_address)) = $1 LIMIT 1`, [normalizedForMapping] ); if (mappingResult.rows.length > 0) { const buildingId = mappingResult.rows[0].building_id; buildingResult = await client.query( `SELECT id, data->>'districtId' as district_id, data->'passport'->>'address' as address FROM buildings WHERE id = $1`, [buildingId] ); if (buildingResult.rows.length > 0) { console.log(`[balance-sheet] Дом найден по doma_address_mappings: "${data.address}" → building_id=${buildingId}`); } } // 2. Точное совпадение с адресом в buildings if (buildingResult.rows.length === 0) { buildingResult = await client.query( `SELECT id, data->>'districtId' as district_id, data->'passport'->>'address' as address FROM buildings WHERE data->'passport'->>'address' = $1`, [data.address] ); } // 3. Без учета регистра и лишних пробелов if (buildingResult.rows.length === 0) { buildingResult = await client.query( `SELECT id, data->>'districtId' as district_id, data->'passport'->>'address' as address FROM buildings WHERE LOWER(TRIM(data->'passport'->>'address')) = $1`, [normalizedForMapping] ); } // 4. Нормализация: убрать "ул.", "д.", запятые, лишние пробелы и сравнить if (buildingResult.rows.length === 0) { const normalizedAddr = data.address .replace(/\bул\.?\s*/gi, '') .replace(/\bд\.?\s*/gi, '') .replace(/,/g, ' ') .replace(/\s+/g, ' ') .trim() .toLowerCase(); if (normalizedAddr.length > 3) { buildingResult = await client.query( `SELECT id, data->>'districtId' as district_id, data->'passport'->>'address' as address FROM buildings WHERE LOWER(TRIM(REPLACE(REPLACE(REPLACE(data->'passport'->>'address', ',', ' '), 'ул.', ''), 'д.', ''))) = $1 OR LOWER(TRIM(data->'passport'->>'address')) LIKE $2`, [normalizedAddr, `%${normalizedAddr.replace(/%/g, '\\%')}%`] ); } } // 5. По основной части адреса (до запятой или точки) if (buildingResult.rows.length === 0) { const mainPart = data.address.split(/[,\.]/)[0].trim(); if (mainPart.length > 5) { buildingResult = await client.query( `SELECT id, data->>'districtId' as district_id, data->'passport'->>'address' as address FROM buildings WHERE data->'passport'->>'address' ILIKE $1`, [`%${mainPart}%`] ); } } if (buildingResult.rows.length === 0) { console.log(`[balance-sheet] ⚠ Дом не найден в базе, пропускаем: "${data.address}" (totalExpenses=${data.totalExpenses})`); notFoundAddresses.push(data.address); continue; } if (buildingResult.rows.length > 0) { const buildingId = buildingResult.rows[0].id; const foundAddress = buildingResult.rows[0].address; const districtId = buildingResult.rows[0].district_id; if (foundAddress !== data.address) { console.log(`[balance-sheet] Найден дом с похожим адресом: "${foundAddress}" (искали: "${data.address}")`); } if (districtId) { console.log(`[balance-sheet] Дом связан с участком: ${districtId}`); } // Сохраняем финансовые данные // Используем INSERT с ON CONFLICT для обновления только если это тот же отчет // Если период совпадает, но отчет другой - создается новая запись (данные накапливаются) await client.query( `INSERT INTO building_financial_data (building_id, report_id, period_start, period_end, period_type, total_income, income_by_items, total_expenses, expenses_by_items, balance, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (building_id, period_start, period_end, period_type) DO UPDATE SET report_id = EXCLUDED.report_id, total_income = EXCLUDED.total_income, income_by_items = EXCLUDED.income_by_items, total_expenses = EXCLUDED.total_expenses, expenses_by_items = EXCLUDED.expenses_by_items, balance = EXCLUDED.balance, metadata = EXCLUDED.metadata, updated_at = NOW()`, [ buildingId, reportId, data.periodStart, data.periodEnd, data.periodType, data.totalIncome, JSON.stringify({}), data.totalExpenses, JSON.stringify(data.expensesByItems), data.balance, JSON.stringify(data.metadata || {}) ] ); processedCount++; console.log(`[balance-sheet] ✓ Сохранены данные для дома: "${foundAddress}"`); } } if (notFoundAddresses.length > 0) { console.warn(`[balance-sheet] Всего не найдено домов: ${notFoundAddresses.length}`); console.warn(`[balance-sheet] Адреса, которые не были найдены:`, notFoundAddresses); } console.log(`[balance-sheet] Обработано домов: ${processedCount} из ${financialData.length}`); await client.query('COMMIT'); // Обновляем статус отчета const finalStatus = processedCount > 0 ? (processedCount === financialData.length ? 'completed' : 'partial') : 'failed'; await query( `UPDATE financial_reports SET status = $1, processed_rows = $2, total_rows = $3, error_rows = $4 WHERE id = $5`, [finalStatus, processedCount, financialData.length, notFoundAddresses.length, reportId] ); if (notFoundAddresses.length > 0) { // Сохраняем список не найденных адресов в error_log await query( `UPDATE financial_reports SET error_log = $1 WHERE id = $2`, [JSON.stringify({ notFoundAddresses: notFoundAddresses, message: `Не найдено ${notFoundAddresses.length} домов из ${financialData.length}. Данные по этим адресам не были загружены. Создайте дома вручную в системе, затем загрузите отчет повторно.` }), reportId] ); } // Удаляем файл fs.unlink(req.file.path, (err) => { if (err) console.error('Ошибка удаления файла:', err); }); return res.json({ success: true, reportId, message: 'Ведомость обработана успешно', buildingsProcessed: processedCount }); } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } catch (error) { console.error('Ошибка обработки ведомости:', error); await query( 'UPDATE financial_reports SET status = $1, error_log = $2 WHERE id = $3', ['failed', JSON.stringify([{ message: error.message }]), reportId] ); return res.status(500).json({ error: 'Ошибка обработки ведомости', details: error.message }); } } // Если это отчёт по должникам (задолженности), парсим и сохраняем в debtor_report_data if (reportType === 'debtors') { try { // Убедиться, что таблица debtor_report_data существует (миграция могла не успеть выполниться) const tableCheck = await query( `SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'debtor_report_data' LIMIT 1` ); if (!tableCheck || tableCheck.length === 0) { const migrationPath = path.join(__dirname, 'migrations', 'create_debtor_report_data.sql'); if (fs.existsSync(migrationPath)) { const migrationSQL = fs.readFileSync(migrationPath, 'utf8'); const client = await pool.connect(); try { await client.query(migrationSQL); console.log('[debtors] Таблица debtor_report_data создана из миграции'); } finally { client.release(); } } else { throw new Error('Таблица debtor_report_data не найдена. Перезапустите сервер или примените миграцию create_debtor_report_data.sql'); } } const rows = await debtorReportProcessor.parseDebtorReport(req.file.path, fileType); const client = await pool.connect(); try { await client.query('BEGIN'); await client.query('DELETE FROM debtor_report_data WHERE report_id = $1', [reportId]); let processedRows = 0; for (const row of rows) { await client.query( `INSERT INTO debtor_report_data (report_id, row_index, account, responsible_name, object_address, months_debt, total_debt) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [ reportId, row.row_index, row.account, row.responsible_name || null, row.object_address || null, row.months_debt, row.total_debt ] ); processedRows++; } await client.query('COMMIT'); const finalStatus = processedRows > 0 ? 'completed' : 'failed'; await query( `UPDATE financial_reports SET status = $1, total_rows = $2, processed_rows = $3, error_rows = $4 WHERE id = $5`, [finalStatus, rows.length, processedRows, rows.length - processedRows, reportId] ); } catch (err) { await client.query('ROLLBACK'); throw err; } finally { client.release(); } try { fs.unlinkSync(req.file.path); } catch (e) { console.warn('Ошибка удаления файла после обработки должников:', e.message); } return res.json({ success: true, reportId, message: 'Отчёт по должникам обработан', processedRows: rows.length }); } catch (error) { console.error('Ошибка обработки отчёта по должникам:', error); await query( 'UPDATE financial_reports SET status = $1, error_log = $2 WHERE id = $3', ['failed', JSON.stringify([{ message: error.message }]), reportId] ); try { fs.unlink(req.file.path, () => {}); } catch (e) {} return res.status(500).json({ error: 'Ошибка обработки отчёта по должникам', details: error.message }); } } // Получаем маппинг (по умолчанию или указанный) const mappingId = req.body.mappingId; let mapping; if (mappingId) { const mappingResult = await query( 'SELECT * FROM financial_report_mappings WHERE id = $1', [mappingId] ); if (mappingResult.length > 0) { const dbMapping = mappingResult[0]; // Преобразуем column_mappings (snake_case из БД) в columnMappings (camelCase для кода) // column_mappings - это JSONB, PostgreSQL возвращает его как объект const columnMappings = dbMapping.column_mappings || dbMapping.columnMappings; mapping = { ...dbMapping, columnMappings: typeof columnMappings === 'object' && columnMappings !== null ? columnMappings : (typeof columnMappings === 'string' ? JSON.parse(columnMappings) : {}) }; } } // Если маппинг не указан, берем по умолчанию if (!mapping) { const defaultMappingResult = await query( 'SELECT * FROM financial_report_mappings WHERE is_default = true LIMIT 1' ); if (defaultMappingResult.length > 0) { const dbMapping = defaultMappingResult[0]; // Преобразуем column_mappings (snake_case из БД) в columnMappings (camelCase для кода) // column_mappings - это JSONB, PostgreSQL возвращает его как объект const columnMappings = dbMapping.column_mappings || dbMapping.columnMappings; mapping = { ...dbMapping, columnMappings: typeof columnMappings === 'object' && columnMappings !== null ? columnMappings : (typeof columnMappings === 'string' ? JSON.parse(columnMappings) : {}) }; } else { // Создаем базовый маппинг по умолчанию mapping = { id: null, name: 'Default Mapping', columnMappings: { 'Адрес': 'address', 'Период': 'periodStart', 'Доход': 'totalIncome', 'Расход': 'totalExpenses', 'Баланс': 'balance' } }; } } // Проверяем, что маппинг корректен if (!mapping || !mapping.columnMappings || Object.keys(mapping.columnMappings).length === 0) { return res.status(400).json({ error: 'Маппинг колонок не настроен. Пожалуйста, настройте маппинг перед загрузкой файла.' }); } // Запускаем обработку файла асинхронно const { jobId, processPromise } = fileProcessor.processFile(req.file.path, fileType, mapping, reportId); processPromise .then(result => { // Удаляем файл после обработки fs.unlink(req.file.path, (err) => { if (err) console.error('Ошибка удаления файла:', err); }); }) .catch(error => { console.error('Ошибка обработки файла:', error); // Обновляем статус отчета на failed query( 'UPDATE financial_reports SET status = $1, error_log = $2 WHERE id = $3', ['failed', JSON.stringify([{ message: error.message }]), reportId] ); }); res.json({ success: true, reportId, jobId, message: 'Файл загружен, обработка начата' }); } catch (err) { console.error('Error uploading report:', err); console.error('Error stack:', err.stack); res.status(500).json({ error: 'Failed to upload report', details: err.message, stack: process.env.NODE_ENV === 'development' ? err.stack : undefined }); } }); // GET /api/finance/processing-status/:jobId - статус обработки app.get(`${API_PREFIX}/finance/processing-status/:jobId`, (req, res) => { const { jobId } = req.params; const status = fileProcessor.getJobStatus(jobId); if (!status) { return res.status(404).json({ error: 'Job not found' }); } res.json(status); }); // POST /api/finance/configure-mapping - настройка маппинга app.post(`${API_PREFIX}/finance/configure-mapping`, async (req, res) => { try { const { name, description, columnMappings, isDefault } = req.body; if (!name || !columnMappings) { return res.status(400).json({ error: 'name и columnMappings обязательны' }); } // Если устанавливаем как default, снимаем флаг с других if (isDefault) { await query('UPDATE financial_report_mappings SET is_default = false'); } const result = await query( `INSERT INTO financial_report_mappings (name, description, column_mappings, is_default) VALUES ($1, $2, $3, $4) RETURNING *`, [name, description || null, JSON.stringify(columnMappings), isDefault || false] ); res.status(201).json(result[0]); } catch (err) { console.error('Error creating mapping:', err); res.status(500).json({ error: 'Failed to create mapping' }); } }); // GET /api/finance/mappings - список маппингов app.get(`${API_PREFIX}/finance/mappings`, async (req, res) => { try { const rows = await query( 'SELECT * FROM financial_report_mappings ORDER BY is_default DESC, created_at DESC' ); res.json(rows); } catch (err) { console.error('Error fetching mappings:', err); res.status(500).json({ error: 'Failed to fetch mappings' }); } }); // GET /api/finance/building/:buildingId - финансовая сводка по дому app.get(`${API_PREFIX}/finance/building/:buildingId`, async (req, res) => { try { const { buildingId } = req.params; const { periodStart, periodEnd, periodType } = req.query; let queryText = ` SELECT * FROM building_financial_data WHERE building_id = $1 `; const params = [buildingId]; if (periodStart) { queryText += ` AND period_start >= $${params.length + 1}`; params.push(periodStart); } if (periodEnd) { queryText += ` AND period_end <= $${params.length + 1}`; params.push(periodEnd); } if (periodType) { queryText += ` AND period_type = $${params.length + 1}`; params.push(periodType); } queryText += ' ORDER BY period_start DESC, period_end DESC'; const rows = await query(queryText, params); res.json(rows); } catch (err) { console.error('Error fetching building financial data:', err); res.status(500).json({ error: 'Failed to fetch building financial data' }); } }); // GET /api/finance/reports - список всех финансовых отчетов app.get(`${API_PREFIX}/finance/reports`, async (req, res) => { try { const { reportType, status } = req.query; console.log('GET /api/finance/reports - запрос с параметрами:', { reportType, status }); // Проверяем наличие колонки report_type let hasReportTypeColumn = false; try { const checkColumn = await query(` SELECT column_name FROM information_schema.columns WHERE table_name = 'financial_reports' AND column_name = 'report_type' `); hasReportTypeColumn = checkColumn.length > 0; console.log('Колонка report_type существует:', hasReportTypeColumn); } catch (checkErr) { console.warn('Ошибка проверки колонки report_type:', checkErr); } let queryText = ` SELECT id, filename, file_type, ${hasReportTypeColumn ? 'report_type,' : 'NULL as report_type,'} uploaded_at, uploaded_by, status, total_rows, processed_rows, error_rows, mapping_id FROM financial_reports WHERE 1=1 `; const params = []; if (reportType && hasReportTypeColumn) { queryText += ` AND report_type = $${params.length + 1}`; params.push(reportType); console.log('Добавлен фильтр по типу отчета:', reportType); } else if (reportType && !hasReportTypeColumn) { console.warn('Фильтр по типу отчета не применен: колонка report_type не существует'); } if (status) { queryText += ` AND status = $${params.length + 1}`; params.push(status); } queryText += ' ORDER BY uploaded_at DESC'; console.log('Выполняется запрос:', queryText, 'с параметрами:', params); const rows = await query(queryText, params); console.log('Найдено отчетов:', rows.length); res.json(rows); } catch (err) { console.error('Error fetching financial reports:', err); res.status(500).json({ error: 'Failed to fetch reports', details: err.message }); } }); // GET /api/finance/buildings/:buildingId/personal-accounts - лицевые счета, привязанные к дому (для ОСВ 76) app.get(`${API_PREFIX}/finance/buildings/:buildingId/personal-accounts`, async (req, res) => { try { const { buildingId } = req.params; const rows = await query( `SELECT id, building_id AS "buildingId", account_ls AS "accountLs", account_label AS "accountLabel", apartment, created_at AS "createdAt" FROM building_personal_account_mappings WHERE building_id = $1 ORDER BY account_ls`, [buildingId] ); res.json(rows); } catch (err) { console.error('Error fetching building personal accounts:', err); res.status(500).json({ error: 'Ошибка загрузки лицевых счетов дома', details: err.message }); } }); // POST /api/finance/building-personal-account-mappings - добавить/обновить привязку лицевого счёта к дому app.post(`${API_PREFIX}/finance/building-personal-account-mappings`, async (req, res) => { try { const { building_id, account_ls, account_label, apartment } = req.body || {}; if (!building_id || !account_ls) { return res.status(400).json({ error: 'Укажите building_id и account_ls' }); } await query( `INSERT INTO building_personal_account_mappings (building_id, account_ls, account_label, apartment, updated_at) VALUES ($1, $2, $3, $4, NOW()) ON CONFLICT (building_id, account_ls) DO UPDATE SET account_label = COALESCE(EXCLUDED.account_label, building_personal_account_mappings.account_label), apartment = COALESCE(EXCLUDED.apartment, building_personal_account_mappings.apartment), updated_at = NOW()`, [building_id, String(account_ls).trim(), account_label && String(account_label).trim() || null, apartment && String(apartment).trim() || null] ); const row = await query( `SELECT id, building_id AS "buildingId", account_ls AS "accountLs", account_label AS "accountLabel", apartment, created_at AS "createdAt" FROM building_personal_account_mappings WHERE building_id = $1 AND account_ls = $2`, [building_id, String(account_ls).trim()] ); res.status(201).json(row[0] || { buildingId: building_id, accountLs: account_ls, accountLabel: account_label, apartment }); } catch (err) { console.error('Error upserting building personal account mapping:', err); res.status(500).json({ error: 'Ошибка сохранения привязки', details: err.message }); } }); // GET /api/finance/buildings - список домов с финансовыми данными app.get(`${API_PREFIX}/finance/buildings`, async (req, res) => { try { const { reportId } = req.query; let queryText = ` SELECT b.id, b.data->'passport'->>'address' as address, b.data->>'districtId' as district_id, CASE WHEN d.name IS NOT NULL AND d.name != '' THEN d.name ELSE b.data->>'districtId' END as district_name, COALESCE(SUM(bfd.total_income), 0) as total_income, COALESCE(SUM(bfd.total_expenses), 0) as total_expenses, COALESCE(SUM(bfd.balance), 0) as balance, COUNT(bfd.id) as reports_count, MAX(bfd.period_start) as period_start, MAX(bfd.period_end) as period_end FROM buildings b INNER JOIN building_financial_data bfd ON b.id = bfd.building_id LEFT JOIN districts d ON d.id = b.data->>'districtId' `; const params = []; if (reportId) { queryText += ` WHERE bfd.report_id = $1`; params.push(reportId); } queryText += ` GROUP BY b.id, b.data->>'districtId', b.data->'passport'->>'address', d.name ORDER BY COALESCE(NULLIF(d.name, ''), b.data->>'districtId'), b.data->'passport'->>'address' `; const rows = await query(queryText, params); // Логируем для отладки if (rows.length > 0) { console.log('[finance/buildings] Пример данных:', { district_id: rows[0].district_id, district_name: rows[0].district_name, address: rows[0].address }); } res.json(rows); } catch (err) { console.error('Error fetching buildings with financial data:', err); res.status(500).json({ error: 'Failed to fetch buildings' }); } }); // GET /api/finance/reports/:reportId/debtor-rows - строки отчёта по должникам app.get(`${API_PREFIX}/finance/reports/:reportId/debtor-rows`, async (req, res) => { try { const { reportId } = req.params; const { search, minDebt } = req.query; let queryText = ` SELECT id, report_id AS "reportId", row_index AS "rowIndex", account, responsible_name AS "responsibleName", object_address AS "objectAddress", months_debt AS "monthsDebt", total_debt AS "totalDebt", created_at AS "createdAt" FROM debtor_report_data WHERE report_id = $1 `; const params = [reportId]; if (search && String(search).trim() !== '') { const term = '%' + String(search).trim().replace(/%/g, '\\%') + '%'; queryText += ` AND (account ILIKE $${params.length + 1} OR responsible_name ILIKE $${params.length + 1} OR object_address ILIKE $${params.length + 1})`; params.push(term, term, term); } if (minDebt !== undefined && minDebt !== '') { const num = parseFloat(minDebt); if (!Number.isNaN(num)) { queryText += ` AND total_debt >= $${params.length + 1}`; params.push(num); } } queryText += ' ORDER BY row_index'; const result = await query(queryText, params); res.json(result); } catch (err) { console.error('Error fetching debtor rows:', err); res.status(500).json({ error: 'Failed to fetch debtor rows', details: err.message }); } }); // GET /api/finance/reports/:reportId/balance-sheet-76-rows - строки ОСВ по счёту 76 (лицевые счета) app.get(`${API_PREFIX}/finance/reports/:reportId/balance-sheet-76-rows`, async (req, res) => { try { const { reportId } = req.params; const { buildingId } = req.query; const reportRow = await query( 'SELECT id, report_type, filename FROM financial_reports WHERE id = $1', [reportId] ); if (!reportRow || reportRow.length === 0) { return res.status(404).json({ error: 'Отчёт не найден' }); } if (reportRow[0].report_type !== 'balance_sheet_76') { return res.status(400).json({ error: 'Отчёт не является ОСВ по счёту 76' }); } let queryText = ` SELECT id, report_id AS "reportId", row_index AS "rowIndex", account_label AS "accountLabel", account_ls AS "accountLs", saldo_start_debet AS "saldoStartDebet", turnover_debet AS "turnoverDebet", turnover_credit AS "turnoverCredit", saldo_end_debet AS "saldoEndDebet", saldo_end_credit AS "saldoEndCredit" FROM report_76_rows WHERE report_id = $1 `; const params = [reportId]; if (buildingId && String(buildingId).trim() !== '') { queryText = ` SELECT r.id, r.report_id AS "reportId", r.row_index AS "rowIndex", r.account_label AS "accountLabel", r.account_ls AS "accountLs", r.saldo_start_debet AS "saldoStartDebet", r.turnover_debet AS "turnoverDebet", r.turnover_credit AS "turnoverCredit", r.saldo_end_debet AS "saldoEndDebet", r.saldo_end_credit AS "saldoEndCredit" FROM report_76_rows r INNER JOIN building_personal_account_mappings m ON (m.account_ls = r.account_ls OR (r.account_ls IS NULL AND LOWER(TRIM(m.account_label)) = LOWER(TRIM(r.account_label)))) WHERE r.report_id = $1 AND m.building_id = $2 ORDER BY r.row_index `; params.push(buildingId); } else { queryText += ' ORDER BY row_index'; } const rows = await query(queryText, params); res.json(rows); } catch (err) { console.error('Error fetching balance-sheet-76 rows:', err); res.status(500).json({ error: 'Ошибка загрузки строк ОСВ 76', details: err.message }); } }); // GET /api/finance/reports/:reportId/aggregated - агрегированный отчет по всем домам app.get(`${API_PREFIX}/finance/reports/:reportId/aggregated`, async (req, res) => { try { const { reportId } = req.params; const { periodStart, periodEnd } = req.query; // Формируем запрос с фильтрацией по периоду, если указан let queryText = ` SELECT bfd.*, b.data->'passport'->>'address' as address, b.data->>'districtId' as district_id, b.data->'passport'->'general'->>'totalArea' as total_area, b.data->'passport'->'general'->>'livingArea' as living_area, b.data->'passport'->'general'->>'nonLivingArea' as non_living_area FROM building_financial_data bfd JOIN buildings b ON bfd.building_id = b.id WHERE bfd.report_id = $1 `; const params = [reportId]; if (periodStart) { queryText += ` AND bfd.period_start >= $${params.length + 1}`; params.push(periodStart); } if (periodEnd) { queryText += ` AND bfd.period_end <= $${params.length + 1}`; params.push(periodEnd); } // Получаем все финансовые данные по отчету const financialData = await query(queryText, params); if (financialData.length === 0) { return res.status(404).json({ error: 'Данные не найдены' }); } // Агрегируем данные let totalIncome = 0; let totalExpenses = 0; let totalBalance = 0; let totalArea = 0; let totalLivingArea = 0; let totalNonLivingArea = 0; const aggregatedExpenses = {}; const dataPeriodStart = financialData[0].period_start; const dataPeriodEnd = financialData[0].period_end; financialData.forEach(row => { totalIncome += parseFloat(row.total_income) || 0; totalExpenses += parseFloat(row.total_expenses) || 0; totalBalance += parseFloat(row.balance) || 0; totalArea += parseFloat(row.total_area) || 0; totalLivingArea += parseFloat(row.living_area) || 0; totalNonLivingArea += parseFloat(row.non_living_area) || 0; // Агрегируем расходы по статьям if (row.expenses_by_items) { const expenses = typeof row.expenses_by_items === 'string' ? JSON.parse(row.expenses_by_items) : row.expenses_by_items; for (const [key, value] of Object.entries(expenses)) { aggregatedExpenses[key] = (aggregatedExpenses[key] || 0) + parseFloat(value); } } }); // Используем выбранный период или период из данных const reportPeriodStart = periodStart || (financialData.length > 0 ? dataPeriodStart : null); const reportPeriodEnd = periodEnd || (financialData.length > 0 ? dataPeriodEnd : null); res.json({ periodStart: reportPeriodStart, periodEnd: reportPeriodEnd, totalIncome, totalExpenses, totalBalance, totalArea, totalLivingArea, totalNonLivingArea, buildingsCount: financialData.length, aggregatedExpenses, buildings: financialData.map(row => ({ id: row.building_id, address: row.address, income: parseFloat(row.total_income) || 0, expenses: parseFloat(row.total_expenses) || 0, balance: parseFloat(row.balance) || 0, expensesByItems: typeof row.expenses_by_items === 'string' ? JSON.parse(row.expenses_by_items) : row.expenses_by_items })) }); } catch (err) { console.error('Error fetching aggregated report:', err); res.status(500).json({ error: 'Failed to fetch aggregated report' }); } }); // GET /api/finance/reports/:reportId/districts/:districtId/aggregated - агрегированный отчет по участку app.get(`${API_PREFIX}/finance/reports/:reportId/districts/:districtId/aggregated`, async (req, res) => { try { const { reportId, districtId } = req.params; const { periodStart, periodEnd } = req.query; // Получаем название участка const districtResult = await query( 'SELECT name FROM districts WHERE id = $1', [districtId] ); const districtName = districtResult.length > 0 ? districtResult[0].name : districtId; // Получаем все финансовые данные по отчету и участку const financialData = await query( `SELECT bfd.*, b.data->'passport'->>'address' as address, b.data->'passport'->'general'->>'totalArea' as total_area, b.data->'passport'->'general'->>'livingArea' as living_area, b.data->'passport'->'general'->>'nonLivingArea' as non_living_area FROM building_financial_data bfd JOIN buildings b ON bfd.building_id = b.id WHERE bfd.report_id = $1 AND b.data->>'districtId' = $2`, [reportId, districtId] ); if (financialData.length === 0) { return res.status(404).json({ error: 'Данные по участку не найдены' }); } // Агрегируем данные let totalIncome = 0; let totalExpenses = 0; let totalBalance = 0; let totalArea = 0; let totalLivingArea = 0; let totalNonLivingArea = 0; const aggregatedExpenses = {}; const dataPeriodStart = financialData[0].period_start; const dataPeriodEnd = financialData[0].period_end; financialData.forEach(row => { totalIncome += parseFloat(row.total_income) || 0; totalExpenses += parseFloat(row.total_expenses) || 0; totalBalance += parseFloat(row.balance) || 0; totalArea += parseFloat(row.total_area) || 0; totalLivingArea += parseFloat(row.living_area) || 0; totalNonLivingArea += parseFloat(row.non_living_area) || 0; // Агрегируем расходы по статьям if (row.expenses_by_items) { const expenses = typeof row.expenses_by_items === 'string' ? JSON.parse(row.expenses_by_items) : row.expenses_by_items; for (const [key, value] of Object.entries(expenses)) { aggregatedExpenses[key] = (aggregatedExpenses[key] || 0) + parseFloat(value); } } }); // Используем выбранный период или период из данных const reportPeriodStart = periodStart || (financialData.length > 0 ? dataPeriodStart : null); const reportPeriodEnd = periodEnd || (financialData.length > 0 ? dataPeriodEnd : null); res.json({ districtId, districtName, periodStart: reportPeriodStart, periodEnd: reportPeriodEnd, totalIncome, totalExpenses, totalBalance, totalArea, totalLivingArea, totalNonLivingArea, buildingsCount: financialData.length, aggregatedExpenses, buildings: financialData.map(row => ({ id: row.building_id, address: row.address, income: parseFloat(row.total_income) || 0, expenses: parseFloat(row.total_expenses) || 0, balance: parseFloat(row.balance) || 0, expensesByItems: typeof row.expenses_by_items === 'string' ? JSON.parse(row.expenses_by_items) : row.expenses_by_items })) }); } catch (err) { console.error('Error fetching district aggregated report:', err); res.status(500).json({ error: 'Failed to fetch district aggregated report' }); } }); // ========= СПРАВОЧНИК СТАТЕЙ РАСХОДОВ ========= // GET /api/finance/expense-categories - список категорий расходов app.get(`${API_PREFIX}/finance/expense-categories`, async (req, res) => { try { const rows = await query( `SELECT id, name, code, description, sort_order, is_active, created_at, updated_at FROM expense_categories WHERE is_active = TRUE ORDER BY sort_order, name` ); res.json(rows); } catch (err) { console.error('Error fetching expense categories:', err); res.status(500).json({ error: 'Failed to fetch expense categories' }); } }); // POST /api/finance/expense-categories - создание категории app.post(`${API_PREFIX}/finance/expense-categories`, async (req, res) => { try { const { name, code, description, sortOrder } = req.body; if (!name) { return res.status(400).json({ error: 'name обязателен' }); } const result = await query( `INSERT INTO expense_categories (name, code, description, sort_order) VALUES ($1, $2, $3, $4) RETURNING *`, [name, code || null, description || null, sortOrder || 0] ); res.status(201).json(result[0]); } catch (err) { console.error('Error creating expense category:', err); if (err.code === '23505') { // Unique violation return res.status(400).json({ error: 'Категория с таким названием уже существует' }); } res.status(500).json({ error: 'Failed to create expense category' }); } }); // PUT /api/finance/expense-categories/:id - обновление категории app.put(`${API_PREFIX}/finance/expense-categories/:id`, async (req, res) => { try { const { id } = req.params; const { name, code, description, sortOrder, isActive } = req.body; const result = await query( `UPDATE expense_categories SET name = $1, code = $2, description = $3, sort_order = $4, is_active = $5, updated_at = NOW() WHERE id = $6 RETURNING *`, [name, code || null, description || null, sortOrder || 0, isActive !== undefined ? isActive : true, id] ); if (result.length === 0) { return res.status(404).json({ error: 'Категория не найдена' }); } res.json(result[0]); } catch (err) { console.error('Error updating expense category:', err); res.status(500).json({ error: 'Failed to update expense category' }); } }); // DELETE /api/finance/expense-categories/:id - удаление категории app.delete(`${API_PREFIX}/finance/expense-categories/:id`, async (req, res) => { try { const { id } = req.params; // Проверяем, есть ли статьи в этой категории const itemsCheck = await query( 'SELECT COUNT(*) as count FROM expense_items WHERE category_id = $1', [id] ); if (parseInt(itemsCheck[0].count) > 0) { return res.status(400).json({ error: 'Невозможно удалить категорию: в ней есть статьи расходов. Сначала удалите или переместите статьи.' }); } const result = await query( 'DELETE FROM expense_categories WHERE id = $1 RETURNING id', [id] ); if (result.length === 0) { return res.status(404).json({ error: 'Категория не найдена' }); } res.json({ success: true, message: 'Категория удалена' }); } catch (err) { console.error('Error deleting expense category:', err); res.status(500).json({ error: 'Failed to delete expense category' }); } }); // GET /api/finance/expense-items - список статей расходов app.get(`${API_PREFIX}/finance/expense-items`, async (req, res) => { try { const { categoryId } = req.query; let queryText = ` SELECT ei.id, ei.category_id, ei.name, ei.code, ei.description, ei.parent_item_id, ei.sort_order, ei.is_active, ei.created_at, ei.updated_at, ec.name as category_name FROM expense_items ei JOIN expense_categories ec ON ei.category_id = ec.id WHERE ei.is_active = TRUE `; const params = []; if (categoryId) { queryText += ' AND ei.category_id = $1'; params.push(categoryId); } queryText += ' ORDER BY ec.sort_order, ec.name, ei.sort_order, ei.name'; const rows = await query(queryText, params); res.json(rows); } catch (err) { console.error('Error fetching expense items:', err); res.status(500).json({ error: 'Failed to fetch expense items' }); } }); // POST /api/finance/expense-items - создание статьи app.post(`${API_PREFIX}/finance/expense-items`, async (req, res) => { try { const { categoryId, name, code, description, parentItemId, sortOrder } = req.body; if (!categoryId || !name) { return res.status(400).json({ error: 'categoryId и name обязательны' }); } const result = await query( `INSERT INTO expense_items (category_id, name, code, description, parent_item_id, sort_order) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, [categoryId, name, code || null, description || null, parentItemId || null, sortOrder || 0] ); res.status(201).json(result[0]); } catch (err) { console.error('Error creating expense item:', err); if (err.code === '23505') { // Unique violation return res.status(400).json({ error: 'Статья с таким названием уже существует в этой категории' }); } res.status(500).json({ error: 'Failed to create expense item' }); } }); // PUT /api/finance/expense-items/:id - обновление статьи app.put(`${API_PREFIX}/finance/expense-items/:id`, async (req, res) => { try { const { id } = req.params; const { categoryId, name, code, description, parentItemId, sortOrder, isActive } = req.body; const result = await query( `UPDATE expense_items SET category_id = $1, name = $2, code = $3, description = $4, parent_item_id = $5, sort_order = $6, is_active = $7, updated_at = NOW() WHERE id = $8 RETURNING *`, [categoryId, name, code || null, description || null, parentItemId || null, sortOrder || 0, isActive !== undefined ? isActive : true, id] ); if (result.length === 0) { return res.status(404).json({ error: 'Статья не найдена' }); } res.json(result[0]); } catch (err) { console.error('Error updating expense item:', err); res.status(500).json({ error: 'Failed to update expense item' }); } }); // DELETE /api/finance/expense-items/:id - удаление статьи app.delete(`${API_PREFIX}/finance/expense-items/:id`, async (req, res) => { try { const { id } = req.params; // Проверяем, есть ли дочерние статьи const childrenCheck = await query( 'SELECT COUNT(*) as count FROM expense_items WHERE parent_item_id = $1', [id] ); if (parseInt(childrenCheck[0].count) > 0) { return res.status(400).json({ error: 'Невозможно удалить статью: у неё есть дочерние статьи. Сначала удалите или переместите дочерние статьи.' }); } const result = await query( 'DELETE FROM expense_items WHERE id = $1 RETURNING id', [id] ); if (result.length === 0) { return res.status(404).json({ error: 'Статья не найдена' }); } res.json({ success: true, message: 'Статья удалена' }); } catch (err) { console.error('Error deleting expense item:', err); res.status(500).json({ error: 'Failed to delete expense item' }); } }); // GET /api/finance/expense-directory/tree - иерархическая структура справочника app.get(`${API_PREFIX}/finance/expense-directory/tree`, async (req, res) => { try { const categories = await query( `SELECT id, name, code, description, sort_order FROM expense_categories WHERE is_active = TRUE ORDER BY sort_order, name` ); const items = await query( `SELECT id, category_id, name, code, description, parent_item_id, sort_order FROM expense_items WHERE is_active = TRUE ORDER BY sort_order, name` ); // Формируем иерархическую структуру const tree = categories.map(cat => { const categoryItems = items .filter(item => item.category_id === cat.id && !item.parent_item_id) .map(item => { const children = items.filter(child => child.parent_item_id === item.id); return { ...item, children: children.length > 0 ? children : undefined }; }); return { ...cat, items: categoryItems }; }); res.json(tree); } catch (err) { console.error('Error fetching expense directory tree:', err); res.status(500).json({ error: 'Failed to fetch expense directory tree' }); } }); // POST /api/finance/expense-directory/initialize - инициализация справочника из списка app.post(`${API_PREFIX}/finance/expense-directory/initialize`, async (req, res) => { try { // Список категорий и статей из предоставленного изображения const directoryData = [ { name: 'Административно-управленческие расходы', items: [ 'Набор персонала', 'Публикация вакансий', 'Налоги', 'Аренда автомобиля без экипажа', { name: 'Программное обеспечение / IT', children: [ 'Обслуживание операционной системы АСУС МКД, Управдог и...', 'Обслуживание сайта ООО Дружба' ]}, { name: 'Расчетно-кассовое обслуживание в Банке', children: [ 'Комиссия банку' ]}, { name: 'Содержание офиса', children: [ 'Аренда помещения', 'Канц. товар', 'Обслуживание орг.', 'Ремонт и заправка картриджа', 'Содержание офиса. Тревожная кнопка', 'Хоз. средства для...', 'Вода' ]}, { name: 'Транспортные услуги', children: [ 'Доставка' ]}, 'Услуги по управлению', 'Амортизационная', 'Взносы в ФСС от НС и...', 'Инвестиционный...', 'Командировочные расходы АУП', 'Компенсация за использование личного транспорта', 'Обучение (повышение квалификации)', 'Оплата больничного', 'Оплата труда', 'Осмотры инженерного оборудования, конструктивных элементов МКД', 'Страховые взносы', 'Судебные издержки / работы с дебиторской задолженностью / юридическое сопровождение', 'Топливные карты' ] }, { name: 'Услуги по начислению и сбору платы за содержание, выгрузка лиц счетов в ГИС ЖКХ, регистрационный учет', items: [ 'Услуги СММ', { name: 'Услуги связи', children: [ 'Интернет в офисе', 'Сотовая связь', 'Телефон консьерж' ]} ] }, { name: 'Благоустройство', items: [ 'Озеленение', 'Обслуживание клумб/газонов', 'Материалы', 'Обслуживание', { name: 'Ремонт/установка контейнерных площадок/урн', children: [ 'Работа/услуги', 'Вывоз КГМ (крупногабаритного мусора)' ]} ] }, { name: 'Механизированная уборка и вывоз снега', items: [ 'Уборка снега экскаватор погрузчик' ] }, { name: 'Обслуживание инженерного оборудования', items: [ { name: 'Восстановление системы пожаротушения', children: [ 'Материалы', 'Восстановление системы' ]}, 'Расходные материалы' ] }, { name: 'Общестроительные', items: [ 'Материалы общестроительные' ] }, { name: 'Общехозяйственные', items: [ 'Материалы общехозяйственные', { name: 'Обработка наледи на дороге', children: [ 'Материалы Обработка наледи на...' ]} ] }, { name: 'Сантехнические', items: [ 'Материалы' ] }, { name: 'Электротехнические', items: [ 'Замена...', 'Материалы' ] }, { name: 'Судебные издержки / работы с дебиторской', items: [ 'Судебные издержки/работа с...', 'Государственная...', 'Обзвон должников', 'Почтовые расходы' ] }, { name: 'Текущее обслуживание общедомового имущества', items: [ { name: 'Косметический ремонт МОП (места общего пользования)', children: [ 'Косметический ремонт подъездов', 'Покрасочные работы в подъезде', 'Материалы Покрасочные работы...', 'Работа/услуги Покрасочные работы в подъезде' ]}, { name: 'Ремонт / замена / изоляция / сварочные работы трубопроводов', children: [ 'Сварочные работы', 'Сварочные работы на стояке', 'Работа/услуги Сварочные работы на стояке' ]}, { name: 'Ремонт кровли', children: [ 'Материалы Ремонт кровли' ]}, 'Ремонт/замена/установка столбов, заборов, перекладин и досок', { name: 'Изготовление / замена стенда, таблички, наклейки и т.д.', children: [ 'Материалы Изготовление / замена стенда, таблички' ]} ] }, { name: 'Техническое обслуживание конструктивных элементов и инженерных коммуникаций МКД, работы', items: [ 'Работа по заявкам и техническому...', 'Компенсация за раз...', 'Выполнение заявок ремонтные и плотничные работы', 'Выполнение электромонтажных и сантехнических работ и заявок', { name: 'Консьерж', children: [ 'Контроль исполнения заявок и работа с ними в приложении' ]} ] }, { name: 'Обслуживание финансовых таблиц', items: [ 'Проценты по кредитам/займам' ] }, { name: 'Санитарное содержание МОП', items: [ 'Санитарное содержание' ] }, { name: 'Сопровождение 1с / консультационные', items: [] }, { name: 'Убытки', items: [ 'Затопление' ] }, { name: 'Услуги специализированных', items: [ 'Аварийно-диспетчерская служба - ЛайфТелеком', 'Дератизация/дезинфекция в МОП', 'Диспетчерская служба', 'Обслуживание АППЗ и ДУ', 'Обслуживание ЗПУ (домофон)', 'Обслуживание контейнеров тип КМ-2-2', 'Обслуживание лифтов', 'Страхование ответственности (лифты)', 'Уборка подъездов' ] }, { name: 'Хозяйственные расходы', items: [ 'Хоз расходы (спец одежда, инвентарь, инструменты, и т.д.)', { name: 'Инвентарь для уборки', children: [ 'Материалы Инвентарь для уборки' ]}, 'Ремонт инструментов', 'Спец одежда', 'Расходы на приобретение спецодежды СИЗ для слесарей и...', 'Расходы на приобретение спец одежды СИЗ' ] } ]; const client = await pool.connect(); try { await client.query('BEGIN'); let sortOrder = 0; for (const categoryData of directoryData) { // Создаем категорию const categoryResult = await client.query( `INSERT INTO expense_categories (name, sort_order) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET sort_order = EXCLUDED.sort_order RETURNING id`, [categoryData.name, sortOrder++] ); const categoryId = categoryResult.rows[0].id; let itemSortOrder = 0; // Создаем статьи в категории for (const itemData of categoryData.items) { if (typeof itemData === 'string') { // Простая статья await client.query( `INSERT INTO expense_items (category_id, name, sort_order) VALUES ($1, $2, $3) ON CONFLICT (category_id, name) DO UPDATE SET sort_order = EXCLUDED.sort_order`, [categoryId, itemData, itemSortOrder++] ); } else if (itemData.name) { // Статья с дочерними элементами const parentResult = await client.query( `INSERT INTO expense_items (category_id, name, sort_order) VALUES ($1, $2, $3) ON CONFLICT (category_id, name) DO UPDATE SET sort_order = EXCLUDED.sort_order RETURNING id`, [categoryId, itemData.name, itemSortOrder++] ); const parentId = parentResult.rows[0].id; let childSortOrder = 0; // Создаем дочерние статьи if (itemData.children) { for (const childName of itemData.children) { await client.query( `INSERT INTO expense_items (category_id, name, parent_item_id, sort_order) VALUES ($1, $2, $3, $4) ON CONFLICT (category_id, name) DO UPDATE SET sort_order = EXCLUDED.sort_order`, [categoryId, childName, parentId, childSortOrder++] ); } } } } } await client.query('COMMIT'); res.json({ success: true, message: 'Справочник инициализирован' }); } catch (err) { await client.query('ROLLBACK'); throw err; } finally { client.release(); } } catch (err) { console.error('Error initializing expense directory:', err); res.status(500).json({ error: 'Failed to initialize expense directory', details: err.message }); } }); // POST /api/finance/expense-directory/match - сопоставление статей из отчета со справочником app.post(`${API_PREFIX}/finance/expense-directory/match`, async (req, res) => { try { const { expenseName } = req.body; if (!expenseName) { return res.status(400).json({ error: 'expenseName обязателен' }); } // Ищем совпадения в справочнике (точное и частичное) const exactMatch = await query( `SELECT ei.id, ei.name, ei.category_id, ec.name as category_name FROM expense_items ei JOIN expense_categories ec ON ei.category_id = ec.id WHERE LOWER(ei.name) = LOWER($1) AND ei.is_active = TRUE LIMIT 1`, [expenseName] ); if (exactMatch.length > 0) { return res.json({ matched: true, item: exactMatch[0] }); } // Частичное совпадение const partialMatch = await query( `SELECT ei.id, ei.name, ei.category_id, ec.name as category_name FROM expense_items ei JOIN expense_categories ec ON ei.category_id = ec.id WHERE LOWER(ei.name) LIKE LOWER($1) AND ei.is_active = TRUE ORDER BY LENGTH(ei.name) LIMIT 5`, [`%${expenseName}%`] ); res.json({ matched: false, suggestions: partialMatch }); } catch (err) { console.error('Error matching expense item:', err); res.status(500).json({ error: 'Failed to match expense item' }); } }); // POST /api/finance/balance-sheet/:reportId/generate-resident-reports - генерация отчетов для всех домов app.post(`${API_PREFIX}/finance/balance-sheet/:reportId/generate-resident-reports`, async (req, res) => { try { const { reportId } = req.params; // Получаем отчет const reportResult = await query( 'SELECT * FROM financial_reports WHERE id = $1 AND report_type = $2', [reportId, 'balance_sheet'] ); if (reportResult.length === 0) { return res.status(404).json({ error: 'Ведомость не найдена' }); } const report = reportResult[0]; // Получаем все дома с данными из этой ведомости const buildingsData = await query( `SELECT bfd.*, b.id as building_id, b.data->'passport'->>'address' as address FROM building_financial_data bfd JOIN buildings b ON bfd.building_id = b.id WHERE bfd.report_id = $1`, [reportId] ); if (buildingsData.length === 0) { return res.status(404).json({ error: 'Данные по домам не найдены' }); } const createdReports = []; for (const buildingData of buildingsData) { // Проверяем, существует ли уже отчет для этого дома и периода const existingReport = await query( `SELECT id FROM resident_reports WHERE building_id = $1 AND period_start = $2 AND period_end = $3`, [buildingData.building_id, buildingData.period_start, buildingData.period_end] ); if (existingReport.length > 0) { // Обновляем существующий отчет const reportId = existingReport[0].id; await updateResidentReportContent(reportId, buildingData, report); createdReports.push({ buildingId: buildingData.building_id, reportId, updated: true }); } else { // Создаем новый отчет const month = formatMonthFromPeriod(buildingData.period_start, buildingData.period_end); const content = await generateResidentReportContent(buildingData, report); const newReport = await query( `INSERT INTO resident_reports (building_id, month, period_start, period_end, status, content) VALUES ($1, $2, $3, $4, 'draft', $5) RETURNING id`, [ buildingData.building_id, month, buildingData.period_start, buildingData.period_end, JSON.stringify(content) ] ); createdReports.push({ buildingId: buildingData.building_id, reportId: newReport[0].id, updated: false }); } } res.json({ success: true, reportsCreated: createdReports.length, reports: createdReports }); } catch (err) { console.error('Error generating resident reports:', err); res.status(500).json({ error: 'Failed to generate reports', details: err.message }); } }); // POST /api/finance/balance-sheet/:reportId/generate-resident-report/:buildingId - генерация отчета для конкретного дома app.post(`${API_PREFIX}/finance/balance-sheet/:reportId/generate-resident-report/:buildingId`, async (req, res) => { try { const { reportId, buildingId } = req.params; // Получаем данные по дому из ведомости const buildingData = await query( `SELECT bfd.*, b.data->'passport'->>'address' as address FROM building_financial_data bfd JOIN buildings b ON bfd.building_id = b.id WHERE bfd.report_id = $1 AND bfd.building_id = $2`, [reportId, buildingId] ); if (buildingData.length === 0) { return res.status(404).json({ error: 'Данные по дому не найдены' }); } const data = buildingData[0]; const report = await query('SELECT * FROM financial_reports WHERE id = $1', [reportId]); // Проверяем существующий отчет const existingReport = await query( `SELECT id FROM resident_reports WHERE building_id = $1 AND period_start = $2 AND period_end = $3`, [buildingId, data.period_start, data.period_end] ); let reportIdResult; if (existingReport.length > 0) { // Обновляем reportIdResult = existingReport[0].id; await updateResidentReportContent(reportIdResult, data, report[0]); } else { // Создаем новый const month = formatMonthFromPeriod(data.period_start, data.period_end); const content = await generateResidentReportContent(data, report[0]); const newReport = await query( `INSERT INTO resident_reports (building_id, month, period_start, period_end, status, content) VALUES ($1, $2, $3, $4, 'draft', $5) RETURNING id`, [buildingId, month, data.period_start, data.period_end, JSON.stringify(content)] ); reportIdResult = newReport[0].id; } res.json({ success: true, reportId: reportIdResult, buildingId: buildingId }); } catch (err) { console.error('Error generating resident report:', err); res.status(500).json({ error: 'Failed to generate report', details: err.message }); } }); // GET /api/finance/reports/:reportId/buildings/:buildingId/aggregated - агрегированный отчет по конкретному дому app.get(`${API_PREFIX}/finance/reports/:reportId/buildings/:buildingId/aggregated`, async (req, res) => { try { const { reportId, buildingId } = req.params; const { periodStart, periodEnd } = req.query; // Формируем запрос с фильтрацией по периоду, если указан let queryText = ` SELECT bfd.*, b.data->'passport'->>'address' as address, b.data->'passport'->'general'->>'totalArea' as total_area, b.data->'passport'->'general'->>'livingArea' as living_area, b.data->'passport'->'general'->>'nonLivingArea' as non_living_area FROM building_financial_data bfd JOIN buildings b ON bfd.building_id = b.id WHERE bfd.report_id = $1 AND bfd.building_id = $2 `; const params = [reportId, buildingId]; if (periodStart) { queryText += ` AND bfd.period_start >= $${params.length + 1}`; params.push(periodStart); } if (periodEnd) { queryText += ` AND bfd.period_end <= $${params.length + 1}`; params.push(periodEnd); } // Получаем финансовые данные по дому из отчета const financialData = await query(queryText, params); if (financialData.length === 0) { return res.status(404).json({ error: 'Данные по дому не найдены' }); } const data = financialData[0]; // Парсим расходы по статьям const expensesByItems = typeof data.expenses_by_items === 'string' ? JSON.parse(data.expenses_by_items) : (data.expenses_by_items || {}); // Используем выбранный период или период из данных const reportPeriodStart = periodStart || data.period_start; const reportPeriodEnd = periodEnd || data.period_end; res.json({ periodStart: reportPeriodStart, periodEnd: reportPeriodEnd, totalIncome: parseFloat(data.total_income) || 0, totalExpenses: parseFloat(data.total_expenses) || 0, totalBalance: parseFloat(data.balance) || 0, totalArea: parseFloat(data.total_area) || 0, totalLivingArea: parseFloat(data.living_area) || 0, totalNonLivingArea: parseFloat(data.non_living_area) || 0, buildingsCount: 1, aggregatedExpenses: expensesByItems, title: `Отчет по дому: ${data.address}`, subtitle: data.address }); } catch (err) { console.error('Error fetching building aggregated report:', err); res.status(500).json({ error: 'Failed to fetch building aggregated report' }); } }); // GET /api/finance/reports/:reportId/buildings/:buildingId/report - получение отчета по дому из ведомости (генерирует на лету, если нет) app.get(`${API_PREFIX}/finance/reports/:reportId/buildings/:buildingId/report`, async (req, res) => { try { const { reportId, buildingId } = req.params; // Получаем данные по дому из ведомости (по report_id и building_id) let buildingData = await query( `SELECT bfd.*, b.data->'passport'->>'address' as address FROM building_financial_data bfd JOIN buildings b ON bfd.building_id = b.id WHERE bfd.report_id = $1 AND bfd.building_id = $2`, [reportId, buildingId] ); let data = buildingData.length > 0 ? buildingData[0] : null; let dataFromFallback = false; // Если нет строки или данные нулевые (total_expenses=0 и пустые expenses_by_items) — пробуем взять последние ненулевые данные по дому из любой ведомости const hasNoData = !data || ( (parseFloat(data.total_expenses) || 0) === 0 && (!data.expenses_by_items || (typeof data.expenses_by_items === 'object' && Object.keys(data.expenses_by_items).length === 0)) ); if (hasNoData) { const fallback = await query( `SELECT bfd.*, b.data->'passport'->>'address' as address FROM building_financial_data bfd JOIN buildings b ON bfd.building_id = b.id WHERE bfd.building_id = $1 AND (bfd.total_expenses > 0 OR (bfd.expenses_by_items IS NOT NULL AND bfd.expenses_by_items != '{}'::jsonb)) ORDER BY bfd.period_start DESC LIMIT 1`, [buildingId] ); if (fallback.length > 0) { data = fallback[0]; dataFromFallback = true; } } if (!data) { return res.status(404).json({ error: 'Данные по дому не найдены в ведомости' }); } const report = await query('SELECT * FROM financial_reports WHERE id = $1', [reportId]); const reportRow = report.length > 0 ? report[0] : null; if (!reportRow) { return res.status(404).json({ error: 'Ведомость не найдена' }); } // Проверяем существующий отчет (не используем сохранённый контент с нулями — перегенерируем из актуальных данных) const existingReport = await query( `SELECT id, content FROM resident_reports WHERE building_id = $1 AND period_start = $2 AND period_end = $3`, [buildingId, data.period_start, data.period_end] ); let reportContent; let residentReportId = null; const existingHasZeros = existingReport.length > 0 && (() => { const c = existingReport[0].content; const content = typeof c === 'string' ? (() => { try { return JSON.parse(c); } catch (e) { return {}; } })() : c; const total = content?.totals?.totalExpenses ?? content?.stats?.fundsSpent ?? 0; const hasServices = content?.services?.length > 0 && content.services.some(s => (s.accrued || s.paid) > 0); return total === 0 && !hasServices; })(); if (existingReport.length > 0 && !existingHasZeros && !dataFromFallback) { residentReportId = existingReport[0].id; reportContent = existingReport[0].content; if (typeof reportContent === 'string') { try { reportContent = JSON.parse(reportContent); } catch (e) { reportContent = await generateResidentReportContent(data, reportRow); } } } else { reportContent = await generateResidentReportContent(data, reportRow); } const month = formatMonthFromPeriod(data.period_start, data.period_end); res.json({ id: residentReportId, buildingId: buildingId, address: data.address, month: month, periodStart: data.period_start, periodEnd: data.period_end, content: reportContent, dataFromFallback: dataFromFallback || undefined }); } catch (err) { console.error('Error getting building report:', err); res.status(500).json({ error: 'Failed to get report', details: err.message }); } }); // Вспомогательные функции для генерации контента отчета function formatMonthFromPeriod(start, end) { const startDate = new Date(start); const endDate = new Date(end); const months = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь']; if (startDate.getFullYear() === endDate.getFullYear() && startDate.getMonth() === endDate.getMonth()) { return `${months[startDate.getMonth()]} ${startDate.getFullYear()}`; } return `${startDate.getFullYear()} год`; } async function generateResidentReportContent(buildingData, report) { const expensesByItems = buildingData.expenses_by_items || {}; const metadata = buildingData.metadata || {}; // Получаем информацию о доме для параметров const buildingInfo = await query( `SELECT data FROM buildings WHERE id = $1`, [buildingData.building_id] ); const building = buildingInfo.length > 0 ? buildingInfo[0].data : null; const passport = building?.passport || {}; const general = passport.general || {}; // Рассчитываем период в месяцах const startDate = new Date(buildingData.period_start); const endDate = new Date(buildingData.period_end); const monthsDiff = (endDate.getFullYear() - startDate.getFullYear()) * 12 + (endDate.getMonth() - startDate.getMonth()) + 1; // Получаем площади из данных дома const residentialArea = general.livingArea || 0; const nonResidentialArea = general.nonLivingArea || 0; const totalArea = general.totalArea || (residentialArea + nonResidentialArea); // Получаем тариф из настроек дома или используем по умолчанию const tariff = building?.passport?.management?.tariffMaintenance || 25.00; // Получаем процент резервного фонда из настроек дома или используем по умолчанию const reserveFundPercent = building?.passport?.management?.reserveFund || 5.00; // Формируем структуру отчета const content = { parameters: { periodStart: buildingData.period_start, periodEnd: buildingData.period_end, building: buildingData.address, residentialArea: residentialArea, nonResidentialArea: nonResidentialArea, totalArea: totalArea }, tariffs: { tariff: tariff, reserveFund: reserveFundPercent }, services: generateServices(expensesByItems, buildingData, totalArea, monthsDiff, tariff), balance: calculateBalance(buildingData, totalArea, monthsDiff, tariff, expensesByItems), expenseItems: generateExpenseItems(metadata, expensesByItems, buildingData, totalArea, monthsDiff), totals: calculateTotals(buildingData, totalArea, monthsDiff, expensesByItems, metadata), financialResults: calculateFinancialResults(buildingData, totalArea, monthsDiff, tariff, expensesByItems) }; return content; } function generateServices(expensesByItems, buildingData, totalArea, monthsDiff, tariff) { // Группируем расходы по услугам const services = []; let totalMaintenance = buildingData.total_expenses || 0; let totalNonResidential = 0; let totalParking = 0; let concierge = 0; let snowRemoval = 0; let reserveFund = 0; let videoSurveillance = 0; let barrierService = 0; for (const [key, amount] of Object.entries(expensesByItems)) { const keyLower = key.toLowerCase(); if (keyLower.includes('консьерж')) concierge += amount; if (keyLower.includes('механизированная уборка') || keyLower.includes('снег') || keyLower.includes('вывоз снега')) { snowRemoval += amount; } if (keyLower.includes('резервный фонд') || keyLower.includes('резерв')) reserveFund += amount; if (keyLower.includes('нж') || keyLower.includes('нежил')) totalNonResidential += amount; if (keyLower.includes('парковк')) totalParking += amount; if (keyLower.includes('видеонаблюдени')) videoSurveillance += amount; if (keyLower.includes('шлагбаум')) barrierService += amount; } // Рассчитываем начисленное (исходя из тарифа и площади) const accrued = totalArea * tariff * monthsDiff; const paid = accrued * 0.95; // Пример, можно получить из реальных данных // СОДЕРЖАНИЕ ВСЕГО services.push({ name: 'СОДЕРЖАНИЕ ВСЕГО', debt: 0, accrued: accrued, paid: paid, percentOfPlan: Math.round((paid / accrued) * 100 * 100) / 100, order: 1 }); // Содержание (основное) const maintenanceAmount = totalMaintenance - totalNonResidential - totalParking; services.push({ name: 'Содержание', debt: 0, accrued: maintenanceAmount, paid: maintenanceAmount * 0.95, percentOfPlan: 95, order: 2 }); // Содержание НЖ if (totalNonResidential > 0) { services.push({ name: 'Содержание НЖ (в том числе парковка)', debt: 0, accrued: totalNonResidential, paid: totalNonResidential * 0.95, percentOfPlan: 95, order: 3 }); } // Парковка отдельно if (totalParking > 0) { services.push({ name: 'Содержание - парковка', debt: 0, accrued: totalParking, paid: totalParking * 0.95, percentOfPlan: 95, order: 4 }); } // Видеонаблюдение if (videoSurveillance > 0) { services.push({ name: 'Видеонаблюдение', debt: 0, accrued: 0, paid: 0, percentOfPlan: 5, order: 5 }); } // Обслуживание шлагбаума if (barrierService > 0) { services.push({ name: 'Обслуживание шлагбаума', debt: 0, accrued: 0, paid: 0, percentOfPlan: 6, order: 6 }); } // Консьерж if (concierge > 0) { services.push({ name: 'Консьерж', debt: 0, accrued: concierge, paid: concierge * 0.95, percentOfPlan: 95, order: 7 }); } // Механизированная уборка if (snowRemoval > 0) { services.push({ name: 'Механизированная уборка и вывоз снега', debt: 0, accrued: snowRemoval, paid: snowRemoval * 0.95, percentOfPlan: 95, order: 8 }); } // Резервный фонд if (reserveFund > 0) { services.push({ name: 'Резервный фонд', debt: 0, accrued: reserveFund, paid: reserveFund * 0.95, percentOfPlan: 95, order: 9 }); } return services; } function generateExpenseItems(metadata, expensesByItems, buildingData, totalArea, monthsDiff) { const items = []; let itemNumber = 1; if (metadata.groups) { for (const [groupName, group] of Object.entries(metadata.groups)) { const perMonth = group.total / monthsDiff; const perSquareMeter = totalArea > 0 ? perMonth / totalArea : 0; const groupItem = { number: `${itemNumber}.`, name: groupName, perMonth: perMonth, total: group.total, perSquareMeter: perSquareMeter, children: [] }; let subItemNumber = 1; for (const [articleName, article] of Object.entries(group.articles)) { const articlePerMonth = article.amount / monthsDiff; const articlePerSquareMeter = totalArea > 0 ? articlePerMonth / totalArea : 0; const articleItem = { number: `${itemNumber}.${subItemNumber}.`, name: articleName, perMonth: articlePerMonth, total: article.amount, perSquareMeter: articlePerSquareMeter, children: [] }; if (article.details && article.details.length > 0) { let detailNumber = 1; for (const detail of article.details) { const detailPerMonth = detail.amount / monthsDiff; const detailPerSquareMeter = totalArea > 0 ? detailPerMonth / totalArea : 0; articleItem.children.push({ number: `${itemNumber}.${subItemNumber}.${detailNumber}.`, name: detail.description, perMonth: detailPerMonth, total: detail.amount, perSquareMeter: detailPerSquareMeter }); detailNumber++; } } groupItem.children.push(articleItem); subItemNumber++; } items.push(groupItem); itemNumber++; } } else { // Если нет групп в metadata, формируем из expensesByItems for (const [key, amount] of Object.entries(expensesByItems)) { const parts = key.split(' > '); if (parts.length >= 2) { const groupName = parts[0]; const articleName = parts.slice(1).join(' > '); // Ищем существующую группу let groupItem = items.find(item => item.name === groupName); if (!groupItem) { groupItem = { number: `${itemNumber}.`, name: groupName, perMonth: 0, total: 0, perSquareMeter: 0, children: [] }; items.push(groupItem); itemNumber++; } const perMonth = amount / monthsDiff; const perSquareMeter = totalArea > 0 ? perMonth / totalArea : 0; groupItem.children.push({ number: `${groupItem.number}${groupItem.children.length + 1}.`, name: articleName, perMonth: perMonth, total: amount, perSquareMeter: perSquareMeter, children: [] }); groupItem.total += amount; groupItem.perMonth = groupItem.total / monthsDiff; groupItem.perSquareMeter = totalArea > 0 ? groupItem.perMonth / totalArea : 0; } } } return items; } function calculateBalance(buildingData, totalArea, monthsDiff, tariff, expensesByItems) { // Рассчитываем начисленное const accruedTotal = totalArea * tariff * monthsDiff; // Рассчитываем поступившие (примерно 95% от начисленного) const receivedTotal = accruedTotal * 0.95; // Расходы на содержание const maintenanceExpenses = buildingData.total_expenses || 0; // Сальдо исходя из начисленных const fromAccrued = accruedTotal - maintenanceExpenses; // Сальдо исходя из поступивших средств const fromReceived = receivedTotal - maintenanceExpenses; // Резервный фонд - начислено const reserveFundAccrued = totalArea * (tariff * 0.05) * monthsDiff; // Резервный фонд - поступило const reserveFundReceived = reserveFundAccrued * 0.95; // Расходы резервного фонда let reserveFundExpenses = 0; for (const [key, amount] of Object.entries(expensesByItems)) { if (key.toLowerCase().includes('резервный фонд') || key.toLowerCase().includes('резерв')) { reserveFundExpenses += amount; } } // Сальдо резервного фонда const reserveFundFromAccrued = reserveFundAccrued - reserveFundExpenses; const reserveFundFromReceived = reserveFundReceived - reserveFundExpenses; return { fromAccrued: Math.round(fromAccrued * 100) / 100, fromReceived: Math.round(fromReceived * 100) / 100, reserveFundFromAccrued: Math.round(reserveFundFromAccrued * 100) / 100, reserveFundFromReceived: Math.round(reserveFundFromReceived * 100) / 100 }; } function calculateTotals(buildingData, totalArea, monthsDiff, expensesByItems, metadata) { const totalExpenses = buildingData.total_expenses || 0; // Находим перерасчет (механизированная уборка + часть резервного фонда) let recalculation = 0; let snowRemovalAmount = 0; let reserveFundAmount = 0; // Ищем механизированную уборку for (const [key, amount] of Object.entries(expensesByItems)) { const keyLower = key.toLowerCase(); if (keyLower.includes('механизированная уборка') || keyLower.includes('вывоз снега') || keyLower.includes('уборка снега')) { snowRemovalAmount += amount; } if (keyLower.includes('резервный фонд') || keyLower.includes('резерв')) { reserveFundAmount += amount; } } // Перерасчет = механизированная уборка + часть резервного фонда (если есть) recalculation = snowRemovalAmount; // Часть резервного фонда может быть в перерасчете (примерно 10-20%) if (reserveFundAmount > 0 && snowRemovalAmount > 0) { recalculation += reserveFundAmount * 0.15; // Примерная доля } // НДС рассчитывается от суммы расходов (10%) const baseForVAT = totalExpenses; const vat = Math.round(baseForVAT * 0.1 * 100) / 100; // 10% НДС // Итого с перерасчетом без НДС const totalWithRecalculation = totalExpenses - recalculation; // Итого с перерасчетом с НДС const totalWithRecalculationWithVAT = totalWithRecalculation + vat; // Итого тариф (то же что и итого с перерасчетом с НДС) const totalTariff = totalWithRecalculationWithVAT; // Прочие доходы (можно получить из buildingData или рассчитать) const otherIncome = 0; // TODO: получить из реальных данных return { totalExpenses: Math.round(totalExpenses * 100) / 100, vat: Math.round(vat * 100) / 100, recalculation: Math.round(recalculation * 100) / 100, totalWithRecalculation: Math.round(totalWithRecalculation * 100) / 100, totalWithRecalculationWithVAT: Math.round(totalWithRecalculationWithVAT * 100) / 100, debtReturn: 0, totalTariff: Math.round(totalTariff * 100) / 100, otherIncome: Math.round(otherIncome * 100) / 100 }; } function calculateFinancialResults(buildingData, totalArea, monthsDiff, tariff, expensesByItems) { // Рассчитываем начисленное const accruedTotal = totalArea * tariff * monthsDiff; // Рассчитываем поступившие (примерно 95% от начисленного) const receivedTotal = accruedTotal * 0.95; // Расходы на содержание (без резервного фонда) let maintenanceExpenses = buildingData.total_expenses || 0; let reserveFundExpenses = 0; for (const [key, amount] of Object.entries(expensesByItems)) { const keyLower = key.toLowerCase(); if (keyLower.includes('резервный фонд') || keyLower.includes('резерв')) { reserveFundExpenses += amount; maintenanceExpenses -= amount; } } // Финансовый результат по содержанию // Исходя из начисленных const maintenanceFromAccrued = accruedTotal - maintenanceExpenses; // Исходя из поступивших const maintenanceFromReceived = receivedTotal - maintenanceExpenses; // Финансовый результат по резервному фонду // Начислено на резервный фонд const reserveFundAccrued = totalArea * (tariff * 0.05) * monthsDiff; // 5% от тарифа // Поступило на резервный фонд const reserveFundReceived = reserveFundAccrued * 0.95; // Исходя из начисленных const reserveFundFromAccrued = reserveFundAccrued - reserveFundExpenses; // Исходя из поступивших const reserveFundFromReceived = reserveFundReceived - reserveFundExpenses; return { maintenanceFromAccrued: Math.round(maintenanceFromAccrued * 100) / 100, maintenanceFromReceived: Math.round(maintenanceFromReceived * 100) / 100, reserveFundFromAccrued: Math.round(reserveFundFromAccrued * 100) / 100, reserveFundFromReceived: Math.round(reserveFundFromReceived * 100) / 100 }; } async function updateResidentReportContent(reportId, buildingData, report) { const content = await generateResidentReportContent(buildingData, report); await query( `UPDATE resident_reports SET content = $1, updated_at = NOW() WHERE id = $2`, [JSON.stringify(content), reportId] ); } // ========= ОФИС: ENDPOINTS ========= // GET /api/office/dashboard — полный дашборд офиса для продвинутой статистики и карточки сводки app.get(`${API_PREFIX}/office/dashboard`, async (req, res) => { const now = new Date(); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); const monthStartStr = monthStart.toISOString().slice(0, 10); const weekStart = new Date(now); weekStart.setDate(weekStart.getDate() - weekStart.getDay()); weekStart.setHours(0, 0, 0, 0); const weekEnd = new Date(weekStart); weekEnd.setDate(weekEnd.getDate() + 7); const warrantyLimit = new Date(now); warrantyLimit.setDate(warrantyLimit.getDate() + 30); const defaultData = { companyNews: { total: 0, published: 0, draft: 0, pending: 0, publishedThisMonth: 0 }, equipment: { total: 0, byCondition: { good: 0, fair: 0, poor: 0 }, warrantyExpiringSoon: 0 }, repairRequests: { total: 0, open: 0, in_progress: 0, completed: 0 }, supplyRequests: { total: 0, byStatus: {} }, inventory: { totalItems: 0, lowStockCount: 0 }, documents: { total: 0, incoming: 0, outgoing: 0, byStatus: {} }, orders: { total: 0, byStatus: {} }, meetings: { thisWeek: 0, upcoming: 0 }, meetingRooms: { total: 0 }, knowledgeBase: { categoriesCount: 0, articlesCount: 0 } }; try { // 1. Новости компании let companyNews = defaultData.companyNews; try { const newsRes = await query(` SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE status = 'published') as published, COUNT(*) FILTER (WHERE status = 'draft') as draft, COUNT(*) FILTER (WHERE status = 'pending') as pending, COUNT(*) FILTER (WHERE status = 'published' AND published_at >= $1) as published_this_month FROM company_news `, [monthStart]); if (newsRes.length > 0) { const r = newsRes[0]; companyNews = { total: parseInt(r.total) || 0, published: parseInt(r.published) || 0, draft: parseInt(r.draft) || 0, pending: parseInt(r.pending) || 0, publishedThisMonth: parseInt(r.published_this_month) || 0 }; } } catch (e) { if (e.code !== '42P01') throw e; } // 2. Оборудование let equipment = defaultData.equipment; try { const eqRes = await query(` SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE condition = 'good') as good, COUNT(*) FILTER (WHERE condition = 'fair') as fair, COUNT(*) FILTER (WHERE condition = 'poor') as poor, COUNT(*) FILTER (WHERE warranty_until IS NOT NULL AND warranty_until >= $1 AND warranty_until <= $2) as warranty_expiring_soon FROM office_equipment `, [now.toISOString().slice(0, 10), warrantyLimit.toISOString().slice(0, 10)]); if (eqRes.length > 0) { const r = eqRes[0]; equipment = { total: parseInt(r.total) || 0, byCondition: { good: parseInt(r.good) || 0, fair: parseInt(r.fair) || 0, poor: parseInt(r.poor) || 0 }, warrantyExpiringSoon: parseInt(r.warranty_expiring_soon) || 0 }; } } catch (e) { if (e.code !== '42P01') throw e; } // 3. Заявки на ремонт let repairRequests = defaultData.repairRequests; try { const repRes = await query(` SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE status = 'completed') as completed FROM office_repair_requests `); if (repRes.length > 0) { const r = repRes[0]; const total = parseInt(r.total) || 0; const completed = parseInt(r.completed) || 0; repairRequests = { total, open: total - completed, in_progress: total - completed, completed }; } } catch (e) { if (e.code !== '42P01') throw e; } // 4. Заявки на закупку (по статусам) let supplyRequests = defaultData.supplyRequests; try { const supCount = await query('SELECT COUNT(*) as total FROM office_supply_requests'); const supByStatus = await query(` SELECT status, COUNT(*) as cnt FROM office_supply_requests GROUP BY status `); const byStatus = {}; (supByStatus || []).forEach(row => { byStatus[row.status || 'other'] = parseInt(row.cnt) || 0; }); supplyRequests = { total: parseInt(supCount[0]?.total) || 0, byStatus }; } catch (e) { if (e.code !== '42P01') throw e; } // 5. Инвентарь let inventory = defaultData.inventory; try { const invRes = await query(` SELECT COUNT(*) as total_items, COUNT(*) FILTER (WHERE (min_threshold IS NOT NULL AND quantity <= min_threshold) OR (min_threshold IS NULL AND quantity <= 0)) as low_stock FROM office_inventory `); if (invRes.length > 0) { const r = invRes[0]; inventory = { totalItems: parseInt(r.total_items) || 0, lowStockCount: parseInt(r.low_stock) || 0 }; } } catch (e) { if (e.code !== '42P01') throw e; } // 6. Документооборот let documents = defaultData.documents; try { const docRes = await query(` SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE document_type = 'incoming' OR letter_type = 'incoming') as incoming, COUNT(*) FILTER (WHERE document_type = 'outgoing' OR letter_type = 'outgoing') as outgoing FROM office_documents `); const docByStatus = await query(`SELECT status, COUNT(*) as cnt FROM office_documents GROUP BY status`); const docStatusMap = {}; (docByStatus || []).forEach(row => { docStatusMap[row.status || 'other'] = parseInt(row.cnt) || 0; }); if (docRes.length > 0) { const r = docRes[0]; documents = { total: parseInt(r.total) || 0, incoming: parseInt(r.incoming) || 0, outgoing: parseInt(r.outgoing) || 0, byStatus: docStatusMap }; } } catch (e) { if (e.code !== '42P01') throw e; } // 7. Заказы let orders = defaultData.orders; try { const ordCount = await query('SELECT COUNT(*) as total FROM office_orders'); const ordByStatus = await query(`SELECT status, COUNT(*) as cnt FROM office_orders GROUP BY status`); const ordStatusMap = {}; (ordByStatus || []).forEach(row => { ordStatusMap[row.status || 'other'] = parseInt(row.cnt) || 0; }); orders = { total: parseInt(ordCount[0]?.total) || 0, byStatus: ordStatusMap }; } catch (e) { if (e.code !== '42P01') throw e; } // 8. Совещания (текущая неделя, предстоящие) let meetings = defaultData.meetings; try { const weekStartStr = weekStart.toISOString().slice(0, 10); const weekEndStr = weekEnd.toISOString().slice(0, 10); const nowStr = now.toISOString(); const meetRes = await query(` SELECT COUNT(*) FILTER (WHERE start_time >= $1 AND start_time < $2) as this_week, COUNT(*) FILTER (WHERE start_time > $3) as upcoming FROM meetings `, [weekStartStr, weekEndStr, nowStr]); if (meetRes.length > 0) { const r = meetRes[0]; meetings = { thisWeek: parseInt(r.this_week) || 0, upcoming: parseInt(r.upcoming) || 0 }; } } catch (e) { if (e.code !== '42P01') throw e; } // 9. Переговорные let meetingRooms = defaultData.meetingRooms; try { const mrRes = await query('SELECT COUNT(*) as total FROM meeting_rooms'); if (mrRes.length > 0) meetingRooms = { total: parseInt(mrRes[0].total) || 0 }; } catch (e) { if (e.code !== '42P01') throw e; } // 10. База знаний let knowledgeBase = defaultData.knowledgeBase; try { const kbCat = await query('SELECT COUNT(*) as c FROM knowledge_base_categories'); const kbArt = await query('SELECT COUNT(*) as c FROM knowledge_base_articles'); knowledgeBase = { categoriesCount: kbCat.length > 0 ? parseInt(kbCat[0].c) || 0 : 0, articlesCount: kbArt.length > 0 ? parseInt(kbArt[0].c) || 0 : 0 }; } catch (e) { if (e.code !== '42P01') throw e; } res.json({ success: true, data: { companyNews, equipment, repairRequests, supplyRequests, inventory, documents, orders, meetings, meetingRooms, knowledgeBase } }); } catch (err) { console.error('[office/dashboard] Ошибка:', err); res.status(500).json({ success: false, error: err.message || 'Failed to get office dashboard' }); } }); // Ремонтные заявки app.get(`${API_PREFIX}/office/repair-requests`, async (req, res) => { try { const { status, equipmentId, assignedTo } = req.query; let queryText = ` SELECT r.*, e.name as equipment_name, e.type as equipment_type FROM office_repair_requests r LEFT JOIN office_equipment e ON r.equipment_id = e.id WHERE 1=1 `; const params = []; if (status) { queryText += ` AND r.status = $${params.length + 1}`; params.push(status); } if (equipmentId) { queryText += ` AND r.equipment_id = $${params.length + 1}`; params.push(equipmentId); } if (assignedTo) { queryText += ` AND r.assigned_to = $${params.length + 1}`; params.push(assignedTo); } queryText += ' ORDER BY r.created_at DESC'; const rows = await query(queryText, params); const normalizedRows = rows.map((row) => ({ ...row, is_paid: row.is_paid || false, cost: row.cost || 0, cost_estimated: row.cost_estimated || false, invoice_id: row.invoice_id || null, invoice_url: row.invoice_url || null, expected_return_date: row.expected_return_date || null, waiting_delivery_deadline: row.waiting_delivery_deadline || null, waiting_delivery_contacts: row.waiting_delivery_contacts || null, taken_for_repair_deadline: row.taken_for_repair_deadline || null, taken_for_repair_contacts: row.taken_for_repair_contacts || null, agreed_contractor_price: row.agreed_contractor_price != null ? row.agreed_contractor_price : null })); res.json(normalizedRows); } catch (err) { console.error('Error fetching repair requests:', err); res.status(500).json({ error: 'Failed to fetch repair requests' }); } }); app.post(`${API_PREFIX}/office/repair-requests`, async (req, res) => { try { const { equipmentId, requesterName, description, priority, expectedReturnDate } = req.body; if (!equipmentId || !requesterName || !description) { return res.status(400).json({ error: 'equipmentId, requesterName и description обязательны' }); } const result = await query( `INSERT INTO office_repair_requests (equipment_id, requester_name, description, priority, status, expected_return_date) VALUES ($1, $2, $3, $4, 'new', $5) RETURNING *`, [equipmentId, requesterName, description, priority || 'medium', expectedReturnDate || null] ); // Статус имущества — неисправно, в истории — на ремонте с причиной const today = new Date().toISOString().slice(0, 10); try { await query( `UPDATE office_equipment SET condition = 'poor', updated_at = NOW() WHERE id = $1`, [equipmentId] ); await query( `INSERT INTO office_equipment_history (equipment_id, event_type, event_date, reason) VALUES ($1, 'repair', $2, $3)`, [equipmentId, today, description] ); } catch (histErr) { console.warn('Equipment condition/history update on repair request:', histErr.message); } res.status(201).json(result[0]); } catch (err) { console.error('Error creating repair request:', err); res.status(500).json({ error: 'Failed to create repair request' }); } }); app.put(`${API_PREFIX}/office/repair-requests/:id`, async (req, res) => { try { const { id } = req.params; const updates = req.body; // Поддерживаем как camelCase, так и snake_case const isPaid = updates.isPaid !== undefined ? updates.isPaid : updates.is_paid; const cost = updates.cost !== undefined ? updates.cost : null; const costEstimated = updates.costEstimated !== undefined ? updates.costEstimated : updates.cost_estimated; const invoiceId = updates.invoiceId !== undefined ? updates.invoiceId : updates.invoice_id; const invoiceUrl = updates.invoiceUrl !== undefined ? updates.invoiceUrl : updates.invoice_url; const expectedReturnDate = updates.expectedReturnDate !== undefined ? updates.expectedReturnDate : updates.expected_return_date; const allowedFields = ['status', 'assigned_to', 'assignedTo', 'solution', 'cancel_reason', 'cancelReason', 'comments']; const updateFields = []; const values = []; let paramIndex = 1; for (const field of allowedFields) { const dbField = field === 'assignedTo' ? 'assigned_to' : field === 'cancelReason' ? 'cancel_reason' : field; if (updates[field] !== undefined) { updateFields.push(`${dbField} = $${paramIndex}`); values.push(updates[field]); paramIndex++; } } if (expectedReturnDate !== undefined) { updateFields.push(`expected_return_date = $${paramIndex}`); values.push(expectedReturnDate || null); paramIndex++; } const waitingDeliveryDeadline = updates.waitingDeliveryDeadline !== undefined ? updates.waitingDeliveryDeadline : updates.waiting_delivery_deadline; const waitingDeliveryContacts = updates.waitingDeliveryContacts !== undefined ? updates.waitingDeliveryContacts : updates.waiting_delivery_contacts; const takenForRepairDeadline = updates.takenForRepairDeadline !== undefined ? updates.takenForRepairDeadline : updates.taken_for_repair_deadline; const takenForRepairContacts = updates.takenForRepairContacts !== undefined ? updates.takenForRepairContacts : updates.taken_for_repair_contacts; const agreedContractorPrice = updates.agreedContractorPrice !== undefined ? updates.agreedContractorPrice : updates.agreed_contractor_price; if (waitingDeliveryDeadline !== undefined) { updateFields.push(`waiting_delivery_deadline = $${paramIndex}`); values.push(waitingDeliveryDeadline || null); paramIndex++; } if (waitingDeliveryContacts !== undefined) { updateFields.push(`waiting_delivery_contacts = $${paramIndex}`); values.push(waitingDeliveryContacts || null); paramIndex++; } if (takenForRepairDeadline !== undefined) { updateFields.push(`taken_for_repair_deadline = $${paramIndex}`); values.push(takenForRepairDeadline || null); paramIndex++; } if (takenForRepairContacts !== undefined) { updateFields.push(`taken_for_repair_contacts = $${paramIndex}`); values.push(takenForRepairContacts || null); paramIndex++; } if (agreedContractorPrice !== undefined) { updateFields.push(`agreed_contractor_price = $${paramIndex}`); values.push(agreedContractorPrice != null && agreedContractorPrice !== '' ? parseFloat(agreedContractorPrice) : null); paramIndex++; } // Поля для платного ремонта if (isPaid !== undefined) { updateFields.push(`is_paid = $${paramIndex}`); values.push(isPaid); paramIndex++; } if (cost !== undefined && cost !== null) { updateFields.push(`cost = $${paramIndex}`); values.push(parseFloat(cost) || 0); paramIndex++; } if (costEstimated !== undefined) { updateFields.push(`cost_estimated = $${paramIndex}`); values.push(costEstimated); paramIndex++; } if (invoiceId !== undefined && invoiceId !== null) { updateFields.push(`invoice_id = $${paramIndex}`); values.push(invoiceId); paramIndex++; } if (invoiceUrl !== undefined && invoiceUrl !== null) { updateFields.push(`invoice_url = $${paramIndex}`); values.push(invoiceUrl); paramIndex++; } if (updates.status === 'in_progress' && !updates.started_at) { updateFields.push(`started_at = NOW()`); } if (updates.status === 'completed' && !updates.completed_at) { updateFields.push(`completed_at = NOW()`); } if (updates.status === 'canceled' && !updates.canceled_at) { updateFields.push(`canceled_at = NOW()`); } if (updateFields.length === 0) { return res.status(400).json({ error: 'Нет полей для обновления' }); } values.push(id); const result = await query( `UPDATE office_repair_requests SET ${updateFields.join(', ')} WHERE id = $${paramIndex} RETURNING *`, values ); if (result.length === 0) { return res.status(404).json({ error: 'Repair request not found' }); } const row = result[0]; try { const assignedTo = row.assigned_to || updates.assigned_to || updates.assignedTo; const opts = { type: 'repair_request_update', title: `Заявка на ремонт №${id}`, body: row.status ? `Статус: ${row.status}` : 'Обновление заявки', entityType: 'repair_request', entityId: String(id) }; if (assignedTo) { const userIds = await notificationService.resolveEmployeeNamesToUserIds(pool, [assignedTo]); if (userIds.length > 0) { await notificationService.createNotificationForUserIds(pool, userIds, opts); } else { await notificationService.createNotificationForResponsibleZone(pool, 'office', 'repair', opts); } } else { await notificationService.createNotificationForResponsibleZone(pool, 'office', 'repair', opts); } } catch (notifErr) { console.warn('[notifications] repair request PUT:', notifErr.message); } res.json({ ...row, waiting_delivery_deadline: row.waiting_delivery_deadline || null, waiting_delivery_contacts: row.waiting_delivery_contacts || null, taken_for_repair_deadline: row.taken_for_repair_deadline || null, taken_for_repair_contacts: row.taken_for_repair_contacts || null, agreed_contractor_price: row.agreed_contractor_price != null ? row.agreed_contractor_price : null }); } catch (err) { console.error('Error updating repair request:', err); const isEnumError = err.code === '22P02' || (err.message && /invalid input value for enum|enum.*does not exist/i.test(err.message)); const message = isEnumError ? 'Недопустимый статус. Выполните миграции: backend/migrations/add_repair_request_statuses.sql' : (err.message || 'Failed to update repair request'); res.status(isEnumError ? 400 : 500).json({ error: message }); } }); // Оборудование app.get(`${API_PREFIX}/office/equipment`, async (req, res) => { try { const { type, assignedTo } = req.query; let queryText = 'SELECT * FROM office_equipment WHERE 1=1'; const params = []; if (type) { queryText += ` AND type = $${params.length + 1}`; params.push(type); } if (assignedTo) { queryText += ` AND assigned_to = $${params.length + 1}`; params.push(assignedTo); } queryText += ' ORDER BY name'; const rows = await query(queryText, params); res.json(rows); } catch (err) { console.error('Error fetching equipment:', err); res.status(500).json({ error: 'Failed to fetch equipment' }); } }); app.post(`${API_PREFIX}/office/equipment`, async (req, res) => { try { const { name, type, brand, model, serialNumber, assignedTo, purchaseDate, warrantyUntil, condition, notes } = req.body; if (!name || !type) { return res.status(400).json({ error: 'name и type обязательны' }); } const result = await query( `INSERT INTO office_equipment (name, type, brand, model, serial_number, assigned_to, purchase_date, warranty_until, condition, notes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`, [name, type, brand || null, model || null, serialNumber || null, assignedTo || null, purchaseDate || null, warrantyUntil || null, condition || 'good', notes || null] ); const equipmentId = result[0].id; const today = new Date().toISOString().slice(0, 10); try { if (purchaseDate) { await query( `INSERT INTO office_equipment_history (equipment_id, event_type, event_date) VALUES ($1, 'purchase', $2)`, [equipmentId, purchaseDate] ); } if (assignedTo) { await query( `INSERT INTO office_equipment_history (equipment_id, event_type, event_date, assigned_to) VALUES ($1, 'issue', $2, $3)`, [equipmentId, today, assignedTo] ); } } catch (histErr) { console.error('Error writing equipment history on create:', histErr); } res.status(201).json(result[0]); } catch (err) { console.error('Error creating equipment:', err); res.status(500).json({ error: 'Failed to create equipment' }); } }); app.put(`${API_PREFIX}/office/equipment/:id`, async (req, res) => { try { const { id } = req.params; const { name, type, brand, model, serialNumber, assignedTo, purchaseDate, warrantyUntil, nextMaintenanceDate, condition, notes } = req.body; const updates = []; const values = []; let paramIndex = 1; if (name !== undefined) { updates.push(`name = $${paramIndex++}`); values.push(name); } if (type !== undefined) { updates.push(`type = $${paramIndex++}`); values.push(type); } if (brand !== undefined) { updates.push(`brand = $${paramIndex++}`); values.push(brand); } if (model !== undefined) { updates.push(`model = $${paramIndex++}`); values.push(model); } if (serialNumber !== undefined) { updates.push(`serial_number = $${paramIndex++}`); values.push(serialNumber); } if (assignedTo !== undefined) { updates.push(`assigned_to = $${paramIndex++}`); values.push(assignedTo); } if (purchaseDate !== undefined) { updates.push(`purchase_date = $${paramIndex++}`); values.push(purchaseDate); } if (warrantyUntil !== undefined) { updates.push(`warranty_until = $${paramIndex++}`); values.push(warrantyUntil); } if (nextMaintenanceDate !== undefined) { updates.push(`next_maintenance_date = $${paramIndex++}`); values.push(nextMaintenanceDate); } if (condition !== undefined) { updates.push(`condition = $${paramIndex++}`); values.push(condition); } if (notes !== undefined) { updates.push(`notes = $${paramIndex++}`); values.push(notes); } if (updates.length === 0) { return res.status(400).json({ error: 'Нет полей для обновления' }); } updates.push(`updated_at = NOW()`); values.push(id); const result = await query( `UPDATE office_equipment SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`, values ); if (result.length === 0) { return res.status(404).json({ error: 'Оборудование не найдено' }); } res.json(result[0]); } catch (err) { console.error('Error updating equipment:', err); res.status(500).json({ error: 'Failed to update equipment' }); } }); // История оборудования: закупка, выдача, перемещения, ремонты, списание app.get(`${API_PREFIX}/office/equipment/:id/history`, async (req, res) => { try { const { id } = req.params; const histRows = await query( `SELECT id, equipment_id, event_type, event_date, assigned_to, assigned_from, reason, created_at FROM office_equipment_history WHERE equipment_id = $1 ORDER BY event_date DESC, created_at DESC`, [id] ); const repairs = await query( `SELECT id, equipment_id, description, solution, completed_at, created_at FROM office_repair_requests WHERE equipment_id = $1 AND status = 'completed' AND completed_at IS NOT NULL ORDER BY completed_at DESC`, [id] ); const repairEvents = repairs.map((r) => ({ id: `repair-${r.id}`, equipment_id: parseInt(id, 10), event_type: 'repair', event_date: r.completed_at ? r.completed_at.slice(0, 10) : r.created_at.slice(0, 10), reason: (r.solution || r.description) || '', created_at: r.completed_at || r.created_at })); const combined = [...histRows.map((h) => ({ ...h, equipment_id: parseInt(h.equipment_id, 10) })), ...repairEvents].sort( (a, b) => new Date(b.event_date).getTime() - new Date(a.event_date).getTime() ); res.json(combined); } catch (err) { console.error('Error fetching equipment history:', err); res.status(500).json({ error: 'Failed to fetch equipment history' }); } }); // Перемещение: сменить ответственного и записать в историю app.post(`${API_PREFIX}/office/equipment/:id/transfer`, async (req, res) => { try { const { id } = req.params; const assignedTo = req.body.assignedTo !== undefined ? req.body.assignedTo : req.body.assigned_to; const current = await query('SELECT id, assigned_to FROM office_equipment WHERE id = $1', [id]); if (!current.length) { return res.status(404).json({ error: 'Оборудование не найдено' }); } const fromName = current[0].assigned_to || null; const toName = assignedTo !== undefined && assignedTo !== null && String(assignedTo).trim() !== '' ? String(assignedTo).trim() : null; const today = new Date().toISOString().slice(0, 10); await query( `INSERT INTO office_equipment_history (equipment_id, event_type, event_date, assigned_from, assigned_to) VALUES ($1, 'transfer', $2, $3, $4)`, [id, today, fromName, toName] ); await query( `UPDATE office_equipment SET assigned_to = $1, updated_at = NOW() WHERE id = $2`, [toName, id] ); const updated = await query('SELECT * FROM office_equipment WHERE id = $1', [id]); res.json(updated[0]); } catch (err) { console.error('Error transferring equipment:', err); res.status(500).json({ error: err.message || 'Failed to transfer equipment' }); } }); // Добавить событие в историю (списание, ручной ремонт и т.д.) app.post(`${API_PREFIX}/office/equipment/:id/history`, async (req, res) => { try { const { id } = req.params; const { event_type, event_date, assigned_to, reason } = req.body; if (!event_type || !event_date) { return res.status(400).json({ error: 'event_type и event_date обязательны' }); } const allowed = ['purchase', 'issue', 'transfer', 'repair', 'write_off']; if (!allowed.includes(event_type)) { return res.status(400).json({ error: 'Недопустимый event_type' }); } await query( `INSERT INTO office_equipment_history (equipment_id, event_type, event_date, assigned_to, reason) VALUES ($1, $2, $3, $4, $5)`, [id, event_type, event_date, assigned_to || null, reason || null] ); const rows = await query( 'SELECT * FROM office_equipment_history WHERE equipment_id = $1 ORDER BY created_at DESC LIMIT 1', [id] ); res.status(201).json(rows[0]); } catch (err) { console.error('Error adding equipment history:', err); res.status(500).json({ error: 'Failed to add equipment history' }); } }); // База знаний - категории app.get(`${API_PREFIX}/office/knowledge-base/categories`, async (req, res) => { try { const rows = await query( 'SELECT * FROM knowledge_base_categories ORDER BY sort_order, name' ); res.json(rows); } catch (err) { console.error('Error fetching categories:', err); res.status(500).json({ error: 'Failed to fetch categories' }); } }); app.post(`${API_PREFIX}/office/knowledge-base/categories`, async (req, res) => { try { const { name, description, parentId, sortOrder } = req.body; if (!name || !name.trim()) { return res.status(400).json({ error: 'name обязательно' }); } const slug = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); const result = await query( `INSERT INTO knowledge_base_categories (name, slug, description, parent_id, sort_order) VALUES ($1, $2, $3, $4, $5) RETURNING *`, [name.trim(), slug, description || null, parentId || null, sortOrder || 0] ); res.status(201).json(result[0]); } catch (err) { console.error('Error creating category:', err); if (err.code === '23505') { // Unique violation return res.status(400).json({ error: 'Категория с таким названием уже существует' }); } res.status(500).json({ error: 'Failed to create category' }); } }); // База знаний - статьи app.get(`${API_PREFIX}/office/knowledge-base/articles`, async (req, res) => { try { const { categoryId, search, isPublished } = req.query; let queryText = ` SELECT a.*, c.name as category_name FROM knowledge_base_articles a LEFT JOIN knowledge_base_categories c ON a.category_id = c.id WHERE 1=1 `; const params = []; if (categoryId) { queryText += ` AND a.category_id = $${params.length + 1}`; params.push(categoryId); } if (search) { queryText += ` AND (a.title ILIKE $${params.length + 1} OR a.content ILIKE $${params.length + 1})`; const searchTerm = `%${search}%`; params.push(searchTerm, searchTerm); } if (isPublished !== undefined) { queryText += ` AND a.is_published = $${params.length + 1}`; params.push(isPublished === 'true'); } queryText += ' ORDER BY a.created_at DESC'; const rows = await query(queryText, params); res.json(rows); } catch (err) { console.error('Error fetching articles:', err); res.status(500).json({ error: 'Failed to fetch articles' }); } }); app.post(`${API_PREFIX}/office/knowledge-base/articles`, async (req, res) => { try { const { title, slug, categoryId, content, contentType, author, tags, attachments } = req.body; if (!title || !content || !author) { return res.status(400).json({ error: 'title, content и author обязательны' }); } // Преобразуем массивы для PostgreSQL const tagsArray = Array.isArray(tags) ? tags : (tags ? [tags] : []); const attachmentsArray = Array.isArray(attachments) ? attachments : (attachments ? [attachments] : []); const result = await query( `INSERT INTO knowledge_base_articles (title, slug, category_id, content, content_type, author, tags, attachments, is_published) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`, [ title, slug || title.toLowerCase().replace(/\s+/g, '-'), categoryId || null, content, contentType || 'markdown', author, tagsArray.length > 0 ? tagsArray : null, attachmentsArray.length > 0 ? attachmentsArray : null, true ] ); res.status(201).json(result[0]); } catch (err) { console.error('Error creating article:', err); res.status(500).json({ error: 'Failed to create article' }); } }); // POST /api/office/knowledge-base/upload - загрузка файла для базы знаний app.post(`${API_PREFIX}/office/knowledge-base/upload`, uploadKnowledgeBase.single('file'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: 'Файл не загружен' }); } const fileUrl = `/uploads/knowledge-base/${req.file.filename}`; const fileInfo = { filename: req.file.originalname, url: fileUrl, size: req.file.size, mimetype: req.file.mimetype, uploadedAt: new Date().toISOString() }; res.json({ success: true, file: fileInfo }); } catch (err) { console.error('Error uploading file:', err); res.status(500).json({ error: 'Failed to upload file' }); } }); app.put(`${API_PREFIX}/office/knowledge-base/articles/:id`, async (req, res) => { try { const { id } = req.params; const updates = req.body; // Поддерживаем как camelCase, так и snake_case const fieldMapping = { 'categoryId': 'category_id', 'contentType': 'content_type', 'isPublished': 'is_published' }; const updateFields = []; const values = []; let paramIndex = 1; // Обрабатываем поля if (updates.title !== undefined) { updateFields.push(`title = $${paramIndex++}`); values.push(updates.title); } if (updates.slug !== undefined) { updateFields.push(`slug = $${paramIndex++}`); values.push(updates.slug); } if (updates.categoryId !== undefined || updates.category_id !== undefined) { updateFields.push(`category_id = $${paramIndex++}`); const categoryId = updates.categoryId !== undefined ? updates.categoryId : updates.category_id; values.push(categoryId ? parseInt(categoryId) : null); } if (updates.content !== undefined) { updateFields.push(`content = $${paramIndex++}`); values.push(updates.content); } if (updates.contentType !== undefined || updates.content_type !== undefined) { updateFields.push(`content_type = $${paramIndex++}`); values.push(updates.contentType !== undefined ? updates.contentType : updates.content_type); } if (updates.tags !== undefined) { updateFields.push(`tags = $${paramIndex++}`); const tagsArray = Array.isArray(updates.tags) ? updates.tags : (updates.tags ? [updates.tags] : []); values.push(tagsArray.length > 0 ? tagsArray : null); } if (updates.attachments !== undefined) { updateFields.push(`attachments = $${paramIndex++}`); const attachmentsArray = Array.isArray(updates.attachments) ? updates.attachments : (updates.attachments ? [updates.attachments] : []); values.push(attachmentsArray.length > 0 ? attachmentsArray : null); } if (updates.isPublished !== undefined || updates.is_published !== undefined) { updateFields.push(`is_published = $${paramIndex++}`); values.push(updates.isPublished !== undefined ? updates.isPublished : updates.is_published); } if (updateFields.length === 0) { return res.status(400).json({ error: 'Нет полей для обновления' }); } updateFields.push(`updated_at = NOW()`, `version = version + 1`); values.push(id); const result = await query( `UPDATE knowledge_base_articles SET ${updateFields.join(', ')} WHERE id = $${paramIndex} RETURNING *`, values ); if (result.length === 0) { return res.status(404).json({ error: 'Article not found' }); } res.json(result[0]); } catch (err) { console.error('Error updating article:', err); res.status(500).json({ error: 'Failed to update article' }); } }); // Совещания app.get(`${API_PREFIX}/office/meetings`, async (req, res) => { try { const { startDate, endDate, status, organizer } = req.query; let queryText = ` SELECT m.*, r.name as room_name, r.capacity as room_capacity FROM meetings m LEFT JOIN meeting_rooms r ON m.room_id = r.id WHERE 1=1 `; const params = []; if (startDate && endDate && startDate === endDate) { // Если даты одинаковые, ищем совещания, которые начинаются в этот день queryText += ` AND DATE(m.start_time) = $${params.length + 1}`; params.push(startDate); } else { if (startDate) { queryText += ` AND m.start_time >= $${params.length + 1}`; params.push(startDate); } if (endDate) { // Добавляем 1 день к endDate, чтобы включить весь день const endDatePlusOne = new Date(endDate); endDatePlusOne.setDate(endDatePlusOne.getDate() + 1); queryText += ` AND m.start_time < $${params.length + 1}`; params.push(endDatePlusOne.toISOString().split('T')[0]); } } if (status) { queryText += ` AND m.status = $${params.length + 1}`; params.push(status); } if (organizer) { queryText += ` AND m.organizer = $${params.length + 1}`; params.push(organizer); } queryText += ' ORDER BY m.start_time'; const rows = await query(queryText, params); res.json(rows); } catch (err) { console.error('Error fetching meetings:', err); res.status(500).json({ error: 'Failed to fetch meetings' }); } }); app.post(`${API_PREFIX}/office/meetings`, async (req, res) => { try { const { title, description, organizer, startTime, endTime, roomId, participants, agenda } = req.body; if (!title || !organizer || !startTime || !endTime) { return res.status(400).json({ error: 'title, organizer, startTime и endTime обязательны' }); } // Если указана переговорная — проверяем, что на это время она не занята if (roomId) { const conflicts = await query( `SELECT id FROM meeting_bookings WHERE room_id = $1 AND status = 'active' AND tstzrange(start_time, end_time) && tstzrange($2::timestamptz, $3::timestamptz)`, [roomId, startTime, endTime] ); if (conflicts.length > 0) { return res.status(409).json({ error: 'Переговорная на это время уже занята', code: 'ROOM_CONFLICT' }); } } // Преобразуем массив участников для PostgreSQL const participantsArray = Array.isArray(participants) ? participants : (participants ? [participants] : []); const result = await query( `INSERT INTO meetings (title, description, organizer, start_time, end_time, room_id, participants, agenda, status) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'scheduled') RETURNING *`, [ title, description || null, organizer, startTime, endTime, roomId || null, participantsArray, agenda || null ] ); // Создаем бронирование, если указана комната if (roomId) { await query( `INSERT INTO meeting_bookings (room_id, meeting_id, booked_by, start_time, end_time, status) VALUES ($1, $2, $3, $4, $5, 'active')`, [roomId, result[0].id, organizer, startTime, endTime] ); } res.status(201).json(result[0]); } catch (err) { console.error('Error creating meeting:', err); res.status(500).json({ error: 'Failed to create meeting' }); } }); // PUT /api/office/meetings/:id - обновление совещания app.put(`${API_PREFIX}/office/meetings/:id`, async (req, res) => { try { const { id } = req.params; const { title, description, startTime, endTime, roomId, participants, agenda, notes, conclusions, status } = req.body; const current = await query( 'SELECT id, room_id, start_time, end_time FROM meetings WHERE id = $1', [id] ); if (current.length === 0) { return res.status(404).json({ error: 'Meeting not found' }); } const cur = current[0]; const newStart = startTime !== undefined ? startTime : cur.start_time; const newEnd = endTime !== undefined ? endTime : cur.end_time; const newRoomId = roomId !== undefined ? (roomId || null) : cur.room_id; // При смене времени или комнаты — проверяем, что переговорная не занята (кроме текущего совещания) const timeOrRoomChanged = startTime !== undefined || endTime !== undefined || roomId !== undefined; if (timeOrRoomChanged && newRoomId) { const conflicts = await query( `SELECT id FROM meeting_bookings WHERE room_id = $1 AND status = 'active' AND tstzrange(start_time, end_time) && tstzrange($2::timestamptz, $3::timestamptz) AND (meeting_id IS NULL OR meeting_id != $4)`, [newRoomId, newStart, newEnd, id] ); if (conflicts.length > 0) { return res.status(409).json({ error: 'Переговорная на это время уже занята', code: 'ROOM_CONFLICT' }); } } const updates = []; const params = []; let paramIndex = 1; if (title !== undefined) { updates.push(`title = $${paramIndex++}`); params.push(title); } if (description !== undefined) { updates.push(`description = $${paramIndex++}`); params.push(description); } if (startTime !== undefined) { updates.push(`start_time = $${paramIndex++}`); params.push(startTime); } if (endTime !== undefined) { updates.push(`end_time = $${paramIndex++}`); params.push(endTime); } if (roomId !== undefined) { updates.push(`room_id = $${paramIndex++}`); params.push(roomId || null); } if (participants !== undefined) { updates.push(`participants = $${paramIndex++}`); const participantsArray = Array.isArray(participants) ? participants : (participants ? [participants] : []); params.push(participantsArray); } if (agenda !== undefined) { updates.push(`agenda = $${paramIndex++}`); params.push(agenda || null); } if (notes !== undefined) { updates.push(`notes = $${paramIndex++}`); params.push(notes || null); } if (conclusions !== undefined) { updates.push(`conclusions = $${paramIndex++}`); params.push(conclusions || null); } if (status !== undefined) { updates.push(`status = $${paramIndex++}`); params.push(status); } if (updates.length === 0) { return res.status(400).json({ error: 'Нет полей для обновления' }); } updates.push(`updated_at = NOW()`); params.push(id); const result = await query( `UPDATE meetings SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`, params ); if (result.length === 0) { return res.status(404).json({ error: 'Meeting not found' }); } // Синхронизируем бронирование переговорной при смене времени или комнаты if (timeOrRoomChanged) { if (newRoomId) { const upd = await query( `UPDATE meeting_bookings SET room_id = $1, start_time = $2, end_time = $3, updated_at = NOW() WHERE meeting_id = $4 RETURNING id`, [newRoomId, newStart, newEnd, id] ); if (upd.rows.length === 0) { await query( `INSERT INTO meeting_bookings (room_id, meeting_id, booked_by, start_time, end_time, status) VALUES ($1, $2, $3, $4, $5, 'active')`, [newRoomId, id, result[0].organizer || 'unknown', newStart, newEnd] ); } } else { await query( `UPDATE meeting_bookings SET status = 'canceled', updated_at = NOW() WHERE meeting_id = $1`, [id] ); } } res.json(result[0]); } catch (err) { console.error('Error updating meeting:', err); res.status(500).json({ error: 'Failed to update meeting' }); } }); // GET /api/office/meetings/:id - получение деталей совещания app.get(`${API_PREFIX}/office/meetings/:id`, async (req, res) => { try { const { id } = req.params; const result = await query( `SELECT m.*, r.name as room_name, r.capacity as room_capacity FROM meetings m LEFT JOIN meeting_rooms r ON m.room_id = r.id WHERE m.id = $1`, [id] ); if (result.length === 0) { return res.status(404).json({ error: 'Meeting not found' }); } res.json(result[0]); } catch (err) { console.error('Error fetching meeting:', err); res.status(500).json({ error: 'Failed to fetch meeting' }); } }); // Переговорные комнаты app.get(`${API_PREFIX}/office/meeting-rooms`, async (req, res) => { try { const { all } = req.query; const queryText = all === 'true' || all === '1' ? 'SELECT * FROM meeting_rooms ORDER BY is_active DESC, name' : 'SELECT * FROM meeting_rooms WHERE is_active = true ORDER BY name'; const rows = await query(queryText); res.json(rows); } catch (err) { console.error('Error fetching meeting rooms:', err); res.status(500).json({ error: 'Failed to fetch meeting rooms' }); } }); app.post(`${API_PREFIX}/office/meeting-rooms`, async (req, res) => { try { const { name, capacity, location, equipment, description } = req.body; if (!name || !name.trim()) { return res.status(400).json({ error: 'name обязательно' }); } if (!capacity || capacity <= 0) { return res.status(400).json({ error: 'capacity должен быть больше 0' }); } const equipmentArray = Array.isArray(equipment) ? equipment : (equipment ? [equipment] : []); const result = await query( `INSERT INTO meeting_rooms (name, capacity, location, equipment, description, is_active) VALUES ($1, $2, $3, $4, $5, true) RETURNING *`, [name.trim(), parseInt(capacity), location || null, equipmentArray.length > 0 ? equipmentArray : null, description || null] ); res.status(201).json(result[0]); } catch (err) { console.error('Error creating meeting room:', err); res.status(500).json({ error: 'Failed to create meeting room' }); } }); // GET /api/office/meeting-rooms/:id — одна переговорная (для редактирования) app.get(`${API_PREFIX}/office/meeting-rooms/:id`, async (req, res) => { try { const { id } = req.params; const rows = await query('SELECT * FROM meeting_rooms WHERE id = $1', [id]); if (rows.length === 0) { return res.status(404).json({ error: 'Переговорная не найдена' }); } res.json(rows[0]); } catch (err) { console.error('Error fetching meeting room:', err); res.status(500).json({ error: 'Failed to fetch meeting room' }); } }); // PUT /api/office/meeting-rooms/:id — обновление переговорной app.put(`${API_PREFIX}/office/meeting-rooms/:id`, async (req, res) => { try { const { id } = req.params; const { name, capacity, location, equipment, description, isActive } = req.body; const existing = await query('SELECT id FROM meeting_rooms WHERE id = $1', [id]); if (existing.length === 0) { return res.status(404).json({ error: 'Переговорная не найдена' }); } if (name !== undefined && (!name || !name.toString().trim())) { return res.status(400).json({ error: 'name не может быть пустым' }); } if (capacity !== undefined && (capacity == null || parseInt(capacity) <= 0)) { return res.status(400).json({ error: 'capacity должен быть больше 0' }); } const updates = []; const params = []; let paramIndex = 1; if (name !== undefined) { updates.push(`name = $${paramIndex++}`); params.push(name.trim()); } if (capacity !== undefined) { updates.push(`capacity = $${paramIndex++}`); params.push(parseInt(capacity)); } if (location !== undefined) { updates.push(`location = $${paramIndex++}`); params.push(location || null); } if (description !== undefined) { updates.push(`description = $${paramIndex++}`); params.push(description || null); } if (equipment !== undefined) { const equipmentArray = Array.isArray(equipment) ? equipment : (equipment ? [equipment] : []); updates.push(`equipment = $${paramIndex++}`); params.push(equipmentArray.length > 0 ? equipmentArray : null); } if (isActive !== undefined) { updates.push(`is_active = $${paramIndex++}`); params.push(!!isActive); } if (updates.length === 0) { const rows = await query('SELECT * FROM meeting_rooms WHERE id = $1', [id]); return res.json(rows[0]); } updates.push(`updated_at = NOW()`); params.push(id); const result = await query( `UPDATE meeting_rooms SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`, params ); res.json(result[0]); } catch (err) { console.error('Error updating meeting room:', err); res.status(500).json({ error: 'Failed to update meeting room' }); } }); // Бронирования app.get(`${API_PREFIX}/office/meeting-bookings`, async (req, res) => { try { const { roomId, startDate, endDate } = req.query; let queryText = ` SELECT b.*, r.name as room_name, m.title as meeting_title FROM meeting_bookings b LEFT JOIN meeting_rooms r ON b.room_id = r.id LEFT JOIN meetings m ON b.meeting_id = m.id WHERE b.status = 'active' `; const params = []; if (roomId) { queryText += ` AND b.room_id = $${params.length + 1}`; params.push(roomId); } // Пересечение с диапазоном дат: бронь попадает в день/диапазон если пересекается с ним if (startDate && endDate) { queryText += ` AND b.start_time < ($${params.length + 2}::date + interval '1 day') AND b.end_time > $${params.length + 1}::date`; params.push(startDate, endDate); } queryText += ' ORDER BY b.start_time'; const rows = await query(queryText, params); res.json(rows); } catch (err) { console.error('Error fetching bookings:', err); res.status(500).json({ error: 'Failed to fetch bookings' }); } }); app.post(`${API_PREFIX}/office/meeting-bookings`, async (req, res) => { try { const { roomId, bookedBy, startTime, endTime, purpose, meetingId } = req.body; if (!roomId || !bookedBy || !startTime || !endTime) { return res.status(400).json({ error: 'roomId, bookedBy, startTime и endTime обязательны' }); } // Проверяем конфликты const conflicts = await query( `SELECT * FROM meeting_bookings WHERE room_id = $1 AND status = 'active' AND tstzrange(start_time, end_time) && tstzrange($2, $3)`, [roomId, startTime, endTime] ); if (conflicts.length > 0) { return res.status(409).json({ error: 'Комната уже забронирована на это время', conflicts: conflicts }); } const result = await query( `INSERT INTO meeting_bookings (room_id, meeting_id, booked_by, start_time, end_time, purpose, status) VALUES ($1, $2, $3, $4, $5, $6, 'active') RETURNING *`, [roomId, meetingId || null, bookedBy, startTime, endTime, purpose || null] ); res.status(201).json(result[0]); } catch (err) { console.error('Error creating booking:', err); res.status(500).json({ error: 'Failed to create booking' }); } }); // ========= ОФИС: ЗАЯВКИ НА ТМЦ ========= // GET /api/office/supply-requests - список заявок app.get(`${API_PREFIX}/office/supply-requests`, async (req, res) => { try { const { status, category, requesterName } = req.query; let queryText = 'SELECT * FROM office_supply_requests WHERE 1=1'; const params = []; if (status) { queryText += ` AND status = $${params.length + 1}`; params.push(status); } if (category) { queryText += ` AND category = $${params.length + 1}`; params.push(category); } if (requesterName) { queryText += ` AND requester_name = $${params.length + 1}`; params.push(requesterName); } queryText += ' ORDER BY created_at DESC'; const rows = await query(queryText, params); res.json(rows); } catch (err) { console.error('Error fetching supply requests:', err); res.status(500).json({ error: 'Failed to fetch supply requests' }); } }); // POST /api/office/supply-requests - создание заявки app.post(`${API_PREFIX}/office/supply-requests`, async (req, res) => { try { const { requesterName, category, itemName, quantity, unit, amount, priority, notes } = req.body; if (!requesterName || !category || !itemName) { return res.status(400).json({ error: 'requesterName, category и itemName обязательны' }); } const result = await query( `INSERT INTO office_supply_requests (requester_name, category, item_name, quantity, unit, amount, priority, notes, status) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'new') RETURNING *`, [ requesterName, category, itemName, quantity || 1, unit || 'шт.', amount || 0, priority || 'medium', notes || null ] ); res.status(201).json(result[0]); } catch (err) { console.error('Error creating supply request:', err); res.status(500).json({ error: 'Failed to create supply request' }); } }); // PUT /api/office/supply-requests/:id - обновление заявки app.put(`${API_PREFIX}/office/supply-requests/:id`, async (req, res) => { try { const { id } = req.params; const updates = req.body; const allowedFields = ['status', 'approved_by', 'ordered_at', 'received_at', 'notes', 'priority', 'amount', 'issued_quantity']; const updateFields = []; const values = []; let paramIndex = 1; for (const field of allowedFields) { if (updates[field] !== undefined) { updateFields.push(`${field} = $${paramIndex}`); values.push(updates[field]); paramIndex++; } } if (updates.status === 'approved' && !updates.approved_at) { updateFields.push(`approved_at = NOW()`); } if (updates.status === 'ordered' && !updates.ordered_at) { updateFields.push(`ordered_at = NOW()`); } if (updates.status === 'received' && !updates.received_at) { updateFields.push(`received_at = NOW()`); } if (updateFields.length === 0) { return res.status(400).json({ error: 'Нет полей для обновления' }); } updateFields.push(`updated_at = NOW()`); values.push(id); const result = await query( `UPDATE office_supply_requests SET ${updateFields.join(', ')} WHERE id = $${paramIndex} RETURNING *`, values ); if (result.length === 0) { return res.status(404).json({ error: 'Supply request not found' }); } res.json(result[0]); } catch (err) { console.error('Error updating supply request:', err); res.status(500).json({ error: 'Failed to update supply request' }); } }); // DELETE /api/office/supply-requests/:id - удаление заявки app.delete(`${API_PREFIX}/office/supply-requests/:id`, async (req, res) => { try { const { id } = req.params; const result = await query('DELETE FROM office_supply_requests WHERE id = $1 RETURNING *', [id]); if (result.length === 0) { return res.status(404).json({ error: 'Supply request not found' }); } res.json({ message: 'Supply request deleted', id: result[0].id }); } catch (err) { console.error('Error deleting supply request:', err); res.status(500).json({ error: 'Failed to delete supply request' }); } }); // ========= ОФИС: СКЛАД ========= // GET /api/office/inventory - список товаров на складе app.get(`${API_PREFIX}/office/inventory`, async (req, res) => { try { const { category, search } = req.query; let queryText = 'SELECT * FROM office_inventory WHERE 1=1'; const params = []; if (category) { queryText += ` AND category = $${params.length + 1}`; params.push(category); } if (search) { queryText += ` AND name ILIKE $${params.length + 1}`; params.push(`%${search}%`); } queryText += ' ORDER BY name'; const rows = await query(queryText, params); res.json(rows); } catch (err) { console.error('Error fetching inventory:', err); res.status(500).json({ error: 'Failed to fetch inventory' }); } }); // POST /api/office/inventory - добавление товара app.post(`${API_PREFIX}/office/inventory`, async (req, res) => { try { const { name, category, quantity, unit, minThreshold, location, notes } = req.body; if (!name) { return res.status(400).json({ error: 'name обязательно' }); } const result = await query( `INSERT INTO office_inventory (name, category, quantity, unit, min_threshold, location, notes) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, [ name, category || null, quantity || 0, unit || 'шт.', minThreshold || 0, location || null, notes || null ] ); res.status(201).json(result[0]); } catch (err) { console.error('Error creating inventory item:', err); res.status(500).json({ error: 'Failed to create inventory item' }); } }); // PUT /api/office/inventory/:id - обновление товара app.put(`${API_PREFIX}/office/inventory/:id`, async (req, res) => { try { const { id } = req.params; const updates = req.body; const allowedFields = ['name', 'category', 'quantity', 'unit', 'min_threshold', 'location', 'notes', 'last_restock', 'last_restock_by']; const updateFields = []; const values = []; let paramIndex = 1; for (const field of allowedFields) { if (updates[field] !== undefined) { updateFields.push(`${field} = $${paramIndex}`); values.push(updates[field]); paramIndex++; } } if (updateFields.length === 0) { return res.status(400).json({ error: 'Нет полей для обновления' }); } updateFields.push(`updated_at = NOW()`); values.push(id); const result = await query( `UPDATE office_inventory SET ${updateFields.join(', ')} WHERE id = $${paramIndex} RETURNING *`, values ); if (result.length === 0) { return res.status(404).json({ error: 'Inventory item not found' }); } res.json(result[0]); } catch (err) { console.error('Error updating inventory item:', err); res.status(500).json({ error: 'Failed to update inventory item' }); } }); // DELETE /api/office/inventory/:id - удаление товара app.delete(`${API_PREFIX}/office/inventory/:id`, async (req, res) => { try { const { id } = req.params; const result = await query('DELETE FROM office_inventory WHERE id = $1 RETURNING *', [id]); if (result.length === 0) { return res.status(404).json({ error: 'Inventory item not found' }); } res.json({ message: 'Inventory item deleted', id: result[0].id }); } catch (err) { console.error('Error deleting inventory item:', err); res.status(500).json({ error: 'Failed to delete inventory item' }); } }); // ========= ОФИС: ДОКУМЕНТООБОРОТ ========= // GET /api/office/documents - список документов app.get(`${API_PREFIX}/office/documents`, async (req, res) => { try { const { status, documentType, search, assignedTo } = req.query; let queryText = ` SELECT id, reg_number, title, correspondent, document_type, COALESCE(letter_type, 'paper') as letter_type, status, date, assigned_to, tracking_number, file_url, notes, created_by, created_at, updated_at FROM office_documents WHERE 1=1 `; const params = []; if (status) { queryText += ` AND status = $${params.length + 1}`; params.push(status); } if (documentType) { queryText += ` AND document_type = $${params.length + 1}`; params.push(documentType); } if (assignedTo) { queryText += ` AND assigned_to = $${params.length + 1}`; params.push(assignedTo); } if (search) { queryText += ` AND (title ILIKE $${params.length + 1} OR reg_number ILIKE $${params.length + 1} OR correspondent ILIKE $${params.length + 1})`; const searchTerm = `%${search}%`; params.push(searchTerm, searchTerm, searchTerm); } queryText += ' ORDER BY date DESC, created_at DESC'; const rows = await query(queryText, params); res.json(rows); } catch (err) { console.error('Error fetching documents:', err); res.status(500).json({ error: 'Failed to fetch documents' }); } }); // POST /api/office/documents/upload - загрузка файла для документов корреспонденции app.post(`${API_PREFIX}/office/documents/upload`, uploadDocuments.single('file'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: 'Файл не загружен' }); } // Исправляем кодировку оригинального имени файла let originalName = req.file.originalname; try { const hasCyrillic = /[А-Яа-яЁё]/.test(originalName); const hasGarbled = /[ÐÑ]/i.test(originalName) || /[â€]/i.test(originalName); if (hasGarbled || (!hasCyrillic && /[^\x00-\x7F]/.test(originalName))) { try { const buffer = Buffer.from(originalName, 'latin1'); const decoded = buffer.toString('utf8'); if (/[А-Яа-яЁё]/.test(decoded)) { originalName = decoded; } } catch (decodeErr) { try { originalName = decodeURIComponent(escape(originalName)); } catch (e) { // Используем оригинал } } } } catch (decodeErr) { console.warn('Ошибка декодирования имени файла:', decodeErr); } // Используем API endpoint для скачивания вместо прямого пути const fileUrl = `/api/office/documents/download/${req.file.filename}`; const fileInfo = { filename: originalName, url: fileUrl, size: req.file.size, mimetype: req.file.mimetype, uploadedAt: new Date().toISOString(), storedFilename: req.file.filename // Сохраняем имя файла на диске }; res.json({ success: true, file: fileInfo, url: fileUrl, fileUrl: fileUrl }); } catch (err) { console.error('Error uploading document file:', err); if (err.message && err.message.includes('Неподдерживаемый тип файла')) { return res.status(400).json({ error: err.message }); } res.status(500).json({ error: 'Failed to upload file' }); } }); // GET /api/office/documents/download/:filename - скачивание файла документа app.get(`${API_PREFIX}/office/documents/download/:filename`, async (req, res) => { try { const { filename } = req.params; const filePath = path.join(documentsDir, filename); // Проверяем существование файла if (!fs.existsSync(filePath)) { return res.status(404).json({ error: 'Файл не найден' }); } // Получаем оригинальное имя файла из базы данных // Имя файла на диске имеет формат: UUID-безопасное_имя.расширение // Пытаемся извлечь оригинальное имя из имени файла на диске let originalFilename = filename; // Убираем UUID из начала имени файла const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-/i; if (uuidPattern.test(filename)) { originalFilename = filename.replace(uuidPattern, ''); } // Если имя файла содержит только подчеркивания вместо кириллицы, пытаемся получить из БД if (originalFilename.replace(/[^_]/g, '').length > 5) { try { // Ищем документ с этим файлом в базе данных const docResult = await query( 'SELECT file_url, title FROM office_documents WHERE file_url LIKE $1 LIMIT 1', [`%${filename}%`] ); // Пытаемся восстановить имя из названия документа или используем безопасное имя if (docResult.length > 0) { const ext = path.extname(filename); // Используем название документа как основу для имени файла if (docResult[0].title) { const safeTitle = docResult[0].title.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 50); originalFilename = `${safeTitle}${ext}`; } } } catch (dbErr) { console.warn('Не удалось получить оригинальное имя файла из БД:', dbErr); } } // Определяем MIME тип файла const ext = path.extname(filePath).toLowerCase(); const mimeTypes = { '.pdf': 'application/pdf', '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.txt': 'text/plain', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp' }; const contentType = mimeTypes[ext] || 'application/octet-stream'; // Устанавливаем правильные заголовки для скачивания res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(originalFilename)}"; filename*=UTF-8''${encodeURIComponent(originalFilename)}`); res.setHeader('Content-Type', contentType); // Отправляем файл (используем абсолютный путь) res.sendFile(path.resolve(filePath)); } catch (err) { console.error('Error downloading document file:', err); res.status(500).json({ error: 'Failed to download file' }); } }); // GET /api/office/documents/next-reg-number - получение следующего регистрационного номера app.get(`${API_PREFIX}/office/documents/next-reg-number`, async (req, res) => { try { const { documentType, date } = req.query; const docType = documentType || 'incoming'; const docDate = date ? new Date(date) : new Date(); const year = docDate.getFullYear(); // Получаем последний номер для данного типа и года const prefix = docType === 'incoming' ? 'ВХ' : 'ИСХ'; const yearStr = year.toString().slice(-2); // Последние 2 цифры года const result = await query( `SELECT reg_number FROM office_documents WHERE reg_number LIKE $1 ORDER BY reg_number DESC LIMIT 1`, [`${prefix}-${yearStr}-%`] ); let nextNumber = 1; if (result.length > 0) { const lastNumber = result[0].reg_number; const match = lastNumber.match(/-(\d+)$/); if (match) { nextNumber = parseInt(match[1]) + 1; } } const regNumber = `${prefix}-${yearStr}-${nextNumber.toString().padStart(4, '0')}`; res.json({ regNumber }); } catch (err) { console.error('Error generating reg number:', err); res.status(500).json({ error: 'Failed to generate registration number' }); } }); // POST /api/office/documents - регистрация документа app.post(`${API_PREFIX}/office/documents`, async (req, res) => { try { // Поддерживаем как camelCase, так и snake_case let { regNumber, reg_number, title, correspondent, documentType, document_type, letterType, letter_type, date, assignedTo, assigned_to, trackingNumber, tracking_number, fileUrl, file_url, notes, createdBy, created_by } = req.body; // Нормализуем к camelCase regNumber = regNumber || reg_number; documentType = documentType || document_type; letterType = letterType || letter_type; assignedTo = assignedTo || assigned_to; trackingNumber = trackingNumber || tracking_number; fileUrl = fileUrl || file_url; createdBy = createdBy || created_by; // Если регистрационный номер не указан, генерируем автоматически if (!regNumber || !regNumber.trim()) { const docType = documentType || 'incoming'; const docDate = date ? new Date(date) : new Date(); const year = docDate.getFullYear(); const prefix = docType === 'incoming' ? 'ВХ' : 'ИСХ'; const yearStr = year.toString().slice(-2); const result = await query( `SELECT reg_number FROM office_documents WHERE reg_number LIKE $1 ORDER BY reg_number DESC LIMIT 1`, [`${prefix}-${yearStr}-%`] ); let nextNumber = 1; if (result.length > 0) { const lastNumber = result[0].reg_number; const match = lastNumber.match(/-(\d+)$/); if (match) { nextNumber = parseInt(match[1]) + 1; } } regNumber = `${prefix}-${yearStr}-${nextNumber.toString().padStart(4, '0')}`; } // Проверяем обязательные поля (regNumber уже сгенерирован выше, если не был указан) if (!title || !title.trim()) { return res.status(400).json({ error: 'title обязателен' }); } if (!correspondent || !correspondent.trim()) { return res.status(400).json({ error: 'correspondent обязателен' }); } if (!documentType) { return res.status(400).json({ error: 'documentType обязателен' }); } if (!date) { return res.status(400).json({ error: 'date обязателен' }); } if (!createdBy || !createdBy.trim()) { return res.status(400).json({ error: 'createdBy обязателен' }); } // Если fileUrl содержит прямой путь к файлу, преобразуем его в API endpoint let finalFileUrl = fileUrl; if (fileUrl) { if (fileUrl.startsWith('/uploads/documents/')) { const filename = fileUrl.replace('/uploads/documents/', ''); finalFileUrl = `/api/office/documents/download/${filename}`; } else if (fileUrl.startsWith('/api/office/documents/download/')) { // Уже правильный формат finalFileUrl = fileUrl; } } const result = await query( `INSERT INTO office_documents (reg_number, title, correspondent, document_type, letter_type, date, assigned_to, tracking_number, file_url, notes, created_by, status) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'registered') RETURNING *`, [ regNumber, title, correspondent, documentType, letterType || 'paper', date, assignedTo || null, trackingNumber || null, finalFileUrl || null, notes || null, createdBy ] ); res.status(201).json(result[0]); } catch (err) { if (err.code === '23505') { // unique_violation return res.status(409).json({ error: 'Документ с таким регистрационным номером уже существует' }); } console.error('Error creating document:', err); res.status(500).json({ error: 'Failed to create document' }); } }); // PUT /api/office/documents/:id - обновление документа app.put(`${API_PREFIX}/office/documents/:id`, async (req, res) => { try { const { id } = req.params; const updates = req.body; // Маппинг camelCase в snake_case для БД const fieldMapping = { 'status': 'status', 'assignedTo': 'assigned_to', 'assigned_to': 'assigned_to', 'trackingNumber': 'tracking_number', 'tracking_number': 'tracking_number', 'fileUrl': 'file_url', 'file_url': 'file_url', 'notes': 'notes', 'title': 'title', 'correspondent': 'correspondent', 'documentType': 'document_type', 'document_type': 'document_type', 'letterType': 'letter_type', 'letter_type': 'letter_type', 'regNumber': 'reg_number', 'reg_number': 'reg_number', 'date': 'date' }; const updateFields = []; const values = []; let paramIndex = 1; for (const [key, value] of Object.entries(updates)) { const dbField = fieldMapping[key]; if (dbField && value !== undefined) { let processedValue = value; // Если обновляется file_url, преобразуем прямой путь в API endpoint if (dbField === 'file_url' && value) { if (value.startsWith('/uploads/documents/')) { const filename = value.replace('/uploads/documents/', ''); processedValue = `/api/office/documents/download/${filename}`; } } updateFields.push(`${dbField} = $${paramIndex}`); values.push(processedValue); paramIndex++; } } if (updateFields.length === 0) { return res.status(400).json({ error: 'Нет полей для обновления' }); } updateFields.push(`updated_at = NOW()`); values.push(id); const result = await query( `UPDATE office_documents SET ${updateFields.join(', ')} WHERE id = $${paramIndex} RETURNING *`, values ); if (result.length === 0) { return res.status(404).json({ error: 'Document not found' }); } res.json(result[0]); } catch (err) { console.error('Error updating document:', err); res.status(500).json({ error: 'Failed to update document' }); } }); // DELETE /api/office/documents/:id - удаление документа app.delete(`${API_PREFIX}/office/documents/:id`, async (req, res) => { try { const { id } = req.params; const result = await query('DELETE FROM office_documents WHERE id = $1 RETURNING *', [id]); if (result.length === 0) { return res.status(404).json({ error: 'Document not found' }); } res.json({ message: 'Document deleted', id: result[0].id }); } catch (err) { console.error('Error deleting document:', err); res.status(500).json({ error: 'Failed to delete document' }); } }); // ========= ОФИС: ЗАКАЗЫ ========= // GET /api/office/orders - список заказов app.get(`${API_PREFIX}/office/orders`, async (req, res) => { try { const { status } = req.query; let queryText = 'SELECT * FROM office_orders WHERE 1=1'; const params = []; if (status) { queryText += ` AND status = $${params.length + 1}`; params.push(status); } queryText += ' ORDER BY created_at DESC'; const rows = await query(queryText, params); res.json(rows); } catch (err) { console.error('Error fetching orders:', err); res.status(500).json({ error: 'Failed to fetch orders' }); } }); // POST /api/office/orders - создание заказа app.post(`${API_PREFIX}/office/orders`, async (req, res) => { try { const { title, description, requestIds, supplierName, supplierContact, expectedDate, notes, createdBy } = req.body; if (!title || !title.trim()) { return res.status(400).json({ error: 'title обязательно' }); } if (!createdBy || !createdBy.trim()) { return res.status(400).json({ error: 'createdBy обязательно' }); } // Генерируем номер заказа const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); const timestamp = String(Date.now()).slice(-4); const orderNumber = `ORD-${year}-${month}${day}-${timestamp}`; // Создаем заказ const orderResult = await query( `INSERT INTO office_orders (order_number, title, description, status, supplier_name, supplier_contact, expected_date, notes, created_by) VALUES ($1, $2, $3, 'waiting_quote', $4, $5, $6, $7, $8) RETURNING *`, [ orderNumber, title.trim(), description || null, supplierName || null, supplierContact || null, expectedDate || null, notes || null, createdBy.trim() ] ); const order = orderResult[0]; // Добавляем позиции заказа из заявок if (requestIds && Array.isArray(requestIds) && requestIds.length > 0) { for (const requestId of requestIds) { // Получаем данные заявки const requestResult = await query('SELECT * FROM office_supply_requests WHERE id = $1', [requestId]); if (requestResult.length > 0) { const req = requestResult[0]; const quantity = req.quantity || 1; const unitPrice = req.amount ? (req.amount / quantity) : 0; const totalPrice = req.amount || 0; await query( `INSERT INTO office_order_items (order_id, request_id, quantity, unit_price, total_price) VALUES ($1, $2, $3, $4, $5)`, [order.id, requestId, quantity, unitPrice, totalPrice] ); } } // Пересчитываем общую сумму заказа const itemsResult = await query( 'SELECT SUM(total_price) as total FROM office_order_items WHERE order_id = $1', [order.id] ); const totalAmount = itemsResult[0]?.total || 0; await query( 'UPDATE office_orders SET total_amount = $1 WHERE id = $2', [totalAmount, order.id] ); order.total_amount = totalAmount; } res.status(201).json(order); } catch (err) { console.error('Error creating order:', err); res.status(500).json({ error: 'Failed to create order' }); } }); // PUT /api/office/orders/:orderId/items/:itemId - обновление позиции заказа (кол-во / цена / сумма) app.put(`${API_PREFIX}/office/orders/:orderId/items/:itemId`, async (req, res) => { try { const { orderId, itemId } = req.params; const { quantity, unitPrice, totalPrice } = req.body; const updateFields = []; const values = []; let paramIndex = 1; if (quantity !== undefined) { updateFields.push(`quantity = $${paramIndex++}`); values.push(parseFloat(quantity) || 0); } if (unitPrice !== undefined) { updateFields.push(`unit_price = $${paramIndex++}`); values.push(parseFloat(unitPrice) || 0); } if (totalPrice !== undefined) { updateFields.push(`total_price = $${paramIndex++}`); values.push(parseFloat(totalPrice) || 0); } // Если итоговая сумма не передана, пересчитываем как quantity * unit_price if (!updateFields.some(f => f.startsWith('total_price'))) { updateFields.push(`total_price = (COALESCE(quantity, 0) * COALESCE(unit_price, 0))`); } if (updateFields.length === 0) { return res.status(400).json({ error: 'Нет полей для обновления' }); } values.push(itemId); const updatedItems = await query( `UPDATE office_order_items SET ${updateFields.join(', ')} WHERE id = $${paramIndex} RETURNING *`, values ); if (updatedItems.length === 0) { return res.status(404).json({ error: 'Order item not found' }); } // Синхронизируем цену в исходной заявке (office_supply_requests) const item = updatedItems[0]; if (item.request_id) { await query( 'UPDATE office_supply_requests SET amount = $1, updated_at = NOW() WHERE id = $2', [item.total_price || 0, item.request_id] ); } // Пересчитываем общую сумму заказа const sumResult = await query( 'SELECT SUM(total_price) as total FROM office_order_items WHERE order_id = $1', [orderId] ); const totalAmount = sumResult[0]?.total || 0; await query( 'UPDATE office_orders SET total_amount = $1, updated_at = NOW() WHERE id = $2', [totalAmount, orderId] ); res.json({ item: updatedItems[0], totalAmount }); } catch (err) { console.error('Error updating order item:', err); res.status(500).json({ error: 'Failed to update order item' }); } }); // GET /api/office/orders/:id/items - позиции заказа app.get(`${API_PREFIX}/office/orders/:id/items`, async (req, res) => { try { const { id } = req.params; const rows = await query( `SELECT oi.*, osr.item_name, osr.category, osr.unit FROM office_order_items oi JOIN office_supply_requests osr ON oi.request_id = osr.id WHERE oi.order_id = $1`, [id] ); res.json(rows.map((item) => ({ id: item.id, orderId: item.order_id, requestId: item.request_id, request: { id: item.request_id, itemName: item.item_name, category: item.category, unit: item.unit }, quantity: item.quantity, unitPrice: item.unit_price, totalPrice: item.total_price }))); } catch (err) { console.error('Error fetching order items:', err); res.status(500).json({ error: 'Failed to fetch order items' }); } }); // GET /api/office/orders/:id/quotes - предложения для заказа app.get(`${API_PREFIX}/office/orders/:id/quotes`, async (req, res) => { try { const { id } = req.params; const rows = await query( 'SELECT * FROM office_order_quotes WHERE order_id = $1 ORDER BY created_at DESC', [id] ); res.json(rows); } catch (err) { console.error('Error fetching order quotes:', err); res.status(500).json({ error: 'Failed to fetch order quotes' }); } }); // POST /api/office/orders/:id/quotes - добавить предложение app.post(`${API_PREFIX}/office/orders/:id/quotes`, async (req, res) => { try { const { id } = req.params; const { supplierName, supplierContact, totalAmount, quoteFileUrl, notes } = req.body; if (!supplierName || !totalAmount) { return res.status(400).json({ error: 'supplierName и totalAmount обязательны' }); } const result = await query( `INSERT INTO office_order_quotes (order_id, supplier_name, supplier_contact, total_amount, quote_file_url, notes) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, [id, supplierName, supplierContact || null, parseFloat(totalAmount), quoteFileUrl || null, notes || null] ); // Обновляем статус заказа если это первое предложение const quotesCount = await query( 'SELECT COUNT(*) as count FROM office_order_quotes WHERE order_id = $1', [id] ); if (quotesCount[0].count === 1) { await query( 'UPDATE office_orders SET status = $1 WHERE id = $2', ['quotes_received', id] ); } res.status(201).json(result[0]); } catch (err) { console.error('Error creating quote:', err); res.status(500).json({ error: 'Failed to create quote' }); } }); // PUT /api/office/orders/:id/quotes/:quoteId - обновить предложение app.put(`${API_PREFIX}/office/orders/:id/quotes/:quoteId`, async (req, res) => { try { const { quoteId } = req.params; const updates = req.body; const updateFields = []; const values = []; let paramIndex = 1; if (updates.isSelected !== undefined) { updateFields.push(`is_selected = $${paramIndex++}`); values.push(updates.isSelected); } if (updates.supplierName !== undefined) { updateFields.push(`supplier_name = $${paramIndex++}`); values.push(updates.supplierName); } if (updates.totalAmount !== undefined) { updateFields.push(`total_amount = $${paramIndex++}`); values.push(parseFloat(updates.totalAmount)); } if (updateFields.length === 0) { return res.status(400).json({ error: 'Нет полей для обновления' }); } updateFields.push(`updated_at = NOW()`); values.push(quoteId); const result = await query( `UPDATE office_order_quotes SET ${updateFields.join(', ')} WHERE id = $${paramIndex} RETURNING *`, values ); if (result.length === 0) { return res.status(404).json({ error: 'Quote not found' }); } res.json(result[0]); } catch (err) { console.error('Error updating quote:', err); res.status(500).json({ error: 'Failed to update quote' }); } }); // PUT /api/office/orders/:id - обновление заказа app.put(`${API_PREFIX}/office/orders/:id`, async (req, res) => { try { const { id } = req.params; const updates = req.body; const updateFields = []; const values = []; let paramIndex = 1; if (updates.status !== undefined) { updateFields.push(`status = $${paramIndex++}`); values.push(updates.status); } if (updates.supplierName !== undefined) { updateFields.push(`supplier_name = $${paramIndex++}`); values.push(updates.supplierName); } if (updates.supplierContact !== undefined) { updateFields.push(`supplier_contact = $${paramIndex++}`); values.push(updates.supplierContact); } if (updates.totalAmount !== undefined) { updateFields.push(`total_amount = $${paramIndex++}`); values.push(parseFloat(updates.totalAmount)); } if (updates.invoiceId !== undefined) { updateFields.push(`invoice_id = $${paramIndex++}`); values.push(updates.invoiceId); } if (updates.invoiceUrl !== undefined) { updateFields.push(`invoice_url = $${paramIndex++}`); values.push(updates.invoiceUrl); } if (updates.expectedDate !== undefined) { updateFields.push(`expected_date = $${paramIndex++}`); values.push(updates.expectedDate); } if (updates.receivedDate !== undefined) { updateFields.push(`received_date = $${paramIndex++}`); values.push(updates.receivedDate); } if (updateFields.length === 0) { return res.status(400).json({ error: 'Нет полей для обновления' }); } updateFields.push(`updated_at = NOW()`); values.push(id); const result = await query( `UPDATE office_orders SET ${updateFields.join(', ')} WHERE id = $${paramIndex} RETURNING *`, values ); if (result.length === 0) { return res.status(404).json({ error: 'Order not found' }); } res.json(result[0]); } catch (err) { console.error('Error updating order:', err); res.status(500).json({ error: 'Failed to update order' }); } }); // ========= HR: ПРАЗДНИКИ РФ ========= // Праздники РФ (государственные, фиксированные даты). Актуальный список — производственный календарь. function getRussianHolidaysForYear(year) { const y = String(year); const list = [ { date: `${y}-01-01`, name: 'Новый год' }, { date: `${y}-01-02`, name: 'Новогодние каникулы' }, { date: `${y}-01-03`, name: 'Новогодние каникулы' }, { date: `${y}-01-04`, name: 'Новогодние каникулы' }, { date: `${y}-01-05`, name: 'Новогодние каникулы' }, { date: `${y}-01-06`, name: 'Новогодние каникулы' }, { date: `${y}-01-07`, name: 'Рождество Христово' }, { date: `${y}-01-08`, name: 'Новогодние каникулы' }, { date: `${y}-02-23`, name: 'День защитника Отечества' }, { date: `${y}-03-08`, name: 'Международный женский день' }, { date: `${y}-05-01`, name: 'Праздник Весны и Труда' }, { date: `${y}-05-09`, name: 'День Победы' }, { date: `${y}-06-12`, name: 'День России' }, { date: `${y}-11-04`, name: 'День народного единства' } ]; return list; } // GET /api/holidays/ru?year=2025 — праздники РФ на год (для календаря в HR-сводке) app.get(`${API_PREFIX}/holidays/ru`, async (req, res) => { try { const year = parseInt(req.query.year, 10) || new Date().getFullYear(); if (year < 2000 || year > 2100) { return res.status(400).json({ error: 'Год должен быть от 2000 до 2100' }); } const holidays = getRussianHolidaysForYear(year); res.json({ year, holidays }); } catch (err) { console.error('Error fetching RU holidays:', err); res.status(500).json({ error: 'Failed to fetch holidays' }); } }); // ========= HR: СВОДКА ========= // GET /api/hr/summary — данные для HR-сводки (KPI, участки, события, частые отпуска/прогулы/больничные) app.get(`${API_PREFIX}/hr/summary`, async (req, res) => { try { const today = new Date(); today.setHours(0, 0, 0, 0); const yearStart = new Date(today.getFullYear(), 0, 1); const in7Days = new Date(today); in7Days.setDate(in7Days.getDate() + 7); const in14Days = new Date(today); in14Days.setDate(in14Days.getDate() + 14); const toDate = (d) => d.toISOString().slice(0, 10); const todayStr = toDate(today); const yearStartStr = toDate(yearStart); const in7Str = toDate(in7Days); const in14Str = toDate(in14Days); const getInitials = (name) => { if (!name || typeof name !== 'string') return '??'; const parts = name.trim().split(/\s+/).filter(Boolean); if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); return '??'; }; // Всего в штате (активные) const staffRows = await query( `SELECT COUNT(*) AS c FROM employees WHERE status = 'active'` ); const totalStaff = parseInt(staffRows[0]?.c || 0, 10); // Вакансии (активные/срочные) и кандидаты let vacancyCount = 0; let activeCandidates = 0; try { const vacRows = await query( `SELECT COUNT(*) AS c FROM vacancies WHERE status IN ('urgent', 'active')` ); vacancyCount = parseInt(vacRows[0]?.c || 0, 10); const candRows = await query(`SELECT COUNT(*) AS c FROM candidates`); activeCandidates = parseInt(candRows[0]?.c || 0, 10); } catch (e) { // таблицы vacancies/candidates могут отсутствовать } // Текучесть за год: увольнения за последние 12 мес / средняя численность (упрощённо: текущая) let turnoverRate = 0; try { const termRows = await query( `SELECT COUNT(*) AS c FROM employee_terminations WHERE termination_date >= $1::date AND termination_date <= $2::date`, [toDate(new Date(today.getFullYear() - 1, today.getMonth(), today.getDate() + 1)), todayStr] ); const termCount = parseInt(termRows[0]?.c || 0, 10); turnoverRate = totalStaff > 0 ? Math.round((termCount / totalStaff) * 100) : 0; } catch (e) {} // На обучение (scheduled, in_progress) let onTraining = 0; try { const trRows = await query( `SELECT COUNT(DISTINCT employee_id) AS c FROM employee_training WHERE status IN ('scheduled', 'in_progress')` ); onTraining = parseInt(trRows[0]?.c || 0, 10); } catch (e) {} // Укомплектованность участков (учитываем назначения из employee_districts; один сотрудник может быть на нескольких участках) const districts = await query( `SELECT id, name FROM districts ORDER BY id` ); let countByDistrict = {}; try { const staffByDistrict = await query( `SELECT ed.district_id AS "districtId", COUNT(DISTINCT ed.employee_id) AS c FROM employee_districts ed INNER JOIN employees e ON e.id = ed.employee_id AND e.status = 'active' GROUP BY ed.district_id` ); staffByDistrict.forEach((r) => { countByDistrict[r.districtId] = parseInt(r.c, 10); }); } catch (_) { const staffByDistrict = await query( `SELECT assigned_district_id AS "districtId", COUNT(*) AS c FROM employees WHERE status = 'active' AND assigned_district_id IS NOT NULL GROUP BY assigned_district_id` ); staffByDistrict.forEach((r) => { countByDistrict[r.districtId] = parseInt(r.c, 10); }); } const staffingByDistrict = districts.map((d) => ({ districtId: d.id, districtName: d.name, current: countByDistrict[d.id] || 0, total: countByDistrict[d.id] || 0 })); // События: дни рождения (в ближайшие 14 дней по календарю «в этом году») const allBirthdays = await query( `SELECT id, name, birth_date AS "birthDate" FROM employees WHERE status = 'active' AND birth_date IS NOT NULL` ); const thisYear = today.getFullYear(); const todayTime = today.getTime(); const in14Time = in14Days.getTime(); const birthdays = (allBirthdays || []) .map((e) => { const b = new Date(e.birthDate); const thisYearB = new Date(thisYear, b.getMonth(), b.getDate()); return { ...e, thisYearDate: thisYearB.getTime() }; }) .filter((e) => e.thisYearDate >= todayTime && e.thisYearDate <= in14Time) .sort((a, b) => a.thisYearDate - b.thisYearDate); const birthDateToLabel = (birthDate) => { if (!birthDate) return ''; const b = new Date(birthDate); const bM = b.getMonth(), bD = b.getDate(); const tM = today.getMonth(), tD = today.getDate(); if (bM === tM && bD === tD) return 'Сегодня'; const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1); if (bM === tomorrow.getMonth() && bD === tomorrow.getDate()) return 'Завтра'; return `День рождения • ${String(bD).padStart(2, '0')}.${String(bM + 1).padStart(2, '0')}`; }; const eventsBirthdays = (birthdays || []).slice(0, 10).map((e) => ({ employeeId: e.id, name: e.name, initials: getInitials(e.name), date: e.birthDate, label: birthDateToLabel(e.birthDate) })); let eventsVacationReturns = []; try { const returnsRows = await query( `SELECT e.id, e.name, v.end_date AS "endDate" FROM employee_vacations v JOIN employees e ON e.id = v.employee_id WHERE v.status = 'approved' AND v.end_date >= $1::date AND v.end_date <= $2::date ORDER BY v.end_date`, [todayStr, in7Str] ); eventsVacationReturns = (returnsRows || []).slice(0, 10).map((r) => ({ employeeId: r.id, name: r.name, initials: getInitials(r.name), date: r.endDate, label: `Выход из отпуска • ${String(new Date(r.endDate).getDate()).padStart(2, '0')}.${String(new Date(r.endDate).getMonth() + 1).padStart(2, '0')}` })); } catch (e) {} // Самые частые в отпуске (по количеству записей отпусков) let mostFrequentVacation = []; try { const vacFreq = await query( `SELECT e.id, e.name, COUNT(v.id) AS cnt FROM employees e JOIN employee_vacations v ON v.employee_id = e.id WHERE e.status = 'active' GROUP BY e.id, e.name ORDER BY cnt DESC LIMIT 10` ); mostFrequentVacation = (vacFreq || []).map((r) => ({ employeeId: r.id, name: r.name, initials: getInitials(r.name), count: parseInt(r.cnt, 10) })); } catch (e) {} // Самые частые: прогулы, отгулы, опоздания, уходы, больничные (агрегация по сотруднику) let mostFrequentAbsences = []; try { const absAgg = await query( `SELECT employee_id AS "employeeId", COUNT(*) FILTER (WHERE absence_type = 'day_off') AS "dayOff", COUNT(*) FILTER (WHERE absence_type = 'absence') AS "absence", COUNT(*) FILTER (WHERE absence_type = 'late') AS "late", COUNT(*) FILTER (WHERE absence_type = 'early_leave') AS "earlyLeave" FROM employee_absences GROUP BY employee_id` ); const sickAgg = await query( `SELECT employee_id AS "employeeId", COUNT(*) AS "sick" FROM employee_sick_leaves GROUP BY employee_id` ); const byEmp = {}; absAgg.forEach((r) => { byEmp[r.employeeId] = { dayOff: parseInt(r.dayOff || 0, 10), absence: parseInt(r.absence || 0, 10), late: parseInt(r.late || 0, 10), earlyLeave: parseInt(r.earlyLeave || 0, 10), sick: 0 }; }); sickAgg.forEach((r) => { if (!byEmp[r.employeeId]) byEmp[r.employeeId] = { dayOff: 0, absence: 0, late: 0, earlyLeave: 0, sick: 0 }; byEmp[r.employeeId].sick = parseInt(r.sick || 0, 10); }); const empIds = Object.keys(byEmp).filter((id) => { const x = byEmp[id]; return (x.dayOff + x.absence + x.late + x.earlyLeave + x.sick) > 0; }); if (empIds.length > 0) { const namesRows = await query( `SELECT id, name FROM employees WHERE id = ANY($1::varchar[])`, [empIds] ); const names = {}; namesRows.forEach((r) => { names[r.id] = r.name; }); mostFrequentAbsences = empIds .map((id) => { const x = byEmp[id]; const total = x.dayOff + x.absence + x.late + x.earlyLeave + x.sick; return { employeeId: id, name: names[id] || id, initials: getInitials(names[id]), ...x, total }; }) .sort((a, b) => b.total - a.total) .slice(0, 10); } } catch (e) {} res.json({ totalStaff, vacancyCount, activeCandidates, turnoverRate, onTraining, staffingByDistrict, events: { birthdays: eventsBirthdays, vacationReturns: eventsVacationReturns }, mostFrequentVacation, mostFrequentAbsences }); } catch (err) { console.error('Error fetching HR summary:', err); res.status(500).json({ error: 'Failed to fetch HR summary' }); } }); // GET /api/hr/template-documents — список типовых документов HR app.get(`${API_PREFIX}/hr/template-documents`, async (req, res) => { try { const tableCheck = await query( `SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'hr_template_documents')` ); if (!tableCheck[0]?.exists) { return res.json([]); } const rows = await query( `SELECT id, name, file_path AS "filePath", original_filename AS "originalFilename", created_at AS "createdAt" FROM hr_template_documents ORDER BY created_at DESC` ); res.json(rows); } catch (err) { console.error('Error fetching HR template documents:', err); res.status(500).json({ error: 'Failed to fetch template documents' }); } }); // POST /api/hr/template-documents — загрузка типового документа app.post(`${API_PREFIX}/hr/template-documents`, uploadHrTemplate.single('file'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: 'Файл не выбран' }); } const name = (req.body && req.body.name && String(req.body.name).trim()) || req.file.originalname || 'Типовой документ'; const filePath = `hr-templates/${req.file.filename}`; const originalFilename = req.file.originalname || null; const tableCheck = await query( `SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'hr_template_documents')` ); if (!tableCheck[0]?.exists) { return res.status(500).json({ error: 'Таблица типовых документов не создана' }); } const result = await query( `INSERT INTO hr_template_documents (name, file_path, original_filename) VALUES ($1, $2, $3) RETURNING id, name, file_path AS "filePath", original_filename AS "originalFilename", created_at AS "createdAt"`, [name, filePath, originalFilename] ); res.status(201).json(result[0]); } catch (err) { console.error('Error uploading HR template document:', err); res.status(500).json({ error: err.message || 'Failed to upload template document' }); } }); // GET /api/hr/template-documents/:id/download — скачать типовой документ app.get(`${API_PREFIX}/hr/template-documents/:id/download`, async (req, res) => { try { const { id } = req.params; const rows = await query( `SELECT file_path AS "filePath", original_filename AS "originalFilename", name FROM hr_template_documents WHERE id = $1`, [id] ); if (!rows.length) { return res.status(404).json({ error: 'Документ не найден' }); } const filePath = path.join(uploadDir, rows[0].filePath); if (!fs.existsSync(filePath)) { return res.status(404).json({ error: 'Файл не найден на диске' }); } const downloadName = rows[0].originalFilename || rows[0].name || 'document'; res.download(filePath, downloadName); } catch (err) { console.error('Error downloading HR template document:', err); res.status(500).json({ error: 'Failed to download' }); } }); // DELETE /api/hr/template-documents/:id — удаление типового документа (используется в панели управления) app.delete(`${API_PREFIX}/hr/template-documents/:id`, async (req, res) => { try { const { id } = req.params; const rows = await query( `SELECT file_path AS "filePath" FROM hr_template_documents WHERE id = $1`, [id] ); if (rows.length) { const filePath = path.join(uploadDir, rows[0].filePath); if (fs.existsSync(filePath)) { fs.unlink(filePath, (err) => { if (err) console.error('Ошибка удаления файла типового документа:', err); }); } } await query('DELETE FROM hr_template_documents WHERE id = $1', [id]); res.json({ success: true }); } catch (err) { console.error('Error deleting HR template document:', err); res.status(500).json({ error: 'Failed to delete template document' }); } }); // ========= HR: ВАКАНСИИ ========= // GET /api/vacancies - получить все вакансии app.get(`${API_PREFIX}/vacancies`, async (req, res) => { try { // Проверяем существование таблицы vacancies const tableCheck = await query( `SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'vacancies' )` ); if (!tableCheck[0]?.exists) { return res.status(500).json({ error: 'Failed to fetch vacancies', message: 'Table "vacancies" does not exist. Please run database migrations.' }); } const { status, department } = req.query; let queryText = 'SELECT * FROM vacancies WHERE 1=1'; const params = []; if (status) { queryText += ` AND status = $${params.length + 1}`; params.push(status); } if (department) { queryText += ` AND department = $${params.length + 1}`; params.push(department); } queryText += ' ORDER BY posted_date DESC, created_at DESC'; const rows = await query(queryText, params); // Подсчитываем количество кандидатов для каждой вакансии const vacanciesWithCounts = await Promise.all(rows.map(async (vacancy) => { try { const candidatesResult = await query( 'SELECT COUNT(*) as count FROM candidates WHERE vacancy_id = $1', [vacancy.id] ); return { ...vacancy, applicantsCount: parseInt(candidatesResult[0]?.count || 0) }; } catch (candidateErr) { console.error(`Error counting candidates for vacancy ${vacancy.id}:`, candidateErr); return { ...vacancy, applicantsCount: 0 }; } })); res.json(vacanciesWithCounts); } catch (err) { console.error('Error fetching vacancies:', err); // В режиме разработки возвращаем детальную информацию об ошибке const errorMessage = process.env.NODE_ENV === 'production' ? 'Failed to fetch vacancies' : `Failed to fetch vacancies: ${err.message}`; res.status(500).json({ error: errorMessage, details: process.env.NODE_ENV !== 'production' ? err.stack : undefined }); } }); // GET /api/vacancies/:id - получить вакансию по ID app.get(`${API_PREFIX}/vacancies/:id`, async (req, res) => { try { const { id } = req.params; const result = await query('SELECT * FROM vacancies WHERE id = $1', [id]); if (result.length === 0) { return res.status(404).json({ error: 'Вакансия не найдена' }); } // Подсчитываем количество кандидатов const candidatesResult = await query( 'SELECT COUNT(*) as count FROM candidates WHERE vacancy_id = $1', [id] ); const vacancy = { ...result[0], applicantsCount: parseInt(candidatesResult[0]?.count || 0) }; res.json(vacancy); } catch (err) { console.error('Error fetching vacancy:', err); res.status(500).json({ error: 'Failed to fetch vacancy' }); } }); // POST /api/vacancies - создать вакансию app.post(`${API_PREFIX}/vacancies`, async (req, res) => { try { const { id, position, department, status, salary, description, requirements, conditions, responsibilities, postedDate, closingDate } = req.body; if (!position || !department || !description) { return res.status(400).json({ error: 'Позиция, отдел и описание обязательны' }); } // Валидация статуса const validStatuses = ['urgent', 'active', 'paused', 'closed']; const vacancyStatus = status || 'active'; if (!validStatuses.includes(vacancyStatus)) { return res.status(400).json({ error: `Недопустимый статус: ${vacancyStatus}. Допустимые значения: ${validStatuses.join(', ')}` }); } const vacancyId = id || `vac-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const posted = postedDate || new Date().toISOString().split('T')[0]; const result = await query( `INSERT INTO vacancies (id, position, department, status, salary, description, requirements, conditions, responsibilities, posted_date, closing_date) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`, [ vacancyId, position, department, vacancyStatus, salary || null, description, requirements || null, conditions || null, responsibilities || null, posted, closingDate || null ] ); if (!result || result.length === 0) { throw new Error('Запрос выполнен, но не вернул данные'); } const created = result[0]; try { await notificationService.createNotificationForResponsibleZone(pool, 'hr', 'vacancies', { type: 'vacancy', title: 'Новая вакансия', body: `Создана вакансия: ${position} (${department})`, entityType: 'vacancy', entityId: vacancyId, }); } catch (notifErr) { console.warn('Responsibility zone notification (hr/vacancies):', notifErr.message); } res.status(201).json({ ...created, applicantsCount: 0 }); } catch (err) { console.error('Error creating vacancy:', err); console.error('Error details:', { message: err.message, code: err.code, detail: err.detail, constraint: err.constraint, table: err.table, column: err.column }); // Отправляем детальную информацию об ошибке let errorMessage = 'Failed to create vacancy'; let statusCode = 500; if (err.code === '23505') { errorMessage = 'Вакансия с таким ID уже существует'; statusCode = 409; } else if (err.code === '23502') { errorMessage = `Обязательное поле не заполнено: ${err.column || 'неизвестное поле'}`; statusCode = 400; } else if (err.code === '23514') { errorMessage = `Нарушение ограничения: ${err.constraint || err.message}`; statusCode = 400; } else if (err.code === '42P01') { errorMessage = 'Таблица vacancies не найдена в базе данных'; statusCode = 500; } else if (err.code === '42703') { errorMessage = `Ошибка в структуре таблицы: ${err.message}`; statusCode = 500; } else if (err.message) { errorMessage = err.message; // Если это ошибка валидации статуса ENUM if (err.message.includes('invalid input value for enum')) { errorMessage = 'Недопустимое значение статуса. Допустимые: urgent, active, paused, closed'; statusCode = 400; } } res.status(statusCode).json({ error: errorMessage, details: process.env.NODE_ENV === 'development' ? { message: err.message, code: err.code, detail: err.detail, stack: err.stack } : undefined }); } }); // PUT /api/vacancies/:id - обновить вакансию app.put(`${API_PREFIX}/vacancies/:id`, async (req, res) => { try { const { id } = req.params; const { position, department, status, salary, description, requirements, conditions, responsibilities, postedDate, closingDate } = req.body; // Проверяем существование вакансии const existing = await query('SELECT id FROM vacancies WHERE id = $1', [id]); if (existing.length === 0) { return res.status(404).json({ error: 'Вакансия не найдена' }); } const updateFields = []; const values = []; let paramIndex = 1; if (position !== undefined) { updateFields.push(`position = $${paramIndex++}`); values.push(position); } if (department !== undefined) { updateFields.push(`department = $${paramIndex++}`); values.push(department); } if (status !== undefined) { updateFields.push(`status = $${paramIndex++}`); values.push(status); } if (salary !== undefined) { updateFields.push(`salary = $${paramIndex++}`); values.push(salary || null); } if (description !== undefined) { updateFields.push(`description = $${paramIndex++}`); values.push(description); } if (requirements !== undefined) { updateFields.push(`requirements = $${paramIndex++}`); values.push(requirements || null); } if (conditions !== undefined) { updateFields.push(`conditions = $${paramIndex++}`); values.push(conditions || null); } if (responsibilities !== undefined) { updateFields.push(`responsibilities = $${paramIndex++}`); values.push(responsibilities || null); } if (postedDate !== undefined) { updateFields.push(`posted_date = $${paramIndex++}`); values.push(postedDate); } if (closingDate !== undefined) { updateFields.push(`closing_date = $${paramIndex++}`); values.push(closingDate || null); } if (updateFields.length > 0) { updateFields.push(`updated_at = NOW()`); values.push(id); await query( `UPDATE vacancies SET ${updateFields.join(', ')} WHERE id = $${paramIndex}`, values ); } // Получаем обновленную вакансию const result = await query('SELECT * FROM vacancies WHERE id = $1', [id]); const candidatesResult = await query( 'SELECT COUNT(*) as count FROM candidates WHERE vacancy_id = $1', [id] ); res.json({ ...result[0], applicantsCount: parseInt(candidatesResult[0]?.count || 0) }); } catch (err) { console.error('Error updating vacancy:', err); res.status(500).json({ error: 'Failed to update vacancy' }); } }); // DELETE /api/vacancies/:id - удалить вакансию app.delete(`${API_PREFIX}/vacancies/:id`, async (req, res) => { try { const { id } = req.params; const result = await query('DELETE FROM vacancies WHERE id = $1 RETURNING *', [id]); if (result.length === 0) { return res.status(404).json({ error: 'Вакансия не найдена' }); } res.json({ message: 'Вакансия удалена', vacancy: result[0] }); } catch (err) { console.error('Error deleting vacancy:', err); res.status(500).json({ error: 'Failed to delete vacancy' }); } }); // ========= HR: КАНДИДАТЫ ========= // GET /api/candidates - получить всех кандидатов app.get(`${API_PREFIX}/candidates`, async (req, res) => { try { const { vacancyId, stage, includeEvents } = req.query; let queryText = 'SELECT * FROM candidates WHERE 1=1'; const params = []; if (vacancyId) { queryText += ` AND vacancy_id = $${params.length + 1}`; params.push(vacancyId); } if (stage) { queryText += ` AND stage = $${params.length + 1}`; params.push(stage); } queryText += ' ORDER BY created_at DESC'; const rows = await query(queryText, params); // Если запрошены события, загружаем их для каждого кандидата if (includeEvents === 'true') { try { for (const candidate of rows) { try { const events = await query( `SELECT id, candidate_id AS "candidateId", event_type AS "eventType", event_date AS "eventDate", notes, result, interviewer, location, duration_minutes AS "durationMinutes", created_at AS "createdAt", updated_at AS "updatedAt" FROM candidate_events WHERE candidate_id = $1 ORDER BY event_date DESC`, [candidate.id] ); candidate.events = events; } catch (eventError) { // Если таблица candidate_events не существует, просто не добавляем события if (eventError.code === '42P01') { console.warn(`Таблица candidate_events не существует. Миграция будет выполнена автоматически при следующем перезапуске сервера.`); candidate.events = []; } else { throw eventError; } } } } catch (err) { // Если таблица не существует, просто продолжаем без событий if (err.code === '42P01') { console.warn(`Таблица candidate_events не существует. Выполните миграцию migrate_candidate_events.sql`); } else { throw err; } } } res.json(rows); } catch (err) { console.error('Error fetching candidates:', err); res.status(500).json({ error: 'Failed to fetch candidates' }); } }); // POST /api/candidates - создать кандидата app.post(`${API_PREFIX}/candidates`, async (req, res) => { try { const { id, name, position, vacancyId, stage, phone, email, resumeUrl, coverLetter, offerSalary, hiredDate } = req.body; if (!name || !position || !phone) { return res.status(400).json({ error: 'Имя, позиция и телефон обязательны' }); } const candidateId = id || `cand-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const candidateStage = stage || 'new'; const result = await query( `INSERT INTO candidates (id, name, position, vacancy_id, stage, phone, email, resume_url, cover_letter, offer_salary, hired_date) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`, [ candidateId, name, position, vacancyId || null, candidateStage, phone, email || null, resumeUrl || null, coverLetter || null, offerSalary || null, hiredDate || null ] ); const createdCandidate = result[0]; // Если кандидат создается сразу со статусом 'hired', создаем сотрудника if (candidateStage === 'hired') { const candidateData = { name: name, position: position, phone: phone, email: email, offerSalary: offerSalary, hiredDate: hiredDate || new Date().toISOString().split('T')[0], vacancyId: vacancyId }; // Проверяем, не создан ли уже сотрудник с таким именем и телефоном const existingEmployee = await query( 'SELECT id FROM employees WHERE name = $1 AND phone = $2', [candidateData.name, candidateData.phone] ); if (existingEmployee.length === 0) { // Создаем сотрудника из кандидата const employeeId = `e-${Date.now()}`; const employeeSalary = candidateData.offerSalary || 0; await query( `INSERT INTO employees (id, name, position, phone, status, salary, registration_date) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [ employeeId, candidateData.name, candidateData.position, candidateData.phone, 'active', employeeSalary, candidateData.hiredDate ] ); console.log(`Создан сотрудник ${employeeId} из кандидата ${candidateId}`); } else { console.log(`Сотрудник с именем ${candidateData.name} и телефоном ${candidateData.phone} уже существует`); } // Закрываем вакансию, если она есть if (candidateData.vacancyId) { const today = new Date().toISOString().split('T')[0]; await query( `UPDATE vacancies SET status = 'closed', closing_date = $1, updated_at = NOW() WHERE id = $2`, [today, candidateData.vacancyId] ); console.log(`Вакансия ${candidateData.vacancyId} закрыта кандидатом ${candidateId}`); } } res.status(201).json(createdCandidate); } catch (err) { console.error('Error creating candidate:', err); res.status(500).json({ error: 'Failed to create candidate' }); } }); // PUT /api/candidates/:id - обновить кандидата app.put(`${API_PREFIX}/candidates/:id`, async (req, res) => { try { const { id } = req.params; const { name, position, vacancyId, stage, phone, email, resumeUrl, coverLetter, interviewDate, interviewNotes, offerSalary, offerDate, hiredDate, rejectedReason } = req.body; // Получаем текущего кандидата для проверки предыдущего статуса const existingCandidate = await query('SELECT * FROM candidates WHERE id = $1', [id]); if (existingCandidate.length === 0) { return res.status(404).json({ error: 'Кандидат не найден' }); } const currentCandidate = existingCandidate[0]; const previousStage = currentCandidate.stage; const newStage = stage !== undefined ? stage : currentCandidate.stage; const updateFields = []; const values = []; let paramIndex = 1; if (name !== undefined) { updateFields.push(`name = $${paramIndex++}`); values.push(name); } if (position !== undefined) { updateFields.push(`position = $${paramIndex++}`); values.push(position); } if (vacancyId !== undefined) { updateFields.push(`vacancy_id = $${paramIndex++}`); values.push(vacancyId || null); } if (stage !== undefined) { updateFields.push(`stage = $${paramIndex++}`); values.push(stage); } if (phone !== undefined) { updateFields.push(`phone = $${paramIndex++}`); values.push(phone); } if (email !== undefined) { updateFields.push(`email = $${paramIndex++}`); values.push(email || null); } if (resumeUrl !== undefined) { updateFields.push(`resume_url = $${paramIndex++}`); values.push(resumeUrl || null); } if (coverLetter !== undefined) { updateFields.push(`cover_letter = $${paramIndex++}`); values.push(coverLetter || null); } if (interviewDate !== undefined) { updateFields.push(`interview_date = $${paramIndex++}`); values.push(interviewDate || null); } if (interviewNotes !== undefined) { updateFields.push(`interview_notes = $${paramIndex++}`); values.push(interviewNotes || null); } if (offerSalary !== undefined) { updateFields.push(`offer_salary = $${paramIndex++}`); values.push(offerSalary || null); } if (offerDate !== undefined) { updateFields.push(`offer_date = $${paramIndex++}`); values.push(offerDate || null); } if (hiredDate !== undefined) { updateFields.push(`hired_date = $${paramIndex++}`); values.push(hiredDate || null); } if (rejectedReason !== undefined) { updateFields.push(`rejected_reason = $${paramIndex++}`); values.push(rejectedReason || null); } // Если статус меняется на 'hired', автоматически устанавливаем hiredDate если он не указан if (newStage === 'hired' && hiredDate === undefined && !currentCandidate.hired_date) { updateFields.push(`hired_date = $${paramIndex++}`); values.push(new Date().toISOString().split('T')[0]); } if (updateFields.length > 0) { updateFields.push(`updated_at = NOW()`); values.push(id); await query( `UPDATE candidates SET ${updateFields.join(', ')} WHERE id = $${paramIndex}`, values ); } // Если статус изменился на 'hired', создаем сотрудника и закрываем вакансию if (newStage === 'hired' && previousStage !== 'hired') { // Получаем обновленные данные кандидата (с учетом установленного hiredDate) const updatedCandidate = await query('SELECT * FROM candidates WHERE id = $1', [id]); const finalCandidate = updatedCandidate.length > 0 ? updatedCandidate[0] : currentCandidate; const candidateData = { name: name !== undefined ? name : finalCandidate.name, position: position !== undefined ? position : finalCandidate.position, phone: phone !== undefined ? phone : finalCandidate.phone, email: email !== undefined ? email : finalCandidate.email, offerSalary: offerSalary !== undefined ? offerSalary : finalCandidate.offer_salary, hiredDate: finalCandidate.hired_date || new Date().toISOString().split('T')[0], vacancyId: vacancyId !== undefined ? vacancyId : finalCandidate.vacancy_id }; // Проверяем, не создан ли уже сотрудник с таким именем и телефоном const existingEmployee = await query( 'SELECT id FROM employees WHERE name = $1 AND phone = $2', [candidateData.name, candidateData.phone] ); if (existingEmployee.length === 0) { // Создаем сотрудника из кандидата const employeeId = `e-${Date.now()}`; const employeeSalary = candidateData.offerSalary || 0; // Используем предложенную зарплату или 0 await query( `INSERT INTO employees (id, name, position, phone, status, salary, registration_date) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [ employeeId, candidateData.name, candidateData.position, candidateData.phone, 'active', employeeSalary, candidateData.hiredDate ] ); console.log(`Создан сотрудник ${employeeId} из кандидата ${id}`); } else { console.log(`Сотрудник с именем ${candidateData.name} и телефоном ${candidateData.phone} уже существует`); } // Закрываем вакансию, если она есть if (candidateData.vacancyId) { const today = new Date().toISOString().split('T')[0]; await query( `UPDATE vacancies SET status = 'closed', closing_date = $1, updated_at = NOW() WHERE id = $2`, [today, candidateData.vacancyId] ); console.log(`Вакансия ${candidateData.vacancyId} закрыта кандидатом ${id}`); } } // Получаем обновленного кандидата const result = await query('SELECT * FROM candidates WHERE id = $1', [id]); res.json(result[0]); } catch (err) { console.error('Error updating candidate:', err); res.status(500).json({ error: 'Failed to update candidate' }); } }); // DELETE /api/candidates/:id - удалить кандидата app.delete(`${API_PREFIX}/candidates/:id`, async (req, res) => { try { const { id } = req.params; const result = await query('DELETE FROM candidates WHERE id = $1 RETURNING *', [id]); if (result.length === 0) { return res.status(404).json({ error: 'Кандидат не найден' }); } res.json({ message: 'Кандидат удален', candidate: result[0] }); } catch (err) { console.error('Error deleting candidate:', err); res.status(500).json({ error: 'Failed to delete candidate' }); } }); // GET /api/vacancies/:id/candidates - получить кандидатов по вакансии app.get(`${API_PREFIX}/vacancies/:id/candidates`, async (req, res) => { try { const { id } = req.params; const result = await query( 'SELECT * FROM candidates WHERE vacancy_id = $1 ORDER BY created_at DESC', [id] ); res.json(result); } catch (err) { console.error('Error fetching candidates for vacancy:', err); res.status(500).json({ error: 'Failed to fetch candidates' }); } }); // ========= HR: СОБЫТИЯ КАНДИДАТОВ ========= // ВАЖНО: Эти роуты должны быть зарегистрированы ПЕРЕД общим роутом /candidates/:id // чтобы Express правильно их обрабатывал // GET /api/candidates/:id/events - получить все события кандидата app.get(`${API_PREFIX}/candidates/:id/events`, async (req, res) => { try { const { id } = req.params; const result = await query( `SELECT id, candidate_id AS "candidateId", event_type AS "eventType", event_date AS "eventDate", notes, result, interviewer, location, duration_minutes AS "durationMinutes", created_at AS "createdAt", updated_at AS "updatedAt" FROM candidate_events WHERE candidate_id = $1 ORDER BY event_date DESC`, [id] ); res.json(result); } catch (err) { console.error('Error fetching candidate events:', err); if (err.code === '42P01') { return res.status(503).json({ error: 'Таблица candidate_events не существует. Перезапустите сервер - миграция выполнится автоматически, или выполните migrate_candidate_events.sql вручную' }); } res.status(500).json({ error: 'Failed to fetch candidate events' }); } }); // POST /api/candidates/:id/events - создать событие для кандидата app.post(`${API_PREFIX}/candidates/:id/events`, async (req, res) => { try { const { id } = req.params; const { eventType, eventDate, notes, result, interviewer, location, durationMinutes } = req.body; if (!eventType || !eventDate) { return res.status(400).json({ error: 'Тип события и дата обязательны' }); } // Проверяем существование кандидата и получаем его данные const candidate = await query('SELECT * FROM candidates WHERE id = $1', [id]); if (candidate.length === 0) { return res.status(404).json({ error: 'Кандидат не найден' }); } const candidateData = candidate[0]; const eventId = `evt-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; // Определяем новый статус кандидата на основе типа события let newStage = candidateData.stage; // По умолчанию оставляем текущий статус let updateStage = false; switch (eventType) { case 'call': // Созвон не меняет статус, но может быть первым шагом if (candidateData.stage === 'new') { // Можно оставить 'new' или перейти к следующему этапу } break; case 'interview_1': case 'interview_2': case 'interview_3': newStage = 'interview'; updateStage = true; break; case 'test_task': // Тестовое задание обычно после собеседования if (candidateData.stage === 'interview') { // Оставляем 'interview' } break; case 'offer': // Оффер - можно оставить текущий статус или перейти к 'probation' break; case 'offer_accepted': newStage = 'probation'; updateStage = true; break; case 'offer_rejected': newStage = 'rejected'; updateStage = true; break; case 'probation_start': newStage = 'probation'; updateStage = true; break; case 'hired': newStage = 'hired'; updateStage = true; break; case 'rejected': newStage = 'rejected'; updateStage = true; break; } // Обновляем статус кандидата если нужно if (updateStage && newStage !== candidateData.stage) { await query( `UPDATE candidates SET stage = $1, updated_at = NOW() WHERE id = $2`, [newStage, id] ); console.log(`Статус кандидата ${id} изменен с '${candidateData.stage}' на '${newStage}' через событие '${eventType}'`); } // Создаем событие let result_query; try { result_query = await query( `INSERT INTO candidate_events (id, candidate_id, event_type, event_date, notes, result, interviewer, location, duration_minutes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, candidate_id AS "candidateId", event_type AS "eventType", event_date AS "eventDate", notes, result, interviewer, location, duration_minutes AS "durationMinutes", created_at AS "createdAt", updated_at AS "updatedAt"`, [ eventId, id, eventType, eventDate, notes || null, result || 'pending', interviewer || null, location || null, durationMinutes || null ] ); } catch (dbError) { console.error('Database error creating event:', dbError); // Проверяем, может быть проблема с типами ENUM if (dbError.message && dbError.message.includes('invalid input value for enum')) { return res.status(400).json({ error: `Неверный тип события: ${eventType}. Доступные типы: call, interview_1, interview_2, interview_3, test_task, offer, offer_accepted, offer_rejected, probation_start, hired, rejected, other` }); } // Проверяем, существует ли таблица if (dbError.code === '42P01') { return res.status(503).json({ error: 'Таблица candidate_events не существует. Перезапустите сервер - миграция выполнится автоматически, или выполните migrate_candidate_events.sql вручную' }); } throw dbError; } // Если создается событие типа 'hired', создаем сотрудника if (eventType === 'hired') { // Обновляем статус кандидата на 'hired' и устанавливаем hiredDate const hiredDateValue = new Date(eventDate).toISOString().split('T')[0]; await query( `UPDATE candidates SET stage = 'hired', hired_date = $1, updated_at = NOW() WHERE id = $2`, [hiredDateValue, id] ); // Создаем сотрудника из кандидата const candidateEmployeeData = { name: candidateData.name, position: candidateData.position, phone: candidateData.phone, email: candidateData.email, offerSalary: candidateData.offer_salary, hiredDate: hiredDateValue, vacancyId: candidateData.vacancy_id }; // Проверяем, не создан ли уже сотрудник с таким именем и телефоном const existingEmployee = await query( 'SELECT id FROM employees WHERE name = $1 AND phone = $2', [candidateEmployeeData.name, candidateEmployeeData.phone] ); if (existingEmployee.length === 0) { // Создаем сотрудника из кандидата const employeeId = `e-${Date.now()}`; const employeeSalary = candidateEmployeeData.offerSalary || 0; await query( `INSERT INTO employees (id, name, position, phone, status, salary, registration_date) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [ employeeId, candidateEmployeeData.name, candidateEmployeeData.position, candidateEmployeeData.phone, 'active', employeeSalary, candidateEmployeeData.hiredDate ] ); console.log(`Создан сотрудник ${employeeId} из кандидата ${id} через событие 'hired'`); } else { console.log(`Сотрудник с именем ${candidateEmployeeData.name} и телефоном ${candidateEmployeeData.phone} уже существует`); } // Закрываем вакансию, если она есть if (candidateEmployeeData.vacancyId) { const today = new Date().toISOString().split('T')[0]; await query( `UPDATE vacancies SET status = 'closed', closing_date = $1, updated_at = NOW() WHERE id = $2`, [today, candidateEmployeeData.vacancyId] ); console.log(`Вакансия ${candidateEmployeeData.vacancyId} закрыта кандидатом ${id}`); } } res.status(201).json(result_query[0]); } catch (err) { console.error('Error creating candidate event:', err); const errorMessage = err.message || 'Failed to create candidate event'; res.status(500).json({ error: 'Failed to create candidate event', details: errorMessage }); } }); // PUT /api/candidates/:id/events/:eventId - обновить событие кандидата app.put(`${API_PREFIX}/candidates/:id/events/:eventId`, async (req, res) => { try { const { id, eventId } = req.params; const { eventType, eventDate, notes, result, interviewer, location, durationMinutes } = req.body; // Проверяем существование события let existing; try { existing = await query( 'SELECT id FROM candidate_events WHERE id = $1 AND candidate_id = $2', [eventId, id] ); } catch (dbError) { if (dbError.code === '42P01') { return res.status(503).json({ error: 'Таблица candidate_events не существует. Выполните миграцию migrate_candidate_events.sql' }); } throw dbError; } if (existing.length === 0) { return res.status(404).json({ error: 'Событие не найдено' }); } const updateFields = []; const values = []; let paramIndex = 1; if (eventType !== undefined) { updateFields.push(`event_type = $${paramIndex++}`); values.push(eventType); } if (eventDate !== undefined) { updateFields.push(`event_date = $${paramIndex++}`); values.push(eventDate); } if (notes !== undefined) { updateFields.push(`notes = $${paramIndex++}`); values.push(notes || null); } if (result !== undefined) { updateFields.push(`result = $${paramIndex++}`); values.push(result); } if (interviewer !== undefined) { updateFields.push(`interviewer = $${paramIndex++}`); values.push(interviewer || null); } if (location !== undefined) { updateFields.push(`location = $${paramIndex++}`); values.push(location || null); } if (durationMinutes !== undefined) { updateFields.push(`duration_minutes = $${paramIndex++}`); values.push(durationMinutes || null); } if (updateFields.length > 0) { updateFields.push(`updated_at = NOW()`); values.push(eventId); try { await query( `UPDATE candidate_events SET ${updateFields.join(', ')} WHERE id = $${paramIndex}`, values ); } catch (dbError) { if (dbError.code === '42P01') { return res.status(503).json({ error: 'Таблица candidate_events не существует. Выполните миграцию migrate_candidate_events.sql' }); } throw dbError; } } let result_query; try { result_query = await query( `SELECT id, candidate_id AS "candidateId", event_type AS "eventType", event_date AS "eventDate", notes, result, interviewer, location, duration_minutes AS "durationMinutes", created_at AS "createdAt", updated_at AS "updatedAt" FROM candidate_events WHERE id = $1`, [eventId] ); } catch (dbError) { if (dbError.code === '42P01') { return res.status(503).json({ error: 'Таблица candidate_events не существует. Перезапустите сервер - миграция выполнится автоматически, или выполните migrate_candidate_events.sql вручную' }); } throw dbError; } res.json(result_query[0]); } catch (err) { console.error('Error updating candidate event:', err); res.status(500).json({ error: 'Failed to update candidate event' }); } }); // DELETE /api/candidates/:id/events/:eventId - удалить событие кандидата app.delete(`${API_PREFIX}/candidates/:id/events/:eventId`, async (req, res) => { try { const { id, eventId } = req.params; let result; try { result = await query( `DELETE FROM candidate_events WHERE id = $1 AND candidate_id = $2 RETURNING id, candidate_id AS "candidateId", event_type AS "eventType", event_date AS "eventDate", notes, result, interviewer, location, duration_minutes AS "durationMinutes", created_at AS "createdAt", updated_at AS "updatedAt"`, [eventId, id] ); } catch (dbError) { if (dbError.code === '42P01') { return res.status(503).json({ error: 'Таблица candidate_events не существует. Выполните миграцию migrate_candidate_events.sql' }); } throw dbError; } if (result.length === 0) { return res.status(404).json({ error: 'Событие не найдено' }); } res.json({ message: 'Событие удалено', event: result[0] }); } catch (err) { console.error('Error deleting candidate event:', err); res.status(500).json({ error: 'Failed to delete candidate event' }); } }); // Обновляем GET /api/candidates/:id чтобы возвращать события app.get(`${API_PREFIX}/candidates/:id`, async (req, res) => { try { const { id } = req.params; const result = await query('SELECT * FROM candidates WHERE id = $1', [id]); if (result.length === 0) { return res.status(404).json({ error: 'Кандидат не найден' }); } // Получаем события кандидата let events = []; try { const eventsResult = await query( `SELECT id, candidate_id AS "candidateId", event_type AS "eventType", event_date AS "eventDate", notes, result, interviewer, location, duration_minutes AS "durationMinutes", created_at AS "createdAt", updated_at AS "updatedAt" FROM candidate_events WHERE candidate_id = $1 ORDER BY event_date DESC`, [id] ); events = eventsResult; } catch (eventError) { // Если таблица candidate_events не существует, просто возвращаем пустой массив if (eventError.code === '42P01') { console.warn(`Таблица candidate_events не существует. Миграция будет выполнена автоматически при следующем перезапуске сервера.`); events = []; } else { throw eventError; } } res.json({ ...result[0], events: events }); } catch (err) { console.error('Error fetching candidate:', err); res.status(500).json({ error: 'Failed to fetch candidate' }); } }); // Дублирующий роут удален - он уже определен выше (строка 3707) // ========= HR: ИНСТРУКТАЖИ И КУРСЫ (БЛОК ОБУЧЕНИЯ) ========= // Функция нормализации полей программы обучения (snake_case -> camelCase) function normalizeTrainingProgram(program) { if (!program) return null; return { id: program.id, title: program.title, description: program.description, type: program.type, category: program.category, durationHours: program.duration_hours || program.durationHours, validityMonths: program.validity_months || program.validityMonths, isRequired: program.is_required !== undefined ? program.is_required : program.isRequired, requiredForPositions: program.required_for_positions || program.requiredForPositions, instructorName: program.instructor_name || program.instructorName, materialsUrl: program.materials_url || program.materialsUrl, createdAt: program.created_at || program.createdAt, updatedAt: program.updated_at || program.updatedAt }; } // GET /api/training/programs - получить все программы обучения app.get(`${API_PREFIX}/training/programs`, async (req, res) => { try { // Проверяем существование таблицы const tableCheck = await query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'training_programs'` ); if (!tableCheck || tableCheck.length === 0) { return res.json([]); // Таблица еще не создана - возвращаем пустой массив } const { type, category, isRequired } = req.query; let queryText = 'SELECT * FROM training_programs WHERE 1=1'; const params = []; if (type) { queryText += ` AND type = $${params.length + 1}`; params.push(type); } if (category) { queryText += ` AND category = $${params.length + 1}`; params.push(category); } if (isRequired === 'true') { queryText += ` AND is_required = true`; } queryText += ' ORDER BY created_at DESC'; const result = await query(queryText, params); const normalized = (result || []).map(normalizeTrainingProgram); res.json(normalized); } catch (err) { console.error('Error fetching training programs:', err); // Если таблица не существует, возвращаем пустой массив if (err.code === '42P01') { return res.json([]); } res.status(500).json({ error: 'Failed to fetch training programs' }); } }); // GET /api/training/programs/:id - получить программу обучения app.get(`${API_PREFIX}/training/programs/:id`, async (req, res) => { try { // Проверяем существование таблицы const tableCheck = await query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'training_programs'` ); if (!tableCheck || tableCheck.length === 0) { return res.status(404).json({ error: 'Программа обучения не найдена' }); } const { id } = req.params; const result = await query('SELECT * FROM training_programs WHERE id = $1', [id]); if (!result || result.length === 0) { return res.status(404).json({ error: 'Программа обучения не найдена', details: `Программа с ID "${id}" не существует` }); } res.json(normalizeTrainingProgram(result[0])); } catch (err) { console.error('Error fetching training program:', err); if (err.code === '42P01') { return res.status(404).json({ error: 'Программа обучения не найдена' }); } res.status(500).json({ error: 'Failed to fetch training program' }); } }); // POST /api/training/programs - создать программу обучения app.post(`${API_PREFIX}/training/programs`, async (req, res) => { try { // Проверяем существование таблицы const tableCheck = await query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'training_programs'` ); if (!tableCheck || tableCheck.length === 0) { return res.status(503).json({ error: 'Таблица training_programs не существует. Перезапустите сервер - миграция выполнится автоматически' }); } const { id, title, description, type, category, durationHours, validityMonths, isRequired, requiredForPositions, instructorName, materialsUrl } = req.body; if (!title || !type || !category) { return res.status(400).json({ error: 'Название, тип и категория обязательны' }); } // При создании новой программы id генерируется автоматически, если не передан // Если id передан, используем его (для случаев, когда нужно задать конкретный id) const programId = id || `training-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; // Преобразуем requiredForPositions в массив, если это строка или массив let positionsArray = null; if (requiredForPositions) { if (Array.isArray(requiredForPositions)) { positionsArray = requiredForPositions; } else if (typeof requiredForPositions === 'string') { // Если строка не пустая, разбиваем по запятой const trimmed = requiredForPositions.trim(); if (trimmed) { positionsArray = trimmed.split(',').map(p => p.trim()).filter(p => p); } } } const result = await query( `INSERT INTO training_programs ( id, title, description, type, category, duration_hours, validity_months, is_required, required_for_positions, instructor_name, materials_url ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`, [ programId, title, description || null, type, category, durationHours || null, validityMonths || null, isRequired || false, positionsArray || null, instructorName || null, materialsUrl || null ] ); if (!result || result.length === 0) { return res.status(500).json({ error: 'Не удалось создать программу обучения' }); } const normalized = normalizeTrainingProgram(result[0]); console.log('[Training] Created program:', { id: normalized.id, title: normalized.title }); res.status(201).json(normalized); } catch (err) { console.error('Error creating training program:', err); // Проверяем тип ошибки if (err.code === '42P01') { return res.status(503).json({ error: 'Таблица training_programs не существует. Перезапустите сервер - миграция выполнится автоматически' }); } if (err.code === '42P02' || err.message?.includes('invalid input value for enum')) { return res.status(400).json({ error: `Неверное значение типа или категории. Доступные типы: instruction, course, certification, exam, other. Категории: safety, fire_safety, electrical, first_aid, professional, compliance, other`, details: err.message }); } res.status(500).json({ error: 'Failed to create training program', details: err.message }); } }); // PUT /api/training/programs/:id - обновить программу обучения app.put(`${API_PREFIX}/training/programs/:id`, async (req, res) => { try { // Проверяем существование таблицы const tableCheck = await query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'training_programs'` ); if (!tableCheck || tableCheck.length === 0) { return res.status(503).json({ error: 'Таблица training_programs не существует. Перезапустите сервер - миграция выполнится автоматически' }); } const { id } = req.params; const { title, description, type, category, durationHours, validityMonths, isRequired, requiredForPositions, instructorName, materialsUrl } = req.body; const updateFields = []; const values = []; let paramIndex = 1; if (title !== undefined) { updateFields.push(`title = $${paramIndex++}`); values.push(title); } if (description !== undefined) { updateFields.push(`description = $${paramIndex++}`); values.push(description || null); } if (type !== undefined) { updateFields.push(`type = $${paramIndex++}`); values.push(type); } if (category !== undefined) { updateFields.push(`category = $${paramIndex++}`); values.push(category); } if (durationHours !== undefined) { updateFields.push(`duration_hours = $${paramIndex++}`); values.push(durationHours || null); } if (validityMonths !== undefined) { updateFields.push(`validity_months = $${paramIndex++}`); values.push(validityMonths || null); } if (isRequired !== undefined) { updateFields.push(`is_required = $${paramIndex++}`); values.push(isRequired); } if (requiredForPositions !== undefined) { // Преобразуем requiredForPositions в массив, если это строка или массив let positionsArray = null; if (Array.isArray(requiredForPositions)) { positionsArray = requiredForPositions; } else if (typeof requiredForPositions === 'string') { const trimmed = requiredForPositions.trim(); if (trimmed) { positionsArray = trimmed.split(',').map(p => p.trim()).filter(p => p); } } updateFields.push(`required_for_positions = $${paramIndex++}`); values.push(positionsArray || null); } if (instructorName !== undefined) { updateFields.push(`instructor_name = $${paramIndex++}`); values.push(instructorName || null); } if (materialsUrl !== undefined) { updateFields.push(`materials_url = $${paramIndex++}`); values.push(materialsUrl || null); } if (updateFields.length === 0) { return res.status(400).json({ error: 'Нет полей для обновления' }); } updateFields.push(`updated_at = NOW()`); values.push(id); const updateResult = await query( `UPDATE training_programs SET ${updateFields.join(', ')} WHERE id = $${paramIndex}`, values ); const result = await query('SELECT * FROM training_programs WHERE id = $1', [id]); if (!result || result.length === 0) { console.error(`[Training] Program not found for update: ${id}`); return res.status(404).json({ error: 'Программа обучения не найдена', details: `Программа с ID "${id}" не существует в базе данных` }); } const normalized = normalizeTrainingProgram(result[0]); console.log('[Training] Updated program:', { id: normalized.id, title: normalized.title }); res.json(normalized); } catch (err) { console.error('Error updating training program:', err); if (err.code === '42P01') { return res.status(503).json({ error: 'Таблица training_programs не существует. Перезапустите сервер - миграция выполнится автоматически' }); } if (err.code === '42P02' || err.message?.includes('invalid input value for enum')) { return res.status(400).json({ error: `Неверное значение типа или категории`, details: err.message }); } res.status(500).json({ error: 'Failed to update training program', details: err.message }); } }); // DELETE /api/training/programs/:id - удалить программу обучения app.delete(`${API_PREFIX}/training/programs/:id`, async (req, res) => { try { const { id } = req.params; const result = await query('DELETE FROM training_programs WHERE id = $1 RETURNING *', [id]); if (result.length === 0) { return res.status(404).json({ error: 'Программа обучения не найдена' }); } res.json({ message: 'Программа обучения удалена', program: result[0] }); } catch (err) { console.error('Error deleting training program:', err); res.status(500).json({ error: 'Failed to delete training program' }); } }); // GET /api/training/employee/:employeeId - получить обучение сотрудника app.get(`${API_PREFIX}/training/employee/:employeeId`, async (req, res) => { try { // Проверяем существование таблиц const tableCheck = await query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'employee_training'` ); if (!tableCheck || tableCheck.length === 0) { return res.json([]); // Таблица еще не создана - возвращаем пустой массив } const { employeeId } = req.params; const { status, expired } = req.query; let queryText = ` SELECT et.id, et.employee_id AS "employeeId", et.program_id AS "programId", et.status, et.start_date AS "startDate", et.completion_date AS "completionDate", et.expiry_date AS "expiryDate", et.score, et.passed, et.certificate_number AS "certificateNumber", et.certificate_url AS "certificateUrl", et.notes, et.instructor_name AS "instructorName", et.location, et.created_at AS "createdAt", et.updated_at AS "updatedAt", e.name AS employee_name, e.position AS employee_position, tp.title AS program_title, tp.type AS program_type, tp.category AS program_category, tp.duration_hours AS program_duration_hours, tp.validity_months AS program_validity_months FROM employee_training et INNER JOIN employees e ON et.employee_id = e.id INNER JOIN training_programs tp ON et.program_id = tp.id WHERE et.employee_id = $1 `; const params = [employeeId]; if (status) { queryText += ` AND et.status = $${params.length + 1}`; params.push(status); } if (expired === 'true') { queryText += ` AND et.expiry_date < CURRENT_DATE`; } queryText += ' ORDER BY et.created_at DESC'; const result = await query(queryText, params); // Нормализуем поля в camelCase const normalized = (result || []).map(row => ({ id: row.id, employeeId: row.employeeId, programId: row.programId, status: row.status, startDate: row.startDate, completionDate: row.completionDate, expiryDate: row.expiryDate, score: row.score, passed: row.passed, certificateNumber: row.certificateNumber, certificateUrl: row.certificateUrl, notes: row.notes, instructorName: row.instructorName, location: row.location, createdAt: row.createdAt, updatedAt: row.updatedAt, employeeName: row.employee_name, employeePosition: row.employee_position, programTitle: row.program_title, programType: row.program_type, programCategory: row.program_category, programDurationHours: row.program_duration_hours, programValidityMonths: row.program_validity_months })); res.json(normalized); } catch (err) { console.error('Error fetching employee training:', err); // Если таблица не существует, возвращаем пустой массив if (err.code === '42P01') { return res.json([]); } res.status(500).json({ error: 'Failed to fetch employee training' }); } }); // POST /api/training/employee/:employeeId - назначить обучение сотруднику app.post(`${API_PREFIX}/training/employee/:employeeId`, async (req, res) => { try { const { employeeId } = req.params; const { programId, startDate, instructorName, location, notes } = req.body; if (!programId) { return res.status(400).json({ error: 'ID программы обучения обязателен' }); } // Проверяем существование таблицы const tableCheck = await query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'training_programs'` ); if (!tableCheck || tableCheck.length === 0) { return res.status(503).json({ error: 'Таблица training_programs не существует. Перезапустите сервер - миграция выполнится автоматически' }); } // Проверяем существование программы const program = await query('SELECT * FROM training_programs WHERE id = $1', [programId]); if (!program || program.length === 0) { console.error(`[Training] Program not found: ${programId}`); // Попробуем найти все программы для отладки const allPrograms = await query('SELECT id, title FROM training_programs LIMIT 10'); console.log('[Training] Available programs:', allPrograms.map(p => ({ id: p.id, title: p.title }))); return res.status(404).json({ error: 'Программа обучения не найдена', details: `Программа с ID "${programId}" не существует в базе данных` }); } console.log(`[Training] Assigning program ${programId} to employee ${employeeId}`); // Рассчитываем дату окончания срока действия let expiryDate = null; if (program[0].validity_months && startDate) { const start = new Date(startDate); start.setMonth(start.getMonth() + program[0].validity_months); expiryDate = start.toISOString().split('T')[0]; } // Проверяем, не назначено ли уже это обучение const existing = await query( 'SELECT id FROM employee_training WHERE employee_id = $1 AND program_id = $2', [employeeId, programId] ); let result; if (existing.length > 0) { // Обновляем существующую запись await query( `UPDATE employee_training SET start_date = $1, instructor_name = $2, location = $3, notes = $4, expiry_date = $5, status = 'in_progress', updated_at = NOW() WHERE id = $6`, [ startDate || null, instructorName || null, location || null, notes || null, expiryDate, existing[0].id ] ); result = await query('SELECT * FROM employee_training WHERE id = $1', [existing[0].id]); } else { // Создаем новую запись result = await query( `INSERT INTO employee_training ( employee_id, program_id, status, start_date, instructor_name, location, notes, expiry_date ) VALUES ($1, $2, 'in_progress', $3, $4, $5, $6, $7) RETURNING *`, [ employeeId, programId, startDate || null, instructorName || null, location || null, notes || null, expiryDate ] ); } res.status(201).json(result[0]); } catch (err) { console.error('Error assigning training:', err); res.status(500).json({ error: 'Failed to assign training' }); } }); // PUT /api/training/employee/:employeeId/:trainingId - обновить статус обучения app.put(`${API_PREFIX}/training/employee/:employeeId/:trainingId`, async (req, res) => { try { const { employeeId, trainingId } = req.params; const { status, completionDate, score, passed, certificateNumber, certificateUrl, notes } = req.body; // Проверяем существование обучения const existing = await query( 'SELECT * FROM employee_training WHERE id = $1 AND employee_id = $2', [trainingId, employeeId] ); if (existing.length === 0) { return res.status(404).json({ error: 'Обучение не найдено' }); } const updateFields = []; const values = []; let paramIndex = 1; if (status !== undefined) { updateFields.push(`status = $${paramIndex++}`); values.push(status); } if (completionDate !== undefined) { updateFields.push(`completion_date = $${paramIndex++}`); values.push(completionDate || null); } if (score !== undefined) { updateFields.push(`score = $${paramIndex++}`); values.push(score || null); } if (passed !== undefined) { updateFields.push(`passed = $${paramIndex++}`); values.push(passed); } if (certificateNumber !== undefined) { updateFields.push(`certificate_number = $${paramIndex++}`); values.push(certificateNumber || null); } if (certificateUrl !== undefined) { updateFields.push(`certificate_url = $${paramIndex++}`); values.push(certificateUrl || null); } if (notes !== undefined) { updateFields.push(`notes = $${paramIndex++}`); values.push(notes || null); } if (updateFields.length === 0) { return res.status(400).json({ error: 'Нет полей для обновления' }); } updateFields.push(`updated_at = NOW()`); values.push(trainingId); await query( `UPDATE employee_training SET ${updateFields.join(', ')} WHERE id = $${paramIndex}`, values ); // Если статус изменился на completed, автоматически устанавливаем дату завершения, если не указана if (status === 'completed' && !completionDate) { await query( `UPDATE employee_training SET completion_date = CURRENT_DATE WHERE id = $1`, [trainingId] ); } // Сохраняем в историю, если статус изменился if (status && status !== existing[0].status) { await query( `INSERT INTO employee_training_history ( employee_training_id, status, completion_date, score, passed, certificate_number, notes ) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [ trainingId, status, completionDate || null, score || null, passed || null, certificateNumber || null, notes || null ] ); } // Получаем обновленную запись с JOIN для нормализации const result = await query( `SELECT et.id, et.employee_id AS "employeeId", et.program_id AS "programId", et.status, et.start_date AS "startDate", et.completion_date AS "completionDate", et.expiry_date AS "expiryDate", et.score, et.passed, et.certificate_number AS "certificateNumber", et.certificate_url AS "certificateUrl", et.notes, et.instructor_name AS "instructorName", et.location, et.created_at AS "createdAt", et.updated_at AS "updatedAt", e.name AS employee_name, e.position AS employee_position, tp.title AS program_title, tp.type AS program_type, tp.category AS program_category, tp.duration_hours AS program_duration_hours, tp.validity_months AS program_validity_months FROM employee_training et INNER JOIN employees e ON et.employee_id = e.id INNER JOIN training_programs tp ON et.program_id = tp.id WHERE et.id = $1`, [trainingId] ); if (!result || result.length === 0) { return res.status(404).json({ error: 'Обучение не найдено' }); } // Нормализуем поля const row = result[0]; const normalized = { id: row.id, employeeId: row.employeeId, programId: row.programId, status: row.status, startDate: row.startDate, completionDate: row.completionDate, expiryDate: row.expiryDate, score: row.score, passed: row.passed, certificateNumber: row.certificateNumber, certificateUrl: row.certificateUrl, notes: row.notes, instructorName: row.instructorName, location: row.location, createdAt: row.createdAt, updatedAt: row.updatedAt, employeeName: row.employee_name, employeePosition: row.employee_position, programTitle: row.program_title, programType: row.program_type, programCategory: row.program_category, programDurationHours: row.program_duration_hours, programValidityMonths: row.program_validity_months }; res.json(normalized); } catch (err) { console.error('Error updating training:', err); res.status(500).json({ error: 'Failed to update training' }); } }); // GET /api/training/all - получить все назначенные обучения app.get(`${API_PREFIX}/training/all`, async (req, res) => { try { // Проверяем существование таблиц const tableCheck = await query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'employee_training'` ); if (!tableCheck || tableCheck.length === 0) { return res.json([]); // Таблица еще не создана - возвращаем пустой массив } const result = await query( `SELECT et.id, et.employee_id AS "employeeId", et.program_id AS "programId", et.status, et.start_date AS "startDate", et.completion_date AS "completionDate", et.expiry_date AS "expiryDate", et.score, et.passed, et.certificate_number AS "certificateNumber", et.certificate_url AS "certificateUrl", et.notes, et.instructor_name AS "instructorName", et.location, et.created_at AS "createdAt", et.updated_at AS "updatedAt", e.name AS employee_name, e.position AS employee_position, tp.title AS program_title, tp.type AS program_type, tp.category AS program_category, tp.duration_hours AS program_duration_hours, tp.validity_months AS program_validity_months FROM employee_training et INNER JOIN employees e ON et.employee_id = e.id INNER JOIN training_programs tp ON et.program_id = tp.id ORDER BY et.created_at DESC` ); // Нормализуем поля в camelCase const normalized = (result || []).map(row => ({ id: row.id, employeeId: row.employeeId, programId: row.programId, status: row.status, startDate: row.startDate, completionDate: row.completionDate, expiryDate: row.expiryDate, score: row.score, passed: row.passed, certificateNumber: row.certificateNumber, certificateUrl: row.certificateUrl, notes: row.notes, instructorName: row.instructorName, location: row.location, createdAt: row.createdAt, updatedAt: row.updatedAt, employeeName: row.employee_name, employeePosition: row.employee_position, programTitle: row.program_title, programType: row.program_type, programCategory: row.program_category, programDurationHours: row.program_duration_hours, programValidityMonths: row.program_validity_months })); res.json(normalized); } catch (err) { console.error('Error fetching all trainings:', err); // Если таблица не существует, возвращаем пустой массив if (err.code === '42P01') { return res.json([]); } res.status(500).json({ error: 'Failed to fetch all trainings' }); } }); // GET /api/training/overdue - получить просроченные обучения app.get(`${API_PREFIX}/training/overdue`, async (req, res) => { try { // Проверяем существование таблиц const tableCheck = await query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'employee_training'` ); // query() возвращает массив напрямую (res.rows), поэтому проверяем tableCheck.length if (!tableCheck || tableCheck.length === 0) { return res.json([]); // Таблица еще не создана - возвращаем пустой массив } const result = await query( `SELECT et.id, et.employee_id AS "employeeId", et.program_id AS "programId", et.status, et.start_date AS "startDate", et.completion_date AS "completionDate", et.expiry_date AS "expiryDate", et.score, et.passed, et.certificate_number AS "certificateNumber", et.certificate_url AS "certificateUrl", et.notes, et.instructor_name AS "instructorName", et.location, et.created_at AS "createdAt", et.updated_at AS "updatedAt", e.name AS employee_name, e.position AS employee_position, tp.title AS program_title, tp.type AS program_type, tp.category AS program_category, tp.duration_hours AS program_duration_hours, tp.validity_months AS program_validity_months FROM employee_training et INNER JOIN employees e ON et.employee_id = e.id INNER JOIN training_programs tp ON et.program_id = tp.id WHERE et.expiry_date < CURRENT_DATE AND et.status IN ('completed', 'in_progress') ORDER BY et.expiry_date ASC` ); // Нормализуем поля в camelCase const normalized = (result || []).map(row => ({ id: row.id, employeeId: row.employeeId, programId: row.programId, status: row.status, startDate: row.startDate, completionDate: row.completionDate, expiryDate: row.expiryDate, score: row.score, passed: row.passed, certificateNumber: row.certificateNumber, certificateUrl: row.certificateUrl, notes: row.notes, instructorName: row.instructorName, location: row.location, createdAt: row.createdAt, updatedAt: row.updatedAt, employeeName: row.employee_name, employeePosition: row.employee_position, programTitle: row.program_title, programType: row.program_type, programCategory: row.program_category, programDurationHours: row.program_duration_hours, programValidityMonths: row.program_validity_months })); res.json(normalized); } catch (err) { console.error('Error fetching overdue training:', err); // Если таблица не существует, возвращаем пустой массив вместо ошибки if (err.code === '42P01') { return res.json([]); } res.status(500).json({ error: 'Failed to fetch overdue training' }); } }); // POST /api/training/upload-report - загрузка отчета об обучении из файла (XLS/CSV) app.post(`${API_PREFIX}/training/upload-report`, upload.single('file'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: 'Файл не загружен' }); } const fileType = path.extname(req.file.originalname).toLowerCase() === '.csv' ? 'CSV' : 'XLSX'; // Парсим файл let rows = []; if (fileType === 'CSV') { rows = await fileProcessor.parseCSV(req.file.path); } else { rows = await fileProcessor.parseXLSX(req.file.path); } if (rows.length === 0) { return res.status(400).json({ error: 'Файл пуст или не может быть прочитан' }); } const processed = []; const errors = []; // Обрабатываем каждую строку for (let i = 0; i < rows.length; i++) { const row = rows[i]; try { // Ожидаемые колонки в файле: // ФИО, Программа, Тип, Категория, Дата начала, Дата завершения, Статус, Оценка, Срок действия (месяцев) const employeeName = row['ФИО'] || row['Сотрудник'] || row['Имя'] || row['employeeName']; const programTitle = row['Программа'] || row['Название'] || row['programTitle']; const programType = row['Тип'] || row['type'] || 'instruction'; const programCategory = row['Категория'] || row['category'] || 'safety'; const startDate = row['Дата начала'] || row['startDate']; const completionDate = row['Дата завершения'] || row['completionDate']; const status = row['Статус'] || row['status'] || 'completed'; const score = row['Оценка'] || row['score']; const validityMonths = row['Срок действия'] || row['validityMonths']; if (!employeeName || !programTitle) { errors.push({ row: i + 2, message: 'Не указаны ФИО сотрудника или название программы', data: row }); continue; } // Находим сотрудника const employeeResult = await query( 'SELECT id FROM employees WHERE name ILIKE $1 LIMIT 1', [`%${employeeName}%`] ); if (employeeResult.length === 0) { errors.push({ row: i + 2, message: `Сотрудник "${employeeName}" не найден`, data: row }); continue; } const employeeId = employeeResult[0].id; // Находим или создаем программу обучения let programId; const programResult = await query( 'SELECT id FROM training_programs WHERE title = $1 LIMIT 1', [programTitle] ); if (programResult.length > 0) { programId = programResult[0].id; } else { // Создаем новую программу programId = `training-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; await query( `INSERT INTO training_programs (id, title, type, category, validity_months) VALUES ($1, $2, $3, $4, $5)`, [programId, programTitle, programType, programCategory, validityMonths ? parseInt(validityMonths) : null] ); } // Рассчитываем дату окончания срока действия let expiryDate = null; if (validityMonths && completionDate) { const completion = new Date(completionDate); completion.setMonth(completion.getMonth() + parseInt(validityMonths)); expiryDate = completion.toISOString().split('T')[0]; } // Создаем или обновляем запись об обучении const existing = await query( 'SELECT id FROM employee_training WHERE employee_id = $1 AND program_id = $2', [employeeId, programId] ); if (existing.length > 0) { // Обновляем существующую запись await query( `UPDATE employee_training SET status = $1, start_date = $2, completion_date = $3, expiry_date = $4, score = $5, passed = $6, updated_at = NOW() WHERE id = $7`, [ status, startDate || null, completionDate || null, expiryDate, score ? parseFloat(score) : null, status === 'completed' ? true : (status === 'failed' ? false : null), existing[0].id ] ); } else { // Создаем новую запись await query( `INSERT INTO employee_training ( employee_id, program_id, status, start_date, completion_date, expiry_date, score, passed ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [ employeeId, programId, status, startDate || null, completionDate || null, expiryDate, score ? parseFloat(score) : null, status === 'completed' ? true : (status === 'failed' ? false : null) ] ); } processed.push({ row: i + 2, employee: employeeName, program: programTitle }); } catch (error) { errors.push({ row: i + 2, message: error.message, data: row }); } } // Удаляем файл после обработки fs.unlinkSync(req.file.path); res.json({ success: true, processed: processed.length, errors: errors.length, details: { processed, errors: errors.slice(0, 50) // Ограничиваем количество ошибок } }); } catch (error) { console.error('Error processing training report:', error); res.status(500).json({ error: 'Ошибка обработки отчета об обучении', details: error.message }); } }); // ========= PR и NPS: ENDPOINTS ========= // ========= ОТЗЫВЫ ========= // GET /api/pr/reviews - список отзывов app.get(`${API_PREFIX}/pr/reviews`, async (req, res) => { try { const { building_id, source, status } = req.query; // Проверяем существование таблицы перед запросом try { const tableCheck = await query('SELECT 1 FROM reviews LIMIT 1'); } catch (tableErr) { if (tableErr.message?.includes('relation "reviews" does not exist') || tableErr.message?.includes('does not exist')) { console.warn('[server] Таблица reviews не найдена. Возвращаем пустой массив.'); return res.json([]); } } let queryText = ` SELECT r.*, CASE WHEN b.data IS NOT NULL AND b.data->'passport' IS NOT NULL THEN b.data->'passport'->>'address' ELSE NULL END as address FROM reviews r LEFT JOIN buildings b ON r.building_id = b.id WHERE 1=1 `; const params = []; if (building_id) { queryText += ` AND r.building_id = $${params.length + 1}`; params.push(building_id); } if (source) { queryText += ` AND r.source = $${params.length + 1}`; params.push(source); } if (status) { queryText += ` AND r.status = $${params.length + 1}`; params.push(status); } queryText += ' ORDER BY r.date DESC, r.created_at DESC'; console.log(`[server] Запрос отзывов: building_id=${building_id || 'all'}, source=${source || 'all'}, status=${status || 'all'}`); const rows = await query(queryText, params); console.log(`[server] Найдено отзывов: ${rows.length}`); // Преобразуем snake_case в camelCase для фронтенда const formattedRows = rows.map(row => { try { return { id: row.id, buildingId: row.building_id || null, source: row.source || 'other', sourceUrl: row.source_url || null, authorName: row.author_name || null, text: row.text || '', rating: row.rating || 5, date: row.date || new Date().toISOString().split('T')[0], status: row.status || 'new', processedAt: row.processed_at || null, processedBy: row.processed_by || null, createdAt: row.created_at || new Date().toISOString(), updatedAt: row.updated_at || new Date().toISOString(), address: row.address || null }; } catch (mapErr) { console.error('[server] Ошибка преобразования строки отзыва:', mapErr, row); return null; } }).filter(row => row !== null); console.log(`[server] Отформатировано отзывов: ${formattedRows.length}`); res.json(formattedRows); } catch (err) { console.error('[server] Error fetching reviews:', err); console.error('[server] Stack trace:', err.stack); // Если таблица не существует, возвращаем пустой массив вместо ошибки if (err.message?.includes('relation "reviews" does not exist') || err.message?.includes('does not exist')) { console.warn('[server] Таблица reviews не найдена. Возвращаем пустой массив.'); return res.json([]); } res.status(500).json({ error: 'Failed to fetch reviews', details: err.message, stack: process.env.NODE_ENV === 'development' ? err.stack : undefined }); } }); // POST /api/pr/reviews - создание отзыва app.post(`${API_PREFIX}/pr/reviews`, async (req, res) => { try { const { building_id, source, source_url, author_name, text, rating, date } = req.body; if (!building_id || !text || !rating || !date) { return res.status(400).json({ error: 'Missing required fields' }); } const result = await query( `INSERT INTO reviews (building_id, source, source_url, author_name, text, rating, date, status) VALUES ($1, $2, $3, $4, $5, $6, $7, 'new') RETURNING *`, [building_id, source || 'internal', source_url, author_name, text, rating, date] ); res.status(201).json(result[0]); } catch (err) { console.error('Error creating review:', err); res.status(500).json({ error: 'Failed to create review' }); } }); // PUT /api/pr/reviews/:id - обновление отзыва app.put(`${API_PREFIX}/pr/reviews/:id`, async (req, res) => { try { const { id } = req.params; const { text, rating, status, processed_by } = req.body; const updates = []; const params = []; let paramIndex = 1; if (text !== undefined) { updates.push(`text = $${paramIndex++}`); params.push(text); } if (rating !== undefined) { updates.push(`rating = $${paramIndex++}`); params.push(rating); } if (status !== undefined) { updates.push(`status = $${paramIndex++}`); params.push(status); if (status === 'processed' || status === 'archived') { updates.push(`processed_at = NOW()`); if (processed_by) { updates.push(`processed_by = $${paramIndex++}`); params.push(processed_by); } } } if (updates.length === 0) { return res.status(400).json({ error: 'No fields to update' }); } updates.push(`updated_at = NOW()`); params.push(id); const result = await query( `UPDATE reviews SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`, params ); if (result.length === 0) { return res.status(404).json({ error: 'Review not found' }); } res.json(result[0]); } catch (err) { console.error('Error updating review:', err); res.status(500).json({ error: 'Failed to update review' }); } }); // PUT /api/pr/reviews/:id/status - изменение статуса app.put(`${API_PREFIX}/pr/reviews/:id/status`, async (req, res) => { try { const { id } = req.params; const { status, processed_by } = req.body; if (!status) { return res.status(400).json({ error: 'Status is required' }); } const updates = ['status = $1', 'updated_at = NOW()']; const params = [status]; if (status === 'processed' || status === 'archived') { updates.push('processed_at = NOW()'); if (processed_by) { updates.push('processed_by = $2'); params.push(processed_by); } } params.push(id); const result = await query( `UPDATE reviews SET ${updates.join(', ')} WHERE id = $${params.length} RETURNING *`, params ); if (result.length === 0) { return res.status(404).json({ error: 'Review not found' }); } res.json(result[0]); } catch (err) { console.error('Error updating review status:', err); res.status(500).json({ error: 'Failed to update review status' }); } }); // GET /api/pr/reviews/stats - статистика по отзывам app.get(`${API_PREFIX}/pr/reviews/stats`, async (req, res) => { try { const { building_id } = req.query; let queryText = ` SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE status = 'new') as new_count, COUNT(*) FILTER (WHERE status = 'processed') as processed_count, COUNT(*) FILTER (WHERE status = 'archived') as archived_count, AVG(rating) as avg_rating, COUNT(*) FILTER (WHERE rating >= 7) as positive_count, COUNT(*) FILTER (WHERE rating <= 3) as negative_count, COUNT(*) FILTER (WHERE source = 'yandex_maps') as yandex_count, COUNT(*) FILTER (WHERE source = '2gis') as gis2_count FROM reviews WHERE 1=1 `; const params = []; if (building_id) { queryText += ` AND building_id = $1`; params.push(building_id); } const result = await query(queryText, params); res.json(result[0]); } catch (err) { console.error('Error fetching review stats:', err); res.status(500).json({ error: 'Failed to fetch review stats' }); } }); // ========= ИНЦИДЕНТЫ ========= // GET /api/pr/incidents - список инцидентов app.get(`${API_PREFIX}/pr/incidents`, async (req, res) => { try { const { building_id, status, type, assigned_to, review_id } = req.query; let queryText = ` SELECT i.*, b.data->'passport'->>'address' as address, r.text as review_text, r.rating as review_rating FROM incidents i LEFT JOIN buildings b ON i.building_id = b.id LEFT JOIN reviews r ON i.review_id = r.id WHERE 1=1 `; const params = []; if (building_id) { queryText += ` AND i.building_id = $${params.length + 1}`; params.push(building_id); } if (status) { queryText += ` AND i.status = $${params.length + 1}`; params.push(status); } if (type) { queryText += ` AND i.type = $${params.length + 1}`; params.push(type); } if (assigned_to) { queryText += ` AND i.assigned_to = $${params.length + 1}`; params.push(assigned_to); } if (review_id) { queryText += ` AND i.review_id = $${params.length + 1}`; params.push(review_id); } queryText += ' ORDER BY i.created_at DESC'; const rows = await query(queryText, params); res.json(rows); } catch (err) { console.error('Error fetching incidents:', err); res.status(500).json({ error: 'Failed to fetch incidents' }); } }); // POST /api/pr/incidents - создание инцидента app.post(`${API_PREFIX}/pr/incidents`, async (req, res) => { try { const { review_id, building_id, type, title, description, priority, assigned_to, created_by } = req.body; if (!building_id || !type || !title || !description || !created_by) { return res.status(400).json({ error: 'Missing required fields' }); } const result = await query( `INSERT INTO incidents (review_id, building_id, type, title, description, priority, assigned_to, created_by, status) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'new') RETURNING *`, [review_id || null, building_id, type, title, description, priority || 'medium', assigned_to || null, created_by] ); try { const row = result[0]; const opts = { type: 'incident_new', title: 'Новый инцидент', body: (title || '').slice(0, 80) + ((title || '').length > 80 ? '…' : ''), entityType: 'incident', entityId: String(row.id) }; if (assigned_to) { const userIds = await notificationService.resolveEmployeeNamesToUserIds(pool, [assigned_to]); if (userIds.length > 0) { await notificationService.createNotificationForUserIds(pool, userIds, opts); } else { await notificationService.createNotificationForResponsibleZone(pool, 'pr', 'feedback', opts); } } else { await notificationService.createNotificationForResponsibleZone(pool, 'pr', 'feedback', opts); } } catch (notifErr) { console.warn('[notifications] incident POST:', notifErr.message); } res.status(201).json(result[0]); } catch (err) { console.error('Error creating incident:', err); res.status(500).json({ error: 'Failed to create incident' }); } }); // PUT /api/pr/incidents/:id - обновление инцидента app.put(`${API_PREFIX}/pr/incidents/:id`, async (req, res) => { try { const { id } = req.params; const { title, description, status, priority, assigned_to, resolution_notes } = req.body; const updates = []; const params = []; let paramIndex = 1; if (title !== undefined) { updates.push(`title = $${paramIndex++}`); params.push(title); } if (description !== undefined) { updates.push(`description = $${paramIndex++}`); params.push(description); } if (status !== undefined) { updates.push(`status = $${paramIndex++}`); params.push(status); if (status === 'resolved' || status === 'closed') { updates.push(`resolved_at = NOW()`); if (resolution_notes) { updates.push(`resolution_notes = $${paramIndex++}`); params.push(resolution_notes); } } } if (priority !== undefined) { updates.push(`priority = $${paramIndex++}`); params.push(priority); } if (assigned_to !== undefined) { updates.push(`assigned_to = $${paramIndex++}`); params.push(assigned_to); } if (resolution_notes !== undefined && !updates.includes('resolution_notes')) { updates.push(`resolution_notes = $${paramIndex++}`); params.push(resolution_notes); } if (updates.length === 0) { return res.status(400).json({ error: 'No fields to update' }); } updates.push(`updated_at = NOW()`); params.push(id); const result = await query( `UPDATE incidents SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`, params ); if (result.length === 0) { return res.status(404).json({ error: 'Incident not found' }); } const row = result[0]; try { const opts = { type: 'incident_assigned', title: 'Инцидент: обновление', body: (row.title || '').slice(0, 80) + ((row.title || '').length > 80 ? '…' : ''), entityType: 'incident', entityId: String(id) }; if (assigned_to !== undefined && row.assigned_to) { const userIds = await notificationService.resolveEmployeeNamesToUserIds(pool, [row.assigned_to]); if (userIds.length > 0) { await notificationService.createNotificationForUserIds(pool, userIds, opts); } else { await notificationService.createNotificationForResponsibleZone(pool, 'pr', 'feedback', opts); } } else { await notificationService.createNotificationForResponsibleZone(pool, 'pr', 'feedback', opts); } } catch (notifErr) { console.warn('[notifications] incident PUT:', notifErr.message); } res.json(row); } catch (err) { console.error('Error updating incident:', err); res.status(500).json({ error: 'Failed to update incident' }); } }); // PUT /api/pr/incidents/:id/resolve - разрешение инцидента app.put(`${API_PREFIX}/pr/incidents/:id/resolve`, async (req, res) => { try { const { id } = req.params; const { resolution_notes } = req.body; const result = await query( `UPDATE incidents SET status = 'resolved', resolved_at = NOW(), resolution_notes = $1, updated_at = NOW() WHERE id = $2 RETURNING *`, [resolution_notes || null, id] ); if (result.length === 0) { return res.status(404).json({ error: 'Incident not found' }); } res.json(result[0]); } catch (err) { console.error('Error resolving incident:', err); res.status(500).json({ error: 'Failed to resolve incident' }); } }); // POST /api/pr/incidents/from-review/:reviewId - создание инцидента из отзыва app.post(`${API_PREFIX}/pr/incidents/from-review/:reviewId`, async (req, res) => { try { const { reviewId } = req.params; const { type, title, description, priority, assigned_to, created_by } = req.body; // Получаем отзыв const reviewResult = await query('SELECT * FROM reviews WHERE id = $1', [reviewId]); if (reviewResult.length === 0) { return res.status(404).json({ error: 'Review not found' }); } const review = reviewResult[0]; // Создаем инцидент const result = await query( `INSERT INTO incidents (review_id, building_id, type, title, description, priority, assigned_to, created_by, status) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'new') RETURNING *`, [ reviewId, review.building_id, type || 'service_quality', title || `Инцидент по отзыву от ${review.author_name || 'пользователя'}`, description || review.text, priority || 'medium', assigned_to || null, created_by || 'System' ] ); // Обновляем статус отзыва await query( `UPDATE reviews SET status = 'processed', processed_at = NOW() WHERE id = $1`, [reviewId] ); res.status(201).json(result[0]); } catch (err) { console.error('Error creating incident from review:', err); res.status(500).json({ error: 'Failed to create incident from review' }); } }); // ========= ОТЧЕТЫ ЖИТЕЛЯМ ========= // GET /api/pr/reports - список отчетов (один отчет на дом - последний) app.get(`${API_PREFIX}/pr/reports`, async (req, res) => { try { const { building_id, status } = req.query; let queryText = ` SELECT DISTINCT ON (r.building_id) r.*, (b.data->'passport')->>'address' as address FROM resident_reports r LEFT JOIN buildings b ON r.building_id = b.id WHERE 1=1 `; const params = []; if (building_id) { queryText += ` AND r.building_id = $${params.length + 1}`; params.push(building_id); } // Фильтрация по статусу: если не указан явно, показываем только опубликованные отчеты // или черновики текущего месяца (созданные 1 числа текущего месяца) if (status) { queryText += ` AND r.status = $${params.length + 1}`; params.push(status); } else { // По умолчанию показываем только опубликованные отчеты или черновики текущего месяца const now = new Date(); const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); const currentMonthStartStr = currentMonthStart.toISOString().split('T')[0]; queryText += ` AND ( r.status = 'published' OR (r.status = 'draft' AND DATE(r.created_at) >= $${params.length + 1}) )`; params.push(currentMonthStartStr); } // Для DISTINCT ON нужно сначала сортировать по building_id queryText += ' ORDER BY r.building_id, r.period_start DESC, r.created_at DESC'; const rows = await query(queryText, params); // Преобразуем snake_case в camelCase для фронтенда const formattedRows = rows.map((r) => ({ id: r.id, buildingId: r.building_id, address: r.address, month: r.month, periodStart: r.period_start, periodEnd: r.period_end, status: r.status, publishedAt: r.published_at, content: (() => { try { if (typeof r.content === 'string') { return r.content ? JSON.parse(r.content) : {}; } return r.content || {}; } catch (e) { console.warn('Error parsing report content:', e); return {}; } })(), createdAt: r.created_at, updatedAt: r.updated_at })); // Живая статистика по каждому отчёту (заявки, NPS, собрано) — актуальные данные из БД const liveStatsPromises = formattedRows.map(async (row) => { const buildingId = row.buildingId; const periodStart = row.periodStart ? new Date(row.periodStart) : null; const periodEnd = row.periodEnd ? new Date(row.periodEnd) : null; if (!buildingId || !periodStart || !periodEnd) { return { applicationsTotal: 0, npsScore: 0, fundsCollected: 0 }; } const periodStartStr = periodStart.toISOString().slice(0, 10); const periodEndStr = periodEnd.toISOString().slice(0, 10); try { const [appsRes, npsRes, finRes] = await Promise.all([ query( `SELECT COUNT(*) as total FROM applications WHERE building_id = $1 AND created_at::date >= $2::date AND created_at::date <= $3::date`, [buildingId, periodStartStr, periodEndStr] ), query( `SELECT total_responses, nps_score, promoters, detractors FROM nps_building_stats WHERE building_id = $1 AND period_start = $2 AND period_end = $3 LIMIT 1`, [buildingId, periodStartStr, periodEndStr] ).then(async (npsRows) => { if (npsRows.length > 0 && parseInt(npsRows[0].total_responses) > 0) return npsRows; const liveNps = await query( `SELECT COUNT(*) as total_responses, COUNT(*) FILTER (WHERE score >= 9) as promoters, COUNT(*) FILTER (WHERE score <= 6) as detractors FROM nps_responses WHERE building_id = $1 AND (created_at::date >= $2::date AND created_at::date <= $3::date)`, [buildingId, periodStartStr, periodEndStr] ); if (liveNps.length > 0 && parseInt(liveNps[0].total_responses) > 0) { const t = parseInt(liveNps[0].total_responses); const p = parseInt(liveNps[0].promoters) || 0; const d = parseInt(liveNps[0].detractors) || 0; return [{ nps_score: Math.round((p / t * 100) - (d / t * 100)) }]; } return [{ nps_score: 0 }]; }), query( `SELECT COALESCE(SUM(total_income), 0) as total_income FROM building_financial_data WHERE building_id = $1 AND period_start::date <= $3::date AND period_end::date >= $2::date`, [buildingId, periodStartStr, periodEndStr] ) ]); const applicationsTotal = appsRes.length > 0 ? parseInt(appsRes[0].total) || 0 : 0; const npsScore = npsRes.length > 0 ? parseInt(npsRes[0].nps_score) || 0 : 0; const fundsCollected = finRes.length > 0 ? parseFloat(finRes[0].total_income) || 0 : 0; return { applicationsTotal, npsScore, fundsCollected }; } catch (e) { console.warn('[PR reports] liveStats for report', row.id, e.message); return { applicationsTotal: 0, npsScore: 0, fundsCollected: 0 }; } }); const liveStatsList = await Promise.all(liveStatsPromises); formattedRows.forEach((row, i) => { row.liveStats = liveStatsList[i] || { applicationsTotal: 0, npsScore: 0, fundsCollected: 0 }; }); res.json(formattedRows); } catch (err) { console.error('Error fetching reports:', err); res.status(500).json({ error: 'Failed to fetch reports' }); } }); // GET /api/pr/reports/:id - детали отчета app.get(`${API_PREFIX}/pr/reports/:id`, async (req, res) => { try { const { id } = req.params; const result = await query( `SELECT r.*, b.data->'passport'->>'address' as address, b.data as building_data FROM resident_reports r LEFT JOIN buildings b ON r.building_id = b.id WHERE r.id = $1`, [id] ); if (result.length === 0) { return res.status(404).json({ error: 'Report not found' }); } const report = result[0]; // Преобразуем content из JSONB в объект, если это строка if (report.content && typeof report.content === 'string') { try { report.content = JSON.parse(report.content); } catch (e) { console.warn('Error parsing report content:', e); } } // Если в контенте все нули — пробуем перегенерировать из building_financial_data (последние ненулевые данные по дому) const contentHasZeros = report.content && (() => { const c = report.content; const total = c.totals?.totalExpenses ?? 0; const hasServices = c.services?.length > 0 && c.services.some(s => (s.accrued || s.paid) > 0); const hasExpenseItems = c.expenseItems?.length > 0 && c.expenseItems.some(e => (e.total || e.perMonth) > 0); return total === 0 && !hasServices && !hasExpenseItems; })(); if (contentHasZeros && report.building_id) { const fallback = await query( `SELECT bfd.*, b.data->'passport'->>'address' as address FROM building_financial_data bfd JOIN buildings b ON bfd.building_id = b.id WHERE bfd.building_id = $1 AND (bfd.total_expenses > 0 OR (bfd.expenses_by_items IS NOT NULL AND bfd.expenses_by_items != '{}'::jsonb)) ORDER BY bfd.period_start DESC LIMIT 1`, [report.building_id] ); if (fallback.length > 0) { const data = fallback[0]; const reportRow = await query('SELECT * FROM financial_reports WHERE id = $1', [data.report_id || 0]); const reportForContent = reportRow.length > 0 ? reportRow[0] : { id: data.report_id }; report.content = await generateResidentReportContent(data, reportForContent); report.period_start = data.period_start; report.period_end = data.period_end; report.month = formatMonthFromPeriod(data.period_start, data.period_end); // Сохраняем перегенерированный контент в БД await query( `UPDATE resident_reports SET content = $1, period_start = $2, period_end = $3, month = $4, updated_at = NOW() WHERE id = $5`, [JSON.stringify(report.content), data.period_start, data.period_end, report.month, id] ); } } // Преобразуем snake_case в camelCase для фронтенда const formattedReport = { id: report.id, buildingId: report.building_id, address: report.address, month: report.month, periodStart: report.period_start, periodEnd: report.period_end, status: report.status, publishedAt: report.published_at, content: report.content, createdAt: report.created_at, updatedAt: report.updated_at }; res.json(formattedReport); } catch (err) { console.error('Error fetching report:', err); res.status(500).json({ error: 'Failed to fetch report' }); } }); // POST /api/pr/reports - создание отчета app.post(`${API_PREFIX}/pr/reports`, async (req, res) => { try { const { building_id, month, period_start, period_end, content } = req.body; if (!building_id || !month || !period_start || !period_end) { return res.status(400).json({ error: 'Missing required fields' }); } const result = await query( `INSERT INTO resident_reports (building_id, month, period_start, period_end, content, status) VALUES ($1, $2, $3, $4, $5, 'draft') RETURNING *`, [building_id, month, period_start, period_end, content ? JSON.stringify(content) : null] ); res.status(201).json(result[0]); } catch (err) { console.error('Error creating report:', err); res.status(500).json({ error: 'Failed to create report' }); } }); // PUT /api/pr/reports/:id - обновление отчета app.put(`${API_PREFIX}/pr/reports/:id`, async (req, res) => { try { const { id } = req.params; const { month, period_start, period_end, content, status } = req.body; const updates = []; const params = []; let paramIndex = 1; if (month !== undefined) { updates.push(`month = $${paramIndex++}`); params.push(month); } if (period_start !== undefined) { updates.push(`period_start = $${paramIndex++}`); params.push(period_start); } if (period_end !== undefined) { updates.push(`period_end = $${paramIndex++}`); params.push(period_end); } if (content !== undefined) { updates.push(`content = $${paramIndex++}`); params.push(JSON.stringify(content)); } if (status !== undefined) { updates.push(`status = $${paramIndex++}`); params.push(status); if (status === 'published') { updates.push(`published_at = NOW()`); } } if (updates.length === 0) { return res.status(400).json({ error: 'No fields to update' }); } updates.push(`updated_at = NOW()`); params.push(id); const result = await query( `UPDATE resident_reports SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`, params ); if (result.length === 0) { return res.status(404).json({ error: 'Report not found' }); } res.json(result[0]); } catch (err) { console.error('Error updating report:', err); res.status(500).json({ error: 'Failed to update report' }); } }); // POST /api/pr/reports/:id/generate - генерация контента отчета (AI) app.post(`${API_PREFIX}/pr/reports/:id/generate`, async (req, res) => { try { const { id } = req.params; // Получаем отчет const reportResult = await query('SELECT * FROM resident_reports WHERE id = $1', [id]); if (reportResult.length === 0) { return res.status(404).json({ error: 'Report not found' }); } const report = reportResult[0]; // Собираем данные для генерации // Заявки за период const applications = await query( `SELECT * FROM applications WHERE address = (SELECT data->>'passport'->>'address' FROM buildings WHERE id = $1) AND created_at >= $2 AND created_at <= $3`, [report.building_id, report.period_start, report.period_end] ); // Финансовые данные const financialData = await query( `SELECT * FROM building_financial_data WHERE building_id = $1 AND period_start <= $2 AND period_end >= $3 ORDER BY period_start DESC LIMIT 1`, [report.building_id, report.period_end, report.period_start] ); // Фото отчеты const workPhotos = await query( `SELECT * FROM work_photos WHERE building_id = $1 AND work_date >= $2 AND work_date <= $3`, [report.building_id, report.period_start, report.period_end] ); // Генерируем контент (заглушка - в реальности здесь будет AI) const generatedText = `Отчет за период ${report.month}. Выполнено заявок: ${applications.length}. Фото отчетов: ${workPhotos.length}.`; // Обновляем контент отчета const content = report.content ? JSON.parse(report.content) : {}; content.generatedText = generatedText; content.applications = { total: applications.length, completed: applications.filter(a => a.status === 'done').length, inProgress: applications.filter(a => a.status === 'in_progress').length, new: applications.filter(a => a.status === 'new').length }; content.workPhotos = workPhotos; if (financialData.length > 0) { const fin = financialData[0]; content.finances = { income: parseFloat(fin.total_income) || 0, expenses: parseFloat(fin.total_expenses) || 0, balance: parseFloat(fin.balance) || 0 }; } await query( `UPDATE resident_reports SET content = $1, updated_at = NOW() WHERE id = $2`, [JSON.stringify(content), id] ); res.json({ success: true, content }); } catch (err) { console.error('Error generating report content:', err); res.status(500).json({ error: 'Failed to generate report content' }); } }); // POST /api/pr/reports/bulk-create - массовое создание отчетов для всех домов app.post(`${API_PREFIX}/pr/reports/bulk-create`, async (req, res) => { try { const { month, period_start, period_end, building_ids } = req.body; if (!period_start || !period_end) { return res.status(400).json({ error: 'period_start and period_end are required' }); } // Получаем список домов let targetBuildings; if (building_ids && Array.isArray(building_ids) && building_ids.length > 0) { const placeholders = building_ids.map((_, i) => `$${i + 1}`).join(', '); const result = await query( `SELECT id FROM buildings WHERE id IN (${placeholders})`, building_ids ); targetBuildings = result.map(row => row.id); } else { const result = await query('SELECT id FROM buildings'); targetBuildings = result.map(row => row.id); } const createdReports = []; // Создаем отчеты для каждого дома for (const buildingId of targetBuildings) { try { // Проверяем, существует ли уже отчет за этот период const existing = await query( `SELECT id FROM resident_reports WHERE building_id = $1 AND period_start = $2 AND period_end = $3`, [buildingId, period_start, period_end] ); if (existing.length > 0) { // Обновляем существующий отчет const reportId = existing[0].id; // Генерируем данные для отчета const reportData = await query( `SELECT * FROM resident_reports WHERE id = $1`, [reportId] ); if (reportData.length > 0) { // Обновляем месяц, если изменился await query( `UPDATE resident_reports SET month = $1, updated_at = NOW() WHERE id = $2`, [month || formatMonthFromPeriod(period_start, period_end), reportId] ); } createdReports.push({ buildingId, reportId, updated: true }); } else { // Создаем новый отчет const reportMonth = month || formatMonthFromPeriod(period_start, period_end); const newReport = await query( `INSERT INTO resident_reports (building_id, month, period_start, period_end, status, content) VALUES ($1, $2, $3, $4, 'draft', '{}'::jsonb) RETURNING id`, [buildingId, reportMonth, period_start, period_end] ); const reportId = newReport[0].id; // Генерируем контент отчета try { await query( `UPDATE resident_reports SET content = ( SELECT jsonb_build_object( 'building', jsonb_build_object( 'id', b.id, 'address', COALESCE(b.data->'passport'->>'address', b.data->'passport'->'general'->>'address', 'Адрес не указан'), 'imageUrl', COALESCE(b.data->>'imageUrl', '') ), 'period', jsonb_build_object( 'start', $2::text, 'end', $3::text ), 'stats', jsonb_build_object( 'appsQuality', 0, 'appsTotal', 0, 'appsCompleted', 0, 'tasksTotal', 0, 'tasksCompleted', 0, 'fundsCollected', 0, 'fundsSpent', 0, 'fundsBalance', 0, 'debtCasesWon', 0, 'debtCollected', 0 ), 'expenses', jsonb_build_object( 'total', 0, 'byCategory', jsonb_build_object( 'utilities', 0, 'maintenance', 0, 'management', 0, 'other', 0 ) ), 'events', '[]'::jsonb, 'nps', jsonb_build_object( 'score', 0, 'avgScore', 0, 'totalResponses', 0, 'promoters', 0, 'passives', 0, 'detractors', 0 ), 'workPhotos', '[]'::jsonb, 'planItems', '[]'::jsonb ) FROM buildings b WHERE b.id = $1 ), updated_at = NOW() WHERE id = $4`, [buildingId, period_start, period_end, reportId] ); } catch (genErr) { console.error(`Error generating content for report ${reportId}:`, genErr); // Продолжаем, даже если генерация контента не удалась } createdReports.push({ buildingId, reportId, updated: false }); } } catch (err) { console.error(`Error creating report for building ${buildingId}:`, err); // Продолжаем создание отчетов для других домов } } res.json({ success: true, reportsCreated: createdReports.length, reports: createdReports }); } catch (err) { console.error('Error bulk creating reports:', err); res.status(500).json({ error: 'Failed to create reports', details: err.message }); } }); // Вспомогательная функция для форматирования месяца function formatMonthFromPeriod(start, end) { try { const startDate = new Date(start); const months = [ 'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь' ]; return `${months[startDate.getMonth()]} ${startDate.getFullYear()}`; } catch { return 'Неизвестный период'; } } // POST /api/pr/reports/:id/publish - публикация отчета (обновляет данные и публикует) app.post(`${API_PREFIX}/pr/reports/:id/publish`, async (req, res) => { try { const { id } = req.params; // Получаем отчет const reportResult = await query('SELECT * FROM resident_reports WHERE id = $1', [id]); if (reportResult.length === 0) { return res.status(404).json({ error: 'Report not found' }); } const report = reportResult[0]; // Обновляем данные отчета перед публикацией // Используем логику из GET /api/pr/reports/:id/data для получения актуальных данных const periodStart = new Date(report.period_start); const periodEnd = new Date(report.period_end); // Собираем актуальные данные (упрощенная версия логики из /data endpoint) const buildingResult = await query('SELECT * FROM buildings WHERE id = $1', [report.building_id]); const building = buildingResult.length > 0 ? buildingResult[0] : null; const buildingData = building?.data || {}; const buildingAddress = buildingData?.passport?.address || buildingData?.passport?.general?.address || 'Адрес не указан'; const buildingImage = building?.data?.imageUrl || building?.imageUrl || ''; // Качество отработки заявок const appsResult = await query( `SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE status = 'done' AND completed_at IS NOT NULL) as completed, COUNT(*) FILTER (WHERE status = 'done' AND completed_at <= deadline_at) as completed_on_time FROM applications WHERE building_id = $1 AND created_at >= $2 AND created_at <= $3`, [report.building_id, periodStart, periodEnd] ); const appsStats = appsResult.length > 0 ? appsResult[0] : { total: 0, completed: 0, completed_on_time: 0 }; const appsQuality = appsStats.total > 0 ? Math.round((appsStats.completed_on_time / appsStats.total) * 100) : 0; // Задачи const tasksResult = await query( `SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE status = 'done') as completed FROM applications WHERE building_id = $1 AND created_at >= $2 AND created_at <= $3 AND category IS NOT NULL`, [report.building_id, periodStart, periodEnd] ); const tasksStats = tasksResult.length > 0 ? tasksResult[0] : { total: 0, completed: 0 }; // Финансы const financesResult = await query( `SELECT COALESCE(SUM(total_income), 0) as total_income, COALESCE(SUM(total_expenses), 0) as total_expenses, COALESCE(SUM(balance), 0) as balance FROM building_financial_data WHERE building_id = $1 AND period_start <= $3 AND period_end >= $2`, [report.building_id, periodStart, periodEnd] ); const finances = financesResult.length > 0 ? financesResult[0] : { total_income: 0, total_expenses: 0, balance: 0 }; // Долги const debtResult = await query( `SELECT COUNT(*) as cases_won, COALESCE(SUM(CAST(metadata->>'debt_collected' AS NUMERIC)), 0) as debt_collected FROM applications WHERE building_id = $1 AND category = 'debt_collection' AND status = 'done' AND created_at >= $2 AND created_at <= $3`, [report.building_id, periodStart, periodEnd] ); const debtStats = debtResult.length > 0 ? debtResult[0] : { cases_won: 0, debt_collected: 0 }; // Расходы по категориям const expensesResult = await query( `SELECT expenses_by_items FROM building_financial_data WHERE building_id = $1 AND period_start <= $3 AND period_end >= $2 ORDER BY period_start DESC LIMIT 1`, [report.building_id, periodStart, periodEnd] ); let expensesByCategory = { utilities: 0, maintenance: 0, management: 0, other: 0 }; if (expensesResult.length > 0 && expensesResult[0].expenses_by_items) { const expenses = expensesResult[0].expenses_by_items; const utilitiesKeywords = ['электроэнергия', 'электричество', 'водоснабжение', 'водоотведение', 'отопление', 'газ', 'коммунальные']; const maintenanceKeywords = ['ремонт', 'содержание', 'лифт', 'уборка', 'мусор', 'материалы', 'благоустройство', 'техническое обслуживание', 'инженерное оборудование', 'общедомовое имущество', 'механизированная уборка', 'расходные материалы']; const managementKeywords = ['зарплата', 'персонал', 'административ', 'управленческ', 'страхование', 'управление', 'судебные издержки', 'услуги специализирован']; Object.entries(expenses).forEach(([item, amount]) => { const itemLower = item.toLowerCase(); const amountNum = parseFloat(amount) || 0; if (utilitiesKeywords.some(kw => itemLower.includes(kw))) { expensesByCategory.utilities += amountNum; } else if (maintenanceKeywords.some(kw => itemLower.includes(kw))) { expensesByCategory.maintenance += amountNum; } else if (managementKeywords.some(kw => itemLower.includes(kw))) { expensesByCategory.management += amountNum; } else { expensesByCategory.other += amountNum; } }); } // Мероприятия — только из модуля PR Мероприятия (pr_events) const periodStartStr = periodStart.toISOString().slice(0, 10); const periodEndStr = periodEnd.toISOString().slice(0, 10); let events = []; try { let prEventsResult = []; try { prEventsResult = await query( `SELECT id, title, date, type, category, status, location, attendees_count, budget, short_plan AS "shortPlan", announcement FROM pr_events WHERE date >= $2::date AND date <= $3::date AND ( location_building_id = $1 OR EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(location_building_ids, '[]'::jsonb)) AS elem WHERE elem::text = $1) OR (location_district_id IS NOT NULL AND location_district_id = (SELECT data->>'districtId' FROM buildings WHERE id = $1 LIMIT 1)) ) ORDER BY date DESC LIMIT 20`, [report.building_id, periodStartStr, periodEndStr] ); } catch (qErr) { prEventsResult = await query( `SELECT id, title, date, type, category, status, location, attendees_count, budget, short_plan AS "shortPlan", announcement FROM pr_events WHERE location_building_id = $1 AND date >= $2::date AND date <= $3::date ORDER BY date DESC LIMIT 20`, [report.building_id, periodStartStr, periodEndStr] ); } events = (prEventsResult || []).map(row => ({ id: row.id, title: row.title || '', date: row.date, description: (row.shortPlan || row.announcement) || '', location: row.location || buildingAddress, attendeesCount: row.attendees_count != null ? parseInt(row.attendees_count, 10) : 0 })); } catch (e) { console.warn('[Report] events (pr_events):', e.message); } // NPS const npsResult = await query( `SELECT COUNT(*) as total_responses, AVG(score)::numeric(3,1) as avg_score, COUNT(*) FILTER (WHERE score >= 9) as promoters, COUNT(*) FILTER (WHERE score >= 7 AND score <= 8) as passives, COUNT(*) FILTER (WHERE score <= 6) as detractors FROM nps_responses WHERE building_id = $1 AND created_at >= $2 AND created_at <= $3`, [report.building_id, periodStart, periodEnd] ); const npsStats = npsResult.length > 0 ? npsResult[0] : { total_responses: 0, avg_score: 0, promoters: 0, passives: 0, detractors: 0 }; const nps = npsStats.total_responses > 0 ? Math.round((npsStats.promoters / npsStats.total_responses * 100) - (npsStats.detractors / npsStats.total_responses * 100)) : 0; // Фото отчеты (до/после по задачам, привязка — task_id, опц. taskTitle из applications) const photosResult = await query( `SELECT w.id, w.work_name, w.work_date, w.description, w.photo_before_url, w.photo_after_url, w.task_id, a.description AS task_title, a.number AS task_number FROM work_photos w LEFT JOIN applications a ON (a.id::text = w.task_id OR a.number = w.task_id) WHERE w.building_id = $1 AND w.work_date >= $2 AND w.work_date <= $3 ORDER BY w.work_date DESC`, [report.building_id, periodStart, periodEnd] ); const workPhotos = photosResult.map(row => ({ id: row.id, workName: row.work_name, workDate: row.work_date, description: row.description, photoBeforeUrl: row.photo_before_url, photoAfterUrl: row.photo_after_url, taskId: row.task_id || null, taskTitle: row.task_title || row.task_number || null })); // План работ на следующий месяц const nextMonthStart = new Date(periodEnd); nextMonthStart.setMonth(nextMonthStart.getMonth() + 1); nextMonthStart.setDate(1); const nextMonthEnd = new Date(nextMonthStart.getFullYear(), nextMonthStart.getMonth() + 1, 0); const planResult = await query( `SELECT id, title, description, deadline_at, category, status FROM applications WHERE building_id = $1 AND deadline_at >= $2 AND deadline_at <= $3 AND status != 'done' ORDER BY deadline_at ASC LIMIT 10`, [report.building_id, nextMonthStart, nextMonthEnd] ); const planItems = planResult.map(row => ({ id: row.id, title: row.title || row.description, description: row.description, deadline: row.deadline_at, category: row.category, status: row.status })); // Настройки компании const companyResult = await query('SELECT * FROM company_settings WHERE id = 1 LIMIT 1'); const company = companyResult.length > 0 ? companyResult[0] : null; // Формируем обновленный контент const updatedContent = { building: { id: report.building_id, address: buildingAddress, imageUrl: buildingImage }, company: company ? { name: company.name, fullName: company.full_name, address: company.address, phone: company.phone, email: company.email, website: company.website, licenseNumber: company.license_number, licenseValidUntil: company.license_valid_until, logoUrl: company.logo_url } : null, period: { start: periodStart.toISOString(), end: periodEnd.toISOString() }, stats: { appsQuality: appsQuality, appsTotal: parseInt(appsStats.total) || 0, appsCompleted: parseInt(appsStats.completed) || 0, tasksTotal: parseInt(tasksStats.total) || 0, tasksCompleted: parseInt(tasksStats.completed) || 0, fundsCollected: parseFloat(finances.total_income) || 0, fundsSpent: parseFloat(finances.total_expenses) || 0, fundsBalance: parseFloat(finances.balance) || 0, debtCasesWon: parseInt(debtStats.cases_won) || 0, debtCollected: parseFloat(debtStats.debt_collected) || 0 }, expenses: { total: parseFloat(finances.total_expenses) || 0, byCategory: expensesByCategory, details: expensesResult.length > 0 && expensesResult[0].expenses_by_items ? expensesResult[0].expenses_by_items : {} }, events: events, nps: { score: nps, avgScore: parseFloat(npsStats.avg_score) || 0, totalResponses: parseInt(npsStats.total_responses) || 0, promoters: parseInt(npsStats.promoters) || 0, passives: parseInt(npsStats.passives) || 0, detractors: parseInt(npsStats.detractors) || 0 }, workPhotos: workPhotos, planItems: planItems }; // Сохраняем NPS цифры в nps_building_stats для отображения в отчетах if (npsStats.total_responses > 0) { const periodStartDate = periodStart.toISOString().slice(0, 10); const periodEndDate = periodEnd.toISOString().slice(0, 10); const promoterPct = (npsStats.promoters / npsStats.total_responses * 100).toFixed(2); const detractorPct = (npsStats.detractors / npsStats.total_responses * 100).toFixed(2); await query( `INSERT INTO nps_building_stats (building_id, period_start, period_end, total_responses, nps_score, avg_score, promoters, passives, detractors, promoter_percent, detractor_percent, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW()) ON CONFLICT (building_id, period_start, period_end) DO UPDATE SET total_responses = $4, nps_score = $5, avg_score = $6, promoters = $7, passives = $8, detractors = $9, promoter_percent = $10, detractor_percent = $11, updated_at = NOW()`, [report.building_id, periodStartDate, periodEndDate, npsStats.total_responses, nps, parseFloat(npsStats.avg_score) || 0, npsStats.promoters, npsStats.passives, npsStats.detractors, promoterPct, detractorPct] ).catch(err => console.warn('[Publish] Ошибка сохранения NPS в nps_building_stats:', err.message)); } // Сохраняем снимок отчета в resident_report_data (дом + период: например дом 12, январь) const periodStartDate = periodStart.toISOString().slice(0, 10); const periodEndDate = periodEnd.toISOString().slice(0, 10); const periodMonth = periodStart.getMonth() + 1; const periodYear = periodStart.getFullYear(); const expensesTotalPublish = parseFloat(finances.total_expenses) || 0; await query( `INSERT INTO resident_report_data ( building_id, period_start, period_end, period_month, period_year, nps_score, nps_total_responses, nps_avg_score, nps_promoters, nps_passives, nps_detractors, apps_total, apps_completed, apps_quality, tasks_total, tasks_completed, funds_collected, funds_spent, funds_balance, debt_cases_won, debt_collected, expenses_total, expenses_by_category, events, work_photos, plan_items, snapshot, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23::jsonb, $24::jsonb, $25::jsonb, $26::jsonb, $27::jsonb, NOW() ) ON CONFLICT (building_id, period_start, period_end) DO UPDATE SET nps_score = EXCLUDED.nps_score, nps_total_responses = EXCLUDED.nps_total_responses, nps_avg_score = EXCLUDED.nps_avg_score, nps_promoters = EXCLUDED.nps_promoters, nps_passives = EXCLUDED.nps_passives, nps_detractors = EXCLUDED.nps_detractors, apps_total = EXCLUDED.apps_total, apps_completed = EXCLUDED.apps_completed, apps_quality = EXCLUDED.apps_quality, tasks_total = EXCLUDED.tasks_total, tasks_completed = EXCLUDED.tasks_completed, funds_collected = EXCLUDED.funds_collected, funds_spent = EXCLUDED.funds_spent, funds_balance = EXCLUDED.funds_balance, debt_cases_won = EXCLUDED.debt_cases_won, debt_collected = EXCLUDED.debt_collected, expenses_total = EXCLUDED.expenses_total, expenses_by_category = EXCLUDED.expenses_by_category, events = EXCLUDED.events, work_photos = EXCLUDED.work_photos, plan_items = EXCLUDED.plan_items, snapshot = EXCLUDED.snapshot, updated_at = NOW()`, [ report.building_id, periodStartDate, periodEndDate, periodMonth, periodYear, nps, parseInt(npsStats.total_responses) || 0, parseFloat(npsStats.avg_score) || 0, parseInt(npsStats.promoters) || 0, parseInt(npsStats.passives) || 0, parseInt(npsStats.detractors) || 0, parseInt(appsStats.total) || 0, parseInt(appsStats.completed) || 0, appsQuality, parseInt(tasksStats.total) || 0, parseInt(tasksStats.completed) || 0, parseFloat(finances.total_income) || 0, parseFloat(finances.total_expenses) || 0, parseFloat(finances.balance) || 0, parseInt(debtStats.cases_won) || 0, parseFloat(debtStats.debt_collected) || 0, expensesTotalPublish, JSON.stringify(expensesByCategory), JSON.stringify(events), JSON.stringify(workPhotos), JSON.stringify(planItems), JSON.stringify(updatedContent) ] ).catch(err => console.warn('[Publish] Ошибка сохранения в resident_report_data:', err.message)); // Обновляем отчет с актуальными данными и публикуем const result = await query( `UPDATE resident_reports SET status = 'published', published_at = NOW(), content = $1, updated_at = NOW() WHERE id = $2 RETURNING *`, [JSON.stringify(updatedContent), id] ); if (result.length === 0) { return res.status(404).json({ error: 'Report not found' }); } res.json(result[0]); } catch (err) { console.error('Error publishing report:', err); res.status(500).json({ error: 'Failed to publish report', details: err.message }); } }); // ========= ФОТО ОТЧЕТЫ РАБОТ ========= // Настройка multer для загрузки фото работ const workPhotosDir = path.join(__dirname, 'uploads', 'work-photos'); if (!fs.existsSync(workPhotosDir)) { fs.mkdirSync(workPhotosDir, { recursive: true }); } const workPhotoStorage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, workPhotosDir); }, filename: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase(); const uniqueName = `${uuidv4()}${ext}`; cb(null, uniqueName); } }); const uploadWorkPhoto = multer({ storage: workPhotoStorage, fileFilter: (req, file, cb) => { const allowedTypes = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; const ext = path.extname(file.originalname).toLowerCase(); if (allowedTypes.includes(ext)) { cb(null, true); } else { cb(new Error('Неподдерживаемый тип файла. Разрешены только изображения')); } }, limits: { fileSize: 10 * 1024 * 1024 // 10MB } }); // Multer для фото мероприятий (pr_event_photos) const eventPhotosDir = path.join(__dirname, 'uploads', 'event-photos'); if (!fs.existsSync(eventPhotosDir)) { fs.mkdirSync(eventPhotosDir, { recursive: true }); } const eventPhotoStorage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, eventPhotosDir); }, filename: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase() || '.jpg'; cb(null, `${uuidv4()}${ext}`); } }); const uploadEventPhoto = multer({ storage: eventPhotoStorage, fileFilter: (req, file, cb) => { const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; if (allowed.includes(path.extname(file.originalname).toLowerCase())) cb(null, true); else cb(new Error('Только изображения')); }, limits: { fileSize: 10 * 1024 * 1024 } }); // Multer для изображений постов (pr_scheduled_posts) const postImagesDir = path.join(__dirname, 'uploads', 'post-images'); if (!fs.existsSync(postImagesDir)) { fs.mkdirSync(postImagesDir, { recursive: true }); } const postImageStorage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, postImagesDir); }, filename: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase() || '.jpg'; cb(null, `${uuidv4()}${ext}`); } }); const uploadPostImage = multer({ storage: postImageStorage, fileFilter: (req, file, cb) => { const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; if (allowed.includes(path.extname(file.originalname).toLowerCase())) cb(null, true); else cb(new Error('Только изображения')); }, limits: { fileSize: 10 * 1024 * 1024 } }); // GET /api/pr/work-photos - список фото отчетов app.get(`${API_PREFIX}/pr/work-photos`, async (req, res) => { try { const { building_id, resident_report_id, task_id } = req.query; let queryText = ` SELECT w.*, b.data->'passport'->>'address' as address FROM work_photos w LEFT JOIN buildings b ON w.building_id = b.id WHERE 1=1 `; const params = []; if (building_id) { queryText += ` AND w.building_id = $${params.length + 1}`; params.push(building_id); } if (resident_report_id) { queryText += ` AND w.resident_report_id = $${params.length + 1}`; params.push(resident_report_id); } if (task_id) { queryText += ` AND w.task_id = $${params.length + 1}`; params.push(task_id); } queryText += ' ORDER BY w.work_date DESC, w.created_at DESC'; const rows = await query(queryText, params); const mapped = rows.map(r => ({ id: r.id, buildingId: r.building_id, residentReportId: r.resident_report_id || undefined, taskId: r.task_id || undefined, workName: r.work_name, workDate: r.work_date, description: r.description || undefined, photoBeforeUrl: r.photo_before_url || undefined, photoAfterUrl: r.photo_after_url || undefined, createdAt: r.created_at, updatedAt: r.updated_at, address: r.address || undefined })); res.json(mapped); } catch (err) { console.error('Error fetching work photos:', err); const msg = (err && err.message) || 'Failed to fetch work photos'; res.status(500).json({ error: msg }); } }); // GET /api/pr/work-photos/:id - один фото отчёт по id app.get(`${API_PREFIX}/pr/work-photos/:id`, async (req, res) => { try { const { id } = req.params; const rows = await query( `SELECT w.*, b.data->'passport'->>'address' as address FROM work_photos w LEFT JOIN buildings b ON w.building_id = b.id WHERE w.id = $1`, [id] ); if (!rows || rows.length === 0) { return res.status(404).json({ error: 'Work photo not found' }); } const r = rows[0]; res.json({ id: r.id, buildingId: r.building_id, residentReportId: r.resident_report_id || undefined, taskId: r.task_id || undefined, workName: r.work_name, workDate: r.work_date, description: r.description || undefined, photoBeforeUrl: r.photo_before_url || undefined, photoAfterUrl: r.photo_after_url || undefined, createdAt: r.created_at, updatedAt: r.updated_at, address: r.address || undefined }); } catch (err) { console.error('Error fetching work photo:', err); res.status(500).json({ error: 'Failed to fetch work photo' }); } }); // POST /api/pr/work-photos - создание фото отчета (с загрузкой фото) app.post(`${API_PREFIX}/pr/work-photos`, uploadWorkPhoto.fields([ { name: 'photo_before', maxCount: 1 }, { name: 'photo_after', maxCount: 1 } ]), async (req, res) => { try { const { building_id, resident_report_id, task_id, work_name, work_date, description } = req.body; if (!building_id || !work_name || !work_date) { // Удаляем загруженные файлы, если валидация не прошла if (req.files) { Object.values(req.files).flat().forEach(file => { fs.unlink(file.path, (err) => { if (err) console.error('Error deleting file:', err); }); }); } return res.status(400).json({ error: 'Missing required fields: building_id, work_name, work_date' }); } const photoBeforeUrl = req.files?.photo_before?.[0] ? `/uploads/work-photos/${req.files.photo_before[0].filename}` : null; const photoAfterUrl = req.files?.photo_after?.[0] ? `/uploads/work-photos/${req.files.photo_after[0].filename}` : null; let result; try { result = await query( `INSERT INTO work_photos (building_id, resident_report_id, task_id, work_name, work_date, description, photo_before_url, photo_after_url) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, [ building_id, resident_report_id || null, task_id || null, work_name, work_date, description || null, photoBeforeUrl, photoAfterUrl ] ); } catch (insertErr) { const msg = insertErr.message || ''; if (msg.includes('task_id') && (msg.includes('does not exist') || msg.includes('column'))) { result = await query( `INSERT INTO work_photos (building_id, resident_report_id, work_name, work_date, description, photo_before_url, photo_after_url) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, [ building_id, resident_report_id || null, work_name, work_date, description || null, photoBeforeUrl, photoAfterUrl ] ); } else { throw insertErr; } } const row = result[0]; if (!row) { return res.status(500).json({ error: 'Не удалось сохранить запись' }); } res.status(201).json({ id: row.id, buildingId: row.building_id, residentReportId: row.resident_report_id || undefined, taskId: row.task_id || undefined, workName: row.work_name, workDate: row.work_date, description: row.description || undefined, photoBeforeUrl: row.photo_before_url || undefined, photoAfterUrl: row.photo_after_url || undefined, createdAt: row.created_at, updatedAt: row.updated_at }); } catch (err) { console.error('Error creating work photo:', err); const msg = err.message || 'Failed to create work photo'; res.status(500).json({ error: msg }); } }); // PUT /api/pr/work-photos/:id - обновление фото отчета app.put(`${API_PREFIX}/pr/work-photos/:id`, uploadWorkPhoto.fields([ { name: 'photo_before', maxCount: 1 }, { name: 'photo_after', maxCount: 1 } ]), async (req, res) => { try { const { id } = req.params; const { work_name, work_date, description, resident_report_id, task_id } = req.body; // Получаем текущую запись const currentResult = await query('SELECT * FROM work_photos WHERE id = $1', [id]); if (currentResult.length === 0) { // Удаляем загруженные файлы, если запись не найдена if (req.files) { Object.values(req.files).flat().forEach(file => { fs.unlink(file.path, (err) => { if (err) console.error('Error deleting file:', err); }); }); } return res.status(404).json({ error: 'Work photo not found' }); } const current = currentResult[0]; const updates = []; const params = []; let paramIndex = 1; if (work_name !== undefined) { updates.push(`work_name = $${paramIndex++}`); params.push(work_name); } if (work_date !== undefined) { updates.push(`work_date = $${paramIndex++}`); params.push(work_date); } if (description !== undefined) { updates.push(`description = $${paramIndex++}`); params.push(description); } if (resident_report_id !== undefined) { updates.push(`resident_report_id = $${paramIndex++}`); params.push(resident_report_id); } if (task_id !== undefined) { updates.push(`task_id = $${paramIndex++}`); params.push(task_id); } // Обработка загруженных фото if (req.files?.photo_before?.[0]) { // Удаляем старое фото, если есть if (current.photo_before_url) { const oldPath = path.join(__dirname, current.photo_before_url); fs.unlink(oldPath, (err) => { if (err && err.code !== 'ENOENT') console.error('Error deleting old photo:', err); }); } updates.push(`photo_before_url = $${paramIndex++}`); params.push(`/uploads/work-photos/${req.files.photo_before[0].filename}`); } if (req.files?.photo_after?.[0]) { // Удаляем старое фото, если есть if (current.photo_after_url) { const oldPath = path.join(__dirname, current.photo_after_url); fs.unlink(oldPath, (err) => { if (err && err.code !== 'ENOENT') console.error('Error deleting old photo:', err); }); } updates.push(`photo_after_url = $${paramIndex++}`); params.push(`/uploads/work-photos/${req.files.photo_after[0].filename}`); } if (updates.length === 0) { // Удаляем загруженные файлы, если нет обновлений if (req.files) { Object.values(req.files).flat().forEach(file => { fs.unlink(file.path, (err) => { if (err) console.error('Error deleting file:', err); }); }); } return res.status(400).json({ error: 'No fields to update' }); } updates.push(`updated_at = NOW()`); params.push(id); const result = await query( `UPDATE work_photos SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`, params ); const row = result[0]; res.json({ id: row.id, buildingId: row.building_id, residentReportId: row.resident_report_id || undefined, taskId: row.task_id || undefined, workName: row.work_name, workDate: row.work_date, description: row.description || undefined, photoBeforeUrl: row.photo_before_url || undefined, photoAfterUrl: row.photo_after_url || undefined, createdAt: row.created_at, updatedAt: row.updated_at }); } catch (err) { console.error('Error updating work photo:', err); res.status(500).json({ error: 'Failed to update work photo' }); } }); // DELETE /api/pr/work-photos/:id - удаление фото отчета app.delete(`${API_PREFIX}/pr/work-photos/:id`, async (req, res) => { try { const { id } = req.params; // Получаем запись для удаления файлов const result = await query('SELECT * FROM work_photos WHERE id = $1', [id]); if (result.length === 0) { return res.status(404).json({ error: 'Work photo not found' }); } const workPhoto = result[0]; // Удаляем файлы if (workPhoto.photo_before_url) { const beforePath = path.join(__dirname, workPhoto.photo_before_url); fs.unlink(beforePath, (err) => { if (err && err.code !== 'ENOENT') console.error('Error deleting photo:', err); }); } if (workPhoto.photo_after_url) { const afterPath = path.join(__dirname, workPhoto.photo_after_url); fs.unlink(afterPath, (err) => { if (err && err.code !== 'ENOENT') console.error('Error deleting photo:', err); }); } // Удаляем запись из БД await query('DELETE FROM work_photos WHERE id = $1', [id]); res.json({ success: true }); } catch (err) { console.error('Error deleting work photo:', err); res.status(500).json({ error: 'Failed to delete work photo' }); } }); // ========= PR МЕРОПРИЯТИЯ (pr_events) ========= // GET /api/pr/events - список мероприятий app.get(`${API_PREFIX}/pr/events`, async (req, res) => { try { const { status, type, buildingId, districtId, from, to, limit = 50 } = req.query; let queryText = ` SELECT id, title, date, type, category, status, location, location_type AS "locationType", location_building_id AS "locationBuildingId", location_place_type AS "locationPlaceType", location_district_id AS "locationDistrictId", location_building_ids AS "locationBuildingIds", attendees_count AS "attendeesCount", budget, short_plan AS "shortPlan", announcement, created_at AS "createdAt", created_by AS "createdBy" FROM pr_events WHERE 1=1 `; const params = []; if (status) { queryText += ` AND status = $${params.length + 1}`; params.push(status); } if (type) { queryText += ` AND type = $${params.length + 1}`; params.push(type); } if (buildingId) { queryText += ` AND (location_building_id = $${params.length + 1} OR location_building_ids @> $${params.length + 2}::jsonb)`; params.push(buildingId, JSON.stringify([buildingId])); } if (districtId) { queryText += ` AND location_district_id = $${params.length + 1}`; params.push(districtId); } if (from) { queryText += ` AND date >= $${params.length + 1}`; params.push(from); } if (to) { queryText += ` AND date <= $${params.length + 1}`; params.push(to); } queryText += ` ORDER BY date DESC LIMIT $${params.length + 1}`; params.push(parseInt(limit) || 50); const rows = await query(queryText, params); const eventIds = rows.map(r => r.id); if (eventIds.length === 0) { return res.json(rows.map(r => ({ ...r, assignedEmployeeIds: [], photos: [] }))); } const assignees = await query( `SELECT event_id, employee_id FROM pr_event_assignees WHERE event_id = ANY($1)`, [eventIds] ); const assigneesByEvent = {}; assignees.forEach(a => { if (!assigneesByEvent[a.event_id]) assigneesByEvent[a.event_id] = []; assigneesByEvent[a.event_id].push(a.employee_id); }); const photos = await query( `SELECT id, event_id, photo_url AS "photoUrl", caption, location_type AS "locationType", location_building_id AS "locationBuildingId", created_at AS "createdAt" FROM pr_event_photos WHERE event_id = ANY($1) ORDER BY created_at`, [eventIds] ); const photosByEvent = {}; photos.forEach(p => { if (!photosByEvent[p.event_id]) photosByEvent[p.event_id] = []; photosByEvent[p.event_id].push(p); }); const list = rows.map(r => ({ ...r, assignedEmployeeIds: assigneesByEvent[r.id] || [], photos: photosByEvent[r.id] || [] })); res.json(list); } catch (err) { if (err.code === '42P01') { return res.json([]); } console.error('Error fetching pr events:', err); res.status(500).json({ error: 'Failed to fetch events', details: err.message }); } }); // GET /api/pr/events/:id - одно мероприятие с assignees и фото app.get(`${API_PREFIX}/pr/events/:id`, async (req, res) => { try { const { id } = req.params; const rows = await query( `SELECT id, title, date, type, category, status, location, location_type AS "locationType", location_building_id AS "locationBuildingId", location_place_type AS "locationPlaceType", location_district_id AS "locationDistrictId", location_building_ids AS "locationBuildingIds", attendees_count AS "attendeesCount", budget, short_plan AS "shortPlan", announcement, created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" FROM pr_events WHERE id = $1`, [id] ); if (!rows || rows.length === 0) { return res.status(404).json({ error: 'Event not found' }); } const event = rows[0]; const assignees = await query( `SELECT employee_id FROM pr_event_assignees WHERE event_id = $1`, [id] ); const photos = await query( `SELECT id, event_id, photo_url AS "photoUrl", caption, location_type AS "locationType", location_building_id AS "locationBuildingId", created_at AS "createdAt" FROM pr_event_photos WHERE event_id = $1 ORDER BY created_at`, [id] ); const invoiceRows = await query( `SELECT id, invoice_number AS "invoiceNumber", status, total_amount AS "totalAmount" FROM payment_invoices WHERE purpose_event_id = $1 ORDER BY created_at DESC`, [id] ).catch(() => []); res.json({ ...event, assignedEmployeeIds: assignees.map(a => a.employee_id), photos: photos || [], invoices: invoiceRows || [] }); } catch (err) { if (err.code === '42P01') return res.status(404).json({ error: 'Event not found' }); console.error('Error fetching pr event:', err); res.status(500).json({ error: 'Failed to fetch event', details: err.message }); } }); // POST /api/pr/events - создание мероприятия app.post(`${API_PREFIX}/pr/events`, async (req, res) => { try { const { title, date, type, category, status = 'planned', location, locationType, locationBuildingId, locationPlaceType, locationDistrictId, locationBuildingIds = [], attendeesCount = 0, budget, shortPlan, announcement, createdBy, assignedEmployeeIds = [] } = req.body; if (!title || !date || !type || !category) { return res.status(400).json({ error: 'Missing required fields: title, date, type, category' }); } const buildingIdsJson = Array.isArray(locationBuildingIds) ? JSON.stringify(locationBuildingIds) : '[]'; const result = await query( `INSERT INTO pr_events (title, date, type, category, status, location, location_type, location_building_id, location_place_type, location_district_id, location_building_ids, attendees_count, budget, short_plan, announcement, created_by, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13, $14, $15, $16, NOW()) RETURNING id, title, date, type, category, status, location, location_type AS "locationType", location_building_id AS "locationBuildingId", location_place_type AS "locationPlaceType", location_district_id AS "locationDistrictId", location_building_ids AS "locationBuildingIds", attendees_count AS "attendeesCount", budget, short_plan AS "shortPlan", announcement, created_at AS "createdAt", created_by AS "createdBy"`, [title, date, type, category, status, location || null, locationType || null, locationBuildingId || null, locationPlaceType || null, locationDistrictId || null, buildingIdsJson, parseInt(attendeesCount) || 0, budget ? parseFloat(budget) : null, shortPlan || null, announcement || null, createdBy || null] ).catch(async (err) => { if (err.message && (err.message.includes('location_place_type') || err.message.includes('location_district_id') || err.message.includes('location_building_ids'))) { return await query( `INSERT INTO pr_events (title, date, type, category, status, location, location_type, location_building_id, attendees_count, budget, short_plan, announcement, created_by, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW()) RETURNING id, title, date, type, category, status, location, location_type AS "locationType", location_building_id AS "locationBuildingId", attendees_count AS "attendeesCount", budget, short_plan AS "shortPlan", announcement, created_at AS "createdAt", created_by AS "createdBy"`, [title, date, type, category, status, location || null, locationType || null, locationBuildingId || null, parseInt(attendeesCount) || 0, budget ? parseFloat(budget) : null, shortPlan || null, announcement || null, createdBy || null] ); } throw err; }); const event = result[0]; if (Array.isArray(assignedEmployeeIds) && assignedEmployeeIds.length > 0) { for (const empId of assignedEmployeeIds) { if (empId) await query(`INSERT INTO pr_event_assignees (event_id, employee_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [event.id, empId]); } } const assignees = await query(`SELECT employee_id FROM pr_event_assignees WHERE event_id = $1`, [event.id]); try { const empIds = assignees.map(a => a.employee_id).filter(Boolean); const userIds = empIds.length > 0 ? await notificationService.resolveEmployeeIdsToUserIds(pool, empIds) : []; const opts = { type: 'pr_event', title: 'Новое мероприятие', body: (event.title || '').toString().slice(0, 150), entityType: 'pr_event', entityId: String(event.id), }; if (userIds.length > 0) { await notificationService.createNotificationForUserIds(pool, userIds, opts); } else { await notificationService.createNotificationForResponsibleZone(pool, 'pr', 'events', opts); } } catch (notifErr) { console.warn('Notification (pr event create):', notifErr.message); } res.status(201).json({ ...event, assignedEmployeeIds: assignees.map(a => a.employee_id), photos: [] }); } catch (err) { if (err.code === '42P01') return res.status(503).json({ error: 'PR events table not ready' }); console.error('Error creating pr event:', err); res.status(500).json({ error: 'Failed to create event', details: err.message }); } }); // PUT /api/pr/events/:id - обновление мероприятия app.put(`${API_PREFIX}/pr/events/:id`, async (req, res) => { try { const { id } = req.params; const { title, date, type, category, status, location, locationType, locationBuildingId, locationPlaceType, locationDistrictId, locationBuildingIds, attendeesCount, budget, shortPlan, announcement, assignedEmployeeIds } = req.body; const existing = await query('SELECT id FROM pr_events WHERE id = $1', [id]); if (!existing || existing.length === 0) { return res.status(404).json({ error: 'Event not found' }); } const updates = []; const params = []; let idx = 1; if (title !== undefined) { updates.push(`title = $${idx++}`); params.push(title); } if (date !== undefined) { updates.push(`date = $${idx++}`); params.push(date); } if (type !== undefined) { updates.push(`type = $${idx++}`); params.push(type); } if (category !== undefined) { updates.push(`category = $${idx++}`); params.push(category); } if (status !== undefined) { updates.push(`status = $${idx++}`); params.push(status); } if (location !== undefined) { updates.push(`location = $${idx++}`); params.push(location); } if (locationType !== undefined) { updates.push(`location_type = $${idx++}`); params.push(locationType); } if (locationBuildingId !== undefined) { updates.push(`location_building_id = $${idx++}`); params.push(locationBuildingId); } if (locationPlaceType !== undefined) { updates.push(`location_place_type = $${idx++}`); params.push(locationPlaceType); } if (locationDistrictId !== undefined) { updates.push(`location_district_id = $${idx++}`); params.push(locationDistrictId); } if (locationBuildingIds !== undefined) { updates.push(`location_building_ids = $${idx++}`); params.push(JSON.stringify(Array.isArray(locationBuildingIds) ? locationBuildingIds : [])); } if (attendeesCount !== undefined) { updates.push(`attendees_count = $${idx++}`); params.push(parseInt(attendeesCount)); } if (budget !== undefined) { updates.push(`budget = $${idx++}`); params.push(budget ? parseFloat(budget) : null); } if (shortPlan !== undefined) { updates.push(`short_plan = $${idx++}`); params.push(shortPlan); } if (announcement !== undefined) { updates.push(`announcement = $${idx++}`); params.push(announcement); } updates.push('updated_at = NOW()'); params.push(id); await query( `UPDATE pr_events SET ${updates.join(', ')} WHERE id = $${idx}`, params ); if (Array.isArray(assignedEmployeeIds)) { await query('DELETE FROM pr_event_assignees WHERE event_id = $1', [id]); for (const empId of assignedEmployeeIds) { if (empId) await query(`INSERT INTO pr_event_assignees (event_id, employee_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [id, empId]); } } const rows = await query( `SELECT id, title, date, type, category, status, location, location_type AS "locationType", location_building_id AS "locationBuildingId", location_place_type AS "locationPlaceType", location_district_id AS "locationDistrictId", location_building_ids AS "locationBuildingIds", attendees_count AS "attendeesCount", budget, short_plan AS "shortPlan", announcement, created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" FROM pr_events WHERE id = $1`, [id] ); const assignees = await query(`SELECT employee_id FROM pr_event_assignees WHERE event_id = $1`, [id]); const photos = await query( `SELECT id, event_id, photo_url AS "photoUrl", caption, location_type AS "locationType", location_building_id AS "locationBuildingId", created_at AS "createdAt" FROM pr_event_photos WHERE event_id = $1 ORDER BY created_at`, [id] ); try { const empIds = assignees.map(a => a.employee_id).filter(Boolean); const userIds = empIds.length > 0 ? await notificationService.resolveEmployeeIdsToUserIds(pool, empIds) : []; const opts = { type: 'pr_event', title: 'Изменение мероприятия', body: (rows[0].title || '').toString().slice(0, 150), entityType: 'pr_event', entityId: String(id), }; if (userIds.length > 0) { await notificationService.createNotificationForUserIds(pool, userIds, opts); } else { await notificationService.createNotificationForResponsibleZone(pool, 'pr', 'events', opts); } } catch (notifErr) { console.warn('Notification (pr event update):', notifErr.message); } res.json({ ...rows[0], assignedEmployeeIds: assignees.map(a => a.employee_id), photos: photos || [] }); } catch (err) { if (err.code === '42P01') return res.status(404).json({ error: 'Event not found' }); console.error('Error updating pr event:', err); res.status(500).json({ error: 'Failed to update event', details: err.message }); } }); // DELETE /api/pr/events/:id app.delete(`${API_PREFIX}/pr/events/:id`, async (req, res) => { try { const { id } = req.params; const existing = await query('SELECT id FROM pr_events WHERE id = $1', [id]); if (!existing || existing.length === 0) { return res.status(404).json({ error: 'Event not found' }); } await query('DELETE FROM pr_events WHERE id = $1', [id]); res.json({ success: true }); } catch (err) { if (err.code === '42P01') return res.status(404).json({ error: 'Event not found' }); console.error('Error deleting pr event:', err); res.status(500).json({ error: 'Failed to delete event', details: err.message }); } }); // POST /api/pr/events/:id/photos - загрузка фото мероприятия app.post(`${API_PREFIX}/pr/events/:id/photos`, uploadEventPhoto.single('photo'), async (req, res) => { try { const { id } = req.params; const { caption, locationType, locationBuildingId } = req.body || {}; const existing = await query('SELECT id FROM pr_events WHERE id = $1', [id]); if (!existing || existing.length === 0) { if (req.file && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path); return res.status(404).json({ error: 'Event not found' }); } if (!req.file) { return res.status(400).json({ error: 'No photo file' }); } const photoUrl = `/api/pr/events/${id}/photos/file/${req.file.filename}`; await query( `INSERT INTO pr_event_photos (event_id, photo_url, caption, location_type, location_building_id) VALUES ($1, $2, $3, $4, $5) RETURNING id, event_id, photo_url AS "photoUrl", caption, location_type AS "locationType", location_building_id AS "locationBuildingId", created_at AS "createdAt"`, [id, photoUrl, caption || null, locationType || null, locationBuildingId || null] ); const result = await query( `SELECT id, event_id, photo_url AS "photoUrl", caption, location_type AS "locationType", location_building_id AS "locationBuildingId", created_at AS "createdAt" FROM pr_event_photos WHERE event_id = $1 ORDER BY created_at DESC LIMIT 1`, [id] ); res.status(201).json(result[0]); } catch (err) { if (req.file && req.file.path && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path); if (err.code === '42P01') return res.status(404).json({ error: 'Event not found' }); console.error('Error uploading event photo:', err); res.status(500).json({ error: 'Failed to upload photo', details: err.message }); } }); // GET /api/pr/scheduled-posts/image/:filename - отдача файла изображения поста app.get(`${API_PREFIX}/pr/scheduled-posts/image/:filename`, (req, res) => { try { const { filename } = req.params; const filePath = path.join(postImagesDir, filename); if (!fs.existsSync(filePath)) { return res.status(404).json({ error: 'Image not found' }); } res.sendFile(filePath); } catch (err) { console.error('Error serving post image:', err); res.status(500).json({ error: 'Failed to serve image' }); } }); // GET /api/pr/events/:id/photos/file/:filename - отдача файла фото мероприятия app.get(`${API_PREFIX}/pr/events/:id/photos/file/:filename`, (req, res) => { try { const filename = req.params.filename; const filePath = path.join(eventPhotosDir, filename); if (!path.resolve(filePath).startsWith(path.resolve(eventPhotosDir)) || !fs.existsSync(filePath)) { return res.status(404).json({ error: 'File not found' }); } res.sendFile(filePath); } catch (err) { res.status(500).json({ error: 'Error serving file' }); } }); // GET /api/pr/events/:id/photos - список фото мероприятия app.get(`${API_PREFIX}/pr/events/:id/photos`, async (req, res) => { try { const { id } = req.params; const rows = await query( `SELECT id, event_id, photo_url AS "photoUrl", caption, location_type AS "locationType", location_building_id AS "locationBuildingId", created_at AS "createdAt" FROM pr_event_photos WHERE event_id = $1 ORDER BY created_at`, [id] ); res.json(rows || []); } catch (err) { if (err.code === '42P01') return res.json([]); console.error('Error fetching event photos:', err); res.status(500).json({ error: 'Failed to fetch photos', details: err.message }); } }); // DELETE /api/pr/events/:id/photos/:photoId app.delete(`${API_PREFIX}/pr/events/:id/photos/:photoId`, async (req, res) => { try { const { id, photoId } = req.params; const row = await query('SELECT id, photo_url FROM pr_event_photos WHERE id = $1 AND event_id = $2', [photoId, id]); if (!row || row.length === 0) return res.status(404).json({ error: 'Photo not found' }); const photoUrl = row[0].photo_url; const match = photoUrl && photoUrl.match(/\/file\/([^/]+)$/); if (match) { const filePath = path.join(eventPhotosDir, match[1]); if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } await query('DELETE FROM pr_event_photos WHERE id = $1', [photoId]); res.json({ success: true }); } catch (err) { console.error('Error deleting event photo:', err); res.status(500).json({ error: 'Failed to delete photo', details: err.message }); } }); // ========= НОВОСТИ КОМПАНИИ (company_news) ========= // Маппинг отделов (frontend Department) в разделы для уведомлений const DEPARTMENT_TO_SECTION = { production: 'dashboard', pr: 'pr', finance: 'finance', development: 'development', legal: 'legal', hr: 'hr' }; function mapNotifyDepartmentsToSections(notifyDepartments) { if (!Array.isArray(notifyDepartments) || notifyDepartments.length === 0) return []; const sections = new Set(); for (const d of notifyDepartments) { if (DEPARTMENT_TO_SECTION[d]) sections.add(DEPARTMENT_TO_SECTION[d]); } return Array.from(sections); } async function sendCompanyNewsNotifications(newsId, title, body, notifyDepartments, notifyEmployeeIds) { try { const sections = mapNotifyDepartmentsToSections(notifyDepartments || []); const userIdsBySection = sections.length ? await notificationService.getPortalUserIdsBySections(pool, sections) : []; const userIdsByEmployees = (notifyEmployeeIds && notifyEmployeeIds.length) ? await notificationService.resolveEmployeeIdsToUserIds(pool, notifyEmployeeIds) : []; const allUserIds = [...new Set([...userIdsBySection, ...userIdsByEmployees])]; if (allUserIds.length === 0) return 0; const shortBody = body && body.length > 80 ? body.slice(0, 80) + '…' : (body || null); return await notificationService.createNotificationForUserIds(pool, allUserIds, { type: 'company_news', title: title || 'Новость компании', body: shortBody, entityType: 'company_news', entityId: String(newsId), payload: { newsId } }); } catch (notifErr) { console.warn('[company-news] notifications on publish:', notifErr.message); return 0; } } // GET /api/company-news — список (query: status, limit, offset) app.get(`${API_PREFIX}/company-news`, async (req, res) => { try { const status = req.query.status ? String(req.query.status).trim() : null; const limit = Math.min(parseInt(req.query.limit, 10) || 50, 100); const offset = parseInt(req.query.offset, 10) || 0; let where = ''; const params = []; if (status && ['draft', 'pending', 'published'].includes(status)) { params.push(status); where = ' WHERE cn.status = $1'; } const countResult = await query( `SELECT COUNT(*)::int AS c FROM company_news cn${where}`, params ); const total = countResult[0].c; params.push(limit, offset); const orderCol = status === 'published' ? 'published_at' : 'created_at'; const rows = await query( `SELECT cn.id, cn.title, cn.body, cn.status, cn.created_at AS "createdAt", cn.updated_at AS "updatedAt", cn.published_at AS "publishedAt", cn.notify_departments AS "notifyDepartments", cn.notify_employee_ids AS "notifyEmployeeIds", cn.created_by AS "createdBy", e.name AS "createdByName" FROM company_news cn LEFT JOIN portal_users pu ON pu.id = cn.created_by LEFT JOIN employees e ON e.id = pu.employee_id ${where} ORDER BY cn.${orderCol} DESC NULLS LAST LIMIT $${params.length - 1} OFFSET $${params.length}`, params ); res.setHeader('X-Total-Count', String(total)); res.json(rows); } catch (err) { if (err.code === '42P01') return res.json([]); console.error('Error GET /company-news:', err); res.status(500).json({ error: 'Failed to list company news', details: err.message }); } }); // GET /api/company-news/:id — одна новость app.get(`${API_PREFIX}/company-news/:id`, async (req, res) => { try { const id = parseInt(req.params.id, 10); if (Number.isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); const rows = await query( `SELECT cn.id, cn.title, cn.body, cn.status, cn.created_at AS "createdAt", cn.updated_at AS "updatedAt", cn.published_at AS "publishedAt", cn.notify_departments AS "notifyDepartments", cn.notify_employee_ids AS "notifyEmployeeIds", cn.created_by AS "createdBy", e.name AS "createdByName" FROM company_news cn LEFT JOIN portal_users pu ON pu.id = cn.created_by LEFT JOIN employees e ON e.id = pu.employee_id WHERE cn.id = $1`, [id] ); if (!rows || rows.length === 0) return res.status(404).json({ error: 'News not found' }); res.json(rows[0]); } catch (err) { if (err.code === '42P01') return res.status(404).json({ error: 'News not found' }); console.error('Error GET /company-news/:id:', err); res.status(500).json({ error: 'Failed to get news', details: err.message }); } }); // POST /api/company-news — создание (требует auth) app.post(`${API_PREFIX}/company-news`, authMiddleware, async (req, res) => { try { const { title, body, status, notifyDepartments, notifyEmployeeIds } = req.body || {}; if (!title || !String(title).trim()) return res.status(400).json({ error: 'Title is required' }); const st = (status && ['draft', 'pending', 'published'].includes(status)) ? status : 'draft'; const createdBy = req.user && req.user.userId ? req.user.userId : null; const notifyDept = Array.isArray(notifyDepartments) ? notifyDepartments : []; const notifyEmp = Array.isArray(notifyEmployeeIds) ? notifyEmployeeIds : []; const rows = await query( `INSERT INTO company_news (title, body, status, created_by, updated_at, notify_departments, notify_employee_ids) VALUES ($1, $2, $3, $4, NOW(), $5::jsonb, $6::jsonb) RETURNING id, title, body, status, created_at AS "createdAt", updated_at AS "updatedAt", published_at AS "publishedAt", notify_departments AS "notifyDepartments", notify_employee_ids AS "notifyEmployeeIds", created_by AS "createdBy"`, [String(title).trim(), body != null ? String(body) : null, st, createdBy, JSON.stringify(notifyDept), JSON.stringify(notifyEmp)] ); const row = rows[0]; if (st === 'published') { await query('UPDATE company_news SET published_at = NOW() WHERE id = $1', [row.id]); row.publishedAt = new Date().toISOString(); await sendCompanyNewsNotifications(row.id, row.title, row.body, notifyDept, notifyEmp); } res.status(201).json(row); } catch (err) { if (err.code === '42P01') return res.status(503).json({ error: 'Таблица новостей ещё не создана. Перезапустите сервер.' }); console.error('Error POST /company-news:', err); res.status(500).json({ error: 'Failed to create company news', details: err.message }); } }); // PUT /api/company-news/:id — обновление (в т.ч. смена статуса на published) app.put(`${API_PREFIX}/company-news/:id`, authMiddleware, async (req, res) => { try { const id = parseInt(req.params.id, 10); if (Number.isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); const { title, body, status, notifyDepartments, notifyEmployeeIds } = req.body || {}; const existing = await query('SELECT id, status, title, body, notify_departments, notify_employee_ids FROM company_news WHERE id = $1', [id]); if (!existing || existing.length === 0) return res.status(404).json({ error: 'News not found' }); const cur = existing[0]; const newTitle = title !== undefined ? String(title).trim() : cur.title; const newBody = body !== undefined ? (body != null ? String(body) : null) : cur.body; const newStatus = (status && ['draft', 'pending', 'published'].includes(status)) ? status : cur.status; const newNotifyDept = notifyDepartments !== undefined ? (Array.isArray(notifyDepartments) ? notifyDepartments : []) : cur.notify_departments; const newNotifyEmp = notifyEmployeeIds !== undefined ? (Array.isArray(notifyEmployeeIds) ? notifyEmployeeIds : []) : cur.notify_employee_ids; const updates = ['title = $1', 'body = $2', 'status = $3', 'updated_at = NOW()', 'notify_departments = $4', 'notify_employee_ids = $5']; const values = [newTitle, newBody, newStatus, JSON.stringify(newNotifyDept), JSON.stringify(newNotifyEmp)]; if (newStatus === 'published' && cur.status !== 'published') { updates.push('published_at = NOW()'); values.push(null); } values.push(id); await query( `UPDATE company_news SET ${updates.join(', ')} WHERE id = $${values.length}`, values ); if (newStatus === 'published' && cur.status !== 'published') { await sendCompanyNewsNotifications(id, newTitle, newBody, newNotifyDept, newNotifyEmp); } const rows = await query( `SELECT cn.id, cn.title, cn.body, cn.status, cn.created_at AS "createdAt", cn.updated_at AS "updatedAt", cn.published_at AS "publishedAt", cn.notify_departments AS "notifyDepartments", cn.notify_employee_ids AS "notifyEmployeeIds", cn.created_by AS "createdBy", e.name AS "createdByName" FROM company_news cn LEFT JOIN portal_users pu ON pu.id = cn.created_by LEFT JOIN employees e ON e.id = pu.employee_id WHERE cn.id = $1`, [id] ); res.json(rows[0]); } catch (err) { if (err.code === '42P01') return res.status(404).json({ error: 'News not found' }); console.error('Error PUT /company-news/:id:', err); res.status(500).json({ error: 'Failed to update company news', details: err.message }); } }); // DELETE /api/company-news/:id app.delete(`${API_PREFIX}/company-news/:id`, authMiddleware, async (req, res) => { try { const id = parseInt(req.params.id, 10); if (Number.isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); const result = await query('DELETE FROM company_news WHERE id = $1 RETURNING id', [id]); if (result.length === 0) return res.status(404).json({ error: 'News not found' }); res.json({ success: true }); } catch (err) { if (err.code === '42P01') return res.status(404).json({ error: 'News not found' }); console.error('Error DELETE /company-news/:id:', err); res.status(500).json({ error: 'Failed to delete company news', details: err.message }); } }); // ========= PR SMM КАНАЛЫ И СНИМКИ ПОДПИСЧИКОВ ========= // GET /api/pr/smm-channels/summary — сводка по подписчикам (маршрут до /:id) app.get(`${API_PREFIX}/pr/smm-channels/summary`, async (req, res) => { try { const channels = await query( `SELECT id, name, type FROM pr_smm_channels ORDER BY sort_order ASC, id ASC` ); if (channels.length === 0) return res.json({ total: 0, byChannel: [] }); const channelIds = channels.map(c => c.id); const latestSnapshots = await query( `SELECT DISTINCT ON (channel_id) channel_id AS "channelId", subscribers_count AS "subscribersCount" FROM pr_smm_subscriber_snapshots WHERE channel_id = ANY($1) ORDER BY channel_id, recorded_at DESC`, [channelIds] ); const snapshotByChannel = {}; latestSnapshots.forEach(s => { snapshotByChannel[s.channelId] = s.subscribersCount; }); const byChannel = channels.map(c => ({ id: c.id, name: c.name, type: c.type, subscribersCount: snapshotByChannel[c.id] ?? 0 })); const total = byChannel.reduce((sum, c) => sum + (c.subscribersCount || 0), 0); res.json({ total, byChannel }); } catch (err) { if (err.code === '42P01') return res.json({ total: 0, byChannel: [] }); console.error('Error GET /pr/smm-channels/summary:', err); res.status(500).json({ error: 'Failed to get SMM summary', details: err.message }); } }); // GET /api/pr/dashboard — полный дашборд PR для продвинутой статистики и карточки сводки app.get(`${API_PREFIX}/pr/dashboard`, async (req, res) => { const now = new Date(); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); const monthStartStr = monthStart.toISOString().slice(0, 10); const todayStr = now.toISOString().slice(0, 10); const defaultData = { npsCompany: 0, npsResponsesCount: 0, npsAvgScore: 0, npsPromoters: 0, npsPassives: 0, npsDetractors: 0, csatPercent: 0, reviewsAvgRating: 0, reviewsTotal: 0, smm: { total: 0, byChannel: [] }, reviewsStats: { total: 0, new_count: 0, processed_count: 0, archived_count: 0, positive_count: 0, negative_count: 0, yandex_count: 0, gis2_count: 0 }, incidentsSummary: { total: 0, open: 0, in_progress: 0, resolved: 0 }, events: { countMonth: 0, upcoming: 0, past: 0 }, residentReports: { total: 0, publishedThisMonth: 0 }, workPhotos: { total: 0, thisMonth: 0 }, npsSurveys: { total: 0, active: 0, list: [] }, scheduledPosts: { draft: 0, pending_approval: 0, approved: 0, published: 0, rejected: 0, edited: 0 }, negative: { openIncidents: 0, negativeReviewsCount: 0 } }; try { // 1. SMM — переиспользуем логику summary let smm = { total: 0, byChannel: [] }; try { const channels = await query('SELECT id, name, type FROM pr_smm_channels ORDER BY sort_order ASC, id ASC'); if (channels.length > 0) { const channelIds = channels.map(c => c.id); const latestSnapshots = await query( `SELECT DISTINCT ON (channel_id) channel_id AS "channelId", subscribers_count AS "subscribersCount" FROM pr_smm_subscriber_snapshots WHERE channel_id = ANY($1) ORDER BY channel_id, recorded_at DESC`, [channelIds] ); const snapshotByChannel = {}; latestSnapshots.forEach(s => { snapshotByChannel[s.channelId] = s.subscribersCount; }); smm.byChannel = channels.map(c => ({ id: c.id, name: c.name, type: c.type, subscribersCount: snapshotByChannel[c.id] ?? 0 })); smm.total = smm.byChannel.reduce((sum, c) => sum + (c.subscribersCount || 0), 0); } } catch (e) { if (e.code !== '42P01') throw e; } // 2. NPS по компании — все ответы за текущий месяц let npsCompany = 0, npsResponsesCount = 0, npsAvgScore = 0, npsPromoters = 0, npsPassives = 0, npsDetractors = 0; try { const npsRes = await query(` SELECT COUNT(*) as total, AVG(score)::numeric(4,2) as avg_score, COUNT(*) FILTER (WHERE score >= 9) as promoters, COUNT(*) FILTER (WHERE score >= 7 AND score <= 8) as passives, COUNT(*) FILTER (WHERE score <= 6) as detractors FROM nps_responses WHERE created_at >= $1 `, [monthStart]); if (npsRes.length > 0 && parseInt(npsRes[0].total) > 0) { const r = npsRes[0]; npsResponsesCount = parseInt(r.total) || 0; npsAvgScore = parseFloat(r.avg_score) || 0; npsPromoters = parseInt(r.promoters) || 0; npsPassives = parseInt(r.passives) || 0; npsDetractors = parseInt(r.detractors) || 0; npsCompany = Math.round((npsPromoters / npsResponsesCount * 100) - (npsDetractors / npsResponsesCount * 100)); } } catch (e) { if (e.code !== '42P01') throw e; } // 3. Отзывы — stats и CSAT (rating >= 7 как положительные) let reviewsStats = defaultData.reviewsStats; let csatPercent = 0, reviewsAvgRating = 0, reviewsTotal = 0; try { const revStats = await query(` SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE status = 'new') as new_count, COUNT(*) FILTER (WHERE status = 'processed') as processed_count, COUNT(*) FILTER (WHERE status = 'archived') as archived_count, AVG(rating)::numeric(4,2) as avg_rating, COUNT(*) FILTER (WHERE rating >= 7) as positive_count, COUNT(*) FILTER (WHERE rating <= 3) as negative_count, COUNT(*) FILTER (WHERE source = 'yandex_maps') as yandex_count, COUNT(*) FILTER (WHERE source = '2gis') as gis2_count FROM reviews WHERE 1=1 `); if (revStats.length > 0) { const r = revStats[0]; reviewsTotal = parseInt(r.total) || 0; reviewsStats = { total: reviewsTotal, new_count: parseInt(r.new_count) || 0, processed_count: parseInt(r.processed_count) || 0, archived_count: parseInt(r.archived_count) || 0, positive_count: parseInt(r.positive_count) || 0, negative_count: parseInt(r.negative_count) || 0, yandex_count: parseInt(r.yandex_count) || 0, gis2_count: parseInt(r.gis2_count) || 0 }; reviewsAvgRating = parseFloat(r.avg_rating) || 0; csatPercent = reviewsTotal > 0 ? Math.round((parseInt(r.positive_count) || 0) / reviewsTotal * 100) : 0; } } catch (e) { if (e.code !== '42P01') throw e; } // 4. Инциденты — по статусам let incidentsSummary = defaultData.incidentsSummary; try { const incRes = await query(` SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE status = 'open') as open, COUNT(*) FILTER (WHERE status = 'in_progress') as in_progress, COUNT(*) FILTER (WHERE status = 'resolved') as resolved FROM incidents `); if (incRes.length > 0) { const r = incRes[0]; incidentsSummary = { total: parseInt(r.total) || 0, open: parseInt(r.open) || 0, in_progress: parseInt(r.in_progress) || 0, resolved: parseInt(r.resolved) || 0 }; } } catch (e) { if (e.code !== '42P01') throw e; } // 5. Мероприятия — за месяц, предстоящие, прошедшие let events = defaultData.events; try { const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0); const monthEndStr = monthEnd.toISOString().slice(0, 10); const evRes = await query(` SELECT COUNT(*) FILTER (WHERE date >= $1 AND date <= $2) as count_month, COUNT(*) FILTER (WHERE date > $3) as upcoming, COUNT(*) FILTER (WHERE date < $1) as past FROM pr_events WHERE 1=1 `, [monthStartStr, monthEndStr, todayStr]); if (evRes.length > 0) { const r = evRes[0]; events = { countMonth: parseInt(r.count_month) || 0, upcoming: parseInt(r.upcoming) || 0, past: parseInt(r.past) || 0 }; } } catch (e) { if (e.code !== '42P01') throw e; } // 6. Отчёты жителям let residentReports = defaultData.residentReports; try { const rrRes = await query(` SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE status = 'published' AND (published_at::date >= $1 OR updated_at::date >= $1)) as published_this_month FROM resident_reports `, [monthStartStr]); if (rrRes.length > 0) { const r = rrRes[0]; residentReports = { total: parseInt(r.total) || 0, publishedThisMonth: parseInt(r.published_this_month) || 0 }; } } catch (e) { try { const rrTotal = await query('SELECT COUNT(*) as c FROM resident_reports'); residentReports = { total: parseInt(rrTotal[0]?.c) || 0, publishedThisMonth: 0 }; } catch (e2) { if (e2.code !== '42P01') throw e; } } // 7. Фото отчёты — всего и за месяц (по work_date или created_at) let workPhotos = defaultData.workPhotos; try { const wpRes = await query(` SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE (work_date >= $1 OR (work_date IS NULL AND created_at >= $1))) as this_month FROM work_photos `, [monthStartStr]); if (wpRes.length > 0) { const r = wpRes[0]; workPhotos = { total: parseInt(r.total) || 0, thisMonth: parseInt(r.this_month) || 0 }; } } catch (e) { if (e.code !== '42P01') throw e; } // 8. NPS опросы — всего, активные, список с кол-вом ответов (топ-5) let npsSurveys = defaultData.npsSurveys; try { const countRes = await query(`SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE status = 'active') as active FROM nps_surveys`); const surveysRes = await query(` SELECT s.id, s.title, s.status, (SELECT COUNT(*) FROM nps_responses r WHERE r.survey_id = s.id) as responses_count FROM nps_surveys s ORDER BY s.created_at DESC LIMIT 10 `); const totalSurveys = countRes.length > 0 ? parseInt(countRes[0].total) || 0 : 0; const activeSurveys = countRes.length > 0 ? parseInt(countRes[0].active) || 0 : 0; npsSurveys = { total: totalSurveys, active: activeSurveys, list: (surveysRes || []).slice(0, 5).map(s => ({ id: s.id, title: s.title || 'Без названия', responsesCount: parseInt(s.responses_count) || 0, status: s.status })) }; } catch (e) { if (e.code !== '42P01') throw e; } // 9. Отложенные посты — по статусам let scheduledPosts = defaultData.scheduledPosts; try { const spRes = await query(` SELECT status, COUNT(*) as cnt FROM pr_scheduled_posts GROUP BY status `); if (spRes.length > 0) { const map = { draft: 0, pending_approval: 0, approved: 0, published: 0, rejected: 0, edited: 0 }; spRes.forEach(row => { const k = row.status in map ? row.status : 'draft'; map[k] = parseInt(row.cnt) || 0; }); scheduledPosts = { ...map }; } } catch (e) { if (e.code !== '42P01') throw e; } // 10. Негатив в работе — открытые инциденты и кол-во негативных отзывов const negative = { openIncidents: incidentsSummary.open + incidentsSummary.in_progress, negativeReviewsCount: reviewsStats.negative_count }; res.json({ success: true, data: { npsCompany, npsResponsesCount, npsAvgScore, npsPromoters, npsPassives, npsDetractors, csatPercent, reviewsAvgRating, reviewsTotal, smm, reviewsStats, incidentsSummary, events, residentReports, workPhotos, npsSurveys, scheduledPosts, negative } }); } catch (err) { console.error('[pr/dashboard] Ошибка:', err); res.status(500).json({ success: false, error: err.message || 'Failed to get PR dashboard' }); } }); // GET /api/pr/smm-channels — список каналов с последним снимком подписчиков app.get(`${API_PREFIX}/pr/smm-channels`, async (req, res) => { try { const channels = await query( `SELECT id, name, type, url, sort_order AS "sortOrder", created_at AS "createdAt" FROM pr_smm_channels ORDER BY sort_order ASC, id ASC` ); if (channels.length === 0) return res.json([]); const channelIds = channels.map(c => c.id); const latestSnapshots = await query( `SELECT DISTINCT ON (channel_id) channel_id AS "channelId", subscribers_count AS "subscribersCount", recorded_at AS "recordedAt", note, created_at AS "createdAt" FROM pr_smm_subscriber_snapshots WHERE channel_id = ANY($1) ORDER BY channel_id, recorded_at DESC`, [channelIds] ); const snapshotByChannel = {}; latestSnapshots.forEach(s => { snapshotByChannel[s.channelId] = s; }); const list = channels.map(c => ({ ...c, lastSnapshot: snapshotByChannel[c.id] || null, subscribersCount: snapshotByChannel[c.id] ? snapshotByChannel[c.id].subscribersCount : null })); res.json(list); } catch (err) { if (err.code === '42P01') return res.json([]); console.error('Error GET /pr/smm-channels:', err); res.status(500).json({ error: 'Failed to list SMM channels', details: err.message }); } }); // POST /api/pr/smm-channels — создать канал app.post(`${API_PREFIX}/pr/smm-channels`, async (req, res) => { try { const { name, type, url, sortOrder = 0 } = req.body || {}; if (!name || !type) return res.status(400).json({ error: 'Missing required fields: name, type' }); if (!['tg', 'vk', 'wa', 'other'].includes(type)) return res.status(400).json({ error: 'type must be tg, vk, wa or other' }); const rows = await query( `INSERT INTO pr_smm_channels (name, type, url, sort_order) VALUES ($1, $2, $3, $4) RETURNING id, name, type, url, sort_order AS "sortOrder", created_at AS "createdAt"`, [String(name).trim(), type, url ? String(url).trim() : null, parseInt(sortOrder, 10) || 0] ); res.status(201).json(rows[0]); } catch (err) { if (err.code === '42P01') return res.status(503).json({ error: 'SMM channels table not ready' }); console.error('Error POST /pr/smm-channels:', err); res.status(500).json({ error: 'Failed to create SMM channel', details: err.message }); } }); // PUT /api/pr/smm-channels/:id — обновить канал app.put(`${API_PREFIX}/pr/smm-channels/:id`, async (req, res) => { try { const id = parseInt(req.params.id, 10); if (Number.isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); const { name, type, url, sortOrder } = req.body || {}; const existing = await query('SELECT id FROM pr_smm_channels WHERE id = $1', [id]); if (!existing || existing.length === 0) return res.status(404).json({ error: 'Channel not found' }); const updates = []; const params = []; let idx = 1; if (name !== undefined) { updates.push(`name = $${idx}`); params.push(String(name).trim()); idx++; } if (type !== undefined) { if (!['tg', 'vk', 'wa', 'other'].includes(type)) return res.status(400).json({ error: 'type must be tg, vk, wa or other' }); updates.push(`type = $${idx}`); params.push(type); idx++; } if (url !== undefined) { updates.push(`url = $${idx}`); params.push(url ? String(url).trim() : null); idx++; } if (sortOrder !== undefined) { updates.push(`sort_order = $${idx}`); params.push(parseInt(sortOrder, 10) || 0); idx++; } if (updates.length === 0) { const rows = await query(`SELECT id, name, type, url, sort_order AS "sortOrder", created_at AS "createdAt" FROM pr_smm_channels WHERE id = $1`, [id]); return res.json(rows[0]); } params.push(id); await query(`UPDATE pr_smm_channels SET ${updates.join(', ')} WHERE id = $${idx}`, params); const rows = await query(`SELECT id, name, type, url, sort_order AS "sortOrder", created_at AS "createdAt" FROM pr_smm_channels WHERE id = $1`, [id]); res.json(rows[0]); } catch (err) { if (err.code === '42P01') return res.status(404).json({ error: 'Channel not found' }); console.error('Error PUT /pr/smm-channels/:id:', err); res.status(500).json({ error: 'Failed to update SMM channel', details: err.message }); } }); // DELETE /api/pr/smm-channels/:id — удалить канал (и снимки) app.delete(`${API_PREFIX}/pr/smm-channels/:id`, async (req, res) => { try { const id = parseInt(req.params.id, 10); if (Number.isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); const result = await query('DELETE FROM pr_smm_channels WHERE id = $1 RETURNING id', [id]); if (result.length === 0) return res.status(404).json({ error: 'Channel not found' }); res.json({ success: true }); } catch (err) { if (err.code === '42P01') return res.status(404).json({ error: 'Channel not found' }); console.error('Error DELETE /pr/smm-channels/:id:', err); res.status(500).json({ error: 'Failed to delete SMM channel', details: err.message }); } }); // POST /api/pr/smm-channels/:id/snapshot — зафиксировать количество подписчиков app.post(`${API_PREFIX}/pr/smm-channels/:id/snapshot`, async (req, res) => { try { const id = parseInt(req.params.id, 10); if (Number.isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); const { subscribersCount, note, recordedAt } = req.body || {}; const count = subscribersCount != null ? parseInt(subscribersCount, 10) : null; if (count == null || count < 0) return res.status(400).json({ error: 'subscribersCount is required and must be >= 0' }); const existing = await query('SELECT id FROM pr_smm_channels WHERE id = $1', [id]); if (!existing || existing.length === 0) return res.status(404).json({ error: 'Channel not found' }); const recDate = recordedAt ? String(recordedAt).slice(0, 10) : new Date().toISOString().slice(0, 10); const rows = await query( `INSERT INTO pr_smm_subscriber_snapshots (channel_id, subscribers_count, recorded_at, note) VALUES ($1, $2, $3::date, $4) RETURNING id, channel_id AS "channelId", subscribers_count AS "subscribersCount", recorded_at AS "recordedAt", note, created_at AS "createdAt"`, [id, count, recDate, note ? String(note).trim() : null] ); res.status(201).json(rows[0]); } catch (err) { if (err.code === '42P01') return res.status(404).json({ error: 'Channel not found' }); console.error('Error POST /pr/smm-channels/:id/snapshot:', err); res.status(500).json({ error: 'Failed to create snapshot', details: err.message }); } }); // ========= PR ПРИВЛЕЧЕНИЕ (pr_attraction_actions) ========= // GET /api/pr/attraction-actions — список с фильтром app.get(`${API_PREFIX}/pr/attraction-actions`, async (req, res) => { try { const { channelId, actionType, from, to, limit = 50 } = req.query; let queryText = ` SELECT a.id, a.title, a.description, a.channel_id AS "channelId", a.action_type AS "actionType", a.action_date AS "actionDate", a.new_subscribers_attributed AS "newSubscribersAttributed", a.event_id AS "eventId", a.created_at AS "createdAt", a.created_by AS "createdBy", c.name AS "channelName", c.type AS "channelType" FROM pr_attraction_actions a LEFT JOIN pr_smm_channels c ON c.id = a.channel_id WHERE 1=1 `; const params = []; let idx = 1; if (channelId) { queryText += ` AND a.channel_id = $${idx}`; params.push(parseInt(channelId, 10)); idx++; } if (actionType && ['mailing', 'event', 'post', 'other'].includes(String(actionType))) { queryText += ` AND a.action_type = $${idx}`; params.push(actionType); idx++; } if (from) { queryText += ` AND a.action_date >= $${idx}::date`; params.push(String(from).slice(0, 10)); idx++; } if (to) { queryText += ` AND a.action_date <= $${idx}::date`; params.push(String(to).slice(0, 10)); idx++; } queryText += ` ORDER BY a.action_date DESC, a.created_at DESC LIMIT $${idx}`; params.push(parseInt(limit, 10) || 50); const rows = await query(queryText, params); res.json(rows); } catch (err) { if (err.code === '42P01') return res.json([]); console.error('Error GET /pr/attraction-actions:', err); res.status(500).json({ error: 'Failed to list attraction actions', details: err.message }); } }); // POST /api/pr/attraction-actions — создать app.post(`${API_PREFIX}/pr/attraction-actions`, async (req, res) => { try { const { title, description, channelId, actionType, actionDate, newSubscribersAttributed, eventId, createdBy } = req.body || {}; if (!title || !actionType) return res.status(400).json({ error: 'Missing required fields: title, actionType' }); if (!['mailing', 'event', 'post', 'other'].includes(actionType)) return res.status(400).json({ error: 'actionType must be mailing, event, post or other' }); const actionDateVal = actionDate ? String(actionDate).slice(0, 10) : new Date().toISOString().slice(0, 10); const rows = await query( `INSERT INTO pr_attraction_actions (title, description, channel_id, action_type, action_date, new_subscribers_attributed, event_id, created_by) VALUES ($1, $2, $3, $4, $5::date, $6, $7, $8) RETURNING id, title, description, channel_id AS "channelId", action_type AS "actionType", action_date AS "actionDate", new_subscribers_attributed AS "newSubscribersAttributed", event_id AS "eventId", created_at AS "createdAt", created_by AS "createdBy"`, [ String(title).trim(), description ? String(description).trim() : null, channelId != null ? parseInt(channelId, 10) : null, actionType, actionDateVal, newSubscribersAttributed != null ? parseInt(newSubscribersAttributed, 10) : null, eventId != null ? parseInt(eventId, 10) : null, createdBy ? String(createdBy) : null ] ); res.status(201).json(rows[0]); } catch (err) { if (err.code === '42P01') return res.status(503).json({ error: 'pr_attraction_actions table not ready' }); console.error('Error POST /pr/attraction-actions:', err); res.status(500).json({ error: 'Failed to create attraction action', details: err.message }); } }); // PUT /api/pr/attraction-actions/:id — обновить app.put(`${API_PREFIX}/pr/attraction-actions/:id`, async (req, res) => { try { const id = parseInt(req.params.id, 10); if (Number.isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); const { title, description, channelId, actionType, actionDate, newSubscribersAttributed, eventId } = req.body || {}; const existing = await query('SELECT id FROM pr_attraction_actions WHERE id = $1', [id]); if (!existing || existing.length === 0) return res.status(404).json({ error: 'Attraction action not found' }); const updates = []; const params = []; let idx = 1; if (title !== undefined) { updates.push(`title = $${idx}`); params.push(String(title).trim()); idx++; } if (description !== undefined) { updates.push(`description = $${idx}`); params.push(description ? String(description).trim() : null); idx++; } if (channelId !== undefined) { updates.push(`channel_id = $${idx}`); params.push(channelId != null ? parseInt(channelId, 10) : null); idx++; } if (actionType !== undefined) { if (!['mailing', 'event', 'post', 'other'].includes(actionType)) return res.status(400).json({ error: 'actionType must be mailing, event, post or other' }); updates.push(`action_type = $${idx}`); params.push(actionType); idx++; } if (actionDate !== undefined) { updates.push(`action_date = $${idx}::date`); params.push(String(actionDate).slice(0, 10)); idx++; } if (newSubscribersAttributed !== undefined) { updates.push(`new_subscribers_attributed = $${idx}`); params.push(newSubscribersAttributed != null ? parseInt(newSubscribersAttributed, 10) : null); idx++; } if (eventId !== undefined) { updates.push(`event_id = $${idx}`); params.push(eventId != null ? parseInt(eventId, 10) : null); idx++; } if (updates.length === 0) { const rows = await query( `SELECT a.id, a.title, a.description, a.channel_id AS "channelId", a.action_type AS "actionType", a.action_date AS "actionDate", a.new_subscribers_attributed AS "newSubscribersAttributed", a.event_id AS "eventId", a.created_at AS "createdAt", a.created_by AS "createdBy", c.name AS "channelName", c.type AS "channelType" FROM pr_attraction_actions a LEFT JOIN pr_smm_channels c ON c.id = a.channel_id WHERE a.id = $1`, [id] ); return res.json(rows[0]); } params.push(id); await query(`UPDATE pr_attraction_actions SET ${updates.join(', ')} WHERE id = $${idx}`, params); const rows = await query( `SELECT a.id, a.title, a.description, a.channel_id AS "channelId", a.action_type AS "actionType", a.action_date AS "actionDate", a.new_subscribers_attributed AS "newSubscribersAttributed", a.event_id AS "eventId", a.created_at AS "createdAt", a.created_by AS "createdBy", c.name AS "channelName", c.type AS "channelType" FROM pr_attraction_actions a LEFT JOIN pr_smm_channels c ON c.id = a.channel_id WHERE a.id = $1`, [id] ); res.json(rows[0]); } catch (err) { if (err.code === '42P01') return res.status(404).json({ error: 'Attraction action not found' }); console.error('Error PUT /pr/attraction-actions/:id:', err); res.status(500).json({ error: 'Failed to update attraction action', details: err.message }); } }); // DELETE /api/pr/attraction-actions/:id app.delete(`${API_PREFIX}/pr/attraction-actions/:id`, async (req, res) => { try { const id = parseInt(req.params.id, 10); if (Number.isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); const result = await query('DELETE FROM pr_attraction_actions WHERE id = $1 RETURNING id', [id]); if (result.length === 0) return res.status(404).json({ error: 'Attraction action not found' }); res.json({ success: true }); } catch (err) { if (err.code === '42P01') return res.status(404).json({ error: 'Attraction action not found' }); console.error('Error DELETE /pr/attraction-actions/:id:', err); res.status(500).json({ error: 'Failed to delete attraction action', details: err.message }); } }); // ========= PR ТЕМЫ ПОСТОВ (pr_post_topics) ========= // GET /api/pr/post-topics — список тем графика публикации (фильтр по месяцу, статусу) app.get(`${API_PREFIX}/pr/post-topics`, async (req, res) => { try { const { month, status, limit = 50 } = req.query; let queryText = ` SELECT id, title, description, scheduled_date AS "scheduledDate", month, status, created_at AS "createdAt", created_by AS "createdBy", approved_at AS "approvedAt", approved_by AS "approvedBy", rejection_reason AS "rejectionReason" FROM pr_post_topics WHERE 1=1 `; const params = []; if (month) { queryText += ` AND month = $${params.length + 1}`; params.push(String(month).slice(0, 7)); } if (status && ['draft', 'pending_approval', 'approved', 'rejected'].includes(String(status))) { queryText += ` AND status = $${params.length + 1}`; params.push(status); } queryText += ` ORDER BY scheduled_date ASC, created_at DESC LIMIT $${params.length + 1}`; params.push(parseInt(limit, 10) || 50); const rows = await query(queryText, params); res.json(rows); } catch (err) { if (err.code === '42P01') return res.json([]); console.error('Error GET /pr/post-topics:', err); res.status(500).json({ error: 'Failed to list post topics', details: err.message }); } }); // POST /api/pr/post-topics — создать тему графика публикации app.post(`${API_PREFIX}/pr/post-topics`, async (req, res) => { try { const { title, description, scheduledDate, month, status = 'draft', createdBy } = req.body || {}; if (!title || !scheduledDate || !month) return res.status(400).json({ error: 'Missing required fields: title, scheduledDate, month' }); const monthVal = String(month).slice(0, 7); const scheduledDateVal = String(scheduledDate).slice(0, 10); const rows = await query( `INSERT INTO pr_post_topics (title, description, scheduled_date, month, status, created_by) VALUES ($1, $2, $3::date, $4, $5, $6) RETURNING id, title, description, scheduled_date AS "scheduledDate", month, status, created_at AS "createdAt", created_by AS "createdBy", approved_at AS "approvedAt", approved_by AS "approvedBy", rejection_reason AS "rejectionReason"`, [String(title).trim(), description ? String(description).trim() : null, scheduledDateVal, monthVal, status, createdBy ? String(createdBy) : null] ); res.status(201).json(rows[0]); } catch (err) { if (err.code === '42P01') return res.status(503).json({ error: 'pr_post_topics table not ready' }); console.error('Error POST /pr/post-topics:', err); res.status(500).json({ error: 'Failed to create post topic', details: err.message }); } }); // PUT /api/pr/post-topics/:id — обновить тему графика публикации app.put(`${API_PREFIX}/pr/post-topics/:id`, async (req, res) => { try { const id = parseInt(req.params.id, 10); if (Number.isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); const { title, description, scheduledDate, status } = req.body || {}; const existing = await query('SELECT id, status FROM pr_post_topics WHERE id = $1', [id]); if (!existing || existing.length === 0) return res.status(404).json({ error: 'Topic not found' }); const updates = []; const params = []; let idx = 1; if (title !== undefined) { updates.push(`title = $${idx}`); params.push(String(title).trim()); idx++; } if (description !== undefined) { updates.push(`description = $${idx}`); params.push(description ? String(description).trim() : null); idx++; } if (scheduledDate !== undefined) { updates.push(`scheduled_date = $${idx}::date`); params.push(String(scheduledDate).slice(0, 10)); idx++; } if (status !== undefined && ['draft', 'pending_approval', 'approved', 'rejected'].includes(status)) { updates.push(`status = $${idx}`); params.push(status); if (status === 'approved') { updates.push(`approved_at = NOW()`); } idx++; } if (updates.length === 0) { const rows = await query(`SELECT id, title, description, scheduled_date AS "scheduledDate", month, status, created_at AS "createdAt", created_by AS "createdBy", approved_at AS "approvedAt", approved_by AS "approvedBy", rejection_reason AS "rejectionReason" FROM pr_post_topics WHERE id = $1`, [id]); return res.json(rows[0]); } params.push(id); await query(`UPDATE pr_post_topics SET ${updates.join(', ')} WHERE id = $${idx}`, params); const rows = await query(`SELECT id, title, description, scheduled_date AS "scheduledDate", month, status, created_at AS "createdAt", created_by AS "createdBy", approved_at AS "approvedAt", approved_by AS "approvedBy", rejection_reason AS "rejectionReason" FROM pr_post_topics WHERE id = $1`, [id]); res.json(rows[0]); } catch (err) { if (err.code === '42P01') return res.status(404).json({ error: 'Topic not found' }); console.error('Error PUT /pr/post-topics/:id:', err); res.status(500).json({ error: 'Failed to update post topic', details: err.message }); } }); // POST /api/pr/post-topics/:id/approve — одобрить тему графика публикации app.post(`${API_PREFIX}/pr/post-topics/:id/approve`, async (req, res) => { try { const id = parseInt(req.params.id, 10); if (Number.isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); const { approvedBy } = req.body || {}; const rows = await query( `UPDATE pr_post_topics SET status = 'approved', approved_at = NOW(), approved_by = $1 WHERE id = $2 RETURNING id, title, description, scheduled_date AS "scheduledDate", month, status, created_at AS "createdAt", created_by AS "createdBy", approved_at AS "approvedAt", approved_by AS "approvedBy", rejection_reason AS "rejectionReason"`, [approvedBy ? String(approvedBy) : null, id] ); if (rows.length === 0) return res.status(404).json({ error: 'Topic not found' }); res.json(rows[0]); } catch (err) { if (err.code === '42P01') return res.status(404).json({ error: 'Topic not found' }); console.error('Error POST /pr/post-topics/:id/approve:', err); res.status(500).json({ error: 'Failed to approve topic', details: err.message }); } }); // POST /api/pr/post-topics/:id/reject — отклонить тему графика публикации app.post(`${API_PREFIX}/pr/post-topics/:id/reject`, async (req, res) => { try { const id = parseInt(req.params.id, 10); if (Number.isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); const { rejectionReason, approvedBy } = req.body || {}; const rows = await query( `UPDATE pr_post_topics SET status = 'rejected', approved_by = $1, rejection_reason = $2 WHERE id = $3 RETURNING id, title, description, scheduled_date AS "scheduledDate", month, status, created_at AS "createdAt", created_by AS "createdBy", approved_at AS "approvedAt", approved_by AS "approvedBy", rejection_reason AS "rejectionReason"`, [approvedBy ? String(approvedBy) : null, rejectionReason ? String(rejectionReason).trim() : null, id] ); if (rows.length === 0) return res.status(404).json({ error: 'Topic not found' }); res.json(rows[0]); } catch (err) { if (err.code === '42P01') return res.status(404).json({ error: 'Topic not found' }); console.error('Error POST /pr/post-topics/:id/reject:', err); res.status(500).json({ error: 'Failed to reject topic', details: err.message }); } }); // DELETE /api/pr/post-topics/:id app.delete(`${API_PREFIX}/pr/post-topics/:id`, async (req, res) => { try { const id = parseInt(req.params.id, 10); if (Number.isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); const result = await query('DELETE FROM pr_post_topics WHERE id = $1 RETURNING id', [id]); if (result.length === 0) return res.status(404).json({ error: 'Topic not found' }); res.json({ success: true }); } catch (err) { if (err.code === '42P01') return res.status(404).json({ error: 'Topic not found' }); console.error('Error DELETE /pr/post-topics/:id:', err); res.status(500).json({ error: 'Failed to delete topic', details: err.message }); } }); // ========= PR ОТЛОЖЕННЫЕ ПОСТЫ (pr_scheduled_posts) ========= // GET /api/pr/scheduled-posts — список отложенных постов app.get(`${API_PREFIX}/pr/scheduled-posts`, async (req, res) => { try { const { status, topicId, from, to, limit = 50 } = req.query; let queryText = ` SELECT id, title, content, channel_ids AS "channelIds", scheduled_at AS "scheduledAt", status, topic_id AS "topicId", image_url AS "imageUrl", created_at AS "createdAt", created_by AS "createdBy", approved_at AS "approvedAt", approved_by AS "approvedBy", rejection_reason AS "rejectionReason", edited_content AS "editedContent", published_at AS "publishedAt" FROM pr_scheduled_posts WHERE 1=1 `; const params = []; if (status && ['draft', 'pending_approval', 'approved', 'rejected', 'edited', 'published'].includes(String(status))) { queryText += ` AND status = $${params.length + 1}`; params.push(status); } if (topicId) { queryText += ` AND topic_id = $${params.length + 1}`; params.push(parseInt(topicId, 10)); } if (from) { queryText += ` AND scheduled_at >= $${params.length + 1}::timestamptz`; params.push(String(from)); } if (to) { queryText += ` AND scheduled_at <= $${params.length + 1}::timestamptz`; params.push(String(to)); } queryText += ` ORDER BY scheduled_at DESC LIMIT $${params.length + 1}`; params.push(parseInt(limit, 10) || 50); const rows = await query(queryText, params); res.json(rows); } catch (err) { if (err.code === '42P01') return res.json([]); console.error('Error GET /pr/scheduled-posts:', err); res.status(500).json({ error: 'Failed to list scheduled posts', details: err.message }); } }); // POST /api/pr/scheduled-posts — создать отложенный пост (с возможной загрузкой изображения) app.post(`${API_PREFIX}/pr/scheduled-posts`, uploadPostImage.single('image'), async (req, res) => { try { const { title, content, channelIds = [], scheduledAt, status = 'draft', topicId, createdBy } = req.body || {}; if (!title || !content || !scheduledAt) { if (req.file && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path); return res.status(400).json({ error: 'Missing required fields: title, content, scheduledAt' }); } const channelIdsJson = Array.isArray(channelIds) ? JSON.stringify(channelIds) : '[]'; const imageUrl = req.file ? `/api/pr/scheduled-posts/image/${req.file.filename}` : null; const rows = await query( `INSERT INTO pr_scheduled_posts (title, content, channel_ids, scheduled_at, status, topic_id, image_url, created_by) VALUES ($1, $2, $3::jsonb, $4::timestamptz, $5, $6, $7, $8) RETURNING id, title, content, channel_ids AS "channelIds", scheduled_at AS "scheduledAt", status, topic_id AS "topicId", image_url AS "imageUrl", created_at AS "createdAt", created_by AS "createdBy", approved_at AS "approvedAt", approved_by AS "approvedBy", rejection_reason AS "rejectionReason", edited_content AS "editedContent", published_at AS "publishedAt"`, [ String(title).trim(), String(content).trim(), channelIdsJson, String(scheduledAt), status, topicId != null ? parseInt(topicId, 10) : null, imageUrl, createdBy ? String(createdBy) : null ] ); res.status(201).json(rows[0]); } catch (err) { if (req.file && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path); if (err.code === '42P01') return res.status(503).json({ error: 'pr_scheduled_posts table not ready' }); console.error('Error POST /pr/scheduled-posts:', err); res.status(500).json({ error: 'Failed to create scheduled post', details: err.message }); } }); // PUT /api/pr/scheduled-posts/:id — обновить пост (с возможной загрузкой изображения) app.put(`${API_PREFIX}/pr/scheduled-posts/:id`, uploadPostImage.single('image'), async (req, res) => { try { const id = parseInt(req.params.id, 10); if (Number.isNaN(id)) { if (req.file && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path); return res.status(400).json({ error: 'Invalid id' }); } const { title, content, channelIds, scheduledAt, status, topicId, removeImage } = req.body || {}; const existing = await query('SELECT id, image_url FROM pr_scheduled_posts WHERE id = $1', [id]); if (!existing || existing.length === 0) { if (req.file && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path); return res.status(404).json({ error: 'Post not found' }); } const updates = []; const params = []; let idx = 1; if (title !== undefined) { updates.push(`title = $${idx}`); params.push(String(title).trim()); idx++; } if (content !== undefined) { updates.push(`content = $${idx}`); params.push(String(content).trim()); idx++; } if (channelIds !== undefined) { const channelIdsJson = Array.isArray(channelIds) ? JSON.stringify(channelIds) : '[]'; updates.push(`channel_ids = $${idx}::jsonb`); params.push(channelIdsJson); idx++; } if (scheduledAt !== undefined) { updates.push(`scheduled_at = $${idx}::timestamptz`); params.push(String(scheduledAt)); idx++; } if (req.file) { const imageUrl = `/api/pr/scheduled-posts/image/${req.file.filename}`; // Удалить старое изображение если есть if (existing[0].image_url) { const oldFilename = existing[0].image_url.split('/').pop(); const oldPath = path.join(postImagesDir, oldFilename); if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath); } updates.push(`image_url = $${idx}`); params.push(imageUrl); idx++; } else if (removeImage === 'true' || removeImage === true) { if (existing[0].image_url) { const oldFilename = existing[0].image_url.split('/').pop(); const oldPath = path.join(postImagesDir, oldFilename); if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath); } updates.push(`image_url = NULL`); } if (status !== undefined && ['draft', 'pending_approval', 'approved', 'rejected', 'edited', 'published'].includes(status)) { updates.push(`status = $${idx}`); params.push(status); if (status === 'approved') { updates.push(`approved_at = NOW()`); } else if (status === 'published') { updates.push(`published_at = NOW()`); } idx++; } if (topicId !== undefined) { updates.push(`topic_id = $${idx}`); params.push(topicId != null ? parseInt(topicId, 10) : null); idx++; } if (updates.length === 0) { const rows = await query(`SELECT id, title, content, channel_ids AS "channelIds", scheduled_at AS "scheduledAt", status, topic_id AS "topicId", image_url AS "imageUrl", created_at AS "createdAt", created_by AS "createdBy", approved_at AS "approvedAt", approved_by AS "approvedBy", rejection_reason AS "rejectionReason", edited_content AS "editedContent", published_at AS "publishedAt" FROM pr_scheduled_posts WHERE id = $1`, [id]); return res.json(rows[0]); } params.push(id); await query(`UPDATE pr_scheduled_posts SET ${updates.join(', ')} WHERE id = $${idx}`, params); const rows = await query(`SELECT id, title, content, channel_ids AS "channelIds", scheduled_at AS "scheduledAt", status, topic_id AS "topicId", image_url AS "imageUrl", created_at AS "createdAt", created_by AS "createdBy", approved_at AS "approvedAt", approved_by AS "approvedBy", rejection_reason AS "rejectionReason", edited_content AS "editedContent", published_at AS "publishedAt" FROM pr_scheduled_posts WHERE id = $1`, [id]); res.json(rows[0]); } catch (err) { if (req.file && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path); if (err.code === '42P01') return res.status(404).json({ error: 'Post not found' }); console.error('Error PUT /pr/scheduled-posts/:id:', err); res.status(500).json({ error: 'Failed to update scheduled post', details: err.message }); } }); // POST /api/pr/scheduled-posts/:id/approve — одобрить пост app.post(`${API_PREFIX}/pr/scheduled-posts/:id/approve`, async (req, res) => { try { const id = parseInt(req.params.id, 10); if (Number.isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); const { approvedBy } = req.body || {}; const rows = await query( `UPDATE pr_scheduled_posts SET status = 'approved', approved_at = NOW(), approved_by = $1 WHERE id = $2 RETURNING id, title, content, channel_ids AS "channelIds", scheduled_at AS "scheduledAt", status, topic_id AS "topicId", image_url AS "imageUrl", created_at AS "createdAt", created_by AS "createdBy", approved_at AS "approvedAt", approved_by AS "approvedBy", rejection_reason AS "rejectionReason", edited_content AS "editedContent", published_at AS "publishedAt"`, [approvedBy ? String(approvedBy) : null, id] ); if (rows.length === 0) return res.status(404).json({ error: 'Post not found' }); res.json(rows[0]); } catch (err) { if (err.code === '42P01') return res.status(404).json({ error: 'Post not found' }); console.error('Error POST /pr/scheduled-posts/:id/approve:', err); res.status(500).json({ error: 'Failed to approve post', details: err.message }); } }); // POST /api/pr/scheduled-posts/:id/reject — отклонить пост (с причиной) app.post(`${API_PREFIX}/pr/scheduled-posts/:id/reject`, async (req, res) => { try { const id = parseInt(req.params.id, 10); if (Number.isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); const { rejectionReason, approvedBy } = req.body || {}; const rows = await query( `UPDATE pr_scheduled_posts SET status = 'rejected', approved_by = $1, rejection_reason = $2 WHERE id = $3 RETURNING id, title, content, channel_ids AS "channelIds", scheduled_at AS "scheduledAt", status, topic_id AS "topicId", image_url AS "imageUrl", created_at AS "createdAt", created_by AS "createdBy", approved_at AS "approvedAt", approved_by AS "approvedBy", rejection_reason AS "rejectionReason", edited_content AS "editedContent", published_at AS "publishedAt"`, [approvedBy ? String(approvedBy) : null, rejectionReason ? String(rejectionReason).trim() : null, id] ); if (rows.length === 0) return res.status(404).json({ error: 'Post not found' }); res.json(rows[0]); } catch (err) { if (err.code === '42P01') return res.status(404).json({ error: 'Post not found' }); console.error('Error POST /pr/scheduled-posts/:id/reject:', err); res.status(500).json({ error: 'Failed to reject post', details: err.message }); } }); // POST /api/pr/scheduled-posts/:id/send-to-edit — отправить на редактирование app.post(`${API_PREFIX}/pr/scheduled-posts/:id/send-to-edit`, async (req, res) => { try { const id = parseInt(req.params.id, 10); if (Number.isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); const { editedContent, approvedBy } = req.body || {}; if (!editedContent) return res.status(400).json({ error: 'editedContent is required' }); const rows = await query( `UPDATE pr_scheduled_posts SET status = 'edited', edited_content = $1, approved_by = $2 WHERE id = $3 RETURNING id, title, content, channel_ids AS "channelIds", scheduled_at AS "scheduledAt", status, topic_id AS "topicId", image_url AS "imageUrl", created_at AS "createdAt", created_by AS "createdBy", approved_at AS "approvedAt", approved_by AS "approvedBy", rejection_reason AS "rejectionReason", edited_content AS "editedContent", published_at AS "publishedAt"`, [String(editedContent).trim(), approvedBy ? String(approvedBy) : null, id] ); if (rows.length === 0) return res.status(404).json({ error: 'Post not found' }); res.json(rows[0]); } catch (err) { if (err.code === '42P01') return res.status(404).json({ error: 'Post not found' }); console.error('Error POST /pr/scheduled-posts/:id/send-to-edit:', err); res.status(500).json({ error: 'Failed to send post to edit', details: err.message }); } }); // DELETE /api/pr/scheduled-posts/:id app.delete(`${API_PREFIX}/pr/scheduled-posts/:id`, async (req, res) => { try { const id = parseInt(req.params.id, 10); if (Number.isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); const result = await query('DELETE FROM pr_scheduled_posts WHERE id = $1 RETURNING id', [id]); if (result.length === 0) return res.status(404).json({ error: 'Post not found' }); res.json({ success: true }); } catch (err) { if (err.code === '42P01') return res.status(404).json({ error: 'Post not found' }); console.error('Error DELETE /pr/scheduled-posts/:id:', err); res.status(500).json({ error: 'Failed to delete scheduled post', details: err.message }); } }); // ========= НАСТРОЙКИ ПАРСИНГА (API ОТЗЫВОВ) ========= // GET /api/pr/parsing-settings - получить настройки app.get(`${API_PREFIX}/pr/parsing-settings`, async (req, res) => { try { const rows = await query(` SELECT id, source, enabled, url_template AS "urlTemplate", api_key AS "apiKey", parsing_interval_hours AS "parsingIntervalHours", last_parsed_at AS "lastParsedAt", settings, created_at AS "createdAt", updated_at AS "updatedAt" FROM parsing_settings ORDER BY source `); const processedRows = rows.map(row => { let settings = row.settings; let buildingId = null; if (settings && typeof settings === 'object') { buildingId = settings.building_id || null; } else if (settings && typeof settings === 'string') { try { const parsed = JSON.parse(settings); buildingId = parsed.building_id || null; settings = parsed; } catch (err) { console.warn('[server] Не удалось распарсить settings для', row.source, err); } } return { ...row, settings: settings || {}, buildingId: buildingId }; }); res.json(processedRows); } catch (err) { console.error('Error fetching parsing settings:', err); res.status(500).json({ error: 'Failed to fetch parsing settings' }); } }); // PUT /api/pr/parsing-settings/:source - обновить настройки app.put(`${API_PREFIX}/pr/parsing-settings/:source`, async (req, res) => { try { const { source } = req.params; const { enabled, url_template, api_key, parsing_interval_hours, settings } = req.body; if (!['yandex_maps', '2gis'].includes(source)) { return res.status(400).json({ error: 'Invalid source. Must be yandex_maps or 2gis' }); } const updates = []; const params = []; let paramIndex = 1; if (enabled !== undefined) { updates.push(`enabled = $${paramIndex++}`); params.push(enabled); } if (url_template !== undefined) { updates.push(`url_template = $${paramIndex++}`); params.push(url_template); } if (api_key !== undefined) { updates.push(`api_key = $${paramIndex++}`); params.push(api_key); } if (parsing_interval_hours !== undefined) { updates.push(`parsing_interval_hours = $${paramIndex++}`); params.push(parsing_interval_hours); } if (settings !== undefined) { updates.push(`settings = $${paramIndex++}`); params.push(JSON.stringify(settings)); } if (updates.length === 0) { return res.status(400).json({ error: 'No fields to update' }); } updates.push(`updated_at = NOW()`); params.push(source); const existing = await query('SELECT id FROM parsing_settings WHERE source = $1', [source]); if (existing.length === 0) { updates.unshift('source', 'enabled', 'url_template', 'api_key', 'parsing_interval_hours', 'settings'); params.unshift(source, enabled !== undefined ? enabled : true, url_template || '', api_key || null, parsing_interval_hours || 24, settings ? JSON.stringify(settings) : '{}'); await query( `INSERT INTO parsing_settings (${updates.slice(0, -1).join(', ')}, created_at, updated_at) VALUES (${updates.slice(0, -1).map((_, i) => `$${i + 1}`).join(', ')}, NOW(), NOW())`, params.slice(0, -1) ); } else { await query( `UPDATE parsing_settings SET ${updates.join(', ')} WHERE source = $${paramIndex}`, params ); } const result = await query(` SELECT id, source, enabled, url_template AS "urlTemplate", api_key AS "apiKey", parsing_interval_hours AS "parsingIntervalHours", last_parsed_at AS "lastParsedAt", settings, created_at AS "createdAt", updated_at AS "updatedAt" FROM parsing_settings WHERE source = $1`, [source] ); res.json(result[0] || {}); } catch (err) { console.error('Error updating parsing settings:', err); res.status(500).json({ error: 'Failed to update parsing settings' }); } }); // POST /api/pr/parsing-settings/:source/test - тест API (без сохранения в БД) app.post(`${API_PREFIX}/pr/parsing-settings/:source/test`, async (req, res) => { try { const { source } = req.params; if (!['yandex_maps', '2gis'].includes(source)) { return res.status(400).json({ error: 'Invalid source' }); } const settingsResult = await query('SELECT * FROM parsing_settings WHERE source = $1', [source]); if (settingsResult.length === 0) { return res.status(404).json({ error: 'Настройки парсинга не найдены' }); } const settings = settingsResult[0]; const apiKey = (settings.api_key || '').trim(); if (!apiKey) { return res.status(400).json({ error: 'API key required', details: 'Укажите API ключ в настройках' }); } const reviewApiService = require('./reviewApiService'); const fetched = await reviewApiService.fetchReviewsFromApi(source, settings); const found = fetched.length; res.json({ success: true, parsed: found, found, message: `Тестовый запрос завершен. Найдено отзывов: ${found}` }); } catch (err) { console.error('Error testing parsing API:', err); res.status(500).json({ error: 'Failed to test API', details: err.message }); } }); // POST /api/pr/reviews/fetch - загрузка отзывов через API app.post(`${API_PREFIX}/pr/reviews/fetch`, async (req, res) => { try { const { source } = req.body; if (!source) { return res.status(400).json({ error: 'Source is required', details: 'Укажите источник: 2gis или yandex_maps' }); } if (!['yandex_maps', '2gis'].includes(source)) { return res.status(400).json({ error: 'Invalid source. Must be yandex_maps or 2gis' }); } const settingsResult = await query('SELECT * FROM parsing_settings WHERE source = $1', [source]); if (settingsResult.length === 0) { return res.status(400).json({ error: 'Настройки парсинга не найдены', details: 'Укажите API ключ в Настройках → Интеграции' }); } const settings = settingsResult[0]; const apiKey = (settings.api_key || '').trim(); if (!apiKey) { return res.status(400).json({ error: 'API key required', details: 'Укажите API ключ в Настройках → Интеграции' }); } const reviewApiService = require('./reviewApiService'); const fetched = await reviewApiService.fetchReviewsFromApi(source, settings); const found = fetched.length; const errors = []; let parsed = 0; const client = await pool.connect(); try { for (const review of fetched) { const text = (review.text || '').trim(); const rating = Math.max(1, Math.min(10, parseInt(review.rating, 10) || 5)); const date = (review.date || new Date().toISOString().split('T')[0]).substring(0, 10); if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) continue; if (text.length < 10) continue; const buildingId = review.building_id || null; const sourceUrl = review.source_url || null; const authorName = review.author_name || 'Аноним'; const existing = await client.query( `SELECT id FROM reviews WHERE source = $1 AND (source_url = $2 OR (text = $3 AND date = $4)) LIMIT 1`, [source, sourceUrl, text, date] ); if (existing.rows.length > 0) continue; await client.query( `INSERT INTO reviews (building_id, source, source_url, author_name, text, rating, date, status) VALUES ($1, $2, $3, $4, $5, $6, $7, 'new')`, [buildingId, source, sourceUrl, authorName, text.substring(0, 10000), rating, date] ); parsed++; } await client.query( 'UPDATE parsing_settings SET last_parsed_at = NOW() WHERE source = $1', [source] ); } finally { client.release(); } const message = `Загрузка завершена. Найдено: ${found}, сохранено: ${parsed}`; console.log('[server]', message); res.json({ success: true, parsed, found, errors: errors.length ? errors : undefined, message }); } catch (err) { console.error('[server] Error fetching reviews via API:', err); res.status(500).json({ success: false, error: 'Failed to fetch reviews', details: err.message }); } }); // ========= NPS ОПРОСЫ ========= // GET /api/pr/nps-surveys - список опросов app.get(`${API_PREFIX}/pr/nps-surveys`, async (req, res) => { try { const { building_id, status } = req.query; let queryText = ` SELECT s.*, (b.data->'passport')->>'address' as address, COUNT(r.id) as responses_count, AVG(r.score)::numeric(3,1) as avg_score FROM nps_surveys s LEFT JOIN buildings b ON s.building_id = b.id LEFT JOIN nps_responses r ON s.id = r.survey_id WHERE 1=1 `; const params = []; if (building_id) { queryText += ` AND s.building_id = $${params.length + 1}`; params.push(building_id); } if (status) { queryText += ` AND s.status = $${params.length + 1}`; params.push(status); } queryText += ' GROUP BY s.id, b.data ORDER BY s.created_at DESC'; const rows = await query(queryText, params); // Преобразуем snake_case в camelCase const formattedRows = rows.map(row => ({ id: row.id, buildingId: row.building_id, title: row.title, description: row.description, status: row.status, accessKey: row.access_key, publishedAt: row.published_at, expiresAt: row.expires_at, createdBy: row.created_by, createdAt: row.created_at, updatedAt: row.updated_at, address: row.address, responsesCount: parseInt(row.responses_count) || 0, avgScore: parseFloat(row.avg_score) || 0 })); res.json(formattedRows); } catch (err) { console.error('Error fetching NPS surveys:', err); res.status(500).json({ error: 'Failed to fetch NPS surveys' }); } }); // GET /api/pr/nps-surveys/:id - детали опроса app.get(`${API_PREFIX}/pr/nps-surveys/:id`, async (req, res) => { try { const { id } = req.params; // Сначала получаем опрос и адрес const surveyResult = await query( `SELECT s.*, (b.data->'passport')->>'address' as address FROM nps_surveys s LEFT JOIN buildings b ON s.building_id = b.id WHERE s.id = $1`, [id] ); if (surveyResult.length === 0) { return res.status(404).json({ error: 'Survey not found' }); } const survey = surveyResult[0]; // Затем получаем статистику по ответам const statsResult = await query( `SELECT COUNT(*) as responses_count, AVG(score)::numeric(3,1) as avg_score FROM nps_responses WHERE survey_id = $1`, [id] ); const stats = statsResult[0] || { responses_count: 0, avg_score: null }; res.json({ id: survey.id, buildingId: survey.building_id, title: survey.title, description: survey.description, status: survey.status, accessKey: survey.access_key, publishedAt: survey.published_at, expiresAt: survey.expires_at, createdBy: survey.created_by, createdAt: survey.created_at, updatedAt: survey.updated_at, address: survey.address, responsesCount: parseInt(stats.responses_count) || 0, avgScore: parseFloat(stats.avg_score) || 0 }); } catch (err) { console.error('Error fetching NPS survey:', err); res.status(500).json({ error: 'Failed to fetch NPS survey' }); } }); // POST /api/pr/nps-surveys - создание опроса app.post(`${API_PREFIX}/pr/nps-surveys`, async (req, res) => { try { const { building_id, title, description, expires_at, created_by } = req.body; if (!building_id || !created_by) { return res.status(400).json({ error: 'building_id and created_by are required' }); } // Генерируем уникальный ключ доступа const accessKey = `nps-${building_id}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const result = await query( `INSERT INTO nps_surveys (building_id, title, description, access_key, expires_at, created_by) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, [building_id, title || 'Опрос удовлетворенности', description || null, accessKey, expires_at || null, created_by] ); const row = result[0]; try { await notificationService.createNotificationForResponsibleZone(pool, 'pr', 'nps', { type: 'nps_survey', title: 'Новый NPS-опрос', body: (row.title || 'Опрос удовлетворенности').toString().slice(0, 150), entityType: 'nps_survey', entityId: String(row.id), }); } catch (notifErr) { console.warn('Notification (NPS create):', notifErr.message); } res.status(201).json({ id: row.id, buildingId: row.building_id, title: row.title, description: row.description, status: row.status, accessKey: row.access_key, publishedAt: row.published_at, expiresAt: row.expires_at, createdBy: row.created_by, createdAt: row.created_at, updatedAt: row.updated_at }); } catch (err) { console.error('Error creating NPS survey:', err); res.status(500).json({ error: 'Failed to create NPS survey' }); } }); // PUT /api/pr/nps-surveys/:id - обновление опроса app.put(`${API_PREFIX}/pr/nps-surveys/:id`, async (req, res) => { try { const { id } = req.params; const { title, description, status, expires_at } = req.body; const updates = []; const params = []; let paramIndex = 1; if (title !== undefined) { updates.push(`title = $${paramIndex++}`); params.push(title); } if (description !== undefined) { updates.push(`description = $${paramIndex++}`); params.push(description); } if (status !== undefined) { updates.push(`status = $${paramIndex++}`); params.push(status); if (status === 'active' && !req.body.published_at) { updates.push(`published_at = NOW()`); } } if (expires_at !== undefined) { updates.push(`expires_at = $${paramIndex++}`); params.push(expires_at); } if (updates.length === 0) { return res.status(400).json({ error: 'No fields to update' }); } updates.push(`updated_at = NOW()`); params.push(id); const result = await query( `UPDATE nps_surveys SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`, params ); if (result.length === 0) { return res.status(404).json({ error: 'Survey not found' }); } const row = result[0]; try { await notificationService.createNotificationForResponsibleZone(pool, 'pr', 'nps', { type: 'nps_survey', title: 'Изменение NPS-опроса', body: (row.title || 'Опрос').toString().slice(0, 150), entityType: 'nps_survey', entityId: String(row.id), }); } catch (notifErr) { console.warn('Notification (NPS update):', notifErr.message); } res.json({ id: row.id, buildingId: row.building_id, title: row.title, description: row.description, status: row.status, accessKey: row.access_key, publishedAt: row.published_at, expiresAt: row.expires_at, createdBy: row.created_by, createdAt: row.created_at, updatedAt: row.updated_at }); } catch (err) { console.error('Error updating NPS survey:', err); res.status(500).json({ error: 'Failed to update NPS survey' }); } }); // GET /api/pr/nps-surveys/:id/responses - ответы на опрос (с фильтром по месяцу) app.get(`${API_PREFIX}/pr/nps-surveys/:id/responses`, async (req, res) => { try { const { id } = req.params; const { month, year } = req.query; // Параметры для фильтрации по месяцу let queryText = `SELECT * FROM nps_responses WHERE survey_id = $1`; const params = [id]; // Добавляем фильтр по месяцу и году, если указаны if (month && year) { queryText += ` AND EXTRACT(MONTH FROM created_at) = $${params.length + 1} AND EXTRACT(YEAR FROM created_at) = $${params.length + 2}`; params.push(parseInt(month), parseInt(year)); } queryText += ` ORDER BY created_at DESC`; const rows = await query(queryText, params); const formattedRows = rows.map(row => ({ id: row.id, surveyId: row.survey_id, buildingId: row.building_id, score: row.score, comment: row.comment, respondentName: row.respondent_name, apartment: row.apartment, phone: row.phone, email: row.email, createdAt: row.created_at })); res.json(formattedRows); } catch (err) { console.error('Error fetching NPS responses:', err); res.status(500).json({ error: 'Failed to fetch NPS responses' }); } }); // POST /api/pr/nps-surveys/:id/responses - добавление ответа (публичный endpoint) app.post(`${API_PREFIX}/pr/nps-surveys/:id/responses`, async (req, res) => { try { const { id } = req.params; const { score, comment, respondent_name, apartment, phone, email, access_key } = req.body; if (!score || score < 0 || score > 10) { return res.status(400).json({ error: 'Score must be between 0 and 10' }); } // Проверяем ключ доступа const surveyResult = await query('SELECT * FROM nps_surveys WHERE id = $1', [id]); if (surveyResult.length === 0) { return res.status(404).json({ error: 'Survey not found' }); } const survey = surveyResult[0]; if (survey.access_key !== access_key) { return res.status(403).json({ error: 'Invalid access key' }); } if (survey.status !== 'active') { return res.status(400).json({ error: 'Survey is not active' }); } const result = await query( `INSERT INTO nps_responses (survey_id, building_id, score, comment, respondent_name, apartment, phone, email) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, [id, survey.building_id, score, comment || null, respondent_name || null, apartment || null, phone || null, email || null] ); const row = result[0]; res.status(201).json({ id: row.id, surveyId: row.survey_id, buildingId: row.building_id, score: row.score, comment: row.comment, respondentName: row.respondent_name, apartment: row.apartment, phone: row.phone, email: row.email, createdAt: row.created_at }); } catch (err) { console.error('Error creating NPS response:', err); res.status(500).json({ error: 'Failed to create NPS response' }); } }); // GET /api/pr/nps-surveys/:id/stats - статистика по опросу (с фильтром по месяцу) app.get(`${API_PREFIX}/pr/nps-surveys/:id/stats`, async (req, res) => { try { const { id } = req.params; const { month, year } = req.query; // Параметры для фильтрации по месяцу let queryText = ` SELECT COUNT(*) as total_responses, AVG(score)::numeric(3,1) as avg_score, COUNT(*) FILTER (WHERE score >= 9) as promoters, COUNT(*) FILTER (WHERE score >= 7 AND score <= 8) as passives, COUNT(*) FILTER (WHERE score <= 6) as detractors, (COUNT(*) FILTER (WHERE score >= 9)::numeric / NULLIF(COUNT(*), 0) * 100)::numeric(5,1) as promoter_percent, (COUNT(*) FILTER (WHERE score <= 6)::numeric / NULLIF(COUNT(*), 0) * 100)::numeric(5,1) as detractor_percent FROM nps_responses WHERE survey_id = $1 `; const params = [id]; // Добавляем фильтр по месяцу и году, если указаны if (month && year) { queryText += ` AND EXTRACT(MONTH FROM created_at) = $${params.length + 1} AND EXTRACT(YEAR FROM created_at) = $${params.length + 2}`; params.push(parseInt(month), parseInt(year)); } const result = await query(queryText, params); if (result.length === 0) { return res.json({ totalResponses: 0, avgScore: 0, promoters: 0, passives: 0, detractors: 0, nps: 0, promoterPercent: 0, detractorPercent: 0 }); } const row = result[0]; const promoters = parseInt(row.promoters) || 0; const detractors = parseInt(row.detractors) || 0; const total = parseInt(row.total_responses) || 0; const nps = total > 0 ? Math.round((promoters / total * 100) - (detractors / total * 100)) : 0; res.json({ totalResponses: total, avgScore: parseFloat(row.avg_score) || 0, promoters: promoters, passives: parseInt(row.passives) || 0, detractors: detractors, nps: nps, promoterPercent: parseFloat(row.promoter_percent) || 0, detractorPercent: parseFloat(row.detractor_percent) || 0 }); } catch (err) { console.error('Error fetching NPS survey stats:', err); res.status(500).json({ error: 'Failed to fetch NPS survey stats' }); } }); // ========= НАСТРОЙКИ КОМПАНИИ ========= // GET /api/settings/company - получить настройки компании app.get(`${API_PREFIX}/settings/company`, async (req, res) => { try { const result = await query('SELECT * FROM company_settings WHERE id = 1 LIMIT 1'); if (result.length === 0) { // Возвращаем дефолтные значения return res.json({ id: 1, name: 'Управляющая компания "Дружба"', fullName: 'ООО "Управляющая компания Дружба"', address: 'г. Уфа, ул. Ленина, 1', phone: '+7 (347) 123-45-67', email: 'info@uk-druzhba.ru', website: '', licenseNumber: 'Лицензия №12345', licenseValidUntil: null, logoUrl: '' }); } const row = result[0]; res.json({ id: row.id, name: row.name, fullName: row.full_name, address: row.address, phone: row.phone, email: row.email, website: row.website, licenseNumber: row.license_number, licenseValidUntil: row.license_valid_until, logoUrl: row.logo_url }); } catch (err) { // #region agent log fetch('http://127.0.0.1:7242/ingest/22a1555a-4536-4181-8ad4-9c1b25c65316', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: 'debug-session', runId: 'post-fix', hypothesisId: 'H1', location: 'backend/server.js:11987', message: 'Error fetching company settings', data: { code: err.code, severity: err.severity, detail: err.detail }, timestamp: Date.now() }) }).catch(() => {}); // #endregion agent log console.error('Error fetching company settings:', err); res.status(500).json({ error: 'Failed to fetch company settings' }); } }); // PUT /api/settings/company - обновить настройки компании app.put(`${API_PREFIX}/settings/company`, async (req, res) => { try { const { name, fullName, address, phone, email, website, licenseNumber, licenseValidUntil, logoUrl } = req.body; const updates = []; const params = []; let paramIndex = 1; if (name !== undefined) { updates.push(`name = $${paramIndex++}`); params.push(name); } if (fullName !== undefined) { updates.push(`full_name = $${paramIndex++}`); params.push(fullName); } if (address !== undefined) { updates.push(`address = $${paramIndex++}`); params.push(address); } if (phone !== undefined) { updates.push(`phone = $${paramIndex++}`); params.push(phone); } if (email !== undefined) { updates.push(`email = $${paramIndex++}`); params.push(email); } if (website !== undefined) { updates.push(`website = $${paramIndex++}`); params.push(website); } if (licenseNumber !== undefined) { updates.push(`license_number = $${paramIndex++}`); params.push(licenseNumber); } if (licenseValidUntil !== undefined) { updates.push(`license_valid_until = $${paramIndex++}`); params.push(licenseValidUntil); } if (logoUrl !== undefined) { updates.push(`logo_url = $${paramIndex++}`); params.push(logoUrl); } if (updates.length === 0) { return res.status(400).json({ error: 'No fields to update' }); } updates.push(`updated_at = NOW()`); // Проверяем, существует ли запись const existing = await query('SELECT id FROM company_settings WHERE id = 1'); if (existing.length === 0) { // Создаем новую запись await query( `INSERT INTO company_settings (id, name, full_name, address, phone, email, website, license_number, license_valid_until, logo_url) VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (id) DO UPDATE SET ${updates.join(', ')}`, [name || 'Управляющая компания', fullName || null, address || null, phone || null, email || null, website || null, licenseNumber || null, licenseValidUntil || null, logoUrl || null] ); } else { // Обновляем существующую params.push(1); await query( `UPDATE company_settings SET ${updates.join(', ')} WHERE id = $${paramIndex}`, params ); } const result = await query('SELECT * FROM company_settings WHERE id = 1'); const row = result[0]; res.json({ id: row.id, name: row.name, fullName: row.full_name, address: row.address, phone: row.phone, email: row.email, website: row.website, licenseNumber: row.license_number, licenseValidUntil: row.license_valid_until, logoUrl: row.logo_url }); } catch (err) { console.error('Error updating company settings:', err); res.status(500).json({ error: 'Failed to update company settings' }); } }); // ========= ДАННЫЕ ДЛЯ ОТЧЕТА ЖИТЕЛЯМ ========= // GET /api/pr/reports/:id/data - получить все данные для отчета // Маппинг пунктов отчёта → источник: // stats.appsQuality, appsTotal, appsCompleted ← applications (COUNT, completed, completed_on_time за период) // stats.tasksTotal, tasksCompleted ← календарный план дома (building.data.annualPlan за период месяц/год), иначе fallback applications // stats.fundsCollected, fundsSpent, fundsBalance ← building_financial_data (SUM total_income, total_expenses, balance) // stats.debtCasesWon, debtCollected ← applications (category = 'debt_collection', metadata->>'debt_collected') // expenses.total, byCategory, details ← building_financial_data.expenses_by_items + ключевые слова // events ← только pr_events (модуль PR Мероприятия) по дому и периоду // nps.* ← nps_building_stats или nps_responses (расчёт и сохранение в nps_building_stats) // workPhotos ← work_photos по building_id и work_date в периоде (task_id для привязки к задаче) // planItems ← applications (deadline_at в следующем месяце, status != 'completed') // building, company ← buildings, company_settings app.get(`${API_PREFIX}/pr/reports/:id/data`, async (req, res) => { try { const { id } = req.params; const { month, year, building_id, force_refresh } = req.query; // Если id это buildingId (строка, не число), работаем напрямую с buildingId let targetBuildingId = building_id; let report = null; // Проверяем, является ли id числом (reportId) или строкой (buildingId). UUID и строковые id не считаем числовыми. const isNumericId = /^\d+$/.test(String(id).trim()); if (isNumericId) { // Получаем отчет по ID const reportResult = await query( 'SELECT * FROM resident_reports WHERE id = $1', [id] ); if (reportResult.length === 0) { return res.status(404).json({ error: 'Report not found' }); } report = reportResult[0]; targetBuildingId = building_id || report.building_id; } else { // id это buildingId (строка, в т.ч. UUID) targetBuildingId = id; } if (!targetBuildingId) { return res.status(400).json({ error: 'Не указан дом (building_id) для загрузки данных отчёта' }); } // Определяем период отчета let periodStart, periodEnd; // Если указаны month и year в запросе, используем их if (month && year) { const targetYear = parseInt(year); const targetMonth = parseInt(month) - 1; periodStart = new Date(targetYear, targetMonth, 1); periodEnd = new Date(targetYear, targetMonth + 1, 0, 23, 59, 59); } else if (report && report.period_start && report.period_end) { // Используем период из отчета periodStart = new Date(report.period_start); periodEnd = new Date(report.period_end); } else { // Если период не указан, используем текущий месяц const now = new Date(); periodStart = new Date(now.getFullYear(), now.getMonth(), 1); periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59); } const periodStartDate = periodStart.toISOString().slice(0, 10); const periodEndDate = periodEnd.toISOString().slice(0, 10); // При force_refresh не используем кэш — всегда пересчитываем из таблиц (фото, NPS, заявки и т.д.) const useCache = !force_refresh; // Сначала пробуем взять сохраненные данные отчета (дом + период), если не запрошено принудительное обновление let savedReportDataResult = []; if (useCache) { savedReportDataResult = await query( `SELECT * FROM resident_report_data WHERE building_id = $1 AND period_start = $2 AND period_end = $3`, [targetBuildingId, periodStartDate, periodEndDate] ); } if (savedReportDataResult.length > 0) { const row = savedReportDataResult[0]; const buildingResult = await query('SELECT * FROM buildings WHERE id = $1', [targetBuildingId]); const building = buildingResult.length > 0 ? buildingResult[0] : null; const buildingData = building?.data || {}; const buildingAddress = buildingData?.passport?.address || buildingData?.passport?.general?.address || 'Адрес не указан'; const companyResult = await query('SELECT * FROM company_settings WHERE id = 1 LIMIT 1'); const company = companyResult.length > 0 ? companyResult[0] : null; // NPS всегда подтягиваем актуально из nps_responses/nps_building_stats, чтобы новые ответы опроса попадали в отчёт let npsFromCache = { score: parseInt(row.nps_score) || 0, avgScore: parseFloat(row.nps_avg_score) || 0, totalResponses: parseInt(row.nps_total_responses) || 0, promoters: parseInt(row.nps_promoters) || 0, passives: parseInt(row.nps_passives) || 0, detractors: parseInt(row.nps_detractors) || 0 }; const npsStatsRow = await query( `SELECT total_responses, nps_score, avg_score, promoters, passives, detractors FROM nps_building_stats WHERE building_id = $1 AND period_start = $2 AND period_end = $3`, [targetBuildingId, periodStartDate, periodEndDate] ); if (npsStatsRow.length > 0 && parseInt(npsStatsRow[0].total_responses) > 0) { const r = npsStatsRow[0]; npsFromCache = { score: parseInt(r.nps_score) || 0, avgScore: parseFloat(r.avg_score) || 0, totalResponses: parseInt(r.total_responses) || 0, promoters: parseInt(r.promoters) || 0, passives: parseInt(r.passives) || 0, detractors: parseInt(r.detractors) || 0 }; } else { const npsLiveResult = await query( `SELECT COUNT(*) as total_responses, AVG(score)::numeric(3,1) as avg_score, COUNT(*) FILTER (WHERE score >= 9) as promoters, COUNT(*) FILTER (WHERE score >= 7 AND score <= 8) as passives, COUNT(*) FILTER (WHERE score <= 6) as detractors FROM nps_responses WHERE building_id = $1 AND (created_at::date >= $2::date AND created_at::date <= $3::date)`, [targetBuildingId, periodStartDate, periodEndDate] ); if (npsLiveResult.length > 0 && parseInt(npsLiveResult[0].total_responses) > 0) { const r = npsLiveResult[0]; const total = parseInt(r.total_responses) || 0; const promoters = parseInt(r.promoters) || 0; const passives = parseInt(r.passives) || 0; const detractors = parseInt(r.detractors) || 0; const score = total > 0 ? Math.round((promoters / total * 100) - (detractors / total * 100)) : 0; npsFromCache = { score, avgScore: parseFloat(r.avg_score) || 0, totalResponses: total, promoters, passives, detractors }; const promoterPct = total > 0 ? (promoters / total * 100).toFixed(2) : '0'; const detractorPct = total > 0 ? (detractors / total * 100).toFixed(2) : '0'; await query( `INSERT INTO nps_building_stats (building_id, period_start, period_end, total_responses, nps_score, avg_score, promoters, passives, detractors, promoter_percent, detractor_percent, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW()) ON CONFLICT (building_id, period_start, period_end) DO UPDATE SET total_responses = $4, nps_score = $5, avg_score = $6, promoters = $7, passives = $8, detractors = $9, promoter_percent = $10, detractor_percent = $11, updated_at = NOW()`, [targetBuildingId, periodStartDate, periodEndDate, total, score, parseFloat(r.avg_score) || 0, promoters, passives, detractors, promoterPct, detractorPct] ).catch(err => console.warn('[Report Data] Ошибка сохранения NPS в nps_building_stats:', err.message)); } } // Задачи «запланировано / выполнено» всегда из календарного плана дома (building.data.annualPlan) за период const REPORT_MONTHS_CACHE = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь']; const periodStartFromCache = new Date(row.period_start || periodStartDate); const periodYearCache = periodStartFromCache.getFullYear(); const periodMonthNameCache = REPORT_MONTHS_CACHE[periodStartFromCache.getMonth()]; const annualPlanCache = buildingData?.annualPlan; let tasksTotalCache = parseInt(row.tasks_total) || 0; let tasksCompletedCache = parseInt(row.tasks_completed) || 0; if (Array.isArray(annualPlanCache) && annualPlanCache.length > 0) { const planForPeriodCache = annualPlanCache.filter( (item) => (item.year === periodYearCache || item.year === undefined) && (String(item.month || '').trim() === periodMonthNameCache) ); if (planForPeriodCache.length > 0) { tasksTotalCache = planForPeriodCache.length; tasksCompletedCache = planForPeriodCache.filter((item) => item.status === 'completed').length; } } return res.json({ building: { id: targetBuildingId, address: buildingAddress, imageUrl: building?.data?.imageUrl || building?.imageUrl || '' }, company: company ? { name: company.name, fullName: company.full_name, address: company.address, phone: company.phone, email: company.email, website: company.website, licenseNumber: company.license_number, licenseValidUntil: company.license_valid_until, logoUrl: company.logo_url } : null, period: { start: periodStart.toISOString(), end: periodEnd.toISOString() }, stats: { appsQuality: parseInt(row.apps_quality) || 0, appsTotal: parseInt(row.apps_total) || 0, appsCompleted: parseInt(row.apps_completed) || 0, tasksTotal: tasksTotalCache, tasksCompleted: tasksCompletedCache, fundsCollected: parseFloat(row.funds_collected) || 0, fundsSpent: parseFloat(row.funds_spent) || 0, fundsBalance: parseFloat(row.funds_balance) || 0, debtCasesWon: parseInt(row.debt_cases_won) || 0, debtCollected: parseFloat(row.debt_collected) || 0 }, expenses: { total: parseFloat(row.expenses_total) || 0, byCategory: row.expenses_by_category || {}, details: row.expenses_by_category || {} }, events: row.events || [], nps: npsFromCache, workPhotos: row.work_photos || [], planItems: row.plan_items || [] }); } // 1. Информация о доме const buildingResult = await query('SELECT * FROM buildings WHERE id = $1', [targetBuildingId]); const building = buildingResult.length > 0 ? buildingResult[0] : null; const buildingData = building?.data || {}; const buildingAddress = buildingData?.passport?.address || buildingData?.passport?.general?.address || 'Адрес не указан'; const buildingImage = building?.data?.imageUrl || building?.imageUrl || ''; // 2. Настройки компании const companyResult = await query('SELECT * FROM company_settings WHERE id = 1 LIMIT 1'); const company = companyResult.length > 0 ? companyResult[0] : null; // 3. Качество отработки заявок (completed_at в schema нет — используем updated_at для «время завершения») const appsResult = await query( `SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE status = 'done') as completed, COUNT(*) FILTER (WHERE status = 'done' AND updated_at <= deadline_at) as completed_on_time, COUNT(*) FILTER (WHERE status = 'done' AND updated_at > deadline_at) as completed_late FROM applications WHERE building_id = $1 AND created_at::date >= $2::date AND created_at::date <= $3::date`, [targetBuildingId, periodStartDate, periodEndDate] ); const appsStats = appsResult.length > 0 ? appsResult[0] : { total: 0, completed: 0, completed_on_time: 0, completed_late: 0 }; const appsQuality = appsStats.total > 0 ? Math.round((appsStats.completed_on_time / appsStats.total) * 100) : 0; // 4. Задачи из календарного плана дома: building.data.annualPlan за период (месяц/год), иначе fallback — applications const REPORT_MONTHS = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь']; const periodYear = periodStart.getFullYear(); const periodMonthName = REPORT_MONTHS[periodStart.getMonth()]; let tasksStats = { total: 0, completed: 0 }; const annualPlan = buildingData?.annualPlan || (Array.isArray(buildingData?.annualPlan) ? buildingData.annualPlan : []); if (Array.isArray(annualPlan) && annualPlan.length > 0) { const planForPeriod = annualPlan.filter( (item) => (item.year === periodYear || item.year === undefined) && (String(item.month || '').trim() === periodMonthName) ); const completedInPlan = planForPeriod.filter((item) => item.status === 'completed').length; tasksStats = { total: planForPeriod.length, completed: completedInPlan }; } if (tasksStats.total === 0) { try { const tasksResult = await query( `SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE status = 'done') as completed FROM applications WHERE building_id = $1 AND created_at::date >= $2::date AND created_at::date <= $3::date`, [targetBuildingId, periodStartDate, periodEndDate] ); if (tasksResult.length > 0) tasksStats = tasksResult[0]; } catch (e) { console.warn('[Report Data] tasks stats fallback:', e.message); } } // 5. Собрано средств (из building_financial_data); сравниваем по датам без времени const financesResult = await query( `SELECT COALESCE(SUM(total_income), 0) as total_income, COALESCE(SUM(total_expenses), 0) as total_expenses, COALESCE(SUM(balance), 0) as balance FROM building_financial_data WHERE building_id = $1 AND period_start::date <= $3::date AND period_end::date >= $2::date`, [targetBuildingId, periodStartDate, periodEndDate] ); const finances = financesResult.length > 0 ? financesResult[0] : { total_income: 0, total_expenses: 0, balance: 0 }; // 6. Работа с долгами (колонки category, metadata могут отсутствовать — при ошибке нули) let debtStats = { cases_won: 0, debt_collected: 0 }; try { const debtResult = await query( `SELECT COUNT(*) as cases_won, COALESCE(SUM(CAST(metadata->>'debt_collected' AS NUMERIC)), 0) as debt_collected FROM applications WHERE building_id = $1 AND category = 'debt_collection' AND status = 'done' AND created_at::date >= $2::date AND created_at::date <= $3::date`, [targetBuildingId, periodStartDate, periodEndDate] ); if (debtResult.length > 0) debtStats = debtResult[0]; } catch (e) { console.warn('[Report Data] debt stats (column category/metadata may be missing):', e.message); } // 7. Расходы по категориям (из building_financial_data.expenses_by_items) const expensesResult = await query( `SELECT expenses_by_items FROM building_financial_data WHERE building_id = $1 AND period_start::date <= $3::date AND period_end::date >= $2::date ORDER BY period_start DESC LIMIT 1`, [targetBuildingId, periodStartDate, periodEndDate] ); let expensesByCategory = {}; if (expensesResult.length > 0 && expensesResult[0].expenses_by_items) { expensesByCategory = expensesResult[0].expenses_by_items; } // Категоризируем расходы const categorizedExpenses = { utilities: 0, // Коммунальные услуги maintenance: 0, // Содержание и ремонт management: 0, // Управление и обслуживание other: 0 // Прочие }; // У разных УК названия статей в ОСВ отличаются — проверяем по вхождению ключевых слов const utilitiesKeywords = ['электроэнергия', 'электричество', 'водоснабжение', 'водоотведение', 'отопление', 'газ', 'коммунальные']; const maintenanceKeywords = ['ремонт', 'содержание', 'лифт', 'уборка', 'мусор', 'материалы', 'благоустройство', 'техническое обслуживание', 'инженерное оборудование', 'общедомовое имущество', 'механизированная уборка', 'расходные материалы']; const managementKeywords = ['зарплата', 'персонал', 'административ', 'управленческ', 'страхование', 'управление', 'судебные издержки', 'услуги специализирован']; Object.entries(expensesByCategory).forEach(([item, amount]) => { const itemLower = item.toLowerCase(); const amountNum = parseFloat(amount) || 0; if (utilitiesKeywords.some(kw => itemLower.includes(kw))) { categorizedExpenses.utilities += amountNum; } else if (maintenanceKeywords.some(kw => itemLower.includes(kw))) { categorizedExpenses.maintenance += amountNum; } else if (managementKeywords.some(kw => itemLower.includes(kw))) { categorizedExpenses.management += amountNum; } else { categorizedExpenses.other += amountNum; } }); // 8. Мероприятия — только из модуля PR Мероприятия (pr_events) по дому и периоду let events = []; try { let prEventsResult = []; try { prEventsResult = await query( `SELECT id, title, date, type, category, status, location, attendees_count, budget, short_plan AS "shortPlan", announcement FROM pr_events WHERE date >= $2::date AND date <= $3::date AND ( location_building_id = $1 OR EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(location_building_ids, '[]'::jsonb)) AS elem WHERE elem::text = $1) OR (location_district_id IS NOT NULL AND location_district_id = (SELECT data->>'districtId' FROM buildings WHERE id = $1 LIMIT 1)) ) ORDER BY date DESC LIMIT 20`, [targetBuildingId, periodStartDate, periodEndDate] ); } catch (qErr) { // Если колонки location_building_ids/location_district_id отсутствуют — только по дому и дате prEventsResult = await query( `SELECT id, title, date, type, category, status, location, attendees_count, budget, short_plan AS "shortPlan", announcement FROM pr_events WHERE location_building_id = $1 AND date >= $2::date AND date <= $3::date ORDER BY date DESC LIMIT 20`, [targetBuildingId, periodStartDate, periodEndDate] ); } events = (prEventsResult || []).map(row => ({ id: row.id, title: row.title || '', date: row.date, description: (row.shortPlan || row.announcement) || '', location: row.location || buildingAddress, attendeesCount: row.attendees_count != null ? parseInt(row.attendees_count, 10) : 0 })); } catch (e) { console.warn('[Report Data] events (pr_events):', e.message); } // 9. NPS — сначала из таблицы nps_building_stats (сохраненные цифры), иначе из nps_responses let npsStats = { total_responses: 0, avg_score: 0, promoters: 0, passives: 0, detractors: 0 }; let nps = 0; const savedStatsResult = await query( `SELECT total_responses, nps_score, avg_score, promoters, passives, detractors, promoter_percent, detractor_percent FROM nps_building_stats WHERE building_id = $1 AND period_start = $2 AND period_end = $3`, [targetBuildingId, periodStartDate, periodEndDate] ); if (savedStatsResult.length > 0) { const row = savedStatsResult[0]; npsStats = { total_responses: parseInt(row.total_responses) || 0, avg_score: parseFloat(row.avg_score) || 0, promoters: parseInt(row.promoters) || 0, passives: parseInt(row.passives) || 0, detractors: parseInt(row.detractors) || 0 }; nps = parseInt(row.nps_score) || 0; } // Если в nps_building_stats нет данных — считаем из nps_responses (сравнение по дате без времени) if (npsStats.total_responses === 0) { const npsResult = await query( `SELECT COUNT(*) as total_responses, AVG(score)::numeric(3,1) as avg_score, COUNT(*) FILTER (WHERE score >= 9) as promoters, COUNT(*) FILTER (WHERE score >= 7 AND score <= 8) as passives, COUNT(*) FILTER (WHERE score <= 6) as detractors FROM nps_responses WHERE building_id = $1 AND (created_at::date >= $2::date AND created_at::date <= $3::date)`, [targetBuildingId, periodStartDate, periodEndDate] ); npsStats = npsResult.length > 0 ? npsResult[0] : npsStats; nps = npsStats.total_responses > 0 ? Math.round((npsStats.promoters / npsStats.total_responses * 100) - (npsStats.detractors / npsStats.total_responses * 100)) : 0; // Сохраняем в nps_building_stats для следующих запросов if (npsStats.total_responses > 0) { const promoterPct = (npsStats.promoters / npsStats.total_responses * 100).toFixed(2); const detractorPct = (npsStats.detractors / npsStats.total_responses * 100).toFixed(2); await query( `INSERT INTO nps_building_stats (building_id, period_start, period_end, total_responses, nps_score, avg_score, promoters, passives, detractors, promoter_percent, detractor_percent, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW()) ON CONFLICT (building_id, period_start, period_end) DO UPDATE SET total_responses = $4, nps_score = $5, avg_score = $6, promoters = $7, passives = $8, detractors = $9, promoter_percent = $10, detractor_percent = $11, updated_at = NOW()`, [targetBuildingId, periodStartDate, periodEndDate, npsStats.total_responses, nps, parseFloat(npsStats.avg_score) || 0, npsStats.promoters, npsStats.passives, npsStats.detractors, promoterPct, detractorPct] ).catch(err => console.warn('[NPS Stats] Ошибка сохранения в nps_building_stats:', err.message)); } } // Если всё ещё нет данных — пробуем из сохраненного отчета (content) if (npsStats.total_responses === 0 && report && report.content) { try { const savedContent = typeof report.content === 'string' ? JSON.parse(report.content) : report.content; if (savedContent?.nps && savedContent.nps.totalResponses > 0) { npsStats = { total_responses: savedContent.nps.totalResponses || 0, avg_score: savedContent.nps.avgScore || 0, promoters: savedContent.nps.promoters || 0, passives: savedContent.nps.passives || 0, detractors: savedContent.nps.detractors || 0 }; nps = savedContent.nps.score || 0; } } catch (e) { console.warn('[NPS Stats] Ошибка при извлечении сохраненных данных:', e); } } console.log(`[NPS Stats] Building: ${targetBuildingId}, Period: ${periodStartDate}-${periodEndDate}, Responses: ${npsStats.total_responses}, NPS: ${nps}`); // 10. Фото отчеты (из work_photos); task_id может отсутствовать, если миграция add_task_id не применена let workPhotos = []; try { const photosResult = await query( `SELECT w.id, w.work_name, w.work_date, w.description, w.photo_before_url, w.photo_after_url, w.task_id, a.description AS task_title, a.number AS task_number FROM work_photos w LEFT JOIN applications a ON (a.id::text = w.task_id OR a.number = w.task_id) WHERE w.building_id = $1 AND (w.work_date::date >= $2::date AND w.work_date::date <= $3::date) ORDER BY w.work_date DESC`, [targetBuildingId, periodStartDate, periodEndDate] ); workPhotos = photosResult.map(row => ({ id: row.id, workName: row.work_name, workDate: row.work_date, description: row.description, photoBeforeUrl: row.photo_before_url, photoAfterUrl: row.photo_after_url, taskId: row.task_id != null ? row.task_id : null, taskTitle: (row.task_title || row.task_number) || null })); } catch (e) { try { const photosResult = await query( `SELECT id, work_name, work_date, description, photo_before_url, photo_after_url FROM work_photos WHERE building_id = $1 AND (work_date::date >= $2::date AND work_date::date <= $3::date) ORDER BY work_date DESC`, [targetBuildingId, periodStartDate, periodEndDate] ); workPhotos = photosResult.map(row => ({ id: row.id, workName: row.work_name, workDate: row.work_date, description: row.description, photoBeforeUrl: row.photo_before_url, photoAfterUrl: row.photo_after_url, taskId: null, taskTitle: null })); } catch (e2) { console.warn('[Report Data] work_photos:', e2.message); } } // 11. План работ на следующий месяц (из applications с будущими датами) const nextMonthStart = new Date(periodEnd); nextMonthStart.setMonth(nextMonthStart.getMonth() + 1); nextMonthStart.setDate(1); const nextMonthEnd = new Date(nextMonthStart.getFullYear(), nextMonthStart.getMonth() + 1, 0); // План работ: в applications нет title, category — используем description let planItems = []; try { const planResult = await query( `SELECT id, description, deadline_at, status FROM applications WHERE building_id = $1 AND deadline_at >= $2 AND deadline_at <= $3 AND status != 'done' ORDER BY deadline_at ASC LIMIT 10`, [targetBuildingId, nextMonthStart, nextMonthEnd] ); planItems = planResult.map(row => ({ id: row.id, title: row.description || '', description: row.description, deadline: row.deadline_at, category: null, status: row.status })); } catch (e) { console.warn('[Report Data] plan items:', e.message); } // Если актуальных данных нет, пробуем взять из сохраненного отчета let savedContent = null; if (report && report.content) { try { savedContent = typeof report.content === 'string' ? JSON.parse(report.content) : report.content; } catch (e) { console.warn('[Report Data] Ошибка при парсинге сохраненного content:', e); } } // Fallback для finances if (finances.total_income === 0 && finances.total_expenses === 0 && savedContent?.finances) { console.log(`[Report Data] Используем сохраненные finances из отчета ${report?.id}`); finances.total_income = savedContent.finances.collected || 0; finances.total_expenses = savedContent.finances.expenses || 0; finances.balance = savedContent.finances.balance || 0; } // Fallback для applications if (appsStats.total === 0 && savedContent?.applications) { console.log(`[Report Data] Используем сохраненные applications из отчета ${report?.id}`); appsStats.total = savedContent.applications.total || 0; appsStats.completed = savedContent.applications.completed || 0; appsStats.completed_on_time = Math.round((savedContent.applications.quality || 0) / 100 * (savedContent.applications.total || 0)); appsQuality = savedContent.applications.quality || 0; } // Формируем ответ const payload = { building: { id: targetBuildingId, address: buildingAddress, imageUrl: buildingImage }, company: company ? { name: company.name, fullName: company.full_name, address: company.address, phone: company.phone, email: company.email, website: company.website, licenseNumber: company.license_number, licenseValidUntil: company.license_valid_until, logoUrl: company.logo_url } : null, period: { start: periodStart.toISOString(), end: periodEnd.toISOString() }, stats: { appsQuality: appsQuality, appsTotal: parseInt(appsStats.total) || 0, appsCompleted: parseInt(appsStats.completed) || 0, tasksTotal: parseInt(tasksStats.total) || 0, tasksCompleted: parseInt(tasksStats.completed) || 0, fundsCollected: parseFloat(finances.total_income) || 0, fundsSpent: parseFloat(finances.total_expenses) || 0, fundsBalance: parseFloat(finances.balance) || 0, debtCasesWon: parseInt(debtStats.cases_won) || 0, debtCollected: parseFloat(debtStats.debt_collected) || 0 }, expenses: { total: parseFloat(finances.total_expenses) || 0, byCategory: categorizedExpenses, details: expensesByCategory }, events: events, nps: { score: nps, avgScore: parseFloat(npsStats.avg_score) || 0, totalResponses: parseInt(npsStats.total_responses) || 0, promoters: parseInt(npsStats.promoters) || 0, passives: parseInt(npsStats.passives) || 0, detractors: parseInt(npsStats.detractors) || 0 }, workPhotos: workPhotos, planItems: planItems }; // Сохраняем снимок в resident_report_data (дом + период), чтобы данные были по ключу "дом 12, январь" const periodMonth = periodStart.getMonth() + 1; const expensesTotal = parseFloat(finances.total_expenses) || 0; await query( `INSERT INTO resident_report_data ( building_id, period_start, period_end, period_month, period_year, nps_score, nps_total_responses, nps_avg_score, nps_promoters, nps_passives, nps_detractors, apps_total, apps_completed, apps_quality, tasks_total, tasks_completed, funds_collected, funds_spent, funds_balance, debt_cases_won, debt_collected, expenses_total, expenses_by_category, events, work_photos, plan_items, snapshot, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23::jsonb, $24::jsonb, $25::jsonb, $26::jsonb, $27::jsonb, NOW() ) ON CONFLICT (building_id, period_start, period_end) DO UPDATE SET nps_score = EXCLUDED.nps_score, nps_total_responses = EXCLUDED.nps_total_responses, nps_avg_score = EXCLUDED.nps_avg_score, nps_promoters = EXCLUDED.nps_promoters, nps_passives = EXCLUDED.nps_passives, nps_detractors = EXCLUDED.nps_detractors, apps_total = EXCLUDED.apps_total, apps_completed = EXCLUDED.apps_completed, apps_quality = EXCLUDED.apps_quality, tasks_total = EXCLUDED.tasks_total, tasks_completed = EXCLUDED.tasks_completed, funds_collected = EXCLUDED.funds_collected, funds_spent = EXCLUDED.funds_spent, funds_balance = EXCLUDED.funds_balance, debt_cases_won = EXCLUDED.debt_cases_won, debt_collected = EXCLUDED.debt_collected, expenses_total = EXCLUDED.expenses_total, expenses_by_category = EXCLUDED.expenses_by_category, events = EXCLUDED.events, work_photos = EXCLUDED.work_photos, plan_items = EXCLUDED.plan_items, snapshot = EXCLUDED.snapshot, updated_at = NOW()`, [ targetBuildingId, periodStartDate, periodEndDate, periodMonth, periodYear, nps, parseInt(npsStats.total_responses) || 0, parseFloat(npsStats.avg_score) || 0, parseInt(npsStats.promoters) || 0, parseInt(npsStats.passives) || 0, parseInt(npsStats.detractors) || 0, parseInt(appsStats.total) || 0, parseInt(appsStats.completed) || 0, appsQuality, parseInt(tasksStats.total) || 0, parseInt(tasksStats.completed) || 0, parseFloat(finances.total_income) || 0, parseFloat(finances.total_expenses) || 0, parseFloat(finances.balance) || 0, parseInt(debtStats.cases_won) || 0, parseFloat(debtStats.debt_collected) || 0, expensesTotal, JSON.stringify(expensesByCategory), JSON.stringify(events), JSON.stringify(workPhotos), JSON.stringify(planItems), JSON.stringify(payload) ] ).catch(err => console.warn('[Report Data] Ошибка сохранения в resident_report_data:', err.message)); res.json(payload); } catch (err) { console.error('Error fetching report data:', err); const msg = (err && err.message) ? err.message : 'Unknown error'; res.status(500).json({ error: 'Failed to fetch report data: ' + msg, details: msg }); } }); // ========= ЮРИДИЧЕСКИЙ ОТДЕЛ: ДОСУДЕБНАЯ РАБОТА ========= // GET /api/legal/debtors - список должников app.get(`${API_PREFIX}/legal/debtors`, async (req, res) => { try { const { status, buildingId, search } = req.query; let queryText = ` SELECT id, building_id AS "buildingId", apartment, debtor_name AS "debtorName", phone, email, address, debt_amount AS "debtAmount", debt_months AS "debtMonths", status, transferred_from_finance AS "transferredFromFinance", created_at AS "createdAt", updated_at AS "updatedAt" FROM legal_debtors WHERE 1=1 `; const params = []; if (status) { queryText += ` AND status = $${params.length + 1}`; params.push(status); } if (buildingId) { queryText += ` AND building_id = $${params.length + 1}`; params.push(buildingId); } if (search) { queryText += ` AND ( apartment ILIKE $${params.length + 1} OR debtor_name ILIKE $${params.length + 1} OR address ILIKE $${params.length + 1} )`; const searchTerm = `%${search}%`; params.push(searchTerm, searchTerm, searchTerm); } queryText += ' ORDER BY created_at DESC'; const rows = await query(queryText, params); res.json(rows); } catch (err) { console.error('Error fetching legal debtors:', err); res.status(500).json({ error: 'Failed to fetch debtors', details: err.message }); } }); // POST /api/legal/debtors - создание должника (перемещение из финансов) app.post(`${API_PREFIX}/legal/debtors`, async (req, res) => { try { const { buildingId, apartment, debtorName, phone, email, address, debtAmount, debtMonths } = req.body; if (!apartment || !address || !debtAmount || !debtMonths) { return res.status(400).json({ error: 'Missing required fields: apartment, address, debtAmount, debtMonths' }); } const result = await query( `INSERT INTO legal_debtors (building_id, apartment, debtor_name, phone, email, address, debt_amount, debt_months, transferred_from_finance) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, building_id AS "buildingId", apartment, debtor_name AS "debtorName", phone, email, address, debt_amount AS "debtAmount", debt_months AS "debtMonths", status, transferred_from_finance AS "transferredFromFinance", created_at AS "createdAt", updated_at AS "updatedAt"`, [buildingId || null, apartment, debtorName || null, phone || null, email || null, address, debtAmount, debtMonths, true] ); // Автоматически создаем запись досудебной работы if (result[0]) { await query( `INSERT INTO pre_trial_work (debtor_id, status) VALUES ($1, 'new')`, [result[0].id] ); } try { await notificationService.createNotificationForResponsibleZone(pool, 'legal', 'debt', { type: 'legal_debtor', title: 'Новый должник', body: (result[0].debtorName || result[0].address || '').toString().slice(0, 150), entityType: 'legal_debtor', entityId: String(result[0].id), }); } catch (notifErr) { console.warn('Notification (legal debtor create):', notifErr.message); } res.status(201).json(result[0]); } catch (err) { console.error('Error creating legal debtor:', err); res.status(500).json({ error: 'Failed to create debtor', details: err.message }); } }); // GET /api/legal/pre-trial-work - список досудебных работ app.get(`${API_PREFIX}/legal/pre-trial-work`, async (req, res) => { try { // Проверяем существование таблиц const tableCheck = await query( `SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'pre_trial_work' )` ); if (!tableCheck[0]?.exists) { console.log('[pre-trial-work] Таблицы еще не созданы, возвращаем пустой массив'); return res.json([]); } const { status, assignedTo, search } = req.query; let queryText = ` SELECT pt.id, pt.debtor_id AS "debtorId", pt.assigned_to AS "assignedTo", pt.status, pt.promised_payment_date AS "promisedPaymentDate", pt.promised_payment_amount AS "promisedPaymentAmount", pt.transferred_to_court AS "transferredToCourt", pt.court_case_id AS "courtCaseId", pt.notes, pt.created_at AS "createdAt", pt.updated_at AS "updatedAt", json_build_object( 'id', d.id, 'buildingId', d.building_id, 'apartment', d.apartment, 'debtorName', d.debtor_name, 'phone', d.phone, 'email', d.email, 'address', d.address, 'debtAmount', d.debt_amount, 'debtMonths', d.debt_months, 'status', d.status ) AS debtor FROM pre_trial_work pt LEFT JOIN legal_debtors d ON pt.debtor_id = d.id WHERE 1=1 `; const params = []; if (status) { queryText += ` AND pt.status = $${params.length + 1}`; params.push(status); } if (assignedTo) { queryText += ` AND pt.assigned_to = $${params.length + 1}`; params.push(assignedTo); } if (search) { queryText += ` AND ( d.apartment ILIKE $${params.length + 1} OR d.debtor_name ILIKE $${params.length + 1} OR d.address ILIKE $${params.length + 1} )`; const searchTerm = `%${search}%`; params.push(searchTerm, searchTerm, searchTerm); } queryText += ' ORDER BY pt.updated_at DESC'; const rows = await query(queryText, params); // Загружаем действия и обещанные оплаты для каждой работы const worksWithDetails = await Promise.all(rows.map(async (work) => { // Фильтруем работы без должника if (!work.debtor || !work.debtor.id) { return null; } let actions = []; let promisedPayments = []; try { actions = await query( `SELECT id, work_id AS "workId", action_type AS "actionType", action_date AS "actionDate", performed_by AS "performedBy", result, notes, attachments, created_at AS "createdAt" FROM pre_trial_actions WHERE work_id = $1 ORDER BY action_date DESC`, [work.id] ).catch(() => []); } catch (err) { console.warn('Error loading actions for work', work.id, err); } try { promisedPayments = await query( `SELECT id, work_id AS "workId", promised_date AS "promisedDate", promised_amount AS "promisedAmount", actual_payment_date AS "actualPaymentDate", actual_payment_amount AS "actualPaymentAmount", is_paid AS "isPaid", reminder_sent AS "reminderSent", reminder_date AS "reminderDate", notes, created_at AS "createdAt", updated_at AS "updatedAt" FROM promised_payments WHERE work_id = $1 ORDER BY promised_date DESC`, [work.id] ).catch(() => []); } catch (err) { console.warn('Error loading payments for work', work.id, err); } return { ...work, debtor: work.debtor, actions: actions || [], promisedPayments: promisedPayments || [] }; })); // Фильтруем null значения (работы без должника) const validWorks = worksWithDetails.filter(w => w !== null && w.debtor); res.json(validWorks); } catch (err) { console.error('Error fetching pre-trial work:', err); // Возвращаем пустой массив при ошибке для graceful degradation res.json([]); } }); // POST /api/legal/pre-trial-work - создание досудебной работы app.post(`${API_PREFIX}/legal/pre-trial-work`, async (req, res) => { try { const { debtorId, assignedTo, notes } = req.body; if (!debtorId) { return res.status(400).json({ error: 'Missing required field: debtorId' }); } const result = await query( `INSERT INTO pre_trial_work (debtor_id, assigned_to, notes) VALUES ($1, $2, $3) RETURNING id, debtor_id AS "debtorId", assigned_to AS "assignedTo", status, promised_payment_date AS "promisedPaymentDate", promised_payment_amount AS "promisedPaymentAmount", transferred_to_court AS "transferredToCourt", court_case_id AS "courtCaseId", notes, created_at AS "createdAt", updated_at AS "updatedAt"`, [debtorId, assignedTo || null, notes || null] ); try { const row = result[0]; const opts = { type: 'pre_trial_new', title: 'Новая досудебная работа', body: (notes || '').slice(0, 80) || 'Назначена досудебная работа', entityType: 'pre_trial_work', entityId: String(row.id) }; if (assignedTo) { const userIds = await notificationService.resolveEmployeeNamesToUserIds(pool, [assignedTo]); if (userIds.length > 0) { await notificationService.createNotificationForUserIds(pool, userIds, opts); } else { await notificationService.createNotificationForResponsibleZone(pool, 'legal', 'preTrial', opts); } } else { await notificationService.createNotificationForResponsibleZone(pool, 'legal', 'preTrial', opts); } } catch (notifErr) { console.warn('[notifications] pre-trial POST:', notifErr.message); } res.status(201).json(result[0]); } catch (err) { console.error('Error creating pre-trial work:', err); res.status(500).json({ error: 'Failed to create pre-trial work', details: err.message }); } }); // PUT /api/legal/pre-trial-work/:id - обновление досудебной работы app.put(`${API_PREFIX}/legal/pre-trial-work/:id`, async (req, res) => { try { const { id } = req.params; const { assignedTo, status, notes, promisedPaymentDate, promisedPaymentAmount } = req.body; const updates = []; const params = []; let paramIndex = 1; if (assignedTo !== undefined) { updates.push(`assigned_to = $${paramIndex++}`); params.push(assignedTo); } if (status !== undefined) { updates.push(`status = $${paramIndex++}`); params.push(status); } if (notes !== undefined) { updates.push(`notes = $${paramIndex++}`); params.push(notes); } if (promisedPaymentDate !== undefined) { updates.push(`promised_payment_date = $${paramIndex++}`); params.push(promisedPaymentDate || null); } if (promisedPaymentAmount !== undefined) { updates.push(`promised_payment_amount = $${paramIndex++}`); params.push(promisedPaymentAmount || null); } updates.push(`updated_at = NOW()`); params.push(id); const result = await query( `UPDATE pre_trial_work SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING id, debtor_id AS "debtorId", assigned_to AS "assignedTo", status, promised_payment_date AS "promisedPaymentDate", promised_payment_amount AS "promisedPaymentAmount", transferred_to_court AS "transferredToCourt", court_case_id AS "courtCaseId", notes, created_at AS "createdAt", updated_at AS "updatedAt"`, params ); if (result.length === 0) { return res.status(404).json({ error: 'Pre-trial work not found' }); } res.json(result[0]); } catch (err) { console.error('Error updating pre-trial work:', err); res.status(500).json({ error: 'Failed to update pre-trial work', details: err.message }); } }); // POST /api/legal/pre-trial-work/:id/actions - добавление действия app.post(`${API_PREFIX}/legal/pre-trial-work/:id/actions`, async (req, res) => { try { const { id } = req.params; const { actionType, actionDate, performedBy, result, notes, attachments } = req.body; if (!actionType || !performedBy) { return res.status(400).json({ error: 'Missing required fields: actionType, performedBy' }); } if (!['call', 'letter', 'visit'].includes(actionType)) { return res.status(400).json({ error: 'Invalid actionType. Must be: call, letter, or visit' }); } const resultRows = await query( `INSERT INTO pre_trial_actions (work_id, action_type, action_date, performed_by, result, notes, attachments) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, work_id AS "workId", action_type AS "actionType", action_date AS "actionDate", performed_by AS "performedBy", result, notes, attachments, created_at AS "createdAt"`, [id, actionType, actionDate || new Date(), performedBy, result || null, notes || null, attachments || []] ); // Обновляем статус работы на "in_progress", если был "new" await query( `UPDATE pre_trial_work SET status = 'in_progress', updated_at = NOW() WHERE id = $1 AND status = 'new'`, [id] ); res.status(201).json(resultRows[0]); } catch (err) { console.error('Error creating action:', err); res.status(500).json({ error: 'Failed to create action', details: err.message }); } }); // POST /api/legal/pre-trial-work/:id/promised-payment - добавление обещанной оплаты app.post(`${API_PREFIX}/legal/pre-trial-work/:id/promised-payment`, async (req, res) => { try { const { id } = req.params; const { promisedDate, promisedAmount, notes } = req.body; if (!promisedDate || !promisedAmount) { return res.status(400).json({ error: 'Missing required fields: promisedDate, promisedAmount' }); } const resultRows = await query( `INSERT INTO promised_payments (work_id, promised_date, promised_amount, notes) VALUES ($1, $2, $3, $4) RETURNING id, work_id AS "workId", promised_date AS "promisedDate", promised_amount AS "promisedAmount", actual_payment_date AS "actualPaymentDate", actual_payment_amount AS "actualPaymentAmount", is_paid AS "isPaid", reminder_sent AS "reminderSent", reminder_date AS "reminderDate", notes, created_at AS "createdAt", updated_at AS "updatedAt"`, [id, promisedDate, promisedAmount, notes || null] ); // Обновляем досудебную работу await query( `UPDATE pre_trial_work SET status = 'promised_payment', promised_payment_date = $1, promised_payment_amount = $2, updated_at = NOW() WHERE id = $3`, [promisedDate, promisedAmount, id] ); res.status(201).json(resultRows[0]); } catch (err) { console.error('Error creating promised payment:', err); res.status(500).json({ error: 'Failed to create promised payment', details: err.message }); } }); // PUT /api/legal/pre-trial-work/:id/transfer-to-court - передача в суд app.put(`${API_PREFIX}/legal/pre-trial-work/:id/transfer-to-court`, async (req, res) => { try { const { id } = req.params; const { courtCaseId, courtCaseNumber, subject, courtName, judge } = req.body; // Получаем данные досудебной работы и должника const workData = await query( `SELECT pt.id, pt.debtor_id, pt.notes, d.debtor_name, d.address, d.debt_amount, d.apartment FROM pre_trial_work pt JOIN legal_debtors d ON pt.debtor_id = d.id WHERE pt.id = $1`, [id] ); if (workData.length === 0) { return res.status(404).json({ error: 'Pre-trial work not found' }); } const work = workData[0]; let createdCourtCaseId = courtCaseId; // Если судебное дело еще не создано, создаем его автоматически if (!courtCaseId) { const caseId = `case-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const caseNumber = courtCaseNumber || `А40-${Date.now().toString().slice(-6)}/${new Date().getFullYear()}`; const caseSubject = subject || `Взыскание задолженности по оплате жилищно-коммунальных услуг. ${work.address}, кв. ${work.apartment}`; try { const courtCaseResult = await query( `INSERT INTO legal_court_cases ( id, case_number, type, role, subject, debtor_name, address, amount, court_name, judge, notes, status ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'pre_trial') RETURNING id`, [ caseId, caseNumber, 'debt_recovery', 'plaintiff', caseSubject, work.debtor_name || null, work.address, work.debt_amount || 0, courtName || null, judge || null, `Автоматически создано при передаче из досудебной работы. ${work.notes || ''}` ] ); createdCourtCaseId = caseId; // Записываем в историю try { await query( `INSERT INTO legal_court_case_history (case_id, from_status, to_status, changed_by) VALUES ($1, NULL, 'pre_trial', $2)`, [caseId, 'System'] ); } catch (histErr) { console.warn('Failed to save court case history:', histErr); } console.log(`[transfer-to-court] Создано судебное дело ${caseNumber} для досудебной работы ${id}`); } catch (courtErr) { console.error('Error creating court case:', courtErr); // Продолжаем выполнение даже если не удалось создать дело } } // Обновляем досудебную работу const result = await query( `UPDATE pre_trial_work SET status = 'transferred_to_court', transferred_to_court = true, court_case_id = $1, updated_at = NOW() WHERE id = $2 RETURNING id, debtor_id AS "debtorId", assigned_to AS "assignedTo", status, promised_payment_date AS "promisedPaymentDate", promised_payment_amount AS "promisedPaymentAmount", transferred_to_court AS "transferredToCourt", court_case_id AS "courtCaseId", notes, created_at AS "createdAt", updated_at AS "updatedAt"`, [createdCourtCaseId || null, id] ); if (result.length === 0) { return res.status(404).json({ error: 'Pre-trial work not found' }); } // Обновляем статус должника await query( `UPDATE legal_debtors SET status = 'transferred_to_court', updated_at = NOW() WHERE id = (SELECT debtor_id FROM pre_trial_work WHERE id = $1)`, [id] ); res.json({ ...result[0], courtCaseId: createdCourtCaseId, message: createdCourtCaseId ? 'Дело передано в суд и создано судебное дело' : 'Дело передано в суд' }); } catch (err) { console.error('Error transferring to court:', err); res.status(500).json({ error: 'Failed to transfer to court', details: err.message }); } }); // PUT /api/legal/promised-payments/:id/mark-paid - отметить обещанную оплату как выполненную app.put(`${API_PREFIX}/legal/promised-payments/:id/mark-paid`, async (req, res) => { try { const { id } = req.params; const { actualPaymentDate, actualPaymentAmount } = req.body; const result = await query( `UPDATE promised_payments SET is_paid = true, actual_payment_date = $1, actual_payment_amount = $2, updated_at = NOW() WHERE id = $3 RETURNING id, work_id AS "workId", promised_date AS "promisedDate", promised_amount AS "promisedAmount", actual_payment_date AS "actualPaymentDate", actual_payment_amount AS "actualPaymentAmount", is_paid AS "isPaid", reminder_sent AS "reminderSent", reminder_date AS "reminderDate", notes, created_at AS "createdAt", updated_at AS "updatedAt"`, [actualPaymentDate || new Date(), actualPaymentAmount || null, id] ); if (result.length === 0) { return res.status(404).json({ error: 'Promised payment not found' }); } res.json(result[0]); } catch (err) { console.error('Error marking payment as paid:', err); res.status(500).json({ error: 'Failed to mark payment as paid', details: err.message }); } }); // ========= ПРОВЕРКА КОНТРАГЕНТОВ ЧЕРЕЗ DADATA ========= // DaData API credentials (fallback из env, если не заданы в панели управления) const DADATA_API_KEY_ENV = process.env.DADATA_API_KEY || 'e133e91a9124932f30c29bf0b2c88f9f278bb841'; const DADATA_SECRET_ENV = process.env.DADATA_SECRET || '51b46514663c36c314f8983068a1814062c24821'; // Пороги и настройки для расчёта благонадежности (Dadata findById/party) // Поля Dadata, используемые для риска: state.status, state.code, invalid, founders[].invalidity, // managers[].invalidity, address.invalidity, management.disqualified, finance.debt/penalty/income/revenue/expense, // capital.value, licenses[] (valid_to, activities). const COUNTERPARTY_RISK = { MIN_CAPITAL_THRESHOLD_RUB: 10000, // уставный капитал ниже — средний риск LICENSED_OKVED_CODES: [] // коды ОКВЭД, требующие лицензии (если заданы — проверяем наличие действующей лицензии) }; // POST /api/legal/check-counterparty - проверка контрагента по ИНН через DaData app.post(`${API_PREFIX}/legal/check-counterparty`, async (req, res) => { try { const { inn } = req.body; if (!inn) { return res.status(400).json({ error: 'ИНН не указан' }); } // Проверяем формат ИНН (10 или 12 цифр) const innRegex = /^\d{10}$|^\d{12}$/; if (!innRegex.test(inn)) { return res.status(400).json({ error: 'Неверный формат ИНН. Должно быть 10 или 12 цифр' }); } // Учётные данные: из панели управления (integration_settings) или из env let apiKey = DADATA_API_KEY_ENV; let secret = DADATA_SECRET_ENV; try { const settingsRows = await query( 'SELECT enabled, config FROM integration_settings WHERE key = $1', ['dadata'] ); if (settingsRows.length > 0 && settingsRows[0].enabled) { const config = settingsRows[0].config || {}; if (config.apiKey && config.apiKey.trim()) { apiKey = config.apiKey.trim(); } if (config.secret && config.secret.trim()) { secret = config.secret.trim(); } } } catch (settingsErr) { console.warn('[check-counterparty] Не удалось прочитать настройки DaData из БД, используем env:', settingsErr.message); } // Вызываем DaData API const dadataResponse = await fetch('https://suggestions.dadata.ru/suggestions/api/4_1/rs/findById/party', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': `Token ${apiKey}`, 'X-Secret': secret }, body: JSON.stringify({ query: inn }) }); if (!dadataResponse.ok) { const errorText = await dadataResponse.text(); console.error('DaData API error:', errorText); return res.status(dadataResponse.status).json({ error: 'Ошибка при обращении к DaData API', details: errorText }); } const dadataData = await dadataResponse.json(); if (!dadataData.suggestions || dadataData.suggestions.length === 0) { return res.status(404).json({ error: 'Организация с указанным ИНН не найдена', inn }); } const company = dadataData.suggestions[0].data; // Извлечение дополнительных полей Dadata (тариф «Максимальный») const capital = company.capital ? { type: company.capital.type, value: company.capital.value } : null; const documents = company.documents || {}; const smb = documents.smb ? { category: documents.smb.category, issueDate: documents.smb.issue_date } : null; const licenses = Array.isArray(company.licenses) ? company.licenses.map(l => ({ number: l.number, series: l.series, validFrom: l.valid_from, validTo: l.valid_to, activities: l.activities || [], issueAuthority: l.issue_authority })) : []; const addressInvalidity = company.address && company.address.invalidity ? company.address.invalidity : null; const foundersInvalidity = Array.isArray(company.founders) ? company.founders.filter(f => f.invalidity).map(f => ({ name: f.name || f.fio, invalidity: f.invalidity })) : []; const managersInvalidity = Array.isArray(company.managers) ? company.managers.filter(m => m.invalidity).map(m => ({ name: (m.fio && `${m.fio.surname || ''} ${m.fio.name || ''} ${m.fio.patronymic || ''}`.trim()) || m.name, post: m.post, invalidity: m.invalidity })) : []; const managementDisqualified = company.management && company.management.disqualified === true; // Определяем уровень риска на основе данных let riskLevel = 'low'; const riskReasons = []; // 1. Статус организации if (company.state) { if (company.state.status === 'LIQUIDATED') { riskLevel = 'high'; riskReasons.push('Организация ликвидирована'); } else if (company.state.status === 'LIQUIDATING') { riskLevel = 'high'; riskReasons.push('Организация находится в процессе ликвидации'); } else if (company.state.status === 'BANKRUPT') { riskLevel = 'high'; riskReasons.push('Организация признана банкротом'); } else if (company.state.status === 'REORGANIZING') { riskLevel = 'medium'; riskReasons.push('Организация находится в процессе реорганизации'); } } // 2. Недостоверность сведений (общая и по категориям) if (company.invalid === true) { riskLevel = 'high'; riskReasons.push('Обнаружены недостоверные сведения об организации'); } if (addressInvalidity) { if (riskLevel !== 'high') riskLevel = 'high'; if (!riskReasons.some(r => r.includes('адрес'))) riskReasons.push('Недостоверный адрес (ФНС)'); } if (foundersInvalidity.length > 0) { if (riskLevel !== 'high') riskLevel = 'high'; if (!riskReasons.some(r => r.includes('учредител'))) riskReasons.push('Недостоверные сведения об учредителях'); } if (managersInvalidity.length > 0) { if (riskLevel !== 'high') riskLevel = 'high'; if (!riskReasons.some(r => r.includes('руководител'))) riskReasons.push('Недостоверные сведения о руководителе'); } // 3. Дисквалификация руководителя if (managementDisqualified) { riskLevel = 'high'; riskReasons.push('Руководитель дисквалифицирован'); } // 4. Недоимки и штрафы if (company.finance) { if (company.finance.debt && company.finance.debt > 0) { riskLevel = riskLevel === 'high' ? 'high' : 'medium'; riskReasons.push(`Недоимки по налогам: ${company.finance.debt.toLocaleString()} ₽`); } if (company.finance.penalty && company.finance.penalty > 0) { riskLevel = riskLevel === 'high' ? 'high' : 'medium'; riskReasons.push(`Налоговые штрафы: ${company.finance.penalty.toLocaleString()} ₽`); } // 5. Убыточность const rev = company.finance.revenue; const exp = company.finance.expense; if (typeof rev === 'number' && typeof exp === 'number' && exp > rev) { riskLevel = riskLevel === 'high' ? 'high' : 'medium'; riskReasons.push('Компания убыточна по отчётности за год'); } } // 6. Низкий уставный капитал (только если поле пришло) if (capital && typeof capital.value === 'number' && capital.value < COUNTERPARTY_RISK.MIN_CAPITAL_THRESHOLD_RUB) { riskLevel = riskLevel === 'high' ? 'high' : 'medium'; riskReasons.push(`Низкий уставный капитал: ${capital.value.toLocaleString()} ₽`); } // 7. Лицензии: при заданном списке лицензируемых ОКВЭД — проверка наличия действующей лицензии const mainOkved = company.okved; if (COUNTERPARTY_RISK.LICENSED_OKVED_CODES.length > 0 && mainOkved && COUNTERPARTY_RISK.LICENSED_OKVED_CODES.some(code => mainOkved.startsWith(code))) { const now = Date.now(); const hasValidLicense = licenses.some(l => l.validTo != null && new Date(l.validTo).getTime() >= now); if (!hasValidLicense) { riskLevel = riskLevel === 'high' ? 'high' : 'medium'; riskReasons.push('Отсутствует или истекла лицензия на основной вид деятельности'); } } // Формируем ответ const result = { inn: company.inn, kpp: company.kpp || null, ogrn: company.ogrn || null, name: company.name?.full_with_opf || company.name?.short_with_opf || company.name?.full || 'Не указано', shortName: company.name?.short_with_opf || company.name?.short || null, type: company.type, // LEGAL или INDIVIDUAL status: company.state?.status || 'UNKNOWN', registrationDate: company.state?.registration_date ? new Date(company.state.registration_date).toISOString().split('T')[0] : null, liquidationDate: company.state?.liquidation_date ? new Date(company.state.liquidation_date).toISOString().split('T')[0] : null, address: company.address?.value || company.address?.unrestricted_value || null, okved: company.okved || null, okveds: company.okveds || [], management: company.management ? { name: company.management.name, post: company.management.post } : null, finance: company.finance ? { taxSystem: company.finance.tax_system, income: company.finance.income, revenue: company.finance.revenue, expense: company.finance.expense, debt: company.finance.debt, penalty: company.finance.penalty, year: company.finance.year } : null, authorities: company.authorities ? { ftsRegistration: company.authorities.fts_registration, ftsReport: company.authorities.fts_report, pf: company.authorities.pf, sif: company.authorities.sif } : null, phones: company.phones || [], emails: company.emails || [], employeeCount: company.employee_count || null, capital, smb, licenses, addressInvalidity: addressInvalidity || undefined, foundersInvalidity: foundersInvalidity.length ? foundersInvalidity : undefined, managersInvalidity: managersInvalidity.length ? managersInvalidity : undefined, managementDisqualified: managementDisqualified || undefined, riskLevel, riskReasons, checkedDate: new Date().toISOString(), rawData: company // Полные данные для детального просмотра }; // Сохраняем результат проверки в БД try { const checkId = await query( `INSERT INTO counterparty_checks ( inn, kpp, ogrn, name, short_name, type, status, registration_date, liquidation_date, address, okved, okveds, management_name, management_post, finance_data, authorities_data, phones, emails, employee_count, risk_level, risk_reasons, raw_data, checked_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23) RETURNING id`, [ result.inn, result.kpp, result.ogrn, result.name, result.shortName, result.type, result.status, result.registrationDate, result.liquidationDate, result.address, result.okved, JSON.stringify(result.okveds || []), result.management?.name || null, result.management?.post || null, JSON.stringify(result.finance || {}), JSON.stringify(result.authorities || {}), JSON.stringify(result.phones || []), JSON.stringify(result.emails || []), result.employeeCount, result.riskLevel, result.riskReasons, JSON.stringify(result.rawData || {}), req.body.checkedBy || 'System' // Можно передавать из фронтенда ] ); result.id = checkId[0].id; } catch (saveError) { console.error('Error saving counterparty check:', saveError); // Не прерываем выполнение, просто логируем ошибку } res.json(result); } catch (err) { console.error('Error checking counterparty:', err); res.status(500).json({ error: 'Ошибка при проверке контрагента', details: err.message }); } }); // GET /api/legal/counterparties - список проверенных контрагентов app.get(`${API_PREFIX}/legal/counterparties`, async (req, res) => { try { const { riskLevel, search, limit = 50 } = req.query; let queryText = ` SELECT id, inn, kpp, ogrn, name, short_name AS "shortName", type, status, registration_date AS "registrationDate", liquidation_date AS "liquidationDate", address, okved, okveds, management_name AS "managementName", management_post AS "managementPost", finance_data AS "finance", authorities_data AS "authorities", phones, emails, employee_count AS "employeeCount", risk_level AS "riskLevel", risk_reasons AS "riskReasons", checked_by AS "checkedBy", checked_at AS "checkedDate", notes, created_at AS "createdAt" FROM counterparty_checks WHERE 1=1 `; const params = []; if (riskLevel) { queryText += ` AND risk_level = $${params.length + 1}`; params.push(riskLevel); } if (search) { queryText += ` AND ( name ILIKE $${params.length + 1} OR inn ILIKE $${params.length + 1} )`; const searchTerm = `%${search}%`; params.push(searchTerm, searchTerm); } queryText += ` ORDER BY checked_at DESC LIMIT $${params.length + 1}`; params.push(parseInt(limit)); const rows = await query(queryText, params); // Преобразуем JSONB поля const formattedRows = rows.map(row => ({ ...row, okveds: row.okveds || [], finance: row.finance || null, authorities: row.authorities || null, phones: row.phones || [], emails: row.emails || [], riskReasons: row.riskReasons || [] })); res.json(formattedRows); } catch (err) { console.error('Error fetching counterparties:', err); res.status(500).json({ error: 'Failed to fetch counterparties', details: err.message }); } }); // GET /api/legal/counterparties/:id - получить детальный отчет о проверке app.get(`${API_PREFIX}/legal/counterparties/:id`, async (req, res) => { try { const { id } = req.params; const result = await query( `SELECT id, inn, kpp, ogrn, name, short_name AS "shortName", type, status, registration_date AS "registrationDate", liquidation_date AS "liquidationDate", address, okved, okveds, management_name AS "managementName", management_post AS "managementPost", finance_data AS "finance", authorities_data AS "authorities", phones, emails, employee_count AS "employeeCount", risk_level AS "riskLevel", risk_reasons AS "riskReasons", raw_data AS "rawData", checked_by AS "checkedBy", checked_at AS "checkedDate", notes, created_at AS "createdAt" FROM counterparty_checks WHERE id = $1`, [id] ); if (result.length === 0) { return res.status(404).json({ error: 'Проверка не найдена' }); } const check = result[0]; const raw = check.rawData || {}; // Извлекаем расширенные поля из raw_data (для отчёта и UI) const capital = raw.capital ? { type: raw.capital.type, value: raw.capital.value } : null; const documents = raw.documents || {}; const smb = documents.smb ? { category: documents.smb.category, issueDate: documents.smb.issue_date } : null; const licenses = Array.isArray(raw.licenses) ? raw.licenses.map(l => ({ number: l.number, series: l.series, validFrom: l.valid_from, validTo: l.valid_to, activities: l.activities || [], issueAuthority: l.issue_authority })) : []; const addressInvalidity = raw.address && raw.address.invalidity ? raw.address.invalidity : null; const foundersInvalidity = Array.isArray(raw.founders) ? raw.founders.filter(f => f.invalidity).map(f => ({ name: f.name || f.fio, invalidity: f.invalidity })) : []; const managersInvalidity = Array.isArray(raw.managers) ? raw.managers.filter(m => m.invalidity).map(m => ({ name: (m.fio && `${(m.fio.surname || '').trim()} ${(m.fio.name || '').trim()} ${(m.fio.patronymic || '').trim()}`.trim()) || m.name, post: m.post, invalidity: m.invalidity })) : []; const managementDisqualified = raw.management && raw.management.disqualified === true; // Преобразуем JSONB поля const formattedCheck = { ...check, okveds: check.okveds || [], finance: check.finance || null, authorities: check.authorities || null, phones: check.phones || [], emails: check.emails || [], riskReasons: check.riskReasons || [], rawData: check.rawData || null, capital: capital || undefined, smb: smb || undefined, licenses: licenses.length ? licenses : undefined, addressInvalidity: addressInvalidity || undefined, foundersInvalidity: foundersInvalidity.length ? foundersInvalidity : undefined, managersInvalidity: managersInvalidity.length ? managersInvalidity : undefined, managementDisqualified: managementDisqualified || undefined }; res.json(formattedCheck); } catch (err) { console.error('Error fetching counterparty check:', err); res.status(500).json({ error: 'Failed to fetch counterparty check', details: err.message }); } }); // ========= МОДУЛЬ РАЗВИТИЯ: ENDPOINTS ========= /** * Создание аудита по дому при попадании объекта воронки на этап «Анализ». * Вызывать при создании (POST) или переходе (PUT) на status === 'analysis'. * Создаёт запись в development_audits только если для объекта (address/building_id) ещё нет аудита. */ async function ensureAuditWhenLandedOnAnalysis(pipelineRow) { if (!pipelineRow || pipelineRow.status !== 'analysis') return; const buildingId = pipelineRow.building_id || null; const address = pipelineRow.address; if (!address) return; try { const existing = await query( 'SELECT id FROM development_audits WHERE building_id = $1 OR address = $2 LIMIT 1', [buildingId, address] ); if (existing.length > 0) return; const auditId = `a-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const wear_percent = 0; const complexity_index = 0; const projected_margin = 15; const calculated_tariff = calculateTariffFromAudit({ wear_percent, complexity_index, projected_margin }); await query( `INSERT INTO development_audits (id, building_id, address, status, wear_percent, roof_condition, basement_condition, calculated_tariff, projected_margin, complexity_index, inspection_data, audit_date, notes) VALUES ($1, $2, $3, 'new', $4, $5, $6, $7, $8, $9, $10::jsonb, $11, $12)`, [ auditId, buildingId, address, wear_percent, 'fair', 'fair', calculated_tariff, projected_margin, complexity_index, JSON.stringify({}), new Date().toISOString().split('T')[0], 'Автоматически создан при попадании объекта на этап «Анализ». Заполните пункты осмотра — износ и тариф рассчитаются автоматически.' ] ); } catch (err) { console.warn('[ensureAuditWhenLandedOnAnalysis]', err.message); } } /** * Расчёт прогнозного тарифа по данным аудита. * Формула: базовая ставка × (1 + коэффициент износа) × (1 + коэффициент сложности) / (1 − маржа). * Базовая ставка 28 ₽/м²; износ и сложность увеличивают тариф; маржа — целевая доля прибыли. */ function calculateTariffFromAudit(audit) { const BASE_TARIFF = 28; const wearPercent = Math.max(0, Math.min(100, Number(audit.wear_percent) || 0)); const complexityIndex = audit.complexity_index != null ? Math.max(0, Math.min(100, Number(audit.complexity_index))) : 50; const marginPercent = Math.max(0, Math.min(50, Number(audit.projected_margin) || 15)); const wearFactor = 1 + (wearPercent / 100) * 0.5; const complexityFactor = 1 + (complexityIndex / 100) * 0.2; const marginFactor = 1 / (1 - marginPercent / 100); const tariff = BASE_TARIFF * wearFactor * complexityFactor * marginFactor; return Math.round(tariff * 100) / 100; } // ========= PIPELINE (Воронка) ========= // GET /api/development/pipeline - список объектов в воронке app.get(`${API_PREFIX}/development/pipeline`, async (req, res) => { try { const { status, search } = req.query; let queryText = 'SELECT * FROM development_pipeline WHERE 1=1'; const params = []; if (status) { queryText += ` AND status = $${params.length + 1}`; params.push(status); } if (search) { const searchParam = `%${search}%`; queryText += ` AND (address ILIKE $${params.length + 1} OR manager ILIKE $${params.length + 1})`; params.push(searchParam); } queryText += ' ORDER BY created_at DESC'; const rows = await query(queryText, params); // Преобразуем snake_case в camelCase для фронтенда const formatted = rows.map((r) => ({ id: r.id, address: r.address, type: r.type, floors: r.floors, area: parseFloat(r.area) || 0, apartments: r.apartments, status: r.status, probability: r.probability, expectedRevenue: parseFloat(r.expected_revenue) || 0, manager: r.manager, buildingId: r.building_id || null, notes: r.notes || null, })); res.json(formatted); } catch (err) { console.error('Error fetching pipeline:', err); res.status(500).json({ error: 'Failed to fetch pipeline' }); } }); // POST /api/development/pipeline - добавить объект app.post(`${API_PREFIX}/development/pipeline`, async (req, res) => { try { const { id, address, type, floors, area, apartments, status, probability, expected_revenue, manager, building_id, notes } = req.body; const pipelineStatus = status || 'incoming'; if (!id || !address || !type || !floors || !area || !apartments || !manager) { return res.status(400).json({ error: 'Missing required fields' }); } const result = await query( `INSERT INTO development_pipeline (id, address, type, floors, area, apartments, status, probability, expected_revenue, manager, building_id, notes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`, [id, address, type, floors, area, apartments, pipelineStatus, probability || 0, expected_revenue || 0, manager, building_id || null, notes || null] ); // Автоматически создаем маркетинговую активность для нового объекта try { await query( `INSERT INTO development_marketing_activities (id, address, status, activists_count, meetings_held, ads_distributed) VALUES ($1, $2, 'voting', 0, 0, 0) ON CONFLICT DO NOTHING`, [`m-${id}`, address] ); } catch (err) { console.warn('Failed to create marketing activity:', err); } // При попадании на этап «Анализ» создаём аудит по дому (если ещё нет) await ensureAuditWhenLandedOnAnalysis(result[0]); // Проверяем условия для автоматического перехода (только для старых статусов) try { await pipelineAutomation.checkAutoTransition(id); } catch (err) { console.warn('Failed to check auto transition:', err); } const r = result[0]; res.status(201).json({ id: r.id, address: r.address, type: r.type, floors: r.floors, area: parseFloat(r.area) || 0, apartments: r.apartments, status: r.status, probability: r.probability, expectedRevenue: parseFloat(r.expected_revenue) || 0, manager: r.manager, buildingId: r.building_id || null, notes: r.notes || null, }); } catch (err) { console.error('Error creating pipeline item:', err); res.status(500).json({ error: 'Failed to create pipeline item', details: err.message }); } }); // PUT /api/development/pipeline/:id - обновить объект app.put(`${API_PREFIX}/development/pipeline/:id`, async (req, res) => { try { const { id } = req.params; const body = req.body; const address = body.address; const type = body.type; const floors = body.floors; const area = body.area; const apartments = body.apartments; const status = body.status; const probability = body.probability; const expected_revenue = body.expected_revenue !== undefined ? body.expected_revenue : body.expectedRevenue; const manager = body.manager; const building_id = body.building_id !== undefined ? body.building_id : body.buildingId; const notes = body.notes; const updates = []; const params = []; let paramIndex = 1; if (address !== undefined) { updates.push(`address = $${paramIndex++}`); params.push(address); } if (type !== undefined) { updates.push(`type = $${paramIndex++}`); params.push(type); } if (floors !== undefined) { updates.push(`floors = $${paramIndex++}`); params.push(floors); } if (area !== undefined) { updates.push(`area = $${paramIndex++}`); params.push(area); } if (apartments !== undefined) { updates.push(`apartments = $${paramIndex++}`); params.push(apartments); } if (status !== undefined) { updates.push(`status = $${paramIndex++}`); params.push(status); } if (probability !== undefined) { updates.push(`probability = $${paramIndex++}`); params.push(probability); } if (expected_revenue !== undefined) { updates.push(`expected_revenue = $${paramIndex++}`); params.push(expected_revenue); } if (manager !== undefined) { updates.push(`manager = $${paramIndex++}`); params.push(manager); } if (building_id !== undefined) { updates.push(`building_id = $${paramIndex++}`); params.push(building_id); } if (notes !== undefined) { updates.push(`notes = $${paramIndex++}`); params.push(notes); } if (updates.length === 0) { return res.status(400).json({ error: 'No fields to update' }); } params.push(id); const result = await query( `UPDATE development_pipeline SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`, params ); if (result.length === 0) { return res.status(404).json({ error: 'Pipeline item not found' }); } const r = result[0]; // При переходе на этап «Анализ» создаём аудит по дому (если ещё нет) if (status !== undefined && r.status === 'analysis') { await ensureAuditWhenLandedOnAnalysis(r); } try { const opts = { type: 'pipeline_update', title: 'Объект воронки обновлён', body: status !== undefined ? `Статус: ${r.status}. ${(r.address || '').slice(0, 50)}` : (r.address || '').slice(0, 80), entityType: 'pipeline', entityId: String(id) }; const managerName = r.manager || manager; if (managerName) { const userIds = await notificationService.resolveEmployeeNamesToUserIds(pool, [managerName]); if (userIds.length > 0) { await notificationService.createNotificationForUserIds(pool, userIds, opts); } else { await notificationService.createNotificationForResponsibleZone(pool, 'development', 'pipeline', opts); } } else { await notificationService.createNotificationForResponsibleZone(pool, 'development', 'pipeline', opts); } } catch (notifErr) { console.warn('[notifications] pipeline PUT:', notifErr.message); } res.json({ id: r.id, address: r.address, type: r.type, floors: r.floors, area: parseFloat(r.area) || 0, apartments: r.apartments, status: r.status, probability: r.probability, expectedRevenue: parseFloat(r.expected_revenue) || 0, manager: r.manager, buildingId: r.building_id || null, notes: r.notes || null, }); } catch (err) { console.error('Error updating pipeline item:', err); res.status(500).json({ error: 'Failed to update pipeline item', details: err.message }); } }); // DELETE /api/development/pipeline/:id - удалить объект app.delete(`${API_PREFIX}/development/pipeline/:id`, async (req, res) => { try { const { id } = req.params; const result = await query('DELETE FROM development_pipeline WHERE id = $1 RETURNING *', [id]); if (result.length === 0) { return res.status(404).json({ error: 'Pipeline item not found' }); } res.json({ success: true, message: 'Pipeline item deleted' }); } catch (err) { console.error('Error deleting pipeline item:', err); res.status(500).json({ error: 'Failed to delete pipeline item' }); } }); // ========= OSS (Собрания) ========= // GET /api/development/oss - список ОСС app.get(`${API_PREFIX}/development/oss`, async (req, res) => { try { const { status, building_id } = req.query; let queryText = 'SELECT * FROM development_oss_sessions WHERE 1=1'; const params = []; if (status) { queryText += ` AND status = $${params.length + 1}`; params.push(status); } if (building_id) { queryText += ` AND building_id = $${params.length + 1}`; params.push(building_id); } queryText += ' ORDER BY start_date DESC, created_at DESC'; const rows = await query(queryText, params); // Преобразуем snake_case в camelCase для фронтенда const formattedRows = rows.map((row) => ({ id: row.id, buildingId: row.building_id || '', address: row.address, startDate: row.start_date, endDate: row.end_date, quorumCurrent: parseFloat(row.quorum_current) || 0, quorumTotal: parseFloat(row.quorum_total) || 0, status: row.status, type: row.type, description: row.description || null, agendaItems: Array.isArray(row.agenda_items) ? row.agenda_items : (row.agenda_items ? JSON.parse(row.agenda_items) : []), })); res.json(formattedRows); } catch (err) { console.error('Error fetching OSS sessions:', err); res.status(500).json({ error: 'Failed to fetch OSS sessions' }); } }); // POST /api/development/oss - создать ОСС app.post(`${API_PREFIX}/development/oss`, async (req, res) => { try { const { id, building_id, address, start_date, end_date, quorum_total, status, type, description, agenda_items } = req.body; const agendaItemsJson = Array.isArray(agenda_items) ? JSON.stringify(agenda_items) : (agenda_items != null ? (typeof agenda_items === 'string' ? agenda_items : JSON.stringify([])) : '[]'); if (!id || !address || !start_date || !end_date || !quorum_total || !type) { return res.status(400).json({ error: 'Missing required fields' }); } let result; try { result = await query( `INSERT INTO development_oss_sessions (id, building_id, address, start_date, end_date, quorum_total, quorum_current, status, type, description, agenda_items) VALUES ($1, $2, $3, $4, $5, $6, 0, $7, $8, $9, $10::jsonb) RETURNING *`, [id, building_id || null, address, start_date, end_date, quorum_total, status || 'planned', type, description || null, agendaItemsJson] ); } catch (insertErr) { if (insertErr.message && insertErr.message.includes('agenda_items')) { result = await query( `INSERT INTO development_oss_sessions (id, building_id, address, start_date, end_date, quorum_total, quorum_current, status, type, description) VALUES ($1, $2, $3, $4, $5, $6, 0, $7, $8, $9) RETURNING *`, [id, building_id || null, address, start_date, end_date, quorum_total, status || 'planned', type, description || null] ); } else { throw insertErr; } } if (result.length === 0) { return res.status(500).json({ error: 'Failed to create OSS session' }); } const row = result[0]; // Преобразуем snake_case в camelCase для фронтенда const formattedResult = { id: row.id, buildingId: row.building_id || '', address: row.address, startDate: row.start_date, endDate: row.end_date, quorumCurrent: parseFloat(row.quorum_current) || 0, quorumTotal: parseFloat(row.quorum_total) || 0, status: row.status, type: row.type, description: row.description || null, agendaItems: Array.isArray(row.agenda_items) ? row.agenda_items : (row.agenda_items ? JSON.parse(row.agenda_items) : []), }; try { await notificationService.createNotificationForResponsibleZone(pool, 'development', 'oss', { type: 'oss', title: 'Новая ОСС', body: (row.address || row.type || 'ОСС') + (row.start_date ? ' — ' + String(row.start_date).slice(0, 10) : ''), entityType: 'oss', entityId: String(row.id), }); } catch (notifErr) { console.warn('Notification (OSS create):', notifErr.message); } res.status(201).json(formattedResult); } catch (err) { console.error('Error creating OSS session:', err); res.status(500).json({ error: 'Failed to create OSS session', details: err.message }); } }); // PUT /api/development/oss/:id - обновить ОСС app.put(`${API_PREFIX}/development/oss/:id`, async (req, res) => { try { const { id } = req.params; const { address, start_date, end_date, quorum_total, quorum_current, status, type, description, agenda_items } = req.body; const updates = []; const params = []; let paramIndex = 1; if (address !== undefined) { updates.push(`address = $${paramIndex++}`); params.push(address); } if (start_date !== undefined) { updates.push(`start_date = $${paramIndex++}`); params.push(start_date); } if (end_date !== undefined) { updates.push(`end_date = $${paramIndex++}`); params.push(end_date); } if (quorum_total !== undefined) { updates.push(`quorum_total = $${paramIndex++}`); params.push(quorum_total); } if (quorum_current !== undefined) { updates.push(`quorum_current = $${paramIndex++}`); params.push(quorum_current); } if (status !== undefined) { updates.push(`status = $${paramIndex++}`); params.push(status); } if (type !== undefined) { updates.push(`type = $${paramIndex++}`); params.push(type); } if (description !== undefined) { updates.push(`description = $${paramIndex++}`); params.push(description); } if (agenda_items !== undefined) { const agendaItemsJson = Array.isArray(agenda_items) ? JSON.stringify(agenda_items) : (typeof agenda_items === 'string' ? agenda_items : '[]'); updates.push(`agenda_items = $${paramIndex++}::jsonb`); params.push(agendaItemsJson); } if (updates.length === 0) { return res.status(400).json({ error: 'No fields to update' }); } params.push(id); const result = await query( `UPDATE development_oss_sessions SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`, params ); if (result.length === 0) { return res.status(404).json({ error: 'OSS session not found' }); } const row = result[0]; try { await notificationService.createNotificationForResponsibleZone(pool, 'development', 'oss', { type: 'oss', title: 'Изменение ОСС', body: (row.address || row.type || 'ОСС') + (row.start_date ? ' — ' + String(row.start_date).slice(0, 10) : ''), entityType: 'oss', entityId: String(row.id), }); } catch (notifErr) { console.warn('Notification (OSS update):', notifErr.message); } res.json({ id: row.id, buildingId: row.building_id || '', address: row.address, startDate: row.start_date, endDate: row.end_date, quorumCurrent: parseFloat(row.quorum_current) || 0, quorumTotal: parseFloat(row.quorum_total) || 0, status: row.status, type: row.type, description: row.description || null, agendaItems: Array.isArray(row.agenda_items) ? row.agenda_items : (row.agenda_items ? JSON.parse(row.agenda_items) : []), }); } catch (err) { console.error('Error updating OSS session:', err); res.status(500).json({ error: 'Failed to update OSS session', details: err.message }); } }); // PUT /api/development/oss/:id/quorum - обновить кворум app.put(`${API_PREFIX}/development/oss/:id/quorum`, async (req, res) => { try { const { id } = req.params; const { quorum_current } = req.body; if (quorum_current === undefined) { return res.status(400).json({ error: 'quorum_current is required' }); } const result = await query( `UPDATE development_oss_sessions SET quorum_current = $1 WHERE id = $2 RETURNING *`, [quorum_current, id] ); if (result.length === 0) { return res.status(404).json({ error: 'OSS session not found' }); } const oss = result[0]; // Проверяем, достигнут ли кворум для автоматического завершения const quorumPercent = (oss.quorum_current / oss.quorum_total) * 100; if (quorumPercent > 50 && oss.status === 'active') { // Можно автоматически завершить ОСС, если кворум достигнут // Но лучше оставить это на ручное подтверждение } // Вызываем обработчик автоматизации для проверки переходов try { await pipelineAutomation.handleOSSCompletion(oss.id); } catch (autoError) { console.warn('Pipeline automation error (non-critical):', autoError); } res.json(oss); } catch (err) { console.error('Error updating OSS quorum:', err); res.status(500).json({ error: 'Failed to update OSS quorum' }); } }); // POST /api/development/oss/:id/ballot - внести бюллетень app.post(`${API_PREFIX}/development/oss/:id/ballot`, async (req, res) => { try { const { id } = req.params; const { apartment, owner_name, area, vote_result, notes, votes_by_item } = req.body; const votesByItemJson = votes_by_item != null && typeof votes_by_item === 'object' ? JSON.stringify(votes_by_item) : (votes_by_item != null && typeof votes_by_item === 'string' ? votes_by_item : '{}'); if (!apartment || !area) { return res.status(400).json({ error: 'Missing required fields: apartment, area' }); } // Проверяем существование ОСС const ossCheck = await query('SELECT id FROM development_oss_sessions WHERE id = $1', [id]); if (ossCheck.length === 0) { return res.status(404).json({ error: 'OSS session not found' }); } // Добавляем или обновляем запись в реестре const existing = await query( 'SELECT id FROM development_oss_registry WHERE oss_session_id = $1 AND apartment = $2', [id, apartment] ); let result; const updateWithVotes = () => query( `UPDATE development_oss_registry SET owner_name = $1, area = $2, ballot_submitted = TRUE, ballot_date = CURRENT_DATE, vote_result = $3, notes = $4, votes_by_item = $5::jsonb WHERE id = $6 RETURNING *`, [owner_name || null, area, vote_result || null, notes || null, votesByItemJson, existing[0].id] ); const insertWithVotes = () => query( `INSERT INTO development_oss_registry (oss_session_id, apartment, owner_name, area, ballot_submitted, ballot_date, vote_result, notes, votes_by_item) VALUES ($1, $2, $3, $4, TRUE, CURRENT_DATE, $5, $6, $7::jsonb) RETURNING *`, [id, apartment, owner_name || null, area, vote_result || null, notes || null, votesByItemJson] ); try { if (existing.length > 0) { result = await updateWithVotes(); } else { result = await insertWithVotes(); } } catch (err) { if (err.message && err.message.includes('votes_by_item')) { if (existing.length > 0) { result = await query( `UPDATE development_oss_registry SET owner_name = $1, area = $2, ballot_submitted = TRUE, ballot_date = CURRENT_DATE, vote_result = $3, notes = $4 WHERE id = $5 RETURNING *`, [owner_name || null, area, vote_result || null, notes || null, existing[0].id] ); } else { result = await query( `INSERT INTO development_oss_registry (oss_session_id, apartment, owner_name, area, ballot_submitted, ballot_date, vote_result, notes) VALUES ($1, $2, $3, $4, TRUE, CURRENT_DATE, $5, $6) RETURNING *`, [id, apartment, owner_name || null, area, vote_result || null, notes || null] ); } } else { throw err; } } // Обновляем кворум ОСС const quorumResult = await query( `SELECT COALESCE(SUM(area), 0) as total FROM development_oss_registry WHERE oss_session_id = $1 AND ballot_submitted = TRUE`, [id] ); const newQuorum = parseFloat(quorumResult[0].total); const updateResult = await query( `UPDATE development_oss_sessions SET quorum_current = $1 WHERE id = $2 RETURNING *`, [newQuorum, id] ); // Проверяем автоматическое завершение ОСС через триггер // Триггер в БД автоматически обработает переход pipeline объекта // Также вызываем обработчик автоматизации для дополнительной логики try { await pipelineAutomation.handleOSSCompletion(id); } catch (autoError) { console.warn('Pipeline automation error (non-critical):', autoError); } res.status(201).json(result[0]); } catch (err) { console.error('Error submitting ballot:', err); res.status(500).json({ error: 'Failed to submit ballot', details: err.message }); } }); // POST /api/development/oss/:id/ballots/bulk - массовая загрузка бюллетеней app.post(`${API_PREFIX}/development/oss/:id/ballots/bulk`, async (req, res) => { try { const { id } = req.params; const { ballots } = req.body; if (!Array.isArray(ballots) || ballots.length === 0) { return res.status(400).json({ error: 'Missing or empty ballots array' }); } const ossCheck = await query('SELECT id FROM development_oss_sessions WHERE id = $1', [id]); if (ossCheck.length === 0) { return res.status(404).json({ error: 'OSS session not found' }); } let inserted = 0; let updated = 0; for (const b of ballots) { const apartment = b.apartment != null ? String(b.apartment).trim() : ''; const area = parseFloat(b.area); if (!apartment || isNaN(area) || area <= 0) continue; const owner_name = b.owner_name != null ? String(b.owner_name).trim() : null; const vote_result = ['for', 'against', 'abstain'].includes(b.vote_result) ? b.vote_result : null; const notes = b.notes != null ? String(b.notes).trim() : null; const votes_by_item = b.votes_by_item != null && typeof b.votes_by_item === 'object' ? JSON.stringify(b.votes_by_item) : (b.votes_by_item != null && typeof b.votes_by_item === 'string' ? b.votes_by_item : '{}'); const existing = await query( 'SELECT id FROM development_oss_registry WHERE oss_session_id = $1 AND apartment = $2', [id, apartment] ); try { if (existing.length > 0) { await query( `UPDATE development_oss_registry SET owner_name = $1, area = $2, ballot_submitted = TRUE, ballot_date = CURRENT_DATE, vote_result = $3, notes = $4, votes_by_item = $5::jsonb WHERE id = $6`, [owner_name, area, vote_result, notes, votes_by_item, existing[0].id] ); updated += 1; } else { await query( `INSERT INTO development_oss_registry (oss_session_id, apartment, owner_name, area, ballot_submitted, ballot_date, vote_result, notes, votes_by_item) VALUES ($1, $2, $3, $4, TRUE, CURRENT_DATE, $5, $6, $7::jsonb)`, [id, apartment, owner_name, area, vote_result, notes, votes_by_item] ); inserted += 1; } } catch (err) { if (err.message && err.message.includes('votes_by_item')) { if (existing.length > 0) { await query( `UPDATE development_oss_registry SET owner_name = $1, area = $2, ballot_submitted = TRUE, ballot_date = CURRENT_DATE, vote_result = $3, notes = $4 WHERE id = $5`, [owner_name, area, vote_result, notes, existing[0].id] ); updated += 1; } else { await query( `INSERT INTO development_oss_registry (oss_session_id, apartment, owner_name, area, ballot_submitted, ballot_date, vote_result, notes) VALUES ($1, $2, $3, $4, TRUE, CURRENT_DATE, $5, $6)`, [id, apartment, owner_name, area, vote_result, notes] ); inserted += 1; } } else { throw err; } } } const quorumResult = await query( `SELECT COALESCE(SUM(area), 0) as total FROM development_oss_registry WHERE oss_session_id = $1 AND ballot_submitted = TRUE`, [id] ); const newQuorum = parseFloat(quorumResult[0].total); await query( `UPDATE development_oss_sessions SET quorum_current = $1 WHERE id = $2`, [newQuorum, id] ); try { await pipelineAutomation.handleOSSCompletion(id); } catch (autoError) { console.warn('Pipeline automation error (non-critical):', autoError); } res.status(201).json({ inserted, updated, total: inserted + updated }); } catch (err) { console.error('Error submitting bulk ballots:', err); res.status(500).json({ error: 'Failed to submit bulk ballots', details: err.message }); } }); // GET /api/development/oss/:id/registry - получить реестр app.get(`${API_PREFIX}/development/oss/:id/registry`, async (req, res) => { try { const { id } = req.params; const rows = await query( 'SELECT * FROM development_oss_registry WHERE oss_session_id = $1 ORDER BY apartment', [id] ); // Преобразуем snake_case в camelCase для фронтенда const formattedRows = rows.map((row) => ({ id: row.id, apartment: row.apartment, ownerName: row.owner_name || null, area: parseFloat(row.area) || 0, ballotSubmitted: row.ballot_submitted || false, ballotDate: row.ballot_date || null, voteResult: row.vote_result || null, notes: row.notes || null, })); res.json(formattedRows); } catch (err) { console.error('Error fetching OSS registry:', err); res.status(500).json({ error: 'Failed to fetch OSS registry' }); } }); // ========= AUDITS (Аудиты) ========= /** Нормализация оценки: число 1–5 или старые good/fair/poor → 5/3/1. */ function normalizeRatingToScore(rating) { if (rating != null && typeof rating === 'number' && rating >= 1 && rating <= 5) return rating; if (rating === 'good') return 5; if (rating === 'fair') return 3; if (rating === 'poor') return 1; return null; } /** Среднее по подпунктам категории: только оценки 1–5 (или good→5, fair→3, poor→1), без noAccess/notPresent. */ function calcCategoryAverageFromSubItems(subItems) { if (!Array.isArray(subItems)) return null; const valid = subItems.filter((s) => normalizeRatingToScore(s.rating) != null && !s.noAccess && !s.notPresent); if (valid.length === 0) return null; const sum = valid.reduce((a, s) => a + normalizeRatingToScore(s.rating), 0); return Math.round((sum / valid.length) * 10) / 10; } /** Износ % из inspection_data: средние по пунктам (1–5) → (5 − среднее) / 4 × 100. */ function calcWearPercentFromInspectionData(inspectionData) { if (!inspectionData || typeof inspectionData !== 'object') return null; const categories = ['roof', 'facade', 'entrances', 'infrastructure', 'yard']; const averages = categories .map((key) => { const cat = inspectionData[key]; return cat && Array.isArray(cat.subItems) ? calcCategoryAverageFromSubItems(cat.subItems) : null; }) .filter((a) => a != null); if (averages.length === 0) return null; const avg = averages.reduce((s, a) => s + a, 0) / averages.length; return Math.round(((5 - avg) / 4) * 100); } /** Индекс сложности (0–100) из inspection_data: та же формула, что и износ — из средних по пунктам осмотра (1–5) → (5 − среднее) / 4 × 100. */ function calcComplexityIndexFromInspectionData(inspectionData) { return calcWearPercentFromInspectionData(inspectionData); } function formatAuditRow(r) { return { id: r.id, buildingId: r.building_id || null, address: r.address, status: r.status || 'new', wearPercent: r.wear_percent != null ? Number(r.wear_percent) : 0, roofCondition: r.roof_condition || 'fair', basementCondition: r.basement_condition || 'fair', calculatedTariff: parseFloat(r.calculated_tariff) || 0, projectedMargin: parseFloat(r.projected_margin) || 0, complexityIndex: r.complexity_index != null ? parseFloat(r.complexity_index) : null, inspectionData: r.inspection_data || {}, auditDate: r.audit_date ? String(r.audit_date).split('T')[0] : null, auditorName: r.auditor_name || null, defectListUrl: r.defect_list_url || null, notes: r.notes || null, }; } // GET /api/development/audits - список аудитов app.get(`${API_PREFIX}/development/audits`, async (req, res) => { try { const { building_id } = req.query; let queryText = 'SELECT * FROM development_audits WHERE 1=1'; const params = []; if (building_id) { queryText += ` AND building_id = $${params.length + 1}`; params.push(building_id); } queryText += ' ORDER BY audit_date DESC, created_at DESC'; const rows = await query(queryText, params); res.json(rows.map(formatAuditRow)); } catch (err) { console.error('Error fetching audits:', err); res.status(500).json({ error: 'Failed to fetch audits' }); } }); // GET /api/development/audits/:id - один аудит (карточка) app.get(`${API_PREFIX}/development/audits/:id`, async (req, res) => { try { const { id } = req.params; const rows = await query('SELECT * FROM development_audits WHERE id = $1', [id]); if (rows.length === 0) { return res.status(404).json({ error: 'Audit not found' }); } res.json(formatAuditRow(rows[0])); } catch (err) { console.error('Error fetching audit:', err); res.status(500).json({ error: 'Failed to fetch audit' }); } }); // POST /api/development/audits - создать аудит app.post(`${API_PREFIX}/development/audits`, async (req, res) => { try { const body = req.body; const id = body.id; const building_id = body.building_id || body.buildingId || null; const address = body.address; const status = body.status || 'new'; const inspection_data = body.inspection_data ?? body.inspectionData ?? {}; const wearFromInspection = calcWearPercentFromInspectionData(inspection_data); const complexityFromInspection = calcComplexityIndexFromInspectionData(inspection_data); const wear_percent = wearFromInspection != null ? wearFromInspection : (body.wear_percent ?? body.wearPercent ?? 0); const complexity_index = complexityFromInspection != null ? complexityFromInspection : (body.complexity_index ?? body.complexityIndex ?? 50); const roof_condition = body.roof_condition || body.roofCondition || 'fair'; const basement_condition = body.basement_condition || body.basementCondition || 'fair'; const projected_margin = body.projected_margin ?? body.projectedMargin ?? 15; const audit_date = body.audit_date || body.auditDate || new Date().toISOString().split('T')[0]; const auditor_name = body.auditor_name || body.auditorName || null; const defect_list_url = body.defect_list_url || body.defectListUrl || null; const notes = body.notes || null; if (!id || !address) { return res.status(400).json({ error: 'Missing required fields: id, address' }); } const calculated_tariff = calculateTariffFromAudit({ wear_percent, complexity_index, projected_margin }); const result = await query( `INSERT INTO development_audits (id, building_id, address, status, wear_percent, roof_condition, basement_condition, calculated_tariff, projected_margin, complexity_index, inspection_data, audit_date, auditor_name, defect_list_url, notes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13, $14, $15) RETURNING *`, [id, building_id, address, status, wear_percent, roof_condition, basement_condition, calculated_tariff, projected_margin, complexity_index, JSON.stringify(inspection_data), audit_date, auditor_name, defect_list_url, notes] ); const audit = result[0]; try { const pipelineResult = await query( 'SELECT id FROM development_pipeline WHERE building_id = $1 OR address = $2 LIMIT 1', [building_id, address] ); if (pipelineResult.length > 0) { await pipelineAutomation.recalculateProbability(pipelineResult[0].id); await pipelineAutomation.checkAutoTransition(pipelineResult[0].id); } } catch (autoError) { console.warn('Pipeline automation error (non-critical):', autoError); } res.status(201).json(formatAuditRow(audit)); } catch (err) { console.error('Error creating audit:', err); res.status(500).json({ error: 'Failed to create audit', details: err.message }); } }); // PUT /api/development/audits/:id - обновить аудит app.put(`${API_PREFIX}/development/audits/:id`, async (req, res) => { try { const { id } = req.params; const body = req.body; const current = await query('SELECT * FROM development_audits WHERE id = $1', [id]); if (current.length === 0) { return res.status(404).json({ error: 'Audit not found' }); } const row = current[0]; const address = body.address !== undefined ? body.address : row.address; const status = body.status !== undefined ? body.status : row.status; const inspection_data = body.inspection_data !== undefined ? body.inspection_data : body.inspectionData !== undefined ? body.inspectionData : row.inspection_data; const wearFromInspection = calcWearPercentFromInspectionData(inspection_data); const wear_percent = wearFromInspection != null ? wearFromInspection : body.wear_percent !== undefined ? body.wear_percent : body.wearPercent !== undefined ? body.wearPercent : row.wear_percent; const roof_condition = body.roof_condition || body.roofCondition || row.roof_condition; const basement_condition = body.basement_condition || body.basementCondition || row.basement_condition; const projected_margin = body.projected_margin !== undefined ? body.projected_margin : body.projectedMargin !== undefined ? body.projectedMargin : row.projected_margin; const complexityFromInspection = calcComplexityIndexFromInspectionData(inspection_data); const complexity_index = complexityFromInspection != null ? complexityFromInspection : body.complexity_index !== undefined ? body.complexity_index : body.complexityIndex !== undefined ? body.complexityIndex : row.complexity_index; const calculated_tariff = calculateTariffFromAudit({ wear_percent, complexity_index, projected_margin }); const updates = []; const params = []; let paramIndex = 1; if (body.address !== undefined) { updates.push(`address = $${paramIndex++}`); params.push(address); } if (body.status !== undefined) { updates.push(`status = $${paramIndex++}`); params.push(status); } if (body.wear_percent !== undefined || body.wearPercent !== undefined || body.inspection_data !== undefined || body.inspectionData !== undefined) { updates.push(`wear_percent = $${paramIndex++}`); params.push(wear_percent); } if (body.roof_condition !== undefined || body.roofCondition !== undefined) { updates.push(`roof_condition = $${paramIndex++}`); params.push(roof_condition); } if (body.basement_condition !== undefined || body.basementCondition !== undefined) { updates.push(`basement_condition = $${paramIndex++}`); params.push(basement_condition); } updates.push(`calculated_tariff = $${paramIndex++}`); params.push(calculated_tariff); if (body.projected_margin !== undefined || body.projectedMargin !== undefined) { updates.push(`projected_margin = $${paramIndex++}`); params.push(projected_margin); } if (body.complexity_index !== undefined || body.complexityIndex !== undefined || body.inspection_data !== undefined || body.inspectionData !== undefined) { updates.push(`complexity_index = $${paramIndex++}`); params.push(complexity_index); } if (body.inspection_data !== undefined || body.inspectionData !== undefined) { updates.push(`inspection_data = $${paramIndex++}::jsonb`); params.push(JSON.stringify(inspection_data)); } if (body.audit_date !== undefined || body.auditDate !== undefined) { updates.push(`audit_date = $${paramIndex++}`); params.push(body.audit_date || body.auditDate); } if (body.auditor_name !== undefined || body.auditorName !== undefined) { updates.push(`auditor_name = $${paramIndex++}`); params.push(body.auditor_name ?? body.auditorName); } if (body.defect_list_url !== undefined || body.defectListUrl !== undefined) { updates.push(`defect_list_url = $${paramIndex++}`); params.push(body.defect_list_url ?? body.defectListUrl); } if (body.notes !== undefined) { updates.push(`notes = $${paramIndex++}`); params.push(body.notes); } if (updates.length === 0) { return res.status(400).json({ error: 'No fields to update' }); } params.push(id); const result = await query( `UPDATE development_audits SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`, params ); if (result.length === 0) { return res.status(404).json({ error: 'Audit not found' }); } res.json(formatAuditRow(result[0])); } catch (err) { console.error('Error updating audit:', err); res.status(500).json({ error: 'Failed to update audit', details: err.message }); } }); // GET /api/development/audits/:id/defect-list - скачать дефектную ведомость app.get(`${API_PREFIX}/development/audits/:id/defect-list`, async (req, res) => { try { const { id } = req.params; const result = await query('SELECT * FROM development_audits WHERE id = $1', [id]); if (result.length === 0) { return res.status(404).json({ error: 'Audit not found' }); } const audit = result[0]; // Если есть URL файла, возвращаем его if (audit.defect_list_url) { return res.json({ url: audit.defect_list_url }); } // Иначе возвращаем данные для генерации PDF на фронтенде res.json({ audit: audit, message: 'Defect list URL not available. Use audit data to generate PDF on frontend.' }); } catch (err) { console.error('Error fetching defect list:', err); res.status(500).json({ error: 'Failed to fetch defect list' }); } }); // ========= MARKETING (Маркетинг) ========= // Форматирование строки маркетинговой активности в camelCase для фронтенда function formatMarketingRow(row) { if (!row) return null; return { id: row.id, buildingId: row.building_id || null, address: row.address, activistsCount: row.activists_count != null ? Number(row.activists_count) : 0, meetingsHeld: row.meetings_held != null ? Number(row.meetings_held) : 0, adsDistributed: row.ads_distributed != null ? Number(row.ads_distributed) : 0, competitor: row.competitor || null, status: row.status, notes: row.notes || null, }; } // GET /api/development/marketing - список активностей app.get(`${API_PREFIX}/development/marketing`, async (req, res) => { try { const { status, building_id } = req.query; let queryText = 'SELECT * FROM development_marketing_activities WHERE 1=1'; const params = []; if (status) { queryText += ` AND status = $${params.length + 1}`; params.push(status); } if (building_id) { queryText += ` AND building_id = $${params.length + 1}`; params.push(building_id); } queryText += ' ORDER BY created_at DESC'; const rows = await query(queryText, params); res.json(rows.map(formatMarketingRow)); } catch (err) { console.error('Error fetching marketing activities:', err); res.status(500).json({ error: 'Failed to fetch marketing activities' }); } }); // POST /api/development/marketing - создать активность app.post(`${API_PREFIX}/development/marketing`, async (req, res) => { try { const { id, building_id, address, activists_count, meetings_held, ads_distributed, competitor, status, notes } = req.body; if (!id || !address || !status) { return res.status(400).json({ error: 'Missing required fields' }); } const result = await query( `INSERT INTO development_marketing_activities (id, building_id, address, activists_count, meetings_held, ads_distributed, competitor, status, notes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`, [id, building_id || null, address, activists_count || 0, meetings_held || 0, ads_distributed || 0, competitor || null, status, notes || null] ); res.status(201).json(formatMarketingRow(result[0])); } catch (err) { console.error('Error creating marketing activity:', err); res.status(500).json({ error: 'Failed to create marketing activity', details: err.message }); } }); // PUT /api/development/marketing/:id - обновить активность app.put(`${API_PREFIX}/development/marketing/:id`, async (req, res) => { try { const { id } = req.params; const { address, activists_count, meetings_held, ads_distributed, competitor, status, notes } = req.body; const updates = []; const params = []; let paramIndex = 1; if (address !== undefined) { updates.push(`address = $${paramIndex++}`); params.push(address); } if (activists_count !== undefined) { updates.push(`activists_count = $${paramIndex++}`); params.push(activists_count); } if (meetings_held !== undefined) { updates.push(`meetings_held = $${paramIndex++}`); params.push(meetings_held); } if (ads_distributed !== undefined) { updates.push(`ads_distributed = $${paramIndex++}`); params.push(ads_distributed); } if (competitor !== undefined) { updates.push(`competitor = $${paramIndex++}`); params.push(competitor); } if (status !== undefined) { updates.push(`status = $${paramIndex++}`); params.push(status); } if (notes !== undefined) { updates.push(`notes = $${paramIndex++}`); params.push(notes); } if (updates.length === 0) { return res.status(400).json({ error: 'No fields to update' }); } params.push(id); const result = await query( `UPDATE development_marketing_activities SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`, params ); if (result.length === 0) { return res.status(404).json({ error: 'Marketing activity not found' }); } res.json(formatMarketingRow(result[0])); } catch (err) { console.error('Error updating marketing activity:', err); res.status(500).json({ error: 'Failed to update marketing activity', details: err.message }); } }); // PUT /api/development/marketing/:id/metrics - обновить метрики app.put(`${API_PREFIX}/development/marketing/:id/metrics`, async (req, res) => { try { const { id } = req.params; const { activists_count, meetings_held, ads_distributed } = req.body; const updates = []; const params = []; let paramIndex = 1; if (activists_count !== undefined) { updates.push(`activists_count = $${paramIndex++}`); params.push(activists_count); } if (meetings_held !== undefined) { updates.push(`meetings_held = $${paramIndex++}`); params.push(meetings_held); } if (ads_distributed !== undefined) { updates.push(`ads_distributed = $${paramIndex++}`); params.push(ads_distributed); } if (updates.length === 0) { return res.status(400).json({ error: 'No metrics to update' }); } params.push(id); const result = await query( `UPDATE development_marketing_activities SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`, params ); if (result.length === 0) { return res.status(404).json({ error: 'Marketing activity not found' }); } const activity = result[0]; // Автоматически пересчитываем probability для связанного pipeline объекта try { const pipelineResult = await query( 'SELECT id FROM development_pipeline WHERE building_id = $1 OR address = $2 LIMIT 1', [activity.building_id || null, activity.address] ); if (pipelineResult.length > 0) { await pipelineAutomation.recalculateProbability(pipelineResult[0].id); await pipelineAutomation.checkAutoTransition(pipelineResult[0].id); } } catch (autoError) { console.warn('Pipeline automation error (non-critical):', autoError); } res.json(formatMarketingRow(activity)); } catch (err) { console.error('Error updating marketing metrics:', err); res.status(500).json({ error: 'Failed to update marketing metrics' }); } }); // ========= SUMMARY (Сводка) ========= // GET /api/development/summary - KPI метрики и статистика app.get(`${API_PREFIX}/development/summary`, async (req, res) => { try { // Прирост фонда (сумма area из pipeline со status='transfer') const growthResult = await query( `SELECT COALESCE(SUM(area), 0) as growth_m2 FROM development_pipeline WHERE status = 'transfer'` ); const growthM2 = parseFloat(growthResult[0].growth_m2) || 0; // Успех ОСС (процент завершенных ОСС с кворумом > 50%) const ossResult = await query( `SELECT COUNT(*) FILTER (WHERE status = 'completed' AND (quorum_current / NULLIF(quorum_total, 0)) > 0.5) as successful, COUNT(*) FILTER (WHERE status = 'completed') as total FROM development_oss_sessions` ); const ossSuccessRate = ossResult[0].total > 0 ? Math.round((ossResult[0].successful / ossResult[0].total) * 100) : 0; // CAC (средняя стоимость привлечения на дом) - упрощенный расчет const cacResult = await query( `SELECT COALESCE(AVG(expected_revenue), 0) as avg_revenue FROM development_pipeline WHERE status = 'transfer'` ); const cac = parseFloat(cacResult[0].avg_revenue) || 15000; // Выручка в воронке (сумма expected_revenue из всех объектов) const revenueResult = await query( `SELECT COALESCE(SUM(expected_revenue), 0) as potential_revenue FROM development_pipeline` ); const potentialRevenue = parseFloat(revenueResult[0].potential_revenue) || 0; res.json({ growthM2: Math.round(growthM2), ossSuccessRate, cac: Math.round(cac), potentialRevenue: Math.round(potentialRevenue) }); } catch (err) { console.error('Error fetching development summary:', err); res.status(500).json({ error: 'Failed to fetch development summary' }); } }); // ========= LOCATIONS (Карта) ========= // GET /api/development/locations - координаты для карты app.get(`${API_PREFIX}/development/locations`, async (req, res) => { try { const { status } = req.query; let queryText = 'SELECT * FROM development_building_locations WHERE 1=1'; const params = []; if (status) { queryText += ` AND status = $${params.length + 1}`; params.push(status); } queryText += ' ORDER BY created_at DESC'; const rows = await query(queryText, params); res.json(rows); } catch (err) { console.error('Error fetching locations:', err); res.status(500).json({ error: 'Failed to fetch locations' }); } }); // POST /api/development/locations - добавить/обновить локацию app.post(`${API_PREFIX}/development/locations`, async (req, res) => { try { const { building_id, address, latitude, longitude, status, competitor_name } = req.body; if (!address || !status) { return res.status(400).json({ error: 'Missing required fields: address, status' }); } // Проверяем существование записи const existing = building_id ? await query('SELECT id FROM development_building_locations WHERE building_id = $1', [building_id]) : await query('SELECT id FROM development_building_locations WHERE address = $1', [address]); let result; if (existing.length > 0 && building_id) { // Обновляем существующую запись result = await query( `UPDATE development_building_locations SET address = $1, latitude = $2, longitude = $3, status = $4, competitor_name = $5 WHERE building_id = $6 RETURNING *`, [address, latitude || null, longitude || null, status, competitor_name || null, building_id] ); } else { // Создаем новую запись result = await query( `INSERT INTO development_building_locations (building_id, address, latitude, longitude, status, competitor_name) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, [building_id || null, address, latitude || null, longitude || null, status, competitor_name || null] ); } res.status(201).json(result[0]); } catch (err) { console.error('Error creating/updating location:', err); res.status(500).json({ error: 'Failed to create/update location', details: err.message }); } }); // ========= CRON ЗАДАЧИ ДЛЯ АВТОМАТИЗАЦИИ ========= // Ежедневная проверка воронки (каждый день в 9:00) let dailyCheckInterval = null; function startDailyPipelineCheck() { // Проверяем каждый час (можно изменить на раз в день) const CHECK_INTERVAL = 60 * 60 * 1000; // 1 час для тестирования, в продакшене можно 24 часа dailyCheckInterval = setInterval(async () => { try { console.log('[Cron] Starting daily pipeline automation check...'); await pipelineAutomation.dailyCheck(); console.log('[Cron] Daily pipeline check completed'); } catch (error) { console.error('[Cron] Error in daily pipeline check:', error); } }, CHECK_INTERVAL); // Первый запуск через 5 минут после старта сервера setTimeout(async () => { try { console.log('[Cron] Running initial pipeline automation check...'); await pipelineAutomation.dailyCheck(); } catch (error) { console.error('[Cron] Error in initial pipeline check:', error); } }, 5 * 60 * 1000); } // Ручной запуск проверки через API app.post(`${API_PREFIX}/development/pipeline/auto-check`, async (req, res) => { try { const result = await pipelineAutomation.dailyCheck(); res.json({ success: true, message: `Проверка завершена: обновлено ${result.updated}, переведено ${result.transitioned}`, ...result }); } catch (err) { console.error('Error running pipeline auto-check:', err); res.status(500).json({ error: 'Failed to run auto-check', details: err.message }); } }); // Пересчет probability для конкретного объекта app.post(`${API_PREFIX}/development/pipeline/:id/recalculate`, async (req, res) => { try { const { id } = req.params; const newProbability = await pipelineAutomation.recalculateProbability(id); const transition = await pipelineAutomation.checkAutoTransition(id); res.json({ success: true, probability: newProbability, transition: transition || null }); } catch (err) { console.error('Error recalculating probability:', err); res.status(500).json({ error: 'Failed to recalculate', details: err.message }); } }); // ========= ЮРИДИЧЕСКИЙ ОТДЕЛ: ДОГОВОРЫ ========= // GET /api/legal/contracts - список договоров app.get(`${API_PREFIX}/legal/contracts`, async (req, res) => { try { const { status, search, viewMode } = req.query; let queryText = ` SELECT id, number, type, counterparty, counterparty_inn AS "counterpartyInn", amount, status, start_date AS "startDate", end_date AS "endDate", auto_prolongation AS "autoProlongation", manager, has_disagreements AS "hasDisagreements", contract_file_url AS "contractFileUrl", notes, created_at AS "createdAt", updated_at AS "updatedAt" FROM legal_contracts WHERE 1=1 `; const params = []; if (viewMode === 'archive') { queryText += ` AND status = 'archived'`; } else if (viewMode === 'active') { queryText += ` AND status != 'archived'`; } if (status && status !== 'all') { queryText += ` AND status = $${params.length + 1}`; params.push(status); } if (search) { queryText += ` AND ( counterparty ILIKE $${params.length + 1} OR number ILIKE $${params.length + 1} OR counterparty_inn ILIKE $${params.length + 1} )`; const searchTerm = `%${search}%`; params.push(searchTerm, searchTerm, searchTerm); } queryText += ' ORDER BY created_at DESC'; const rows = await query(queryText, params); res.json(rows); } catch (err) { console.error('Error fetching contracts:', err); // Если таблицы еще не созданы, возвращаем пустой массив res.json([]); } }); // POST /api/legal/contracts/upload - загрузка файла договора app.post(`${API_PREFIX}/legal/contracts/upload`, uploadContract.single('file'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: 'Файл не выбран' }); } const fileUrl = `/uploads/contracts/${req.file.filename}`; res.json({ fileUrl }); } catch (err) { console.error('Error uploading contract file:', err); res.status(500).json({ error: 'Failed to upload file', details: err.message }); } }); // POST /api/legal/contracts - создание договора app.post(`${API_PREFIX}/legal/contracts`, async (req, res) => { try { const { id, number, type, counterparty, counterpartyInn, amount, startDate, endDate, autoProlongation, manager, notes } = req.body; if (!number || !type || !counterparty || !amount || !startDate || !endDate || !manager) { return res.status(400).json({ error: 'Missing required fields' }); } const contractId = id || `contract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const result = await query( `INSERT INTO legal_contracts ( id, number, type, counterparty, counterparty_inn, amount, start_date, end_date, auto_prolongation, manager, notes, status ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'draft') RETURNING id, number, type, counterparty, counterparty_inn AS "counterpartyInn", amount, status, start_date AS "startDate", end_date AS "endDate", auto_prolongation AS "autoProlongation", manager, has_disagreements AS "hasDisagreements", contract_file_url AS "contractFileUrl", notes, created_at AS "createdAt", updated_at AS "updatedAt"`, [ contractId, number, type, counterparty, counterpartyInn || null, amount, startDate, endDate, autoProlongation || false, manager, notes || null ] ); // Записываем в историю try { await query( `INSERT INTO legal_contract_history (contract_id, from_status, to_status, changed_by) VALUES ($1, NULL, 'draft', $2)`, [contractId, manager] ); } catch (histErr) { console.warn('Failed to save contract history:', histErr); } res.status(201).json(result[0]); } catch (err) { console.error('Error creating contract:', err); if (err.code === '23505') { // Unique violation return res.status(400).json({ error: 'Договор с таким номером уже существует' }); } res.status(500).json({ error: 'Failed to create contract', details: err.message }); } }); // PUT /api/legal/contracts/:id - обновление договора app.put(`${API_PREFIX}/legal/contracts/:id`, async (req, res) => { try { const { id } = req.params; const updates = req.body; const updateFields = []; const params = []; let paramIndex = 1; if (updates.status !== undefined) { // Получаем текущий статус для истории try { const current = await query('SELECT status FROM legal_contracts WHERE id = $1', [id]); if (current.length > 0 && current[0].status !== updates.status) { await query( `INSERT INTO legal_contract_history (contract_id, from_status, to_status, changed_by) VALUES ($1, $2, $3, $4)`, [id, current[0].status, updates.status, updates.changedBy || 'System'] ); } } catch (histErr) { console.warn('Failed to save contract history:', histErr); } updateFields.push(`status = $${paramIndex++}`); params.push(updates.status); } const allowedFields = ['type', 'counterparty', 'counterpartyInn', 'amount', 'startDate', 'endDate', 'autoProlongation', 'manager', 'hasDisagreements', 'contractFileUrl', 'notes']; for (const field of allowedFields) { if (updates[field] !== undefined) { const dbField = field === 'counterpartyInn' ? 'counterparty_inn' : field === 'startDate' ? 'start_date' : field === 'endDate' ? 'end_date' : field === 'autoProlongation' ? 'auto_prolongation' : field === 'hasDisagreements' ? 'has_disagreements' : field === 'contractFileUrl' ? 'contract_file_url' : field; updateFields.push(`${dbField} = $${paramIndex++}`); params.push(updates[field]); } } if (updateFields.length === 0) { return res.status(400).json({ error: 'No fields to update' }); } updateFields.push(`updated_at = NOW()`); params.push(id); const result = await query( `UPDATE legal_contracts SET ${updateFields.join(', ')} WHERE id = $${paramIndex} RETURNING id, number, type, counterparty, counterparty_inn AS "counterpartyInn", amount, status, start_date AS "startDate", end_date AS "endDate", auto_prolongation AS "autoProlongation", manager, has_disagreements AS "hasDisagreements", contract_file_url AS "contractFileUrl", notes, created_at AS "createdAt", updated_at AS "updatedAt"`, params ); if (result.length === 0) { return res.status(404).json({ error: 'Contract not found' }); } res.json(result[0]); } catch (err) { console.error('Error updating contract:', err); res.status(500).json({ error: 'Failed to update contract', details: err.message }); } }); // GET /api/legal/contracts/:id/history - история изменений статусов договора app.get(`${API_PREFIX}/legal/contracts/:id/history`, async (req, res) => { try { const { id } = req.params; const historyRows = await query( `SELECT from_status AS "fromStatus", to_status AS "toStatus", changed_by AS "changedBy", created_at AS "createdAt" FROM legal_contract_history WHERE contract_id = $1 ORDER BY created_at DESC`, [id] ); res.json(historyRows || []); } catch (err) { console.error('Error fetching contract history:', err); res.json([]); } }); // GET /api/legal/contracts/:id - получить договор app.get(`${API_PREFIX}/legal/contracts/:id`, async (req, res) => { try { const { id } = req.params; const result = await query( `SELECT id, number, type, counterparty, counterparty_inn AS "counterpartyInn", amount, status, start_date AS "startDate", end_date AS "endDate", auto_prolongation AS "autoProlongation", manager, has_disagreements AS "hasDisagreements", contract_file_url AS "contractFileUrl", notes, created_at AS "createdAt", updated_at AS "updatedAt" FROM legal_contracts WHERE id = $1`, [id] ); if (result.length === 0) { return res.status(404).json({ error: 'Contract not found' }); } res.json(result[0]); } catch (err) { console.error('Error fetching contract:', err); res.status(500).json({ error: 'Failed to fetch contract', details: err.message }); } }); // ========= ЮРИДИЧЕСКИЙ ОТДЕЛ: СУДЕБНЫЕ ДЕЛА ========= // GET /api/legal/court-cases - список судебных дел app.get(`${API_PREFIX}/legal/court-cases`, async (req, res) => { try { const { status, type, role, search } = req.query; let queryText = ` SELECT id, case_number AS "caseNumber", type, role, subject, debtor_name AS "debtorName", address, amount, recovered_amount AS "recoveredAmount", amount_at_bailiffs AS "amountAtBailiffs", status, fssp_status AS "fsspStatus", bailiff_name AS "bailiffName", fssp_last_action_date AS "fsspLastActionDate", next_hearing_date AS "nextHearingDate", judge, court_name AS "courtName", case_file_url AS "caseFileUrl", notes, enforcement_number AS "enforcementNumber", enforcement_start_date AS "enforcementStartDate", fssp_stage AS "fsspStage", penalty_amount AS "penaltyAmount", overdue_since AS "overdueSince", created_at AS "createdAt", updated_at AS "updatedAt" FROM legal_court_cases WHERE 1=1 `; const params = []; if (status) { queryText += ` AND status = $${params.length + 1}`; params.push(status); } if (type) { queryText += ` AND type = $${params.length + 1}`; params.push(type); } if (role) { queryText += ` AND role = $${params.length + 1}`; params.push(role); } if (search) { queryText += ` AND ( case_number ILIKE $${params.length + 1} OR subject ILIKE $${params.length + 1} OR debtor_name ILIKE $${params.length + 1} )`; const searchTerm = `%${search}%`; params.push(searchTerm, searchTerm, searchTerm); } queryText += ' ORDER BY created_at DESC'; const rows = await query(queryText, params); res.json(rows.map(r => ({ ...r, id: String(r.id) }))); } catch (err) { console.error('Error fetching court cases:', err); // Если таблицы еще не созданы, возвращаем пустой массив res.json([]); } }); // POST /api/legal/court-cases - создание судебного дела app.post(`${API_PREFIX}/legal/court-cases`, async (req, res) => { try { const { id, caseNumber, type, role, subject, debtorName, address, amount, nextHearingDate, judge, courtName, notes, createdBy } = req.body; if (!caseNumber || !type || !role || !subject || !amount) { return res.status(400).json({ error: 'Missing required fields: caseNumber, type, role, subject, amount' }); } const caseId = id || `case-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const result = await query( `INSERT INTO legal_court_cases ( id, case_number, type, role, subject, debtor_name, address, amount, next_hearing_date, judge, court_name, notes, status ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'pre_trial') RETURNING id, case_number AS "caseNumber", type, role, subject, debtor_name AS "debtorName", address, amount, recovered_amount AS "recoveredAmount", amount_at_bailiffs AS "amountAtBailiffs", status, fssp_status AS "fsspStatus", bailiff_name AS "bailiffName", fssp_last_action_date AS "fsspLastActionDate", next_hearing_date AS "nextHearingDate", judge, court_name AS "courtName", case_file_url AS "caseFileUrl", notes, created_at AS "createdAt", updated_at AS "updatedAt"`, [ caseId, caseNumber, type, role, subject, debtorName || null, address || null, amount, nextHearingDate || null, judge || null, courtName || null, notes || null ] ); // Записываем в историю try { await query( `INSERT INTO legal_court_case_history (case_id, from_status, to_status, changed_by) VALUES ($1, NULL, 'pre_trial', $2)`, [caseId, createdBy || 'System'] ); } catch (histErr) { console.warn('Failed to save court case history:', histErr); } res.status(201).json({ ...result[0], id: String(result[0].id) }); } catch (err) { console.error('Error creating court case:', err); if (err.code === '23505') { // Unique violation return res.status(400).json({ error: 'Судебное дело с таким номером уже существует' }); } res.status(500).json({ error: 'Failed to create court case', details: err.message }); } }); // PUT /api/legal/court-cases/:id - обновление судебного дела app.put(`${API_PREFIX}/legal/court-cases/:id`, async (req, res) => { try { const { id } = req.params; const updates = req.body; const updateFields = []; const params = []; let paramIndex = 1; if (updates.status !== undefined) { // Получаем текущий статус для истории try { const current = await query('SELECT status FROM legal_court_cases WHERE id = $1', [id]); if (current.length > 0 && current[0].status !== updates.status) { await query( `INSERT INTO legal_court_case_history (case_id, from_status, to_status, changed_by) VALUES ($1, $2, $3, $4)`, [id, current[0].status, updates.status, updates.changedBy || 'System'] ); } } catch (histErr) { console.warn('Failed to save court case history:', histErr); } updateFields.push(`status = $${paramIndex++}`); params.push(updates.status); } const allowedFields = ['caseNumber', 'type', 'role', 'subject', 'debtorName', 'address', 'amount', 'recoveredAmount', 'amountAtBailiffs', 'fsspStatus', 'bailiffName', 'fsspLastActionDate', 'nextHearingDate', 'judge', 'courtName', 'caseFileUrl', 'notes', 'enforcementNumber', 'enforcementStartDate', 'fsspStage', 'penaltyAmount', 'overdueSince']; for (const field of allowedFields) { if (updates[field] !== undefined) { const dbField = field === 'caseNumber' ? 'case_number' : field === 'debtorName' ? 'debtor_name' : field === 'recoveredAmount' ? 'recovered_amount' : field === 'amountAtBailiffs' ? 'amount_at_bailiffs' : field === 'fsspStatus' ? 'fssp_status' : field === 'bailiffName' ? 'bailiff_name' : field === 'fsspLastActionDate' ? 'fssp_last_action_date' : field === 'nextHearingDate' ? 'next_hearing_date' : field === 'courtName' ? 'court_name' : field === 'caseFileUrl' ? 'case_file_url' : field === 'enforcementNumber' ? 'enforcement_number' : field === 'enforcementStartDate' ? 'enforcement_start_date' : field === 'fsspStage' ? 'fssp_stage' : field === 'penaltyAmount' ? 'penalty_amount' : field === 'overdueSince' ? 'overdue_since' : field; updateFields.push(`${dbField} = $${paramIndex++}`); params.push(updates[field]); } } if (updateFields.length === 0) { return res.status(400).json({ error: 'No fields to update' }); } updateFields.push(`updated_at = NOW()`); params.push(id); const result = await query( `UPDATE legal_court_cases SET ${updateFields.join(', ')} WHERE id = $${paramIndex} RETURNING id, case_number AS "caseNumber", type, role, subject, debtor_name AS "debtorName", address, amount, recovered_amount AS "recoveredAmount", amount_at_bailiffs AS "amountAtBailiffs", status, fssp_status AS "fsspStatus", bailiff_name AS "bailiffName", fssp_last_action_date AS "fsspLastActionDate", next_hearing_date AS "nextHearingDate", judge, court_name AS "courtName", case_file_url AS "caseFileUrl", notes, enforcement_number AS "enforcementNumber", enforcement_start_date AS "enforcementStartDate", fssp_stage AS "fsspStage", penalty_amount AS "penaltyAmount", overdue_since AS "overdueSince", created_at AS "createdAt", updated_at AS "updatedAt"`, params ); if (result.length === 0) { return res.status(404).json({ error: 'Court case not found' }); } res.json({ ...result[0], id: String(result[0].id) }); } catch (err) { console.error('Error updating court case:', err); res.status(500).json({ error: 'Failed to update court case', details: err.message }); } }); // GET /api/legal/court-cases/:id - получить судебное дело app.get(`${API_PREFIX}/legal/court-cases/:id`, async (req, res) => { try { const { id } = req.params; const result = await query( `SELECT id, case_number AS "caseNumber", type, role, subject, debtor_name AS "debtorName", address, amount, recovered_amount AS "recoveredAmount", amount_at_bailiffs AS "amountAtBailiffs", status, fssp_status AS "fsspStatus", bailiff_name AS "bailiffName", fssp_last_action_date AS "fsspLastActionDate", next_hearing_date AS "nextHearingDate", judge, court_name AS "courtName", case_file_url AS "caseFileUrl", notes, enforcement_number AS "enforcementNumber", enforcement_start_date AS "enforcementStartDate", fssp_stage AS "fsspStage", penalty_amount AS "penaltyAmount", overdue_since AS "overdueSince", created_at AS "createdAt", updated_at AS "updatedAt" FROM legal_court_cases WHERE id = $1`, [id] ); if (result.length === 0) { return res.status(404).json({ error: 'Court case not found' }); } res.json({ ...result[0], id: String(result[0].id) }); } catch (err) { console.error('Error fetching court case:', err); res.status(500).json({ error: 'Failed to fetch court case', details: err.message }); } }); // GET /api/legal/court-cases/:id/comments - получить комментарии к делу app.get(`${API_PREFIX}/legal/court-cases/:id/comments`, async (req, res) => { try { const { id } = req.params; // Проверяем существование таблицы const tableCheck = await query( `SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'legal_court_case_comments' )` ); if (!tableCheck[0]?.exists) { return res.json([]); } const result = await query( `SELECT id, case_id AS "caseId", author, comment, created_at AS "createdAt" FROM legal_court_case_comments WHERE case_id = $1 ORDER BY created_at DESC`, [id] ); res.json(result); } catch (err) { console.error('Error fetching court case comments:', err); res.json([]); } }); // POST /api/legal/court-cases/:id/comments - добавить комментарий к делу app.post(`${API_PREFIX}/legal/court-cases/:id/comments`, async (req, res) => { try { const { id } = req.params; const { author, comment } = req.body; if (!author || !comment) { return res.status(400).json({ error: 'Missing required fields: author, comment' }); } // Проверяем существование таблицы const tableCheck = await query( `SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'legal_court_case_comments' )` ); if (!tableCheck[0]?.exists) { return res.status(503).json({ error: 'Comments table not yet created. Please restart server.' }); } const result = await query( `INSERT INTO legal_court_case_comments (case_id, author, comment) VALUES ($1, $2, $3) RETURNING id, case_id AS "caseId", author, comment, created_at AS "createdAt"`, [id, author, comment] ); res.status(201).json(result[0]); } catch (err) { console.error('Error creating court case comment:', err); res.status(500).json({ error: 'Failed to create comment', details: err.message }); } }); // GET /api/legal/court-cases/:id/history - получить историю изменений статусов app.get(`${API_PREFIX}/legal/court-cases/:id/history`, async (req, res) => { try { const { id } = req.params; // Проверяем существование таблицы const tableCheck = await query( `SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'legal_court_case_history' )` ); if (!tableCheck[0]?.exists) { return res.json([]); } const result = await query( `SELECT id, case_id AS "caseId", from_status AS "fromStatus", to_status AS "toStatus", changed_by AS "changedBy", change_reason AS "changeReason", created_at AS "createdAt" FROM legal_court_case_history WHERE case_id = $1 ORDER BY created_at DESC`, [id] ); res.json(result); } catch (err) { console.error('Error fetching court case history:', err); res.json([]); } }); // GET /api/legal/court-cases/:id/documents - список документов по делу app.get(`${API_PREFIX}/legal/court-cases/:id/documents`, async (req, res) => { try { const { id } = req.params; const tableCheck = await query( `SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'legal_case_documents')` ); if (!tableCheck[0]?.exists) { return res.json([]); } const result = await query( `SELECT id, case_id AS "caseId", doc_type AS "docType", file_url AS "fileUrl", doc_date AS "docDate", title, created_at AS "createdAt" FROM legal_case_documents WHERE case_id = $1 ORDER BY doc_date DESC NULLS LAST, created_at DESC`, [id] ); res.json(result); } catch (err) { console.error('Error fetching case documents:', err); res.json([]); } }); // POST /api/legal/court-cases/:id/documents - добавить документ app.post(`${API_PREFIX}/legal/court-cases/:id/documents`, async (req, res) => { try { const { id } = req.params; const { docType, fileUrl, docDate, title } = req.body; if (!docType || !fileUrl) { return res.status(400).json({ error: 'Missing required fields: docType, fileUrl' }); } const validTypes = ['pretenzia', 'isk', 'reshenie', 'ispolnitelny_list', 'postanovlenie_ip', 'other']; if (!validTypes.includes(docType)) { return res.status(400).json({ error: 'Invalid docType' }); } const tableCheck = await query( `SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'legal_case_documents')` ); if (!tableCheck[0]?.exists) { return res.status(503).json({ error: 'Documents table not yet created.' }); } const result = await query( `INSERT INTO legal_case_documents (case_id, doc_type, file_url, doc_date, title) VALUES ($1, $2, $3, $4, $5) RETURNING id, case_id AS "caseId", doc_type AS "docType", file_url AS "fileUrl", doc_date AS "docDate", title, created_at AS "createdAt"`, [id, docType, fileUrl, docDate || null, title || null] ); res.status(201).json(result[0]); } catch (err) { console.error('Error creating case document:', err); res.status(500).json({ error: 'Failed to create document', details: err.message }); } }); // DELETE /api/legal/court-cases/:id/documents/:docId - удалить документ app.delete(`${API_PREFIX}/legal/court-cases/:id/documents/:docId`, async (req, res) => { try { const { id, docId } = req.params; const tableCheck = await query( `SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'legal_case_documents')` ); if (!tableCheck[0]?.exists) { return res.status(404).json({ error: 'Not found' }); } const result = await query('DELETE FROM legal_case_documents WHERE id = $1 AND case_id = $2 RETURNING id', [docId, id]); if (result.length === 0) { return res.status(404).json({ error: 'Document not found' }); } res.json({ deleted: true, id: docId }); } catch (err) { console.error('Error deleting case document:', err); res.status(500).json({ error: 'Failed to delete document', details: err.message }); } }); // ========= СЧЕТА НА ОПЛАТУ ========= // Вспомогательная функция для получения ролей пользователя async function getUserRoles(userId) { if (!userId) return []; try { const result = await query( 'SELECT role FROM user_roles WHERE user_id = $1', [userId] ); return result.map(row => row.role); } catch (err) { console.error('Error fetching user roles:', err); return []; } } // ========= ПЛАТЕЖНЫЙ КАЛЕНДАРЬ ========= // GET /api/finance/payment-calendar/entries - записи календаря app.get(`${API_PREFIX}/finance/payment-calendar/entries`, async (req, res) => { try { const { dateFrom, dateTo, direction, type, limit: limitParam = 500 } = req.query; const params = []; let where = '1=1'; if (dateFrom) { params.push(dateFrom); where += ` AND scheduled_date >= $${params.length}`; } if (dateTo) { params.push(dateTo); where += ` AND scheduled_date <= $${params.length}`; } if (direction && (direction === 'outgoing' || direction === 'incoming')) { params.push(direction); where += ` AND direction = $${params.length}`; } if (type && ['invoice', 'manual', 'cash'].includes(type)) { params.push(type); where += ` AND type = $${params.length}`; } const limit = Math.min(parseInt(limitParam) || 500, 1000); params.push(limit); const rows = await query( `SELECT id, direction, type, payment_invoice_id AS "paymentInvoiceId", category, description, amount, scheduled_date AS "scheduledDate", payment_date AS "paymentDate", probability, currency, is_cash AS "isCash", contractor_name AS "contractorName", notes, created_by AS "createdBy", created_at AS "createdAt", updated_at AS "updatedAt" FROM payment_calendar_entries WHERE ${where} ORDER BY scheduled_date ASC, id ASC LIMIT $${params.length}`, params ); res.json({ entries: rows }); } catch (err) { console.error('Error fetching payment calendar entries:', err); res.status(500).json({ error: 'Failed to fetch payment calendar entries', details: err.message }); } }); // POST /api/finance/payment-calendar/entries - создание записи app.post(`${API_PREFIX}/finance/payment-calendar/entries`, async (req, res) => { try { const { createdBy, direction, type = 'manual', paymentInvoiceId, category = '', description = '', amount, scheduledDate, paymentDate, probability = 'confirmed', currency = 'RUB', isCash = false, contractorName = '', notes } = req.body; if (!createdBy || !direction || amount == null || !scheduledDate) { return res.status(400).json({ error: 'Missing required: createdBy, direction, amount, scheduledDate' }); } if (direction !== 'outgoing' && direction !== 'incoming') { return res.status(400).json({ error: 'direction must be outgoing or incoming' }); } const rows = await query( `INSERT INTO payment_calendar_entries (direction, type, payment_invoice_id, category, description, amount, scheduled_date, payment_date, probability, currency, is_cash, contractor_name, notes, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, direction, type, payment_invoice_id AS "paymentInvoiceId", category, description, amount, scheduled_date AS "scheduledDate", payment_date AS "paymentDate", probability, currency, is_cash AS "isCash", contractor_name AS "contractorName", notes, created_by AS "createdBy", created_at AS "createdAt", updated_at AS "updatedAt"`, [ direction, ['invoice', 'manual', 'cash'].includes(type) ? type : 'manual', paymentInvoiceId || null, category || '', description || '', parseFloat(amount), scheduledDate, paymentDate || null, ['confirmed', 'high', 'medium', 'low'].includes(probability) ? probability : 'confirmed', currency || 'RUB', !!isCash, contractorName || '', notes || null ] ); res.status(201).json(rows[0]); } catch (err) { console.error('Error creating payment calendar entry:', err); res.status(500).json({ error: 'Failed to create entry', details: err.message }); } }); // PUT /api/finance/payment-calendar/entries/:id - обновление записи app.put(`${API_PREFIX}/finance/payment-calendar/entries/:id`, async (req, res) => { try { const { id } = req.params; const { direction, type, category, description, amount, scheduledDate, paymentDate, probability, currency, isCash, contractorName, notes } = req.body; const updates = []; const params = []; let idx = 1; if (direction !== undefined) { params.push(direction); updates.push(`direction = $${idx++}`); } if (type !== undefined) { params.push(type); updates.push(`type = $${idx++}`); } if (category !== undefined) { params.push(category); updates.push(`category = $${idx++}`); } if (description !== undefined) { params.push(description); updates.push(`description = $${idx++}`); } if (amount !== undefined) { params.push(parseFloat(amount)); updates.push(`amount = $${idx++}`); } if (scheduledDate !== undefined) { params.push(scheduledDate); updates.push(`scheduled_date = $${idx++}`); } if (paymentDate !== undefined) { params.push(paymentDate); updates.push(`payment_date = $${idx++}`); } if (probability !== undefined) { params.push(probability); updates.push(`probability = $${idx++}`); } if (currency !== undefined) { params.push(currency); updates.push(`currency = $${idx++}`); } if (isCash !== undefined) { params.push(!!isCash); updates.push(`is_cash = $${idx++}`); } if (contractorName !== undefined) { params.push(contractorName); updates.push(`contractor_name = $${idx++}`); } if (notes !== undefined) { params.push(notes); updates.push(`notes = $${idx++}`); } if (updates.length === 0) { return res.status(400).json({ error: 'No fields to update' }); } updates.push(`updated_at = NOW()`); params.push(id); const place = params.length; const rows = await query( `UPDATE payment_calendar_entries SET ${updates.join(', ')} WHERE id = $${place} RETURNING id, direction, type, payment_invoice_id AS "paymentInvoiceId", category, description, amount, scheduled_date AS "scheduledDate", payment_date AS "paymentDate", probability, currency, is_cash AS "isCash", contractor_name AS "contractorName", notes, created_by AS "createdBy", created_at AS "createdAt", updated_at AS "updatedAt"`, params ); if (rows.length === 0) return res.status(404).json({ error: 'Entry not found' }); res.json(rows[0]); } catch (err) { console.error('Error updating payment calendar entry:', err); res.status(500).json({ error: 'Failed to update entry', details: err.message }); } }); // DELETE /api/finance/payment-calendar/entries/:id app.delete(`${API_PREFIX}/finance/payment-calendar/entries/:id`, async (req, res) => { try { const { id } = req.params; const result = await query('DELETE FROM payment_calendar_entries WHERE id = $1 RETURNING id', [id]); if (result.length === 0) return res.status(404).json({ error: 'Entry not found' }); res.status(204).send(); } catch (err) { console.error('Error deleting payment calendar entry:', err); res.status(500).json({ error: 'Failed to delete entry', details: err.message }); } }); // GET /api/finance/payment-calendar/categories - статьи доходов/расходов app.get(`${API_PREFIX}/finance/payment-calendar/categories`, async (req, res) => { try { const { direction } = req.query; let where = 'is_active = true'; const params = []; if (direction && (direction === 'income' || direction === 'expense')) { params.push(direction); where += ` AND direction = $${params.length}`; } const rows = await query( `SELECT id, name, code, direction, parent_id AS "parentId", is_active AS "isActive", sort_order AS "sortOrder" FROM payment_categories WHERE ${where} ORDER BY sort_order, name`, params ); res.json(rows); } catch (err) { console.error('Error fetching payment categories:', err); res.status(500).json({ error: 'Failed to fetch categories', details: err.message }); } }); // GET /api/finance/payment-calendar/summary - сводка (остаток, плановые платежи/поступления) app.get(`${API_PREFIX}/finance/payment-calendar/summary`, async (req, res) => { try { const { dateFrom, dateTo } = req.query; const params = []; let where = '1=1'; if (dateFrom) { params.push(dateFrom); where += ` AND scheduled_date >= $${params.length}`; } if (dateTo) { params.push(dateTo); where += ` AND scheduled_date <= $${params.length}`; } const rows = await query( `SELECT direction, SUM(amount) AS total FROM payment_calendar_entries WHERE ${where} GROUP BY direction`, params ); const outgoing = rows.find(r => r.direction === 'outgoing'); const incoming = rows.find(r => r.direction === 'incoming'); res.json({ plannedOutgoing: parseFloat(outgoing?.total || 0), plannedIncoming: parseFloat(incoming?.total || 0), dateFrom: dateFrom || null, dateTo: dateTo || null }); } catch (err) { console.error('Error fetching payment calendar summary:', err); res.status(500).json({ error: 'Failed to fetch summary', details: err.message }); } }); // GET /api/finance/accounts - банки и кошельки (наличка) app.get(`${API_PREFIX}/finance/accounts`, async (req, res) => { try { const { type } = req.query; let where = 'is_active = true'; const params = []; if (type && (type === 'bank' || type === 'cash')) { params.push(type); where += ` AND type = $${params.length}`; } const rows = await query( `SELECT id, type, name, balance, currency, sort_order AS "sortOrder" FROM finance_accounts WHERE ${where} ORDER BY type, sort_order, name`, params ); res.json(rows); } catch (err) { console.error('Error fetching finance accounts:', err); res.status(500).json({ error: 'Failed to fetch accounts', details: err.message }); } }); // POST /api/finance/accounts - создать счет (банк/кошелек) app.post(`${API_PREFIX}/finance/accounts`, async (req, res) => { try { const { type, name, balance = 0, currency = 'RUB', sortOrder = 0 } = req.body; if (!type || !name || !['bank', 'cash'].includes(type)) { return res.status(400).json({ error: 'type (bank|cash) and name are required' }); } const rows = await query( `INSERT INTO finance_accounts (type, name, balance, currency, sort_order) VALUES ($1, $2, $3, $4, $5) RETURNING id, type, name, balance, currency, sort_order AS "sortOrder"`, [type, name, parseFloat(balance) || 0, currency || 'RUB', parseInt(sortOrder) || 0] ); res.status(201).json(rows[0]); } catch (err) { console.error('Error creating finance account:', err); res.status(500).json({ error: 'Failed to create account', details: err.message }); } }); // PUT /api/finance/accounts/:id - обновить счет (баланс, название) app.put(`${API_PREFIX}/finance/accounts/:id`, async (req, res) => { try { const { id } = req.params; const { name, balance, currency, sortOrder } = req.body; const updates = []; const params = []; let idx = 1; if (name !== undefined) { params.push(name); updates.push(`name = $${idx++}`); } if (balance !== undefined) { params.push(parseFloat(balance)); updates.push(`balance = $${idx++}`); } if (currency !== undefined) { params.push(currency); updates.push(`currency = $${idx++}`); } if (sortOrder !== undefined) { params.push(parseInt(sortOrder)); updates.push(`sort_order = $${idx++}`); } if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' }); updates.push('updated_at = NOW()'); params.push(id); const rows = await query( `UPDATE finance_accounts SET ${updates.join(', ')} WHERE id = $${params.length} RETURNING id, type, name, balance, currency, sort_order AS "sortOrder"`, params ); if (rows.length === 0) return res.status(404).json({ error: 'Account not found' }); res.json(rows[0]); } catch (err) { console.error('Error updating finance account:', err); res.status(500).json({ error: 'Failed to update account', details: err.message }); } }); // GET /api/finance/payment-invoices - список счетов (scope=own — только счета, созданные текущим пользователем) app.get(`${API_PREFIX}/finance/payment-invoices`, async (req, res) => { try { const { status, purposeType, paymentFormat, search, page = 1, limit = 50, buildingId, scope } = req.query; let queryText = ` SELECT id, invoice_number AS "invoiceNumber", created_by AS "createdBy", created_at AS "createdAt", updated_at AS "updatedAt", purpose_type AS "purposeType", purpose_building_ids AS "purposeBuildingIds", purpose_district_ids AS "purposeDistrictIds", purpose_description AS "purposeDescription", purpose_event_id AS "purposeEventId", plan_item_id AS "planItemId", plan_item_building_id AS "planItemBuildingId", payment_format AS "paymentFormat", item_type AS "itemType", contractor_name AS "contractorName", contractor_inn AS "contractorInn", service_description AS "serviceDescription", service_items AS "serviceItems", material_items AS "materialItems", total_amount AS "totalAmount", distribution_method AS "distributionMethod", distribution_data AS "distributionData", status, current_approver_role AS "currentApproverRole", approval_history AS "approvalHistory", rejection_reason AS "rejectionReason", scheduled_date AS "scheduledDate", payment_date AS "paymentDate", payment_ref AS "paymentRef", is_cash AS "isCash", postponed_date AS "postponedDate", cancel_reason AS "cancelReason", is_completed AS "isCompleted", closing_docs_received AS "closingDocsReceived", closing_docs_files AS "closingDocsFiles", notes, file_urls AS "fileUrls" FROM payment_invoices WHERE 1=1 `; const params = []; if (status && status !== 'all') { queryText += ` AND status = $${params.length + 1}`; params.push(status); } if (purposeType && purposeType !== 'all') { queryText += ` AND purpose_type = $${params.length + 1}`; params.push(purposeType); } if (req.query.purposeEventId) { queryText += ` AND purpose_event_id = $${params.length + 1}`; params.push(req.query.purposeEventId); } if (buildingId && typeof buildingId === 'string') { queryText += ` AND (purpose_building_ids @> $${params.length + 1}::jsonb OR plan_item_building_id = $${params.length + 2})`; params.push(JSON.stringify([buildingId]), buildingId); } if (paymentFormat && paymentFormat !== 'all') { queryText += ` AND payment_format = $${params.length + 1}`; params.push(paymentFormat); } if (search) { queryText += ` AND ( invoice_number ILIKE $${params.length + 1} OR contractor_name ILIKE $${params.length + 1} OR service_description ILIKE $${params.length + 1} )`; const searchTerm = `%${search}%`; params.push(searchTerm, searchTerm, searchTerm); } if (scope === 'own' && req.user && req.user.employeeId) { queryText += ` AND created_by = $${params.length + 1}`; params.push(req.user.employeeId); } queryText += ` ORDER BY created_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`; params.push(parseInt(limit), (parseInt(page) - 1) * parseInt(limit)); const rows = await query(queryText, params); // Получаем общее количество для пагинации let countQuery = `SELECT COUNT(*) as total FROM payment_invoices WHERE 1=1`; const countParams = []; if (status && status !== 'all') { countQuery += ` AND status = $${countParams.length + 1}`; countParams.push(status); } if (purposeType && purposeType !== 'all') { countQuery += ` AND purpose_type = $${countParams.length + 1}`; countParams.push(purposeType); } if (req.query.purposeEventId) { countQuery += ` AND purpose_event_id = $${countParams.length + 1}`; countParams.push(req.query.purposeEventId); } if (buildingId && typeof buildingId === 'string') { countQuery += ` AND (purpose_building_ids @> $${countParams.length + 1}::jsonb OR plan_item_building_id = $${countParams.length + 2})`; countParams.push(JSON.stringify([buildingId]), buildingId); } if (paymentFormat && paymentFormat !== 'all') { countQuery += ` AND payment_format = $${countParams.length + 1}`; countParams.push(paymentFormat); } if (search) { countQuery += ` AND ( invoice_number ILIKE $${countParams.length + 1} OR contractor_name ILIKE $${countParams.length + 1} OR service_description ILIKE $${countParams.length + 1} )`; const searchTerm = `%${search}%`; countParams.push(searchTerm, searchTerm, searchTerm); } if (scope === 'own' && req.user && req.user.employeeId) { countQuery += ` AND created_by = $${countParams.length + 1}`; countParams.push(req.user.employeeId); } const countResult = await query(countQuery, countParams); const total = parseInt(countResult[0]?.total || 0); res.json({ invoices: rows, pagination: { page: parseInt(page), limit: parseInt(limit), total, totalPages: Math.ceil(total / parseInt(limit)) } }); } catch (err) { console.error('Error fetching payment invoices:', err); res.status(500).json({ error: 'Failed to fetch payment invoices', details: err.message }); } }); // GET /api/finance/payment-invoices/:id - детали счета app.get(`${API_PREFIX}/finance/payment-invoices/:id`, async (req, res) => { try { const { id } = req.params; const result = await query( `SELECT id, invoice_number AS "invoiceNumber", created_by AS "createdBy", created_at AS "createdAt", updated_at AS "updatedAt", purpose_type AS "purposeType", purpose_building_ids AS "purposeBuildingIds", purpose_district_ids AS "purposeDistrictIds", purpose_description AS "purposeDescription", purpose_event_id AS "purposeEventId", plan_item_id AS "planItemId", plan_item_building_id AS "planItemBuildingId", payment_format AS "paymentFormat", item_type AS "itemType", contractor_name AS "contractorName", contractor_inn AS "contractorInn", service_description AS "serviceDescription", service_items AS "serviceItems", material_items AS "materialItems", total_amount AS "totalAmount", distribution_method AS "distributionMethod", distribution_data AS "distributionData", status, current_approver_role AS "currentApproverRole", approval_history AS "approvalHistory", rejection_reason AS "rejectionReason", scheduled_date AS "scheduledDate", payment_date AS "paymentDate", payment_ref AS "paymentRef", is_cash AS "isCash", postponed_date AS "postponedDate", cancel_reason AS "cancelReason", is_completed AS "isCompleted", closing_docs_received AS "closingDocsReceived", closing_docs_files AS "closingDocsFiles", notes, file_urls AS "fileUrls" FROM payment_invoices WHERE id = $1`, [id] ); if (result.length === 0) { return res.status(404).json({ error: 'Invoice not found' }); } res.json(result[0]); } catch (err) { console.error('Error fetching payment invoice:', err); res.status(500).json({ error: 'Failed to fetch payment invoice', details: err.message }); } }); // POST /api/finance/payment-invoices - создание счета app.post(`${API_PREFIX}/finance/payment-invoices`, async (req, res) => { try { const { createdBy, purposeType, purposeBuildingIds = [], purposeDistrictIds = [], purposeDescription, purposeEventId, planItemId, planItemBuildingId, paymentFormat, itemType = 'service', contractorName, contractorInn, serviceDescription, // Для обратной совместимости serviceItems, // Список услуг materialItems, // Список ТМЦ totalAmount, distributionMethod, distributionData = {}, notes, fileUrls = [] } = req.body; if (!createdBy || !purposeType || !paymentFormat || !contractorName || !totalAmount) { return res.status(400).json({ error: 'Missing required fields' }); } // Проверка наличия услуг или ТМЦ в зависимости от типа if (itemType === 'service') { if (!serviceItems || serviceItems.length === 0) { return res.status(400).json({ error: 'Добавьте хотя бы одну услугу' }); } // Проверяем, что все услуги заполнены for (let i = 0; i < serviceItems.length; i++) { const item = serviceItems[i]; if (!item.name || !item.name.trim()) { return res.status(400).json({ error: `Услуга ${i + 1}: название обязательно` }); } if (!item.amount || item.amount <= 0) { return res.status(400).json({ error: `Услуга ${i + 1}: сумма должна быть больше нуля` }); } } } else if (itemType === 'materials') { if (!materialItems || materialItems.length === 0) { return res.status(400).json({ error: 'Добавьте хотя бы одну позицию ТМЦ' }); } // Проверяем, что все ТМЦ заполнены for (let i = 0; i < materialItems.length; i++) { const item = materialItems[i]; if (!item.name || !item.name.trim()) { return res.status(400).json({ error: `ТМЦ ${i + 1}: наименование обязательно` }); } if (!item.quantity || item.quantity <= 0) { return res.status(400).json({ error: `ТМЦ ${i + 1}: количество должно быть больше нуля` }); } if (!item.unit || !item.unit.trim()) { return res.status(400).json({ error: `ТМЦ ${i + 1}: единица измерения обязательна` }); } if (!item.pricePerUnit || item.pricePerUnit <= 0) { return res.status(400).json({ error: `ТМЦ ${i + 1}: цена за единицу должна быть больше нуля` }); } } } // Генерируем номер счета const invoiceNumber = paymentInvoiceWorkflow.generateInvoiceNumber(); // Определяем начальный статус // Если создатель из высшего звена - пропускаем этап manager const userRoles = await getUserRoles(createdBy); const isTopManagement = userRoles.some(role => paymentInvoiceWorkflow.TOP_MANAGEMENT_ROLES.includes(role)); const initialStatus = isTopManagement ? 'pending_finance_manager_approval' : 'pending_manager_approval'; const currentApproverRole = isTopManagement ? 'finance_manager' : 'manager'; // Формируем service_description из списков для обратной совместимости let finalServiceDescription = serviceDescription || ''; if (!finalServiceDescription) { if (itemType === 'service' && serviceItems && serviceItems.length > 0) { finalServiceDescription = serviceItems.map((item) => `${item.name} - ${item.amount} ₽`).join('; '); } else if (itemType === 'materials' && materialItems && materialItems.length > 0) { finalServiceDescription = materialItems.map((item) => `${item.name} (${item.quantity} ${item.unit}) - ${item.amount} ₽`).join('; '); } } const result = await query( `INSERT INTO payment_invoices ( invoice_number, created_by, purpose_type, purpose_building_ids, purpose_district_ids, purpose_description, purpose_event_id, plan_item_id, plan_item_building_id, payment_format, item_type, contractor_name, contractor_inn, service_description, service_items, material_items, total_amount, distribution_method, distribution_data, status, current_approver_role, notes, file_urls ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23) RETURNING id, invoice_number AS "invoiceNumber", created_at AS "createdAt"`, [ invoiceNumber, createdBy, purposeType, JSON.stringify(purposeBuildingIds), JSON.stringify(purposeDistrictIds), purposeDescription || null, purposeEventId ? parseInt(purposeEventId, 10) : null, planItemId || null, planItemBuildingId || null, paymentFormat, itemType, contractorName, contractorInn || null, finalServiceDescription || null, itemType === 'service' ? JSON.stringify(serviceItems || []) : null, itemType === 'materials' ? JSON.stringify(materialItems || []) : null, totalAmount, distributionMethod || null, JSON.stringify(distributionData), initialStatus, currentApproverRole, notes || null, JSON.stringify(fileUrls) ] ); try { const opts = { type: 'payment_invoice', title: 'Новый счёт на оплату', body: (contractorName || '') + (totalAmount ? ' — ' + totalAmount + ' ₽' : ''), entityType: 'payment_invoice', entityId: String(result[0].id), }; const creatorUserId = createdBy != null ? Number(createdBy) : null; if (creatorUserId) { await notificationService.createNotification(pool, { ...opts, userId: creatorUserId }); } else { await notificationService.createNotificationForResponsibleZone(pool, 'finance', 'invoices', opts); } } catch (notifErr) { console.warn('Notification (payment invoice create):', notifErr.message); } res.status(201).json(result[0]); } catch (err) { console.error('Error creating payment invoice:', err); res.status(500).json({ error: 'Failed to create payment invoice', details: err.message }); } }); // POST /api/finance/payment-invoices/upload - загрузка файла для счета на оплату app.post(`${API_PREFIX}/finance/payment-invoices/upload`, uploadInvoice.single('file'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: 'Файл не загружен' }); } // Исправляем кодировку оригинального имени файла let originalName = req.file.originalname; try { const hasCyrillic = /[А-Яа-яЁё]/.test(originalName); const hasGarbled = /[ÐÑ]/i.test(originalName) || /[â€]/i.test(originalName); if (hasGarbled || (!hasCyrillic && /[^\x00-\x7F]/.test(originalName))) { try { const buffer = Buffer.from(originalName, 'latin1'); const decoded = buffer.toString('utf8'); if (/[А-Яа-яЁё]/.test(decoded)) { originalName = decoded; } } catch (decodeErr) { try { originalName = decodeURIComponent(escape(originalName)); } catch (e) { // Используем оригинал } } } } catch (decodeErr) { console.warn('Ошибка декодирования имени файла:', decodeErr); } // Используем API endpoint для скачивания вместо прямого пути const fileUrl = `/api/finance/payment-invoices/download/${req.file.filename}`; const fileInfo = { filename: originalName, url: fileUrl, size: req.file.size, mimetype: req.file.mimetype, uploadedAt: new Date().toISOString(), storedFilename: req.file.filename }; res.json({ success: true, file: fileInfo, url: fileUrl, fileUrl: fileUrl }); } catch (err) { console.error('Error uploading invoice file:', err); res.status(500).json({ error: 'Failed to upload file' }); } }); // GET /api/finance/payment-invoices/download/:filename - скачивание файла счета app.get(`${API_PREFIX}/finance/payment-invoices/download/:filename`, (req, res) => { try { const filename = req.params.filename; const filePath = path.join(invoicesDir, filename); if (!fs.existsSync(filePath)) { return res.status(404).json({ error: 'Файл не найден' }); } res.download(filePath, (err) => { if (err) { console.error('Error downloading file:', err); if (!res.headersSent) { res.status(500).json({ error: 'Ошибка при скачивании файла' }); } } }); } catch (err) { console.error('Error in download endpoint:', err); res.status(500).json({ error: 'Ошибка при скачивании файла' }); } }); // PUT /api/finance/payment-invoices/:id - обновление счета app.put(`${API_PREFIX}/finance/payment-invoices/:id`, async (req, res) => { try { const { id } = req.params; const { purposeType, purposeBuildingIds, purposeDistrictIds, purposeDescription, purposeEventId, planItemId, planItemBuildingId, paymentFormat, itemType, contractorName, contractorInn, serviceDescription, serviceItems, materialItems, totalAmount, distributionMethod, distributionData, notes, fileUrls } = req.body; // Проверяем, что счет в статусе, который позволяет редактирование const currentInvoice = await query( 'SELECT status FROM payment_invoices WHERE id = $1', [id] ); if (currentInvoice.length === 0) { return res.status(404).json({ error: 'Invoice not found' }); } if (!['draft', 'rejected'].includes(currentInvoice[0].status)) { return res.status(400).json({ error: 'Invoice cannot be edited in current status' }); } const updateFields = []; const params = []; let paramIndex = 1; if (purposeType !== undefined) { updateFields.push(`purpose_type = $${paramIndex++}`); params.push(purposeType); } if (purposeBuildingIds !== undefined) { updateFields.push(`purpose_building_ids = $${paramIndex++}`); params.push(JSON.stringify(purposeBuildingIds)); } if (purposeDistrictIds !== undefined) { updateFields.push(`purpose_district_ids = $${paramIndex++}`); params.push(JSON.stringify(purposeDistrictIds)); } if (purposeDescription !== undefined) { updateFields.push(`purpose_description = $${paramIndex++}`); params.push(purposeDescription); } if (purposeEventId !== undefined) { updateFields.push(`purpose_event_id = $${paramIndex++}`); params.push(purposeEventId ? parseInt(purposeEventId, 10) : null); } if (planItemId !== undefined) { updateFields.push(`plan_item_id = $${paramIndex++}`); params.push(planItemId || null); } if (planItemBuildingId !== undefined) { updateFields.push(`plan_item_building_id = $${paramIndex++}`); params.push(planItemBuildingId || null); } if (paymentFormat !== undefined) { updateFields.push(`payment_format = $${paramIndex++}`); params.push(paymentFormat); } if (itemType !== undefined) { updateFields.push(`item_type = $${paramIndex++}`); params.push(itemType); } if (contractorName !== undefined) { updateFields.push(`contractor_name = $${paramIndex++}`); params.push(contractorName); } if (contractorInn !== undefined) { updateFields.push(`contractor_inn = $${paramIndex++}`); params.push(contractorInn); } if (serviceDescription !== undefined) { updateFields.push(`service_description = $${paramIndex++}`); params.push(serviceDescription); } if (serviceItems !== undefined) { updateFields.push(`service_items = $${paramIndex++}`); params.push(JSON.stringify(serviceItems)); } if (materialItems !== undefined) { updateFields.push(`material_items = $${paramIndex++}`); params.push(JSON.stringify(materialItems)); } if (totalAmount !== undefined) { updateFields.push(`total_amount = $${paramIndex++}`); params.push(totalAmount); } if (distributionMethod !== undefined) { updateFields.push(`distribution_method = $${paramIndex++}`); params.push(distributionMethod); } if (distributionData !== undefined) { updateFields.push(`distribution_data = $${paramIndex++}`); params.push(JSON.stringify(distributionData)); } if (notes !== undefined) { updateFields.push(`notes = $${paramIndex++}`); params.push(notes); } if (fileUrls !== undefined) { updateFields.push(`file_urls = $${paramIndex++}`); params.push(JSON.stringify(fileUrls)); } if (updateFields.length === 0) { return res.status(400).json({ error: 'No fields to update' }); } updateFields.push(`updated_at = NOW()`); params.push(id); const result = await query( `UPDATE payment_invoices SET ${updateFields.join(', ')} WHERE id = $${paramIndex} RETURNING id, invoice_number AS "invoiceNumber", updated_at AS "updatedAt"`, params ); res.json(result[0]); } catch (err) { console.error('Error updating payment invoice:', err); res.status(500).json({ error: 'Failed to update payment invoice', details: err.message }); } }); // POST /api/finance/payment-invoices/:id/approve - согласование счета app.post(`${API_PREFIX}/finance/payment-invoices/:id/approve`, async (req, res) => { try { const { id } = req.params; const { userId, comment = '' } = req.body; if (!userId) { return res.status(400).json({ error: 'userId is required' }); } // Получаем текущий счет const invoiceResult = await query( 'SELECT status, approval_history, current_approver_role FROM payment_invoices WHERE id = $1', [id] ); if (invoiceResult.length === 0) { return res.status(404).json({ error: 'Invoice not found' }); } const currentInvoice = invoiceResult[0]; const currentStatus = currentInvoice.status; // Получаем роли пользователя const userRoles = await getUserRoles(userId); // Проверяем, может ли пользователь согласовать if (!paymentInvoiceWorkflow.canApprove(currentStatus, userRoles)) { return res.status(403).json({ error: 'User does not have permission to approve this invoice' }); } // Определяем следующий статус const nextStatus = paymentInvoiceWorkflow.getNextStatus(currentStatus, userRoles); if (!nextStatus) { return res.status(400).json({ error: 'Cannot determine next status' }); } // Определяем роль следующего согласующего const nextApproverRole = paymentInvoiceWorkflow.getNextApproverRole(nextStatus, userRoles); // Создаем запись в истории const approvalHistory = currentInvoice.approval_history || []; const userRole = userRoles.find(r => paymentInvoiceWorkflow.WORKFLOW_STAGES[currentStatus]?.role === r || (paymentInvoiceWorkflow.WORKFLOW_STAGES[currentStatus]?.role === 'manager' && paymentInvoiceWorkflow.TOP_MANAGEMENT_ROLES.includes(r)) ) || userRoles[0] || 'unknown'; approvalHistory.push( paymentInvoiceWorkflow.createApprovalHistoryEntry(userId, userRole, 'approve', comment) ); // Обновляем счет const updateResult = await query( `UPDATE payment_invoices SET status = $1, current_approver_role = $2, approval_history = $3, updated_at = NOW() WHERE id = $4 RETURNING id, status, current_approver_role AS "currentApproverRole", invoice_number AS "invoiceNumber"`, [nextStatus, nextApproverRole, JSON.stringify(approvalHistory), id] ); const inv = updateResult[0]; try { if (nextApproverRole) { const approverRows = await pool.query( 'SELECT user_id FROM user_roles WHERE role = $1', [nextApproverRole] ); const approverUserIds = approverRows.rows.map(r => r.user_id); await notificationService.createNotificationForUserIds(pool, approverUserIds, { type: 'invoice_approval', title: `Счёт на согласование`, body: `Счёт №${inv.invoiceNumber || id} ожидает вашего согласования`, entityType: 'payment_invoice', entityId: String(id), }); } else { const createdByRows = await query('SELECT created_by FROM payment_invoices WHERE id = $1', [id]); const createdBy = createdByRows[0] && createdByRows[0].created_by; if (createdBy) { await notificationService.createNotification(pool, { userId: createdBy, type: 'invoice_approved', title: `Счёт согласован`, body: `Счёт №${inv.invoiceNumber || id} согласован`, entityType: 'payment_invoice', entityId: String(id), }); } } } catch (notifErr) { console.warn('[notifications] invoice approve:', notifErr.message); } res.json(inv); } catch (err) { console.error('Error approving payment invoice:', err); res.status(500).json({ error: 'Failed to approve payment invoice', details: err.message }); } }); // POST /api/finance/payment-invoices/:id/reject - отклонение счета app.post(`${API_PREFIX}/finance/payment-invoices/:id/reject`, async (req, res) => { try { const { id } = req.params; const { userId, reason } = req.body; if (!userId || !reason) { return res.status(400).json({ error: 'userId and reason are required' }); } // Получаем текущий счет const invoiceResult = await query( 'SELECT status, approval_history FROM payment_invoices WHERE id = $1', [id] ); if (invoiceResult.length === 0) { return res.status(404).json({ error: 'Invoice not found' }); } const currentInvoice = invoiceResult[0]; const currentStatus = currentInvoice.status; // Получаем роли пользователя const userRoles = await getUserRoles(userId); // Проверяем, может ли пользователь отклонить if (!paymentInvoiceWorkflow.canApprove(currentStatus, userRoles)) { return res.status(403).json({ error: 'User does not have permission to reject this invoice' }); } // Создаем запись в истории const approvalHistory = currentInvoice.approval_history || []; const userRole = userRoles.find(r => paymentInvoiceWorkflow.WORKFLOW_STAGES[currentStatus]?.role === r || (paymentInvoiceWorkflow.WORKFLOW_STAGES[currentStatus]?.role === 'manager' && paymentInvoiceWorkflow.TOP_MANAGEMENT_ROLES.includes(r)) ) || userRoles[0] || 'unknown'; approvalHistory.push( paymentInvoiceWorkflow.createApprovalHistoryEntry(userId, userRole, 'reject', reason) ); // Обновляем счет const updateResult = await query( `UPDATE payment_invoices SET status = 'rejected', rejection_reason = $1, current_approver_role = NULL, approval_history = $2, updated_at = NOW() WHERE id = $3 RETURNING id, status, created_by AS "createdBy", invoice_number AS "invoiceNumber"`, [reason, JSON.stringify(approvalHistory), id] ); const inv = updateResult[0]; try { if (inv.createdBy) { await notificationService.createNotification(pool, { userId: inv.createdBy, type: 'invoice_rejected', title: `Счёт отклонён`, body: `Счёт №${inv.invoiceNumber || id} отклонён. Причина: ${(reason || '').slice(0, 60)}${(reason || '').length > 60 ? '…' : ''}`, entityType: 'payment_invoice', entityId: String(id), }); } } catch (notifErr) { console.warn('[notifications] invoice reject:', notifErr.message); } res.json({ id: inv.id, status: inv.status }); } catch (err) { console.error('Error rejecting payment invoice:', err); res.status(500).json({ error: 'Failed to reject payment invoice', details: err.message }); } }); // POST /api/finance/payment-invoices/:id/schedule - постановка в график платежей app.post(`${API_PREFIX}/finance/payment-invoices/:id/schedule`, async (req, res) => { try { const { id } = req.params; const { userId, scheduledDate } = req.body; if (!userId || !scheduledDate) { return res.status(400).json({ error: 'userId and scheduledDate are required' }); } // Проверяем, что пользователь имеет право ставить в график (финансист или фин.рук) const userRoles = await getUserRoles(userId); if (!userRoles.includes('financier') && !userRoles.includes('finance_manager')) { return res.status(403).json({ error: 'Only finance_manager or financier can schedule invoices' }); } // Проверяем, что счет в статусе, из которого можно ставить в график const invoiceResult = await query( 'SELECT status FROM payment_invoices WHERE id = $1', [id] ); if (invoiceResult.length === 0) { return res.status(404).json({ error: 'Invoice not found' }); } const currentStatus = invoiceResult[0].status; const allowedStatuses = ['approved', 'completed']; if (!allowedStatuses.includes(currentStatus)) { return res.status(400).json({ error: 'Invoice must be approved or completed before scheduling', currentStatus }); } // Получаем данные счета для записи в календарь const invoiceRow = await query( 'SELECT contractor_name, service_description, total_amount FROM payment_invoices WHERE id = $1', [id] ); const inv = invoiceRow[0]; // Обновляем счет const updateResult = await query( `UPDATE payment_invoices SET status = 'scheduled', scheduled_date = $1, updated_at = NOW() WHERE id = $2 RETURNING id, status, scheduled_date AS "scheduledDate"`, [scheduledDate, id] ); // Создаем запись в платежном календаре (расход по счету) try { await query( `INSERT INTO payment_calendar_entries (direction, type, payment_invoice_id, category, description, amount, scheduled_date, probability, currency, is_cash, contractor_name, created_by) VALUES ('outgoing', 'invoice', $1, '', $2, $3, $4, 'confirmed', 'RUB', false, $5, $6)`, [id, inv?.service_description || '', parseFloat(inv?.total_amount) || 0, scheduledDate, inv?.contractor_name || '', userId] ); } catch (calErr) { console.warn('Payment calendar entry not created (table may not exist):', calErr.message); } res.json(updateResult[0]); } catch (err) { console.error('Error scheduling payment invoice:', err); res.status(500).json({ error: 'Failed to schedule payment invoice', details: err.message }); } }); // Вспомогательная функция: перенос ТМЦ из счета в склад дома/участка async function transferInvoiceMaterialsToInventory(invoice) { try { const itemType = invoice.item_type; const purposeType = invoice.purpose_type; const materialItems = invoice.material_items || []; const purposeBuildingIds = Array.isArray(invoice.purpose_building_ids) ? invoice.purpose_building_ids : []; const purposeDistrictIds = Array.isArray(invoice.purpose_district_ids) ? invoice.purpose_district_ids : []; if (itemType !== 'materials' || !Array.isArray(materialItems) || materialItems.length === 0) { return; } const today = new Date().toISOString().split('T')[0]; const mapMaterialToInventoryItem = (material) => { if (!material || !material.name) return null; const nameLower = String(material.name).toLowerCase(); let category = 'material'; if ( nameLower.includes('инструмент') || nameLower.includes('дрель') || nameLower.includes('перфоратор') || nameLower.includes('болгарка') || nameLower.includes('отверт') || nameLower.includes('ключ') ) { category = 'tool'; } else if ( nameLower.includes('дверь') || nameLower.includes('окно') || nameLower.includes('рама') || nameLower.includes('блок') ) { category = 'door'; } else if ( nameLower.includes('расход') || nameLower.includes('салфетк') || nameLower.includes('перчатк') || nameLower.includes('скотч') ) { category = 'consumable'; } return { id: `inv-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, name: material.name, category, quantity: Number(material.quantity) || 0, unit: material.unit || 'шт.', lastCheck: today, source: 'invoice' }; }; const newItems = materialItems .map(mapMaterialToInventoryItem) .filter((item) => item && item.quantity > 0); if (newItems.length === 0) return; // Перенос на дом if (purposeType === 'building' && purposeBuildingIds.length > 0) { for (const buildingId of purposeBuildingIds) { const rows = await query('SELECT data FROM buildings WHERE id = $1', [buildingId]); if (!rows.length) continue; const building = rows[0].data || {}; const existingInventory = Array.isArray(building.inventory) ? building.inventory : []; const updatedInventory = [...existingInventory]; newItems.forEach((newItem) => { const existingIndex = updatedInventory.findIndex( (item) => item.name === newItem.name && item.unit === newItem.unit ); if (existingIndex >= 0) { updatedInventory[existingIndex] = { ...updatedInventory[existingIndex], quantity: Number(updatedInventory[existingIndex].quantity || 0) + newItem.quantity }; } else { updatedInventory.push(newItem); } }); building.inventory = updatedInventory; await query('UPDATE buildings SET data = $2 WHERE id = $1', [buildingId, building]); } } // Перенос на участок if (purposeType === 'district' && purposeDistrictIds.length > 0) { for (const districtId of purposeDistrictIds) { const rows = await query( 'SELECT COALESCE(inventory, \'[]\'::jsonb) AS inventory FROM districts WHERE id = $1', [districtId] ); if (!rows.length) continue; const existingInventory = Array.isArray(rows[0].inventory) ? rows[0].inventory : []; const updatedInventory = [...existingInventory]; newItems.forEach((newItem) => { const existingIndex = updatedInventory.findIndex( (item) => item.name === newItem.name && item.unit === newItem.unit ); if (existingIndex >= 0) { updatedInventory[existingIndex] = { ...updatedInventory[existingIndex], quantity: Number(updatedInventory[existingIndex].quantity || 0) + newItem.quantity }; } else { updatedInventory.push(newItem); } }); await query('UPDATE districts SET inventory = $2 WHERE id = $1', [ districtId, JSON.stringify(updatedInventory) ]); } } } catch (err) { console.error('[transferInvoiceMaterialsToInventory] Error:', err); } } // POST /api/finance/payment-invoices/:id/mark-completed - отметка "выполнено" / "получено" (для ТМЦ) app.post(`${API_PREFIX}/finance/payment-invoices/:id/mark-completed`, async (req, res) => { try { const { id } = req.params; // Получаем счет const invoiceResult = await query( `SELECT payment_format, status, purpose_type, purpose_building_ids, purpose_district_ids, item_type, material_items FROM payment_invoices WHERE id = $1`, [id] ); if (invoiceResult.length === 0) { return res.status(404).json({ error: 'Invoice not found' }); } const invoice = invoiceResult[0]; // Для постоплаты: выполнено должно быть до оплаты // Для предоплаты/аванса: выполнено после оплаты if (invoice.payment_format === 'postpayment') { if (!['approved', 'scheduled'].includes(invoice.status)) { return res.status(400).json({ error: 'Postpayment invoice must be approved or scheduled before marking as completed' }); } } else if (invoice.payment_format === 'prepayment' || invoice.payment_format === 'advance') { if (invoice.status !== 'paid') { return res.status(400).json({ error: 'Prepayment/advance invoice must be paid before marking as completed' }); } } // Если это ТМЦ и счет привязан к дому/участку — переносим на склад await transferInvoiceMaterialsToInventory(invoice); // Обновляем счет const updateResult = await query( `UPDATE payment_invoices SET is_completed = TRUE, status = CASE WHEN payment_format = 'postpayment' THEN 'scheduled' ELSE status END, updated_at = NOW() WHERE id = $1 RETURNING id, is_completed AS "isCompleted", status`, [id] ); res.json(updateResult[0]); } catch (err) { console.error('Error marking invoice as completed:', err); res.status(500).json({ error: 'Failed to mark invoice as completed', details: err.message }); } }); // POST /api/finance/payment-invoices/:id/closing-docs - отметка получения закрывающих документов app.post(`${API_PREFIX}/finance/payment-invoices/:id/closing-docs`, async (req, res) => { try { const { id } = req.params; const { received = true, files = [] } = req.body || {}; // Получаем текущие файлы закрывающих документов const existingResult = await query( 'SELECT closing_docs_files FROM payment_invoices WHERE id = $1', [id] ); if (existingResult.length === 0) { return res.status(404).json({ error: 'Invoice not found' }); } const currentFiles = existingResult[0].closing_docs_files || []; const newFiles = Array.isArray(files) ? files : []; const mergedFiles = [...currentFiles, ...newFiles]; const updateResult = await query( `UPDATE payment_invoices SET closing_docs_received = $1, closing_docs_files = $2, updated_at = NOW() WHERE id = $3 RETURNING id, closing_docs_received AS "closingDocsReceived", closing_docs_files AS "closingDocsFiles"`, [!!received, JSON.stringify(mergedFiles), id] ); res.json(updateResult[0]); } catch (err) { console.error('Error updating closing docs flag:', err); res.status(500).json({ error: 'Failed to update closing docs flag', details: err.message }); } }); // POST /api/finance/payment-invoices/:id/update-payment-status - обновление статуса оплаты из календаря app.post(`${API_PREFIX}/finance/payment-invoices/:id/update-payment-status`, async (req, res) => { try { const { id } = req.params; const { status, paymentDate, paymentRef, isCash, postponedDate, cancelReason } = req.body; if (!status || !['paid', 'postponed', 'cancelled'].includes(status)) { return res.status(400).json({ error: 'Invalid status. Must be paid, postponed, or cancelled' }); } const updateFields = ['status = $1', 'updated_at = NOW()']; const params = [status]; let idx = 2; if (status === 'paid') { if (paymentDate) { updateFields.push(`payment_date = $${idx++}`); params.push(paymentDate); } if (paymentRef !== undefined) { updateFields.push(`payment_ref = $${idx++}`); params.push(paymentRef || null); } if (isCash !== undefined) { updateFields.push(`is_cash = $${idx++}`); params.push(!!isCash); } } else if (status === 'postponed') { updateFields[0] = 'status = $1'; params[0] = 'scheduled'; if (postponedDate) { updateFields.push(`scheduled_date = $${idx++}`); updateFields.push(`postponed_date = $${idx++}`); params.push(postponedDate, postponedDate); } } else if (status === 'cancelled') { updateFields[0] = 'status = $1'; params[0] = 'cancelled'; if (cancelReason !== undefined) { updateFields.push(`cancel_reason = $${idx++}`); params.push(cancelReason || null); } } params.push(id); const updateResult = await query( `UPDATE payment_invoices SET ${updateFields.join(', ')} WHERE id = $${params.length} RETURNING id, status, payment_date AS "paymentDate", payment_ref AS "paymentRef", is_cash AS "isCash", scheduled_date AS "scheduledDate", postponed_date AS "postponedDate", cancel_reason AS "cancelReason"`, params ); res.json(updateResult[0]); } catch (err) { console.error('Error updating payment status:', err); res.status(500).json({ error: 'Failed to update payment status', details: err.message }); } }); // POST /api/finance/payment-invoices/:id/distribute - распределение суммы по домам/участкам app.post(`${API_PREFIX}/finance/payment-invoices/:id/distribute`, async (req, res) => { try { const { id } = req.params; const { distributionMethod, distributionData } = req.body; if (!distributionMethod || !['equal', 'by_area', 'manual'].includes(distributionMethod)) { return res.status(400).json({ error: 'Invalid distribution method' }); } if (!distributionData || typeof distributionData !== 'object') { return res.status(400).json({ error: 'distributionData is required' }); } // Обновляем счет const updateResult = await query( `UPDATE payment_invoices SET distribution_method = $1, distribution_data = $2, updated_at = NOW() WHERE id = $3 RETURNING id, distribution_method AS "distributionMethod", distribution_data AS "distributionData"`, [distributionMethod, JSON.stringify(distributionData), id] ); if (updateResult.length === 0) { return res.status(404).json({ error: 'Invoice not found' }); } res.json(updateResult[0]); } catch (err) { console.error('Error distributing invoice:', err); res.status(500).json({ error: 'Failed to distribute invoice', details: err.message }); } }); // ========= РОЛИ ПОЛЬЗОВАТЕЛЕЙ ========= // GET /api/finance/user-roles - роли текущего пользователя app.get(`${API_PREFIX}/finance/user-roles`, async (req, res) => { try { const { userId } = req.query; if (!userId) { return res.status(400).json({ error: 'userId is required' }); } const result = await query( 'SELECT id, user_id AS "userId", role, created_at AS "createdAt" FROM user_roles WHERE user_id = $1', [userId] ); res.json(result); } catch (err) { console.error('Error fetching user roles:', err); res.status(500).json({ error: 'Failed to fetch user roles', details: err.message }); } }); // GET /api/finance/user-roles/:userId - роли конкретного пользователя app.get(`${API_PREFIX}/finance/user-roles/:userId`, async (req, res) => { try { const { userId } = req.params; const result = await query( 'SELECT id, user_id AS "userId", role, created_at AS "createdAt" FROM user_roles WHERE user_id = $1', [userId] ); res.json(result); } catch (err) { console.error('Error fetching user roles:', err); res.status(500).json({ error: 'Failed to fetch user roles', details: err.message }); } }); // POST /api/finance/user-roles - назначение роли пользователю app.post(`${API_PREFIX}/finance/user-roles`, async (req, res) => { try { const { userId, role } = req.body; if (!userId || !role) { return res.status(400).json({ error: 'userId and role are required' }); } const validRoles = ['manager', 'finance_manager', 'financier', 'finance_director', 'director', 'top_management']; if (!validRoles.includes(role)) { return res.status(400).json({ error: 'Invalid role' }); } const result = await query( `INSERT INTO user_roles (user_id, role) VALUES ($1, $2) ON CONFLICT (user_id, role) DO NOTHING RETURNING id, user_id AS "userId", role, created_at AS "createdAt"`, [userId, role] ); if (result.length === 0) { return res.status(409).json({ error: 'Role already assigned' }); } res.status(201).json(result[0]); } catch (err) { console.error('Error assigning user role:', err); res.status(500).json({ error: 'Failed to assign user role', details: err.message }); } }); // DELETE /api/finance/user-roles/:id - удаление роли app.delete(`${API_PREFIX}/finance/user-roles/:id`, async (req, res) => { try { const { id } = req.params; const result = await query( 'DELETE FROM user_roles WHERE id = $1 RETURNING id', [id] ); if (result.length === 0) { return res.status(404).json({ error: 'Role not found' }); } res.json({ success: true }); } catch (err) { console.error('Error deleting user role:', err); res.status(500).json({ error: 'Failed to delete user role', details: err.message }); } }); // Функция для автоматического создания отчетов 1 числа каждого месяца async function createMonthlyReportsForAllBuildings() { try { const now = new Date(); const today = now.getDate(); // Проверяем, что сегодня 1 число if (today !== 1) { return; } // Определяем период прошлого месяца const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59); const periodStart = lastMonth.toISOString().split('T')[0]; const periodEnd = lastMonthEnd.toISOString().split('T')[0]; const months = [ 'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь' ]; const month = `${months[lastMonth.getMonth()]} ${lastMonth.getFullYear()}`; // Получаем все дома const buildingsResult = await query('SELECT id FROM buildings'); const buildingIds = buildingsResult.map(row => row.id); if (buildingIds.length === 0) { console.log('[Auto Reports] Нет домов для создания отчетов'); return; } console.log(`[Auto Reports] Начинаем создание отчетов за ${month} для ${buildingIds.length} домов...`); let createdCount = 0; let updatedCount = 0; // Создаем отчеты для каждого дома for (const buildingId of buildingIds) { try { // Проверяем, существует ли уже отчет за этот период const existing = await query( `SELECT id FROM resident_reports WHERE building_id = $1 AND period_start = $2 AND period_end = $3`, [buildingId, periodStart, periodEnd] ); if (existing.length > 0) { // Обновляем существующий отчет (если он в статусе черновик) const reportId = existing[0].id; await query( `UPDATE resident_reports SET month = $1, updated_at = NOW() WHERE id = $2 AND status = 'draft'`, [month, reportId] ); updatedCount++; } else { // Создаем новый отчет await query( `INSERT INTO resident_reports (building_id, month, period_start, period_end, status, content) VALUES ($1, $2, $3, $4, 'draft', '{}'::jsonb)`, [buildingId, month, periodStart, periodEnd] ); createdCount++; } } catch (err) { console.error(`[Auto Reports] Ошибка создания отчета для дома ${buildingId}:`, err.message); // Продолжаем создание отчетов для других домов } } console.log(`[Auto Reports] Завершено: создано ${createdCount}, обновлено ${updatedCount} отчетов за ${month}`); } catch (err) { console.error('[Auto Reports] Ошибка автоматического создания отчетов:', err); } } // Функция для автоматического создания опросов NPS для всех домов // Создает один опрос для каждого дома один раз (не пересоздается каждый месяц) async function createNPSSurveysForAllBuildings() { try { // Получаем все дома const buildingsResult = await query('SELECT id FROM buildings'); const buildingIds = buildingsResult.map(row => row.id); if (buildingIds.length === 0) { console.log('[Auto NPS] Нет домов для создания опросов'); return; } console.log(`[Auto NPS] Проверка опросов для ${buildingIds.length} домов...`); let createdCount = 0; // Создаем опросы для каждого дома, если их еще нет for (const buildingId of buildingIds) { try { // Проверяем, существует ли уже опрос для этого дома (любой статус) const existing = await query( `SELECT id FROM nps_surveys WHERE building_id = $1`, [buildingId] ); if (existing.length === 0) { // Создаем новый опрос только если его еще нет const accessKey = `nps-${buildingId}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; await query( `INSERT INTO nps_surveys (building_id, title, description, status, access_key, created_by) VALUES ($1, $2, $3, $4, $5, 'system')`, [ buildingId, 'Опрос удовлетворенности жителей', 'Помогите нам стать лучше! Поделитесь своим мнением о качестве обслуживания.', 'draft', accessKey ] ); createdCount++; } } catch (err) { console.error(`[Auto NPS] Ошибка создания опроса для дома ${buildingId}:`, err.message); // Продолжаем создание опросов для других домов } } if (createdCount > 0) { console.log(`[Auto NPS] Создано ${createdCount} новых опросов`); } } catch (err) { console.error('[Auto NPS] Ошибка автоматического создания опросов:', err); } } // Проверка при запуске сервера и ежедневная проверка async function checkAndCreateMonthlyReports() { try { await createMonthlyReportsForAllBuildings(); } catch (err) { console.error('[Auto Reports] Ошибка проверки создания отчетов:', err); } } // Проверка и создание опросов NPS при запуске сервера async function checkAndCreateNPSSurveys() { try { await createNPSSurveysForAllBuildings(); } catch (err) { console.error('[Auto NPS] Ошибка проверки создания опросов:', err); } } // Запускаем проверку при старте сервера if (DATABASE_URL) { // Небольшая задержка, чтобы БД успела инициализироваться setTimeout(() => { checkAndCreateMonthlyReports(); checkAndCreateNPSSurveys(); // Создаем опросы NPS при старте }, 5000); } // Проверяем каждый день в 2:00 ночи setInterval(() => { checkAndCreateMonthlyReports(); }, 24 * 60 * 60 * 1000); // 24 часа // POST /api/pr/reports/auto-create - ручной запуск создания отчетов app.post(`${API_PREFIX}/pr/reports/auto-create`, async (req, res) => { try { await createMonthlyReportsForAllBuildings(); await createNPSSurveysForAllBuildings(); res.json({ success: true, message: 'Проверка создания отчетов и опросов выполнена' }); } catch (err) { console.error('Error in auto-create reports:', err); res.status(500).json({ error: 'Failed to create reports', details: err.message }); } }); startServer();