feat: Phase 4 — inspection sessions (create, execute, complete)
All checks were successful
Deploy to Production / deploy (push) Successful in 1m5s
All checks were successful
Deploy to Production / deploy (push) Successful in 1m5s
Backend: - InspectionSession + InspectionRecord models with alembic migration - 6 API endpoints: create, list, get detail, save records, complete, delete - Auto pass/fail judgment for numeric (spec range) and boolean items - Completed inspections are immutable, required items enforced on complete - 14 new tests (total 53/53 passed) Frontend: - Inspection list page with in_progress/completed tabs - Template select modal for starting new inspections - Inspection execution page with data-type-specific inputs - Auto-save with 1.5s debounce, manual save button - Completion modal with notes and required item validation - Read-only view for completed inspections - Pass/fail badges and color-coded item cards Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
518
src/api/inspections.py
Normal file
518
src/api/inspections.py
Normal file
@@ -0,0 +1,518 @@
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
|
||||
pass_count = sum(1 for r in session.records if r.is_pass is True)
|
||||
fail_count = 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,
|
||||
"no_judgment": total - pass_count - fail_count,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@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": "검사가 삭제되었습니다."}
|
||||
Reference in New Issue
Block a user