""" SI 프로젝트 관리 API — 표준 방법론(분석→설계→구현→인도) 기반. 엔드포인트: GET /api/si/projects — 프로젝트 목록 POST /api/si/projects — 프로젝트 생성 (단계별 체크리스트 자동 생성) GET /api/si/projects/{id} — 프로젝트 상세 PATCH /api/si/projects/{id} — 프로젝트 수정 DELETE /api/si/projects/{id} — 프로젝트 삭제 (ADMIN) PATCH /api/si/projects/{id}/phase — 단계 전환 GET /api/si/projects/{id}/checklist — 단계별 활동 체크리스트 조회 PATCH /api/si/projects/{id}/checklist/{cid} — 활동 완료 처리 GET /api/si/projects/{id}/summary — 진척 요약 (WBS/이슈/위험/TC) POST /api/si/projects/{id}/convert-to-sm — SM 전환 (CMDB 자동 생성) GET /api/si/projects/{id}/rtm — RTM (요구사항↔WBS↔TC 추적표) """ from __future__ import annotations import logging from datetime import datetime from typing import List, Optional from uuid import uuid4 from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import select, desc, func as sqlfunc from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from database import get_db from models import ( Institution, Server, SslHistory, PmSchedule, SiProject, SiProjectCreate, SiProjectOut, SiProjectUpdate, SiPhaseChecklist, SiPhaseChecklistOut, SiPhaseChecklistUpdate, SiRequirement, WbsItem, ProjectIssue, ProjectRisk, SiTestPlan, SiTestCase, SiDefect, ProjectPhase, PHASE_DEFAULT_ACTIVITIES, User, UserRole, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/si/projects", tags=["si-projects"]) _PHASE_ORDER = [ ProjectPhase.INITIATION, ProjectPhase.ANALYSIS, ProjectPhase.DESIGN, ProjectPhase.IMPLEMENTATION, ProjectPhase.DEPLOYMENT, ProjectPhase.STABILIZATION, ProjectPhase.CLOSED, ] def _new_project_code() -> str: return f"SI-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:6].upper()}" def _calc_risk_level(score: int) -> str: if score >= 7: return "CRITICAL" if score >= 5: return "HIGH" if score >= 3: return "MEDIUM" return "LOW" # ── 목록 ────────────────────────────────────────────────────────────────────── @router.get("", response_model=List[SiProjectOut]) async def list_si_projects( inst_id: Optional[int] = Query(None), phase: Optional[str] = Query(None), keyword: Optional[str] = Query(None), is_active: bool = Query(True), skip: int = 0, limit: int = 100, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): q = select(SiProject).where(SiProject.is_active == is_active) if current_user.role == UserRole.CUSTOMER and current_user.inst_code: r_i = await db.execute( select(Institution).where(Institution.inst_code == current_user.inst_code) ) own = r_i.scalars().first() q = q.where(SiProject.inst_id == own.id) if own else q.where(SiProject.id == -1) elif inst_id: q = q.where(SiProject.inst_id == inst_id) if phase: q = q.where(SiProject.phase == phase) if keyword: q = q.where(SiProject.project_name.contains(keyword)) q = q.order_by(desc(SiProject.created_at)).offset(skip).limit(limit) result = await db.execute(q) return result.scalars().all() # ── 생성 ────────────────────────────────────────────────────────────────────── @router.post("", response_model=SiProjectOut, status_code=201) async def create_si_project( body: SiProjectCreate, 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 이상 권한 필요") proj = SiProject( project_code = _new_project_code(), project_name = body.project_name, inst_id = body.inst_id, phase = ProjectPhase.INITIATION, description = body.description, contract_amount = body.contract_amount, planned_start = body.planned_start, planned_end = body.planned_end, pm_user = body.pm_user, dev_lead = body.dev_lead, customer_pm = body.customer_pm, team_members = body.team_members, budget_total = body.budget_total, note = body.note, created_by = current_user.username, ) db.add(proj) await db.flush() # ID 확보 # 4단계 기본 체크리스트 자동 생성 for phase, activities in PHASE_DEFAULT_ACTIVITIES.items(): for activity, name in activities: db.add(SiPhaseChecklist( project_id = proj.id, phase = phase, activity = activity, activity_name = name, )) await db.commit() await db.refresh(proj) logger.info("SI 프로젝트 생성: code=%s name=%s by=%s", proj.project_code, proj.project_name, current_user.username) return proj # ── 상세 ────────────────────────────────────────────────────────────────────── @router.get("/{project_id}", response_model=SiProjectOut) async def get_si_project( project_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): proj = await _get_project_or_404(project_id, db) _check_access(proj, current_user) return proj # ── 수정 ────────────────────────────────────────────────────────────────────── @router.patch("/{project_id}", response_model=SiProjectOut) async def update_si_project( project_id: int, body: SiProjectUpdate, 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 이상 권한 필요") proj = await _get_project_or_404(project_id, db) for field, val in body.model_dump(exclude_none=True).items(): setattr(proj, field, val) await db.commit() await db.refresh(proj) return proj # ── 삭제 ────────────────────────────────────────────────────────────────────── @router.delete("/{project_id}", status_code=204) async def delete_si_project( project_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): if current_user.role != UserRole.ADMIN: raise HTTPException(403, "ADMIN 권한 필요") proj = await _get_project_or_404(project_id, db) await db.delete(proj) await db.commit() # ── 단계 전환 ───────────────────────────────────────────────────────────────── @router.patch("/{project_id}/phase") async def advance_phase( project_id: int, target_phase: ProjectPhase = Query(..., description="전환할 단계"), 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 이상 권한 필요") proj = await _get_project_or_404(project_id, db) # 현재 단계 미완료 항목 수집 current_items = (await db.execute( select(SiPhaseChecklist).where( SiPhaseChecklist.project_id == project_id, SiPhaseChecklist.phase == proj.phase, SiPhaseChecklist.is_done == False, # noqa: E712 ) )).scalars().all() warnings = [item.activity_name for item in current_items] proj.phase = target_phase if target_phase == ProjectPhase.CLOSED: from datetime import date proj.actual_end = proj.actual_end or date.today() await db.commit() await db.refresh(proj) return { "project": SiProjectOut.model_validate(proj), "warnings": warnings, "message": f"단계 전환 완료: {target_phase}" + ( f" (미완료 활동 {len(warnings)}건 있음)" if warnings else "" ), } # ── 체크리스트 ──────────────────────────────────────────────────────────────── @router.get("/{project_id}/checklist", response_model=List[SiPhaseChecklistOut]) async def get_checklist( project_id: int, phase: Optional[str] = Query(None), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): q = select(SiPhaseChecklist).where(SiPhaseChecklist.project_id == project_id) if phase: q = q.where(SiPhaseChecklist.phase == phase) q = q.order_by(SiPhaseChecklist.phase, SiPhaseChecklist.id) return (await db.execute(q)).scalars().all() @router.patch("/{project_id}/checklist/{checklist_id}", response_model=SiPhaseChecklistOut) async def update_checklist_item( project_id: int, checklist_id: int, body: SiPhaseChecklistUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): item = (await db.execute( select(SiPhaseChecklist).where( SiPhaseChecklist.id == checklist_id, SiPhaseChecklist.project_id == project_id, ) )).scalars().first() if not item: raise HTTPException(404, "체크리스트 항목을 찾을 수 없습니다") item.is_done = body.is_done item.done_by = body.done_by or current_user.username item.done_at = datetime.now() if body.is_done else None if body.note is not None: item.note = body.note await db.commit() await db.refresh(item) return item # ── 진척 요약 ───────────────────────────────────────────────────────────────── @router.get("/{project_id}/summary") async def get_project_summary( project_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """프로젝트 대시보드용 요약: WBS/이슈/위험/TC 현황.""" proj = await _get_project_or_404(project_id, db) _check_access(proj, current_user) # WBS 진척 wbs_rows = (await db.execute( select(WbsItem).where(WbsItem.project_id == project_id, WbsItem.is_leaf == True) # noqa: E712 )).scalars().all() total_wbs = len(wbs_rows) wbs_done = sum(1 for w in wbs_rows if w.completion_pct == 100) avg_pct = (sum(w.completion_pct for w in wbs_rows) // total_wbs) if total_wbs else 0 delayed = sum(1 for w in wbs_rows if w.status == "DELAYED") # 이슈 현황 issues = (await db.execute( select(ProjectIssue).where(ProjectIssue.project_id == project_id) )).scalars().all() issue_open = sum(1 for i in issues if i.status in ("OPEN", "IN_PROGRESS")) issue_total = len(issues) # 위험 현황 risks = (await db.execute( select(ProjectRisk).where(ProjectRisk.project_id == project_id) )).scalars().all() risk_high = sum(1 for r in risks if r.risk_level in ("HIGH", "CRITICAL") and r.status == "OPEN") # 요구사항 현황 reqs = (await db.execute( select(SiRequirement).where(SiRequirement.project_id == project_id) )).scalars().all() req_total = len(reqs) req_confirmed = sum(1 for r in reqs if r.status == "CONFIRMED") # 테스트 현황 plans = (await db.execute( select(SiTestPlan).where(SiTestPlan.project_id == project_id) )).scalars().all() tc_total = sum(p.total_cases for p in plans) tc_pass = sum(p.pass_count for p in plans) tc_fail = sum(p.fail_count for p in plans) # 결함 현황 defects = (await db.execute( select(SiDefect).where(SiDefect.project_id == project_id) )).scalars().all() defect_open = sum(1 for d in defects if d.status in ("OPEN", "ASSIGNED")) defect_critical = sum(1 for d in defects if d.severity == "CRITICAL" and d.status not in ("CLOSED", "VERIFIED")) # 체크리스트 완료율 checklists = (await db.execute( select(SiPhaseChecklist).where(SiPhaseChecklist.project_id == project_id) )).scalars().all() cl_total = len(checklists) cl_done = sum(1 for c in checklists if c.is_done) return { "project_id": project_id, "project_name": proj.project_name, "phase": proj.phase, "health_status": proj.health_status, "overall_progress": avg_pct, "wbs": { "total": total_wbs, "completed": wbs_done, "avg_pct": avg_pct, "delayed": delayed, }, "issues": {"total": issue_total, "open": issue_open}, "risks": {"total": len(risks), "high_open": risk_high}, "requirements": {"total": req_total, "confirmed": req_confirmed}, "tests": { "total_cases": tc_total, "pass": tc_pass, "fail": tc_fail, "pass_rate": (tc_pass * 100 // tc_total) if tc_total else 0, }, "defects": {"open": defect_open, "critical_open": defect_critical}, "checklist": { "total": cl_total, "done": cl_done, "pct": (cl_done * 100 // cl_total) if cl_total else 0, }, } # ── RTM (Requirements Traceability Matrix) ──────────────────────────────────── @router.get("/{project_id}/rtm") async def get_rtm( project_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """요구사항 → WBS → 테스트케이스 → 결함 추적 매트릭스.""" proj = await _get_project_or_404(project_id, db) _check_access(proj, current_user) reqs = (await db.execute( select(SiRequirement).where(SiRequirement.project_id == project_id) .order_by(SiRequirement.req_id) )).scalars().all() rows = [] for req in reqs: wbs_title = None if req.wbs_item_id: wbs = (await db.execute( select(WbsItem).where(WbsItem.id == req.wbs_item_id) )).scalars().first() wbs_title = f"{wbs.wbs_code} {wbs.title}" if wbs else None tcs = (await db.execute( select(SiTestCase).where(SiTestCase.req_id == req.id) )).scalars().all() defect_count = 0 for tc in tcs: dc = (await db.execute( select(SiDefect).where(SiDefect.tc_id == tc.id, SiDefect.status.notin_(["CLOSED", "VERIFIED"])) )).scalars().all() defect_count += len(dc) rows.append({ "req_id": req.req_id, "req_title": req.title, "req_type": req.req_type, "req_status": req.status, "wbs": wbs_title, "tc_count": len(tcs), "tc_pass": sum(1 for t in tcs if t.last_result == "PASS"), "tc_fail": sum(1 for t in tcs if t.last_result == "FAIL"), "open_defects": defect_count, }) return {"project_id": project_id, "total": len(rows), "rows": rows} # ── SM 전환 ─────────────────────────────────────────────────────────────────── @router.post("/{project_id}/convert-to-sm") async def convert_to_sm( project_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """SI 프로젝트 완료 → SM 전환: CMDB 서버 등록 + PM 스케줄 생성.""" if current_user.role not in (UserRole.ADMIN, UserRole.PM): raise HTTPException(403, "PM 이상 권한 필요") proj = await _get_project_or_404(project_id, db) if proj.phase not in (ProjectPhase.STABILIZATION, ProjectPhase.CLOSED): raise HTTPException(400, "안정화 또는 종료 단계인 프로젝트만 SM 전환 가능합니다") if proj.converted_to_sm: raise HTTPException(400, "이미 SM으로 전환된 프로젝트입니다") if not proj.inst_id: raise HTTPException(400, "기관(inst_id)이 지정되지 않아 SM 전환 불가합니다") created_servers: List[str] = [] created_pm: List[str] = [] # 요구사항 중 서버 관련 내용으로 최소 CMDB 항목 생성 # (실제 프로젝트에서는 WBS/서버 정보를 별도 관리하지만, 기본 레코드만 생성) default_server = Server( inst_id = proj.inst_id, server_name = f"{proj.project_name} 운영서버", server_role = "WAS", note = f"SI 프로젝트 [{proj.project_code}] SM 전환 자동 생성", is_active = True, ) db.add(default_server) await db.flush() created_servers.append(default_server.server_name) # 분기 PM 스케줄 생성 pm_sched = PmSchedule( schedule_name = f"{proj.project_name} 분기 PM", inst_id = proj.inst_id, server_id = default_server.id, frequency = "QUARTERLY", advance_days = 7, assignee = proj.pm_user, notify_before = True, notify_after = True, created_by = current_user.username, ) db.add(pm_sched) created_pm.append(pm_sched.schedule_name) proj.converted_to_sm = True proj.phase = ProjectPhase.CLOSED await db.commit() logger.info("SI→SM 전환: project=%s by=%s", proj.project_code, current_user.username) return { "message": "SM 전환 완료", "project_code": proj.project_code, "created_servers": created_servers, "created_pm_schedules": created_pm, } # ── 내부 헬퍼 ───────────────────────────────────────────────────────────────── async def _get_project_or_404(project_id: int, db: AsyncSession) -> SiProject: proj = (await db.execute( select(SiProject).where(SiProject.id == project_id) )).scalars().first() if not proj: raise HTTPException(404, f"SI 프로젝트 {project_id}를 찾을 수 없습니다") return proj def _check_access(proj: SiProject, user: User) -> None: if user.role == UserRole.CUSTOMER: raise HTTPException(403, "고객 계정은 SI 프로젝트에 접근할 수 없습니다")