diff --git a/alembic/versions/e7f8a9b0c1d2_add_machine_area_criticality.py b/alembic/versions/e7f8a9b0c1d2_add_machine_area_criticality.py new file mode 100644 index 0000000..35ba1a2 --- /dev/null +++ b/alembic/versions/e7f8a9b0c1d2_add_machine_area_criticality.py @@ -0,0 +1,36 @@ +"""add machine area and criticality fields + +Revision ID: e7f8a9b0c1d2 +Revises: d6e7f8a9b0c1 +Create Date: 2026-02-10 18:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "e7f8a9b0c1d2" +down_revision: Union[str, None] = "d6e7f8a9b0c1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("machines", sa.Column("area", sa.String(50), nullable=True)) + op.add_column( + "machines", + sa.Column("criticality", sa.String(20), nullable=True, server_default="major"), + ) + op.create_index("ix_machines_tenant_area", "machines", ["tenant_id", "area"]) + op.create_index( + "ix_machines_tenant_criticality", "machines", ["tenant_id", "criticality"] + ) + + +def downgrade() -> None: + op.drop_index("ix_machines_tenant_criticality", table_name="machines") + op.drop_index("ix_machines_tenant_area", table_name="machines") + op.drop_column("machines", "criticality") + op.drop_column("machines", "area") diff --git a/dashboard/app/[tenant]/page.tsx b/dashboard/app/[tenant]/page.tsx index 56d3873..88e514a 100644 --- a/dashboard/app/[tenant]/page.tsx +++ b/dashboard/app/[tenant]/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useCallback } from 'react'; -import { useParams } from 'next/navigation'; +import { useParams, useRouter } from 'next/navigation'; import { useMachines } from '@/lib/hooks'; import { useToast } from '@/lib/toast-context'; import { api } from '@/lib/api'; @@ -12,12 +12,15 @@ interface MachineForm { name: string; equipment_code: string; model: string; + area: string; + criticality: string; } -const INITIAL_FORM: MachineForm = { name: '', equipment_code: '', model: '' }; +const INITIAL_FORM: MachineForm = { name: '', equipment_code: '', model: '', area: '', criticality: 'major' }; export default function TenantDashboard() { const params = useParams(); + const router = useRouter(); const tenantId = params?.tenant as string; const { machines, isLoading, error, mutate } = useMachines(tenantId); const { addToast } = useToast(); @@ -39,6 +42,8 @@ export default function TenantDashboard() { name: machine.name, equipment_code: machine.equipment_code, model: machine.model || '', + area: machine.area || '', + criticality: machine.criticality || 'major', }); setShowModal(true); }, []); @@ -59,6 +64,8 @@ export default function TenantDashboard() { name: form.name.trim(), equipment_code: form.equipment_code.trim(), model: form.model.trim() || null, + area: form.area.trim() || null, + criticality: form.criticality, }; if (editTarget) { @@ -77,6 +84,20 @@ export default function TenantDashboard() { } }, [form, tenantId, editTarget, mutate, closeModal, addToast]); + const handleBatchInspect = useCallback(async (area: string, machineIds: string[]) => { + if (!confirm(`"${area}" 구역의 설비 ${machineIds.length}대에 대해 일괄 검사를 시작하시겠습니까?`)) return; + try { + const result = await api.post<{ created_count: number; message: string }>( + `/api/${tenantId}/inspections/batch`, + { machine_ids: machineIds, area }, + ); + addToast(result.message, 'success'); + router.push(`/${tenantId}/inspections`); + } catch { + addToast('일괄 검사 생성에 실패했습니다. 해당 설비에 연결된 검사 템플릿이 있는지 확인해주세요.', 'error'); + } + }, [tenantId, addToast, router]); + const handleDelete = useCallback(async (machine: Machine) => { if (!confirm(`"${machine.name}" 설비를 삭제하시겠습니까?`)) return; try { @@ -129,6 +150,7 @@ export default function TenantDashboard() { tenantId={tenantId} onEdit={openEdit} onDelete={handleDelete} + onBatchInspect={handleBatchInspect} /> )} @@ -173,6 +195,28 @@ export default function TenantDashboard() { disabled={submitting} /> +
+ + setForm((prev) => ({ ...prev, area: e.target.value }))} + disabled={submitting} + /> +
+
+ + +
+ )}
-
- - +
+ {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):