""" EVMS (Earned Value Management System) 계산 서비스 PV, EV, AC, SPI, CPI 산출 """ from datetime import date from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.task import Task from app.models.project import Project def _clamp(v: float, lo: float, hi: float) -> float: return max(lo, min(hi, v)) async def compute_evms( db: AsyncSession, project_id, snapshot_date: date, actual_cost: float | None = None, ) -> dict: """ WBS/Task 기반 EVMS 지표 계산. PV = 총예산 × 기준일 계획 공정률 EV = 총예산 × 기준일 실제 공정률 AC = 실제 투입 비용 (입력값 없으면 EV × 0.95 추정) 반환: {pv, ev, ac, spi, cpi, eac, etc, planned_progress, actual_progress, detail} """ import uuid pid = uuid.UUID(str(project_id)) today = snapshot_date # 프로젝트 정보 proj_r = await db.execute(select(Project).where(Project.id == pid)) project = proj_r.scalar_one_or_none() if not project: raise ValueError("프로젝트를 찾을 수 없습니다") total_budget = float(project.contract_amount or 0) # 태스크 조회 tasks_r = await db.execute(select(Task).where(Task.project_id == pid)) tasks = tasks_r.scalars().all() if not tasks: return { "total_budget": total_budget, "planned_progress": 0.0, "actual_progress": 0.0, "pv": 0.0, "ev": 0.0, "ac": 0.0, "spi": None, "cpi": None, "eac": None, "etc": None, "detail": [], } # 태스크별 PV, EV 계산 total_tasks = len(tasks) planned_pct_sum = 0.0 actual_pct_sum = 0.0 detail = [] for task in tasks: # 계획 공정률: 기준일 기준 planned_start ~ planned_end 선형 보간 p_start = task.planned_start p_end = task.planned_end task_planned_pct = 0.0 if p_start and p_end: ps = p_start if isinstance(p_start, date) else date.fromisoformat(str(p_start)) pe = p_end if isinstance(p_end, date) else date.fromisoformat(str(p_end)) if today < ps: task_planned_pct = 0.0 elif today >= pe: task_planned_pct = 100.0 else: total_days = (pe - ps).days or 1 elapsed = (today - ps).days task_planned_pct = _clamp(elapsed / total_days * 100, 0.0, 100.0) else: task_planned_pct = 0.0 task_actual_pct = float(task.progress_pct or 0.0) planned_pct_sum += task_planned_pct actual_pct_sum += task_actual_pct detail.append({ "task_id": str(task.id), "task_name": task.name, "planned_pct": round(task_planned_pct, 1), "actual_pct": round(task_actual_pct, 1), "is_critical": task.is_critical, }) planned_progress = round(planned_pct_sum / total_tasks, 1) actual_progress = round(actual_pct_sum / total_tasks, 1) pv = total_budget * (planned_progress / 100) if total_budget else None ev = total_budget * (actual_progress / 100) if total_budget else None # AC: 입력값 없으면 EV × 1.05 (5% 비용 초과 가정) if actual_cost is not None: ac = float(actual_cost) elif ev is not None: ac = ev * 1.05 else: ac = None spi = round(ev / pv, 3) if (pv and pv > 0 and ev is not None) else None cpi = round(ev / ac, 3) if (ac and ac > 0 and ev is not None) else None # EAC (Estimate at Completion) = 총예산 / CPI eac = round(total_budget / cpi, 0) if (cpi and cpi > 0 and total_budget) else None # ETC (Estimate to Completion) = EAC - AC etc = round(eac - ac, 0) if (eac is not None and ac is not None) else None return { "total_budget": total_budget, "planned_progress": planned_progress, "actual_progress": actual_progress, "pv": round(pv, 0) if pv is not None else None, "ev": round(ev, 0) if ev is not None else None, "ac": round(ac, 0) if ac is not None else None, "spi": spi, "cpi": cpi, "eac": eac, "etc": etc, "detail": detail, }