diff --git a/api/document-api-integration.md b/api/document-api-integration.md new file mode 100644 index 0000000..c185dcf --- /dev/null +++ b/api/document-api-integration.md @@ -0,0 +1,1049 @@ +# 문서 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 | 최초 작성 |