From 7de011621dd650c604fb616ec2ee91887d382146 Mon Sep 17 00:00:00 2001 From: Johngreen Date: Tue, 10 Feb 2026 13:24:30 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=203=20=E2=80=94=20inspection=20te?= =?UTF-8?q?mplates=20(backend=20+=20frontend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitea/workflows/deploy.yml | 1 - alembic/env.py | 11 +- .../9566bf2a256b_add_inspection_templates.py | 76 +++ .../app/[tenant]/templates/[id]/page.tsx | 148 +++++ dashboard/app/[tenant]/templates/new/page.tsx | 69 +++ dashboard/app/[tenant]/templates/page.tsx | 129 +++++ dashboard/app/globals.css | 248 +++++++- dashboard/components/TemplateEditor.tsx | 429 ++++++++++++++ dashboard/lib/hooks.ts | 35 +- dashboard/lib/types.ts | 36 ++ main.py | 12 +- src/api/templates.py | 533 ++++++++++++++++++ src/database/models.py | 71 ++- tests/test_templates.py | 389 +++++++++++++ 14 files changed, 2177 insertions(+), 10 deletions(-) create mode 100644 alembic/versions/9566bf2a256b_add_inspection_templates.py create mode 100644 dashboard/app/[tenant]/templates/[id]/page.tsx create mode 100644 dashboard/app/[tenant]/templates/new/page.tsx create mode 100644 dashboard/app/[tenant]/templates/page.tsx create mode 100644 dashboard/components/TemplateEditor.tsx create mode 100644 src/api/templates.py create mode 100644 tests/test_templates.py 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 ( +
+
+
+

기본 정보

+
+
+
+ + handleMetaChange('name', e.target.value)} + disabled={submitting} + autoFocus + /> +
+ +
+
+ +
+ + +
+
+
+ + {meta.subject_type === 'equipment' && ( +
+ + +
+ )} + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+

검사 항목 ({items.length}개)

+ +
+
+ {items.length === 0 ? ( +
+ playlist_add +

검사 항목이 없습니다. 항목을 추가하세요.

+
+ ) : ( +
+ {items.map((item, idx) => ( +
+
+ + {idx + 1} + +
+
+
+
+ updateItem(idx, 'name', e.target.value)} + disabled={submitting} + /> +
+
+ +
+ +
+ {item.data_type === 'numeric' && ( +
+
+ updateItem(idx, 'unit', e.target.value)} disabled={submitting} /> +
+
+ updateItem(idx, 'spec_min', e.target.value)} disabled={submitting} step="any" /> +
+
+ updateItem(idx, 'spec_max', e.target.value)} disabled={submitting} step="any" /> +
+
+ )} + {item.data_type === 'select' && ( +
+ updateItem(idx, 'select_options_text', e.target.value)} + disabled={submitting} + /> +
+ )} +
+ +
+ ))} +
+ )} +
+
+ + {showAddItem && ( +
setShowAddItem(false)}> +
e.stopPropagation()}> +
+

검사 항목 추가

+ +
+
+ + setNewItem(prev => ({ ...prev, name: e.target.value }))} + autoFocus + /> +
+
+
+ + +
+
+ + setNewItem(prev => ({ ...prev, category: e.target.value }))} /> +
+
+ {newItem.data_type === 'numeric' && ( +
+
+ + setNewItem(prev => ({ ...prev, unit: e.target.value }))} /> +
+
+ + setNewItem(prev => ({ ...prev, spec_min: e.target.value }))} step="any" /> +
+
+ + setNewItem(prev => ({ ...prev, spec_max: e.target.value }))} step="any" /> +
+
+ )} + {newItem.data_type === 'select' && ( +
+ + setNewItem(prev => ({ ...prev, select_options_text: e.target.value }))} + /> +
+ )} + +
+ + +
+
+
+ )} + +
+ +
+
+ ); +} + +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