""" C-5: 서비스 카탈로그 API 라우터 엔드포인트: GET /api/catalog/ — 서비스 카탈로그 목록 POST /api/catalog/ — 서비스 항목 등록 (관리자) GET /api/catalog/{service_id} — 서비스 항목 상세 PATCH /api/catalog/{service_id} — 서비스 항목 수정 DELETE /api/catalog/{service_id} — 서비스 항목 폐기 POST /api/catalog/{service_id}/request — 서비스 요청 (SR 자동 생성) GET /api/catalog/categories — 카테고리 목록 GET /api/catalog/stats — 카탈로그 통계 """ from __future__ import annotations import logging from datetime import datetime from typing import List, Optional from fastapi import APIRouter, Body, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import select, desc, func, and_ from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from database import get_db from models import ( User, UserRole, ServiceItem, ServiceItemOut, ServiceItemCreate, ServiceItemUpdate, ServiceStatus, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/catalog", tags=["catalog"]) async def _next_service_id(db: AsyncSession) -> str: """SVC-NNNN 형식 서비스 ID 생성.""" last = (await db.execute( select(ServiceItem.service_id) .where(ServiceItem.service_id.like("SVC-%")) .order_by(desc(ServiceItem.service_id)).limit(1) )).scalar() seq = 1 if last: try: seq = int(last.split("-")[-1]) + 1 except ValueError: seq = 1 return f"SVC-{seq:04d}" async def _get_service(db: AsyncSession, service_id: str) -> ServiceItem: svc = (await db.execute( select(ServiceItem).where(ServiceItem.service_id == service_id) )).scalars().first() if not svc: raise HTTPException(404, f"서비스 {service_id}를 찾을 수 없습니다.") return svc @router.get("/categories") async def list_categories( db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """카테고리 목록 조회.""" cats = (await db.execute( select(ServiceItem.category, func.count()) .where(ServiceItem.status == ServiceStatus.ACTIVE.value) .group_by(ServiceItem.category) .order_by(ServiceItem.category) )).all() return [{"category": c, "count": cnt} for c, cnt in cats if c] @router.get("/stats") async def catalog_stats( db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """서비스 카탈로그 통계.""" total = (await db.execute(select(func.count()).select_from(ServiceItem))).scalar() or 0 active = (await db.execute( select(func.count()).select_from(ServiceItem).where(ServiceItem.status == "ACTIVE") )).scalar() or 0 total_requests = (await db.execute( select(func.sum(ServiceItem.request_count)).select_from(ServiceItem) )).scalar() or 0 avg_satisfaction = (await db.execute( select(func.avg(ServiceItem.satisfaction)).select_from(ServiceItem) .where(ServiceItem.satisfaction != None) )).scalar() return { "total_services": total, "active_services": active, "total_requests": total_requests, "avg_satisfaction": round(float(avg_satisfaction), 2) if avg_satisfaction else None, } @router.get("/", response_model=List[ServiceItemOut]) async def list_catalog( category: Optional[str] = Query(None), status: Optional[str] = Query(None, enum=["ACTIVE", "DRAFT", "DEPRECATED", "RETIRED"]), keyword: Optional[str] = Query(None), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """서비스 카탈로그 목록.""" conditions = [] if category: conditions.append(ServiceItem.category == category) if status: conditions.append(ServiceItem.status == status.upper()) else: conditions.append(ServiceItem.status == ServiceStatus.ACTIVE.value) if keyword: conditions.append(ServiceItem.name.ilike(f"%{keyword}%")) q = ( select(ServiceItem) .where(and_(*conditions)) .order_by(ServiceItem.category, ServiceItem.name) .limit(limit).offset(offset) ) return (await db.execute(q)).scalars().all() @router.post("/", response_model=ServiceItemOut, status_code=201) async def create_service( body: ServiceItemCreate, 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/ADMIN 권한이 필요합니다.") service_id = await _next_service_id(db) svc = ServiceItem( service_id = service_id, name = body.name, category = body.category, description = body.description, status = body.status.upper(), sla_response_h = body.sla_response_h, sla_resolve_h = body.sla_resolve_h, sla_availability = body.sla_availability, approval_required = body.approval_required, auto_assignee = body.auto_assignee, estimated_hours = body.estimated_hours, charge_per_request = body.charge_per_request, owner = body.owner, tags = body.tags, created_by = current_user.username, ) db.add(svc) await db.commit() await db.refresh(svc) return svc @router.get("/{service_id}", response_model=ServiceItemOut) async def get_service( service_id: str, db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): return await _get_service(db, service_id) @router.patch("/{service_id}", response_model=ServiceItemOut) async def update_service( service_id: str, body: ServiceItemUpdate, 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/ADMIN 권한이 필요합니다.") svc = await _get_service(db, service_id) for k, v in body.model_dump(exclude_unset=True, exclude_none=True).items(): setattr(svc, k, v) await db.commit() await db.refresh(svc) return svc @router.delete("/{service_id}", status_code=204) async def retire_service( service_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """서비스 항목 폐기 (RETIRED 상태).""" if current_user.role not in (UserRole.ADMIN, UserRole.PM): raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.") svc = await _get_service(db, service_id) svc.status = ServiceStatus.RETIRED.value await db.commit() @router.post("/{service_id}/request") async def request_service( service_id: str, description: Optional[str] = Body(None, embed=True), priority: str = Body("MEDIUM", embed=True), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """ 서비스 카탈로그를 통한 SR 요청. - 서비스 SLA를 SR에 자동 적용 - 승인 필요 서비스는 PENDING_APPROVAL 상태로 생성 """ from models import SRRequest svc = await _get_service(db, service_id) if svc.status != ServiceStatus.ACTIVE.value: raise HTTPException(400, f"활성 서비스가 아닙니다: {svc.status}") # SR 생성 today = datetime.utcnow().strftime("%Y%m%d") prefix = f"SR-{today}-" last_sr = (await db.execute( select(SRRequest.sr_id).where(SRRequest.sr_id.like(f"{prefix}%")) .order_by(desc(SRRequest.sr_id)).limit(1) )).scalar() seq = 1 if last_sr: try: seq = int(last_sr.split("-")[-1]) + 1 except ValueError: seq = 1 sr_id = f"{prefix}{seq:04d}" status = "PENDING_APPROVAL" if svc.approval_required else "OPEN" sr = SRRequest( sr_id = sr_id, title = f"[카탈로그] {svc.name}", description = description or f"서비스 카탈로그 요청: {svc.name}", status = status, priority = priority.upper(), sr_type = "OTHER", created_at = datetime.utcnow(), ) db.add(sr) # 요청 카운트 증가 svc.request_count = (svc.request_count or 0) + 1 await db.commit() return { "sr_id": sr_id, "service_id": service_id, "service_name": svc.name, "status": status, "approval_required": svc.approval_required, "sla_response_h": svc.sla_response_h, "sla_resolve_h": svc.sla_resolve_h, "estimated_hours": svc.estimated_hours, }