EVMS 완전 자동화: - 공기 지연 AI 예측 (SPI 기반 준공일 예측) - 기성청구 가능 금액 자동 산출 - 매일 자정 EVMS 스냅샷 자동 생성 (APScheduler) - 매일 07:00 GONGSA 아침 브리핑 자동 생성 준공도서 패키지: - 준공 요약 + 품질시험 목록 + 검측 이력 + 인허가 현황 → ZIP 번들 - 준공 준비 체크리스트 API - 4종 HTML 템플릿 (WeasyPrint PDF 출력) Vision AI Level 3: - 설계 도면 vs 현장 사진 비교 보조 판독 (Claude Vision) - 철근 배근, 거푸집 치수 1차 분석 설계도서 파싱: - PDF 이미지/텍스트에서 공종·수량·규격 자동 추출 - Pandoc HWP 출력 지원 발주처 전용 포털: - 토큰 기반 읽기 전용 API - 공사 현황 대시보드, 공정률 추이 차트 에이전트 협업 고도화: - 협업 시나리오 (concrete_pour, excavation, weekly_report) - GONGSA→PUMJIL→ANJEON 순차 처리 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
189 lines
6.1 KiB
Python
189 lines
6.1 KiB
Python
"""
|
||
EVMS (Earned Value Management System) — Phase 3 완전 자동화
|
||
PV, EV, AC, SPI, CPI 산출 + 공정 지연 예측 AI + 기성청구 자동 알림
|
||
"""
|
||
from datetime import date, timedelta
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.models.task import Task
|
||
from app.models.project import Project
|
||
|
||
|
||
async def predict_delay(
|
||
db: AsyncSession,
|
||
project_id,
|
||
spi: float,
|
||
planned_end: date | None,
|
||
snapshot_date: date,
|
||
) -> dict:
|
||
"""
|
||
SPI 기반 공기 지연 예측.
|
||
spi < 1 이면 지연, 남은 기간을 SPI로 나눠 예상 준공일 계산.
|
||
"""
|
||
if not planned_end or spi is None or spi <= 0:
|
||
return {"delay_days": None, "predicted_end": None, "status": "예측 불가"}
|
||
|
||
remaining_days = (planned_end - snapshot_date).days
|
||
if remaining_days <= 0:
|
||
return {"delay_days": 0, "predicted_end": str(planned_end), "status": "준공 예정일 경과"}
|
||
|
||
predicted_remaining = remaining_days / spi
|
||
predicted_end = snapshot_date + timedelta(days=int(predicted_remaining))
|
||
delay_days = (predicted_end - planned_end).days
|
||
|
||
if delay_days > 0:
|
||
status = f"{delay_days}일 지연 예상"
|
||
elif delay_days < -3:
|
||
status = f"{abs(delay_days)}일 조기 준공 예상"
|
||
else:
|
||
status = "정상 진행"
|
||
|
||
return {
|
||
"delay_days": delay_days,
|
||
"predicted_end": str(predicted_end),
|
||
"status": status,
|
||
}
|
||
|
||
|
||
async def compute_progress_claim(
|
||
total_budget: float,
|
||
actual_progress: float,
|
||
already_claimed_pct: float = 0.0,
|
||
) -> dict:
|
||
"""
|
||
기성청구 가능 금액 산출.
|
||
기성청구 가능 금액 = 총예산 × (실제 공정률 - 기청구 공정률)
|
||
"""
|
||
claimable_pct = max(0.0, actual_progress - already_claimed_pct)
|
||
claimable_amount = total_budget * (claimable_pct / 100)
|
||
return {
|
||
"actual_progress": actual_progress,
|
||
"already_claimed_pct": already_claimed_pct,
|
||
"claimable_pct": round(claimable_pct, 1),
|
||
"claimable_amount": round(claimable_amount, 0),
|
||
"claimable_amount_formatted": f"{claimable_amount:,.0f}원",
|
||
}
|
||
|
||
|
||
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,
|
||
}
|