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