1050 lines
34 KiB
Markdown
1050 lines
34 KiB
Markdown
|
|
# 문서 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 | 최초 작성 |
|