Files
sam-docs/frontend/api-specs/document-api-integration.md
권혁성 db63fcff85 refactor: [docs] 팀별 폴더 구조 재편 (공유/개발/프론트/기획)
- 개발팀 전용 폴더 dev/ 생성 (standards, guides, quickstart, changes, deploys, data, history, dev_plans 이동)
- 프론트엔드 전용 폴더 frontend/ 생성 (api/ → frontend/api-specs/)
- 기획팀 폴더 requests/ 생성
- plans/ → dev/dev_plans/ 이름 변경
- README.md 신규 (사람용 안내), INDEX.md 재작성 (Claude Code용)
- resources.md 신규 (노션 링크용, assets/brochure 이관 예정)
- CURRENT_WORKS.md 삭제, TODO.md → dev/ 이동
- 전체 참조 경로 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:46:03 +09:00

1050 lines
34 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 문서 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 | 최초 작성 |