Files
test-mcp/js/analysis.js
Taehoon dff3305da1 feat: 세이버메트릭스 기반 프로젝트 자산 가치 분석 시스템 고도화 (AVI/VCI 도입)
- analysis_service.py: AVI 및 VCI(자산 기여도) 산출 로직 구현
- prediction_service.py: 정체 프로젝트 AI 예보 최적화
- js/analysis.js: 전략 매트릭스 및 VCI 등급 시스템 시각화
- templates/analysis.html: UI 용어 최신화 및 스타일 통합
- ANALYSIS_REPORT.md: 분석 지표 공식 및 가이드라인 상세 기술
2026-03-24 17:54:01 +09:00

425 lines
27 KiB
JavaScript
Raw 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.
/**
* Project Master Analysis JS
* AVI (Activity Vitality Index) & VCI (Value Contribution Index) 분석 엔진
*/
// Chart.js 플러그인 전역 등록
if (typeof ChartDataLabels !== 'undefined') {
Chart.register(ChartDataLabels);
}
document.addEventListener('DOMContentLoaded', () => {
console.log("Business Analysis Engine initialized...");
loadProjectAnalysisData();
});
async function loadProjectAnalysisData() {
try {
const response = await fetch('/api/analysis/p-war');
const data = await response.json();
if (data.error) throw new Error(data.error);
renderVitalityLeaderboard(data);
renderValueCharts(data);
if (data.length > 0 && data[0].avg_info) {
const avg = data[0].avg_info;
const infoEl = document.getElementById('avg-system-info');
if (infoEl) infoEl.textContent = `* 시스템 종합 자산 건전도: ${avg.avg_risk}% (운영 표준 70.0% 대비)`;
}
} catch (e) {
console.error("분석 데이터 로딩 실패:", e);
}
}
function getStatusInfo(avi, isAutoDelete) {
if (isAutoDelete || avi < 10) return { label: '중단/방치', class: 'badge-system', key: 'dead' };
if (avi < 30) return { label: '위험 노출', class: 'badge-danger', key: 'danger' };
if (avi < 70) return { label: '관리 주의', class: 'badge-warning', key: 'warning' };
return { label: '정상 운영', class: 'badge-active', key: 'active' };
}
// VCI 등급 판정 로직 (Sabermetrics WAR 등급 기준 응용)
function getVciGrade(vci) {
if (vci >= 10) return { label: 'Masterpiece', class: 'grade-mvp', desc: '시스템 가치를 견인하는 핵심 자산 (MVP급)' };
if (vci >= 2) return { label: 'Blue Chip', class: 'grade-allstar', desc: '꾸준한 활력의 우량 자산 (주전급)' };
if (vci >= -2) return { label: 'Steady', class: 'grade-starter', desc: '표준 수준의 현상 유지 (보결급)' };
if (vci >= -10) return { label: 'Underperform', class: 'grade-bench', desc: '운영 미비로 인한 가치 하락 (마이너급)' };
return { label: 'Liability', class: 'grade-out', desc: '가치를 훼손 중인 방치 자산 (방출급)' };
}
function renderValueCharts(data) {
if (!data || data.length === 0) return;
// 1. 운영 활력 분포 (Doughnut)
try {
const stats = { active: [], warning: [], danger: [], dead: [] };
data.forEach(p => {
const status = getStatusInfo(p.p_war, p.is_auto_delete);
stats[status.key].push(p);
});
const statusCtx = document.getElementById('statusChart').getContext('2d');
if (window.myStatusChart) window.myStatusChart.destroy();
window.myStatusChart = new Chart(statusCtx, {
type: 'doughnut',
data: {
labels: ['정상 운영', '관리 주의', '위험 노출', '중단/방치'],
datasets: [{
data: [stats.active.length, stats.warning.length, stats.danger.length, stats.dead.length],
backgroundColor: ['#1E5149', '#22c55e', '#f59e0b', '#ef4444'],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
layout: { padding: 15 },
plugins: {
legend: { position: 'right', labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true } },
datalabels: { display: false }
},
cutout: '65%',
onClick: (e, elements) => {
if (elements.length > 0) {
const idx = elements[0].index;
openProjectListModal(['정상 운영', '관리 주의', '위험 노출', '중단/방치'][idx], stats[['active', 'warning', 'danger', 'dead'][idx]]);
}
}
}
});
} catch (err) { console.error("도넛 차트 에러:", err); }
// 2. 전략적 자산 매트릭스 (Scatter)
try {
const sortedByAVI = [...data].sort((a, b) => b.p_war - a.p_war);
const top5Ids = sortedByAVI.slice(0, 5).map(p => p.project_nm);
const bottom5Ids = sortedByAVI.slice(-5).map(p => p.project_nm);
const largeProjects = data.filter(p => p.file_count > 450).map(p => p.project_nm);
const vipProjectNames = new Set([...top5Ids, ...bottom5Ids, ...largeProjects]);
const scatterData = data.map(p => {
const vci = p.risk_count || 0;
const absVci = Math.abs(vci);
return {
x: Math.min(500, p.file_count),
y: p.p_war,
label: p.project_nm,
isVip: vipProjectNames.has(p.project_nm),
vci: vci,
radius: Math.max(5, Math.min(25, 5 + (absVci / 10)))
};
});
const vitalityCtx = document.getElementById('forecastChart').getContext('2d');
if (window.myVitalityChart) window.myVitalityChart.destroy();
window.myVitalityChart = new Chart(vitalityCtx, {
type: 'scatter',
data: {
datasets: [{
data: scatterData,
backgroundColor: (ctx) => {
const p = ctx.raw;
if (!p) return '#94a3b8';
if (p.x >= 250 && p.y >= 50) return '#1E5149';
if (p.x < 250 && p.y >= 50) return '#22c55e';
if (p.x < 250 && p.y < 50) return '#94a3b8';
return '#ef4444';
},
pointRadius: (ctx) => ctx.raw ? ctx.raw.radius : 5,
hoverRadius: (ctx) => (ctx.raw ? ctx.raw.radius : 5) + 3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
layout: { padding: { top: 30, right: 45, left: 10, bottom: 10 } },
scales: {
x: {
type: 'linear', min: 0, max: 500,
title: { display: true, text: '파일 수 (Files)', font: { size: 11, weight: '700' } },
grid: { display: false },
ticks: { stepSize: 125, callback: (v) => v >= 500 ? '500+' : v }
},
y: {
min: 0, max: 100,
title: { display: true, text: '운영 활력 (AVI %)', font: { size: 11, weight: '700' } },
grid: { display: false }
}
},
plugins: {
legend: { display: false },
datalabels: {
backgroundColor: 'rgba(255, 255, 255, 0.8)',
borderRadius: 4, padding: 4,
align: (ctx) => (ctx.raw && ctx.raw.y > 80 ? 'bottom' : 'top'),
offset: (ctx) => (ctx.raw ? ctx.raw.radius : 5) + 2,
font: { size: 10, weight: '800' },
color: '#1e293b',
formatter: (v) => v ? v.label : '',
display: (ctx) => ctx.raw && ctx.raw.isVip,
clip: false
},
tooltip: {
callbacks: {
label: (ctx) => ` [${ctx.raw.label}] 활력(AVI): ${ctx.raw.y.toFixed(1)}% | 가치 기여(VCI): ${ctx.raw.vci.toFixed(1)}`
}
}
}
},
plugins: [{
id: 'quadrants',
beforeDraw: (chart) => {
const { ctx, chartArea: { left, top, right, bottom }, scales: { x, y } } = chart;
const midX = x.getPixelForValue(250);
const midY = y.getPixelForValue(50);
ctx.save();
ctx.fillStyle = 'rgba(34, 197, 94, 0.03)'; ctx.fillRect(left, top, midX - left, midY - top);
ctx.fillStyle = 'rgba(30, 81, 73, 0.03)'; ctx.fillRect(midX, top, right - midX, midY - top);
ctx.fillStyle = 'rgba(148, 163, 184, 0.03)'; ctx.fillRect(left, midY, midX - left, bottom - midY);
ctx.fillStyle = 'rgba(239, 68, 68, 0.05)'; ctx.fillRect(midX, midY, right - midX, bottom - midY);
ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(0,0,0,0.1)'; ctx.beginPath();
ctx.moveTo(midX, top); ctx.lineTo(midX, bottom); ctx.moveTo(left, midY); ctx.lineTo(right, midY); ctx.stroke();
ctx.font = 'bold 12px Pretendard'; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.fillText('활력 양호', (left + midX) / 2, (top + midY) / 2);
ctx.fillText('핵심 가치', (midX + right) / 2, (top + midY) / 2);
ctx.fillText('정체/소규모', (left + midX) / 2, (midY + bottom) / 2);
ctx.fillStyle = 'rgba(239, 68, 68, 0.4)'; ctx.fillText('자산 손실 위험', (midX + right) / 2, (midY + bottom) / 2);
ctx.restore();
}
}]
});
} catch (err) { console.error("전략 매트릭스 에러:", err); }
}
function renderVitalityLeaderboard(data) {
const container = document.getElementById('p-war-table-container');
if (!container) return;
const sortedData = [...data].sort((a, b) => a.p_war - b.p_war);
container.innerHTML = `
<div class="table-scroll-wrapper">
<table class="data-table p-war-table">
<thead>
<tr>
<th style="width: 250px;">프로젝트명</th>
<th>파일 수</th>
<th>정체 일수</th>
<th>상태 판정</th>
<th style="text-align:right;">가치 기여 (VCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('vci')">?</button></th>
<th>활력 지수 (AVI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('avi')">?</button></th>
<th style="text-align:center;">업무 집중도 <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('focus')">?</button></th>
<th>상태 예보 (14d) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('ai')">?</button></th>
</tr>
</thead>
<tbody>
${sortedData.map((p, idx) => {
const status = getStatusInfo(p.p_war, p.is_auto_delete);
const rowId = `project-${idx}`;
const vci = p.risk_count || 0;
const avi = p.p_war || 0;
const grade = getVciGrade(vci);
return `
<tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}" onclick="toggleProjectDetail('${rowId}')">
<td class="font-bold">${p.project_nm}</td>
<td>${p.file_count.toLocaleString()}개</td>
<td>${p.days_stagnant}일</td>
<td><span class="${status.class}">${status.label === '사망' ? '중단' : status.label}</span></td>
<td style="text-align:right; font-weight:800; color:${vci >= 0 ? '#059669' : '#dc2626'};">
${vci > 0 ? '+' : ''}${vci.toFixed(1)}
</td>
<td class="p-war-value ${avi >= 70 ? 'text-plus' : 'text-minus'}">${avi.toFixed(1)}%</td>
<td style="text-align:center;">
<div style="display:flex; flex-direction:column; align-items:center; gap:4px;">
<span style="font-weight:800; font-size:12px; color:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span>
<div style="width:40px; height:4px; background:#f1f5f9; border-radius:2px; overflow:hidden;">
<div style="width:${p.work_effort}%; height:100%; background:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div>
</div>
</div>
</td>
<td style="text-align:center; font-weight:700; color:#6366f1;">${p.predicted_soi !== null ? p.predicted_soi.toFixed(1) + '%' : '-'}</td>
</tr>
<tr id="detail-${rowId}" class="detail-row">
<td colspan="8">
<div class="detail-container">
<div class="formula-explanation-card">
<div class="formula-header">⚙️ AI 자산 건전성 분석 시뮬레이션 (AAS Metrics)</div>
<div style="display: flex; gap: 20px; margin-bottom: 20px;">
<div class="work-effort-section" style="flex: 1; margin-bottom: 0;">
<div class="work-effort-header">
<span style="font-size: 13px; font-weight: 800; color: #1e5149;">📊 실질 업무 집중도 Analysis</span>
<span style="font-size: 14px; font-weight: 800; color: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span>
</div>
<div class="work-effort-bar-bg"><div style="width: ${p.work_effort}%; height: 100%; background: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div></div>
<div style="font-size: 11px; color: #64748b; line-height: 1.5;">최근 수집 로그 중 실질적 <b>자산 증분</b>이 포착된 밀도입니다.</div>
</div>
<div style="flex: 1; background: #f8fafc; border-radius: 8px; padding: 16px; border: 1px solid #e2e8f0; display: flex; align-items: center; gap: 15px;">
<div style="text-align: center;">
<div style="font-size: 10px; color: #64748b; font-weight: 700; margin-bottom: 2px;">VCI GRADE</div>
<div class="grade-badge ${grade.class}" style="padding: 4px 12px; border-radius: 6px; font-weight: 900; font-size: 14px; display: inline-block;">${grade.label}</div>
</div>
<div style="font-size: 12px; color: #475569; line-height: 1.4; font-weight: 600;">${grade.desc}</div>
</div>
</div>
<div class="formula-steps-grid">
<div class="formula-step">
<div class="step-num">1</div>
<div class="step-content">
<div class="step-title">동적 감쇄 계수(λ) 산출</div>
<div class="step-desc" style="font-size:11px; color:#64748b; margin-bottom:5px;">자산 규모 및 조직 위험을 합산하여 개별 활력 곡선을 생성합니다.</div>
<div class="math-logic">λ = <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">2</div>
<div class="step-content">
<div class="step-title">활동 진정성 검증</div>
<div class="math-logic">Factor = <span class="highlight-val">${(p.log_quality * 100).toFixed(0)}%</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">3</div>
<div class="step-content">
<div class="step-title">가동 보존율 (AVI)</div>
<div class="math-logic">Result = <span class="highlight-val">${avi.toFixed(1)}%</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">4</div>
<div class="step-content">
<div class="step-title">가치 기여 영향력 (VCI)</div>
<div class="math-logic">VCI = <span class="highlight-val">${vci.toFixed(1)}</span></div>
</div>
</div>
</div>
</div>
</div>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>`;
}
function toggleProjectDetail(rowId) {
const container = document.querySelector('.table-scroll-wrapper');
const mainRow = document.querySelector(`tr[onclick*="toggleProjectDetail('${rowId}')"]`);
const detailRow = document.getElementById(`detail-${rowId}`);
if (detailRow && container) {
if (!detailRow.classList.contains('active')) {
document.querySelectorAll('.detail-row').forEach(row => row.classList.remove('active'));
detailRow.classList.add('active');
setTimeout(() => {
const headerH = container.querySelector('thead').offsetHeight || 45;
const targetScrollTop = mainRow.offsetTop - headerH;
container.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
}, 100);
} else {
detailRow.classList.remove('active');
}
}
}
function openProjectListModal(label, projects) {
const modal = document.getElementById('analysisModal');
const title = document.getElementById('modalTitle');
const body = document.getElementById('modalBody');
title.innerText = `[${label}] 프로젝트 리스트 (${projects.length}건)`;
body.innerHTML = projects.length === 0 ? '<p style="text-align:center; padding: 40px; color: #888;">대상 프로젝트 없음</p>' : `
<div class="table-scroll-wrapper" style="max-height: 400px;">
<table class="data-table">
<thead><tr><th>프로젝트명</th><th>부서</th><th>관리자</th><th>정체일</th><th>활력(AVI)</th></tr></thead>
<tbody>${projects.map(p => `<tr><td class="font-bold">${p.project_nm}</td><td>${p.dept || '-'}</td><td>${p.master || '-'}</td><td>${p.days_stagnant}일</td><td style="font-weight:700; color:#1e5149;">${p.p_war.toFixed(1)}%</td></tr>`).join('')}</tbody>
</table>
</div>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
modal.style.display = 'flex';
}
function openAnalysisModal(type) {
const modal = document.getElementById('analysisModal');
const title = document.getElementById('modalTitle');
const body = document.getElementById('modalBody');
if (type === 'avi') {
title.innerText = '운영 활력 지수 (AVI) 등급 가이드';
body.innerHTML = `
<div class="formula-box" style="margin-bottom:15px;">AVI = exp(-λ × days) × Quality × 100</div>
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">자산의 가동 상태와 생존율을 나타내는 지표입니다.</p>
<table class="data-table" style="font-size:12px;">
<thead><tr style="background:#f8fafc;"><th>지수 (AVI)</th><th>등급</th><th>운영 상태</th></tr></thead>
<tbody>
<tr><td>90%↑</td><td style="font-weight:900; color:#059669;">Live</td><td>실시간 성과물이 도출되는 최상급 가동</td></tr>
<tr><td>70~90%</td><td style="font-weight:900; color:#1e5149;">Stable</td><td>주기적 업데이트가 이뤄지는 표준 안정</td></tr>
<tr><td>30~70%</td><td style="font-weight:900; color:#f59e0b;">Idle</td><td>관리가 필요한 유휴/정체 상태</td></tr>
<tr><td>10~30%</td><td style="font-weight:900; color:#dc2626;">Risk</td><td>자산 가치 소멸 직전의 위험 상태</td></tr>
<tr><td>10%↓</td><td style="font-weight:900; color:#64748b;">Frozen</td><td>운영이 중단된 동결/방치 상태</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
} else if (type === 'vci') {
title.innerText = '자산 가치 기여도 (VCI) 등급 가이드';
body.innerHTML = `
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">운영 표준(AVI 70%) 대비 자산 가치 기여도에 따른 프로젝트 위상 분류입니다.</p>
<table class="data-table" style="font-size:12px;">
<thead><tr style="background:#f8fafc;"><th>점수 (VCI)</th><th>등급</th><th>운영 의미</th></tr></thead>
<tbody>
<tr><td>+10.0↑</td><td style="font-weight:900; color:#6366f1;">Masterpiece</td><td>시스템 가치를 견인하는 핵심 자산 (MVP급)</td></tr>
<tr><td>+2.0 ~ +10.0</td><td style="font-weight:900; color:#059669;">Blue Chip</td><td>꾸준한 활력의 우량 자산 (주전급)</td></tr>
<tr><td>-2.0 ~ +2.0</td><td style="font-weight:900; color:#475569;">Steady</td><td>표준 수준의 현상 유지 (보결급)</td></tr>
<tr><td>-10.0 ~ -2.0</td><td style="font-weight:900; color:#f59e0b;">Underperform</td><td>운영 미비로 인한 가치 하락 (마이너급)</td></tr>
<tr><td>-10.0↓</td><td style="font-weight:900; color:#dc2626;">Liability</td><td>가치를 훼손 중인 방치 자산 (방출급)</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
} else if (type === 'focus') {
title.innerText = '업무 집중도 (Job Focus) 등급 가이드';
body.innerHTML = `
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">단순 관리 로그를 제외한 실질적인 산출물 변화의 밀도입니다.</p>
<table class="data-table" style="font-size:12px;">
<thead><tr style="background:#f8fafc;"><th>비율 (%)</th><th>등급</th><th>활동 성격</th></tr></thead>
<tbody>
<tr><td>80%↑</td><td style="font-weight:900; color:#6366f1;">Intensive</td><td>성과물 위주의 고밀도 집중 작업</td></tr>
<tr><td>50~80%</td><td style="font-weight:900; color:#059669;">Active</td><td>성과와 관리가 균형 잡힌 원활한 실행</td></tr>
<tr><td>20~50%</td><td style="font-weight:900; color:#f59e0b;">Maintenance</td><td>설정/행정 등 단순 관리 중심의 작업</td></tr>
<tr><td>20%↓</td><td style="font-weight:900; color:#dc2626;">Surface</td><td>실체적 변화가 적은 형식적 로그 중심</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
} else {
title.innerText = '상태 예보 (AI Forecast) 분석 가이드';
body.innerHTML = `
<div style="background:#eef2ff; padding:15px; border-radius:8px; border-left:4px solid #6366f1; margin-bottom:15px;">
<strong style="color:#3730a3; display:block; margin-bottom:5px;">"2주 뒤의 프로젝트 건강 상태를 예측합니다"</strong>
<p style="font-size:12.5px; color:#3730a3; margin:0;">단순한 현재 점수 나열이 아닌, 최근 활동의 <b>가속도(Acceleration)</b>와 <b>변화 패턴</b>을 AI가 분석하여 미래의 활력 지수(AVI)를 예보합니다.</p>
</div>
<table class="data-table" style="font-size:12px;">
<thead><tr style="background:#f8fafc;"><th>분석 결과</th><th>상태 등급</th><th>관리 가이드라인</th></tr></thead>
<tbody>
<tr><td style="color:#059669;">AVI 상승↑</td><td style="font-weight:900; color:#059669;">성장 가속</td><td>활동 모멘텀이 상승 중인 우수 자산</td></tr>
<tr><td style="color:#475569;">AVI 유지</td><td style="font-weight:900; color:#475569;">안정 유지</td><td>현재의 리듬을 유지하는 표준 운영 상태</td></tr>
<tr><td style="color:#f59e0b;">AVI 하락↓</td><td style="font-weight:900; color:#f59e0b;">활력 저하</td><td>정체 징후 포착, 관리 리소스 투입 검토</td></tr>
<tr><td style="color:#dc2626;">AVI 10%↓</td><td style="font-weight:900; color:#dc2626;">중단 위기</td><td>단기 내 완전 방치 및 가치 소멸 위험</td></tr>
</tbody>
</table>
<div style="margin-top:15px; font-size:11.5px; color:#64748b; line-height:1.6;">
<strong>※ 분석 알고리즘 안내:</strong><br>
파일 수의 실질적 증가가 없는 프로젝트는 '성장 가속' 예보를 받을 수 없도록 설계되어 있으며, 정체가 길어질수록 감쇄 가중치가 자동으로 강화됩니다.
</div>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
}
modal.style.display = 'flex';
}
function closeAnalysisModal() { document.getElementById('analysisModal').style.display = 'none'; }