Initial commit MKD fixes

This commit is contained in:
Arsen
2026-02-04 00:17:04 +05:00
commit de94ad707b
312 changed files with 138754 additions and 0 deletions

561
backend/pipelineAutomation.js Executable file
View 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;