""" WBS (Work Breakdown Structure) 관리 API. 엔드포인트: GET /api/si/projects/{pid}/wbs — WBS 트리 조회 POST /api/si/projects/{pid}/wbs — WBS 항목 생성 GET /api/si/projects/{pid}/wbs/{id} — 단일 항목 조회 PATCH /api/si/projects/{pid}/wbs/{id} — 항목 수정 DELETE /api/si/projects/{pid}/wbs/{id} — 항목 삭제 PATCH /api/si/projects/{pid}/wbs/{id}/progress — 리프 진척률 업데이트 (부모 자동 계산) POST /api/si/projects/{pid}/wbs/bulk — RFP 요구사항→WBS 일괄 자동 생성 GET /api/si/projects/{pid}/wbs/gantt — 간트 차트용 데이터 WBS 계층: 레벨 1 = 단계 (분석/설계/구현/인도) 레벨 2 = 활동 (PhaseActivity) 레벨 3 = 태스크 레벨 4 = 서브태스크 (리프 노드, 진척 직접 입력) """ from __future__ import annotations import logging from datetime import date from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from database import get_db from models import ( SiProject, WbsItem, WbsItemCreate, WbsItemOut, WbsItemUpdate, WbsStatus, PHASE_DEFAULT_ACTIVITIES, ProjectPhase, PhaseActivity, User, UserRole, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/si/projects", tags=["si-wbs"]) # ── WBS 트리 조회 ───────────────────────────────────────────────────────────── @router.get("/{project_id}/wbs") async def get_wbs_tree( project_id: int, flat: bool = Query(False, description="True면 평면 리스트, False면 트리 구조"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): await _assert_project(project_id, db) items = (await db.execute( select(WbsItem).where(WbsItem.project_id == project_id) .order_by(WbsItem.wbs_code) )).scalars().all() if flat: return [WbsItemOut.model_validate(i) for i in items] return _build_tree(items) @router.get("/{project_id}/wbs/gantt") async def get_gantt_data( project_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """간트 차트용 JSON (Frappe Gantt / dhtmlxGantt 호환).""" await _assert_project(project_id, db) items = (await db.execute( select(WbsItem).where(WbsItem.project_id == project_id) .order_by(WbsItem.wbs_code) )).scalars().all() tasks = [] for item in items: tasks.append({ "id": str(item.id), "name": f"{item.wbs_code} {item.title}", "start": item.planned_start.isoformat() if item.planned_start else None, "end": item.planned_end.isoformat() if item.planned_end else None, "actual_start": item.actual_start.isoformat() if item.actual_start else None, "actual_end": item.actual_end.isoformat() if item.actual_end else None, "progress": item.completion_pct, "status": item.status, "assignee": item.assignee, "dependencies": str(item.parent_id) if item.parent_id else "", "is_leaf": item.is_leaf, }) return {"project_id": project_id, "tasks": tasks} # ── 단일 항목 ───────────────────────────────────────────────────────────────── @router.get("/{project_id}/wbs/{item_id}", response_model=WbsItemOut) async def get_wbs_item( project_id: int, item_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): return await _get_item_or_404(project_id, item_id, db) # ── 생성 ────────────────────────────────────────────────────────────────────── @router.post("/{project_id}/wbs", response_model=WbsItemOut, status_code=201) async def create_wbs_item( project_id: int, body: WbsItemCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER): raise HTTPException(403, "엔지니어 이상 권한 필요") await _assert_project(project_id, db) level = 1 if body.parent_id: parent = await _get_item_or_404(project_id, body.parent_id, db) level = parent.level + 1 if level > 4: raise HTTPException(400, "WBS 최대 깊이(4레벨)를 초과합니다") # 부모 노드를 리프가 아닌 것으로 변경 parent.is_leaf = False item = WbsItem( project_id = project_id, parent_id = body.parent_id, wbs_code = body.wbs_code, title = body.title, phase = body.phase, activity = body.activity, assignee = body.assignee, planned_start = body.planned_start, planned_end = body.planned_end, weight = body.weight, deliverable = body.deliverable, note = body.note, level = level, is_leaf = True, ) db.add(item) await db.commit() await db.refresh(item) return item # ── 수정 ────────────────────────────────────────────────────────────────────── @router.patch("/{project_id}/wbs/{item_id}", response_model=WbsItemOut) async def update_wbs_item( project_id: int, item_id: int, body: WbsItemUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER): raise HTTPException(403, "엔지니어 이상 권한 필요") item = await _get_item_or_404(project_id, item_id, db) data = body.model_dump(exclude_none=True) # 리프가 아닌 노드는 completion_pct 직접 수정 불가 if "completion_pct" in data and not item.is_leaf: raise HTTPException(400, "리프 노드만 진척률을 직접 수정할 수 있습니다") for field, val in data.items(): setattr(item, field, val) await db.commit() await db.refresh(item) return item # ── 진척률 업데이트 (부모 자동 재계산) ────────────────────────────────────── @router.patch("/{project_id}/wbs/{item_id}/progress") async def update_progress( project_id: int, item_id: int, pct: int = Query(..., ge=0, le=100, description="완료율 0-100"), actual_start: Optional[date] = Query(None), actual_end: Optional[date] = Query(None), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """리프 노드 진척 입력 → 상위 노드 가중 평균 자동 계산.""" item = await _get_item_or_404(project_id, item_id, db) if not item.is_leaf: raise HTTPException(400, "리프 노드만 진척률을 직접 입력할 수 있습니다") item.completion_pct = pct if actual_start: item.actual_start = actual_start if actual_end: item.actual_end = actual_end # 자동 상태 설정 if pct == 0: item.status = WbsStatus.NOT_STARTED elif pct == 100: item.status = WbsStatus.COMPLETED else: from datetime import date as dt_date if item.planned_end and item.planned_end < dt_date.today() and pct < 100: item.status = WbsStatus.DELAYED else: item.status = WbsStatus.IN_PROGRESS await db.flush() # 부모 체인 재계산 await _recalc_ancestors(item, db) await db.commit() await db.refresh(item) return {"id": item.id, "completion_pct": item.completion_pct, "status": item.status} # ── 삭제 ────────────────────────────────────────────────────────────────────── @router.delete("/{project_id}/wbs/{item_id}", status_code=204) async def delete_wbs_item( project_id: int, item_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): if current_user.role not in (UserRole.ADMIN, UserRole.PM): raise HTTPException(403, "PM 이상 권한 필요") item = await _get_item_or_404(project_id, item_id, db) await db.delete(item) await db.commit() # ── RFP 요구사항 → WBS 일괄 자동 생성 ────────────────────────────────────── @router.post("/{project_id}/wbs/bulk", status_code=201) async def bulk_generate_wbs( project_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """표준 방법론 4단계 기반 WBS 기본 골격 자동 생성.""" if current_user.role not in (UserRole.ADMIN, UserRole.PM): raise HTTPException(403, "PM 이상 권한 필요") await _assert_project(project_id, db) # 이미 WBS가 있으면 중복 생성 방지 existing = (await db.execute( select(WbsItem).where(WbsItem.project_id == project_id).limit(1) )).scalars().first() if existing: raise HTTPException(400, "이미 WBS 항목이 존재합니다. 삭제 후 재생성하세요.") created = [] phase_order = [ (ProjectPhase.ANALYSIS, "1", "분석"), (ProjectPhase.DESIGN, "2", "설계"), (ProjectPhase.IMPLEMENTATION, "3", "구현"), (ProjectPhase.DEPLOYMENT, "4", "인도"), ] for phase_val, phase_num, phase_label in phase_order: # 레벨 1: 단계 노드 phase_node = WbsItem( project_id = project_id, wbs_code = phase_num, title = phase_label, phase = phase_val, level = 1, is_leaf = False, ) db.add(phase_node) await db.flush() activities = PHASE_DEFAULT_ACTIVITIES.get(phase_val, []) for idx, (activity_val, activity_name) in enumerate(activities, start=1): # 레벨 2: 활동 노드 act_node = WbsItem( project_id = project_id, parent_id = phase_node.id, wbs_code = f"{phase_num}.{idx}", title = activity_name, phase = phase_val, activity = activity_val, level = 2, is_leaf = True, # 기본은 리프 — 하위 태스크 추가 시 False로 변경 ) db.add(act_node) created.append(f"{phase_num}.{idx} {activity_name}") await db.commit() logger.info("WBS 자동 생성: project_id=%d count=%d", project_id, len(created)) return {"message": "WBS 기본 골격 생성 완료", "created": len(created), "items": created} # ── 내부 헬퍼 ───────────────────────────────────────────────────────────────── async def _assert_project(project_id: int, db: AsyncSession) -> None: proj = (await db.execute( select(SiProject).where(SiProject.id == project_id) )).scalars().first() if not proj: raise HTTPException(404, f"SI 프로젝트 {project_id}를 찾을 수 없습니다") async def _get_item_or_404(project_id: int, item_id: int, db: AsyncSession) -> WbsItem: item = (await db.execute( select(WbsItem).where(WbsItem.id == item_id, WbsItem.project_id == project_id) )).scalars().first() if not item: raise HTTPException(404, "WBS 항목을 찾을 수 없습니다") return item async def _recalc_ancestors(item: WbsItem, db: AsyncSession) -> None: """리프부터 루트까지 가중 평균 진척률 재계산.""" current_id = item.parent_id while current_id: parent = (await db.execute( select(WbsItem).where(WbsItem.id == current_id) )).scalars().first() if not parent: break siblings = (await db.execute( select(WbsItem).where(WbsItem.parent_id == parent.id) )).scalars().all() if siblings: total_weight = sum(s.weight for s in siblings) weighted_pct = sum(s.completion_pct * s.weight for s in siblings) parent.completion_pct = weighted_pct // total_weight if total_weight else 0 # 부모 상태 자동 갱신 from datetime import date as dt_date all_done = all(s.completion_pct == 100 for s in siblings) any_started = any(s.completion_pct > 0 for s in siblings) if all_done: parent.status = WbsStatus.COMPLETED elif any_started: if parent.planned_end and parent.planned_end < dt_date.today(): parent.status = WbsStatus.DELAYED else: parent.status = WbsStatus.IN_PROGRESS else: parent.status = WbsStatus.NOT_STARTED current_id = parent.parent_id def _build_tree(items: List[WbsItem]) -> List[Dict[str, Any]]: """리스트 → 계층 트리 변환.""" id_map: Dict[int, Dict] = {} for item in items: node = WbsItemOut.model_validate(item).model_dump() node["children"] = [] id_map[item.id] = node roots = [] for item in items: node = id_map[item.id] if item.parent_id and item.parent_id in id_map: id_map[item.parent_id]["children"].append(node) else: roots.append(node) return roots