Files
conai/backend/app/services/evms_service.py
sinmb79 5a044a3882 feat: Phase 3 구현 — 완전 자동화, 준공도서, Vision L3, 발주처 포털
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>
2026-03-24 22:02:29 +09:00

189 lines
6.1 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) — 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,
}