feat: Phase 3 — inspection templates (backend + frontend)
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:
Johngreen
2026-02-10 13:24:30 +09:00
parent ee691be3ce
commit 7de011621d
14 changed files with 2177 additions and 10 deletions

View File

@@ -42,7 +42,6 @@ jobs:
cd ~/factoryops && ~/.local/bin/docker-compose -f docker-compose.prod.yml down 2>/dev/null || true cd ~/factoryops && ~/.local/bin/docker-compose -f docker-compose.prod.yml down 2>/dev/null || true
cd ~/factoryops-v2 cd ~/factoryops-v2
~/.local/bin/docker-compose -f docker-compose.prod.yml down 2>/dev/null || true ~/.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 ~/.local/bin/docker-compose -f docker-compose.prod.yml up -d --build --force-recreate
echo "=== Waiting 15s for containers to stabilize ===" echo "=== Waiting 15s for containers to stabilize ==="
sleep 15 sleep 15

View File

@@ -17,7 +17,16 @@ if db_url:
config.set_main_option("sqlalchemy.url", db_url) config.set_main_option("sqlalchemy.url", db_url)
from src.database.config import Base 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 target_metadata = Base.metadata

View 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 ###

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -1019,6 +1019,243 @@ a {
margin: 0; 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 ===== */ /* ===== Responsive ===== */
@media (max-width: 768px) { @media (max-width: 768px) {
.topnav-center { .topnav-center {
@@ -1029,7 +1266,8 @@ a {
display: none; display: none;
} }
.machine-grid { .machine-grid,
.template-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1037,6 +1275,14 @@ a {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.form-row {
flex-direction: column;
}
.item-row {
flex-direction: column;
}
.part-table { .part-table {
font-size: 12px; font-size: 12px;
} }

View 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 };

View File

@@ -1,6 +1,6 @@
import useSWR from 'swr'; import useSWR from 'swr';
import { fetcher, getTenantUrl } from './api'; 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() { export function useTenants() {
const { data, error, isLoading, mutate } = useSWR<{ tenants: Tenant[] }>( const { data, error, isLoading, mutate } = useSWR<{ tenants: Tenant[] }>(
@@ -75,3 +75,36 @@ export function useEquipmentParts(tenantId?: string, machineId?: string) {
mutate, 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,
};
}

View File

@@ -61,3 +61,39 @@ export interface EquipmentPart {
export interface MachineDetail extends Machine { export interface MachineDetail extends Machine {
parts: EquipmentPart[]; 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
View File

@@ -21,6 +21,7 @@ from src.tenant import manager as tenant_manager
from src.tenant.manager import TenantNotFoundError, InvalidTenantIdError from src.tenant.manager import TenantNotFoundError, InvalidTenantIdError
from src.api.machines import router as machines_router from src.api.machines import router as machines_router
from src.api.equipment_parts import router as equipment_parts_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__) logger = logging.getLogger(__name__)
@@ -34,11 +35,6 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="FactoryOps v2 API", lifespan=lifespan) 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 = ( CORS_ORIGINS = (
os.getenv("CORS_ORIGINS", "").split(",") if os.getenv("CORS_ORIGINS") else [] os.getenv("CORS_ORIGINS", "").split(",") if os.getenv("CORS_ORIGINS") else []
) )
@@ -52,6 +48,12 @@ app.add_middleware(
allow_headers=["*"], 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") @app.get("/api/health")
async def health_check(): async def health_check():

533
src/api/templates.py Normal file
View 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": "검사 항목이 삭제되었습니다."}

View File

@@ -14,7 +14,7 @@ from sqlalchemy import (
text, text,
) )
from sqlalchemy.dialects.postgresql import UUID, JSONB, TIMESTAMP 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 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_part", "tenant_id", "equipment_part_id"),
Index("ix_part_replacement_tenant_date", "tenant_id", "replaced_at"), 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
View 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