Files
factoryOps-v2/src/api/inspections.py
Johngreen 00a17c0b86
All checks were successful
Deploy to Production / deploy (push) Successful in 1m8s
feat: complete 5 GAP requirements — auto counters, inspection alarms, machine specs, responsive CSS
- GAP 2+4: auto_time/date counter auto-computation with manual update blocking
- GAP 3: auto-generate alarms on inspection completion for failed items
- GAP 1: machine spec fields (manufacturer, location, capacity, power, description)
- GAP 5: enhanced mobile responsive CSS (768px/480px breakpoints)
- Alembic migration for new columns, seed data enriched with specs and date-type parts
2026-02-10 15:45:51 +09:00

559 lines
18 KiB
Python

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": "검사가 삭제되었습니다."}