+ {sortedAreas.map(area => (
+
+
+
+ location_on
+ {area} ({groupedMachines[area].length}대)
+
+ {onBatchInspect && (
+
+ )}
-
-
-
+
+ {groupedMachines[area].map((machine) => (
+
+
router.push(`/${tenantId}/machines/${machine.id}`)}
+ >
+
+
+ precision_manufacturing
+
+ {machine.criticality && (
+
+ {getBadgeText(machine.criticality)}
+
+ )}
+
+
+
+
{machine.name}
+ {machine.equipment_code}
+ {machine.model && (
+ {machine.model}
+ )}
+
+
+
+ settings
+ 부품 {machine.parts_count}개
+
+
+
+
+
+
+
+
+ ))}
))}
+ {sortedAreas.length === 0 && (
+
+
precision_manufacturing
+
등록된 설비가 없습니다.
+
+ )}
);
}
diff --git a/dashboard/lib/types.ts b/dashboard/lib/types.ts
index 126bb62..c118d74 100644
--- a/dashboard/lib/types.ts
+++ b/dashboard/lib/types.ts
@@ -21,6 +21,8 @@ export interface Token {
user: User;
}
+export type Criticality = 'critical' | 'major' | 'minor';
+
export interface Machine {
id: string;
tenant_id: string;
@@ -30,6 +32,8 @@ export interface Machine {
manufacturer: string | null;
installation_date: string | null;
location: string | null;
+ area: string | null;
+ criticality: Criticality | null;
rated_capacity: string | null;
power_rating: string | null;
description: string | null;
diff --git a/scripts/seed.py b/scripts/seed.py
index 2a756df..16aad63 100644
--- a/scripts/seed.py
+++ b/scripts/seed.py
@@ -80,6 +80,8 @@ MACHINES = {
"model": "AMAT Centura 5200",
"manufacturer": "Applied Materials",
"location": "클린룸 Bay 3",
+ "area": "Bay 3",
+ "criticality": "critical",
"rated_capacity": "200mm 웨이퍼",
"power_rating": "30kW",
"description": "화학기상증착 장비. SiO2, Si3N4 박막 증착용.",
@@ -90,6 +92,8 @@ MACHINES = {
"model": "LAM 9600",
"manufacturer": "Lam Research",
"location": "클린룸 Bay 5",
+ "area": "Bay 5",
+ "criticality": "critical",
"rated_capacity": "200mm 웨이퍼",
"power_rating": "25kW",
"description": "건식 식각 장비. Poly-Si, Metal 식각용.",
@@ -100,6 +104,8 @@ MACHINES = {
"model": "Ulvac SH-450",
"manufacturer": "ULVAC",
"location": "클린룸 Bay 7",
+ "area": "Bay 7",
+ "criticality": "major",
"rated_capacity": "Ti/TiN 300mm",
"power_rating": "20kW",
},
@@ -111,6 +117,8 @@ MACHINES = {
"model": "두산 LYNX 220",
"manufacturer": "두산공작기계",
"location": "1공장 A라인",
+ "area": "A라인",
+ "criticality": "major",
"rated_capacity": "최대 가공경 220mm",
"power_rating": "15kW (주축 모터)",
"description": "2축 CNC 선반. 알루미늄/스틸 가공용.",
@@ -121,6 +129,8 @@ MACHINES = {
"model": "현대위아 HF-500",
"manufacturer": "현대위아",
"location": "1공장 B라인",
+ "area": "B라인",
+ "criticality": "critical",
"rated_capacity": "500톤",
"power_rating": "37kW",
},
@@ -130,6 +140,8 @@ MACHINES = {
"model": "화낙 ARC Mate 120",
"manufacturer": "FANUC",
"location": "2공장 용접 셀 1",
+ "area": "용접 셀",
+ "criticality": "major",
"rated_capacity": "6축 120kg 가반하중",
"power_rating": "6kVA",
},
@@ -141,6 +153,8 @@ MACHINES = {
"model": "Custom Reactor 5000L",
"manufacturer": "한화솔루션 엔지니어링",
"location": "1플랜트 반응 구역",
+ "area": "반응 구역",
+ "criticality": "critical",
"rated_capacity": "5,000L (SUS316L)",
"power_rating": "22kW (교반기)",
"description": "교반식 배치 반응기. HCl 합성 공정.",
@@ -151,6 +165,8 @@ MACHINES = {
"model": "Sulzer Packed Tower",
"manufacturer": "Sulzer",
"location": "1플랜트 분리 구역",
+ "area": "분리 구역",
+ "criticality": "critical",
"rated_capacity": "처리량 2,000 kg/h",
"power_rating": "리보일러 150kW",
},
@@ -160,6 +176,8 @@ MACHINES = {
"model": "Alfa Laval M10-BW",
"manufacturer": "Alfa Laval",
"location": "1플랜트 유틸리티",
+ "area": "유틸리티",
+ "criticality": "minor",
"rated_capacity": "열전달 500kW",
"power_rating": "펌프 7.5kW",
},
diff --git a/src/api/inspections.py b/src/api/inspections.py
index 8234a9f..3d7cf20 100644
--- a/src/api/inspections.py
+++ b/src/api/inspections.py
@@ -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(...),
diff --git a/src/api/machines.py b/src/api/machines.py
index cb2ef2a..6b874e6 100644
--- a/src/api/machines.py
+++ b/src/api/machines.py
@@ -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:
diff --git a/src/database/models.py b/src/database/models.py
index 0202199..0295e00 100644
--- a/src/database/models.py
+++ b/src/database/models.py
@@ -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):