- 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>
369 lines
14 KiB
Python
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
|