- 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>
221 lines
8.6 KiB
Python
221 lines
8.6 KiB
Python
"""
|
|
요구사항 관리 API — RFP 요구사항 입력·추적.
|
|
|
|
엔드포인트:
|
|
GET /api/si/projects/{pid}/requirements — 요구사항 목록 (유형·상태 필터)
|
|
POST /api/si/projects/{pid}/requirements — 요구사항 등록
|
|
GET /api/si/projects/{pid}/requirements/{id} — 상세
|
|
PATCH /api/si/projects/{pid}/requirements/{id} — 수정
|
|
DELETE /api/si/projects/{pid}/requirements/{id} — 삭제
|
|
PATCH /api/si/projects/{pid}/requirements/{id}/confirm — 확정 처리
|
|
GET /api/si/projects/{pid}/requirements/stats — 유형별·상태별 통계
|
|
"""
|
|
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
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from core.auth import get_current_user
|
|
from database import get_db
|
|
from models import (
|
|
SiProject, SiRequirement,
|
|
SiRequirementCreate, SiRequirementOut, SiRequirementUpdate,
|
|
ReqStatus, ReqType,
|
|
User, UserRole,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/si/projects", tags=["si-requirements"])
|
|
|
|
|
|
def _new_req_id() -> str:
|
|
return f"REQ-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:4].upper()}"
|
|
|
|
|
|
# ── 목록 ──────────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/{project_id}/requirements", response_model=List[SiRequirementOut])
|
|
async def list_requirements(
|
|
project_id: int,
|
|
req_type: Optional[str] = Query(None),
|
|
status: Optional[str] = Query(None),
|
|
priority: Optional[str] = Query(None),
|
|
keyword: Optional[str] = Query(None),
|
|
skip: int = 0,
|
|
limit: int = 200,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
await _assert_project(project_id, db)
|
|
q = select(SiRequirement).where(SiRequirement.project_id == project_id)
|
|
|
|
if req_type:
|
|
q = q.where(SiRequirement.req_type == req_type)
|
|
if status:
|
|
q = q.where(SiRequirement.status == status)
|
|
if priority:
|
|
q = q.where(SiRequirement.priority == priority)
|
|
if keyword:
|
|
q = q.where(SiRequirement.title.contains(keyword))
|
|
|
|
q = q.order_by(SiRequirement.req_id).offset(skip).limit(limit)
|
|
return (await db.execute(q)).scalars().all()
|
|
|
|
|
|
# ── 생성 ──────────────────────────────────────────────────────────────────────
|
|
|
|
@router.post("/{project_id}/requirements", response_model=SiRequirementOut, status_code=201)
|
|
async def create_requirement(
|
|
project_id: int,
|
|
body: SiRequirementCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER):
|
|
raise HTTPException(403, "엔지니어 이상 권한 필요")
|
|
await _assert_project(project_id, db)
|
|
|
|
req = SiRequirement(
|
|
req_id = _new_req_id(),
|
|
project_id = project_id,
|
|
req_type = body.req_type,
|
|
title = body.title,
|
|
description = body.description,
|
|
source = body.source,
|
|
priority = body.priority,
|
|
wbs_item_id = body.wbs_item_id,
|
|
acceptance_criteria = body.acceptance_criteria,
|
|
created_by = body.created_by or current_user.username,
|
|
)
|
|
db.add(req)
|
|
await db.commit()
|
|
await db.refresh(req)
|
|
return req
|
|
|
|
|
|
# ── 상세 ──────────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/{project_id}/requirements/{req_id}", response_model=SiRequirementOut)
|
|
async def get_requirement(
|
|
project_id: int, req_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
return await _get_req_or_404(project_id, req_id, db)
|
|
|
|
|
|
# ── 수정 ──────────────────────────────────────────────────────────────────────
|
|
|
|
@router.patch("/{project_id}/requirements/{req_id}", response_model=SiRequirementOut)
|
|
async def update_requirement(
|
|
project_id: int, req_id: int,
|
|
body: SiRequirementUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER):
|
|
raise HTTPException(403, "엔지니어 이상 권한 필요")
|
|
req = await _get_req_or_404(project_id, req_id, db)
|
|
|
|
for field, val in body.model_dump(exclude_none=True).items():
|
|
setattr(req, field, val)
|
|
|
|
await db.commit()
|
|
await db.refresh(req)
|
|
return req
|
|
|
|
|
|
# ── 확정 ──────────────────────────────────────────────────────────────────────
|
|
|
|
@router.patch("/{project_id}/requirements/{req_id}/confirm", response_model=SiRequirementOut)
|
|
async def confirm_requirement(
|
|
project_id: int, req_id: int,
|
|
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 이상 권한 필요")
|
|
req = await _get_req_or_404(project_id, req_id, db)
|
|
|
|
req.status = ReqStatus.CONFIRMED
|
|
req.confirmed_by = current_user.username
|
|
req.confirmed_at = datetime.now()
|
|
|
|
await db.commit()
|
|
await db.refresh(req)
|
|
return req
|
|
|
|
|
|
# ── 삭제 ──────────────────────────────────────────────────────────────────────
|
|
|
|
@router.delete("/{project_id}/requirements/{req_id}", status_code=204)
|
|
async def delete_requirement(
|
|
project_id: int, req_id: int,
|
|
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 이상 권한 필요")
|
|
req = await _get_req_or_404(project_id, req_id, db)
|
|
await db.delete(req)
|
|
await db.commit()
|
|
|
|
|
|
# ── 통계 ──────────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/{project_id}/requirements/stats")
|
|
async def get_requirement_stats(
|
|
project_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
await _assert_project(project_id, db)
|
|
reqs = (await db.execute(
|
|
select(SiRequirement).where(SiRequirement.project_id == project_id)
|
|
)).scalars().all()
|
|
|
|
by_type = {}
|
|
by_status = {}
|
|
by_priority = {}
|
|
|
|
for r in reqs:
|
|
by_type[r.req_type] = by_type.get(r.req_type, 0) + 1
|
|
by_status[r.status] = by_status.get(r.status, 0) + 1
|
|
by_priority[r.priority] = by_priority.get(r.priority, 0) + 1
|
|
|
|
return {
|
|
"project_id": project_id,
|
|
"total": len(reqs),
|
|
"by_type": by_type,
|
|
"by_status": by_status,
|
|
"by_priority": by_priority,
|
|
}
|
|
|
|
|
|
# ── 내부 헬퍼 ─────────────────────────────────────────────────────────────────
|
|
|
|
async def _assert_project(project_id: int, db: AsyncSession) -> None:
|
|
if not (await db.execute(
|
|
select(SiProject).where(SiProject.id == project_id)
|
|
)).scalars().first():
|
|
raise HTTPException(404, f"SI 프로젝트 {project_id}를 찾을 수 없습니다")
|
|
|
|
|
|
async def _get_req_or_404(project_id: int, req_id: int, db: AsyncSession) -> SiRequirement:
|
|
req = (await db.execute(
|
|
select(SiRequirement).where(
|
|
SiRequirement.id == req_id,
|
|
SiRequirement.project_id == project_id,
|
|
)
|
|
)).scalars().first()
|
|
if not req:
|
|
raise HTTPException(404, "요구사항을 찾을 수 없습니다")
|
|
return req
|