# 문서 API 연동 가이드 > **버전:** 1.0.0 > **최종 수정:** 2026-02-05 > **담당:** API Team --- ## 1. 개요 SAM 시스템의 문서 관리(검사 성적서 등) API를 React 프론트엔드와 연동하는 방법을 설명합니다. ### 1.1 역할 분리 | 시스템 | 역할 | 사용자 | |--------|------|--------| | **MNG** | 양식 생성/관리, 문서 조회/출력 | 본사 관리자 | | **API** | 문서 CRUD, 결재 워크플로우 | 시스템 | | **React** | 문서 작성/수정 UI | 현장 작업자 | ### 1.2 전체 플로우 ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ 시스템 흐름도 │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │ MNG │ │ API │ │ React │ │ DB │ │ │ │ (본사) │ │ Server │ │ (현장) │ │ │ │ │ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ │ │ │ │ │ │ │ │ 1. 양식 생성/관리 │ │ │ │ │ │──────────────────>│ │ │ │ │ │ │──────────────────────────────────────>│ │ │ │ │ │ │ │ │ │ │ 2. resolve │ │ │ │ │ │<──────────────────│ (category+item_id)│ │ │ │ │──────────────────────────────────────>│ │ │ │ │<──────────────────────────────────────│ │ │ │ │ 3. 템플릿+문서 │ │ │ │ │ │──────────────────>│ │ │ │ │ │ │ │ │ │ │ │ │ 4. 사용자 입력 │ │ │ │ │ │ (측정값, 판정) │ │ │ │ │ │ │ │ │ │ │ 5. upsert │ │ │ │ │ │<──────────────────│ │ │ │ │ │──────────────────────────────────────>│ │ │ │ │ │ │ │ │ │ 6. 문서 조회/출력 │ │ │ │ │ │<──────────────────│ │ │ │ │ │ │ │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` --- ## 2. API 엔드포인트 ### 2.1 Document Resolve (문서 조회/생성 준비) 품목(item_id)과 문서 분류(category)를 기반으로 해당하는 템플릿과 기존 문서를 조회합니다. #### 엔드포인트 ``` GET /api/v1/documents/resolve ``` #### Request **Headers:** ```http Content-Type: application/json x-api-key: {API_KEY} Authorization: Bearer {TOKEN} ``` **Query Parameters:** | 파라미터 | 타입 | 필수 | 설명 | 예시 | |---------|------|:----:|------|------| | `category` | string | ✅ | 문서 분류 코드 | `incoming_inspection` | | `item_id` | integer | ✅ | 품목 ID | `14172` | **category 값 (common_codes 기반):** | code | name | 설명 | |------|------|------| | `incoming_inspection` | 수입검사 | 자재 입고 시 검사 | | `quality_inspection` | 품질검사 | 중간/공정 검사 | | `outgoing_inspection` | 출하검사 | 제품 출하 전 검사 | #### Response - 신규 문서 (is_new: true) ```json { "success": true, "message": "조회 성공", "data": { "is_new": true, "template": { "id": 18, "name": "EGI 수입검사 (두께별 자동매칭)", "category": "수입검사", "title": "수입검사 성적서", "company_name": "케이디산업", "company_address": null, "company_contact": null, "footer_remark_label": "부적합 내용", "footer_judgement_label": "종합판정", "footer_judgement_options": ["합격", "불합격", "조건부합격"], "approval_lines": [ {"id": 1, "role": "작성", "user_id": null, "sort_order": 0}, {"id": 2, "role": "검토", "user_id": null, "sort_order": 1}, {"id": 3, "role": "승인", "user_id": null, "sort_order": 2} ], "basic_fields": [ { "id": 1, "field_key": "lot_no", "label": "LOT No", "input_type": "text", "options": null, "default_value": null, "is_required": true, "sort_order": 0 } ], "section_fields": [ {"id": 1, "field_key": "category", "label": "구분", "field_type": "text", "width": "65px", "is_required": false}, {"id": 2, "field_key": "item", "label": "검사항목", "field_type": "text", "width": "130px", "is_required": true}, {"id": 3, "field_key": "standard", "label": "검사기준", "field_type": "text", "width": "180px", "is_required": false}, {"id": 4, "field_key": "standard_criteria", "label": "기준범위", "field_type": "json_criteria", "width": "100px", "is_required": false}, {"id": 5, "field_key": "tolerance", "label": "공차/범위", "field_type": "json_tolerance", "width": "120px", "is_required": false}, {"id": 6, "field_key": "method", "label": "검사방식", "field_type": "select_api", "width": "110px", "is_required": false}, {"id": 7, "field_key": "measurement_type", "label": "측정유형", "field_type": "select", "width": "100px", "is_required": false} ], "sections": [ { "id": 1, "name": "검사 항목", "sort_order": 0, "items": [ { "id": 307, "field_values": { "category": "", "item": "겉모양", "standard": "사용상 해로울 결함이 없을 것", "method": "visual", "measurement_type": "checkbox" }, "standard_criteria": null, "tolerance": null, "sort_order": 0 }, { "id": 308, "field_values": { "category": "치수", "item": "두께", "standard": null, "method": "check", "measurement_type": "numeric" }, "standard_criteria": {"min": 0.8, "min_op": "gte", "max": 1.0, "max_op": "lt"}, "tolerance": {"type": "symmetric", "value": "0.07"}, "sort_order": 1 } ] } ], "columns": [ {"id": 1, "label": "측정1", "input_type": "text", "width": "60px", "is_required": false}, {"id": 2, "label": "측정2", "input_type": "text", "width": "60px", "is_required": false}, {"id": 3, "label": "측정3", "input_type": "text", "width": "60px", "is_required": false}, {"id": 4, "label": "판정", "input_type": "select", "width": "60px", "is_required": true} ] }, "document": null, "item": { "id": 14172, "code": "20000", "name": "sus1.2*1219*2438", "attributes": { "thickness": 1.2, "width": 1219, "length": 2438, "spec": " ", "item_div": "[원재료]" } } } } ``` #### Response - 기존 문서 (is_new: false) ```json { "success": true, "message": "조회 성공", "data": { "is_new": false, "template": { ... }, "document": { "id": 7, "document_no": "DOC-20260205-0001", "title": "수입검사 성적서 - EGI 1.2T", "status": "DRAFT", "linkable_type": "item", "linkable_id": 14172, "submitted_at": null, "completed_at": null, "created_at": "2026-02-05T12:41:35.000000Z", "data": [ {"section_id": 1, "column_id": 1, "row_index": 0, "field_key": "measurement_1", "field_value": "1.21"}, {"section_id": 1, "column_id": 2, "row_index": 0, "field_key": "measurement_2", "field_value": "1.20"}, {"section_id": 1, "column_id": 3, "row_index": 0, "field_key": "measurement_3", "field_value": "1.22"}, {"section_id": 1, "column_id": 4, "row_index": 0, "field_key": "judgement", "field_value": "합격"} ], "attachments": [], "approvals": [] }, "item": { ... } } } ``` #### Error Responses | HTTP 코드 | 에러 메시지 | 원인 | |:---------:|------------|------| | 400 | 유효하지 않은 문서 분류입니다 | category가 common_codes에 없음 | | 404 | 해당 조건에 맞는 문서 양식을 찾을 수 없습니다 | 해당 category+item_id에 연결된 템플릿 없음 | | 404 | 품목 정보를 찾을 수 없습니다 | item_id가 존재하지 않거나 다른 테넌트 | --- ### 2.2 Document Upsert (문서 저장) 문서를 저장합니다. 기존 문서(DRAFT/REJECTED 상태)가 있으면 UPDATE, 없으면 CREATE. #### 엔드포인트 ``` POST /api/v1/documents/upsert ``` #### Request **Headers:** ```http Content-Type: application/json x-api-key: {API_KEY} Authorization: Bearer {TOKEN} ``` **Body:** ```json { "template_id": 18, "item_id": 14172, "title": "수입검사 성적서 - EGI 1.2T", "data": [ {"section_id": 1, "column_id": 1, "row_index": 0, "field_key": "measurement_1", "field_value": "1.21"}, {"section_id": 1, "column_id": 2, "row_index": 0, "field_key": "measurement_2", "field_value": "1.20"}, {"section_id": 1, "column_id": 3, "row_index": 0, "field_key": "measurement_3", "field_value": "1.22"}, {"section_id": 1, "column_id": 4, "row_index": 0, "field_key": "judgement", "field_value": "합격"}, {"section_id": 1, "column_id": 1, "row_index": 1, "field_key": "measurement_1", "field_value": "1220"}, {"section_id": 1, "column_id": 4, "row_index": 1, "field_key": "judgement", "field_value": "합격"} ], "attachments": [ {"file_id": 123, "attachment_type": "certificate", "description": "Mill Sheet"} ] } ``` **Body Parameters:** | 파라미터 | 타입 | 필수 | 설명 | |---------|------|:----:|------| | `template_id` | integer | ✅ | 템플릿 ID | | `item_id` | integer | ✅ | 품목 ID | | `title` | string | ❌ | 문서 제목 (없으면 기존 유지 또는 빈 값) | | `data` | array | ❌ | 문서 데이터 배열 | | `data[].section_id` | integer | ❌ | 섹션 ID | | `data[].column_id` | integer | ❌ | 컬럼 ID | | `data[].row_index` | integer | ❌ | 행 인덱스 (0부터 시작) | | `data[].field_key` | string | ✅* | 필드 키 (*data가 있으면 필수) | | `data[].field_value` | string | ❌ | 필드 값 | | `attachments` | array | ❌ | 첨부파일 배열 | | `attachments[].file_id` | integer | ✅* | 파일 ID (*attachments가 있으면 필수) | | `attachments[].attachment_type` | string | ❌ | 첨부유형 | | `attachments[].description` | string | ❌ | 설명 | #### Response - 성공 ```json { "success": true, "message": "저장 성공", "data": { "id": 7, "tenant_id": 287, "template_id": 18, "document_no": "DOC-20260205-0001", "title": "수입검사 성적서 - EGI 1.2T", "status": "DRAFT", "linkable_type": "item", "linkable_id": 14172, "submitted_at": null, "completed_at": null, "created_by": 1, "updated_by": 1, "created_at": "2026-02-05", "updated_at": "2026-02-05", "template": { "id": 18, "name": "EGI 수입검사 (두께별 자동매칭)", "category": "수입검사" }, "approvals": [], "data": [...], "attachments": [], "creator": { "id": 1, "name": "홍길동" } } } ``` --- ## 3. React 연동 가이드 ### 3.1 TypeScript 타입 정의 ```typescript // types/document.ts export interface DocumentResolveResponse { is_new: boolean; template: DocumentTemplate; document: Document | null; item: Item; } export interface DocumentTemplate { id: number; name: string; category: string; title: string | null; company_name: string | null; company_address: string | null; company_contact: string | null; footer_remark_label: string; footer_judgement_label: string; footer_judgement_options: string[] | null; approval_lines: ApprovalLine[]; basic_fields: BasicField[]; section_fields: SectionField[]; sections: Section[]; columns: Column[]; } export interface Section { id: number; name: string; sort_order: number; items: SectionItem[]; } export interface SectionItem { id: number; field_values: Record; standard_criteria: StandardCriteria | null; tolerance: Tolerance | null; sort_order: number; } export interface StandardCriteria { min: number | null; min_op: 'gt' | 'gte' | null; max: number | null; max_op: 'lt' | 'lte' | null; } export interface Tolerance { type: 'symmetric' | 'asymmetric' | 'range' | 'percentage'; value?: string; plus?: string; minus?: string; min?: string; max?: string; } export interface Document { id: number; document_no: string; title: string; status: DocumentStatus; linkable_type: string; linkable_id: number; submitted_at: string | null; completed_at: string | null; created_at: string; data: DocumentData[]; attachments: DocumentAttachment[]; approvals: DocumentApproval[]; } export type DocumentStatus = 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'CANCELLED'; export interface DocumentData { section_id: number | null; column_id: number | null; row_index: number; field_key: string; field_value: string | null; } export interface Item { id: number; code: string; name: string; attributes: ItemAttributes | null; } export interface ItemAttributes { thickness?: number; width?: number; length?: number; [key: string]: any; } ``` ### 3.2 Custom Hook ```typescript // hooks/useDocument.ts import { useState, useEffect, useCallback } from 'react'; import { api } from '@/lib/api'; import type { DocumentResolveResponse, DocumentData } from '@/types/document'; interface UseDocumentOptions { category: string; itemId: number; } interface UseDocumentReturn { data: DocumentResolveResponse | null; loading: boolean; error: string | null; save: (formData: SaveDocumentPayload) => Promise; refresh: () => Promise; } interface SaveDocumentPayload { title?: string; data: DocumentData[]; attachments?: { file_id: number; attachment_type?: string; description?: string }[]; } export function useDocument({ category, itemId }: UseDocumentOptions): UseDocumentReturn { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const fetchDocument = useCallback(async () => { if (!category || !itemId) return; try { setLoading(true); setError(null); const response = await api.get('/documents/resolve', { params: { category, item_id: itemId } }); setData(response.data.data); } catch (err: any) { const message = err.response?.data?.message || '문서 조회에 실패했습니다.'; setError(message); setData(null); } finally { setLoading(false); } }, [category, itemId]); useEffect(() => { fetchDocument(); }, [fetchDocument]); const save = useCallback(async (formData: SaveDocumentPayload) => { if (!data?.template.id) { throw new Error('템플릿 정보가 없습니다.'); } const response = await api.post('/documents/upsert', { template_id: data.template.id, item_id: itemId, title: formData.title, data: formData.data, attachments: formData.attachments }); // 저장 후 데이터 갱신 await fetchDocument(); return response.data; }, [data?.template.id, itemId, fetchDocument]); return { data, loading, error, save, refresh: fetchDocument }; } ``` ### 3.3 검사 폼 컴포넌트 예제 ```tsx // components/InspectionForm.tsx import { useState, useEffect, useMemo } from 'react'; import { useDocument } from '@/hooks/useDocument'; import type { SectionItem, DocumentData, ItemAttributes, StandardCriteria } from '@/types/document'; interface Props { category: string; itemId: number; onSaveSuccess?: () => void; } export function InspectionForm({ category, itemId, onSaveSuccess }: Props) { const { data, loading, error, save } = useDocument({ category, itemId }); const [formData, setFormData] = useState>({}); const [saving, setSaving] = useState(false); // 기존 문서 데이터로 폼 초기화 useEffect(() => { if (data?.document?.data) { const initialData: Record = {}; data.document.data.forEach(d => { const key = makeFieldKey(d.section_id, d.row_index, d.field_key); initialData[key] = d.field_value || ''; }); setFormData(initialData); } else { setFormData({}); } }, [data]); // 품목 속성 기반 자동 하이라이트 const highlightedRows = useMemo(() => { if (!data?.item.attributes || !data.template.sections) { return new Set(); } const highlighted = new Set(); data.template.sections.forEach(section => { section.items.forEach(item => { if (shouldHighlight(item, data.item.attributes!)) { highlighted.add(item.id); } }); }); return highlighted; }, [data]); const handleInputChange = (sectionId: number, rowIndex: number, fieldKey: string, value: string) => { const key = makeFieldKey(sectionId, rowIndex, fieldKey); setFormData(prev => ({ ...prev, [key]: value })); }; const handleSubmit = async () => { if (!data) return; try { setSaving(true); // formData를 API 형식으로 변환 const documentData: DocumentData[] = []; Object.entries(formData).forEach(([key, value]) => { const [sectionId, rowIndex, fieldKey] = parseFieldKey(key); if (value) { documentData.push({ section_id: sectionId, column_id: null, row_index: rowIndex, field_key: fieldKey, field_value: value }); } }); await save({ title: `${data.template.name} - ${data.item.name}`, data: documentData }); onSaveSuccess?.(); alert('저장되었습니다.'); } catch (err: any) { alert(err.response?.data?.message || '저장에 실패했습니다.'); } finally { setSaving(false); } }; if (loading) return
로딩 중...
; if (error) return
에러: {error}
; if (!data) return null; return (
{/* 헤더 정보 */}

{data.template.name}

품목: {data.item.name} ({data.item.code}) 상태: {data.is_new ? ( 신규 작성 ) : ( 기존 문서 ({data.document?.document_no}) )}
{data.item.attributes && (
연결 품목 규격: t={data.item.attributes.thickness} w={data.item.attributes.width} l={data.item.attributes.length}
)}
{/* 검사 항목 테이블 */} {data.template.sections.map(section => (

{section.name}

{data.template.section_fields.map(field => ( ))} {data.template.columns.map(col => ( ))} {section.items.map((item, rowIndex) => ( {/* 검사 항목 정보 (읽기 전용) */} {data.template.section_fields.map(field => ( ))} {/* 측정값 입력 */} {data.template.columns.map(col => ( ))} ))}
{field.label} {col.label}
{formatFieldValue(item.field_values?.[field.field_key], field.field_type, item)} {renderInput( col, formData[makeFieldKey(section.id, rowIndex, col.label)] || '', (value) => handleInputChange(section.id, rowIndex, col.label, value), item.field_values?.measurement_type )}
))} {/* 저장 버튼 */}
); } // 헬퍼 함수들 function makeFieldKey(sectionId: number | null, rowIndex: number, fieldKey: string): string { return `${sectionId || 0}_${rowIndex}_${fieldKey}`; } function parseFieldKey(key: string): [number | null, number, string] { const parts = key.split('_'); const sectionId = parts[0] === '0' ? null : parseInt(parts[0]); const rowIndex = parseInt(parts[1]); const fieldKey = parts.slice(2).join('_'); return [sectionId, rowIndex, fieldKey]; } function shouldHighlight(item: SectionItem, attributes: ItemAttributes): boolean { const criteria = item.standard_criteria; if (!criteria) return false; const fieldValues = item.field_values || {}; const itemName = fieldValues.item?.toLowerCase() || ''; // 두께 매칭 if (itemName.includes('두께') && attributes.thickness != null) { return matchCriteria(attributes.thickness, criteria); } // 너비 매칭 if (itemName.includes('너비') && attributes.width != null) { return matchCriteria(attributes.width, criteria); } // 길이 매칭 if (itemName.includes('길이') && attributes.length != null) { return matchCriteria(attributes.length, criteria); } return false; } function matchCriteria(value: number, criteria: StandardCriteria): boolean { const { min, min_op, max, max_op } = criteria; let match = true; if (min != null) { match = match && (min_op === 'gte' ? value >= min : value > min); } if (max != null) { match = match && (max_op === 'lte' ? value <= max : value < max); } return match; } function formatFieldValue(value: any, fieldType: string, item: SectionItem): string { if (value == null) return '-'; switch (fieldType) { case 'json_tolerance': return formatTolerance(item.tolerance); case 'json_criteria': return formatCriteria(item.standard_criteria); default: return String(value); } } function formatTolerance(tolerance: any): string { if (!tolerance) return '-'; switch (tolerance.type) { case 'symmetric': return `±${tolerance.value}`; case 'asymmetric': return `+${tolerance.plus}/-${tolerance.minus}`; case 'range': return `${tolerance.min}~${tolerance.max}`; case 'percentage': return `±${tolerance.value}%`; default: return '-'; } } function formatCriteria(criteria: StandardCriteria | null): string { if (!criteria) return '-'; const parts: string[] = []; if (criteria.min != null) { parts.push(`${criteria.min_op === 'gte' ? '≥' : '>'}${criteria.min}`); } if (criteria.max != null) { parts.push(`${criteria.max_op === 'lte' ? '≤' : '<'}${criteria.max}`); } return parts.join(', ') || '-'; } function renderInput( column: any, value: string, onChange: (value: string) => void, measurementType?: string ): JSX.Element { const inputType = column.input_type || 'text'; if (inputType === 'select' || measurementType === 'checkbox') { return ( ); } return ( onChange(e.target.value)} className="w-full px-2 py-1 border rounded text-sm" /> ); } ``` --- ## 4. 사용 케이스별 예제 ### 4.1 신규 문서 작성 플로우 ```typescript // 1. resolve 호출 const response = await api.get('/documents/resolve', { params: { category: 'incoming_inspection', item_id: 14172 } }); console.log(response.data.data.is_new); // true console.log(response.data.data.document); // null console.log(response.data.data.template.id); // 18 // 2. 폼에 데이터 입력 후 저장 await api.post('/documents/upsert', { template_id: 18, item_id: 14172, title: '수입검사 성적서 - EGI 1.2T', data: [ { row_index: 0, field_key: 'measurement_1', field_value: '1.21' }, { row_index: 0, field_key: 'measurement_2', field_value: '1.20' }, { row_index: 0, field_key: 'measurement_3', field_value: '1.22' }, { row_index: 0, field_key: 'judgement', field_value: '합격' } ] }); // 결과: 새 문서 생성됨 (DOC-20260205-0001) ``` ### 4.2 기존 문서 수정 플로우 ```typescript // 1. resolve 호출 - 기존 DRAFT 문서 반환 const response = await api.get('/documents/resolve', { params: { category: 'incoming_inspection', item_id: 14172 } }); console.log(response.data.data.is_new); // false console.log(response.data.data.document.document_no); // "DOC-20260205-0001" console.log(response.data.data.document.status); // "DRAFT" // 2. 기존 데이터를 폼에 표시 → 수정 → 저장 await api.post('/documents/upsert', { template_id: 18, item_id: 14172, title: '수입검사 성적서 - EGI 1.2T (수정)', data: [ { row_index: 0, field_key: 'measurement_1', field_value: '1.23' }, // 수정됨 { row_index: 0, field_key: 'measurement_2', field_value: '1.20' }, { row_index: 0, field_key: 'measurement_3', field_value: '1.22' }, { row_index: 0, field_key: 'judgement', field_value: '합격' } ] }); // 결과: 기존 문서 업데이트됨 ``` ### 4.3 에러 처리 패턴 ```typescript async function loadDocument(category: string, itemId: number) { try { const response = await api.get('/documents/resolve', { params: { category, item_id: itemId } }); return { success: true, data: response.data.data }; } catch (error: any) { const status = error.response?.status; const message = error.response?.data?.message; if (status === 400) { // 잘못된 카테고리 return { success: false, error: 'invalid_category', message }; } if (status === 404) { if (message?.includes('양식')) { // 템플릿 없음 - MNG에서 해당 품목을 템플릿에 연결해야 함 return { success: false, error: 'template_not_found', message }; } if (message?.includes('품목')) { // 품목 없음 return { success: false, error: 'item_not_found', message }; } } return { success: false, error: 'unknown', message: message || '알 수 없는 오류' }; } } // 사용 예시 const result = await loadDocument('incoming_inspection', 14172); if (!result.success) { switch (result.error) { case 'invalid_category': alert('유효하지 않은 문서 분류입니다.'); break; case 'template_not_found': alert('이 품목에 연결된 검사 양식이 없습니다.\n본사에 문의해주세요.'); break; case 'item_not_found': alert('품목 정보를 찾을 수 없습니다.'); break; default: alert(result.message); } return; } // 성공 시 처리 const { is_new, template, document, item } = result.data; ``` --- ## 5. 문서 상태 워크플로우 ### 5.1 상태 전이도 ``` ┌──────────────────────┐ │ │ ▼ │ ┌────────┐ 결재요청 ┌─────────┐ 회수 ┌───────────┐ │ DRAFT │ ──────────> │ PENDING │ ───────> │ CANCELLED │ └────────┘ └─────────┘ └───────────┘ ▲ │ │ ├── 승인 ──> ┌──────────┐ │ │ │ APPROVED │ │ │ └──────────┘ │ │ │ 재수정 └── 반려 ──> ┌──────────┐ └───────────────────────────────── │ REJECTED │ └──────────┘ ``` ### 5.2 상태별 특성 | 상태 | 수정 가능 | 삭제 가능 | 결재 요청 | 설명 | |------|:--------:|:--------:|:---------:|------| | DRAFT | ✅ | ✅ | ✅ | 임시저장 | | PENDING | ❌ | ❌ | ❌ | 결재 진행 중 | | APPROVED | ❌ | ❌ | ❌ | 승인 완료 | | REJECTED | ✅ | ❌ | ✅ | 반려됨 (수정 시 DRAFT로 변경) | | CANCELLED | ❌ | ❌ | ❌ | 취소됨 | ### 5.3 upsert와 상태의 관계 - **upsert**는 `DRAFT` 또는 `REJECTED` 상태의 문서만 대상으로 함 - 같은 template_id + item_id 조합으로 `APPROVED` 문서가 있어도, `DRAFT`/`REJECTED` 문서가 있으면 그것을 업데이트 - 모든 문서가 `APPROVED`/`PENDING`/`CANCELLED` 상태면 새 `DRAFT` 문서 생성 --- ## 6. 문서 분류 관리 (common_codes) ### 6.1 기본 분류 | code | name | 설명 | |------|------|------| | `incoming_inspection` | 수입검사 | 자재/원료 입고 시 검사 | | `quality_inspection` | 품질검사 | 중간/공정 중 품질 검사 | | `outgoing_inspection` | 출하검사 | 완제품 출하 전 검사 | ### 6.2 테넌트별 커스텀 분류 추가 테넌트별로 추가 분류를 등록할 수 있습니다. ```sql -- common_codes 테이블에 테넌트별 분류 추가 INSERT INTO common_codes (code_group, code, name, tenant_id, sort_order, is_active) VALUES ('document_category', 'interim_inspection', '중간검사', 287, 4, true); ``` ### 6.3 분류 조회 우선순위 1. 테넌트 전용 분류 (tenant_id = 현재 테넌트) 2. 글로벌 분류 (tenant_id = NULL) --- ## 7. 주의사항 ### 7.1 템플릿-품목 연결 필수 - `resolve` API는 해당 category의 템플릿 중 item_id가 **연결된** 템플릿만 반환 - MNG에서 템플릿 편집 시 "연결 설정"에서 품목을 연결해야 함 ### 7.2 다중 문서 방지 - 같은 품목(item_id)에 대해 동일 템플릿의 DRAFT 문서는 **1개만** 존재 - 기존 DRAFT가 있으면 upsert는 UPDATE 동작 ### 7.3 Auto-Highlight - `item.attributes`의 `thickness`, `width`, `length` 값과 검사 항목의 `standard_criteria`를 비교 - UI에서 해당 행을 하이라이트하여 사용자가 어떤 검사 항목을 작성해야 하는지 안내 ### 7.4 날짜 형식 - 응답의 날짜 필드는 `Y-m-d` 형식 (ApiResponse::formatDates 적용) - ISO 8601 원본이 필요하면 `*_at` 필드의 raw 값 사용 --- ## 변경 이력 | 버전 | 날짜 | 작성자 | 변경 내용 | |------|------|--------|----------| | 1.0.0 | 2026-02-05 | API Team | 최초 작성 |