Files
sam-docs/frontend/api-specs/document-api-integration.md

1050 lines
34 KiB
Markdown
Raw Permalink Normal View History

# 문서 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<string, any>;
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<any>;
refresh: () => Promise<void>;
}
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<DocumentResolveResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<Record<string, string>>({});
const [saving, setSaving] = useState(false);
// 기존 문서 데이터로 폼 초기화
useEffect(() => {
if (data?.document?.data) {
const initialData: Record<string, string> = {};
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<number>();
}
const highlighted = new Set<number>();
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 <div className="p-4">로딩 중...</div>;
if (error) return <div className="p-4 text-red-500">에러: {error}</div>;
if (!data) return null;
return (
<div className="p-4">
{/* 헤더 정보 */}
<div className="mb-4">
<h2 className="text-xl font-bold">{data.template.name}</h2>
<div className="text-sm text-gray-600">
<span>품목: {data.item.name} ({data.item.code})</span>
<span className="ml-4">
상태: {data.is_new ? (
<span className="text-blue-600">신규 작성</span>
) : (
<span className="text-green-600">기존 문서 ({data.document?.document_no})</span>
)}
</span>
</div>
{data.item.attributes && (
<div className="mt-2 text-xs bg-blue-50 p-2 rounded">
연결 품목 규격:
t={data.item.attributes.thickness}
w={data.item.attributes.width}
l={data.item.attributes.length}
</div>
)}
</div>
{/* 검사 항목 테이블 */}
{data.template.sections.map(section => (
<div key={section.id} className="mb-6">
<h3 className="font-semibold mb-2">{section.name}</h3>
<table className="w-full border-collapse border">
<thead>
<tr className="bg-gray-100">
{data.template.section_fields.map(field => (
<th key={field.id} className="border p-2 text-sm" style={{ width: field.width }}>
{field.label}
</th>
))}
{data.template.columns.map(col => (
<th key={col.id} className="border p-2 text-sm" style={{ width: col.width }}>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{section.items.map((item, rowIndex) => (
<tr
key={item.id}
className={highlightedRows.has(item.id) ? 'bg-yellow-100' : ''}
>
{/* 검사 항목 정보 (읽기 전용) */}
{data.template.section_fields.map(field => (
<td key={field.id} className="border p-2 text-sm">
{formatFieldValue(item.field_values?.[field.field_key], field.field_type, item)}
</td>
))}
{/* 측정값 입력 */}
{data.template.columns.map(col => (
<td key={col.id} className="border p-1">
{renderInput(
col,
formData[makeFieldKey(section.id, rowIndex, col.label)] || '',
(value) => handleInputChange(section.id, rowIndex, col.label, value),
item.field_values?.measurement_type
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
))}
{/* 저장 버튼 */}
<div className="flex justify-end gap-2">
<button
onClick={handleSubmit}
disabled={saving}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{saving ? '저장 중...' : '저장'}
</button>
</div>
</div>
);
}
// 헬퍼 함수들
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 (
<select
value={value}
onChange={e => onChange(e.target.value)}
className="w-full px-2 py-1 border rounded text-sm"
>
<option value="">선택</option>
<option value="합격">합격</option>
<option value="불합격">불합격</option>
</select>
);
}
return (
<input
type="text"
value={value}
onChange={e => 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 | 최초 작성 |