""" 포트폴리오 관리 + 리소스/인력 관리 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}' 등록 완료"}