from typing import List, Optional from uuid import UUID from fastapi import APIRouter, HTTPException, Depends, Path from pydantic import BaseModel from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from src.database.config import get_db from src.database.models import InspectionTemplate, InspectionTemplateItem, Machine from src.auth.models import TokenData from src.auth.dependencies import require_auth, verify_tenant_access router = APIRouter(prefix="/api/{tenant_id}/templates", tags=["templates"]) VALID_SUBJECT_TYPES = ("equipment", "product") VALID_SCHEDULE_TYPES = ("daily", "weekly", "monthly", "yearly", "ad_hoc") VALID_INSPECTION_MODES = ("checklist", "measurement", "monitoring") VALID_DATA_TYPES = ("numeric", "boolean", "text", "select") class ItemCreate(BaseModel): name: str category: Optional[str] = None data_type: str unit: Optional[str] = None spec_min: Optional[float] = None spec_max: Optional[float] = None warning_min: Optional[float] = None warning_max: Optional[float] = None trend_window: Optional[int] = None select_options: Optional[list] = None equipment_part_id: Optional[str] = None is_required: bool = True class ItemUpdate(BaseModel): name: Optional[str] = None category: Optional[str] = None data_type: Optional[str] = None unit: Optional[str] = None spec_min: Optional[float] = None spec_max: Optional[float] = None warning_min: Optional[float] = None warning_max: Optional[float] = None trend_window: Optional[int] = None select_options: Optional[list] = None equipment_part_id: Optional[str] = None is_required: Optional[bool] = None class TemplateCreate(BaseModel): name: str subject_type: str machine_id: Optional[str] = None product_code: Optional[str] = None schedule_type: str inspection_mode: str = "measurement" items: List[ItemCreate] = [] class TemplateUpdate(BaseModel): name: Optional[str] = None subject_type: Optional[str] = None machine_id: Optional[str] = None product_code: Optional[str] = None schedule_type: Optional[str] = None inspection_mode: Optional[str] = None class ReorderRequest(BaseModel): item_ids: List[str] def _format_ts(val) -> Optional[str]: if val is None: return None return val.isoformat() if hasattr(val, "isoformat") else str(val) def _item_to_dict(item: InspectionTemplateItem) -> dict: return { "id": str(item.id), "template_id": str(item.template_id), "sort_order": item.sort_order, "name": str(item.name), "category": str(item.category) if item.category else None, "data_type": str(item.data_type), "unit": str(item.unit) if item.unit else None, "spec_min": float(item.spec_min) if item.spec_min is not None else None, "spec_max": float(item.spec_max) if item.spec_max is not None else None, "warning_min": float(item.warning_min) if item.warning_min is not None else None, "warning_max": float(item.warning_max) if item.warning_max is not None else None, "trend_window": int(item.trend_window) if item.trend_window is not None else None, "select_options": item.select_options, "equipment_part_id": str(item.equipment_part_id) if item.equipment_part_id else None, "is_required": bool(item.is_required), "created_at": _format_ts(item.created_at), } def _template_to_dict( t: InspectionTemplate, items_count: int = 0, include_items: bool = False ) -> dict: result = { "id": str(t.id), "tenant_id": str(t.tenant_id), "name": str(t.name), "subject_type": str(t.subject_type), "machine_id": str(t.machine_id) if t.machine_id else None, "product_code": str(t.product_code) if t.product_code else None, "schedule_type": str(t.schedule_type), "inspection_mode": str(t.inspection_mode or "measurement"), "version": int(t.version or 1), "is_active": bool(t.is_active), "items_count": items_count, "created_at": _format_ts(t.created_at), "updated_at": _format_ts(t.updated_at), } if include_items: result["items"] = [_item_to_dict(i) for i in (t.items or [])] return result def _validate_subject_type(subject_type: str): if subject_type not in VALID_SUBJECT_TYPES: raise HTTPException( status_code=400, detail=f"subject_type은 {', '.join(VALID_SUBJECT_TYPES)} 중 하나여야 합니다.", ) def _validate_schedule_type(schedule_type: str): if schedule_type not in VALID_SCHEDULE_TYPES: raise HTTPException( status_code=400, detail=f"schedule_type은 {', '.join(VALID_SCHEDULE_TYPES)} 중 하나여야 합니다.", ) def _validate_inspection_mode(mode: str): if mode not in VALID_INSPECTION_MODES: raise HTTPException( status_code=400, detail=f"inspection_mode는 {', '.join(VALID_INSPECTION_MODES)} 중 하나여야 합니다.", ) def _validate_data_type(data_type: str): if data_type not in VALID_DATA_TYPES: raise HTTPException( status_code=400, detail=f"data_type은 {', '.join(VALID_DATA_TYPES)} 중 하나여야 합니다.", ) async def _get_template( db: AsyncSession, tenant_id: str, template_id: UUID, load_items: bool = False ) -> InspectionTemplate: stmt = select(InspectionTemplate).where( InspectionTemplate.id == template_id, InspectionTemplate.tenant_id == tenant_id, InspectionTemplate.is_active == True, ) if load_items: stmt = stmt.options(selectinload(InspectionTemplate.items)) result = await db.execute(stmt) template = result.scalar_one_or_none() if not template: raise HTTPException(status_code=404, detail="검사 템플릿을 찾을 수 없습니다.") return template @router.get("") async def list_templates( tenant_id: str = Path(...), subject_type: Optional[str] = None, schedule_type: Optional[str] = None, machine_id: Optional[str] = None, current_user: TokenData = Depends(require_auth), db: AsyncSession = Depends(get_db), ): verify_tenant_access(tenant_id, current_user) stmt = ( select( InspectionTemplate, func.count(InspectionTemplateItem.id).label("items_count"), ) .outerjoin( InspectionTemplateItem, InspectionTemplateItem.template_id == InspectionTemplate.id, ) .where( InspectionTemplate.tenant_id == tenant_id, InspectionTemplate.is_active == True, ) .group_by(InspectionTemplate.id) .order_by(InspectionTemplate.name) ) if subject_type: stmt = stmt.where(InspectionTemplate.subject_type == subject_type) if schedule_type: stmt = stmt.where(InspectionTemplate.schedule_type == schedule_type) if machine_id: stmt = stmt.where(InspectionTemplate.machine_id == UUID(machine_id)) result = await db.execute(stmt) rows = result.all() return {"templates": [_template_to_dict(t, count) for t, count in rows]} @router.post("") async def create_template( body: TemplateCreate, tenant_id: str = Path(...), current_user: TokenData = Depends(require_auth), db: AsyncSession = Depends(get_db), ): verify_tenant_access(tenant_id, current_user) _validate_subject_type(body.subject_type) _validate_schedule_type(body.schedule_type) _validate_inspection_mode(body.inspection_mode) if body.subject_type == "equipment" and body.machine_id: machine_stmt = select(Machine).where( Machine.id == UUID(body.machine_id), Machine.tenant_id == tenant_id ) if not (await db.execute(machine_stmt)).scalar_one_or_none(): raise HTTPException(status_code=404, detail="설비를 찾을 수 없습니다.") template = InspectionTemplate( tenant_id=tenant_id, name=body.name, subject_type=body.subject_type, machine_id=UUID(body.machine_id) if body.machine_id else None, product_code=body.product_code, schedule_type=body.schedule_type, inspection_mode=body.inspection_mode, ) db.add(template) await db.flush() for idx, item_data in enumerate(body.items): _validate_data_type(item_data.data_type) item = InspectionTemplateItem( template_id=template.id, sort_order=idx + 1, name=item_data.name, category=item_data.category, data_type=item_data.data_type, unit=item_data.unit, spec_min=item_data.spec_min, spec_max=item_data.spec_max, warning_min=item_data.warning_min, warning_max=item_data.warning_max, trend_window=item_data.trend_window, select_options=item_data.select_options, equipment_part_id=UUID(item_data.equipment_part_id) if item_data.equipment_part_id else None, is_required=item_data.is_required, ) db.add(item) await db.commit() stmt = ( select(InspectionTemplate) .options(selectinload(InspectionTemplate.items)) .where(InspectionTemplate.id == template.id) ) result = await db.execute(stmt) created = result.scalar_one() return _template_to_dict(created, len(created.items), include_items=True) @router.get("/{template_id}") async def get_template( tenant_id: str = Path(...), template_id: UUID = Path(...), current_user: TokenData = Depends(require_auth), db: AsyncSession = Depends(get_db), ): verify_tenant_access(tenant_id, current_user) template = await _get_template(db, tenant_id, template_id, load_items=True) return _template_to_dict(template, len(template.items), include_items=True) @router.put("/{template_id}") async def update_template( body: TemplateUpdate, tenant_id: str = Path(...), template_id: UUID = Path(...), current_user: TokenData = Depends(require_auth), db: AsyncSession = Depends(get_db), ): verify_tenant_access(tenant_id, current_user) template = await _get_template(db, tenant_id, template_id, load_items=True) if body.subject_type is not None: _validate_subject_type(body.subject_type) template.subject_type = body.subject_type if body.schedule_type is not None: _validate_schedule_type(body.schedule_type) template.schedule_type = body.schedule_type if body.inspection_mode is not None: _validate_inspection_mode(body.inspection_mode) template.inspection_mode = body.inspection_mode if body.name is not None: template.name = body.name if body.machine_id is not None: template.machine_id = UUID(body.machine_id) if body.machine_id else None if body.product_code is not None: template.product_code = body.product_code template.version = (template.version or 1) + 1 await db.commit() await db.refresh(template) return _template_to_dict(template, len(template.items), include_items=True) @router.delete("/{template_id}") async def delete_template( tenant_id: str = Path(...), template_id: UUID = Path(...), current_user: TokenData = Depends(require_auth), db: AsyncSession = Depends(get_db), ): verify_tenant_access(tenant_id, current_user) template = await _get_template(db, tenant_id, template_id) template.is_active = False await db.commit() return {"status": "success", "message": "검사 템플릿이 비활성화되었습니다."} @router.post("/{template_id}/items") async def create_item( body: ItemCreate, tenant_id: str = Path(...), template_id: UUID = Path(...), current_user: TokenData = Depends(require_auth), db: AsyncSession = Depends(get_db), ): verify_tenant_access(tenant_id, current_user) template = await _get_template(db, tenant_id, template_id, load_items=True) _validate_data_type(body.data_type) max_order = max((i.sort_order for i in template.items), default=0) item = InspectionTemplateItem( template_id=template.id, sort_order=max_order + 1, name=body.name, category=body.category, data_type=body.data_type, unit=body.unit, spec_min=body.spec_min, spec_max=body.spec_max, warning_min=body.warning_min, warning_max=body.warning_max, trend_window=body.trend_window, select_options=body.select_options, equipment_part_id=UUID(body.equipment_part_id) if body.equipment_part_id else None, is_required=body.is_required, ) db.add(item) template.version = (template.version or 1) + 1 await db.commit() await db.refresh(item) return _item_to_dict(item) @router.put("/{template_id}/items/reorder") async def reorder_items( body: ReorderRequest, tenant_id: str = Path(...), template_id: UUID = Path(...), current_user: TokenData = Depends(require_auth), db: AsyncSession = Depends(get_db), ): verify_tenant_access(tenant_id, current_user) template = await _get_template(db, tenant_id, template_id, load_items=True) item_map = {str(i.id): i for i in template.items} if set(body.item_ids) != set(item_map.keys()): raise HTTPException( status_code=400, detail="item_ids는 해당 템플릿의 모든 항목 ID를 포함해야 합니다.", ) for idx, item_id_str in enumerate(body.item_ids): item_map[item_id_str].sort_order = idx + 1 template.version = (template.version or 1) + 1 await db.commit() db.expire_all() stmt = ( select(InspectionTemplate) .options(selectinload(InspectionTemplate.items)) .where(InspectionTemplate.id == template_id) ) result = await db.execute(stmt) refreshed = result.scalar_one() return _template_to_dict(refreshed, len(refreshed.items), include_items=True) @router.put("/{template_id}/items/{item_id}") async def update_item( body: ItemUpdate, tenant_id: str = Path(...), template_id: UUID = Path(...), item_id: UUID = Path(...), current_user: TokenData = Depends(require_auth), db: AsyncSession = Depends(get_db), ): verify_tenant_access(tenant_id, current_user) await _get_template(db, tenant_id, template_id) stmt = select(InspectionTemplateItem).where( InspectionTemplateItem.id == item_id, InspectionTemplateItem.template_id == template_id, ) result = await db.execute(stmt) item = result.scalar_one_or_none() if not item: raise HTTPException(status_code=404, detail="검사 항목을 찾을 수 없습니다.") if body.name is not None: item.name = body.name if body.category is not None: item.category = body.category if body.data_type is not None: _validate_data_type(body.data_type) item.data_type = body.data_type if body.unit is not None: item.unit = body.unit if body.spec_min is not None: item.spec_min = body.spec_min if body.spec_max is not None: item.spec_max = body.spec_max if body.warning_min is not None: item.warning_min = body.warning_min if body.warning_max is not None: item.warning_max = body.warning_max if body.trend_window is not None: item.trend_window = body.trend_window if body.select_options is not None: item.select_options = body.select_options if body.equipment_part_id is not None: item.equipment_part_id = ( UUID(body.equipment_part_id) if body.equipment_part_id else None ) if body.is_required is not None: item.is_required = body.is_required await db.commit() await db.refresh(item) return _item_to_dict(item) @router.delete("/{template_id}/items/{item_id}") async def delete_item( tenant_id: str = Path(...), template_id: UUID = Path(...), item_id: UUID = Path(...), current_user: TokenData = Depends(require_auth), db: AsyncSession = Depends(get_db), ): verify_tenant_access(tenant_id, current_user) await _get_template(db, tenant_id, template_id) stmt = select(InspectionTemplateItem).where( InspectionTemplateItem.id == item_id, InspectionTemplateItem.template_id == template_id, ) result = await db.execute(stmt) item = result.scalar_one_or_none() if not item: raise HTTPException(status_code=404, detail="검사 항목을 찾을 수 없습니다.") deleted_order = item.sort_order await db.delete(item) await db.flush() remaining_stmt = ( select(InspectionTemplateItem) .where( InspectionTemplateItem.template_id == template_id, InspectionTemplateItem.sort_order > deleted_order, ) .order_by(InspectionTemplateItem.sort_order) ) remaining_result = await db.execute(remaining_stmt) for remaining_item in remaining_result.scalars().all(): remaining_item.sort_order -= 1 template_stmt = select(InspectionTemplate).where( InspectionTemplate.id == template_id ) template = (await db.execute(template_stmt)).scalar_one() template.version = (template.version or 1) + 1 await db.commit() return {"status": "success", "message": "검사 항목이 삭제되었습니다."}