Files
conai/backend/app/api/portal.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

163 lines
6.2 KiB
Python

"""
발주처 전용 포털 API (읽기 전용)
토큰 기반 인증으로 발주처가 공사 현황을 실시간 확인합니다.
- 로그인 없이 토큰만으로 접근 가능
- 쓰기 권한 없음
- 민감 정보 제외 (계약 단가 등)
"""
import uuid
import secrets
from datetime import date, datetime, timedelta
from fastapi import APIRouter, HTTPException, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select, func
from pydantic import BaseModel
from app.core.database import AsyncSessionLocal
from app.deps import DB
from app.models.project import Project
from app.models.daily_report import DailyReport, ReportStatus
from app.models.quality import QualityTest, QualityResult
from app.models.inspection import InspectionRequest
from app.models.weather import WeatherAlert
from app.models.evms import EVMSSnapshot
from app.config import settings
router = APIRouter(prefix="/portal", tags=["발주처 포털"])
_bearer = HTTPBearer(auto_error=False)
# 간단한 인메모리 토큰 저장소 (운영에서는 DB/Redis로 교체)
_portal_tokens: dict[str, dict] = {}
def _verify_portal_token(credentials: HTTPAuthorizationCredentials = Depends(_bearer)) -> dict:
if not credentials:
raise HTTPException(status_code=401, detail="포털 토큰이 필요합니다")
token = credentials.credentials
info = _portal_tokens.get(token)
if not info:
raise HTTPException(status_code=401, detail="유효하지 않은 포털 토큰입니다")
if datetime.fromisoformat(info["expires_at"]) < datetime.now():
del _portal_tokens[token]
raise HTTPException(status_code=401, detail="포털 토큰이 만료되었습니다")
return info
PortalAuth = Depends(_verify_portal_token)
class TokenCreateRequest(BaseModel):
project_id: uuid.UUID
expires_days: int = 30
label: str = "발주처"
@router.post("/tokens", summary="포털 접근 토큰 발급 (관리자용)")
async def create_portal_token(data: TokenCreateRequest, db: DB):
"""
발주처에게 공유할 읽기 전용 토큰을 발급합니다.
이 엔드포인트는 현장 관리자만 호출해야 합니다 (별도 인증 추가 권장).
"""
r = await db.execute(select(Project).where(Project.id == data.project_id))
project = r.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
token = secrets.token_urlsafe(32)
expires_at = (datetime.now() + timedelta(days=data.expires_days)).isoformat()
_portal_tokens[token] = {
"project_id": str(data.project_id),
"project_name": project.name,
"label": data.label,
"expires_at": expires_at,
}
return {
"token": token,
"project_name": project.name,
"expires_at": expires_at,
"portal_url": f"/portal/dashboard (Authorization: Bearer {token[:8]}...)",
}
@router.get("/dashboard", summary="발주처 공사 현황 대시보드")
async def portal_dashboard(auth: dict = PortalAuth, db: DB = None):
"""발주처용 공사 현황 요약 (읽기 전용)"""
project_id = uuid.UUID(auth["project_id"])
today = date.today()
# 프로젝트 기본 정보
proj_r = await db.execute(select(Project).where(Project.id == project_id))
project = proj_r.scalar_one_or_none()
# 최근 작업일보 (3건)
dr_r = await db.execute(
select(DailyReport)
.where(DailyReport.project_id == project_id, DailyReport.status == ReportStatus.CONFIRMED)
.order_by(DailyReport.report_date.desc())
.limit(3)
)
recent_reports = [
{"date": str(r.report_date), "weather": r.weather_summary, "work": r.work_content[:100] if r.work_content else ""}
for r in dr_r.scalars().all()
]
# 품질시험 합격률
total_qt_r = await db.execute(select(func.count()).where(QualityTest.project_id == project_id))
total_qt = total_qt_r.scalar() or 0
pass_qt_r = await db.execute(select(func.count()).where(QualityTest.project_id == project_id, QualityTest.result == QualityResult.PASS))
pass_qt = pass_qt_r.scalar() or 0
# EVMS 최신
evms_r = await db.execute(
select(EVMSSnapshot).where(EVMSSnapshot.project_id == project_id).order_by(EVMSSnapshot.snapshot_date.desc()).limit(1)
)
evms = evms_r.scalar_one_or_none()
# 활성 날씨 경보
alert_r = await db.execute(
select(WeatherAlert).where(WeatherAlert.project_id == project_id, WeatherAlert.alert_date == today, WeatherAlert.acknowledged == False)
)
active_alerts = [{"type": a.alert_type, "message": a.message} for a in alert_r.scalars().all()]
return {
"project": {
"name": project.name if project else "-",
"start_date": str(project.start_date) if project and project.start_date else None,
"end_date": str(project.end_date) if project and project.end_date else None,
"status": project.status.value if project else "-",
},
"progress": {
"planned": evms.planned_progress if evms else None,
"actual": evms.actual_progress if evms else None,
"spi": evms.spi if evms else None,
"snapshot_date": str(evms.snapshot_date) if evms else None,
},
"quality": {
"total_tests": total_qt,
"pass_rate": round(pass_qt / total_qt * 100, 1) if total_qt else None,
},
"recent_reports": recent_reports,
"active_alerts": active_alerts,
"generated_at": datetime.now().isoformat(),
}
@router.get("/progress-chart", summary="공정률 추이 데이터")
async def portal_progress_chart(auth: dict = PortalAuth, db: DB = None):
"""발주처용 공정률 추이 차트 데이터"""
project_id = uuid.UUID(auth["project_id"])
r = await db.execute(
select(EVMSSnapshot)
.where(EVMSSnapshot.project_id == project_id)
.order_by(EVMSSnapshot.snapshot_date)
.limit(90) # 최근 90일
)
snapshots = r.scalars().all()
return {
"labels": [str(s.snapshot_date) for s in snapshots],
"planned": [s.planned_progress for s in snapshots],
"actual": [s.actual_progress for s in snapshots],
"spi": [s.spi for s in snapshots],
}