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

34 KiB
Raw Blame 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:

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)

{
  "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)

{
  "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:

Content-Type: application/json
x-api-key: {API_KEY}
Authorization: Bearer {TOKEN}

Body:

{
  "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 - 성공

{
  "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 타입 정의

// 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

// 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 검사 폼 컴포넌트 예제

// 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 신규 문서 작성 플로우

// 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 기존 문서 수정 플로우

// 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 에러 처리 패턴

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와 상태의 관계

  • upsertDRAFT 또는 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 테넌트별 커스텀 분류 추가

테넌트별로 추가 분류를 등록할 수 있습니다.

-- 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.attributesthickness, width, length 값과 검사 항목의 standard_criteria를 비교
  • UI에서 해당 행을 하이라이트하여 사용자가 어떤 검사 항목을 작성해야 하는지 안내

7.4 날짜 형식

  • 응답의 날짜 필드는 Y-m-d 형식 (ApiResponse::formatDates 적용)
  • ISO 8601 원본이 필요하면 *_at 필드의 raw 값 사용

변경 이력

버전 날짜 작성자 변경 내용
1.0.0 2026-02-05 API Team 최초 작성