diff options
| author | joonhoekim <26rote@gmail.com> | 2025-10-29 15:59:04 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-10-29 15:59:04 +0900 |
| commit | 2ecdac866c19abea0b5389708fcdf5b3889c969a (patch) | |
| tree | e02a02cfa0890691fb28a7df3a96ef495b3d4b79 /lib/swp/document-service.ts | |
| parent | 2fc9e5492e220041ba322d9a1479feb7803228cf (diff) | |
(김준회) SWP 파일 업로드 취소 기능 추가, 업로드 파일명 검증로직에서 파일명 비필수로 변경
Diffstat (limited to 'lib/swp/document-service.ts')
| -rw-r--r-- | lib/swp/document-service.ts | 476 |
1 files changed, 476 insertions, 0 deletions
diff --git a/lib/swp/document-service.ts b/lib/swp/document-service.ts new file mode 100644 index 00000000..49e4da4c --- /dev/null +++ b/lib/swp/document-service.ts @@ -0,0 +1,476 @@ +"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<DocumentListItem[]> { + 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<string, SwpFileApiResponse[]>(); + 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<DocumentDetail> { + 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<string, SwpFileApiResponse>(); + 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<string, SwpFileApiResponse> +): DocumentDetail { + if (activityFiles.length === 0) { + return { + docNo: "", + docTitle: "", + projNo: "", + revisions: [], + }; + } + + const first = activityFiles[0]; + + // REV_NO로 그룹핑 + const revisionMap = new Map<string, ActivityFileApiResponse[]>(); + 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<string, ActivityFileApiResponse[]>(); + 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<void> { + 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<DownloadFileResult> { + 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<string, string> = { + ".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"; +} + |
