zioinfo-mail/workspace/guardia-itsm/routers/portfolio.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

283 lines
9.8 KiB
Python

"""
포트폴리오 관리 + 리소스/인력 관리 API
포트폴리오:
- 여러 SI 프로젝트 통합 현황 대시보드
- KPI 집계 (전체 진척률 / 총 예산 / 위험 현황)
리소스:
- 인원 배치 (역할·WBS·기간)
- 역량 매핑 (기술스택·경험)
- 인력 투입 현황 (월별 M/M)
엔드포인트:
GET /api/portfolio/dashboard — 전체 프로젝트 포트폴리오
GET /api/portfolio/kpi — 집계 KPI
GET /api/portfolio/projects/{id}/resources — 프로젝트 인원 배치
POST /api/portfolio/projects/{id}/resources — 인원 배치 등록
GET /api/portfolio/resources/availability — 가용 인력 조회
GET /api/portfolio/resources/{user}/skills — 역량 정보
POST /api/portfolio/resources/{user}/skills — 역량 등록
"""
from __future__ import annotations
import logging
from datetime import date, datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import (
SiProject, WbsItem, ProjectIssue, ProjectRisk,
User, UserRole,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/portfolio", tags=["portfolio"])
# 인메모리 저장소 (운영 시 DB 테이블로 이전)
_resources: dict[int, list] = {} # project_id → [{user, role, wbs_code, mm, ...}]
_skills: dict[str, list] = {} # username → [{skill, level, years}]
# ── 포트폴리오 대시보드 ───────────────────────────────────────────────────────
@router.get("/dashboard")
async def portfolio_dashboard(
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""전체 활성 SI 프로젝트 포트폴리오 현황."""
projects = (await db.execute(
select(SiProject).where(SiProject.is_active == True)
.order_by(SiProject.planned_start.desc())
)).scalars().all()
result = []
for proj in projects:
# WBS 완료율
wbs = (await db.execute(
select(WbsItem).where(WbsItem.project_id == proj.id, WbsItem.is_leaf == True)
)).scalars().all()
progress = round(sum(w.completion_pct for w in wbs) / len(wbs), 1) if wbs else 0
# 미결 이슈
open_issues = (await db.execute(
select(func.count(ProjectIssue.id)).where(
ProjectIssue.project_id == proj.id,
ProjectIssue.status.notin_(["RESOLVED", "CLOSED"])
)
)).scalar() or 0
# 고위험
high_risks = (await db.execute(
select(func.count(ProjectRisk.id)).where(
ProjectRisk.project_id == proj.id,
ProjectRisk.risk_level.in_(["HIGH", "CRITICAL"])
)
)).scalar() or 0
# 일정 지연 여부
today = date.today()
is_delayed = bool(
proj.planned_end and proj.planned_end < today and progress < 100
)
result.append({
"project_id": proj.id,
"project_code": proj.project_code,
"project_name": proj.project_name,
"phase": proj.phase,
"health": proj.health_status,
"progress": progress,
"budget_used_pct": round(proj.budget_used / proj.budget_total * 100, 1)
if proj.budget_total else 0,
"open_issues": open_issues,
"high_risks": high_risks,
"is_delayed": is_delayed,
"planned_end": str(proj.planned_end) if proj.planned_end else None,
"pm": proj.pm_user,
"resources": len(_resources.get(proj.id, [])),
})
return {
"total_projects": len(result),
"active": sum(1 for p in result if p["phase"] not in ("CLOSED",)),
"delayed": sum(1 for p in result if p["is_delayed"]),
"at_risk": sum(1 for p in result if p["high_risks"] > 0),
"projects": result,
}
@router.get("/kpi")
async def portfolio_kpi(
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""포트폴리오 집계 KPI."""
projects = (await db.execute(
select(SiProject).where(SiProject.is_active == True)
)).scalars().all()
total_budget = sum((p.budget_total or 0) for p in projects)
total_used = sum((p.budget_used or 0) for p in projects)
wbs_all = (await db.execute(
select(WbsItem).where(WbsItem.is_leaf == True)
)).scalars().all()
avg_progress = round(
sum(w.completion_pct for w in wbs_all) / len(wbs_all), 1
) if wbs_all else 0
open_issues = (await db.execute(
select(func.count(ProjectIssue.id)).where(
ProjectIssue.status.notin_(["RESOLVED", "CLOSED"])
)
)).scalar() or 0
return {
"total_projects": len(projects),
"avg_progress": avg_progress,
"total_budget_man": total_budget,
"total_used_man": total_used,
"budget_rate": round(total_used / total_budget * 100, 1) if total_budget else 0,
"open_issues": open_issues,
"by_phase": {
phase: sum(1 for p in projects if p.phase == phase)
for phase in ["INITIATION", "ANALYSIS", "DESIGN", "IMPLEMENTATION", "DELIVERY", "CLOSED"]
},
"by_health": {
h: sum(1 for p in projects if p.health_status == h)
for h in ["GREEN", "YELLOW", "RED"]
},
}
# ── 인원 배치 ─────────────────────────────────────────────────────────────────
class ResourceCreate(BaseModel):
username: str
role: str = "DEVELOPER" # PM | ANALYST | DESIGNER | DEVELOPER | TESTER | DBA
wbs_codes: List[str] = [] # 담당 WBS 코드 목록
start_date: date
end_date: date
mm: float = 1.0 # M/M (Man-Month) 투입율
@router.get("/projects/{pid}/resources")
async def list_project_resources(
pid: int,
_u: User = Depends(get_current_user),
):
"""프로젝트 인원 배치 현황."""
return {
"project_id": pid,
"resources": _resources.get(pid, []),
"total_mm": sum(r["mm"] for r in _resources.get(pid, [])),
}
@router.post("/projects/{pid}/resources", status_code=201)
async def add_project_resource(
pid: int,
body: ResourceCreate,
db: AsyncSession = Depends(get_db),
cu: User = Depends(get_current_user),
):
"""인원 배치 등록."""
proj = await db.get(SiProject, pid)
if not proj:
raise HTTPException(404, f"프로젝트 {pid}를 찾을 수 없습니다.")
if cu.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "PM/ADMIN만 인원을 배치할 수 있습니다.")
entry = {
"username": body.username,
"role": body.role,
"wbs_codes": body.wbs_codes,
"start_date": str(body.start_date),
"end_date": str(body.end_date),
"mm": body.mm,
"added_by": cu.username,
"added_at": datetime.utcnow().isoformat(),
}
if pid not in _resources:
_resources[pid] = []
_resources[pid].append(entry)
return {"message": f"{body.username} 인원 배치 완료", "entry": entry}
@router.get("/resources/availability")
async def resource_availability(
start: Optional[str] = None,
end: Optional[str] = None,
_u: User = Depends(get_current_user),
):
"""기간별 가용 인력 조회."""
# 배치된 인력 집계
allocated: dict[str, float] = {}
for pid, entries in _resources.items():
for e in entries:
user = e["username"]
allocated[user] = allocated.get(user, 0) + e["mm"]
return {
"allocated_users": [
{"username": u, "total_mm": mm, "available_mm": max(0, 1.0 - mm)}
for u, mm in allocated.items()
],
"note": "M/M 기반 가용성 — 1.0 = 전일 투입",
}
# ── 역량 관리 ─────────────────────────────────────────────────────────────────
class SkillEntry(BaseModel):
skill: str
category: str = "TECH" # TECH | PM | DOMAIN | TOOL
level: str = "중급" # 초급 | 중급 | 고급 | 전문가
years: int = 0
certifications: List[str] = []
@router.get("/resources/{username}/skills")
async def get_skills(
username: str,
_u: User = Depends(get_current_user),
):
"""사용자 역량 정보 조회."""
return {
"username": username,
"skills": _skills.get(username, []),
}
@router.post("/resources/{username}/skills", status_code=201)
async def add_skill(
username: str,
body: SkillEntry,
cu: User = Depends(get_current_user),
):
"""역량 등록 (본인 또는 PM/ADMIN)."""
if cu.username != username and cu.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "본인 또는 PM/ADMIN만 역량을 등록할 수 있습니다.")
entry = {**body.model_dump(), "updated_at": datetime.utcnow().isoformat()}
if username not in _skills:
_skills[username] = []
# 동일 스킬 업데이트
existing = next((i for i, s in enumerate(_skills[username]) if s["skill"] == body.skill), None)
if existing is not None:
_skills[username][existing] = entry
else:
_skills[username].append(entry)
return {"message": f"{username} 역량 '{body.skill}' 등록 완료"}