Initial commit MKD fixes
This commit is contained in:
561
backend/pipelineAutomation.js
Executable file
561
backend/pipelineAutomation.js
Executable file
@@ -0,0 +1,561 @@
|
||||
/**
|
||||
* Модуль автоматизации воронки развития
|
||||
* Обрабатывает автоматические переходы между стадиями, расчет 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;
|
||||
Reference in New Issue
Block a user