feat: Phase 4 — inspection sessions (create, execute, complete)
All checks were successful
Deploy to Production / deploy (push) Successful in 1m5s
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:
111
alembic/versions/b4c5d6e7f8a9_add_inspection_sessions.py
Normal file
111
alembic/versions/b4c5d6e7f8a9_add_inspection_sessions.py
Normal 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")
|
||||
373
dashboard/app/[tenant]/inspections/[id]/page.tsx
Normal file
373
dashboard/app/[tenant]/inspections/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
215
dashboard/app/[tenant]/inspections/page.tsx
Normal file
215
dashboard/app/[tenant]/inspections/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
2
main.py
2
main.py
@@ -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
518
src/api/inspections.py
Normal 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": "검사가 삭제되었습니다."}
|
||||
@@ -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
411
tests/test_inspections.py
Normal 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)
|
||||
Reference in New Issue
Block a user