- 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>
283 lines
9.8 KiB
Python
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}' 등록 완료"}
|