feat: add equipment area grouping, criticality, and batch inspection
All checks were successful
Deploy to Production / deploy (push) Successful in 1m7s

- Add area and criticality fields to Machine model with DB migration
- Add batch inspection endpoint (POST /inspections/batch) for area-wide inspection creation
- Rewrite MachineList with area grouping, criticality badges, and batch inspect button
- Update machine create/edit forms with area and criticality fields
- Update seed data with area/criticality values for all tenants
This commit is contained in:
Johngreen
2026-02-10 22:38:55 +09:00
parent 4c42f7aff8
commit febdbdc4f0
9 changed files with 455 additions and 43 deletions

View File

@@ -14,6 +14,7 @@ from src.database.models import (
InspectionRecord,
InspectionTemplate,
InspectionTemplateItem,
Machine,
)
from src.auth.models import TokenData
from src.auth.dependencies import require_auth, verify_tenant_access
@@ -28,6 +29,11 @@ class InspectionCreate(BaseModel):
template_id: str
class BatchInspectionCreate(BaseModel):
machine_ids: Optional[List[str]] = None
area: Optional[str] = None
class RecordInput(BaseModel):
template_item_id: str
value_numeric: Optional[float] = None
@@ -530,6 +536,103 @@ async def complete_inspection(
}
@router.post("/batch")
async def batch_create_inspections(
body: BatchInspectionCreate,
tenant_id: str = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
if not body.machine_ids and not body.area:
raise HTTPException(
status_code=400,
detail="machine_ids 또는 area 중 하나를 지정해야 합니다.",
)
machine_stmt = select(Machine).where(Machine.tenant_id == tenant_id)
if body.machine_ids:
machine_stmt = machine_stmt.where(
Machine.id.in_([UUID(mid) for mid in body.machine_ids])
)
if body.area:
machine_stmt = machine_stmt.where(Machine.area == body.area)
machines_result = await db.execute(machine_stmt)
machines = machines_result.scalars().all()
if not machines:
raise HTTPException(
status_code=404,
detail="해당하는 설비를 찾을 수 없습니다.",
)
machine_ids_set = {m.id for m in machines}
templates_stmt = (
select(InspectionTemplate)
.options(selectinload(InspectionTemplate.items))
.where(
InspectionTemplate.tenant_id == tenant_id,
InspectionTemplate.is_active == True,
InspectionTemplate.subject_type == "equipment",
InspectionTemplate.machine_id.in_(machine_ids_set),
)
)
templates_result = await db.execute(templates_stmt)
templates = templates_result.scalars().all()
if not templates:
raise HTTPException(
status_code=404,
detail="해당 설비에 연결된 활성 검사 템플릿이 없습니다.",
)
now = datetime.now(timezone.utc)
created_sessions = []
for template in templates:
if not template.items:
continue
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)
created_sessions.append(
{
"id": str(session.id),
"template_id": str(template.id),
"template_name": str(template.name),
"machine_id": str(template.machine_id) if template.machine_id else None,
"items_count": len(template.items),
}
)
await db.commit()
return {
"status": "success",
"message": f"{len(created_sessions)}건의 검사가 생성되었습니다.",
"created_count": len(created_sessions),
"sessions": created_sessions,
}
@router.delete("/{inspection_id}")
async def delete_inspection(
tenant_id: str = Path(...),

View File

@@ -21,6 +21,9 @@ from src.auth.dependencies import require_auth, verify_tenant_access
router = APIRouter(prefix="/api/{tenant_id}/machines", tags=["machines"])
VALID_CRITICALITIES = ("critical", "major", "minor")
class MachineCreate(BaseModel):
name: str
equipment_code: str = ""
@@ -28,6 +31,8 @@ class MachineCreate(BaseModel):
manufacturer: Optional[str] = None
installation_date: Optional[str] = None
location: Optional[str] = None
area: Optional[str] = None
criticality: str = "major"
rated_capacity: Optional[str] = None
power_rating: Optional[str] = None
description: Optional[str] = None
@@ -40,6 +45,8 @@ class MachineUpdate(BaseModel):
manufacturer: Optional[str] = None
installation_date: Optional[str] = None
location: Optional[str] = None
area: Optional[str] = None
criticality: Optional[str] = None
rated_capacity: Optional[str] = None
power_rating: Optional[str] = None
description: Optional[str] = None
@@ -54,6 +61,8 @@ class MachineResponse(BaseModel):
manufacturer: Optional[str] = None
installation_date: Optional[str] = None
location: Optional[str] = None
area: Optional[str] = None
criticality: Optional[str] = None
rated_capacity: Optional[str] = None
power_rating: Optional[str] = None
description: Optional[str] = None
@@ -84,6 +93,8 @@ def _machine_to_response(m: Machine, parts_count: int = 0) -> MachineResponse:
manufacturer=str(m.manufacturer) if m.manufacturer else None,
installation_date=_format_ts(m.installation_date),
location=str(m.location) if m.location else None,
area=str(m.area) if m.area else None,
criticality=str(m.criticality) if m.criticality else "major",
rated_capacity=str(m.rated_capacity) if m.rated_capacity else None,
power_rating=str(m.power_rating) if m.power_rating else None,
description=str(m.description) if m.description else None,
@@ -136,6 +147,12 @@ async def create_machine(
except ValueError:
pass
if body.criticality not in VALID_CRITICALITIES:
raise HTTPException(
status_code=400,
detail=f"criticality는 {', '.join(VALID_CRITICALITIES)} 중 하나여야 합니다.",
)
machine = Machine(
tenant_id=tenant_id,
name=body.name,
@@ -144,6 +161,8 @@ async def create_machine(
manufacturer=body.manufacturer,
installation_date=install_dt,
location=body.location,
area=body.area,
criticality=body.criticality,
rated_capacity=body.rated_capacity,
power_rating=body.power_rating,
description=body.description,
@@ -202,6 +221,8 @@ async def get_machine(
manufacturer=str(machine.manufacturer) if machine.manufacturer else None,
installation_date=_format_ts(machine.installation_date),
location=str(machine.location) if machine.location else None,
area=str(machine.area) if machine.area else None,
criticality=str(machine.criticality) if machine.criticality else "major",
rated_capacity=str(machine.rated_capacity) if machine.rated_capacity else None,
power_rating=str(machine.power_rating) if machine.power_rating else None,
description=str(machine.description) if machine.description else None,
@@ -251,6 +272,15 @@ async def update_machine(
pass
if body.location is not None:
machine.location = body.location
if body.area is not None:
machine.area = body.area
if body.criticality is not None:
if body.criticality not in VALID_CRITICALITIES:
raise HTTPException(
status_code=400,
detail=f"criticality는 {', '.join(VALID_CRITICALITIES)} 중 하나여야 합니다.",
)
machine.criticality = body.criticality
if body.rated_capacity is not None:
machine.rated_capacity = body.rated_capacity
if body.power_rating is not None:

View File

@@ -65,6 +65,8 @@ class Machine(Base):
manufacturer = Column(String(100), nullable=True)
installation_date = Column(TIMESTAMP(timezone=True), nullable=True)
location = Column(String(200), nullable=True)
area = Column(String(50), nullable=True) # 구역 그룹핑: Bay 3, A라인 등
criticality = Column(String(20), default="major") # critical | major | minor
rated_capacity = Column(String(100), nullable=True)
power_rating = Column(String(100), nullable=True)
description = Column(Text, nullable=True)
@@ -76,7 +78,11 @@ class Machine(Base):
"EquipmentPart", back_populates="machine", cascade="all, delete-orphan"
)
__table_args__ = (Index("ix_machines_tenant_id", "tenant_id"),)
__table_args__ = (
Index("ix_machines_tenant_id", "tenant_id"),
Index("ix_machines_tenant_area", "tenant_id", "area"),
Index("ix_machines_tenant_criticality", "tenant_id", "criticality"),
)
class EquipmentPart(Base):