All checks were successful
Deploy to Production / deploy (push) Successful in 1m8s
- 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
559 lines
18 KiB
Python
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": "검사가 삭제되었습니다."}
|