feat: Phase 2 구현 — AI 에이전트 4인방, EVMS, Vision AI, Geofence
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>
This commit is contained in:
132
backend/app/services/evms_service.py
Normal file
132
backend/app/services/evms_service.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user