562 lines
22 KiB
JavaScript
Executable File
562 lines
22 KiB
JavaScript
Executable File
/**
|
||
* Модуль автоматизации воронки развития
|
||
* Обрабатывает автоматические переходы между стадиями, расчет 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;
|