summaryrefslogtreecommitdiff
path: root/lib/swp/document-service.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/swp/document-service.ts')
-rw-r--r--lib/swp/document-service.ts476
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";
+}
+