- 개발팀 전용 폴더 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>
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 | 최초 작성 |
|