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

@@ -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")

View File

@@ -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}
/>
</div>
<div className="form-field">
<label> (Area)</label>
<input
type="text"
placeholder="예: Bay 3, A라인"
value={form.area}
onChange={(e) => setForm((prev) => ({ ...prev, area: e.target.value }))}
disabled={submitting}
/>
</div>
<div className="form-field">
<label></label>
<select
value={form.criticality}
onChange={(e) => setForm((prev) => ({ ...prev, criticality: e.target.value }))}
disabled={submitting}
>
<option value="critical">Critical ()</option>
<option value="major">Major ()</option>
<option value="minor">Minor ()</option>
</select>
</div>
<div className="modal-actions">
<button type="button" className="btn-outline" onClick={closeModal} disabled={submitting}>

View File

@@ -2416,3 +2416,79 @@ a {
grid-template-columns: 1fr;
}
}
/* ===== Machine Grouping ===== */
.machine-list-container {
display: flex;
flex-direction: column;
gap: 32px;
}
.machine-group {
display: flex;
flex-direction: column;
gap: 16px;
}
.machine-group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 8px;
border-bottom: 1px solid var(--md-outline-variant);
}
.machine-group-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 500;
color: var(--md-on-surface);
margin: 0;
}
.machine-group-title .material-symbols-outlined {
color: var(--md-primary);
font-size: 24px;
}
.machine-count {
font-size: 14px;
color: var(--md-on-surface-variant);
font-weight: 400;
}
.machine-card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.machine-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: var(--md-radius-sm);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
}
.machine-badge-critical {
background: rgba(217, 48, 37, 0.1);
color: var(--md-error);
border: 1px solid rgba(217, 48, 37, 0.2);
}
.machine-badge-major {
background: rgba(249, 171, 0, 0.1);
color: #e65100;
border: 1px solid rgba(249, 171, 0, 0.2);
}
.machine-badge-minor {
background: var(--md-surface-container-highest);
color: var(--md-on-surface-variant);
border: 1px solid var(--md-outline-variant);
}

View File

