AI 에이전트 (Layer 2): - GONGSA: 공사 담당 (공정 브리핑, 공기 지연 감지, 날씨 연동 작업 조정) - PUMJIL: 품질 담당 (시공 전 체크리스트, Vision 보조 판독, 시험 기한 추적) - ANJEON: 안전 담당 (위험 공정 경보, TBM 생성, 중대재해처벌법 Q&A) - GUMU: 공무 담당 (인허가 능동 추적, 기성청구 제안, 보고서 초안) - 에이전트 라우터 (키워드 기반 자동 분배), 아침 브리핑 엔드포인트 EVMS 기본: - PV·EV·AC·SPI·CPI 산출 (WBS/Task 기반) - EAC·ETC 예측, 스냅샷 이력 저장 Vision AI: - Level 1: 현장 사진 분류 (Claude Vision), 작업일보 자동 첨부 - Level 2: 안전장비(안전모/조끼) 착용 감지 Geofence 위험구역: - 구역 CRUD (굴착면, 크레인 반경, 밀폐공간 등) - 진입 이벤트 웹훅 (익명 — 개인 이동 경로 비수집) 인허가 자동도출: - 공종 입력 → AI가 필요 인허가 목록 자동 도출 + 체크리스트 생성 DB 마이그레이션 (002): - agent_conversations, agent_messages, evms_snapshots, geofence_zones Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
133 lines
4.2 KiB
Python
133 lines
4.2 KiB
Python
"""
|
||
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,
|
||
}
|