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';
|
||||
|
||||
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}>
|
||||
취소
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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(...),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user