"use server"; /** * SWP API Client * * ## 목적 * 외부 SWP 시스템의 REST API를 호출하는 저수준 클라이언트입니다. * * ## 설계 원칙 * 1. **단순 API 호출만 담당** - 비즈니스 로직은 document-service.ts에서 처리 * 2. **타입 안전성** - API 응답 타입을 명확히 정의 * 3. **에러 핸들링** - API 실패 시 명확한 에러 메시지 제공 * 4. **디버깅 지원** - 모든 API 호출을 debugLog로 추적 * * ## 주요 API * - `fetchGetVDRDocumentList`: 문서 마스터 조회 (필터링 지원) * - `fetchGetExternalInboxList`: 파일 정보 조회 (업로드된 파일 포함) * - `fetchGetRevTreeCompleteList`: 문서 리비전 트리 조회 (NEW) * - `fetchGetActivityFileList`: Rev-Activity-File 계층 구조 조회 * - `callSaveInBoxList`: 파일 업로드 메타데이터 등록 * - `callSaveInBoxListCancelStatus`: Standby 파일 취소 * * ## 사용 예시 * ```typescript * // 문서 목록 조회 * const documents = await fetchGetVDRDocumentList({ * proj_no: "SN2190", * doc_gb: "V", * vndrCd: "SE00100" * }); * * // 리비전 트리 조회 * const tree = await fetchGetRevTreeCompleteList({ * proj_no: "SN2190", * doc_no: "C168-SH-SBN08-XG-20118-01" * }); * ``` * * @see lib/swp/document-service.ts - 비즈니스 로직 레이어 * @see lib/swp/vendor-actions.ts - 서버 액션 (권한 체크) * @see lib/swp/README.md - 전체 시스템 문서 * @see lib/swp/REVISION_TREE_USAGE.md - 리비전 트리 API 활용 가이드 */ // ============================================================================ // SWP API 클라이언트 // ============================================================================ const SWP_BASE_URL = process.env.SWP_API_URL || "http://60.100.99.217/DDC"; // ============================================================================ // API 요청 타입 정의 // ============================================================================ /** * 문서 리스트 조회 필터 */ export interface GetVDRDocumentListFilter { proj_no: string; // 필수 doc_gb: "M" | "V"; // 필수 (M=MDR, V=VDR) ctgry?: string; // HULL or TOP pkgNo?: string; vndrCd?: string; pic_deptcd?: string; doc_type?: string; displn?: string; mat_cd?: string; proj_nm?: string; stage?: string; own_doc_no?: string; doc_title?: string; lang_gb?: string; } /** * 첨부파일 리스트 조회 필터 */ export interface GetExternalInboxListFilter { projNo: string; // 필수 pkgNo?: string; vndrCd?: string; stage?: string; owndocno?: string; doctitle?: string; } export interface SwpDocumentApiResponse { // 필수 필드 DOC_NO: string; DOC_TITLE: string; PROJ_NO: string; CPY_CD: string; CPY_NM: string; PIC_NM: string; PIC_DEPTNM: string; SKL_CD: string; CRTER: string; CRTE_DTM: string; CHGR: string; CHG_DTM: string; // 선택적 필드 (null 가능) DOC_GB: string | null; DOC_TYPE: string | null; DOC_CLS: string | null; // Document Class (A, B, C 등) OWN_DOC_NO: string | null; SHI_DOC_NO: string | null; PROJ_NM: string | null; PKG_NO: string | null; MAT_CD: string | null; MAT_NM: string | null; DISPLN: string | null; CTGRY: string | null; VNDR_CD: string | null; PIC_DEPTCD: string | null; LTST_REV_NO: string | null; LTST_REV_SEQ: string | null; LTST_ACTV_STAT: string | null; STAGE: string | null; MOD_TYPE: string | null; ACT_TYPE_NM: string | null; USE_YN: string | null; REV_DTM: string | null; } export interface SwpFileApiResponse { // 필수 필드 OWN_DOC_NO: string; REV_NO: string; STAGE: string; FILE_NM: string; FILE_SEQ: string; CRTER: string; CRTE_DTM: string; CHGR: string; CHG_DTM: string; // 선택적 필드 (null 가능) FILE_SZ: string | null; FLD_PATH: string | null; ACTV_NO: string | null; ACTV_SEQ: string | null; BOX_SEQ: string | null; OFDC_NO: string | null; PROJ_NO: string | null; PKG_NO: string | null; VNDR_CD: string | null; CPY_CD: string | null; STAT: string | null; STAT_NM: string | null; IDX: string | null; NOTE: string | null; } // ============================================================================ // 공통 API 호출 함수 // ============================================================================ async function callSwpApi( endpoint: string, body: Record, resultKey: string ): Promise { const url = `${SWP_BASE_URL}/Services/WebService.svc/${endpoint}`; console.log(`[SWP API] 호출: ${endpoint}`, body); try { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(body), signal: AbortSignal.timeout(30000), // 30초 }); if (!response.ok) { throw new Error( `SWP API 오류: ${response.status} ${response.statusText}` ); } const data = await response.json(); if (!data[resultKey]) { throw new Error(`API 응답 형식 오류: ${resultKey} 없음`); } console.log(`[SWP API] 성공: ${data[resultKey].length}개 조회`); return data[resultKey] as T[]; } catch (error) { if (error instanceof Error) { if (error.name === "AbortError") { throw new Error(`API 타임아웃: 30초 초과`); } throw new Error(`${endpoint} 실패: ${error.message}`); } throw error; } } /** * SWP API 호출 (단일 값 반환용) * 배열이 아닌 단일 값(숫자, 문자열 등)을 반환하는 API용 */ async function callSwpApiSingle( endpoint: string, body: Record, resultKey: string ): Promise { const url = `${SWP_BASE_URL}/Services/WebService.svc/${endpoint}`; console.log(`[SWP API] 호출: ${endpoint}`, body); try { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(body), signal: AbortSignal.timeout(30000), // 30초 }); if (!response.ok) { throw new Error( `SWP API 오류: ${response.status} ${response.statusText}` ); } const data = await response.json(); if (data[resultKey] === undefined) { throw new Error(`API 응답 형식 오류: ${resultKey} 없음`); } console.log(`[SWP API] 성공: ${endpoint}`, data[resultKey]); return data[resultKey] as T; } catch (error) { if (error instanceof Error) { if (error.name === "AbortError") { throw new Error(`API 타임아웃: 30초 초과`); } throw new Error(`${endpoint} 실패: ${error.message}`); } throw error; } } // ============================================================================ // 서버 액션: 문서 리스트 조회 // ============================================================================ /** * 문서 리스트 조회 (GetVDRDocumentList) * @param filter 조회 필터 */ export async function fetchGetVDRDocumentList( filter: GetVDRDocumentListFilter ): Promise { // doc_gb 기본값 설정 const body = { proj_no: filter.proj_no, doc_gb: filter.doc_gb || "V", // 기본값 V ctgry: filter.ctgry || "", pkgNo: filter.pkgNo || "", vndrCd: filter.vndrCd || "", pic_deptcd: filter.pic_deptcd || "", doc_type: filter.doc_type || "", displn: filter.displn || "", mat_cd: filter.mat_cd || "", proj_nm: filter.proj_nm || "", stage: filter.stage || "", own_doc_no: filter.own_doc_no || "", doc_title: filter.doc_title || "", lang_gb: filter.lang_gb || "", }; return callSwpApi( "GetVDRDocumentList", body, "GetVDRDocumentListResult" ); } // ============================================================================ // 서버 액션: 첨부파일 리스트 조회 // ============================================================================ /** * 첨부파일 리스트 조회 (GetExternalInboxList) * @param filter 조회 필터 */ export async function fetchGetExternalInboxList( filter: GetExternalInboxListFilter ): Promise { const body = { projNo: filter.projNo, pkgNo: filter.pkgNo || "", vndrCd: filter.vndrCd || "", stage: filter.stage || "", owndocno: filter.owndocno || "", doctitle: filter.doctitle || "", }; return callSwpApi( "GetExternalInboxList", body, "GetExternalInboxListResult" ); } // ============================================================================ // 서버 액션: 프로젝트 데이터 일괄 조회 // ============================================================================ /** * 프로젝트의 문서 + 파일 리스트 동시 조회 * @param projectNo 프로젝트 번호 (예: "SN2190") * @param docGb 문서 구분 (M=MDR, V=VDR, 기본값: V) */ export async function fetchSwpProjectData( projectNo: string, docGb: "M" | "V" = "V" ): Promise<{ documents: SwpDocumentApiResponse[]; files: SwpFileApiResponse[]; }> { console.log(`[SWP API] 프로젝트 ${projectNo} 데이터 조회 시작`); const startTime = Date.now(); try { // 병렬 호출 const [documents, files] = await Promise.all([ fetchGetVDRDocumentList({ proj_no: projectNo, doc_gb: docGb, }), fetchGetExternalInboxList({ projNo: projectNo, }), ]); const duration = Date.now() - startTime; console.log( `[SWP API] 조회 완료: 문서 ${documents.length}개, 파일 ${files.length}개 (${duration}ms)` ); return { documents, files }; } catch (error) { console.error(`[SWP API] 조회 실패:`, error); throw error; } } // ============================================================================ // 파일 다운로드 URL 생성 // ============================================================================ /** * SWP 파일 다운로드 URL 생성 */ export async function getSwpFileDownloadUrl(file: { FLD_PATH: string; FILE_NM: string; }): Promise { // FLD_PATH: "\SN2190\C00035\\20170217180135" // FILE_NM: "C168-SH-SBN08-XG-20118-01_04_IFC_20170216.pdf" const encodedPath = encodeURIComponent(file.FLD_PATH); const encodedName = encodeURIComponent(file.FILE_NM); return `${SWP_BASE_URL}/Files/${encodedPath}/${encodedName}`; } /** * SWP 파일 직접 다운로드 (Blob) */ export async function downloadSwpFile(file: { FLD_PATH: string; FILE_NM: string; }): Promise { const url = await getSwpFileDownloadUrl(file); const response = await fetch(url, { method: "GET", signal: AbortSignal.timeout(60000), // 1분 }); if (!response.ok) { throw new Error(`파일 다운로드 실패: ${response.status}`); } return response.blob(); } // ============================================================================ // 통계 및 유틸리티 // ============================================================================ /** * API 응답 통계 */ export interface SwpDataStats { projectNo: string; documentCount: number; fileCount: number; revisionCount: number; avgFilesPerDoc: number; stages: Record; fileTypes: Record; totalFileSize: number; } export async function analyzeSwpData( projectNo: string, documents: SwpDocumentApiResponse[], files: SwpFileApiResponse[] ): Promise { // 리비전 카운트 const revisionSet = new Set(); files.forEach((f) => revisionSet.add(`${f.OWN_DOC_NO}|${f.REV_NO}`)); // 스테이지별 카운트 const stages: Record = {}; files.forEach((f) => { stages[f.STAGE] = (stages[f.STAGE] || 0) + 1; }); // 파일 타입별 카운트 const fileTypes: Record = {}; files.forEach((f) => { const ext = f.FILE_NM.split(".").pop()?.toLowerCase() || "unknown"; fileTypes[ext] = (fileTypes[ext] || 0) + 1; }); // 총 파일 사이즈 (숫자로 변환 가능한 것만) const totalFileSize = files.reduce((sum, f) => { const size = f.FILE_SZ ? parseInt(f.FILE_SZ, 10) : 0; return sum + (isNaN(size) ? 0 : size); }, 0); return { projectNo, documentCount: documents.length, fileCount: files.length, revisionCount: revisionSet.size, avgFilesPerDoc: documents.length > 0 ? files.length / documents.length : 0, stages, fileTypes, totalFileSize, }; } // ============================================================================ // 서버 액션: 리비전 트리 조회 (GetRevTreeCompleteList) // ============================================================================ /** * 리비전 트리 조회 필터 */ export interface GetRevTreeCompleteListFilter { proj_no: string; // 필수 (예: "SN2190") doc_no: string; // 필수 (예: "C168-SH-SBN08-XG-20118-01") stat?: string; // 상태 코드 필터 (선택) lang_gb?: string; // 언어 구분 (선택) } /** * 리비전 트리 노드 API 응답 * * 트리 구조: * - Root: 문서 자체 * - Revision: 문서의 버전 (01, 02, 03...) * - Activity: 각 리비전에 속한 송수신 활동 */ export interface RevTreeNodeApiResponse { // 노드 식별 정보 SearchType: "Root" | "Revision" | "Activity"; // 노드 타입 NodeName: string; // 노드 이름 (Activity의 경우 ACTV_NO) ParentName: string | null; // 부모 노드 이름 SearchName: string; // 검색용 이름 // Revision 정보 (SearchType이 "Revision"일 때) Stage: string; // IFA, IFC 등 // Activity 정보 (SearchType이 "Activity"일 때) InOut: string; // "IN"=수신, "OUT"=송신 StatusCode: string; // R00=Ready, S00=Ready, S30=Completed StatusName: string; // 상태명 TransmittalNo: string; // 송장번호 RefActivityNo: string; // 참조 Activity 번호 // 메타 정보 CreateDate: string; CreateEmpNo: string; ModifyDate: string; ModifyEmpNo: string; } /** * 리비전 트리 조회 (GetRevTreeCompleteList) * * 문서의 전체 리비전 히스토리와 각 리비전별 Activity 목록을 트리 구조로 조회합니다. * * @param filter 조회 필터 * @returns 트리 구조의 노드 배열 * * @example * ```typescript * const tree = await fetchGetRevTreeCompleteList({ * proj_no: "SN2190", * doc_no: "C168-SH-SBN08-XG-20118-01" * }); * * // Root 노드 찾기 * const root = tree.find(n => n.SearchType === "Root"); * * // Revision 목록 * const revisions = tree.filter(n => n.SearchType === "Revision"); * * // 특정 Revision의 Activity 목록 * const rev04Activities = tree.filter(n => * n.SearchType === "Activity" && n.ParentName === "3" // Rev 04 * ); * ``` */ export async function fetchGetRevTreeCompleteList( filter: GetRevTreeCompleteListFilter ): Promise { const body = { proj_no: filter.proj_no, doc_no: filter.doc_no, stat: filter.stat || "", lang_gb: filter.lang_gb || "", }; return callSwpApi( "GetRevTreeCompleteList", body, "GetRevTreeCompleteListResult" ); } // ============================================================================ // 서버 액션: Activity 및 파일 리스트 조회 (GetActivityFileList) // ============================================================================ /** * Activity 파일 리스트 조회 필터 */ export interface GetActivityFileListFilter { proj_no: string; doc_no: string; rev_seq?: string; // 선택적 } /** * Activity 파일 API 응답 */ export interface ActivityFileApiResponse { ACTV_NO: string; ACT_TYPE: string; DOC_NO: string; DOC_TITLE: string; REV_NO: string; REV_SEQ: string; STAGE: string; STAT: string; // R00=Receive, S30=Send, V00=Review FILE_TYPE: string; // "Receive", "Send", "Review" FILE_NM: string; FILE_SEQ: string; FILE_SZ: string; FILE_FMT: string; OWN_DOC_NO: string; TO_FROM: string; // 업체명 OBJT_ID: string; DSC: string | null; BATCHUPLOAD_ID: string | null; TRNS_DTM: string | null; CRTER: string; CRTE_DTM: string; } /** * Activity 파일 리스트 조회 (GetActivityFileList) * @param filter 조회 필터 */ export async function fetchGetActivityFileList( filter: GetActivityFileListFilter ): Promise { const body = { proj_no: filter.proj_no, doc_no: filter.doc_no, rev_seq: filter.rev_seq || "", }; return callSwpApi( "GetActivityFileList", body, "GetActivityFileListResult" ); } // ============================================================================ // 서버 액션: 파일 취소 (SaveInBoxListCancelStatus) // ============================================================================ export interface CancelFileParams { boxSeq: string; actvSeq: string; chgr: string; // 취소 요청자 (evcp${userId}) } /** * 파일 취소 API (SaveInBoxListCancelStatus) * @returns 취소된 파일 개수 */ export async function callSaveInBoxListCancelStatus( params: CancelFileParams ): Promise { const body = { boxSeq: params.boxSeq, actvSeq: params.actvSeq, chgr: params.chgr, }; // 응답 키는 "SaveInBoxCancelStatusResult" (List 없음) const result = await callSwpApiSingle( "SaveInBoxListCancelStatus", body, "SaveInBoxCancelStatusResult" ); return result; } // ============================================================================ // 유틸리티: GetRevTreeCompleteList + GetActivityFileList 연동 // ============================================================================ /** * 리비전 트리 구조 파싱 결과 */ export interface ParsedRevisionTree { docNo: string; revisions: Array<{ revSeq: string; // "0", "1", "2", "3" (내부 시퀀스) revNo: string; // "01", "02", "03", "04" (표시용) stage: string; // "IFA", "IFC" activities: Array<{ actvNo: string; // NodeName (예: "SHIK2017022008520078313") inOut: "IN" | "OUT"; statusCode: string; statusName: string; transmittalNo: string; refActivityNo: string; createDate: string; createEmpNo: string; }>; }>; } /** * 리비전 트리를 구조화된 형태로 파싱 * * @param tree GetRevTreeCompleteList 응답 * @returns 파싱된 리비전 트리 */ export async function parseRevisionTree( tree: RevTreeNodeApiResponse[] ): Promise { const root = tree.find((n) => n.SearchType === "Root"); if (!root) { throw new Error("Root 노드를 찾을 수 없습니다"); } // Revision 목록 const revisions = tree .filter((n) => n.SearchType === "Revision") .map((rev) => { // 해당 Revision의 Activity 목록 const activities = tree .filter( (n) => n.SearchType === "Activity" && n.ParentName === rev.NodeName ) .map((act) => ({ actvNo: act.NodeName, inOut: act.InOut as "IN" | "OUT", statusCode: act.StatusCode, statusName: act.StatusName, transmittalNo: act.TransmittalNo, refActivityNo: act.RefActivityNo.trim(), createDate: act.CreateDate, createEmpNo: act.CreateEmpNo, })); return { revSeq: rev.NodeName, // "0", "1", "2", "3" revNo: rev.SearchName, // "01", "02", "03", "04" stage: rev.Stage, activities, }; }); return { docNo: root.NodeName, revisions, }; } /** * 특정 문서의 모든 리비전과 파일 정보를 조회 * * GetRevTreeCompleteList로 트리 구조를 가져온 후, * 각 Activity의 NodeName(ACTV_NO)을 사용하여 GetActivityFileList를 호출합니다. * * ⚠️ 주의: Activity가 많을 경우 API 호출이 많아질 수 있습니다. * * @param projNo 프로젝트 번호 * @param docNo 문서 번호 * @returns 리비전 트리 + 각 Activity의 파일 목록 * * @example * ```typescript * const fullData = await fetchDocumentRevisionTreeWithFiles( * "SN2190", * "C168-SH-SBN08-XG-20118-01" * ); * * console.log(`문서: ${fullData.docNo}`); * fullData.revisions.forEach(rev => { * console.log(`\nRevision ${rev.revNo} (${rev.stage})`); * rev.activities.forEach(act => { * console.log(` Activity: ${act.actvNo} (${act.inOut})`); * console.log(` 파일 ${act.files.length}개`); * }); * }); * ``` */ export async function fetchDocumentRevisionTreeWithFiles( projNo: string, docNo: string ): Promise<{ docNo: string; revisions: Array<{ revSeq: string; revNo: string; stage: string; activities: Array<{ actvNo: string; inOut: "IN" | "OUT"; statusCode: string; statusName: string; transmittalNo: string; files: ActivityFileApiResponse[]; }>; }>; }> { console.log(`[SWP API] 문서 ${docNo} 전체 히스토리 조회 시작`); // 1단계: 리비전 트리 조회 const tree = await fetchGetRevTreeCompleteList({ proj_no: projNo, doc_no: docNo, }); const parsed = await parseRevisionTree(tree); // 2단계: 각 Activity의 파일 목록 조회 // Activity가 많을 경우 병렬 처리 고려 (단, API 부하 주의) const revisionsWithFiles = await Promise.all( parsed.revisions.map(async (rev) => { const activitiesWithFiles = await Promise.all( rev.activities.map(async (act) => { try { // GetActivityFileList는 rev_seq가 필요할 수 있음 const files = await fetchGetActivityFileList({ proj_no: projNo, doc_no: docNo, rev_seq: rev.revSeq, }); // Activity 번호로 필터링 const activityFiles = files.filter( (f) => f.ACTV_NO === act.actvNo ); return { ...act, files: activityFiles, }; } catch (error) { console.error( `Activity ${act.actvNo} 파일 조회 실패:`, error ); return { ...act, files: [], }; } }) ); return { ...rev, activities: activitiesWithFiles, }; }) ); console.log(`[SWP API] 조회 완료: ${revisionsWithFiles.length}개 리비전`); return { docNo: parsed.docNo, revisions: revisionsWithFiles, }; } /** * 특정 Revision의 Activity 목록만 조회 (경량 버전) * * @param projNo 프로젝트 번호 * @param docNo 문서 번호 * @param revNo Revision 번호 (예: "04") * @returns 해당 Revision의 Activity 목록 */ export async function fetchRevisionActivities( projNo: string, docNo: string, revNo: string ): Promise { const tree = await fetchGetRevTreeCompleteList({ proj_no: projNo, doc_no: docNo, }); const parsed = await parseRevisionTree(tree); return parsed.revisions.find((r) => r.revNo === revNo) || null; }