from typing import List, Optional from uuid import UUID from datetime import datetime, timezone from fastapi import APIRouter, HTTPException, Depends, Path, Query 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 ( InspectionSession, InspectionRecord, InspectionTemplate, InspectionTemplateItem, ) from src.auth.models import TokenData from src.auth.dependencies import require_auth, verify_tenant_access from src.api.alarms import generate_alarms_for_inspection router = APIRouter(prefix="/api/{tenant_id}/inspections", tags=["inspections"]) VALID_STATUSES = ("draft", "in_progress", "completed") class InspectionCreate(BaseModel): template_id: str class RecordInput(BaseModel): template_item_id: str value_numeric: Optional[float] = None value_boolean: Optional[bool] = None value_text: Optional[str] = None value_select: Optional[str] = None class RecordsBatchInput(BaseModel): records: List[RecordInput] class SessionNotesUpdate(BaseModel): notes: Optional[str] = None def _format_ts(val) -> Optional[str]: if val is None: return None return val.isoformat() if hasattr(val, "isoformat") else str(val) def _record_to_dict(r: InspectionRecord) -> dict: return { "id": str(r.id), "session_id": str(r.session_id), "template_item_id": str(r.template_item_id), "value_numeric": float(r.value_numeric) if r.value_numeric is not None else None, "value_boolean": r.value_boolean, "value_text": r.value_text, "value_select": r.value_select, "is_pass": r.is_pass, "recorded_at": _format_ts(r.recorded_at), "created_at": _format_ts(r.created_at), "updated_at": _format_ts(r.updated_at), } def _session_to_dict( s: InspectionSession, include_records: bool = False, template_name: Optional[str] = None, inspector_name: Optional[str] = None, items_count: int = 0, records_count: int = 0, ) -> dict: result = { "id": str(s.id), "tenant_id": str(s.tenant_id), "template_id": str(s.template_id), "inspector_id": str(s.inspector_id), "status": str(s.status), "started_at": _format_ts(s.started_at), "completed_at": _format_ts(s.completed_at), "notes": s.notes, "template_name": template_name, "inspector_name": inspector_name, "items_count": items_count, "records_count": records_count, "created_at": _format_ts(s.created_at), "updated_at": _format_ts(s.updated_at), } if include_records: result["records"] = [_record_to_dict(r) for r in (s.records or [])] return result def _evaluate_pass( record: RecordInput, item: InspectionTemplateItem, ) -> Optional[bool]: if item.data_type == "numeric" and record.value_numeric is not None: if item.spec_min is not None and record.value_numeric < item.spec_min: return False if item.spec_max is not None and record.value_numeric > item.spec_max: return False if item.spec_min is not None or item.spec_max is not None: return True return None if item.data_type == "boolean" and record.value_boolean is not None: return record.value_boolean return None @router.post("") async def create_inspection( body: InspectionCreate, tenant_id: str = Path(...), current_user: TokenData = Depends(require_auth), db: AsyncSession = Depends(get_db), ): verify_tenant_access(tenant_id, current_user) template_stmt = ( select(InspectionTemplate) .options(selectinload(InspectionTemplate.items)) .where( InspectionTemplate.id == UUID(body.template_id), InspectionTemplate.tenant_id == tenant_id, InspectionTemplate.is_active == True, ) ) result = await db.execute(template_stmt) template = result.scalar_one_or_none() if not template: raise HTTPException(status_code=404, detail="검사 템플릿을 찾을 수 없습니다.") if not template.items: raise HTTPException( status_code=400, detail="검사 항목이 없는 템플릿으로는 검사를 시작할 수 없습니다.", ) now = datetime.now(timezone.utc) session = InspectionSession( tenant_id=tenant_id, template_id=template.id, inspector_id=UUID(current_user.user_id), status="in_progress", started_at=now, ) db.add(session) await db.flush() for item in template.items: record = InspectionRecord( session_id=session.id, template_item_id=item.id, ) db.add(record) await db.commit() stmt = ( select(InspectionSession) .options(selectinload(InspectionSession.records)) .where(InspectionSession.id == session.id) ) result = await db.execute(stmt) created = result.scalar_one() from src.database.models import User inspector = ( await db.execute(select(User).where(User.id == UUID(current_user.user_id))) ).scalar_one_or_none() return _session_to_dict( created, include_records=True, template_name=str(template.name), inspector_name=str(inspector.name) if inspector else None, items_count=len(template.items), records_count=len(created.records), ) @router.get("") async def list_inspections( tenant_id: str = Path(...), status: Optional[str] = Query(None), template_id: Optional[str] = Query(None), current_user: TokenData = Depends(require_auth), db: AsyncSession = Depends(get_db), ): verify_tenant_access(tenant_id, current_user) from src.database.models import User stmt = ( select( InspectionSession, InspectionTemplate.name.label("template_name"), User.name.label("inspector_name"), func.count(InspectionTemplateItem.id).label("items_count"), func.count(InspectionRecord.recorded_at).label("filled_count"), ) .join( InspectionTemplate, InspectionSession.template_id == InspectionTemplate.id ) .join(User, InspectionSession.inspector_id == User.id) .outerjoin( InspectionTemplateItem, InspectionTemplateItem.template_id == InspectionTemplate.id, ) .outerjoin( InspectionRecord, InspectionRecord.session_id == InspectionSession.id ) .where(InspectionSession.tenant_id == tenant_id) .group_by(InspectionSession.id, InspectionTemplate.name, User.name) .order_by(InspectionSession.created_at.desc()) ) if status: if status not in VALID_STATUSES: raise HTTPException( status_code=400, detail=f"status는 {', '.join(VALID_STATUSES)} 중 하나여야 합니다.", ) stmt = stmt.where(InspectionSession.status == status) if template_id: stmt = stmt.where(InspectionSession.template_id == UUID(template_id)) result = await db.execute(stmt) rows = result.all() inspections = [] for s, t_name, i_name, i_count, f_count in rows: inspections.append( _session_to_dict( s, template_name=t_name, inspector_name=i_name, items_count=i_count, records_count=f_count, ) ) return {"inspections": inspections} @router.get("/{inspection_id}") async def get_inspection( tenant_id: str = Path(...), inspection_id: UUID = Path(...), current_user: TokenData = Depends(require_auth), db: AsyncSession = Depends(get_db), ): verify_tenant_access(tenant_id, current_user) from src.database.models import User stmt = ( select(InspectionSession) .options( selectinload(InspectionSession.records).selectinload( InspectionRecord.template_item ), ) .where( InspectionSession.id == inspection_id, InspectionSession.tenant_id == tenant_id, ) ) result = await db.execute(stmt) session = result.scalar_one_or_none() if not session: raise HTTPException(status_code=404, detail="검사를 찾을 수 없습니다.") template_stmt = select(InspectionTemplate).where( InspectionTemplate.id == session.template_id ) template = (await db.execute(template_stmt)).scalar_one_or_none() inspector_stmt = select(User).where(User.id == session.inspector_id) inspector = (await db.execute(inspector_stmt)).scalar_one_or_none() items_stmt = select(func.count(InspectionTemplateItem.id)).where( InspectionTemplateItem.template_id == session.template_id ) items_count = (await db.execute(items_stmt)).scalar() or 0 filled = sum(1 for r in session.records if r.recorded_at is not None) resp = _session_to_dict( session, include_records=True, template_name=str(template.name) if template else None, inspector_name=str(inspector.name) if inspector else None, items_count=items_count, records_count=filled, ) items_detail = [] for record in session.records: ti = record.template_item item_dict = { "record": _record_to_dict(record), "item": { "id": str(ti.id), "name": str(ti.name), "category": str(ti.category) if ti.category else None, "data_type": str(ti.data_type), "unit": str(ti.unit) if ti.unit else None, "spec_min": float(ti.spec_min) if ti.spec_min is not None else None, "spec_max": float(ti.spec_max) if ti.spec_max is not None else None, "warning_min": float(ti.warning_min) if ti.warning_min is not None else None, "warning_max": float(ti.warning_max) if ti.warning_max is not None else None, "select_options": ti.select_options, "is_required": bool(ti.is_required), "sort_order": ti.sort_order, }, } items_detail.append(item_dict) items_detail.sort(key=lambda x: x["item"]["sort_order"]) resp["items_detail"] = items_detail return resp @router.put("/{inspection_id}/records") async def save_records( body: RecordsBatchInput, tenant_id: str = Path(...), inspection_id: UUID = Path(...), current_user: TokenData = Depends(require_auth), db: AsyncSession = Depends(get_db), ): verify_tenant_access(tenant_id, current_user) session_stmt = select(InspectionSession).where( InspectionSession.id == inspection_id, InspectionSession.tenant_id == tenant_id, ) result = await db.execute(session_stmt) session = result.scalar_one_or_none() if not session: raise HTTPException(status_code=404, detail="검사를 찾을 수 없습니다.") if session.status == "completed": raise HTTPException(status_code=400, detail="완료된 검사는 수정할 수 없습니다.") item_ids = [UUID(r.template_item_id) for r in body.records] items_stmt = select(InspectionTemplateItem).where( InspectionTemplateItem.id.in_(item_ids) ) items_result = await db.execute(items_stmt) items_map = {str(i.id): i for i in items_result.scalars().all()} records_stmt = select(InspectionRecord).where( InspectionRecord.session_id == inspection_id, InspectionRecord.template_item_id.in_(item_ids), ) records_result = await db.execute(records_stmt) records_map = {str(r.template_item_id): r for r in records_result.scalars().all()} now = datetime.now(timezone.utc) updated_records = [] for record_input in body.records: item = items_map.get(record_input.template_item_id) if not item: raise HTTPException( status_code=400, detail=f"검사 항목 {record_input.template_item_id}을(를) 찾을 수 없습니다.", ) db_record = records_map.get(record_input.template_item_id) if not db_record: db_record = InspectionRecord( session_id=inspection_id, template_item_id=UUID(record_input.template_item_id), ) db.add(db_record) if record_input.value_numeric is not None: db_record.value_numeric = record_input.value_numeric if record_input.value_boolean is not None: db_record.value_boolean = record_input.value_boolean if record_input.value_text is not None: db_record.value_text = record_input.value_text if record_input.value_select is not None: db_record.value_select = record_input.value_select has_value = any( [ record_input.value_numeric is not None, record_input.value_boolean is not None, record_input.value_text is not None, record_input.value_select is not None, ] ) if has_value: db_record.recorded_at = now db_record.is_pass = _evaluate_pass(record_input, item) updated_records.append(db_record) if session.status == "draft": session.status = "in_progress" session.started_at = now await db.commit() for r in updated_records: await db.refresh(r) return {"records": [_record_to_dict(r) for r in updated_records]} @router.post("/{inspection_id}/complete") async def complete_inspection( tenant_id: str = Path(...), inspection_id: UUID = Path(...), body: Optional[SessionNotesUpdate] = None, current_user: TokenData = Depends(require_auth), db: AsyncSession = Depends(get_db), ): verify_tenant_access(tenant_id, current_user) stmt = ( select(InspectionSession) .options(selectinload(InspectionSession.records)) .where( InspectionSession.id == inspection_id, InspectionSession.tenant_id == tenant_id, ) ) result = await db.execute(stmt) session = result.scalar_one_or_none() if not session: raise HTTPException(status_code=404, detail="검사를 찾을 수 없습니다.") if session.status == "completed": raise HTTPException(status_code=400, detail="이미 완료된 검사입니다.") items_stmt = select(InspectionTemplateItem).where( InspectionTemplateItem.template_id == session.template_id, InspectionTemplateItem.is_required == True, ) required_items = (await db.execute(items_stmt)).scalars().all() required_ids = {str(i.id) for i in required_items} filled_ids = { str(r.template_item_id) for r in session.records if r.recorded_at is not None } missing = required_ids - filled_ids if missing: raise HTTPException( status_code=400, detail=f"필수 검사 항목 {len(missing)}개가 미입력 상태입니다.", ) now = datetime.now(timezone.utc) session.status = "completed" session.completed_at = now if body and body.notes is not None: session.notes = body.notes fail_records_data = [] records_with_items_stmt = ( select(InspectionRecord) .options(selectinload(InspectionRecord.template_item)) .where( InspectionRecord.session_id == inspection_id, InspectionRecord.is_pass == False, ) ) fail_result = await db.execute(records_with_items_stmt) fail_records = fail_result.scalars().all() template_stmt = select(InspectionTemplate).where( InspectionTemplate.id == session.template_id ) tmpl = (await db.execute(template_stmt)).scalar_one_or_none() template_name = str(tmpl.name) if tmpl else "알 수 없는 템플릿" for r in fail_records: ti = r.template_item item_name = str(ti.name) if ti else "?" spec_parts = [] if ti and ti.spec_min is not None: spec_parts.append(f"최소: {ti.spec_min}") if ti and ti.spec_max is not None: spec_parts.append(f"최대: {ti.spec_max}") measured = "" if r.value_numeric is not None: measured = f"측정값: {r.value_numeric}" elif r.value_boolean is not None: measured = f"측정값: {'합격' if r.value_boolean else '불합격'}" spec_info = ", ".join(filter(None, [measured] + spec_parts)) fail_records_data.append((r, item_name, spec_info)) await db.flush() await generate_alarms_for_inspection( db, tenant_id, session, fail_records_data, template_name ) await db.commit() await db.refresh(session) pass_count = sum(1 for r in session.records if r.is_pass is True) fail_count_val = sum(1 for r in session.records if r.is_pass is False) total = len(session.records) return { "status": "success", "message": "검사가 완료되었습니다.", "summary": { "total": total, "pass_count": pass_count, "fail_count": fail_count_val, "no_judgment": total - pass_count - fail_count_val, }, } @router.delete("/{inspection_id}") async def delete_inspection( tenant_id: str = Path(...), inspection_id: UUID = Path(...), current_user: TokenData = Depends(require_auth), db: AsyncSession = Depends(get_db), ): verify_tenant_access(tenant_id, current_user) stmt = select(InspectionSession).where( InspectionSession.id == inspection_id, InspectionSession.tenant_id == tenant_id, ) result = await db.execute(stmt) session = result.scalar_one_or_none() if not session: raise HTTPException(status_code=404, detail="검사를 찾을 수 없습니다.") if session.status != "draft": raise HTTPException( status_code=400, detail="진행 중이거나 완료된 검사는 삭제할 수 없습니다." ) await db.delete(session) await db.commit() return {"status": "success", "message": "검사가 삭제되었습니다."}