feat: Phase 4 — inspection sessions (create, execute, complete)
All checks were successful
Deploy to Production / deploy (push) Successful in 1m5s

Backend:
- InspectionSession + InspectionRecord models with alembic migration
- 6 API endpoints: create, list, get detail, save records, complete, delete
- Auto pass/fail judgment for numeric (spec range) and boolean items
- Completed inspections are immutable, required items enforced on complete
- 14 new tests (total 53/53 passed)

Frontend:
- Inspection list page with in_progress/completed tabs
- Template select modal for starting new inspections
- Inspection execution page with data-type-specific inputs
- Auto-save with 1.5s debounce, manual save button
- Completion modal with notes and required item validation
- Read-only view for completed inspections
- Pass/fail badges and color-coded item cards

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
Johngreen
2026-02-10 13:46:23 +09:00
parent 180cc5b163
commit 581c845f54
10 changed files with 2192 additions and 1 deletions

View File

@@ -0,0 +1,111 @@
"""add_inspection_sessions
Revision ID: b4c5d6e7f8a9
Revises: 9566bf2a256b
Create Date: 2026-02-10 13:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "b4c5d6e7f8a9"
down_revision: Union[str, None] = "9566bf2a256b"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"inspection_sessions",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"tenant_id", sa.String(50), sa.ForeignKey("tenants.id"), nullable=False
),
sa.Column(
"template_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("inspection_templates.id"),
nullable=False,
),
sa.Column(
"inspector_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id"),
nullable=False,
),
sa.Column("status", sa.String(20), nullable=False, server_default="draft"),
sa.Column("started_at", postgresql.TIMESTAMP(timezone=True), nullable=True),
sa.Column("completed_at", postgresql.TIMESTAMP(timezone=True), nullable=True),
sa.Column("notes", sa.Text, nullable=True),
sa.Column(
"created_at",
postgresql.TIMESTAMP(timezone=True),
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
postgresql.TIMESTAMP(timezone=True),
server_default=sa.text("now()"),
),
)
op.create_index(
"ix_sessions_tenant_status", "inspection_sessions", ["tenant_id", "status"]
)
op.create_index(
"ix_sessions_tenant_template",
"inspection_sessions",
["tenant_id", "template_id"],
)
op.create_index(
"ix_sessions_tenant_inspector",
"inspection_sessions",
["tenant_id", "inspector_id"],
)
op.create_table(
"inspection_records",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"session_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("inspection_sessions.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"template_item_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("inspection_template_items.id"),
nullable=False,
),
sa.Column("value_numeric", sa.Float, nullable=True),
sa.Column("value_boolean", sa.Boolean, nullable=True),
sa.Column("value_text", sa.Text, nullable=True),
sa.Column("value_select", sa.String(200), nullable=True),
sa.Column("is_pass", sa.Boolean, nullable=True),
sa.Column("recorded_at", postgresql.TIMESTAMP(timezone=True), nullable=True),
sa.Column(
"created_at",
postgresql.TIMESTAMP(timezone=True),
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
postgresql.TIMESTAMP(timezone=True),
server_default=sa.text("now()"),
),
)
op.create_index("ix_records_session", "inspection_records", ["session_id"])
op.create_unique_constraint(
"uq_record_session_item",
"inspection_records",
["session_id", "template_item_id"],
)
def downgrade() -> None:
op.drop_table("inspection_records")
op.drop_table("inspection_sessions")

View File

@@ -0,0 +1,373 @@
'use client';
import { useState, useCallback, useRef, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useInspection } from '@/lib/hooks';
import { useToast } from '@/lib/toast-context';
import { api } from '@/lib/api';
import type { InspectionItemDetail } from '@/lib/types';
interface LocalValues {
[templateItemId: string]: {
value_numeric?: number | null;
value_boolean?: boolean | null;
value_text?: string | null;
value_select?: string | null;
};
}
export default function InspectionDetailPage() {
const params = useParams();
const router = useRouter();
const tenantId = params?.tenant as string;
const inspectionId = params?.id as string;
const { addToast } = useToast();
const { inspection, isLoading, error, mutate } = useInspection(tenantId, inspectionId);
const [localValues, setLocalValues] = useState<LocalValues>({});
const [saving, setSaving] = useState(false);
const [completing, setCompleting] = useState(false);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [completionNotes, setCompletionNotes] = useState('');
const [showCompleteModal, setShowCompleteModal] = useState(false);
const isCompleted = inspection?.status === 'completed';
const isEditable = !isCompleted;
useEffect(() => {
if (inspection?.items_detail) {
const initial: LocalValues = {};
for (const item of inspection.items_detail) {
const r = item.record;
initial[r.template_item_id] = {
value_numeric: r.value_numeric,
value_boolean: r.value_boolean,
value_text: r.value_text,
value_select: r.value_select,
};
}
setLocalValues(initial);
}
}, [inspection?.id]);
const saveRecords = useCallback(async (values: LocalValues) => {
if (!inspection || isCompleted) return;
const records = Object.entries(values)
.filter(([, v]) => v.value_numeric !== null || v.value_boolean !== null || v.value_text !== null || v.value_select !== null)
.map(([templateItemId, v]) => ({
template_item_id: templateItemId,
...(v.value_numeric !== undefined && v.value_numeric !== null ? { value_numeric: v.value_numeric } : {}),
...(v.value_boolean !== undefined && v.value_boolean !== null ? { value_boolean: v.value_boolean } : {}),
...(v.value_text !== undefined && v.value_text !== null ? { value_text: v.value_text } : {}),
...(v.value_select !== undefined && v.value_select !== null ? { value_select: v.value_select } : {}),
}));
if (records.length === 0) return;
setSaving(true);
try {
await api.put(`/api/${tenantId}/inspections/${inspectionId}/records`, { records });
mutate();
} catch {
addToast('저장에 실패했습니다.', 'error');
} finally {
setSaving(false);
}
}, [inspection, isCompleted, tenantId, inspectionId, mutate, addToast]);
const debouncedSave = useCallback((values: LocalValues) => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => saveRecords(values), 1500);
}, [saveRecords]);
const handleNumericChange = useCallback((templateItemId: string, value: string) => {
const numVal = value === '' ? null : parseFloat(value);
setLocalValues((prev) => {
const next = { ...prev, [templateItemId]: { ...prev[templateItemId], value_numeric: numVal } };
debouncedSave(next);
return next;
});
}, [debouncedSave]);
const handleBooleanChange = useCallback((templateItemId: string, value: boolean) => {
setLocalValues((prev) => {
const next = { ...prev, [templateItemId]: { ...prev[templateItemId], value_boolean: value } };
debouncedSave(next);
return next;
});
}, [debouncedSave]);
const handleTextChange = useCallback((templateItemId: string, value: string) => {
setLocalValues((prev) => {
const next = { ...prev, [templateItemId]: { ...prev[templateItemId], value_text: value || null } };
debouncedSave(next);
return next;
});
}, [debouncedSave]);
const handleSelectChange = useCallback((templateItemId: string, value: string) => {
setLocalValues((prev) => {
const next = { ...prev, [templateItemId]: { ...prev[templateItemId], value_select: value || null } };
debouncedSave(next);
return next;
});
}, [debouncedSave]);
const handleSaveNow = useCallback(async () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
await saveRecords(localValues);
addToast('저장되었습니다.', 'success');
}, [saveRecords, localValues, addToast]);
const handleComplete = useCallback(async () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
await saveRecords(localValues);
setCompleting(true);
try {
await api.post(`/api/${tenantId}/inspections/${inspectionId}/complete`, {
notes: completionNotes || null,
});
addToast('검사가 완료되었습니다.', 'success');
setShowCompleteModal(false);
mutate();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '검사 완료에 실패했습니다.';
addToast(message, 'error');
} finally {
setCompleting(false);
}
}, [localValues, saveRecords, tenantId, inspectionId, completionNotes, mutate, addToast]);
const getPassClass = (item: InspectionItemDetail) => {
const r = item.record;
if (r.is_pass === true) return 'pass';
if (r.is_pass === false) return 'fail';
return '';
};
const getPassLabel = (item: InspectionItemDetail) => {
const r = item.record;
if (r.is_pass === true) return '합격';
if (r.is_pass === false) return '불합격';
return '-';
};
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
const d = new Date(dateStr);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
if (error) {
return (
<div className="page-error">
<span className="material-symbols-outlined">error</span>
<p> .</p>
</div>
);
}
if (isLoading || !inspection) {
return (
<div className="loading">
<span className="material-symbols-outlined spinning">progress_activity</span>
</div>
);
}
const filledCount = inspection.items_detail
? inspection.items_detail.filter((d) => d.record.recorded_at !== null).length
: 0;
const totalCount = inspection.items_detail?.length || 0;
return (
<div className="page-container">
<div className="page-header">
<div className="page-header-left">
<button className="btn-icon" onClick={() => router.push(`/${tenantId}/inspections`)}>
<span className="material-symbols-outlined">arrow_back</span>
</button>
<div>
<h2 className="page-title">{inspection.template_name || '검사'}</h2>
<span className="page-subtitle">
{inspection.inspector_name} · {formatDate(inspection.started_at)}
{isCompleted && ` · 완료: ${formatDate(inspection.completed_at)}`}
</span>
</div>
</div>
<div className="page-header-right">
{isEditable && (
<>
<span className="save-indicator">{saving ? '저장 중...' : `${filledCount}/${totalCount} 입력`}</span>
<button className="btn-outline" onClick={handleSaveNow} disabled={saving}>
<span className="material-symbols-outlined">save</span>
</button>
<button className="btn-primary" onClick={() => setShowCompleteModal(true)}>
<span className="material-symbols-outlined">check_circle</span>
</button>
</>
)}
{isCompleted && (
<span className="badge badge-completed-lg">
<span className="material-symbols-outlined">check_circle</span>
</span>
)}
</div>
</div>
{inspection.notes && (
<div className="inspection-notes">
<span className="material-symbols-outlined">note</span>
{inspection.notes}
</div>
)}
<div className="inspection-items">
{(inspection.items_detail || []).map((detail, idx) => {
const item = detail.item;
const record = detail.record;
const local = localValues[record.template_item_id] || {};
const passClass = getPassClass(detail);
return (
<div key={record.id} className={`inspection-item-card ${passClass}`}>
<div className="inspection-item-header">
<span className="inspection-item-number">{idx + 1}</span>
<span className="inspection-item-name">{item.name}</span>
{item.is_required && <span className="inspection-item-required"></span>}
{item.category && <span className="badge badge-category">{item.category}</span>}
{record.is_pass !== null && (
<span className={`badge ${record.is_pass ? 'badge-pass' : 'badge-fail'}`}>
{getPassLabel(detail)}
</span>
)}
</div>
<div className="inspection-item-body">
{item.data_type === 'numeric' && (
<div className="inspection-input-row">
<div className="inspection-input-group">
<input
type="number"
step="any"
className="form-input"
value={local.value_numeric !== null && local.value_numeric !== undefined ? local.value_numeric : ''}
onChange={(e) => handleNumericChange(record.template_item_id, e.target.value)}
disabled={!isEditable}
placeholder="측정값 입력"
/>
{item.unit && <span className="input-unit">{item.unit}</span>}
</div>
{(item.spec_min !== null || item.spec_max !== null) && (
<span className="spec-range">
: {item.spec_min !== null ? item.spec_min : '-'} ~ {item.spec_max !== null ? item.spec_max : '-'}
{item.unit && ` ${item.unit}`}
</span>
)}
{(item.warning_min !== null || item.warning_max !== null) && (
<span className="warning-range">
: {item.warning_min !== null ? item.warning_min : '-'} ~ {item.warning_max !== null ? item.warning_max : '-'}
</span>
)}
</div>
)}
{item.data_type === 'boolean' && (
<div className="inspection-input-row">
<div className="boolean-toggle">
<button
className={`toggle-btn toggle-pass ${local.value_boolean === true ? 'active' : ''}`}
onClick={() => isEditable && handleBooleanChange(record.template_item_id, true)}
disabled={!isEditable}
>
<span className="material-symbols-outlined">check</span>
</button>
<button
className={`toggle-btn toggle-fail ${local.value_boolean === false ? 'active' : ''}`}
onClick={() => isEditable && handleBooleanChange(record.template_item_id, false)}
disabled={!isEditable}
>
<span className="material-symbols-outlined">close</span>
</button>
</div>
</div>
)}
{item.data_type === 'text' && (
<div className="inspection-input-row">
<textarea
className="form-textarea"
value={local.value_text || ''}
onChange={(e) => handleTextChange(record.template_item_id, e.target.value)}
disabled={!isEditable}
placeholder="내용을 입력하세요"
rows={2}
/>
</div>
)}
{item.data_type === 'select' && (
<div className="inspection-input-row">
<select
className="form-select"
value={local.value_select || ''}
onChange={(e) => handleSelectChange(record.template_item_id, e.target.value)}
disabled={!isEditable}
>
<option value=""></option>
{(item.select_options || []).map((opt) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</div>
)}
</div>
</div>
);
})}
</div>
{showCompleteModal && (
<div className="modal-overlay" onClick={() => setShowCompleteModal(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3> </h3>
<button className="btn-icon" onClick={() => setShowCompleteModal(false)}>
<span className="material-symbols-outlined">close</span>
</button>
</div>
<div className="modal-body">
<p className="complete-summary">
{filledCount}/{totalCount}
</p>
<div className="form-row">
<label> ()</label>
<textarea
className="form-textarea"
value={completionNotes}
onChange={(e) => setCompletionNotes(e.target.value)}
placeholder="검사 완료 메모를 입력하세요"
rows={3}
/>
</div>
</div>
<div className="modal-footer">
<button className="btn-outline" onClick={() => setShowCompleteModal(false)}>
</button>
<button className="btn-primary" onClick={handleComplete} disabled={completing}>
{completing ? '완료 처리 중...' : '검사 완료'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,215 @@
'use client';
import { useState, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useInspections, useTemplates } from '@/lib/hooks';
import { useToast } from '@/lib/toast-context';
import { api } from '@/lib/api';
import type { InspectionSession, InspectionTemplate } from '@/lib/types';
type TabType = 'in_progress' | 'completed';
const STATUS_LABELS: Record<string, string> = {
draft: '대기',
in_progress: '진행 중',
completed: '완료',
};
const STATUS_COLORS: Record<string, string> = {
draft: 'badge-draft',
in_progress: 'badge-progress',
completed: 'badge-completed',
};
export default function InspectionsPage() {
const params = useParams();
const router = useRouter();
const tenantId = params?.tenant as string;
const { addToast } = useToast();
const [activeTab, setActiveTab] = useState<TabType>('in_progress');
const [showNewModal, setShowNewModal] = useState(false);
const { inspections, isLoading, error, mutate } = useInspections(
tenantId,
activeTab === 'in_progress' ? 'in_progress' : 'completed',
);
const { templates } = useTemplates(tenantId);
const activeTemplates = templates.filter((t) => t.items_count > 0);
const handleStartInspection = useCallback(async (template: InspectionTemplate) => {
try {
const data = await api.post<InspectionSession>(`/api/${tenantId}/inspections`, {
template_id: template.id,
});
addToast(`"${template.name}" 검사를 시작합니다.`, 'success');
setShowNewModal(false);
router.push(`/${tenantId}/inspections/${data.id}`);
} catch {
addToast('검사 시작에 실패했습니다.', 'error');
}
}, [tenantId, router, addToast]);
const handleDelete = useCallback(async (inspection: InspectionSession) => {
if (!confirm('이 검사를 삭제하시겠습니까?')) return;
try {
await api.delete(`/api/${tenantId}/inspections/${inspection.id}`);
addToast('검사가 삭제되었습니다.', 'success');
mutate();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '검사 삭제에 실패했습니다.';
addToast(message, 'error');
}
}, [tenantId, mutate, addToast]);
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
const d = new Date(dateStr);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
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">fact_check</span>
</h2>
<button className="btn-primary" onClick={() => setShowNewModal(true)}>
<span className="material-symbols-outlined">add</span>
</button>
</div>
<div className="tab-bar">
<button
className={`tab-item ${activeTab === 'in_progress' ? 'active' : ''}`}
onClick={() => setActiveTab('in_progress')}
>
<span className="material-symbols-outlined">pending_actions</span>
</button>
<button
className={`tab-item ${activeTab === 'completed' ? 'active' : ''}`}
onClick={() => setActiveTab('completed')}
>
<span className="material-symbols-outlined">task_alt</span>
</button>
</div>
{isLoading ? (
<div className="loading">
<span className="material-symbols-outlined spinning">progress_activity</span>
</div>
) : inspections.length === 0 ? (
<div className="empty-state">
<span className="material-symbols-outlined">fact_check</span>
<p>{activeTab === 'in_progress' ? '진행 중인 검사가 없습니다.' : '완료된 검사가 없습니다.'}</p>
{activeTab === 'in_progress' && (
<button className="btn-primary" onClick={() => setShowNewModal(true)}>
<span className="material-symbols-outlined">add</span>
</button>
)}
</div>
) : (
<div className="inspection-list">
{inspections.map((ins) => (
<div
key={ins.id}
className="inspection-card"
onClick={() => router.push(`/${tenantId}/inspections/${ins.id}`)}
>
<div className="inspection-card-left">
<span className="material-symbols-outlined inspection-card-icon">
{ins.status === 'completed' ? 'task_alt' : 'pending_actions'}
</span>
<div className="inspection-card-info">
<span className="inspection-card-name">{ins.template_name || '검사'}</span>
<span className="inspection-card-meta">
{ins.inspector_name} · {formatDate(ins.started_at)}
</span>
</div>
</div>
<div className="inspection-card-right">
<span className="inspection-card-progress">
{ins.records_count}/{ins.items_count}
</span>
<span className={`badge ${STATUS_COLORS[ins.status] || ''}`}>
{STATUS_LABELS[ins.status] || ins.status}
</span>
{ins.status === 'draft' && (
<button
className="btn-icon btn-icon-danger"
onClick={(e) => {
e.stopPropagation();
handleDelete(ins);
}}
title="삭제"
>
<span className="material-symbols-outlined">delete</span>
</button>
)}
</div>
</div>
))}
</div>
)}
{showNewModal && (
<div className="modal-overlay" onClick={() => setShowNewModal(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3> </h3>
<button className="btn-icon" onClick={() => setShowNewModal(false)}>
<span className="material-symbols-outlined">close</span>
</button>
</div>
<div className="modal-body">
{activeTemplates.length === 0 ? (
<div className="empty-state">
<p> 릿 .</p>
<button
className="btn-primary"
onClick={() => router.push(`/${tenantId}/templates/new`)}
>
릿
</button>
</div>
) : (
<div className="template-select-list">
{activeTemplates.map((t) => (
<button
key={t.id}
className="template-select-item"
onClick={() => handleStartInspection(t)}
>
<div className="template-select-info">
<span className="template-select-name">{t.name}</span>
<span className="template-select-meta">
{t.subject_type === 'equipment' ? '설비검사' : '품질검사'} · {t.items_count}
</span>
</div>
<span className="material-symbols-outlined">arrow_forward</span>
</button>
))}
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1266,6 +1266,395 @@ a {
justify-content: space-between;
}
/* ===== Inspection List ===== */
.inspection-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.inspection-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: var(--md-surface);
border: 1px solid var(--md-outline-variant);
border-radius: 12px;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.inspection-card:hover {
border-color: var(--md-primary);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.inspection-card-left {
display: flex;
align-items: center;
gap: 12px;
}
.inspection-card-icon {
font-size: 24px;
color: var(--md-primary);
}
.inspection-card-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.inspection-card-name {
font-weight: 500;
font-size: 14px;
color: var(--md-on-surface);
}
.inspection-card-meta {
font-size: 12px;
color: var(--md-on-surface-variant);
}
.inspection-card-right {
display: flex;
align-items: center;
gap: 12px;
}
.inspection-card-progress {
font-size: 13px;
color: var(--md-on-surface-variant);
font-variant-numeric: tabular-nums;
}
.badge-draft {
background: var(--md-surface-variant);
color: var(--md-on-surface-variant);
}
.badge-progress {
background: #e3f2fd;
color: #1565c0;
}
.badge-completed {
background: #e8f5e9;
color: #2e7d32;
}
.badge-completed-lg {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 16px;
background: #e8f5e9;
color: #2e7d32;
border-radius: 20px;
font-weight: 500;
font-size: 14px;
}
.badge-completed-lg .material-symbols-outlined {
font-size: 18px;
}
/* ===== Template Select Modal ===== */
.template-select-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.template-select-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border: 1px solid var(--md-outline-variant);
border-radius: 10px;
background: transparent;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
width: 100%;
text-align: left;
}
.template-select-item:hover {
background: var(--md-surface-variant);
border-color: var(--md-primary);
}
.template-select-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.template-select-name {
font-weight: 500;
font-size: 14px;
}
.template-select-meta {
font-size: 12px;
color: var(--md-on-surface-variant);
}
.template-select-item .material-symbols-outlined {
color: var(--md-on-surface-variant);
font-size: 20px;
}
/* ===== Inspection Detail ===== */
.page-header-left {
display: flex;
align-items: center;
gap: 12px;
}
.page-header-right {
display: flex;
align-items: center;
gap: 8px;
}
.page-subtitle {
font-size: 13px;
color: var(--md-on-surface-variant);
}
.save-indicator {
font-size: 13px;
color: var(--md-on-surface-variant);
padding-right: 4px;
}
.inspection-notes {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 12px 16px;
background: #fff3e0;
border-radius: 10px;
font-size: 13px;
color: #e65100;
margin-bottom: 16px;
}
.inspection-notes .material-symbols-outlined {
font-size: 18px;
flex-shrink: 0;
}
.inspection-items {
display: flex;
flex-direction: column;
gap: 12px;
}
.inspection-item-card {
border: 1px solid var(--md-outline-variant);
border-radius: 12px;
padding: 16px 20px;
background: var(--md-surface);
transition: border-color 0.15s;
}
.inspection-item-card.pass {
border-left: 4px solid #4caf50;
}
.inspection-item-card.fail {
border-left: 4px solid var(--md-error);
}
.inspection-item-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.inspection-item-number {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--md-primary);
color: white;
font-size: 13px;
font-weight: 600;
flex-shrink: 0;
}
.inspection-item-name {
font-weight: 500;
font-size: 14px;
}
.inspection-item-required {
font-size: 11px;
color: var(--md-error);
padding: 2px 6px;
border: 1px solid var(--md-error);
border-radius: 4px;
}
.badge-category {
background: #f3e5f5;
color: #7b1fa2;
}
.badge-pass {
background: #e8f5e9;
color: #2e7d32;
}
.badge-fail {
background: #fce8e6;
color: var(--md-error);
}
.inspection-item-body {
padding-left: 36px;
}
.inspection-input-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.inspection-input-group {
display: flex;
align-items: center;
gap: 6px;
}
.inspection-input-group .form-input {
max-width: 200px;
}
.input-unit {
font-size: 13px;
color: var(--md-on-surface-variant);
white-space: nowrap;
}
.spec-range,
.warning-range {
font-size: 12px;
color: var(--md-on-surface-variant);
padding: 4px 8px;
background: var(--md-surface-variant);
border-radius: 6px;
}
.warning-range {
background: #fff8e1;
color: #f57f17;
}
.boolean-toggle {
display: flex;
gap: 8px;
}
.toggle-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 20px;
border: 1px solid var(--md-outline-variant);
border-radius: 20px;
background: transparent;
cursor: pointer;
font-size: 14px;
transition: all 0.15s;
}
.toggle-btn:hover:not(:disabled) {
background: var(--md-surface-variant);
}
.toggle-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.toggle-btn .material-symbols-outlined {
font-size: 18px;
}
.toggle-pass.active {
background: #e8f5e9;
border-color: #4caf50;
color: #2e7d32;
}
.toggle-fail.active {
background: #fce8e6;
border-color: var(--md-error);
color: var(--md-error);
}
.form-textarea {
width: 100%;
min-height: 48px;
padding: 10px 14px;
border: 1px solid var(--md-outline-variant);
border-radius: 8px;
font-size: 14px;
resize: vertical;
font-family: inherit;
}
.form-textarea:focus {
outline: none;
border-color: var(--md-primary);
}
.form-textarea:disabled {
background: var(--md-surface-variant);
cursor: not-allowed;
}
.form-select {
padding: 10px 14px;
border: 1px solid var(--md-outline-variant);
border-radius: 8px;
font-size: 14px;
background: var(--md-surface);
min-width: 200px;
}
.form-select:focus {
outline: none;
border-color: var(--md-primary);
}
.form-select:disabled {
background: var(--md-surface-variant);
cursor: not-allowed;
}
.complete-summary {
font-size: 16px;
font-weight: 500;
text-align: center;
padding: 12px;
background: var(--md-surface-variant);
border-radius: 10px;
margin-bottom: 16px;
}
/* ===== Responsive ===== */
@media (max-width: 768px) {
.topnav-center {
@@ -1281,6 +1670,19 @@ a {
grid-template-columns: 1fr;
}
.inspection-item-body {
padding-left: 0;
}
.inspection-input-row {
flex-direction: column;
align-items: flex-start;
}
.page-header-right {
flex-wrap: wrap;
}
.form-grid {
grid-template-columns: 1fr;
}

View File

@@ -1,6 +1,6 @@
import useSWR from 'swr';
import { fetcher, getTenantUrl } from './api';
import type { Tenant, Machine, MachineDetail, EquipmentPart, InspectionTemplate } from './types';
import type { Tenant, Machine, MachineDetail, EquipmentPart, InspectionTemplate, InspectionSession } from './types';
export function useTenants() {
const { data, error, isLoading, mutate } = useSWR<{ tenants: Tenant[] }>(
@@ -108,3 +108,39 @@ export function useTemplate(tenantId?: string, templateId?: string) {
mutate,
};
}
export function useInspections(tenantId?: string, status?: string, templateId?: string) {
const params = new URLSearchParams();
if (status) params.set('status', status);
if (templateId) params.set('template_id', templateId);
const qs = params.toString();
const url = tenantId ? `/api/${tenantId}/inspections${qs ? `?${qs}` : ''}` : null;
const { data, error, isLoading, mutate } = useSWR<{ inspections: InspectionSession[] }>(
url,
fetcher,
{ refreshInterval: 10000, dedupingInterval: 2000 },
);
return {
inspections: data?.inspections || [],
error,
isLoading,
mutate,
};
}
export function useInspection(tenantId?: string, inspectionId?: string) {
const url = tenantId && inspectionId ? `/api/${tenantId}/inspections/${inspectionId}` : null;
const { data, error, isLoading, mutate } = useSWR<InspectionSession>(
url,
fetcher,
{ refreshInterval: 5000, dedupingInterval: 2000 },
);
return {
inspection: data ?? null,
error,
isLoading,
mutate,
};
}

View File

@@ -81,6 +81,57 @@ export interface InspectionTemplateItem {
created_at: string | null;
}
export interface InspectionRecord {
id: string;
session_id: string;
template_item_id: string;
value_numeric: number | null;
value_boolean: boolean | null;
value_text: string | null;
value_select: string | null;
is_pass: boolean | null;
recorded_at: string | null;
created_at: string | null;
updated_at: string | null;
}
export interface InspectionItemDetail {
record: InspectionRecord;
item: {
id: string;
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;
select_options: string[] | null;
is_required: boolean;
sort_order: number;
};
}
export interface InspectionSession {
id: string;
tenant_id: string;
template_id: string;
inspector_id: string;
status: 'draft' | 'in_progress' | 'completed';
started_at: string | null;
completed_at: string | null;
notes: string | null;
template_name: string | null;
inspector_name: string | null;
items_count: number;
records_count: number;
records?: InspectionRecord[];
items_detail?: InspectionItemDetail[];
created_at: string | null;
updated_at: string | null;
}
export interface InspectionTemplate {
id: string;
tenant_id: string;

View File

@@ -22,6 +22,7 @@ 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
from src.api.inspections import router as inspections_router
logger = logging.getLogger(__name__)
@@ -53,6 +54,7 @@ app.include_router(auth_admin_router)
app.include_router(machines_router)
app.include_router(equipment_parts_router)
app.include_router(templates_router)
app.include_router(inspections_router)
@app.get("/api/health")

518
src/api/inspections.py Normal file
View File

@@ -0,0 +1,518 @@
from typing import List, Optional
from uuid import UUID
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Depends, Path, Query
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 (
InspectionSession,
InspectionRecord,
InspectionTemplate,
InspectionTemplateItem,
)
from src.auth.models import TokenData
from src.auth.dependencies import require_auth, verify_tenant_access
router = APIRouter(prefix="/api/{tenant_id}/inspections", tags=["inspections"])
VALID_STATUSES = ("draft", "in_progress", "completed")
class InspectionCreate(BaseModel):
template_id: str
class RecordInput(BaseModel):
template_item_id: str
value_numeric: Optional[float] = None
value_boolean: Optional[bool] = None
value_text: Optional[str] = None
value_select: Optional[str] = None
class RecordsBatchInput(BaseModel):
records: List[RecordInput]
class SessionNotesUpdate(BaseModel):
notes: Optional[str] = None
def _format_ts(val) -> Optional[str]:
if val is None:
return None
return val.isoformat() if hasattr(val, "isoformat") else str(val)
def _record_to_dict(r: InspectionRecord) -> dict:
return {
"id": str(r.id),
"session_id": str(r.session_id),
"template_item_id": str(r.template_item_id),
"value_numeric": float(r.value_numeric)
if r.value_numeric is not None
else None,
"value_boolean": r.value_boolean,
"value_text": r.value_text,
"value_select": r.value_select,
"is_pass": r.is_pass,
"recorded_at": _format_ts(r.recorded_at),
"created_at": _format_ts(r.created_at),
"updated_at": _format_ts(r.updated_at),
}
def _session_to_dict(
s: InspectionSession,
include_records: bool = False,
template_name: Optional[str] = None,
inspector_name: Optional[str] = None,
items_count: int = 0,
records_count: int = 0,
) -> dict:
result = {
"id": str(s.id),
"tenant_id": str(s.tenant_id),
"template_id": str(s.template_id),
"inspector_id": str(s.inspector_id),
"status": str(s.status),
"started_at": _format_ts(s.started_at),
"completed_at": _format_ts(s.completed_at),
"notes": s.notes,
"template_name": template_name,
"inspector_name": inspector_name,
"items_count": items_count,
"records_count": records_count,
"created_at": _format_ts(s.created_at),
"updated_at": _format_ts(s.updated_at),
}
if include_records:
result["records"] = [_record_to_dict(r) for r in (s.records or [])]
return result
def _evaluate_pass(
record: RecordInput,
item: InspectionTemplateItem,
) -> Optional[bool]:
if item.data_type == "numeric" and record.value_numeric is not None:
if item.spec_min is not None and record.value_numeric < item.spec_min:
return False
if item.spec_max is not None and record.value_numeric > item.spec_max:
return False
if item.spec_min is not None or item.spec_max is not None:
return True
return None
if item.data_type == "boolean" and record.value_boolean is not None:
return record.value_boolean
return None
@router.post("")
async def create_inspection(
body: InspectionCreate,
tenant_id: str = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
template_stmt = (
select(InspectionTemplate)
.options(selectinload(InspectionTemplate.items))
.where(
InspectionTemplate.id == UUID(body.template_id),
InspectionTemplate.tenant_id == tenant_id,
InspectionTemplate.is_active == True,
)
)
result = await db.execute(template_stmt)
template = result.scalar_one_or_none()
if not template:
raise HTTPException(status_code=404, detail="검사 템플릿을 찾을 수 없습니다.")
if not template.items:
raise HTTPException(
status_code=400,
detail="검사 항목이 없는 템플릿으로는 검사를 시작할 수 없습니다.",
)
now = datetime.now(timezone.utc)
session = InspectionSession(
tenant_id=tenant_id,
template_id=template.id,
inspector_id=UUID(current_user.user_id),
status="in_progress",
started_at=now,
)
db.add(session)
await db.flush()
for item in template.items:
record = InspectionRecord(
session_id=session.id,
template_item_id=item.id,
)
db.add(record)
await db.commit()
stmt = (
select(InspectionSession)
.options(selectinload(InspectionSession.records))
.where(InspectionSession.id == session.id)
)
result = await db.execute(stmt)
created = result.scalar_one()
from src.database.models import User
inspector = (
await db.execute(select(User).where(User.id == UUID(current_user.user_id)))
).scalar_one_or_none()
return _session_to_dict(
created,
include_records=True,
template_name=str(template.name),
inspector_name=str(inspector.name) if inspector else None,
items_count=len(template.items),
records_count=len(created.records),
)
@router.get("")
async def list_inspections(
tenant_id: str = Path(...),
status: Optional[str] = Query(None),
template_id: Optional[str] = Query(None),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
from src.database.models import User
stmt = (
select(
InspectionSession,
InspectionTemplate.name.label("template_name"),
User.name.label("inspector_name"),
func.count(InspectionTemplateItem.id).label("items_count"),
func.count(InspectionRecord.recorded_at).label("filled_count"),
)
.join(
InspectionTemplate, InspectionSession.template_id == InspectionTemplate.id
)
.join(User, InspectionSession.inspector_id == User.id)
.outerjoin(
InspectionTemplateItem,
InspectionTemplateItem.template_id == InspectionTemplate.id,
)
.outerjoin(
InspectionRecord, InspectionRecord.session_id == InspectionSession.id
)
.where(InspectionSession.tenant_id == tenant_id)
.group_by(InspectionSession.id, InspectionTemplate.name, User.name)
.order_by(InspectionSession.created_at.desc())
)
if status:
if status not in VALID_STATUSES:
raise HTTPException(
status_code=400,
detail=f"status는 {', '.join(VALID_STATUSES)} 중 하나여야 합니다.",
)
stmt = stmt.where(InspectionSession.status == status)
if template_id:
stmt = stmt.where(InspectionSession.template_id == UUID(template_id))
result = await db.execute(stmt)
rows = result.all()
inspections = []
for s, t_name, i_name, i_count, f_count in rows:
inspections.append(
_session_to_dict(
s,
template_name=t_name,
inspector_name=i_name,
items_count=i_count,
records_count=f_count,
)
)
return {"inspections": inspections}
@router.get("/{inspection_id}")
async def get_inspection(
tenant_id: str = Path(...),
inspection_id: UUID = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
from src.database.models import User
stmt = (
select(InspectionSession)
.options(
selectinload(InspectionSession.records).selectinload(
InspectionRecord.template_item
),
)
.where(
InspectionSession.id == inspection_id,
InspectionSession.tenant_id == tenant_id,
)
)
result = await db.execute(stmt)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="검사를 찾을 수 없습니다.")
template_stmt = select(InspectionTemplate).where(
InspectionTemplate.id == session.template_id
)
template = (await db.execute(template_stmt)).scalar_one_or_none()
inspector_stmt = select(User).where(User.id == session.inspector_id)
inspector = (await db.execute(inspector_stmt)).scalar_one_or_none()
items_stmt = select(func.count(InspectionTemplateItem.id)).where(
InspectionTemplateItem.template_id == session.template_id
)
items_count = (await db.execute(items_stmt)).scalar() or 0
filled = sum(1 for r in session.records if r.recorded_at is not None)
resp = _session_to_dict(
session,
include_records=True,
template_name=str(template.name) if template else None,
inspector_name=str(inspector.name) if inspector else None,
items_count=items_count,
records_count=filled,
)
items_detail = []
for record in session.records:
ti = record.template_item
item_dict = {
"record": _record_to_dict(record),
"item": {
"id": str(ti.id),
"name": str(ti.name),
"category": str(ti.category) if ti.category else None,
"data_type": str(ti.data_type),
"unit": str(ti.unit) if ti.unit else None,
"spec_min": float(ti.spec_min) if ti.spec_min is not None else None,
"spec_max": float(ti.spec_max) if ti.spec_max is not None else None,
"warning_min": float(ti.warning_min)
if ti.warning_min is not None
else None,
"warning_max": float(ti.warning_max)
if ti.warning_max is not None
else None,
"select_options": ti.select_options,
"is_required": bool(ti.is_required),
"sort_order": ti.sort_order,
},
}
items_detail.append(item_dict)
items_detail.sort(key=lambda x: x["item"]["sort_order"])
resp["items_detail"] = items_detail
return resp
@router.put("/{inspection_id}/records")
async def save_records(
body: RecordsBatchInput,
tenant_id: str = Path(...),
inspection_id: UUID = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
session_stmt = select(InspectionSession).where(
InspectionSession.id == inspection_id,
InspectionSession.tenant_id == tenant_id,
)
result = await db.execute(session_stmt)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="검사를 찾을 수 없습니다.")
if session.status == "completed":
raise HTTPException(status_code=400, detail="완료된 검사는 수정할 수 없습니다.")
item_ids = [UUID(r.template_item_id) for r in body.records]
items_stmt = select(InspectionTemplateItem).where(
InspectionTemplateItem.id.in_(item_ids)
)
items_result = await db.execute(items_stmt)
items_map = {str(i.id): i for i in items_result.scalars().all()}
records_stmt = select(InspectionRecord).where(
InspectionRecord.session_id == inspection_id,
InspectionRecord.template_item_id.in_(item_ids),
)
records_result = await db.execute(records_stmt)
records_map = {str(r.template_item_id): r for r in records_result.scalars().all()}
now = datetime.now(timezone.utc)
updated_records = []
for record_input in body.records:
item = items_map.get(record_input.template_item_id)
if not item:
raise HTTPException(
status_code=400,
detail=f"검사 항목 {record_input.template_item_id}을(를) 찾을 수 없습니다.",
)
db_record = records_map.get(record_input.template_item_id)
if not db_record:
db_record = InspectionRecord(
session_id=inspection_id,
template_item_id=UUID(record_input.template_item_id),
)
db.add(db_record)
if record_input.value_numeric is not None:
db_record.value_numeric = record_input.value_numeric
if record_input.value_boolean is not None:
db_record.value_boolean = record_input.value_boolean
if record_input.value_text is not None:
db_record.value_text = record_input.value_text
if record_input.value_select is not None:
db_record.value_select = record_input.value_select
has_value = any(
[
record_input.value_numeric is not None,
record_input.value_boolean is not None,
record_input.value_text is not None,
record_input.value_select is not None,
]
)
if has_value:
db_record.recorded_at = now
db_record.is_pass = _evaluate_pass(record_input, item)
updated_records.append(db_record)
if session.status == "draft":
session.status = "in_progress"
session.started_at = now
await db.commit()
for r in updated_records:
await db.refresh(r)
return {"records": [_record_to_dict(r) for r in updated_records]}
@router.post("/{inspection_id}/complete")
async def complete_inspection(
tenant_id: str = Path(...),
inspection_id: UUID = Path(...),
body: Optional[SessionNotesUpdate] = None,
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
stmt = (
select(InspectionSession)
.options(selectinload(InspectionSession.records))
.where(
InspectionSession.id == inspection_id,
InspectionSession.tenant_id == tenant_id,
)
)
result = await db.execute(stmt)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="검사를 찾을 수 없습니다.")
if session.status == "completed":
raise HTTPException(status_code=400, detail="이미 완료된 검사입니다.")
items_stmt = select(InspectionTemplateItem).where(
InspectionTemplateItem.template_id == session.template_id,
InspectionTemplateItem.is_required == True,
)
required_items = (await db.execute(items_stmt)).scalars().all()
required_ids = {str(i.id) for i in required_items}
filled_ids = {
str(r.template_item_id) for r in session.records if r.recorded_at is not None
}
missing = required_ids - filled_ids
if missing:
raise HTTPException(
status_code=400,
detail=f"필수 검사 항목 {len(missing)}개가 미입력 상태입니다.",
)
now = datetime.now(timezone.utc)
session.status = "completed"
session.completed_at = now
if body and body.notes is not None:
session.notes = body.notes
await db.commit()
await db.refresh(session)
pass_count = sum(1 for r in session.records if r.is_pass is True)
fail_count = sum(1 for r in session.records if r.is_pass is False)
total = len(session.records)
return {
"status": "success",
"message": "검사가 완료되었습니다.",
"summary": {
"total": total,
"pass_count": pass_count,
"fail_count": fail_count,
"no_judgment": total - pass_count - fail_count,
},
}
@router.delete("/{inspection_id}")
async def delete_inspection(
tenant_id: str = Path(...),
inspection_id: UUID = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
stmt = select(InspectionSession).where(
InspectionSession.id == inspection_id,
InspectionSession.tenant_id == tenant_id,
)
result = await db.execute(stmt)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="검사를 찾을 수 없습니다.")
if session.status != "draft":
raise HTTPException(
status_code=400, detail="진행 중이거나 완료된 검사는 삭제할 수 없습니다."
)
await db.delete(session)
await db.commit()
return {"status": "success", "message": "검사가 삭제되었습니다."}

View File

@@ -233,3 +233,75 @@ class InspectionTemplateItem(Base):
__table_args__ = (
Index("ix_template_items_template_order", "template_id", "sort_order"),
)
class InspectionSession(Base):
__tablename__ = "inspection_sessions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(String(50), ForeignKey("tenants.id"), nullable=False)
template_id = Column(
UUID(as_uuid=True),
ForeignKey("inspection_templates.id"),
nullable=False,
)
inspector_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
status = Column(
String(20), nullable=False, default="draft"
) # draft | in_progress | completed
started_at = Column(TIMESTAMP(timezone=True), nullable=True)
completed_at = Column(TIMESTAMP(timezone=True), nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(TIMESTAMP(timezone=True), default=utcnow)
updated_at = Column(TIMESTAMP(timezone=True), default=utcnow, onupdate=utcnow)
tenant = relationship("Tenant")
template = relationship("InspectionTemplate")
inspector = relationship("User")
records = relationship(
"InspectionRecord",
back_populates="session",
cascade="all, delete-orphan",
)
__table_args__ = (
Index("ix_sessions_tenant_status", "tenant_id", "status"),
Index("ix_sessions_tenant_template", "tenant_id", "template_id"),
Index("ix_sessions_tenant_inspector", "tenant_id", "inspector_id"),
)
class InspectionRecord(Base):
__tablename__ = "inspection_records"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
session_id = Column(
UUID(as_uuid=True),
ForeignKey("inspection_sessions.id", ondelete="CASCADE"),
nullable=False,
)
template_item_id = Column(
UUID(as_uuid=True),
ForeignKey("inspection_template_items.id"),
nullable=False,
)
value_numeric = Column(Float, nullable=True)
value_boolean = Column(Boolean, nullable=True)
value_text = Column(Text, nullable=True)
value_select = Column(String(200), nullable=True)
is_pass = Column(
Boolean, nullable=True
) # None = 미판정, True = 합격, False = 불합격
recorded_at = Column(TIMESTAMP(timezone=True), nullable=True)
created_at = Column(TIMESTAMP(timezone=True), default=utcnow)
updated_at = Column(TIMESTAMP(timezone=True), default=utcnow, onupdate=utcnow)
session = relationship("InspectionSession", back_populates="records")
template_item = relationship("InspectionTemplateItem")
__table_args__ = (
UniqueConstraint(
"session_id", "template_item_id", name="uq_record_session_item"
),
Index("ix_records_session", "session_id"),
)

411
tests/test_inspections.py Normal file
View File

@@ -0,0 +1,411 @@
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": "INS-001"},
headers=headers,
)
return resp.json()["id"]
async def _create_template_with_items(
client: AsyncClient,
headers: dict,
tenant_id: str = "test-co",
machine_id: str = None,
) -> dict:
body = {
"name": "일일 설비검사",
"subject_type": "equipment",
"schedule_type": "daily",
"inspection_mode": "measurement",
"items": [
{
"name": "온도 확인",
"data_type": "numeric",
"unit": "°C",
"spec_min": 20.0,
"spec_max": 80.0,
"is_required": True,
},
{
"name": "가동 여부",
"data_type": "boolean",
"is_required": True,
},
{
"name": "비고",
"data_type": "text",
"is_required": False,
},
],
}
if machine_id:
body["machine_id"] = machine_id
resp = await client.post(f"/api/{tenant_id}/templates", json=body, headers=headers)
assert resp.status_code == 200
return resp.json()
async def _start_inspection(
client: AsyncClient, headers: dict, template_id: str, tenant_id: str = "test-co"
) -> dict:
resp = await client.post(
f"/api/{tenant_id}/inspections",
json={"template_id": template_id},
headers=headers,
)
assert resp.status_code == 200
return resp.json()
@pytest.mark.asyncio
async def test_create_inspection(client: AsyncClient, seeded_db):
headers = await get_auth_headers(client)
machine_id = await _create_machine(client, headers)
template = await _create_template_with_items(client, headers, machine_id=machine_id)
inspection = await _start_inspection(client, headers, template["id"])
assert inspection["status"] == "in_progress"
assert inspection["template_id"] == template["id"]
assert inspection["template_name"] == "일일 설비검사"
assert inspection["started_at"] is not None
assert len(inspection["records"]) == 3
for record in inspection["records"]:
assert record["recorded_at"] is None
assert record["is_pass"] is None
@pytest.mark.asyncio
async def test_create_inspection_empty_template_fails(client: AsyncClient, seeded_db):
headers = await get_auth_headers(client)
body = {
"name": "빈 템플릿",
"subject_type": "equipment",
"schedule_type": "daily",
"inspection_mode": "checklist",
}
resp = await client.post("/api/test-co/templates", json=body, headers=headers)
template_id = resp.json()["id"]
resp = await client.post(
"/api/test-co/inspections",
json={"template_id": template_id},
headers=headers,
)
assert resp.status_code == 400
assert "검사 항목이 없는" in resp.json()["detail"]
@pytest.mark.asyncio
async def test_create_inspection_invalid_template(client: AsyncClient, seeded_db):
headers = await get_auth_headers(client)
resp = await client.post(
"/api/test-co/inspections",
json={"template_id": "00000000-0000-0000-0000-000000000000"},
headers=headers,
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_list_inspections(client: AsyncClient, seeded_db):
headers = await get_auth_headers(client)
template = await _create_template_with_items(client, headers)
await _start_inspection(client, headers, template["id"])
await _start_inspection(client, headers, template["id"])
resp = await client.get("/api/test-co/inspections", headers=headers)
assert resp.status_code == 200
data = resp.json()
assert len(data["inspections"]) == 2
@pytest.mark.asyncio
async def test_list_inspections_filter_status(client: AsyncClient, seeded_db):
headers = await get_auth_headers(client)
template = await _create_template_with_items(client, headers)
await _start_inspection(client, headers, template["id"])
resp = await client.get(
"/api/test-co/inspections?status=completed", headers=headers
)
assert resp.status_code == 200
assert len(resp.json()["inspections"]) == 0
resp = await client.get(
"/api/test-co/inspections?status=in_progress", headers=headers
)
assert resp.status_code == 200
assert len(resp.json()["inspections"]) == 1
@pytest.mark.asyncio
async def test_get_inspection_detail(client: AsyncClient, seeded_db):
headers = await get_auth_headers(client)
template = await _create_template_with_items(client, headers)
inspection = await _start_inspection(client, headers, template["id"])
resp = await client.get(
f"/api/test-co/inspections/{inspection['id']}", headers=headers
)
assert resp.status_code == 200
data = resp.json()
assert data["id"] == inspection["id"]
assert "items_detail" in data
assert len(data["items_detail"]) == 3
assert data["items_detail"][0]["item"]["name"] == "온도 확인"
assert data["items_detail"][0]["item"]["data_type"] == "numeric"
@pytest.mark.asyncio
async def test_save_records(client: AsyncClient, seeded_db):
headers = await get_auth_headers(client)
template = await _create_template_with_items(client, headers)
inspection = await _start_inspection(client, headers, template["id"])
records = inspection["records"]
numeric_record = next(
r for r in records if r["value_numeric"] is None and r["value_boolean"] is None
)
temp_record = records[0]
resp = await client.put(
f"/api/test-co/inspections/{inspection['id']}/records",
json={
"records": [
{
"template_item_id": temp_record["template_item_id"],
"value_numeric": 45.5,
}
]
},
headers=headers,
)
assert resp.status_code == 200
saved = resp.json()["records"]
assert len(saved) == 1
assert saved[0]["value_numeric"] == 45.5
assert saved[0]["is_pass"] is True
assert saved[0]["recorded_at"] is not None
@pytest.mark.asyncio
async def test_save_records_fail_judgment(client: AsyncClient, seeded_db):
headers = await get_auth_headers(client)
template = await _create_template_with_items(client, headers)
inspection = await _start_inspection(client, headers, template["id"])
temp_record = inspection["records"][0]
resp = await client.put(
f"/api/test-co/inspections/{inspection['id']}/records",
json={
"records": [
{
"template_item_id": temp_record["template_item_id"],
"value_numeric": 100.0,
}
]
},
headers=headers,
)
assert resp.status_code == 200
saved = resp.json()["records"]
assert saved[0]["is_pass"] is False
@pytest.mark.asyncio
async def test_complete_inspection(client: AsyncClient, seeded_db):
headers = await get_auth_headers(client)
template = await _create_template_with_items(client, headers)
inspection = await _start_inspection(client, headers, template["id"])
detail_resp = await client.get(
f"/api/test-co/inspections/{inspection['id']}", headers=headers
)
items_detail = detail_resp.json()["items_detail"]
all_records = []
for item_d in items_detail:
item = item_d["item"]
record = item_d["record"]
if item["data_type"] == "numeric":
all_records.append(
{"template_item_id": record["template_item_id"], "value_numeric": 50.0}
)
elif item["data_type"] == "boolean":
all_records.append(
{"template_item_id": record["template_item_id"], "value_boolean": True}
)
elif item["data_type"] == "text":
all_records.append(
{"template_item_id": record["template_item_id"], "value_text": "정상"}
)
resp = await client.put(
f"/api/test-co/inspections/{inspection['id']}/records",
json={"records": all_records},
headers=headers,
)
assert resp.status_code == 200
resp = await client.post(
f"/api/test-co/inspections/{inspection['id']}/complete",
json={"notes": "검사 완료 메모"},
headers=headers,
)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "success"
assert data["summary"]["total"] == 3
assert data["summary"]["pass_count"] >= 1
@pytest.mark.asyncio
async def test_complete_inspection_missing_required(client: AsyncClient, seeded_db):
headers = await get_auth_headers(client)
template = await _create_template_with_items(client, headers)
inspection = await _start_inspection(client, headers, template["id"])
resp = await client.post(
f"/api/test-co/inspections/{inspection['id']}/complete",
headers=headers,
)
assert resp.status_code == 400
assert "필수 검사 항목" in resp.json()["detail"]
@pytest.mark.asyncio
async def test_complete_already_completed(client: AsyncClient, seeded_db):
headers = await get_auth_headers(client)
template = await _create_template_with_items(client, headers)
inspection = await _start_inspection(client, headers, template["id"])
detail_resp = await client.get(
f"/api/test-co/inspections/{inspection['id']}", headers=headers
)
items_detail = detail_resp.json()["items_detail"]
all_records = []
for item_d in items_detail:
item = item_d["item"]
record = item_d["record"]
if item["data_type"] == "numeric":
all_records.append(
{"template_item_id": record["template_item_id"], "value_numeric": 50.0}
)
elif item["data_type"] == "boolean":
all_records.append(
{"template_item_id": record["template_item_id"], "value_boolean": True}
)
elif item["data_type"] == "text":
all_records.append(
{"template_item_id": record["template_item_id"], "value_text": "정상"}
)
await client.put(
f"/api/test-co/inspections/{inspection['id']}/records",
json={"records": all_records},
headers=headers,
)
await client.post(
f"/api/test-co/inspections/{inspection['id']}/complete",
headers=headers,
)
resp = await client.post(
f"/api/test-co/inspections/{inspection['id']}/complete",
headers=headers,
)
assert resp.status_code == 400
assert "이미 완료된" in resp.json()["detail"]
@pytest.mark.asyncio
async def test_save_records_completed_fails(client: AsyncClient, seeded_db):
headers = await get_auth_headers(client)
template = await _create_template_with_items(client, headers)
inspection = await _start_inspection(client, headers, template["id"])
detail_resp = await client.get(
f"/api/test-co/inspections/{inspection['id']}", headers=headers
)
items_detail = detail_resp.json()["items_detail"]
all_records = []
for item_d in items_detail:
item = item_d["item"]
record = item_d["record"]
if item["data_type"] == "numeric":
all_records.append(
{"template_item_id": record["template_item_id"], "value_numeric": 50.0}
)
elif item["data_type"] == "boolean":
all_records.append(
{"template_item_id": record["template_item_id"], "value_boolean": True}
)
elif item["data_type"] == "text":
all_records.append(
{"template_item_id": record["template_item_id"], "value_text": "정상"}
)
await client.put(
f"/api/test-co/inspections/{inspection['id']}/records",
json={"records": all_records},
headers=headers,
)
await client.post(
f"/api/test-co/inspections/{inspection['id']}/complete",
headers=headers,
)
resp = await client.put(
f"/api/test-co/inspections/{inspection['id']}/records",
json={
"records": [
{
"template_item_id": all_records[0]["template_item_id"],
"value_numeric": 99.0,
}
]
},
headers=headers,
)
assert resp.status_code == 400
assert "완료된 검사" in resp.json()["detail"]
@pytest.mark.asyncio
async def test_delete_draft_inspection_not_allowed_for_in_progress(
client: AsyncClient, seeded_db
):
headers = await get_auth_headers(client)
template = await _create_template_with_items(client, headers)
inspection = await _start_inspection(client, headers, template["id"])
resp = await client.delete(
f"/api/test-co/inspections/{inspection['id']}",
headers=headers,
)
assert resp.status_code == 400
assert "진행 중이거나" in resp.json()["detail"]
@pytest.mark.asyncio
async def test_tenant_isolation(client: AsyncClient, seeded_db):
headers = await get_auth_headers(client, email="admin@test-co.com")
template = await _create_template_with_items(client, headers)
inspection = await _start_inspection(client, headers, template["id"])
resp = await client.get(
f"/api/other-co/inspections/{inspection['id']}", headers=headers
)
assert resp.status_code in (403, 404)