Files
mkd/backend/pipelineAutomation.js
2026-02-04 00:17:04 +05:00

562 lines
22 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Модуль автоматизации воронки развития
* Обрабатывает автоматические переходы между стадиями, расчет 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;