Files
conai/backend/app/services/evms_service.py
sinmb79 48f1027f08 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>
2026-03-24 21:49:44 +09:00

133 lines
4.2 KiB
Python
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.
"""
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,
}