@@ -1,61 +1,156 @@
'use client';
import { useRouter } from 'next/navigation';
import type { Machine } from '@/lib/types';
import type { Machine, Criticality } from '@/lib/types';
interface MachineListProps {
machines: Machine[];
tenantId: string;
onEdit: (machine: Machine) => void;
onDelete: (machine: Machine) => void;
onBatchInspect?: (area: string, machineIds: string[]) => void;
}
export function MachineList({ machines, tenantId, onEdit, onDelete }: MachineListProps) {
const CRITICALITY_ORDER: Record<string, number> = {
critical: 0,
major: 1,
minor: 2,
};
const getCriticalityScore = (c: Criticality | null) => {
if (!c) return 3;
return CRITICALITY_ORDER[c] ?? 3;
};
export function MachineList({ machines, tenantId, onEdit, onDelete, onBatchInspect }: MachineListProps) {
const router = useRouter();
const groupedMachines = machines.reduce((acc, machine) => {
const area = machine.area || '미지정';
if (!acc[area]) {
acc[area] = [];
}
acc[area].push(machine);
return acc;
}, {} as Record<string, Machine[]>);
const sortedAreas = Object.keys(groupedMachines).sort((a, b) => {
const machinesA = groupedMachines[a];
const machinesB = groupedMachines[b];
const hasCriticalA = machinesA.some(m => m.criticality === 'critical');
const hasCriticalB = machinesB.some(m => m.criticality === 'critical');
if (hasCriticalA && !hasCriticalB) return -1;
if (!hasCriticalA && hasCriticalB) return 1;
return a.localeCompare(b);
});
sortedAreas.forEach(area => {
groupedMachines[area].sort((a, b) => {
const scoreA = getCriticalityScore(a.criticality);
const scoreB = getCriticalityScore(b.criticality);
return scoreA - scoreB;
});
});
const getBadgeClass = (criticality: Criticality | null) => {
switch (criticality) {
case 'critical': return 'machine-badge-critical';
case 'major': return 'machine-badge-major';
case 'minor': return 'machine-badge-minor';
default: return null;
}
};
const getBadgeText = (criticality: Criticality | null) => {
switch (criticality) {
case 'critical': return '핵심';
case 'major': return '주요';
case 'minor': return '보조';
default: return null;
}
};
return (
<div className="machine-grid">
{machines.map((machine) => (
<div key={machine.id} className="machine-card">
<div
className="machine-card-body"
onClick={() => router.push(`/${tenantId}/machines/${machine.id}`)}
>
<div className="machine-card-icon">
<span className="material-symbols-outlined">precision_manufacturing</span>
</div>
<div className="machine-card-info">
<h3 className="machine-card-name">{machine.name}</h3>
<span className="machine-card-code">{machine.equipment_code}</span>
{machine.model && (
<span className="machine-card-model">{machine.model}</span>
)}
</div>
<div className="machine-card-meta">
<div className="machine-card-parts">
<span className="material-symbols-outlined">settings</span>
<span> {machine.parts_count}</span>
</div>
</div>
<div className="machine-list-container">
{sortedAreas.map(area => (
<div key={area} className="machine-group">
<div className="machine-group-header">
<h2 className="machine-group-title">
<span className="material-symbols-outlined">location_on</span>
{area} <span className="machine-count">({groupedMachines[area].length})</span>
</h2>
{onBatchInspect && (
<button
className="btn-outline btn-sm"
onClick={() => onBatchInspect(area, groupedMachines[area].map(m => m.id))}
>
<span className="material-symbols-outlined">playlist_add_check</span>
</button>
)}
</div>
<div className="machine-card-actions">
<button
className="btn-icon"
onClick={(e) => { e.stopPropagation(); onEdit(machine); }}
title="수정"
>
<span className="material-symbols-outlined">edit</span>
</button>
<button
className="btn-icon btn-icon-danger"
onClick={(e) => { e.stopPropagation(); onDelete(machine); }}
title="삭제"
>
<span className="material-symbols-outlined">delete</span>
</button>
<div className="machine-grid">
{groupedMachines[area].map((machine) => (
<div key={machine.id} className="machine-card">
<div
className="machine-card-body"
onClick={() => router.push(`/${tenantId}/machines/${machine.id}`)}
>
<div className="machine-card-top">
<div className="machine-card-icon">
<span className="material-symbols-outlined">precision_manufacturing</span>
</div>
{machine.criticality && (
<span className={`machine-badge ${getBadgeClass(machine.criticality)}`}>
{getBadgeText(machine.criticality)}
</span>
)}
</div>
<div className="machine-card-info">
<h3 className="machine-card-name">{machine.name}</h3>
<span className="machine-card-code">{machine.equipment_code}</span>
{machine.model && (
<span className="machine-card-model">{machine.model}</span>
)}
</div>
<div className="machine-card-meta">
<div className="machine-card-parts">
<span className="material-symbols-outlined">settings</span>
<span> {machine.parts_count}</span>
</div>
</div>
</div>
<div className="machine-card-actions">
<button
className="btn-icon"
onClick={(e) => { e.stopPropagation(); onEdit(machine); }}
title="수정"
>
<span className="material-symbols-outlined">edit</span>
</button>
<button
className="btn-icon btn-icon-danger"
onClick={(e) => { e.stopPropagation(); onDelete(machine); }}
title="삭제"
>
<span className="material-symbols-outlined">delete</span>
</button>
</div>
</div>
))}
</div>
</div>
))}
{sortedAreas.length === 0 && (
<div className="empty-state">
<span className="material-symbols-outlined">precision_manufacturing</span>
<p> .</p>
</div>
)}
</div>
);
}

View File

@@ -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;

View File

@@ -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",
},

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):