feat: Phase 3 — inspection templates (backend + frontend)
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 InspectionTemplate and InspectionTemplateItem models - Add 9 API endpoints for template CRUD and item management - Add Alembic migration for inspection_templates tables - Add 15 backend tests (39/39 total pass) - Add TemplateEditor component with item management UI - Add templates list, create, and edit pages - Add tab bar, template grid, form row CSS classes - Fix CORS middleware ordering in main.py - Move CORS middleware before router registration Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
@@ -42,7 +42,6 @@ jobs:
|
||||
cd ~/factoryops && ~/.local/bin/docker-compose -f docker-compose.prod.yml down 2>/dev/null || true
|
||||
cd ~/factoryops-v2
|
||||
~/.local/bin/docker-compose -f docker-compose.prod.yml down 2>/dev/null || true
|
||||
docker volume rm factoryops-v2_postgres_data 2>/dev/null || true
|
||||
~/.local/bin/docker-compose -f docker-compose.prod.yml up -d --build --force-recreate
|
||||
echo "=== Waiting 15s for containers to stabilize ==="
|
||||
sleep 15
|
||||
|
||||
@@ -17,7 +17,16 @@ if db_url:
|
||||
config.set_main_option("sqlalchemy.url", db_url)
|
||||
|
||||
from src.database.config import Base
|
||||
from src.database.models import User, Tenant, Machine # noqa: F401
|
||||
from src.database.models import ( # noqa: F401
|
||||
User,
|
||||
Tenant,
|
||||
Machine,
|
||||
EquipmentPart,
|
||||
PartCounter,
|
||||
PartReplacementLog,
|
||||
InspectionTemplate,
|
||||
InspectionTemplateItem,
|
||||
)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
76
alembic/versions/9566bf2a256b_add_inspection_templates.py
Normal file
76
alembic/versions/9566bf2a256b_add_inspection_templates.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""add_inspection_templates
|
||||
|
||||
Revision ID: 9566bf2a256b
|
||||
Revises: a3b1c2d3e4f5
|
||||
Create Date: 2026-02-10 13:01:03.924293
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '9566bf2a256b'
|
||||
down_revision: Union[str, Sequence[str], None] = 'a3b1c2d3e4f5'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('inspection_templates',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('tenant_id', sa.String(length=50), nullable=False),
|
||||
sa.Column('name', sa.String(length=200), nullable=False),
|
||||
sa.Column('subject_type', sa.String(length=20), nullable=False),
|
||||
sa.Column('machine_id', sa.UUID(), nullable=True),
|
||||
sa.Column('product_code', sa.String(length=50), nullable=True),
|
||||
sa.Column('schedule_type', sa.String(length=20), nullable=False),
|
||||
sa.Column('inspection_mode', sa.String(length=20), nullable=True),
|
||||
sa.Column('version', sa.Integer(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), nullable=True),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['machine_id'], ['machines.id'], ),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_templates_tenant_machine', 'inspection_templates', ['tenant_id', 'machine_id'], unique=False)
|
||||
op.create_index('ix_templates_tenant_subject', 'inspection_templates', ['tenant_id', 'subject_type'], unique=False)
|
||||
op.create_table('inspection_template_items',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('template_id', sa.UUID(), nullable=False),
|
||||
sa.Column('sort_order', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=200), nullable=False),
|
||||
sa.Column('category', sa.String(length=100), nullable=True),
|
||||
sa.Column('data_type', sa.String(length=20), nullable=False),
|
||||
sa.Column('unit', sa.String(length=20), nullable=True),
|
||||
sa.Column('spec_min', sa.Float(), nullable=True),
|
||||
sa.Column('spec_max', sa.Float(), nullable=True),
|
||||
sa.Column('warning_min', sa.Float(), nullable=True),
|
||||
sa.Column('warning_max', sa.Float(), nullable=True),
|
||||
sa.Column('trend_window', sa.Integer(), nullable=True),
|
||||
sa.Column('select_options', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('equipment_part_id', sa.UUID(), nullable=True),
|
||||
sa.Column('is_required', sa.Boolean(), nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['equipment_part_id'], ['equipment_parts.id'], ),
|
||||
sa.ForeignKeyConstraint(['template_id'], ['inspection_templates.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_template_items_template_order', 'inspection_template_items', ['template_id', 'sort_order'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index('ix_template_items_template_order', table_name='inspection_template_items')
|
||||
op.drop_table('inspection_template_items')
|
||||
op.drop_index('ix_templates_tenant_subject', table_name='inspection_templates')
|
||||
op.drop_index('ix_templates_tenant_machine', table_name='inspection_templates')
|
||||
op.drop_table('inspection_templates')
|
||||
# ### end Alembic commands ###
|
||||
148
dashboard/app/[tenant]/templates/[id]/page.tsx
Normal file
148
dashboard/app/[tenant]/templates/[id]/page.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useTemplate } from '@/lib/hooks';
|
||||
import { useToast } from '@/lib/toast-context';
|
||||
import { api } from '@/lib/api';
|
||||
import { TemplateEditor } from '@/components/TemplateEditor';
|
||||
import type { TemplateDraft, ItemDraft } from '@/components/TemplateEditor';
|
||||
|
||||
export default function EditTemplatePage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const tenantId = params?.tenant as string;
|
||||
const templateId = params?.id as string;
|
||||
const { addToast } = useToast();
|
||||
|
||||
const { template, isLoading, error } = useTemplate(tenantId, templateId);
|
||||
|
||||
const handleSave = useCallback(async (meta: TemplateDraft, items: ItemDraft[]) => {
|
||||
// 1. Update template metadata
|
||||
const metaPayload = {
|
||||
name: meta.name.trim(),
|
||||
subject_type: meta.subject_type,
|
||||
machine_id: meta.machine_id || null,
|
||||
product_code: meta.product_code?.trim() || null,
|
||||
schedule_type: meta.schedule_type,
|
||||
inspection_mode: meta.inspection_mode,
|
||||
};
|
||||
|
||||
try {
|
||||
await api.put(`/api/${tenantId}/templates/${templateId}`, metaPayload);
|
||||
|
||||
// 2. Delete existing items that were removed
|
||||
const existingIds = new Set(
|
||||
(template?.items || []).map((i) => i.id)
|
||||
);
|
||||
const keptIds = new Set(
|
||||
items.filter((i) => i.id).map((i) => i.id!)
|
||||
);
|
||||
for (const oldId of existingIds) {
|
||||
if (!keptIds.has(oldId)) {
|
||||
await api.delete(`/api/${tenantId}/templates/${templateId}/items/${oldId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update existing items
|
||||
for (const item of items) {
|
||||
if (item.id && existingIds.has(item.id)) {
|
||||
await api.put(`/api/${tenantId}/templates/${templateId}/items/${item.id}`, {
|
||||
name: item.name.trim(),
|
||||
category: item.category?.trim() || null,
|
||||
data_type: item.data_type,
|
||||
unit: item.unit?.trim() || null,
|
||||
spec_min: item.spec_min ? parseFloat(item.spec_min) : null,
|
||||
spec_max: item.spec_max ? parseFloat(item.spec_max) : null,
|
||||
select_options:
|
||||
item.data_type === 'select' && item.select_options_text
|
||||
? item.select_options_text.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
: null,
|
||||
equipment_part_id: item.equipment_part_id || null,
|
||||
is_required: item.is_required,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Add new items (no id)
|
||||
for (const item of items) {
|
||||
if (!item.id) {
|
||||
await api.post(`/api/${tenantId}/templates/${templateId}/items`, {
|
||||
name: item.name.trim(),
|
||||
category: item.category?.trim() || null,
|
||||
data_type: item.data_type,
|
||||
unit: item.unit?.trim() || null,
|
||||
spec_min: item.spec_min ? parseFloat(item.spec_min) : null,
|
||||
spec_max: item.spec_max ? parseFloat(item.spec_max) : null,
|
||||
select_options:
|
||||
item.data_type === 'select' && item.select_options_text
|
||||
? item.select_options_text.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
: null,
|
||||
equipment_part_id: item.equipment_part_id || null,
|
||||
is_required: item.is_required,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Reorder all items
|
||||
const reorderIds = items.filter((i) => i.id).map((i) => i.id!);
|
||||
if (reorderIds.length > 0) {
|
||||
await api.put(`/api/${tenantId}/templates/${templateId}/items/reorder`, {
|
||||
item_ids: reorderIds,
|
||||
});
|
||||
}
|
||||
|
||||
addToast('템플릿이 저장되었습니다.', 'success');
|
||||
router.push(`/${tenantId}/templates`);
|
||||
} catch {
|
||||
addToast('템플릿 저장에 실패했습니다.', 'error');
|
||||
throw new Error('save failed');
|
||||
}
|
||||
}, [tenantId, templateId, template, router, addToast]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="loading">
|
||||
<span className="material-symbols-outlined spinning">progress_activity</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !template) {
|
||||
return (
|
||||
<div className="page-error">
|
||||
<span className="material-symbols-outlined">error</span>
|
||||
<p>템플릿을 찾을 수 없습니다.</p>
|
||||
<button className="btn-outline" onClick={() => router.push(`/${tenantId}/templates`)}>
|
||||
목록으로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-breadcrumb">
|
||||
<button className="btn-text" onClick={() => router.push(`/${tenantId}/templates`)}>
|
||||
<span className="material-symbols-outlined">arrow_back</span>
|
||||
템플릿 목록
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
템플릿 편집
|
||||
</h2>
|
||||
<span className="detail-subtitle">v{template.version}</span>
|
||||
</div>
|
||||
|
||||
<TemplateEditor
|
||||
tenantId={tenantId}
|
||||
initialData={template}
|
||||
onSave={handleSave}
|
||||
isEdit
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
dashboard/app/[tenant]/templates/new/page.tsx
Normal file
69
dashboard/app/[tenant]/templates/new/page.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useToast } from '@/lib/toast-context';
|
||||
import { api } from '@/lib/api';
|
||||
import { TemplateEditor } from '@/components/TemplateEditor';
|
||||
import type { TemplateDraft, ItemDraft } from '@/components/TemplateEditor';
|
||||
|
||||
export default function NewTemplatePage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const tenantId = params?.tenant as string;
|
||||
const { addToast } = useToast();
|
||||
|
||||
const handleSave = useCallback(async (meta: TemplateDraft, items: ItemDraft[]) => {
|
||||
const payload = {
|
||||
name: meta.name.trim(),
|
||||
subject_type: meta.subject_type,
|
||||
machine_id: meta.machine_id || null,
|
||||
product_code: meta.product_code?.trim() || null,
|
||||
schedule_type: meta.schedule_type,
|
||||
inspection_mode: meta.inspection_mode,
|
||||
items: items.map((item) => ({
|
||||
name: item.name.trim(),
|
||||
category: item.category?.trim() || null,
|
||||
data_type: item.data_type,
|
||||
unit: item.unit?.trim() || null,
|
||||
spec_min: item.spec_min ? parseFloat(item.spec_min) : null,
|
||||
spec_max: item.spec_max ? parseFloat(item.spec_max) : null,
|
||||
select_options:
|
||||
item.data_type === 'select' && item.select_options_text
|
||||
? item.select_options_text.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
: null,
|
||||
equipment_part_id: item.equipment_part_id || null,
|
||||
is_required: item.is_required,
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
await api.post(`/api/${tenantId}/templates`, payload);
|
||||
addToast('템플릿이 생성되었습니다.', 'success');
|
||||
router.push(`/${tenantId}/templates`);
|
||||
} catch {
|
||||
addToast('템플릿 생성에 실패했습니다.', 'error');
|
||||
throw new Error('save failed');
|
||||
}
|
||||
}, [tenantId, router, addToast]);
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-breadcrumb">
|
||||
<button className="btn-text" onClick={() => router.push(`/${tenantId}/templates`)}>
|
||||
<span className="material-symbols-outlined">arrow_back</span>
|
||||
템플릿 목록
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
새 검사 템플릿
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<TemplateEditor tenantId={tenantId} onSave={handleSave} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
dashboard/app/[tenant]/templates/page.tsx
Normal file
129
dashboard/app/[tenant]/templates/page.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useTemplates } from '@/lib/hooks';
|
||||
import { useToast } from '@/lib/toast-context';
|
||||
import { api } from '@/lib/api';
|
||||
import { SCHEDULE_LABELS, MODE_LABELS } from '@/components/TemplateEditor';
|
||||
import type { InspectionTemplate } from '@/lib/types';
|
||||
|
||||
type TabType = 'equipment' | 'product';
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const tenantId = params?.tenant as string;
|
||||
const { addToast } = useToast();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>('equipment');
|
||||
const { templates, isLoading, error, mutate } = useTemplates(tenantId, activeTab);
|
||||
|
||||
const handleDelete = useCallback(async (template: InspectionTemplate) => {
|
||||
if (!confirm(`"${template.name}" 템플릿을 삭제하시겠습니까?`)) return;
|
||||
try {
|
||||
await api.delete(`/api/${tenantId}/templates/${template.id}`);
|
||||
addToast('템플릿이 삭제되었습니다.', 'success');
|
||||
mutate();
|
||||
} catch {
|
||||
addToast('템플릿 삭제에 실패했습니다.', 'error');
|
||||
}
|
||||
}, [tenantId, mutate, addToast]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="page-error">
|
||||
<span className="material-symbols-outlined">error</span>
|
||||
<p>데이터를 불러오는 데 실패했습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">
|
||||
<span className="material-symbols-outlined">assignment</span>
|
||||
검사 템플릿
|
||||
</h2>
|
||||
<button className="btn-primary" onClick={() => router.push(`/${tenantId}/templates/new`)}>
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
새 템플릿
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tab-bar">
|
||||
<button
|
||||
className={`tab-item ${activeTab === 'equipment' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('equipment')}
|
||||
>
|
||||
<span className="material-symbols-outlined">precision_manufacturing</span>
|
||||
설비검사
|
||||
</button>
|
||||
<button
|
||||
className={`tab-item ${activeTab === 'product' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('product')}
|
||||
>
|
||||
<span className="material-symbols-outlined">inventory_2</span>
|
||||
품질검사
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="loading">
|
||||
<span className="material-symbols-outlined spinning">progress_activity</span>
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<span className="material-symbols-outlined">assignment</span>
|
||||
<p>{activeTab === 'equipment' ? '설비검사' : '품질검사'} 템플릿이 없습니다.</p>
|
||||
<button className="btn-primary" onClick={() => router.push(`/${tenantId}/templates/new`)}>
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
첫 번째 템플릿 만들기
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="template-grid">
|
||||
{templates.map((t) => (
|
||||
<div key={t.id} className="template-card">
|
||||
<div
|
||||
className="template-card-body"
|
||||
onClick={() => router.push(`/${tenantId}/templates/${t.id}`)}
|
||||
>
|
||||
<div className="template-card-top">
|
||||
<span className="material-symbols-outlined template-card-icon">assignment</span>
|
||||
<div className="template-card-badges">
|
||||
<span className="badge badge-schedule">{SCHEDULE_LABELS[t.schedule_type] || t.schedule_type}</span>
|
||||
<span className="badge badge-mode">{MODE_LABELS[t.inspection_mode] || t.inspection_mode}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="template-card-info">
|
||||
<span className="template-card-name">{t.name}</span>
|
||||
<span className="template-card-meta">
|
||||
항목 {t.items_count}개 · v{t.version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="template-card-actions">
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => router.push(`/${tenantId}/templates/${t.id}`)}
|
||||
title="편집"
|
||||
>
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon btn-icon-danger"
|
||||
onClick={() => handleDelete(t)}
|
||||
title="삭제"
|
||||
>
|
||||
<span className="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1019,6 +1019,243 @@ a {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ===== Tab Bar ===== */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 20px;
|
||||
padding: 4px;
|
||||
background: var(--md-surface);
|
||||
border: 1px solid var(--md-outline);
|
||||
border-radius: var(--md-radius-lg);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: var(--md-radius);
|
||||
background: transparent;
|
||||
color: var(--md-on-surface-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.tab-item:hover {
|
||||
background: var(--md-surface-variant);
|
||||
color: var(--md-on-surface);
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
background: #e8f0fe;
|
||||
color: var(--md-primary);
|
||||
}
|
||||
|
||||
.tab-item .material-symbols-outlined {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ===== Template Grid ===== */
|
||||
.template-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
background: var(--md-surface);
|
||||
border: 1px solid var(--md-outline);
|
||||
border-radius: var(--md-radius-lg);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.template-card:hover {
|
||||
border-color: var(--md-primary);
|
||||
box-shadow: 0 2px 8px rgba(26, 115, 232, 0.12);
|
||||
}
|
||||
|
||||
.template-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.template-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.template-card-icon {
|
||||
font-size: 28px;
|
||||
color: var(--md-primary);
|
||||
}
|
||||
|
||||
.template-card-badges {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-schedule {
|
||||
background: #e8f0fe;
|
||||
color: var(--md-primary);
|
||||
}
|
||||
|
||||
.badge-mode {
|
||||
background: #e6f4ea;
|
||||
color: var(--md-success);
|
||||
}
|
||||
|
||||
.template-card-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.template-card-name {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.template-card-meta {
|
||||
font-size: 13px;
|
||||
color: var(--md-on-surface-secondary);
|
||||
}
|
||||
|
||||
.template-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--md-outline);
|
||||
background: var(--md-surface-variant);
|
||||
}
|
||||
|
||||
/* ===== Form Row / Radio / Checkbox ===== */
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.form-row > .form-field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.radio-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
color: var(--md-on-surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radio-label input[type="radio"] {
|
||||
accent-color: var(--md-primary);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
color: var(--md-on-surface);
|
||||
cursor: pointer;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
accent-color: var(--md-primary);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* ===== Items List (TemplateEditor) ===== */
|
||||
.items-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.item-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--md-surface-variant);
|
||||
border-radius: var(--md-radius);
|
||||
border: 1px solid var(--md-outline);
|
||||
}
|
||||
|
||||
.item-order {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.order-num {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--md-on-surface-secondary);
|
||||
}
|
||||
|
||||
.item-fields {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--md-error);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #fce8e6;
|
||||
}
|
||||
|
||||
/* ===== Form Actions ===== */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
@media (max-width: 768px) {
|
||||
.topnav-center {
|
||||
@@ -1029,7 +1266,8 @@ a {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.machine-grid {
|
||||
.machine-grid,
|
||||
.template-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -1037,6 +1275,14 @@ a {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.item-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.part-table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
429
dashboard/components/TemplateEditor.tsx
Normal file
429
dashboard/components/TemplateEditor.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useMachines } from '@/lib/hooks';
|
||||
import type { InspectionTemplate, InspectionTemplateItem } from '@/lib/types';
|
||||
|
||||
const SCHEDULE_LABELS: Record<string, string> = {
|
||||
daily: '일일',
|
||||
weekly: '주간',
|
||||
monthly: '월간',
|
||||
yearly: '연간',
|
||||
ad_hoc: '수시',
|
||||
};
|
||||
|
||||
const MODE_LABELS: Record<string, string> = {
|
||||
checklist: '체크리스트',
|
||||
measurement: '측정',
|
||||
monitoring: '모니터링',
|
||||
};
|
||||
|
||||
const DATA_TYPE_LABELS: Record<string, string> = {
|
||||
numeric: '숫자',
|
||||
boolean: '예/아니오',
|
||||
text: '텍스트',
|
||||
select: '선택',
|
||||
};
|
||||
|
||||
interface ItemDraft {
|
||||
id?: string;
|
||||
name: string;
|
||||
category: string;
|
||||
data_type: string;
|
||||
unit: string;
|
||||
spec_min: string;
|
||||
spec_max: string;
|
||||
select_options_text: string;
|
||||
equipment_part_id: string;
|
||||
is_required: boolean;
|
||||
}
|
||||
|
||||
interface TemplateDraft {
|
||||
name: string;
|
||||
subject_type: string;
|
||||
machine_id: string;
|
||||
product_code: string;
|
||||
schedule_type: string;
|
||||
inspection_mode: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tenantId: string;
|
||||
initialData?: InspectionTemplate | null;
|
||||
onSave: (meta: TemplateDraft, items: ItemDraft[]) => Promise<void>;
|
||||
isEdit?: boolean;
|
||||
}
|
||||
|
||||
function itemToItemDraft(item: InspectionTemplateItem): ItemDraft {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
category: item.category || '',
|
||||
data_type: item.data_type,
|
||||
unit: item.unit || '',
|
||||
spec_min: item.spec_min !== null ? String(item.spec_min) : '',
|
||||
spec_max: item.spec_max !== null ? String(item.spec_max) : '',
|
||||
select_options_text: item.select_options ? item.select_options.join(', ') : '',
|
||||
equipment_part_id: item.equipment_part_id || '',
|
||||
is_required: item.is_required,
|
||||
};
|
||||
}
|
||||
|
||||
const EMPTY_ITEM: ItemDraft = {
|
||||
name: '',
|
||||
category: '',
|
||||
data_type: 'numeric',
|
||||
unit: '',
|
||||
spec_min: '',
|
||||
spec_max: '',
|
||||
select_options_text: '',
|
||||
equipment_part_id: '',
|
||||
is_required: true,
|
||||
};
|
||||
|
||||
export function TemplateEditor({ tenantId, initialData, onSave, isEdit }: Props) {
|
||||
const [meta, setMeta] = useState<TemplateDraft>({
|
||||
name: initialData?.name || '',
|
||||
subject_type: initialData?.subject_type || 'equipment',
|
||||
machine_id: initialData?.machine_id || '',
|
||||
product_code: initialData?.product_code || '',
|
||||
schedule_type: initialData?.schedule_type || 'daily',
|
||||
inspection_mode: initialData?.inspection_mode || 'measurement',
|
||||
});
|
||||
|
||||
const [items, setItems] = useState<ItemDraft[]>(
|
||||
initialData?.items?.map(itemToItemDraft) || []
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [showAddItem, setShowAddItem] = useState(false);
|
||||
const [newItem, setNewItem] = useState<ItemDraft>({ ...EMPTY_ITEM });
|
||||
|
||||
const { machines } = useMachines(tenantId);
|
||||
|
||||
const handleMetaChange = useCallback((field: string, value: string) => {
|
||||
setMeta(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
const addItem = useCallback(() => {
|
||||
if (!newItem.name.trim()) return;
|
||||
setItems(prev => [...prev, { ...newItem }]);
|
||||
setNewItem({ ...EMPTY_ITEM });
|
||||
setShowAddItem(false);
|
||||
}, [newItem]);
|
||||
|
||||
const removeItem = useCallback((idx: number) => {
|
||||
if (!confirm('이 검사 항목을 삭제하시겠습니까?')) return;
|
||||
setItems(prev => prev.filter((_, i) => i !== idx));
|
||||
}, []);
|
||||
|
||||
const moveItem = useCallback((idx: number, direction: -1 | 1) => {
|
||||
setItems(prev => {
|
||||
const next = [...prev];
|
||||
const target = idx + direction;
|
||||
if (target < 0 || target >= next.length) return prev;
|
||||
[next[idx], next[target]] = [next[target], next[idx]];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateItem = useCallback((idx: number, field: string, value: string | boolean) => {
|
||||
setItems(prev => prev.map((item, i) =>
|
||||
i === idx ? { ...item, [field]: value } : item
|
||||
));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!meta.name.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSave(meta, items);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [meta, items, onSave]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<div className="card-header">
|
||||
<h3>기본 정보</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="form-field">
|
||||
<label>템플릿 이름 *</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="예: 1호기 일일검사"
|
||||
value={meta.name}
|
||||
onChange={e => handleMetaChange('name', e.target.value)}
|
||||
disabled={submitting}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-field">
|
||||
<label>검사 대상</label>
|
||||
<div className="radio-group">
|
||||
<label className="radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="subject_type"
|
||||
value="equipment"
|
||||
checked={meta.subject_type === 'equipment'}
|
||||
onChange={e => handleMetaChange('subject_type', e.target.value)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
설비검사
|
||||
</label>
|
||||
<label className="radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="subject_type"
|
||||
value="product"
|
||||
checked={meta.subject_type === 'product'}
|
||||
onChange={e => handleMetaChange('subject_type', e.target.value)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
품질검사
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{meta.subject_type === 'equipment' && (
|
||||
<div className="form-field">
|
||||
<label>대상 설비</label>
|
||||
<select
|
||||
value={meta.machine_id}
|
||||
onChange={e => handleMetaChange('machine_id', e.target.value)}
|
||||
disabled={submitting}
|
||||
>
|
||||
<option value="">선택 안함</option>
|
||||
{machines.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.name} ({m.equipment_code})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-field">
|
||||
<label>검사 주기</label>
|
||||
<select
|
||||
value={meta.schedule_type}
|
||||
onChange={e => handleMetaChange('schedule_type', e.target.value)}
|
||||
disabled={submitting}
|
||||
>
|
||||
{Object.entries(SCHEDULE_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>검사 모드</label>
|
||||
<select
|
||||
value={meta.inspection_mode}
|
||||
onChange={e => handleMetaChange('inspection_mode', e.target.value)}
|
||||
disabled={submitting}
|
||||
>
|
||||
{Object.entries(MODE_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<div className="card-header">
|
||||
<h3>검사 항목 ({items.length}개)</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline btn-sm"
|
||||
onClick={() => setShowAddItem(true)}
|
||||
disabled={submitting}
|
||||
>
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
항목 추가
|
||||
</button>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{items.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '2rem' }}>
|
||||
<span className="material-symbols-outlined">playlist_add</span>
|
||||
<p>검사 항목이 없습니다. 항목을 추가하세요.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="items-list">
|
||||
{items.map((item, idx) => (
|
||||
<div key={idx} className="item-row">
|
||||
<div className="item-order">
|
||||
<button type="button" className="btn-icon" onClick={() => moveItem(idx, -1)} disabled={idx === 0 || submitting}>
|
||||
<span className="material-symbols-outlined">keyboard_arrow_up</span>
|
||||
</button>
|
||||
<span className="order-num">{idx + 1}</span>
|
||||
<button type="button" className="btn-icon" onClick={() => moveItem(idx, 1)} disabled={idx === items.length - 1 || submitting}>
|
||||
<span className="material-symbols-outlined">keyboard_arrow_down</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="item-fields">
|
||||
<div className="form-row">
|
||||
<div className="form-field">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="항목명"
|
||||
value={item.name}
|
||||
onChange={e => updateItem(idx, 'name', e.target.value)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field" style={{ maxWidth: '120px' }}>
|
||||
<select
|
||||
value={item.data_type}
|
||||
onChange={e => updateItem(idx, 'data_type', e.target.value)}
|
||||
disabled={submitting}
|
||||
>
|
||||
{Object.entries(DATA_TYPE_LABELS).map(([v, l]) => (
|
||||
<option key={v} value={v}>{l}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<label className="checkbox-label" style={{ minWidth: 'auto' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.is_required}
|
||||
onChange={e => updateItem(idx, 'is_required', e.target.checked)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
필수
|
||||
</label>
|
||||
</div>
|
||||
{item.data_type === 'numeric' && (
|
||||
<div className="form-row">
|
||||
<div className="form-field">
|
||||
<input type="text" placeholder="단위 (예: ℃)" value={item.unit} onChange={e => updateItem(idx, 'unit', e.target.value)} disabled={submitting} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<input type="number" placeholder="하한" value={item.spec_min} onChange={e => updateItem(idx, 'spec_min', e.target.value)} disabled={submitting} step="any" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<input type="number" placeholder="상한" value={item.spec_max} onChange={e => updateItem(idx, 'spec_max', e.target.value)} disabled={submitting} step="any" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.data_type === 'select' && (
|
||||
<div className="form-field">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="선택지 (쉼표로 구분, 예: 양호, 불량, N/A)"
|
||||
value={item.select_options_text}
|
||||
onChange={e => updateItem(idx, 'select_options_text', e.target.value)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="btn-icon btn-danger" onClick={() => removeItem(idx)} disabled={submitting}>
|
||||
<span className="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAddItem && (
|
||||
<div className="modal-overlay" onClick={() => setShowAddItem(false)}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>검사 항목 추가</h3>
|
||||
<button type="button" className="modal-close" onClick={() => setShowAddItem(false)}>
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>항목명 *</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="예: 유압 압력"
|
||||
value={newItem.name}
|
||||
onChange={e => setNewItem(prev => ({ ...prev, name: e.target.value }))}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-field">
|
||||
<label>데이터 유형</label>
|
||||
<select value={newItem.data_type} onChange={e => setNewItem(prev => ({ ...prev, data_type: e.target.value }))}>
|
||||
{Object.entries(DATA_TYPE_LABELS).map(([v, l]) => (
|
||||
<option key={v} value={v}>{l}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>카테고리</label>
|
||||
<input type="text" placeholder="예: 안전" value={newItem.category} onChange={e => setNewItem(prev => ({ ...prev, category: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
{newItem.data_type === 'numeric' && (
|
||||
<div className="form-row">
|
||||
<div className="form-field">
|
||||
<label>단위</label>
|
||||
<input type="text" placeholder="예: ℃" value={newItem.unit} onChange={e => setNewItem(prev => ({ ...prev, unit: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>하한</label>
|
||||
<input type="number" placeholder="스펙 하한" value={newItem.spec_min} onChange={e => setNewItem(prev => ({ ...prev, spec_min: e.target.value }))} step="any" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>상한</label>
|
||||
<input type="number" placeholder="스펙 상한" value={newItem.spec_max} onChange={e => setNewItem(prev => ({ ...prev, spec_max: e.target.value }))} step="any" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{newItem.data_type === 'select' && (
|
||||
<div className="form-field">
|
||||
<label>선택지</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="쉼표로 구분 (예: 양호, 불량, N/A)"
|
||||
value={newItem.select_options_text}
|
||||
onChange={e => setNewItem(prev => ({ ...prev, select_options_text: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" checked={newItem.is_required} onChange={e => setNewItem(prev => ({ ...prev, is_required: e.target.checked }))} />
|
||||
필수 항목
|
||||
</label>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn-outline" onClick={() => setShowAddItem(false)}>취소</button>
|
||||
<button type="button" className="btn-primary" onClick={addItem} disabled={!newItem.name.trim()}>
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn-primary" disabled={submitting || !meta.name.trim()}>
|
||||
{submitting ? (
|
||||
<span className="material-symbols-outlined spinning">progress_activity</span>
|
||||
) : (
|
||||
<span className="material-symbols-outlined">{isEdit ? 'save' : 'add'}</span>
|
||||
)}
|
||||
{isEdit ? '저장' : '템플릿 생성'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export { SCHEDULE_LABELS, MODE_LABELS, DATA_TYPE_LABELS };
|
||||
export type { TemplateDraft, ItemDraft };
|
||||
@@ -1,6 +1,6 @@
|
||||
import useSWR from 'swr';
|
||||
import { fetcher, getTenantUrl } from './api';
|
||||
import type { Tenant, Machine, MachineDetail, EquipmentPart } from './types';
|
||||
import type { Tenant, Machine, MachineDetail, EquipmentPart, InspectionTemplate } from './types';
|
||||
|
||||
export function useTenants() {
|
||||
const { data, error, isLoading, mutate } = useSWR<{ tenants: Tenant[] }>(
|
||||
@@ -75,3 +75,36 @@ export function useEquipmentParts(tenantId?: string, machineId?: string) {
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTemplates(tenantId?: string, subjectType?: string) {
|
||||
const params = subjectType ? `?subject_type=${subjectType}` : '';
|
||||
const url = tenantId ? `/api/${tenantId}/templates${params}` : null;
|
||||
const { data, error, isLoading, mutate } = useSWR<{ templates: InspectionTemplate[] }>(
|
||||
url,
|
||||
fetcher,
|
||||
{ refreshInterval: 30000, dedupingInterval: 2000 },
|
||||
);
|
||||
|
||||
return {
|
||||
templates: data?.templates || [],
|
||||
error,
|
||||
isLoading,
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTemplate(tenantId?: string, templateId?: string) {
|
||||
const url = tenantId && templateId ? `/api/${tenantId}/templates/${templateId}` : null;
|
||||
const { data, error, isLoading, mutate } = useSWR<InspectionTemplate>(
|
||||
url,
|
||||
fetcher,
|
||||
{ refreshInterval: 30000, dedupingInterval: 2000 },
|
||||
);
|
||||
|
||||
return {
|
||||
template: data ?? null,
|
||||
error,
|
||||
isLoading,
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,3 +61,39 @@ export interface EquipmentPart {
|
||||
export interface MachineDetail extends Machine {
|
||||
parts: EquipmentPart[];
|
||||
}
|
||||
|
||||
export interface InspectionTemplateItem {
|
||||
id: string;
|
||||
template_id: string;
|
||||
sort_order: number;
|
||||
name: string;
|
||||
category: string | null;
|
||||
data_type: 'numeric' | 'boolean' | 'text' | 'select';
|
||||
unit: string | null;
|
||||
spec_min: number | null;
|
||||
spec_max: number | null;
|
||||
warning_min: number | null;
|
||||
warning_max: number | null;
|
||||
trend_window: number | null;
|
||||
select_options: string[] | null;
|
||||
equipment_part_id: string | null;
|
||||
is_required: boolean;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface InspectionTemplate {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
subject_type: 'equipment' | 'product';
|
||||
machine_id: string | null;
|
||||
product_code: string | null;
|
||||
schedule_type: 'daily' | 'weekly' | 'monthly' | 'yearly' | 'ad_hoc';
|
||||
inspection_mode: 'checklist' | 'measurement' | 'monitoring';
|
||||
version: number;
|
||||
is_active: boolean;
|
||||
items_count: number;
|
||||
items?: InspectionTemplateItem[];
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
12
main.py
12
main.py
@@ -21,6 +21,7 @@ from src.tenant import manager as tenant_manager
|
||||
from src.tenant.manager import TenantNotFoundError, InvalidTenantIdError
|
||||
from src.api.machines import router as machines_router
|
||||
from src.api.equipment_parts import router as equipment_parts_router
|
||||
from src.api.templates import router as templates_router
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -34,11 +35,6 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
app = FastAPI(title="FactoryOps v2 API", lifespan=lifespan)
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(auth_admin_router)
|
||||
app.include_router(machines_router)
|
||||
app.include_router(equipment_parts_router)
|
||||
|
||||
CORS_ORIGINS = (
|
||||
os.getenv("CORS_ORIGINS", "").split(",") if os.getenv("CORS_ORIGINS") else []
|
||||
)
|
||||
@@ -52,6 +48,12 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(auth_admin_router)
|
||||
app.include_router(machines_router)
|
||||
app.include_router(equipment_parts_router)
|
||||
app.include_router(templates_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
|
||||
533
src/api/templates.py
Normal file
533
src/api/templates.py
Normal file
@@ -0,0 +1,533 @@
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Path
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.database.config import get_db
|
||||
from src.database.models import InspectionTemplate, InspectionTemplateItem, Machine
|
||||
from src.auth.models import TokenData
|
||||
from src.auth.dependencies import require_auth, verify_tenant_access
|
||||
|
||||
router = APIRouter(prefix="/api/{tenant_id}/templates", tags=["templates"])
|
||||
|
||||
|
||||
VALID_SUBJECT_TYPES = ("equipment", "product")
|
||||
VALID_SCHEDULE_TYPES = ("daily", "weekly", "monthly", "yearly", "ad_hoc")
|
||||
VALID_INSPECTION_MODES = ("checklist", "measurement", "monitoring")
|
||||
VALID_DATA_TYPES = ("numeric", "boolean", "text", "select")
|
||||
|
||||
|
||||
class ItemCreate(BaseModel):
|
||||
name: str
|
||||
category: Optional[str] = None
|
||||
data_type: str
|
||||
unit: Optional[str] = None
|
||||
spec_min: Optional[float] = None
|
||||
spec_max: Optional[float] = None
|
||||
warning_min: Optional[float] = None
|
||||
warning_max: Optional[float] = None
|
||||
trend_window: Optional[int] = None
|
||||
select_options: Optional[list] = None
|
||||
equipment_part_id: Optional[str] = None
|
||||
is_required: bool = True
|
||||
|
||||
|
||||
class ItemUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
data_type: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
spec_min: Optional[float] = None
|
||||
spec_max: Optional[float] = None
|
||||
warning_min: Optional[float] = None
|
||||
warning_max: Optional[float] = None
|
||||
trend_window: Optional[int] = None
|
||||
select_options: Optional[list] = None
|
||||
equipment_part_id: Optional[str] = None
|
||||
is_required: Optional[bool] = None
|
||||
|
||||
|
||||
class TemplateCreate(BaseModel):
|
||||
name: str
|
||||
subject_type: str
|
||||
machine_id: Optional[str] = None
|
||||
product_code: Optional[str] = None
|
||||
schedule_type: str
|
||||
inspection_mode: str = "measurement"
|
||||
items: List[ItemCreate] = []
|
||||
|
||||
|
||||
class TemplateUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
subject_type: Optional[str] = None
|
||||
machine_id: Optional[str] = None
|
||||
product_code: Optional[str] = None
|
||||
schedule_type: Optional[str] = None
|
||||
inspection_mode: Optional[str] = None
|
||||
|
||||
|
||||
class ReorderRequest(BaseModel):
|
||||
item_ids: List[str]
|
||||
|
||||
|
||||
def _format_ts(val) -> Optional[str]:
|
||||
if val is None:
|
||||
return None
|
||||
return val.isoformat() if hasattr(val, "isoformat") else str(val)
|
||||
|
||||
|
||||
def _item_to_dict(item: InspectionTemplateItem) -> dict:
|
||||
return {
|
||||
"id": str(item.id),
|
||||
"template_id": str(item.template_id),
|
||||
"sort_order": item.sort_order,
|
||||
"name": str(item.name),
|
||||
"category": str(item.category) if item.category else None,
|
||||
"data_type": str(item.data_type),
|
||||
"unit": str(item.unit) if item.unit else None,
|
||||
"spec_min": float(item.spec_min) if item.spec_min is not None else None,
|
||||
"spec_max": float(item.spec_max) if item.spec_max is not None else None,
|
||||
"warning_min": float(item.warning_min)
|
||||
if item.warning_min is not None
|
||||
else None,
|
||||
"warning_max": float(item.warning_max)
|
||||
if item.warning_max is not None
|
||||
else None,
|
||||
"trend_window": int(item.trend_window)
|
||||
if item.trend_window is not None
|
||||
else None,
|
||||
"select_options": item.select_options,
|
||||
"equipment_part_id": str(item.equipment_part_id)
|
||||
if item.equipment_part_id
|
||||
else None,
|
||||
"is_required": bool(item.is_required),
|
||||
"created_at": _format_ts(item.created_at),
|
||||
}
|
||||
|
||||
|
||||
def _template_to_dict(
|
||||
t: InspectionTemplate, items_count: int = 0, include_items: bool = False
|
||||
) -> dict:
|
||||
result = {
|
||||
"id": str(t.id),
|
||||
"tenant_id": str(t.tenant_id),
|
||||
"name": str(t.name),
|
||||
"subject_type": str(t.subject_type),
|
||||
"machine_id": str(t.machine_id) if t.machine_id else None,
|
||||
"product_code": str(t.product_code) if t.product_code else None,
|
||||
"schedule_type": str(t.schedule_type),
|
||||
"inspection_mode": str(t.inspection_mode or "measurement"),
|
||||
"version": int(t.version or 1),
|
||||
"is_active": bool(t.is_active),
|
||||
"items_count": items_count,
|
||||
"created_at": _format_ts(t.created_at),
|
||||
"updated_at": _format_ts(t.updated_at),
|
||||
}
|
||||
if include_items:
|
||||
result["items"] = [_item_to_dict(i) for i in (t.items or [])]
|
||||
return result
|
||||
|
||||
|
||||
def _validate_subject_type(subject_type: str):
|
||||
if subject_type not in VALID_SUBJECT_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"subject_type은 {', '.join(VALID_SUBJECT_TYPES)} 중 하나여야 합니다.",
|
||||
)
|
||||
|
||||
|
||||
def _validate_schedule_type(schedule_type: str):
|
||||
if schedule_type not in VALID_SCHEDULE_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"schedule_type은 {', '.join(VALID_SCHEDULE_TYPES)} 중 하나여야 합니다.",
|
||||
)
|
||||
|
||||
|
||||
def _validate_inspection_mode(mode: str):
|
||||
if mode not in VALID_INSPECTION_MODES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"inspection_mode는 {', '.join(VALID_INSPECTION_MODES)} 중 하나여야 합니다.",
|
||||
)
|
||||
|
||||
|
||||
def _validate_data_type(data_type: str):
|
||||
if data_type not in VALID_DATA_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"data_type은 {', '.join(VALID_DATA_TYPES)} 중 하나여야 합니다.",
|
||||
)
|
||||
|
||||
|
||||
async def _get_template(
|
||||
db: AsyncSession, tenant_id: str, template_id: UUID, load_items: bool = False
|
||||
) -> InspectionTemplate:
|
||||
stmt = select(InspectionTemplate).where(
|
||||
InspectionTemplate.id == template_id,
|
||||
InspectionTemplate.tenant_id == tenant_id,
|
||||
InspectionTemplate.is_active == True,
|
||||
)
|
||||
if load_items:
|
||||
stmt = stmt.options(selectinload(InspectionTemplate.items))
|
||||
result = await db.execute(stmt)
|
||||
template = result.scalar_one_or_none()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="검사 템플릿을 찾을 수 없습니다.")
|
||||
return template
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_templates(
|
||||
tenant_id: str = Path(...),
|
||||
subject_type: Optional[str] = None,
|
||||
schedule_type: Optional[str] = None,
|
||||
machine_id: Optional[str] = None,
|
||||
current_user: TokenData = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
verify_tenant_access(tenant_id, current_user)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
InspectionTemplate,
|
||||
func.count(InspectionTemplateItem.id).label("items_count"),
|
||||
)
|
||||
.outerjoin(
|
||||
InspectionTemplateItem,
|
||||
InspectionTemplateItem.template_id == InspectionTemplate.id,
|
||||
)
|
||||
.where(
|
||||
InspectionTemplate.tenant_id == tenant_id,
|
||||
InspectionTemplate.is_active == True,
|
||||
)
|
||||
.group_by(InspectionTemplate.id)
|
||||
.order_by(InspectionTemplate.name)
|
||||
)
|
||||
|
||||
if subject_type:
|
||||
stmt = stmt.where(InspectionTemplate.subject_type == subject_type)
|
||||
if schedule_type:
|
||||
stmt = stmt.where(InspectionTemplate.schedule_type == schedule_type)
|
||||
if machine_id:
|
||||
stmt = stmt.where(InspectionTemplate.machine_id == UUID(machine_id))
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
return {"templates": [_template_to_dict(t, count) for t, count in rows]}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_template(
|
||||
body: TemplateCreate,
|
||||
tenant_id: str = Path(...),
|
||||
current_user: TokenData = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
verify_tenant_access(tenant_id, current_user)
|
||||
_validate_subject_type(body.subject_type)
|
||||
_validate_schedule_type(body.schedule_type)
|
||||
_validate_inspection_mode(body.inspection_mode)
|
||||
|
||||
if body.subject_type == "equipment" and body.machine_id:
|
||||
machine_stmt = select(Machine).where(
|
||||
Machine.id == UUID(body.machine_id), Machine.tenant_id == tenant_id
|
||||
)
|
||||
if not (await db.execute(machine_stmt)).scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="설비를 찾을 수 없습니다.")
|
||||
|
||||
template = InspectionTemplate(
|
||||
tenant_id=tenant_id,
|
||||
name=body.name,
|
||||
subject_type=body.subject_type,
|
||||
machine_id=UUID(body.machine_id) if body.machine_id else None,
|
||||
product_code=body.product_code,
|
||||
schedule_type=body.schedule_type,
|
||||
inspection_mode=body.inspection_mode,
|
||||
)
|
||||
db.add(template)
|
||||
await db.flush()
|
||||
|
||||
for idx, item_data in enumerate(body.items):
|
||||
_validate_data_type(item_data.data_type)
|
||||
item = InspectionTemplateItem(
|
||||
template_id=template.id,
|
||||
sort_order=idx + 1,
|
||||
name=item_data.name,
|
||||
category=item_data.category,
|
||||
data_type=item_data.data_type,
|
||||
unit=item_data.unit,
|
||||
spec_min=item_data.spec_min,
|
||||
spec_max=item_data.spec_max,
|
||||
warning_min=item_data.warning_min,
|
||||
warning_max=item_data.warning_max,
|
||||
trend_window=item_data.trend_window,
|
||||
select_options=item_data.select_options,
|
||||
equipment_part_id=UUID(item_data.equipment_part_id)
|
||||
if item_data.equipment_part_id
|
||||
else None,
|
||||
is_required=item_data.is_required,
|
||||
)
|
||||
db.add(item)
|
||||
|
||||
await db.commit()
|
||||
|
||||
stmt = (
|
||||
select(InspectionTemplate)
|
||||
.options(selectinload(InspectionTemplate.items))
|
||||
.where(InspectionTemplate.id == template.id)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
created = result.scalar_one()
|
||||
|
||||
return _template_to_dict(created, len(created.items), include_items=True)
|
||||
|
||||
|
||||
@router.get("/{template_id}")
|
||||
async def get_template(
|
||||
tenant_id: str = Path(...),
|
||||
template_id: UUID = Path(...),
|
||||
current_user: TokenData = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
verify_tenant_access(tenant_id, current_user)
|
||||
template = await _get_template(db, tenant_id, template_id, load_items=True)
|
||||
return _template_to_dict(template, len(template.items), include_items=True)
|
||||
|
||||
|
||||
@router.put("/{template_id}")
|
||||
async def update_template(
|
||||
body: TemplateUpdate,
|
||||
tenant_id: str = Path(...),
|
||||
template_id: UUID = Path(...),
|
||||
current_user: TokenData = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
verify_tenant_access(tenant_id, current_user)
|
||||
template = await _get_template(db, tenant_id, template_id, load_items=True)
|
||||
|
||||
if body.subject_type is not None:
|
||||
_validate_subject_type(body.subject_type)
|
||||
template.subject_type = body.subject_type
|
||||
if body.schedule_type is not None:
|
||||
_validate_schedule_type(body.schedule_type)
|
||||
template.schedule_type = body.schedule_type
|
||||
if body.inspection_mode is not None:
|
||||
_validate_inspection_mode(body.inspection_mode)
|
||||
template.inspection_mode = body.inspection_mode
|
||||
if body.name is not None:
|
||||
template.name = body.name
|
||||
if body.machine_id is not None:
|
||||
template.machine_id = UUID(body.machine_id) if body.machine_id else None
|
||||
if body.product_code is not None:
|
||||
template.product_code = body.product_code
|
||||
|
||||
template.version = (template.version or 1) + 1
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
|
||||
return _template_to_dict(template, len(template.items), include_items=True)
|
||||
|
||||
|
||||
@router.delete("/{template_id}")
|
||||
async def delete_template(
|
||||
tenant_id: str = Path(...),
|
||||
template_id: UUID = Path(...),
|
||||
current_user: TokenData = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
verify_tenant_access(tenant_id, current_user)
|
||||
template = await _get_template(db, tenant_id, template_id)
|
||||
|
||||
template.is_active = False
|
||||
await db.commit()
|
||||
|
||||
return {"status": "success", "message": "검사 템플릿이 비활성화되었습니다."}
|
||||
|
||||
|
||||
@router.post("/{template_id}/items")
|
||||
async def create_item(
|
||||
body: ItemCreate,
|
||||
tenant_id: str = Path(...),
|
||||
template_id: UUID = Path(...),
|
||||
current_user: TokenData = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
verify_tenant_access(tenant_id, current_user)
|
||||
template = await _get_template(db, tenant_id, template_id, load_items=True)
|
||||
_validate_data_type(body.data_type)
|
||||
|
||||
max_order = max((i.sort_order for i in template.items), default=0)
|
||||
|
||||
item = InspectionTemplateItem(
|
||||
template_id=template.id,
|
||||
sort_order=max_order + 1,
|
||||
name=body.name,
|
||||
category=body.category,
|
||||
data_type=body.data_type,
|
||||
unit=body.unit,
|
||||
spec_min=body.spec_min,
|
||||
spec_max=body.spec_max,
|
||||
warning_min=body.warning_min,
|
||||
warning_max=body.warning_max,
|
||||
trend_window=body.trend_window,
|
||||
select_options=body.select_options,
|
||||
equipment_part_id=UUID(body.equipment_part_id)
|
||||
if body.equipment_part_id
|
||||
else None,
|
||||
is_required=body.is_required,
|
||||
)
|
||||
db.add(item)
|
||||
|
||||
template.version = (template.version or 1) + 1
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
|
||||
return _item_to_dict(item)
|
||||
|
||||
|
||||
@router.put("/{template_id}/items/reorder")
|
||||
async def reorder_items(
|
||||
body: ReorderRequest,
|
||||
tenant_id: str = Path(...),
|
||||
template_id: UUID = Path(...),
|
||||
current_user: TokenData = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
verify_tenant_access(tenant_id, current_user)
|
||||
template = await _get_template(db, tenant_id, template_id, load_items=True)
|
||||
|
||||
item_map = {str(i.id): i for i in template.items}
|
||||
|
||||
if set(body.item_ids) != set(item_map.keys()):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="item_ids는 해당 템플릿의 모든 항목 ID를 포함해야 합니다.",
|
||||
)
|
||||
|
||||
for idx, item_id_str in enumerate(body.item_ids):
|
||||
item_map[item_id_str].sort_order = idx + 1
|
||||
|
||||
template.version = (template.version or 1) + 1
|
||||
|
||||
await db.commit()
|
||||
db.expire_all()
|
||||
|
||||
stmt = (
|
||||
select(InspectionTemplate)
|
||||
.options(selectinload(InspectionTemplate.items))
|
||||
.where(InspectionTemplate.id == template_id)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
refreshed = result.scalar_one()
|
||||
|
||||
return _template_to_dict(refreshed, len(refreshed.items), include_items=True)
|
||||
|
||||
|
||||
@router.put("/{template_id}/items/{item_id}")
|
||||
async def update_item(
|
||||
body: ItemUpdate,
|
||||
tenant_id: str = Path(...),
|
||||
template_id: UUID = Path(...),
|
||||
item_id: UUID = Path(...),
|
||||
current_user: TokenData = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
verify_tenant_access(tenant_id, current_user)
|
||||
await _get_template(db, tenant_id, template_id)
|
||||
|
||||
stmt = select(InspectionTemplateItem).where(
|
||||
InspectionTemplateItem.id == item_id,
|
||||
InspectionTemplateItem.template_id == template_id,
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
item = result.scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="검사 항목을 찾을 수 없습니다.")
|
||||
|
||||
if body.name is not None:
|
||||
item.name = body.name
|
||||
if body.category is not None:
|
||||
item.category = body.category
|
||||
if body.data_type is not None:
|
||||
_validate_data_type(body.data_type)
|
||||
item.data_type = body.data_type
|
||||
if body.unit is not None:
|
||||
item.unit = body.unit
|
||||
if body.spec_min is not None:
|
||||
item.spec_min = body.spec_min
|
||||
if body.spec_max is not None:
|
||||
item.spec_max = body.spec_max
|
||||
if body.warning_min is not None:
|
||||
item.warning_min = body.warning_min
|
||||
if body.warning_max is not None:
|
||||
item.warning_max = body.warning_max
|
||||
if body.trend_window is not None:
|
||||
item.trend_window = body.trend_window
|
||||
if body.select_options is not None:
|
||||
item.select_options = body.select_options
|
||||
if body.equipment_part_id is not None:
|
||||
item.equipment_part_id = (
|
||||
UUID(body.equipment_part_id) if body.equipment_part_id else None
|
||||
)
|
||||
if body.is_required is not None:
|
||||
item.is_required = body.is_required
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
|
||||
return _item_to_dict(item)
|
||||
|
||||
|
||||
@router.delete("/{template_id}/items/{item_id}")
|
||||
async def delete_item(
|
||||
tenant_id: str = Path(...),
|
||||
template_id: UUID = Path(...),
|
||||
item_id: UUID = Path(...),
|
||||
current_user: TokenData = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
verify_tenant_access(tenant_id, current_user)
|
||||
await _get_template(db, tenant_id, template_id)
|
||||
|
||||
stmt = select(InspectionTemplateItem).where(
|
||||
InspectionTemplateItem.id == item_id,
|
||||
InspectionTemplateItem.template_id == template_id,
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
item = result.scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="검사 항목을 찾을 수 없습니다.")
|
||||
|
||||
deleted_order = item.sort_order
|
||||
await db.delete(item)
|
||||
await db.flush()
|
||||
|
||||
remaining_stmt = (
|
||||
select(InspectionTemplateItem)
|
||||
.where(
|
||||
InspectionTemplateItem.template_id == template_id,
|
||||
InspectionTemplateItem.sort_order > deleted_order,
|
||||
)
|
||||
.order_by(InspectionTemplateItem.sort_order)
|
||||
)
|
||||
remaining_result = await db.execute(remaining_stmt)
|
||||
for remaining_item in remaining_result.scalars().all():
|
||||
remaining_item.sort_order -= 1
|
||||
|
||||
template_stmt = select(InspectionTemplate).where(
|
||||
InspectionTemplate.id == template_id
|
||||
)
|
||||
template = (await db.execute(template_stmt)).scalar_one()
|
||||
template.version = (template.version or 1) + 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"status": "success", "message": "검사 항목이 삭제되었습니다."}
|
||||
@@ -14,7 +14,7 @@ from sqlalchemy import (
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB, TIMESTAMP
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.orm import relationship, backref
|
||||
|
||||
from src.database.config import Base
|
||||
|
||||
@@ -164,3 +164,72 @@ class PartReplacementLog(Base):
|
||||
Index("ix_part_replacement_tenant_part", "tenant_id", "equipment_part_id"),
|
||||
Index("ix_part_replacement_tenant_date", "tenant_id", "replaced_at"),
|
||||
)
|
||||
|
||||
|
||||
class InspectionTemplate(Base):
|
||||
__tablename__ = "inspection_templates"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(String(50), ForeignKey("tenants.id"), nullable=False)
|
||||
name = Column(String(200), nullable=False)
|
||||
subject_type = Column(String(20), nullable=False) # equipment | product
|
||||
machine_id = Column(UUID(as_uuid=True), ForeignKey("machines.id"), nullable=True)
|
||||
product_code = Column(String(50), nullable=True)
|
||||
schedule_type = Column(
|
||||
String(20), nullable=False
|
||||
) # daily | weekly | monthly | yearly | ad_hoc
|
||||
inspection_mode = Column(
|
||||
String(20), default="measurement"
|
||||
) # checklist | measurement | monitoring
|
||||
version = Column(Integer, default=1)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(TIMESTAMP(timezone=True), default=utcnow)
|
||||
updated_at = Column(TIMESTAMP(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
|
||||
tenant = relationship("Tenant")
|
||||
machine = relationship("Machine")
|
||||
items = relationship(
|
||||
"InspectionTemplateItem",
|
||||
back_populates="template",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="InspectionTemplateItem.sort_order",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_templates_tenant_subject", "tenant_id", "subject_type"),
|
||||
Index("ix_templates_tenant_machine", "tenant_id", "machine_id"),
|
||||
)
|
||||
|
||||
|
||||
class InspectionTemplateItem(Base):
|
||||
__tablename__ = "inspection_template_items"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
template_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("inspection_templates.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
sort_order = Column(Integer, nullable=False)
|
||||
name = Column(String(200), nullable=False)
|
||||
category = Column(String(100), nullable=True)
|
||||
data_type = Column(String(20), nullable=False) # numeric | boolean | text | select
|
||||
unit = Column(String(20), nullable=True)
|
||||
spec_min = Column(Float, nullable=True)
|
||||
spec_max = Column(Float, nullable=True)
|
||||
warning_min = Column(Float, nullable=True)
|
||||
warning_max = Column(Float, nullable=True)
|
||||
trend_window = Column(Integer, nullable=True)
|
||||
select_options = Column(JSONB, nullable=True)
|
||||
equipment_part_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("equipment_parts.id"), nullable=True
|
||||
)
|
||||
is_required = Column(Boolean, default=True)
|
||||
created_at = Column(TIMESTAMP(timezone=True), default=utcnow)
|
||||
|
||||
template = relationship("InspectionTemplate", back_populates="items")
|
||||
equipment_part = relationship("EquipmentPart")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_template_items_template_order", "template_id", "sort_order"),
|
||||
)
|
||||
|
||||
389
tests/test_templates.py
Normal file
389
tests/test_templates.py
Normal file
@@ -0,0 +1,389 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from tests.conftest import get_auth_headers
|
||||
|
||||
|
||||
async def _create_machine(
|
||||
client: AsyncClient, headers: dict, tenant_id: str = "test-co"
|
||||
) -> str:
|
||||
resp = await client.post(
|
||||
f"/api/{tenant_id}/machines",
|
||||
json={"name": "테스트 설비", "equipment_code": "T-001"},
|
||||
headers=headers,
|
||||
)
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
async def _create_template(
|
||||
client: AsyncClient,
|
||||
headers: dict,
|
||||
tenant_id: str = "test-co",
|
||||
name: str = "일일 설비검사",
|
||||
subject_type: str = "equipment",
|
||||
schedule_type: str = "daily",
|
||||
machine_id: str = None,
|
||||
items: list = None,
|
||||
) -> dict:
|
||||
body = {
|
||||
"name": name,
|
||||
"subject_type": subject_type,
|
||||
"schedule_type": schedule_type,
|
||||
"inspection_mode": "measurement",
|
||||
}
|
||||
if machine_id:
|
||||
body["machine_id"] = machine_id
|
||||
if items:
|
||||
body["items"] = items
|
||||
resp = await client.post(f"/api/{tenant_id}/templates", json=body, headers=headers)
|
||||
assert resp.status_code == 200, f"Template create failed: {resp.json()}"
|
||||
return resp.json()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_template_with_items(client: AsyncClient, seeded_db):
|
||||
headers = await get_auth_headers(client)
|
||||
machine_id = await _create_machine(client, headers)
|
||||
|
||||
data = await _create_template(
|
||||
client,
|
||||
headers,
|
||||
machine_id=machine_id,
|
||||
items=[
|
||||
{
|
||||
"name": "유압 압력",
|
||||
"data_type": "numeric",
|
||||
"unit": "bar",
|
||||
"spec_min": 10.0,
|
||||
"spec_max": 20.0,
|
||||
},
|
||||
{
|
||||
"name": "외관 상태",
|
||||
"data_type": "boolean",
|
||||
},
|
||||
{
|
||||
"name": "비고",
|
||||
"data_type": "text",
|
||||
"is_required": False,
|
||||
},
|
||||
],
|
||||
)
|
||||
assert data["name"] == "일일 설비검사"
|
||||
assert data["subject_type"] == "equipment"
|
||||
assert data["machine_id"] == machine_id
|
||||
assert data["version"] == 1
|
||||
assert data["items_count"] == 3
|
||||
assert len(data["items"]) == 3
|
||||
assert data["items"][0]["name"] == "유압 압력"
|
||||
assert data["items"][0]["sort_order"] == 1
|
||||
assert data["items"][0]["spec_min"] == 10.0
|
||||
assert data["items"][1]["name"] == "외관 상태"
|
||||
assert data["items"][1]["sort_order"] == 2
|
||||
assert data["items"][2]["is_required"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_template_no_items(client: AsyncClient, seeded_db):
|
||||
headers = await get_auth_headers(client)
|
||||
|
||||
data = await _create_template(
|
||||
client,
|
||||
headers,
|
||||
name="빈 템플릿",
|
||||
subject_type="product",
|
||||
schedule_type="weekly",
|
||||
)
|
||||
assert data["subject_type"] == "product"
|
||||
assert data["items_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_templates(client: AsyncClient, seeded_db):
|
||||
headers = await get_auth_headers(client)
|
||||
|
||||
await _create_template(client, headers, name="설비검사A", subject_type="equipment")
|
||||
await _create_template(client, headers, name="품질검사A", subject_type="product")
|
||||
await _create_template(client, headers, name="설비검사B", subject_type="equipment")
|
||||
|
||||
resp = await client.get("/api/test-co/templates", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["templates"]) == 3
|
||||
|
||||
resp2 = await client.get(
|
||||
"/api/test-co/templates?subject_type=equipment", headers=headers
|
||||
)
|
||||
assert len(resp2.json()["templates"]) == 2
|
||||
|
||||
resp3 = await client.get(
|
||||
"/api/test-co/templates?subject_type=product", headers=headers
|
||||
)
|
||||
assert len(resp3.json()["templates"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_template_detail(client: AsyncClient, seeded_db):
|
||||
headers = await get_auth_headers(client)
|
||||
|
||||
created = await _create_template(
|
||||
client,
|
||||
headers,
|
||||
items=[{"name": "온도", "data_type": "numeric", "unit": "℃"}],
|
||||
)
|
||||
template_id = created["id"]
|
||||
|
||||
resp = await client.get(f"/api/test-co/templates/{template_id}", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == template_id
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["unit"] == "℃"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_template_increments_version(client: AsyncClient, seeded_db):
|
||||
headers = await get_auth_headers(client)
|
||||
|
||||
created = await _create_template(client, headers)
|
||||
template_id = created["id"]
|
||||
assert created["version"] == 1
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/test-co/templates/{template_id}",
|
||||
json={"name": "수정된 설비검사", "schedule_type": "weekly"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "수정된 설비검사"
|
||||
assert data["schedule_type"] == "weekly"
|
||||
assert data["version"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_template_soft_delete(client: AsyncClient, seeded_db):
|
||||
headers = await get_auth_headers(client)
|
||||
|
||||
created = await _create_template(client, headers)
|
||||
template_id = created["id"]
|
||||
|
||||
resp = await client.delete(f"/api/test-co/templates/{template_id}", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "success"
|
||||
|
||||
list_resp = await client.get("/api/test-co/templates", headers=headers)
|
||||
assert len(list_resp.json()["templates"]) == 0
|
||||
|
||||
detail_resp = await client.get(
|
||||
f"/api/test-co/templates/{template_id}", headers=headers
|
||||
)
|
||||
assert detail_resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_item_to_template(client: AsyncClient, seeded_db):
|
||||
headers = await get_auth_headers(client)
|
||||
|
||||
created = await _create_template(client, headers)
|
||||
template_id = created["id"]
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/test-co/templates/{template_id}/items",
|
||||
json={
|
||||
"name": "습도",
|
||||
"data_type": "numeric",
|
||||
"unit": "%",
|
||||
"spec_min": 30.0,
|
||||
"spec_max": 70.0,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
item = resp.json()
|
||||
assert item["name"] == "습도"
|
||||
assert item["sort_order"] == 1
|
||||
assert item["spec_min"] == 30.0
|
||||
|
||||
detail = await client.get(f"/api/test-co/templates/{template_id}", headers=headers)
|
||||
assert detail.json()["version"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_item(client: AsyncClient, seeded_db):
|
||||
headers = await get_auth_headers(client)
|
||||
|
||||
created = await _create_template(
|
||||
client,
|
||||
headers,
|
||||
items=[{"name": "압력", "data_type": "numeric", "unit": "bar"}],
|
||||
)
|
||||
template_id = created["id"]
|
||||
item_id = created["items"][0]["id"]
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/test-co/templates/{template_id}/items/{item_id}",
|
||||
json={"name": "유압 압력", "spec_min": 5.0, "spec_max": 15.0},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "유압 압력"
|
||||
assert resp.json()["spec_min"] == 5.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_item_reorders(client: AsyncClient, seeded_db):
|
||||
headers = await get_auth_headers(client)
|
||||
|
||||
created = await _create_template(
|
||||
client,
|
||||
headers,
|
||||
items=[
|
||||
{"name": "항목1", "data_type": "boolean"},
|
||||
{"name": "항목2", "data_type": "boolean"},
|
||||
{"name": "항목3", "data_type": "boolean"},
|
||||
],
|
||||
)
|
||||
template_id = created["id"]
|
||||
item_to_delete = created["items"][0]["id"]
|
||||
|
||||
resp = await client.delete(
|
||||
f"/api/test-co/templates/{template_id}/items/{item_to_delete}",
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
detail = await client.get(f"/api/test-co/templates/{template_id}", headers=headers)
|
||||
items = detail.json()["items"]
|
||||
assert len(items) == 2
|
||||
assert items[0]["name"] == "항목2"
|
||||
assert items[0]["sort_order"] == 1
|
||||
assert items[1]["name"] == "항목3"
|
||||
assert items[1]["sort_order"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reorder_items(client: AsyncClient, seeded_db):
|
||||
headers = await get_auth_headers(client)
|
||||
|
||||
created = await _create_template(
|
||||
client,
|
||||
headers,
|
||||
items=[
|
||||
{"name": "A", "data_type": "boolean"},
|
||||
{"name": "B", "data_type": "boolean"},
|
||||
{"name": "C", "data_type": "boolean"},
|
||||
],
|
||||
)
|
||||
template_id = created["id"]
|
||||
ids = [item["id"] for item in created["items"]]
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/test-co/templates/{template_id}/items/reorder",
|
||||
json={"item_ids": [ids[2], ids[0], ids[1]]},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
items = resp.json()["items"]
|
||||
assert items[0]["name"] == "C"
|
||||
assert items[0]["sort_order"] == 1
|
||||
assert items[1]["name"] == "A"
|
||||
assert items[1]["sort_order"] == 2
|
||||
assert items[2]["name"] == "B"
|
||||
assert items[2]["sort_order"] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_select_data_type_with_options(client: AsyncClient, seeded_db):
|
||||
headers = await get_auth_headers(client)
|
||||
|
||||
created = await _create_template(
|
||||
client,
|
||||
headers,
|
||||
items=[
|
||||
{
|
||||
"name": "외관 상태",
|
||||
"data_type": "select",
|
||||
"select_options": ["양호", "불량", "N/A"],
|
||||
},
|
||||
],
|
||||
)
|
||||
item = created["items"][0]
|
||||
assert item["data_type"] == "select"
|
||||
assert item["select_options"] == ["양호", "불량", "N/A"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_subject_type(client: AsyncClient, seeded_db):
|
||||
headers = await get_auth_headers(client)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/test-co/templates",
|
||||
json={
|
||||
"name": "잘못된 템플릿",
|
||||
"subject_type": "invalid",
|
||||
"schedule_type": "daily",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_data_type(client: AsyncClient, seeded_db):
|
||||
headers = await get_auth_headers(client)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/test-co/templates",
|
||||
json={
|
||||
"name": "잘못된 항목",
|
||||
"subject_type": "equipment",
|
||||
"schedule_type": "daily",
|
||||
"items": [{"name": "잘못", "data_type": "invalid_type"}],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_isolation(client: AsyncClient, seeded_db):
|
||||
headers = await get_auth_headers(client)
|
||||
|
||||
await _create_template(client, headers, name="테스트 템플릿")
|
||||
|
||||
other_headers = await get_auth_headers(
|
||||
client, email="admin@test-co.com", password="pass1234"
|
||||
)
|
||||
|
||||
resp = await client.get("/api/other-co/templates", headers=other_headers)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_equipment_part_link(client: AsyncClient, seeded_db):
|
||||
headers = await get_auth_headers(client)
|
||||
machine_id = await _create_machine(client, headers)
|
||||
|
||||
part_resp = await client.post(
|
||||
f"/api/test-co/machines/{machine_id}/parts",
|
||||
json={
|
||||
"name": "베어링",
|
||||
"lifecycle_type": "hours",
|
||||
"lifecycle_limit": 5000,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
part_id = part_resp.json()["id"]
|
||||
|
||||
created = await _create_template(
|
||||
client,
|
||||
headers,
|
||||
machine_id=machine_id,
|
||||
items=[
|
||||
{
|
||||
"name": "베어링 온도",
|
||||
"data_type": "numeric",
|
||||
"unit": "℃",
|
||||
"equipment_part_id": part_id,
|
||||
},
|
||||
],
|
||||
)
|
||||
assert created["items"][0]["equipment_part_id"] == part_id
|
||||
Reference in New Issue
Block a user