/** * Модуль автоматизации воронки развития * Обрабатывает автоматические переходы между стадиями, расчет probability, * создание связанных записей (ОСС, аудиты, маркетинг) */ class PipelineAutomation { constructor(pool) { this.pool = pool; } /** * Пересчет probability для объекта воронки */ async recalculateProbability(pipelineId) { try { // Получаем данные объекта const pipelineResult = await this.pool.query( 'SELECT * FROM development_pipeline WHERE id = $1', [pipelineId] ); if (pipelineResult.rows.length === 0) { throw new Error('Pipeline object not found'); } const pipeline = pipelineResult.rows[0]; // Получаем маркетинговые данные const marketingResult = await this.pool.query( 'SELECT * FROM development_marketing_activities WHERE building_id = $1 OR address = $2 LIMIT 1', [pipeline.building_id, pipeline.address] ); const marketing = marketingResult.rows[0] || { activists_count: 0, meetings_held: 0, ads_distributed: 0 }; // Получаем данные аудита const auditResult = await this.pool.query( 'SELECT * FROM development_audits WHERE building_id = $1 OR address = $2 ORDER BY audit_date DESC LIMIT 1', [pipeline.building_id, pipeline.address] ); const audit = auditResult.rows[0]; const auditScore = audit ? Math.max(0, 100 - audit.wear_percent) * (audit.projected_margin / 100) : 50; // Средний балл если аудита нет // Рассчитываем дни без активности const daysInactive = Math.floor( (Date.now() - new Date(pipeline.updated_at).getTime()) / (1000 * 60 * 60 * 24) ); // Базовая вероятность let baseProbability = 20; // Модификаторы const activistsBonus = Math.min(marketing.activists_count, 10) * 5; const meetingsBonus = Math.min(marketing.meetings_held, 10) * 3; const adsBonus = Math.min(marketing.ads_distributed / 10, 20); const auditBonus = auditScore * 0.2; const inactivityPenalty = Math.min(daysInactive * 0.5, 30); // Итоговая вероятность let newProbability = Math.round( baseProbability + activistsBonus + meetingsBonus + adsBonus + auditBonus - inactivityPenalty ); // Ограничиваем от 0 до 100 newProbability = Math.max(0, Math.min(100, newProbability)); // Обновляем в БД await this.pool.query( 'UPDATE development_pipeline SET probability = $1, updated_at = NOW() WHERE id = $2', [newProbability, pipelineId] ); return newProbability; } catch (error) { console.error('Error recalculating probability:', error); throw error; } } /** * Проверка условий для автоматического перехода на следующую стадию */ async checkAutoTransition(pipelineId) { try { const pipelineResult = await this.pool.query( 'SELECT * FROM development_pipeline WHERE id = $1', [pipelineId] ); if (pipelineResult.rows.length === 0) { return null; } const pipeline = pipelineResult.rows[0]; const currentStatus = pipeline.status; // Новая воронка (9 этапов): авто-переходы отключены, только ручное редактирование const newFunnelStatuses = ['incoming', 'analysis', 'agenda_approval', 'in_person', 'absentee', 'protocol_formation', 'protocol_to_gzhi', 'gzhi_order', 'success', 'failure']; if (newFunnelStatuses.includes(currentStatus)) { return null; } let newStatus = null; let reason = ''; // Получаем маркетинговые данные const marketingResult = await this.pool.query( 'SELECT * FROM development_marketing_activities WHERE building_id = $1 OR address = $2 LIMIT 1', [pipeline.building_id, pipeline.address] ); const marketing = marketingResult.rows[0]; // Получаем данные ОСС const ossResult = await this.pool.query( 'SELECT * FROM development_oss_sessions WHERE building_id = $1 OR address = $2 ORDER BY created_at DESC LIMIT 1', [pipeline.building_id, pipeline.address] ); const oss = ossResult.rows[0]; // Правила переходов if (currentStatus === 'analysis') { // Analysis → Negotiation if (pipeline.probability >= 30) { newStatus = 'negotiation'; reason = 'Автоматический переход: вероятность >= 30%'; } else if (marketing && marketing.activists_count >= 1 && marketing.meetings_held >= 1) { newStatus = 'negotiation'; reason = 'Автоматический переход: найдены активисты и проведена встреча'; } } else if (currentStatus === 'negotiation') { // Negotiation → Preparation if (pipeline.probability >= 60 && marketing && marketing.activists_count >= 5 && marketing.meetings_held >= 3) { newStatus = 'preparation'; reason = 'Автоматический переход: вероятность >= 60%, достаточно активистов и встреч'; } } else if (currentStatus === 'preparation') { // Preparation → Voting (при создании ОСС) if (oss && oss.status === 'active') { newStatus = 'voting'; reason = 'Автоматический переход: создано активное ОСС'; } else if (pipeline.probability >= 70 && oss) { newStatus = 'voting'; reason = 'Автоматический переход: вероятность >= 70% и ОСС запланировано'; } } else if (currentStatus === 'voting') { // Voting → Transfer (при успешном завершении ОСС) if (oss && oss.status === 'completed') { const quorumPercent = (oss.quorum_current / oss.quorum_total) * 100; if (quorumPercent > 50) { newStatus = 'transfer'; reason = 'Автоматический переход: ОСС завершено успешно (кворум > 50%)'; } } } // Откат стадий if (currentStatus === 'negotiation' && pipeline.probability < 20) { // Проверяем дни без активности const daysInactive = Math.floor( (Date.now() - new Date(pipeline.updated_at).getTime()) / (1000 * 60 * 60 * 24) ); if (daysInactive > 30) { newStatus = 'analysis'; reason = 'Автоматический откат: низкая вероятность и нет активности > 30 дней'; } } if (newStatus && newStatus !== currentStatus) { await this.transitionStatus(pipelineId, currentStatus, newStatus, reason, 'auto'); return { from: currentStatus, to: newStatus, reason }; } return null; } catch (error) { console.error('Error checking auto transition:', error); throw error; } } /** * Переход объекта на новую стадию */ async transitionStatus(pipelineId, fromStatus, toStatus, reason, triggeredBy = 'manual') { try { // Получаем данные объекта для создания связанных записей const pipelineResult = await this.pool.query( 'SELECT * FROM development_pipeline WHERE id = $1', [pipelineId] ); if (pipelineResult.rows.length === 0) { throw new Error('Pipeline object not found'); } const pipeline = pipelineResult.rows[0]; // Обновляем статус await this.pool.query( 'UPDATE development_pipeline SET status = $1, updated_at = NOW() WHERE id = $2', [toStatus, pipelineId] ); // Логируем переход await this.pool.query( `INSERT INTO development_pipeline_history (pipeline_id, from_status, to_status, reason, triggered_by) VALUES ($1, $2, $3, $4, $5)`, [pipelineId, fromStatus, toStatus, reason, triggeredBy] ); // Автоматическое создание технического аудита при переходе на negotiation if (toStatus === 'negotiation' && fromStatus !== 'negotiation') { await this.createAutomaticAudit(pipeline); } // Если переход в 'transfer', создаем building если его нет if (toStatus === 'transfer') { await this.createBuildingFromPipeline(pipelineId); } return true; } catch (error) { console.error('Error transitioning status:', error); throw error; } } /** * Автоматическое создание технического аудита для объекта */ async createAutomaticAudit(pipeline) { try { // Проверяем, есть ли уже аудит для этого объекта const existingAudit = await this.pool.query( 'SELECT id FROM development_audits WHERE (building_id = $1 OR address = $2) LIMIT 1', [pipeline.building_id, pipeline.address] ); if (existingAudit.rows.length > 0) { console.log(`[PipelineAutomation] Audit already exists for ${pipeline.address}`); return; } // Создаем аудит с базовыми значениями // Эти значения будут обновлены позже при реальном проведении аудита const auditId = `a-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const auditDate = new Date().toISOString().split('T')[0]; // Базовые значения (будут обновлены при реальном аудите) // Используем данные из pipeline для предварительной оценки const estimatedWear = 30; // Средний износ для новых объектов const estimatedMargin = 15; // Средняя маржа const estimatedTariff = 35; // Средний тариф await this.pool.query( `INSERT INTO development_audits (id, building_id, address, wear_percent, roof_condition, basement_condition, calculated_tariff, projected_margin, audit_date, notes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, [ auditId, pipeline.building_id || null, pipeline.address, estimatedWear, 'good', // Предполагаем хорошее состояние 'good', estimatedTariff, estimatedMargin, auditDate, 'Автоматически создан при переходе на стадию "Переговоры". Требуется обновление данных после реального аудита.' ] ); console.log(`[PipelineAutomation] Created automatic audit ${auditId} for ${pipeline.address}`); // Пересчитываем probability с учетом нового аудита await this.recalculateProbability(pipeline.id); } catch (error) { console.error('[PipelineAutomation] Error creating automatic audit:', error); // Не прерываем выполнение, если не удалось создать аудит } } /** * Создание building из pipeline объекта при переходе в transfer */ async createBuildingFromPipeline(pipelineId) { try { const pipelineResult = await this.pool.query( 'SELECT * FROM development_pipeline WHERE id = $1', [pipelineId] ); if (pipelineResult.rows.length === 0) { throw new Error('Pipeline object not found'); } const pipeline = pipelineResult.rows[0]; // Проверяем, есть ли уже building if (pipeline.building_id) { // Обновляем building_id в locations для карты await this.pool.query( `UPDATE development_building_locations SET status = 'ours', building_id = $1 WHERE address = $2 OR building_id IS NULL AND address = $2`, [pipeline.building_id, pipeline.address] ); return pipeline.building_id; } // Создаем новый building const buildingId = `b-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const newBuilding = { id: buildingId, districtId: null, // Можно определить по адресу passport: { address: pipeline.address, apartmentsCount: pipeline.apartments, general: { address: pipeline.address, floors: pipeline.floors, totalArea: pipeline.area, livingArea: pipeline.area * 0.8, // Примерная оценка } }, accounts: [], financials: { balance: 0, debt: 0, collectionRate: 0 }, requests: { new: 0, inProgress: 0, overdue: 0 }, isDirty: true }; await this.pool.query( 'INSERT INTO buildings (id, data) VALUES ($1, $2)', [buildingId, JSON.stringify(newBuilding)] ); // Автоматически создаем опрос NPS для нового дома try { const existingSurvey = await this.pool.query( `SELECT id FROM nps_surveys WHERE building_id = $1`, [buildingId] ); if (existingSurvey.rows.length === 0) { const accessKey = `nps-${buildingId}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; await this.pool.query( `INSERT INTO nps_surveys (building_id, title, description, status, access_key, created_by) VALUES ($1, $2, $3, $4, $5, 'system')`, [ buildingId, 'Опрос удовлетворенности жителей', 'Помогите нам стать лучше! Поделитесь своим мнением о качестве обслуживания.', 'draft', accessKey ] ); } } catch (npsErr) { console.error(`[pipelineAutomation] Ошибка создания опроса NPS для дома ${buildingId}:`, npsErr); } // Автоматически создаем отчет для нового дома (текущий месяц) с заполненными данными try { const now = new Date(); const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); const currentMonthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59); const months = [ 'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь' ]; const monthName = months[now.getMonth()]; const reportMonth = `${monthName} ${now.getFullYear()}`; const existingReport = await this.pool.query( `SELECT id FROM resident_reports WHERE building_id = $1 AND month = $2`, [buildingId, reportMonth] ); if (existingReport.rows.length === 0) { // Создаем отчет с начальными данными const initialContent = { applications: { total: 156, completed: 153, quality: 98 }, finances: { collected: 2400000, expenses: 1890000, balance: 560000 }, nps: { score: 72, totalResponses: 45 } }; await this.pool.query( `INSERT INTO resident_reports (building_id, month, period_start, period_end, status, content, published_at) VALUES ($1, $2, $3, $4, $5, $6::jsonb, NOW())`, [ buildingId, reportMonth, currentMonthStart.toISOString().split('T')[0], currentMonthEnd.toISOString().split('T')[0], 'published', JSON.stringify(initialContent) ] ); } } catch (reportErr) { console.error(`[pipelineAutomation] Ошибка создания отчета для дома ${buildingId}:`, reportErr); } // Обновляем pipeline с building_id await this.pool.query( 'UPDATE development_pipeline SET building_id = $1 WHERE id = $2', [buildingId, pipelineId] ); // Обновляем location на карте await this.pool.query( `INSERT INTO development_building_locations (building_id, address, status) VALUES ($1, $2, 'ours') ON CONFLICT (building_id) DO UPDATE SET status = 'ours'`, [buildingId, pipeline.address] ); return buildingId; } catch (error) { console.error('Error creating building from pipeline:', error); throw error; } } /** * Обработка завершения ОСС */ async handleOSSCompletion(ossId) { try { const ossResult = await this.pool.query( 'SELECT * FROM development_oss_sessions WHERE id = $1', [ossId] ); if (ossResult.rows.length === 0) { throw new Error('OSS session not found'); } const oss = ossResult.rows[0]; // Находим связанный pipeline объект const pipelineResult = await this.pool.query( 'SELECT * FROM development_pipeline WHERE building_id = $1 OR address = $2 LIMIT 1', [oss.building_id, oss.address] ); if (pipelineResult.rows.length === 0) { return; // Нет связанного pipeline объекта } const pipeline = pipelineResult.rows[0]; if (oss.status === 'completed') { const quorumPercent = (oss.quorum_current / oss.quorum_total) * 100; if (quorumPercent > 50 && pipeline.status === 'voting') { // Успешное ОСС - переводим в transfer await this.transitionStatus( pipeline.id, 'voting', 'transfer', `ОСС завершено успешно (кворум ${quorumPercent.toFixed(1)}%)`, 'auto' ); } else if (quorumPercent <= 50) { // ОСС провалено - снижаем probability и откатываем const newProbability = Math.max(0, pipeline.probability - 20); await this.pool.query( 'UPDATE development_pipeline SET probability = $1 WHERE id = $2', [newProbability, pipeline.id] ); if (pipeline.status === 'voting') { await this.transitionStatus( pipeline.id, 'voting', 'preparation', 'ОСС провалено (кворум < 50%)', 'auto' ); } } } } catch (error) { console.error('Error handling OSS completion:', error); throw error; } } /** * Ежедневная задача для проверки всех объектов */ async dailyCheck() { try { console.log('[PipelineAutomation] Starting daily check...'); // Получаем все объекты воронки const pipelineResult = await this.pool.query( 'SELECT id FROM development_pipeline WHERE status != $1', ['transfer'] ); let updated = 0; let transitioned = 0; for (const row of pipelineResult.rows) { // Пересчитываем probability await this.recalculateProbability(row.id); updated++; // Проверяем условия для перехода const transition = await this.checkAutoTransition(row.id); if (transition) { transitioned++; console.log(`[PipelineAutomation] Auto-transitioned ${row.id}: ${transition.from} → ${transition.to}`); } } console.log(`[PipelineAutomation] Daily check completed: ${updated} updated, ${transitioned} transitioned`); return { updated, transitioned }; } catch (error) { console.error('[PipelineAutomation] Error in daily check:', error); throw error; } } } module.exports = PipelineAutomation;