diff --git a/analysis_service.py b/analysis_service.py
new file mode 100644
index 0000000..786b5fd
--- /dev/null
+++ b/analysis_service.py
@@ -0,0 +1,168 @@
+import re
+import math
+import statistics
+from datetime import datetime
+from sql_queries import DashboardQueries
+from prediction_service import SOIPredictionService
+
+class AnalysisService:
+ """프로젝트 통계 및 활동성 분석 전문 서비스"""
+
+ @staticmethod
+ def calculate_activity_status(target_date_dt, log, file_count):
+ """개별 프로젝트의 활동 상태 및 방치일 산출"""
+ status, days = "unknown", 999
+ file_val = int(file_count) if file_count else 0
+ has_log = log and log != "데이터 없음" and log != "X"
+
+ if file_val == 0:
+ status = "unknown"
+ elif has_log:
+ if "폴더자동삭제" in log.replace(" ", ""):
+ status = "stale"
+ days = 999
+ else:
+ match = re.search(r'(\d{4})\.(\d{2})\.(\d{2})', log)
+ if match:
+ log_date = datetime.strptime(match.group(0), "%Y.%m.%d")
+ diff = (target_date_dt - log_date).days
+ status = "active" if diff <= 7 else "warning" if diff <= 14 else "stale"
+ days = diff
+ else:
+ status = "stale"
+ else:
+ status = "stale"
+
+ return status, days
+
+ @staticmethod
+ def get_project_activity_logic(cursor, date_str):
+ """활동도 분석 리포트 생성 로직"""
+ if not date_str or date_str == "-":
+ cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE)
+ res = cursor.fetchone()
+ target_date_val = res['last_date'] if res['last_date'] else datetime.now().date()
+ else:
+ target_date_val = datetime.strptime(date_str.replace(".", "-"), "%Y-%m-%d").date()
+
+ target_date_dt = datetime.combine(target_date_val, datetime.min.time())
+ cursor.execute(DashboardQueries.GET_PROJECT_LIST_FOR_ANALYSIS, (target_date_val,))
+ rows = cursor.fetchall()
+
+ analysis = {"summary": {"active": 0, "warning": 0, "stale": 0, "unknown": 0}, "details": []}
+ for r in rows:
+ status, days = AnalysisService.calculate_activity_status(target_date_dt, r['recent_log'], r['file_count'])
+ analysis["summary"][status] += 1
+ analysis["details"].append({"name": r['short_nm'] or r['project_nm'], "status": status, "days_ago": days})
+
+ return analysis
+
+ @staticmethod
+ def get_p_zsr_analysis_logic(cursor):
+ """절대적 방치 실태 고발 및 AI 위험 적응형(AAS) 분석 로직"""
+ cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE)
+ res_date = cursor.fetchone()
+ if not res_date or not res_date['last_date']:
+ return []
+ last_date = res_date['last_date']
+
+ cursor.execute("""
+ SELECT m.project_id, m.project_nm, m.short_nm, m.department, m.master,
+ h.recent_log, h.file_count, m.continent, m.country
+ FROM projects_master m
+ LEFT JOIN projects_history h ON m.project_id = h.project_id AND h.crawl_date = %s
+ ORDER BY m.project_id ASC
+ """, (last_date,))
+ projects = cursor.fetchall()
+
+ if not projects: return []
+
+ # [Step 1] AI 전처리: 부서별 평균 방치일 계산 (조직적 위험도 산출용)
+ dept_stats = {}
+ for p in projects:
+ log = p['recent_log']
+ days = 14 # 기본값
+ if log and log != "데이터 없음":
+ match = re.search(r'(\d{4})\.(\d{2})\.(\d{2})', log)
+ if match:
+ log_date = datetime.strptime(match.group(0), "%Y.%m.%d").date()
+ days = (last_date - log_date).days
+
+ dept = p['department'] or "미분류"
+ if dept not in dept_stats: dept_stats[dept] = []
+ dept_stats[dept].append(days)
+
+ dept_avg_risk = {d: statistics.mean(days_list) for d, days_list in dept_stats.items()}
+
+ # [Step 2] AI 위험 적응형 SOI 산출 (AAS 모델)
+ results = []
+ total_soi = 0
+
+ for p in projects:
+ file_count = int(p['file_count']) if p['file_count'] else 0
+ log = p['recent_log']
+ dept = p['department'] or "미분류"
+
+ # 방치일 계산
+ days_stagnant = 14
+ if log and log != "데이터 없음":
+ match = re.search(r'(\d{4})\.(\d{2})\.(\d{2})', log)
+ if match:
+ log_date = datetime.strptime(match.group(0), "%Y.%m.%d").date()
+ days_stagnant = (last_date - log_date).days
+
+ is_auto_delete = log and "폴더자동삭제" in log.replace(" ", "")
+
+ # AI-Hazard 추론 로직 (Dynamic Lambda)
+ # 1. 자산 규모 리스크 (파일이 많을수록 방치 시 가치 하락 가속)
+ scale_impact = min(0.04, math.log10(file_count + 1) * 0.008) if file_count > 0 else 0
+
+ # 2. 조직적 전염 리스크 (부서 전체가 방치 중이면 패널티 부여)
+ dept_risk_days = dept_avg_risk.get(dept, 14)
+ env_impact = min(0.03, (dept_risk_days / 30) * 0.01)
+
+ # 최종 AI 위험 계수 산출 (기본 0.04에서 변동)
+ ai_lambda = 0.04 + scale_impact + env_impact
+
+ # 지수 감쇄 적용 (AAS Score)
+ soi_score = math.exp(-ai_lambda * days_stagnant) * 100
+
+ # [AI 데이터 진정성 검증 로직 - ECV 패널티 추가]
+ # 파일이 하나도 없거나(유령), 현저히 적은 경우(껍데기) 활동의 진정성을 불신함
+ existence_confidence = 1.0
+ if file_count == 0:
+ existence_confidence = 0.05 # 파일 0개는 로그가 있어도 최대 5% 미만으로 강제
+ elif file_count < 10:
+ existence_confidence = 0.4 # 파일 10개 미만은 활동 신뢰도 40%로 제한
+
+ soi_score = soi_score * existence_confidence
+
+ if is_auto_delete:
+ soi_score = 0.1
+
+ # [AI 미래 예측 연동]
+ history = SOIPredictionService.get_historical_soi(cursor, p['project_id'])
+ predicted_soi = SOIPredictionService.predict_future_soi(history, days_ahead=14)
+
+ total_soi += soi_score
+
+ results.append({
+ "project_nm": p['short_nm'] or p['project_nm'],
+ "file_count": file_count,
+ "days_stagnant": days_stagnant,
+ "risk_count": 0,
+ "p_war": round(soi_score, 1),
+ "predicted_soi": predicted_soi,
+ "is_auto_delete": is_auto_delete,
+ "master": p['master'],
+ "dept": p['department'],
+ "ai_lambda": round(ai_lambda, 4), # 디버깅용 계수 포함
+ "avg_info": {
+ "avg_files": 0,
+ "avg_stagnant": 0,
+ "avg_risk": round(total_soi / len(projects), 1)
+ }
+ })
+
+ results.sort(key=lambda x: x['p_war'])
+ return results
diff --git a/inquiry_service.py b/inquiry_service.py
new file mode 100644
index 0000000..262af3d
--- /dev/null
+++ b/inquiry_service.py
@@ -0,0 +1,42 @@
+from datetime import datetime
+from sql_queries import InquiryQueries
+
+class InquiryService:
+ @staticmethod
+ def get_inquiries_logic(cursor, pm_type=None, category=None, status=None, keyword=None):
+ sql = InquiryQueries.SELECT_BASE
+ params = []
+ if pm_type:
+ sql += " AND pm_type = %s"
+ params.append(pm_type)
+ if category:
+ sql += " AND category = %s"
+ params.append(category)
+ if status:
+ sql += " AND status = %s"
+ params.append(status)
+ if keyword:
+ sql += " AND (content LIKE %s OR author LIKE %s OR project_nm LIKE %s)"
+ params.extend([f"%{keyword}%", f"%{keyword}%", f"%{keyword}%"])
+
+ sql += f" {InquiryQueries.ORDER_BY_DESC}"
+ cursor.execute(sql, params)
+ return cursor.fetchall()
+
+ @staticmethod
+ def get_inquiry_detail_logic(cursor, inquiry_id):
+ cursor.execute(InquiryQueries.SELECT_BY_ID, (inquiry_id,))
+ return cursor.fetchone()
+
+ @staticmethod
+ def update_inquiry_reply_logic(cursor, conn, inquiry_id, req):
+ handled_date = datetime.now().strftime("%Y.%m.%d")
+ cursor.execute(InquiryQueries.UPDATE_REPLY, (req.reply, req.status, req.handler, handled_date, inquiry_id))
+ conn.commit()
+ return {"success": True}
+
+ @staticmethod
+ def delete_inquiry_reply_logic(cursor, conn, inquiry_id):
+ cursor.execute(InquiryQueries.DELETE_REPLY, (inquiry_id,))
+ conn.commit()
+ return {"success": True}
diff --git a/js/analysis.js b/js/analysis.js
index 16940aa..e195d06 100644
--- a/js/analysis.js
+++ b/js/analysis.js
@@ -15,15 +15,15 @@ async function loadPWarData() {
if (data.error) throw new Error(data.error);
- updateSummaryMetrics(data);
+ // 업데이트 로직: 리더보드 및 차트 렌더링
renderPWarLeaderboard(data);
- renderRiskSignals(data);
+ renderSOICharts(data);
- // 시스템 평균 정보 표시
+ // 시스템 정보 표시
if (data.length > 0 && data[0].avg_info) {
const avg = data[0].avg_info;
document.getElementById('avg-system-info').textContent =
- `* 0.0 = 시스템 평균 (파일 ${avg.avg_files.toLocaleString()}개 / 방치 ${avg.avg_stagnant}일 / 리스크 ${avg.avg_risk}건)`;
+ `* 시스템 종합 건강도: ${avg.avg_risk}% (0.0%에 가까울수록 시스템 전반의 방치가 심각함)`;
}
} catch (e) {
@@ -31,65 +31,288 @@ async function loadPWarData() {
}
}
-function updateSummaryMetrics(data) {
- // 1. 평균 P-WAR 산출
- const avgPWar = data.reduce((acc, cur) => acc + cur.p_war, 0) / data.length;
- document.querySelector('.metric-card.sra .value').textContent = avgPWar.toFixed(2);
-
- // 2. 고위험 좀비 프로젝트 비율 (P-WAR < -1.0 기준)
- const zombieCount = data.filter(p => p.p_war < -1.0).length;
- const zombieRate = (zombieCount / data.length) * 100;
- document.querySelector('.metric-card.stability .value').textContent = `${zombieRate.toFixed(1)}%`;
-
- // 3. 총 활성 리소스 규모
- const totalActiveFiles = data.filter(p => p.p_war > 0).reduce((acc, cur) => acc + cur.file_count, 0);
- document.querySelector('.metric-card.piso .value').textContent = (totalActiveFiles / 1000).toFixed(1) + "k";
-
- // 4. 방치 리스크 총합
- const totalRisks = data.reduce((acc, cur) => acc + cur.risk_count, 0);
- document.querySelector('.metric-card.iwar .value').textContent = totalRisks;
+// 상태 판정 공통 함수
+function getStatusInfo(soi, isAutoDelete) {
+ if (isAutoDelete || soi < 10) {
+ return { label: '사망', class: 'badge-system', key: 'dead' };
+ } else if (soi < 30) {
+ return { label: '위험', class: 'badge-danger', key: 'danger' };
+ } else if (soi < 70) {
+ return { label: '주의', class: 'badge-warning', key: 'warning' };
+ } else {
+ return { label: '정상', class: 'badge-active', key: 'active' };
+ }
+}
+
+// Chart.js 시각화 엔진
+function renderSOICharts(data) {
+ if (!data || data.length === 0) return;
+
+ // --- 1. 상태 분포 데이터 가공 (Doughnut Chart) ---
+ 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,
+ plugins: {
+ legend: {
+ position: 'right',
+ labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true }
+ },
+ datalabels: { display: false }
+ },
+ cutout: '65%',
+ onClick: (event, elements) => {
+ if (elements.length > 0) {
+ const index = elements[0].index;
+ const keys = ['active', 'warning', 'danger', 'dead'];
+ const labels = ['정상', '주의', '위험', '사망'];
+ openProjectListModal(labels[index], stats[keys[index]]);
+ }
+ }
+ }
+ });
+ } catch (err) { console.error("도넛 차트 생성 실패:", err); }
+
+ // --- 2. 프로젝트 SWOT 매트릭스 진단 (Scatter Chart) ---
+ try {
+ const scatterData = data.map(p => ({
+ x: Math.min(500, p.file_count), // 최대 500으로 조정
+ y: p.p_war,
+ label: p.project_nm
+ }));
+
+ const vitalityCtx = document.getElementById('forecastChart').getContext('2d');
+ if (window.myVitalityChart) window.myVitalityChart.destroy();
+
+ const plugins = [];
+ if (typeof ChartDataLabels !== 'undefined') plugins.push(ChartDataLabels);
+
+ window.myVitalityChart = new Chart(vitalityCtx, {
+ type: 'scatter',
+ plugins: plugins,
+ data: {
+ datasets: [{
+ data: scatterData,
+ backgroundColor: (context) => {
+ const p = context.raw;
+ if (!p) return '#94a3b8';
+ if (p.x >= 250 && p.y >= 50) return '#1E5149'; // 핵심 우량 (기준 250)
+ if (p.x < 250 && p.y >= 50) return '#22c55e'; // 활동 양호
+ if (p.x < 250 && p.y < 50) return '#94a3b8'; // 방치/소규모
+ return '#ef4444'; // 관리 사각지대
+ },
+ pointRadius: 6,
+ hoverRadius: 10
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ layout: { padding: { top: 30, right: 40, left: 10, bottom: 10 } },
+ scales: {
+ x: {
+ type: 'linear',
+ min: 0,
+ max: 500, // 데이터 분포에 최적화
+ title: { display: true, text: '자산 규모 (파일 수)', font: { size: 11, weight: '700' } },
+ grid: { display: false },
+ ticks: { stepSize: 125, callback: (val) => val >= 500 ? '500+' : val.toLocaleString() }
+ },
+ y: {
+ min: 0,
+ max: 100,
+ title: { display: true, text: '활동성 (SOI %)', font: { size: 11, weight: '700' } },
+ grid: { display: false },
+ ticks: { stepSize: 25 }
+ }
+ },
+ plugins: {
+ legend: { display: false },
+ datalabels: {
+ align: 'top',
+ offset: 5,
+ font: { size: 10, weight: '700' },
+ color: '#475569',
+ formatter: (value) => value.label,
+ display: (context) => context.raw.x > 100 || context.raw.y < 30,
+ clip: false
+ },
+ tooltip: {
+ callbacks: {
+ label: (context) => ` [${context.raw.label}] SOI: ${context.raw.y.toFixed(1)}% | 파일: ${context.raw.x >= 500 ? '500+' : context.raw.x}개`
+ }
+ }
+ }
+ },
+ plugins: [{
+ id: 'quadrants',
+ beforeDraw: (chart) => {
+ const { ctx, chartArea: { left, top, right, bottom }, scales: { x, y } } = chart;
+ const midX = x.getPixelForValue(250); // 중앙축을 250으로 변경
+ const midY = y.getPixelForValue(50);
+
+ ctx.save();
+ // 1. 물리적으로 동일한 크기의 배경색 채우기
+ 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); // 하우
+
+ // 2. 명확한 십자 구분선
+ 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();
+
+ // 3. 영역 텍스트
+ ctx.font = 'bold 12px Pretendard'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
+ 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("SWOT 차트 생성 실패:", err); }
+
+
}
function renderPWarLeaderboard(data) {
- const container = document.querySelector('.timeline-analysis .card-body');
+ const container = document.getElementById('p-war-table-container');
+ if (!container) return;
- const sortedData = [...data].sort((a, b) => b.p_war - a.p_war);
+ const sortedData = [...data].sort((a, b) => a.p_war - b.p_war);
container.innerHTML = `