feat: add equipment area grouping, criticality, and batch inspection
All checks were successful
Deploy to Production / deploy (push) Successful in 1m7s
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:
@@ -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")
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useMachines } from '@/lib/hooks';
|
import { useMachines } from '@/lib/hooks';
|
||||||
import { useToast } from '@/lib/toast-context';
|
import { useToast } from '@/lib/toast-context';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
@@ -12,12 +12,15 @@ interface MachineForm {
|
|||||||
name: string;
|
name: string;
|
||||||
equipment_code: string;
|
equipment_code: string;
|
||||||
model: 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() {
|
export default function TenantDashboard() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
const tenantId = params?.tenant as string;
|
const tenantId = params?.tenant as string;
|
||||||
const { machines, isLoading, error, mutate } = useMachines(tenantId);
|
const { machines, isLoading, error, mutate } = useMachines(tenantId);
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
@@ -39,6 +42,8 @@ export default function TenantDashboard() {
|
|||||||
name: machine.name,
|
name: machine.name,
|
||||||
equipment_code: machine.equipment_code,
|
equipment_code: machine.equipment_code,
|
||||||
model: machine.model || '',
|
model: machine.model || '',
|
||||||
|
area: machine.area || '',
|
||||||
|
criticality: machine.criticality || 'major',
|
||||||
});
|
});
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -59,6 +64,8 @@ export default function TenantDashboard() {
|
|||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
equipment_code: form.equipment_code.trim(),
|
equipment_code: form.equipment_code.trim(),
|
||||||
model: form.model.trim() || null,
|
model: form.model.trim() || null,
|
||||||
|
area: form.area.trim() || null,
|
||||||
|
criticality: form.criticality,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editTarget) {
|
if (editTarget) {
|
||||||
@@ -77,6 +84,20 @@ export default function TenantDashboard() {
|
|||||||
}
|
}
|
||||||
}, [form, tenantId, editTarget, mutate, closeModal, addToast]);
|
}, [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) => {
|
const handleDelete = useCallback(async (machine: Machine) => {
|
||||||
if (!confirm(`"${machine.name}" 설비를 삭제하시겠습니까?`)) return;
|
if (!confirm(`"${machine.name}" 설비를 삭제하시겠습니까?`)) return;
|
||||||
try {
|
try {
|
||||||
@@ -129,6 +150,7 @@ export default function TenantDashboard() {
|
|||||||
tenantId={tenantId}
|
tenantId={tenantId}
|
||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onBatchInspect={handleBatchInspect}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -173,6 +195,28 @@ export default function TenantDashboard() {
|
|||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="modal-actions">
|
||||||
<button type="button" className="btn-outline" onClick={closeModal} disabled={submitting}>
|
<button type="button" className="btn-outline" onClick={closeModal} disabled={submitting}>
|
||||||
취소
|
취소
|
||||||
|
|||||||
@@ -2416,3 +2416,79 @@ a {
|
|||||||
grid-template-columns: 1fr;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,61 +1,156 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import type { Machine } from '@/lib/types';
|
import type { Machine, Criticality } from '@/lib/types';
|
||||||
|
|
||||||
interface MachineListProps {
|
interface MachineListProps {
|
||||||
machines: Machine[];
|
machines: Machine[];
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
onEdit: (machine: Machine) => void;
|
onEdit: (machine: Machine) => void;
|
||||||
onDelete: (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 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 (
|
return (
|
||||||
<div className="machine-grid">
|
<div className="machine-list-container">
|
||||||
{machines.map((machine) => (
|
{sortedAreas.map(area => (
|
||||||
<div key={machine.id} className="machine-card">
|
<div key={area} className="machine-group">
|
||||||
<div
|
<div className="machine-group-header">
|
||||||
className="machine-card-body"
|
<h2 className="machine-group-title">
|
||||||
onClick={() => router.push(`/${tenantId}/machines/${machine.id}`)}
|
<span className="material-symbols-outlined">location_on</span>
|
||||||
>
|
{area} <span className="machine-count">({groupedMachines[area].length}대)</span>
|
||||||
<div className="machine-card-icon">
|
</h2>
|
||||||
<span className="material-symbols-outlined">precision_manufacturing</span>
|
{onBatchInspect && (
|
||||||
</div>
|
<button
|
||||||
<div className="machine-card-info">
|
className="btn-outline btn-sm"
|
||||||
<h3 className="machine-card-name">{machine.name}</h3>
|
onClick={() => onBatchInspect(area, groupedMachines[area].map(m => m.id))}
|
||||||
<span className="machine-card-code">{machine.equipment_code}</span>
|
>
|
||||||
{machine.model && (
|
<span className="material-symbols-outlined">playlist_add_check</span>
|
||||||
<span className="machine-card-model">{machine.model}</span>
|
구역 전체 검사
|
||||||
)}
|
</button>
|
||||||
</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>
|
||||||
<div className="machine-card-actions">
|
<div className="machine-grid">
|
||||||
<button
|
{groupedMachines[area].map((machine) => (
|
||||||
className="btn-icon"
|
<div key={machine.id} className="machine-card">
|
||||||
onClick={(e) => { e.stopPropagation(); onEdit(machine); }}
|
<div
|
||||||
title="수정"
|
className="machine-card-body"
|
||||||
>
|
onClick={() => router.push(`/${tenantId}/machines/${machine.id}`)}
|
||||||
<span className="material-symbols-outlined">edit</span>
|
>
|
||||||
</button>
|
<div className="machine-card-top">
|
||||||
<button
|
<div className="machine-card-icon">
|
||||||
className="btn-icon btn-icon-danger"
|
<span className="material-symbols-outlined">precision_manufacturing</span>
|
||||||
onClick={(e) => { e.stopPropagation(); onDelete(machine); }}
|
</div>
|
||||||
title="삭제"
|
{machine.criticality && (
|
||||||
>
|
<span className={`machine-badge ${getBadgeClass(machine.criticality)}`}>
|
||||||
<span className="material-symbols-outlined">delete</span>
|
{getBadgeText(machine.criticality)}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{sortedAreas.length === 0 && (
|
||||||
|
<div className="empty-state">
|
||||||
|
<span className="material-symbols-outlined">precision_manufacturing</span>
|
||||||
|
<p>등록된 설비가 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export interface Token {
|
|||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Criticality = 'critical' | 'major' | 'minor';
|
||||||
|
|
||||||
export interface Machine {
|
export interface Machine {
|
||||||
id: string;
|
id: string;
|
||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
@@ -30,6 +32,8 @@ export interface Machine {
|
|||||||
manufacturer: string | null;
|
manufacturer: string | null;
|
||||||
installation_date: string | null;
|
installation_date: string | null;
|
||||||
location: string | null;
|
location: string | null;
|
||||||
|
area: string | null;
|
||||||
|
criticality: Criticality | null;
|
||||||
rated_capacity: string | null;
|
rated_capacity: string | null;
|
||||||
power_rating: string | null;
|
power_rating: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ MACHINES = {
|
|||||||
"model": "AMAT Centura 5200",
|
"model": "AMAT Centura 5200",
|
||||||
"manufacturer": "Applied Materials",
|
"manufacturer": "Applied Materials",
|
||||||
"location": "클린룸 Bay 3",
|
"location": "클린룸 Bay 3",
|
||||||
|
"area": "Bay 3",
|
||||||
|
"criticality": "critical",
|
||||||
"rated_capacity": "200mm 웨이퍼",
|
"rated_capacity": "200mm 웨이퍼",
|
||||||
"power_rating": "30kW",
|
"power_rating": "30kW",
|
||||||
"description": "화학기상증착 장비. SiO2, Si3N4 박막 증착용.",
|
"description": "화학기상증착 장비. SiO2, Si3N4 박막 증착용.",
|
||||||
@@ -90,6 +92,8 @@ MACHINES = {
|
|||||||
"model": "LAM 9600",
|
"model": "LAM 9600",
|
||||||
"manufacturer": "Lam Research",
|
"manufacturer": "Lam Research",
|
||||||
"location": "클린룸 Bay 5",
|
"location": "클린룸 Bay 5",
|
||||||
|
"area": "Bay 5",
|
||||||
|
"criticality": "critical",
|
||||||
"rated_capacity": "200mm 웨이퍼",
|
"rated_capacity": "200mm 웨이퍼",
|
||||||
"power_rating": "25kW",
|
"power_rating": "25kW",
|
||||||
"description": "건식 식각 장비. Poly-Si, Metal 식각용.",
|
"description": "건식 식각 장비. Poly-Si, Metal 식각용.",
|
||||||
@@ -100,6 +104,8 @@ MACHINES = {
|
|||||||
"model": "Ulvac SH-450",
|
"model": "Ulvac SH-450",
|
||||||
"manufacturer": "ULVAC",
|
"manufacturer": "ULVAC",
|
||||||
"location": "클린룸 Bay 7",
|
"location": "클린룸 Bay 7",
|
||||||
|
"area": "Bay 7",
|
||||||
|
"criticality": "major",
|
||||||
"rated_capacity": "Ti/TiN 300mm",
|
"rated_capacity": "Ti/TiN 300mm",
|
||||||
"power_rating": "20kW",
|
"power_rating": "20kW",
|
||||||
},
|
},
|
||||||
@@ -111,6 +117,8 @@ MACHINES = {
|
|||||||
"model": "두산 LYNX 220",
|
"model": "두산 LYNX 220",
|
||||||
"manufacturer": "두산공작기계",
|
"manufacturer": "두산공작기계",
|
||||||
"location": "1공장 A라인",
|
"location": "1공장 A라인",
|
||||||
|
"area": "A라인",
|
||||||
|
"criticality": "major",
|
||||||
"rated_capacity": "최대 가공경 220mm",
|
"rated_capacity": "최대 가공경 220mm",
|
||||||
"power_rating": "15kW (주축 모터)",
|
"power_rating": "15kW (주축 모터)",
|
||||||
"description": "2축 CNC 선반. 알루미늄/스틸 가공용.",
|
"description": "2축 CNC 선반. 알루미늄/스틸 가공용.",
|
||||||
@@ -121,6 +129,8 @@ MACHINES = {
|
|||||||
"model": "현대위아 HF-500",
|
"model": "현대위아 HF-500",
|
||||||
"manufacturer": "현대위아",
|
"manufacturer": "현대위아",
|
||||||
"location": "1공장 B라인",
|
"location": "1공장 B라인",
|
||||||
|
"area": "B라인",
|
||||||
|
"criticality": "critical",
|
||||||
"rated_capacity": "500톤",
|
"rated_capacity": "500톤",
|
||||||
"power_rating": "37kW",
|
"power_rating": "37kW",
|
||||||
},
|
},
|
||||||
@@ -130,6 +140,8 @@ MACHINES = {
|
|||||||
"model": "화낙 ARC Mate 120",
|
"model": "화낙 ARC Mate 120",
|
||||||
"manufacturer": "FANUC",
|
"manufacturer": "FANUC",
|
||||||
"location": "2공장 용접 셀 1",
|
"location": "2공장 용접 셀 1",
|
||||||
|
"area": "용접 셀",
|
||||||
|
"criticality": "major",
|
||||||
"rated_capacity": "6축 120kg 가반하중",
|
"rated_capacity": "6축 120kg 가반하중",
|
||||||
"power_rating": "6kVA",
|
"power_rating": "6kVA",
|
||||||
},
|
},
|
||||||
@@ -141,6 +153,8 @@ MACHINES = {
|
|||||||
"model": "Custom Reactor 5000L",
|
"model": "Custom Reactor 5000L",
|
||||||
"manufacturer": "한화솔루션 엔지니어링",
|
"manufacturer": "한화솔루션 엔지니어링",
|
||||||
"location": "1플랜트 반응 구역",
|
"location": "1플랜트 반응 구역",
|
||||||
|
"area": "반응 구역",
|
||||||
|
"criticality": "critical",
|
||||||
"rated_capacity": "5,000L (SUS316L)",
|
"rated_capacity": "5,000L (SUS316L)",
|
||||||
"power_rating": "22kW (교반기)",
|
"power_rating": "22kW (교반기)",
|
||||||
"description": "교반식 배치 반응기. HCl 합성 공정.",
|
"description": "교반식 배치 반응기. HCl 합성 공정.",
|
||||||
@@ -151,6 +165,8 @@ MACHINES = {
|
|||||||
"model": "Sulzer Packed Tower",
|
"model": "Sulzer Packed Tower",
|
||||||
"manufacturer": "Sulzer",
|
"manufacturer": "Sulzer",
|
||||||
"location": "1플랜트 분리 구역",
|
"location": "1플랜트 분리 구역",
|
||||||
|
"area": "분리 구역",
|
||||||
|
"criticality": "critical",
|
||||||
"rated_capacity": "처리량 2,000 kg/h",
|
"rated_capacity": "처리량 2,000 kg/h",
|
||||||
"power_rating": "리보일러 150kW",
|
"power_rating": "리보일러 150kW",
|
||||||
},
|
},
|
||||||
@@ -160,6 +176,8 @@ MACHINES = {
|
|||||||
"model": "Alfa Laval M10-BW",
|
"model": "Alfa Laval M10-BW",
|
||||||
"manufacturer": "Alfa Laval",
|
"manufacturer": "Alfa Laval",
|
||||||
"location": "1플랜트 유틸리티",
|
"location": "1플랜트 유틸리티",
|
||||||
|
"area": "유틸리티",
|
||||||
|
"criticality": "minor",
|
||||||
"rated_capacity": "열전달 500kW",
|
"rated_capacity": "열전달 500kW",
|
||||||
"power_rating": "펌프 7.5kW",
|
"power_rating": "펌프 7.5kW",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from src.database.models import (
|
|||||||
InspectionRecord,
|
InspectionRecord,
|
||||||
InspectionTemplate,
|
InspectionTemplate,
|
||||||
InspectionTemplateItem,
|
InspectionTemplateItem,
|
||||||
|
Machine,
|
||||||
)
|
)
|
||||||
from src.auth.models import TokenData
|
from src.auth.models import TokenData
|
||||||
from src.auth.dependencies import require_auth, verify_tenant_access
|
from src.auth.dependencies import require_auth, verify_tenant_access
|
||||||
@@ -28,6 +29,11 @@ class InspectionCreate(BaseModel):
|
|||||||
template_id: str
|
template_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class BatchInspectionCreate(BaseModel):
|
||||||
|
machine_ids: Optional[List[str]] = None
|
||||||
|
area: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class RecordInput(BaseModel):
|
class RecordInput(BaseModel):
|
||||||
template_item_id: str
|
template_item_id: str
|
||||||
value_numeric: Optional[float] = None
|
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}")
|
@router.delete("/{inspection_id}")
|
||||||
async def delete_inspection(
|
async def delete_inspection(
|
||||||
tenant_id: str = Path(...),
|
tenant_id: str = Path(...),
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ from src.auth.dependencies import require_auth, verify_tenant_access
|
|||||||
router = APIRouter(prefix="/api/{tenant_id}/machines", tags=["machines"])
|
router = APIRouter(prefix="/api/{tenant_id}/machines", tags=["machines"])
|
||||||
|
|
||||||
|
|
||||||
|
VALID_CRITICALITIES = ("critical", "major", "minor")
|
||||||
|
|
||||||
|
|
||||||
class MachineCreate(BaseModel):
|
class MachineCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
equipment_code: str = ""
|
equipment_code: str = ""
|
||||||
@@ -28,6 +31,8 @@ class MachineCreate(BaseModel):
|
|||||||
manufacturer: Optional[str] = None
|
manufacturer: Optional[str] = None
|
||||||
installation_date: Optional[str] = None
|
installation_date: Optional[str] = None
|
||||||
location: Optional[str] = None
|
location: Optional[str] = None
|
||||||
|
area: Optional[str] = None
|
||||||
|
criticality: str = "major"
|
||||||
rated_capacity: Optional[str] = None
|
rated_capacity: Optional[str] = None
|
||||||
power_rating: Optional[str] = None
|
power_rating: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
@@ -40,6 +45,8 @@ class MachineUpdate(BaseModel):
|
|||||||
manufacturer: Optional[str] = None
|
manufacturer: Optional[str] = None
|
||||||
installation_date: Optional[str] = None
|
installation_date: Optional[str] = None
|
||||||
location: Optional[str] = None
|
location: Optional[str] = None
|
||||||
|
area: Optional[str] = None
|
||||||
|
criticality: Optional[str] = None
|
||||||
rated_capacity: Optional[str] = None
|
rated_capacity: Optional[str] = None
|
||||||
power_rating: Optional[str] = None
|
power_rating: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
@@ -54,6 +61,8 @@ class MachineResponse(BaseModel):
|
|||||||
manufacturer: Optional[str] = None
|
manufacturer: Optional[str] = None
|
||||||
installation_date: Optional[str] = None
|
installation_date: Optional[str] = None
|
||||||
location: Optional[str] = None
|
location: Optional[str] = None
|
||||||
|
area: Optional[str] = None
|
||||||
|
criticality: Optional[str] = None
|
||||||
rated_capacity: Optional[str] = None
|
rated_capacity: Optional[str] = None
|
||||||
power_rating: Optional[str] = None
|
power_rating: Optional[str] = None
|
||||||
description: 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,
|
manufacturer=str(m.manufacturer) if m.manufacturer else None,
|
||||||
installation_date=_format_ts(m.installation_date),
|
installation_date=_format_ts(m.installation_date),
|
||||||
location=str(m.location) if m.location else None,
|
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,
|
rated_capacity=str(m.rated_capacity) if m.rated_capacity else None,
|
||||||
power_rating=str(m.power_rating) if m.power_rating else None,
|
power_rating=str(m.power_rating) if m.power_rating else None,
|
||||||
description=str(m.description) if m.description else None,
|
description=str(m.description) if m.description else None,
|
||||||
@@ -136,6 +147,12 @@ async def create_machine(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if body.criticality not in VALID_CRITICALITIES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"criticality는 {', '.join(VALID_CRITICALITIES)} 중 하나여야 합니다.",
|
||||||
|
)
|
||||||
|
|
||||||
machine = Machine(
|
machine = Machine(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
name=body.name,
|
name=body.name,
|
||||||
@@ -144,6 +161,8 @@ async def create_machine(
|
|||||||
manufacturer=body.manufacturer,
|
manufacturer=body.manufacturer,
|
||||||
installation_date=install_dt,
|
installation_date=install_dt,
|
||||||
location=body.location,
|
location=body.location,
|
||||||
|
area=body.area,
|
||||||
|
criticality=body.criticality,
|
||||||
rated_capacity=body.rated_capacity,
|
rated_capacity=body.rated_capacity,
|
||||||
power_rating=body.power_rating,
|
power_rating=body.power_rating,
|
||||||
description=body.description,
|
description=body.description,
|
||||||
@@ -202,6 +221,8 @@ async def get_machine(
|
|||||||
manufacturer=str(machine.manufacturer) if machine.manufacturer else None,
|
manufacturer=str(machine.manufacturer) if machine.manufacturer else None,
|
||||||
installation_date=_format_ts(machine.installation_date),
|
installation_date=_format_ts(machine.installation_date),
|
||||||
location=str(machine.location) if machine.location else None,
|
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,
|
rated_capacity=str(machine.rated_capacity) if machine.rated_capacity else None,
|
||||||
power_rating=str(machine.power_rating) if machine.power_rating else None,
|
power_rating=str(machine.power_rating) if machine.power_rating else None,
|
||||||
description=str(machine.description) if machine.description else None,
|
description=str(machine.description) if machine.description else None,
|
||||||
@@ -251,6 +272,15 @@ async def update_machine(
|
|||||||
pass
|
pass
|
||||||
if body.location is not None:
|
if body.location is not None:
|
||||||
machine.location = body.location
|
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:
|
if body.rated_capacity is not None:
|
||||||
machine.rated_capacity = body.rated_capacity
|
machine.rated_capacity = body.rated_capacity
|
||||||
if body.power_rating is not None:
|
if body.power_rating is not None:
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ class Machine(Base):
|
|||||||
manufacturer = Column(String(100), nullable=True)
|
manufacturer = Column(String(100), nullable=True)
|
||||||
installation_date = Column(TIMESTAMP(timezone=True), nullable=True)
|
installation_date = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||||
location = Column(String(200), 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)
|
rated_capacity = Column(String(100), nullable=True)
|
||||||
power_rating = Column(String(100), nullable=True)
|
power_rating = Column(String(100), nullable=True)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
@@ -76,7 +78,11 @@ class Machine(Base):
|
|||||||
"EquipmentPart", back_populates="machine", cascade="all, delete-orphan"
|
"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):
|
class EquipmentPart(Base):
|
||||||
|
|||||||
Reference in New Issue
Block a user