""" SI 프로젝트 산출물(Deliverable) 관리 API 엔드포인트: GET /api/si/projects/{pid}/deliverables — 산출물 목록 POST /api/si/projects/{pid}/deliverables — 산출물 등록 GET /api/si/projects/{pid}/deliverables/{id} — 산출물 상세 PATCH /api/si/projects/{pid}/deliverables/{id} — 산출물 수정 DELETE /api/si/projects/{pid}/deliverables/{id} — 산출물 삭제 POST /api/si/projects/{pid}/deliverables/{id}/submit — 제출 처리 POST /api/si/projects/{pid}/deliverables/{id}/review — 검토 결과 등록 GET /api/si/projects/{pid}/deliverables/summary — 제출 현황 요약 """ from __future__ import annotations import logging from datetime import datetime, date from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from database import get_db from models import ( Deliverable, DeliverableCreate, DeliverableOut, DeliverableUpdate, DeliverableStatus, SiProject, User, UserRole, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/si/projects", tags=["deliverables"]) class ReviewRequest(BaseModel): result: str # APPROVED | REJECTED comment: Optional[str] = None async def _get_project(db: AsyncSession, pid: int) -> SiProject: proj = await db.get(SiProject, pid) if not proj: raise HTTPException(404, f"프로젝트 ID {pid}를 찾을 수 없습니다.") return proj async def _get_deliverable(db: AsyncSession, pid: int, did: int) -> Deliverable: d = await db.get(Deliverable, did) if not d or d.project_id != pid: raise HTTPException(404, f"산출물 ID {did}를 찾을 수 없습니다.") return d # ── 목록 ───────────────────────────────────────────────────────────────────── @router.get("/{pid}/deliverables", response_model=List[DeliverableOut]) async def list_deliverables( pid: int, status: Optional[str] = None, type_: Optional[str] = None, db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """프로젝트 산출물 목록 조회.""" await _get_project(db, pid) q = select(Deliverable).where(Deliverable.project_id == pid) if status: q = q.where(Deliverable.status == status) if type_: q = q.where(Deliverable.deliverable_type == type_) q = q.order_by(Deliverable.due_date.asc().nullslast()) rows = (await db.execute(q)).scalars().all() return rows # ── 생성 ───────────────────────────────────────────────────────────────────── @router.post("/{pid}/deliverables", response_model=DeliverableOut, status_code=201) async def create_deliverable( pid: int, body: DeliverableCreate, db: AsyncSession = Depends(get_db), cu: User = Depends(get_current_user), ): """산출물 등록.""" await _get_project(db, pid) body.project_id = pid d = Deliverable( project_id = pid, wbs_item_id = body.wbs_item_id, milestone_id = body.milestone_id, name = body.name, description = body.description, deliverable_type= body.deliverable_type, version = body.version, due_date = body.due_date, reviewer = body.reviewer, created_by = cu.username, ) db.add(d) await db.commit() await db.refresh(d) return d # ── 상세 ───────────────────────────────────────────────────────────────────── @router.get("/{pid}/deliverables/{did}", response_model=DeliverableOut) async def get_deliverable( pid: int, did: int, db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): return await _get_deliverable(db, pid, did) # ── 수정 ───────────────────────────────────────────────────────────────────── @router.patch("/{pid}/deliverables/{did}", response_model=DeliverableOut) async def update_deliverable( pid: int, did: int, body: DeliverableUpdate, db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): d = await _get_deliverable(db, pid, did) for field, val in body.model_dump(exclude_none=True).items(): setattr(d, field, val) d.updated_at = datetime.now() await db.commit() await db.refresh(d) return d # ── 삭제 ───────────────────────────────────────────────────────────────────── @router.delete("/{pid}/deliverables/{did}", status_code=204) async def delete_deliverable( pid: int, did: int, db: AsyncSession = Depends(get_db), cu: User = Depends(get_current_user), ): if cu.role not in (UserRole.ADMIN, UserRole.PM): raise HTTPException(403, "ADMIN 또는 PM만 산출물을 삭제할 수 있습니다.") d = await _get_deliverable(db, pid, did) await db.delete(d) await db.commit() # ── 제출 처리 ──────────────────────────────────────────────────────────────── @router.post("/{pid}/deliverables/{did}/submit") async def submit_deliverable( pid: int, did: int, file: Optional[UploadFile] = File(None), comment: str = Form(""), db: AsyncSession = Depends(get_db), cu: User = Depends(get_current_user), ): """산출물 제출 (파일 첨부 선택).""" d = await _get_deliverable(db, pid, did) if d.status in (DeliverableStatus.APPROVED,): raise HTTPException(400, "이미 승인된 산출물입니다.") # 파일 저장 if file: import aiofiles from pathlib import Path upload_dir = Path(__file__).parent.parent / "uploads" / "deliverables" / str(pid) upload_dir.mkdir(parents=True, exist_ok=True) safe_name = f"{did}_{file.filename}" out_path = upload_dir / safe_name async with aiofiles.open(out_path, "wb") as f: content = await file.read() await f.write(content) d.file_path = str(out_path) d.file_name = file.filename d.status = DeliverableStatus.SUBMITTED d.submitted_at = datetime.now() d.submitted_by = cu.username d.updated_at = datetime.now() await db.commit() return { "message": f"산출물 '{d.name}' 제출 완료", "status": d.status, "submitted_at": d.submitted_at.isoformat(), } # ── 검토 결과 등록 ──────────────────────────────────────────────────────────── @router.post("/{pid}/deliverables/{did}/review") async def review_deliverable( pid: int, did: int, body: ReviewRequest, db: AsyncSession = Depends(get_db), cu: User = Depends(get_current_user), ): """검토 결과 등록 (APPROVED / REJECTED).""" if cu.role not in (UserRole.ADMIN, UserRole.PM): raise HTTPException(403, "PM/ADMIN만 검토 결과를 등록할 수 있습니다.") d = await _get_deliverable(db, pid, did) if d.status not in (DeliverableStatus.SUBMITTED, DeliverableStatus.REVIEWING): raise HTTPException(400, "제출된 산출물에만 검토 결과를 등록할 수 있습니다.") d.status = body.result # APPROVED or REJECTED d.reviewer = cu.username d.reviewed_at = datetime.now() d.review_comment = body.comment d.updated_at = datetime.now() await db.commit() msg = "승인" if body.result == DeliverableStatus.APPROVED else "반려" return {"message": f"산출물 '{d.name}' {msg} 처리 완료", "status": d.status} # ── 제출 현황 요약 ──────────────────────────────────────────────────────────── @router.get("/{pid}/deliverables/summary") async def deliverable_summary( pid: int, db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """산출물 제출 현황 요약.""" await _get_project(db, pid) rows = (await db.execute( select(Deliverable).where(Deliverable.project_id == pid) )).scalars().all() total = len(rows) by_status= {} overdue = [] today = date.today() for d in rows: by_status[d.status] = by_status.get(d.status, 0) + 1 if d.status == DeliverableStatus.PENDING and d.due_date and d.due_date < today: overdue.append({ "id": d.id, "name": d.name, "due_date": d.due_date.isoformat(), "days_overdue": (today - d.due_date).days, }) approved = by_status.get(DeliverableStatus.APPROVED, 0) submit_rate = round( (total - by_status.get(DeliverableStatus.PENDING, 0)) / total * 100, 1 ) if total else 0.0 return { "total": total, "by_status": by_status, "approved": approved, "submit_rate": submit_rate, "approval_rate": round(approved / total * 100, 1) if total else 0.0, "overdue": overdue, }