"use server"; import { fetchGetVDRDocumentList, fetchGetExternalInboxList, fetchGetActivityFileList, callSaveInBoxListCancelStatus, type SwpDocumentApiResponse, type SwpFileApiResponse, type ActivityFileApiResponse, } from "./api-client"; import { debugLog, debugError, debugSuccess, debugWarn } from "@/lib/debug-utils"; import * as fs from "fs/promises"; import * as path from "path"; // ============================================================================ // 타입 정의 // ============================================================================ /** * 파일 정보 (Activity 파일 + Inbox 파일 결합) */ export interface DocumentFile { fileNm: string; fileSeq: string; fileSz: string; fileFmt: string; fldPath?: string; stat?: string; // SCW01=Standby, SCW03=Complete 등 statNm?: string; canCancel: boolean; // STAT=SCW01인 경우만 취소 가능 canDownload: boolean; // FLD_PATH가 있으면 다운로드 가능 boxSeq?: string; actvSeq?: string; objId: string; crteDate: string; } /** * Activity 정보 */ export interface Activity { actvNo: string; type: "Receive" | "Send" | "Review"; // STAT 첫 글자로 판단 stat: string; // R00, S30 등 toFrom: string; // 업체명 createDate: string; files: DocumentFile[]; } /** * Revision 정보 */ export interface Revision { revNo: string; revSeq: string; stage: string; activities: Activity[]; totalFiles: number; } /** * 문서 상세 (Rev-Activity-File 트리) */ export interface DocumentDetail { docNo: string; docTitle: string; projNo: string; revisions: Revision[]; } /** * 문서 목록 아이템 (통계 포함) */ export interface DocumentListItem extends SwpDocumentApiResponse { fileCount: number; standbyFileCount: number; // STAT=SCW01 latestFiles: SwpFileApiResponse[]; } // ============================================================================ // 문서 목록 조회 (시나리오 1) // ============================================================================ /** * 문서 목록 조회 * - GetVDRDocumentList + GetExternalInboxList 병합 * - 파일 통계 계산 */ export async function getDocumentList( projNo: string, vndrCd?: string ): Promise { debugLog("[getDocumentList] 시작", { projNo, vndrCd }); try { // 병렬 API 호출 const [documents, allFiles] = await Promise.all([ fetchGetVDRDocumentList({ proj_no: projNo, doc_gb: "V", vndrCd: vndrCd, }), fetchGetExternalInboxList({ projNo: projNo, vndrCd: vndrCd, }), ]); debugLog("[getDocumentList] API 조회 완료", { documents: documents.length, files: allFiles.length, }); // 파일을 문서별로 그룹핑 const filesByDoc = new Map(); for (const file of allFiles) { const docNo = file.OWN_DOC_NO; if (!filesByDoc.has(docNo)) { filesByDoc.set(docNo, []); } filesByDoc.get(docNo)!.push(file); } // 문서에 파일 통계 추가 const result = documents.map((doc) => { const files = filesByDoc.get(doc.DOC_NO) || []; const standbyFiles = files.filter((f) => f.STAT === "SCW01"); return { ...doc, fileCount: files.length, standbyFileCount: standbyFiles.length, latestFiles: files .sort((a, b) => b.CRTE_DTM.localeCompare(a.CRTE_DTM)) .slice(0, 5), // 최신 5개만 }; }); debugSuccess("[getDocumentList] 완료", { count: result.length }); return result; } catch (error) { debugError("[getDocumentList] 실패", error); throw new Error( error instanceof Error ? error.message : "문서 목록 조회 실패" ); } } // ============================================================================ // 문서 상세 조회 (Rev-Activity-File 트리) (시나리오 1 상세) // ============================================================================ /** * 문서 상세 조회 * - GetActivityFileList + GetExternalInboxList 결합 * - Rev → Activity → File 트리 구성 */ export async function getDocumentDetail( projNo: string, docNo: string ): Promise { debugLog("[getDocumentDetail] 시작", { projNo, docNo }); try { // 병렬 API 호출 const [activityFiles, inboxFiles] = await Promise.all([ fetchGetActivityFileList({ proj_no: projNo, doc_no: docNo }), fetchGetExternalInboxList({ projNo: projNo, owndocno: docNo }), ]); debugLog("[getDocumentDetail] API 조회 완료", { activityFiles: activityFiles.length, inboxFiles: inboxFiles.length, }); // Inbox 파일을 빠른 조회를 위해 Map으로 변환 const inboxFileMap = new Map(); for (const file of inboxFiles) { const key = `${file.OWN_DOC_NO}|${file.FILE_NM}`; inboxFileMap.set(key, file); } // 트리 구조 빌드 const tree = buildDocumentTree(activityFiles, inboxFileMap); debugSuccess("[getDocumentDetail] 완료", { docNo: tree.docNo, revisions: tree.revisions.length, }); return tree; } catch (error) { debugError("[getDocumentDetail] 실패", error); throw new Error( error instanceof Error ? error.message : "문서 상세 조회 실패" ); } } /** * Rev-Activity-File 트리 빌더 */ function buildDocumentTree( activityFiles: ActivityFileApiResponse[], inboxFileMap: Map ): DocumentDetail { if (activityFiles.length === 0) { return { docNo: "", docTitle: "", projNo: "", revisions: [], }; } const first = activityFiles[0]; // REV_NO로 그룹핑 const revisionMap = new Map(); for (const item of activityFiles) { const revKey = `${item.REV_NO}|${item.REV_SEQ}`; if (!revisionMap.has(revKey)) { revisionMap.set(revKey, []); } revisionMap.get(revKey)!.push(item); } // 각 리비전 처리 const revisions: Revision[] = []; for (const [revKey, revFiles] of revisionMap) { const [revNo, revSeq] = revKey.split("|"); const stage = revFiles[0].STAGE; // ACTV_NO로 그룹핑 const activityMap = new Map(); for (const item of revFiles) { if (!activityMap.has(item.ACTV_NO)) { activityMap.set(item.ACTV_NO, []); } activityMap.get(item.ACTV_NO)!.push(item); } // 각 액티비티 처리 const activities: Activity[] = []; for (const [actvNo, actvFiles] of activityMap) { const firstActvFile = actvFiles[0]; // 파일 정보에 Inbox 데이터 결합 const files: DocumentFile[] = actvFiles.map((af) => { const inboxFile = inboxFileMap.get(`${af.OWN_DOC_NO}|${af.FILE_NM}`); return { fileNm: af.FILE_NM, fileSeq: af.FILE_SEQ, fileSz: af.FILE_SZ, fileFmt: af.FILE_FMT, fldPath: inboxFile?.FLD_PATH, stat: inboxFile?.STAT, statNm: inboxFile?.STAT_NM, canCancel: inboxFile?.STAT === "SCW01", // Standby만 취소 가능 canDownload: !!inboxFile?.FLD_PATH, boxSeq: inboxFile?.BOX_SEQ, actvSeq: inboxFile?.ACTV_SEQ, objId: af.OBJT_ID, crteDate: af.CRTE_DTM, }; }); activities.push({ actvNo: actvNo, type: getActivityType(firstActvFile.STAT), stat: firstActvFile.STAT, toFrom: firstActvFile.TO_FROM, createDate: firstActvFile.CRTE_DTM, files: files, }); } revisions.push({ revNo: revNo, revSeq: revSeq, stage: stage, activities: activities.sort((a, b) => b.createDate.localeCompare(a.createDate) ), totalFiles: revFiles.length, }); } return { docNo: first.DOC_NO, docTitle: first.DOC_TITLE, projNo: first.OWN_DOC_NO.split("-")[0] || "", // 프로젝트 코드 추출 revisions: revisions.sort((a, b) => b.revNo.localeCompare(a.revNo)), }; } /** * STAT 코드로 Activity 타입 판단 */ function getActivityType(stat: string): "Receive" | "Send" | "Review" { const firstChar = stat.charAt(0).toUpperCase(); if (firstChar === "R") return "Receive"; if (firstChar === "S") return "Send"; if (firstChar === "V") return "Review"; return "Send"; // 기본값 } // ============================================================================ // 파일 취소 (시나리오 1-1) // ============================================================================ /** * Standby 상태 파일 취소 */ export async function cancelStandbyFile( boxSeq: string, actvSeq: string, userId: string ): Promise { debugLog("[cancelStandbyFile] 시작", { boxSeq, actvSeq, userId }); try { // varchar(13) 제한 const chgr = `evcp${userId}`.substring(0, 13); await callSaveInBoxListCancelStatus({ boxSeq: boxSeq, actvSeq: actvSeq, chgr: chgr, }); debugSuccess("[cancelStandbyFile] 완료", { boxSeq, actvSeq }); } catch (error) { debugError("[cancelStandbyFile] 실패", error); throw new Error( error instanceof Error ? error.message : "파일 취소 실패" ); } } // ============================================================================ // 파일 다운로드 (시나리오 1-2) // ============================================================================ export interface DownloadFileResult { success: boolean; data?: Uint8Array; fileName?: string; mimeType?: string; error?: string; } /** * 문서 파일 다운로드 * - GetExternalInboxList에서 FLD_PATH 조회 * - 네트워크 드라이브에서 파일 읽기 */ export async function downloadDocumentFile( projNo: string, ownDocNo: string, fileName: string ): Promise { debugLog("[downloadDocumentFile] 시작", { projNo, ownDocNo, fileName }); try { // 1. GetExternalInboxList에서 파일 정보 찾기 const files = await fetchGetExternalInboxList({ projNo: projNo, owndocno: ownDocNo, }); const targetFile = files.find((f) => f.FILE_NM === fileName); if (!targetFile || !targetFile.FLD_PATH) { debugWarn("[downloadDocumentFile] 파일 없음", { fileName }); return { success: false, error: "파일을 찾을 수 없습니다", }; } debugLog("[downloadDocumentFile] 파일 정보 조회 완료", { fileName: targetFile.FILE_NM, fldPath: targetFile.FLD_PATH, }); // 2. NFS 마운트 경로 확인 const nfsBasePath = process.env.SWP_MOUNT_DIR; if (!nfsBasePath) { debugError("[downloadDocumentFile] SWP_MOUNT_DIR 미설정"); return { success: false, error: "서버 설정 오류: NFS 경로가 설정되지 않았습니다", }; } // 3. 전체 파일 경로 생성 const normalizedFldPath = targetFile.FLD_PATH.replace(/\\/g, "/"); const fullPath = path.join(nfsBasePath, normalizedFldPath, targetFile.FILE_NM); debugLog("[downloadDocumentFile] 파일 경로", { fullPath }); // 4. 파일 존재 여부 확인 try { await fs.access(fullPath, fs.constants.R_OK); } catch (accessError) { debugError("[downloadDocumentFile] 파일 접근 불가", accessError); return { success: false, error: `파일을 찾을 수 없습니다: ${targetFile.FILE_NM}`, }; } // 5. 파일 읽기 const fileBuffer = await fs.readFile(fullPath); const fileData = new Uint8Array(fileBuffer); // 6. MIME 타입 결정 const mimeType = getMimeType(targetFile.FILE_NM); debugSuccess("[downloadDocumentFile] 완료", { fileName: targetFile.FILE_NM, size: fileData.length, mimeType, }); return { success: true, data: fileData, fileName: targetFile.FILE_NM, mimeType, }; } catch (error) { debugError("[downloadDocumentFile] 실패", error); return { success: false, error: error instanceof Error ? error.message : "파일 다운로드 실패", }; } } /** * MIME 타입 결정 */ function getMimeType(fileName: string): string { const ext = path.extname(fileName).toLowerCase(); const mimeTypes: Record = { ".pdf": "application/pdf", ".doc": "application/msword", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".xls": "application/vnd.ms-excel", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".ppt": "application/vnd.ms-powerpoint", ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", ".txt": "text/plain", ".csv": "text/csv", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".zip": "application/zip", ".rar": "application/x-rar-compressed", ".7z": "application/x-7z-compressed", ".dwg": "application/acad", ".dxf": "application/dxf", }; return mimeTypes[ext] || "application/octet-stream"; }