zioinfo-mail/workspace/guardia-itsm/routers/si_wbs.py
DESKTOP-TKLFCPR\ython cfe2901a55 refactor(structure): consolidate all projects under workspace/
- itsm/    -> workspace/guardia-itsm/
- manager/ -> workspace/guardia-manager/
- app/     -> workspace/guardia-messenger/
- manual/  -> workspace/guardia-docs/

workspace/zioinfo-web/ unchanged.
git mv preserves full commit history.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:50:56 +09:00

369 lines
14 KiB
Python

"""
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