guardia-itsm/routers/portfolio.py
DESKTOP-TKLFCPRython 6c85fba90f feat(itsm): 추가 기능 7개 + API 명세서 완성
[고객 셀프서비스 포털]
- routers/customer_portal.py: SR접수/추적/AI FAQ자가해결/카탈로그/만족도/통계
  POST /api/portal/faq/suggest — KB+Ollama 기반 SR 접수 전 자가해결 유도

[그룹웨어 전자결재 연동]
- routers/groupware.py: 카카오워크/네이버웍스/한컴/Custom 웹훅
  POST /api/groupware/send-approval → 결재 발송
  POST /api/groupware/callback → 승인/반려 콜백 → SR 상태 자동 갱신

[SIEM 보안 이벤트 연동]
- routers/siem.py: Elasticsearch/Splunk HEC/OpenSearch
  POST /api/siem/alert/receive → SIEM 경보 → 인시던트 자동 생성

[네트워크 토폴로지 시각화]
- routers/topology.py: CMDB CI 의존관계 D3.js 인터랙티브 그래프
  GET /api/topology/page — 드래그/줌/헬스오버레이 뷰어

[포트폴리오 + 리소스/인력 관리]
- routers/portfolio.py: 다중 프로젝트 포트폴리오 대시보드
  + 인원 배치(M/M) + 역량 매핑

[Zero Trust + Kubernetes + ERP]
- routers/infra_ext.py:
  - Zero Trust 세션 재검증 (위험점수 70 이상 → 강제 재인증)
  - K8s pods/services/nodes API 연동
  - ERP 예산 동기화

[API 명세서]
- manual/16_API_명세서.md: 전체 588개 라우트 도메인별 정리

[버그 수정]
- customer_portal.py: ServiceCatalog→ServiceItem, KBDocument.content→solution/symptoms
- customer_portal.py: catalog is_active→status="ACTIVE"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 07:37:52 +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}' 등록 완료"}