diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index c2c09c5..58f74f5 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -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
diff --git a/alembic/env.py b/alembic/env.py
index 5142784..d27d213 100644
--- a/alembic/env.py
+++ b/alembic/env.py
@@ -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
diff --git a/alembic/versions/9566bf2a256b_add_inspection_templates.py b/alembic/versions/9566bf2a256b_add_inspection_templates.py
new file mode 100644
index 0000000..eb579ea
--- /dev/null
+++ b/alembic/versions/9566bf2a256b_add_inspection_templates.py
@@ -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 ###
diff --git a/dashboard/app/[tenant]/templates/[id]/page.tsx b/dashboard/app/[tenant]/templates/[id]/page.tsx
new file mode 100644
index 0000000..5bf1399
--- /dev/null
+++ b/dashboard/app/[tenant]/templates/[id]/page.tsx
@@ -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 (
+
+ progress_activity
+
+ );
+ }
+
+ if (error || !template) {
+ return (
+
+
error
+
템플릿을 찾을 수 없습니다.
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ edit
+ 템플릿 편집
+
+ v{template.version}
+
+
+
+
+ );
+}
diff --git a/dashboard/app/[tenant]/templates/new/page.tsx b/dashboard/app/[tenant]/templates/new/page.tsx
new file mode 100644
index 0000000..2276133
--- /dev/null
+++ b/dashboard/app/[tenant]/templates/new/page.tsx
@@ -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 (
+
+
+
+
+
+
+
+ add
+ 새 검사 템플릿
+
+
+
+
+
+ );
+}
diff --git a/dashboard/app/[tenant]/templates/page.tsx b/dashboard/app/[tenant]/templates/page.tsx
new file mode 100644
index 0000000..cd197bb
--- /dev/null
+++ b/dashboard/app/[tenant]/templates/page.tsx
@@ -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('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 (
+
+
error
+
데이터를 불러오는 데 실패했습니다.
+
+ );
+ }
+
+ return (
+
+
+
+ assignment
+ 검사 템플릿
+
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+ progress_activity
+
+ ) : templates.length === 0 ? (
+
+
assignment
+
{activeTab === 'equipment' ? '설비검사' : '품질검사'} 템플릿이 없습니다.
+
+
+ ) : (
+
+ {templates.map((t) => (
+
+
router.push(`/${tenantId}/templates/${t.id}`)}
+ >
+
+
assignment
+
+ {SCHEDULE_LABELS[t.schedule_type] || t.schedule_type}
+ {MODE_LABELS[t.inspection_mode] || t.inspection_mode}
+
+
+
+ {t.name}
+
+ 항목 {t.items_count}개 · v{t.version}
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/dashboard/app/globals.css b/dashboard/app/globals.css
index c05a53e..ca42a0c 100644
--- a/dashboard/app/globals.css
+++ b/dashboard/app/globals.css
@@ -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;
}
diff --git a/dashboard/components/TemplateEditor.tsx b/dashboard/components/TemplateEditor.tsx
new file mode 100644
index 0000000..a2293a9
--- /dev/null
+++ b/dashboard/components/TemplateEditor.tsx
@@ -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 = {
+ daily: '일일',
+ weekly: '주간',
+ monthly: '월간',
+ yearly: '연간',
+ ad_hoc: '수시',
+};
+
+const MODE_LABELS: Record = {
+ checklist: '체크리스트',
+ measurement: '측정',
+ monitoring: '모니터링',
+};
+
+const DATA_TYPE_LABELS: Record = {
+ 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;
+ 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({
+ 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(
+ initialData?.items?.map(itemToItemDraft) || []
+ );
+ const [submitting, setSubmitting] = useState(false);
+ const [showAddItem, setShowAddItem] = useState(false);
+ const [newItem, setNewItem] = useState({ ...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 (
+
+ );
+}
+
+export { SCHEDULE_LABELS, MODE_LABELS, DATA_TYPE_LABELS };
+export type { TemplateDraft, ItemDraft };
diff --git a/dashboard/lib/hooks.ts b/dashboard/lib/hooks.ts
index 333e289..98434e9 100644
--- a/dashboard/lib/hooks.ts
+++ b/dashboard/lib/hooks.ts
@@ -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(
+ url,
+ fetcher,
+ { refreshInterval: 30000, dedupingInterval: 2000 },
+ );
+
+ return {
+ template: data ?? null,
+ error,
+ isLoading,
+ mutate,
+ };
+}
diff --git a/dashboard/lib/types.ts b/dashboard/lib/types.ts
index 795ba23..dd9d4ad 100644
--- a/dashboard/lib/types.ts
+++ b/dashboard/lib/types.ts
@@ -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;
+}
diff --git a/main.py b/main.py
index f875991..fef1811 100644
--- a/main.py
+++ b/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():
diff --git a/src/api/templates.py b/src/api/templates.py
new file mode 100644
index 0000000..7e12978
--- /dev/null
+++ b/src/api/templates.py
@@ -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": "검사 항목이 삭제되었습니다."}
diff --git a/src/database/models.py b/src/database/models.py
index 5f44505..15c613f 100644
--- a/src/database/models.py
+++ b/src/database/models.py
@@ -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"),
+ )
diff --git a/tests/test_templates.py b/tests/test_templates.py
new file mode 100644
index 0000000..0bf4b39
--- /dev/null
+++ b/tests/test_templates.py
@@ -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