"use server"; import db from "@/db/db"; import { swpDocuments, swpDocumentRevisions, swpDocumentFiles } from "@/db/schema/SWP/swp-documents"; import { eq, and, sql, like, desc, asc, type SQL } from "drizzle-orm"; import { fetchSwpProjectData } from "./api-client"; import { syncSwpProject } from "./sync-service"; import * as fs from "fs/promises"; import * as path from "path"; import { debugLog, debugError, debugWarn, debugSuccess } from "@/lib/debug-utils"; // ============================================================================ // 타입 정의 // ============================================================================ export interface SwpTableFilters { projNo?: string; docNo?: string; docTitle?: string; pkgNo?: string; vndrCd?: string; stage?: string; } export interface SwpTableParams { page: number; pageSize: number; sortBy?: string; sortOrder?: "asc" | "desc"; filters?: SwpTableFilters; } export interface SwpDocumentWithStats { DOC_NO: string; DOC_TITLE: string; PROJ_NO: string; PROJ_NM: string | null; PKG_NO: string | null; VNDR_CD: string | null; CPY_NM: string | null; LTST_REV_NO: string | null; STAGE: string | null; LTST_ACTV_STAT: string | null; sync_status: "synced" | "pending" | "error"; last_synced_at: Date; revision_count: number; file_count: number; } // ============================================================================ // 서버 액션: 문서 목록 조회 (페이지네이션 + 검색) // ============================================================================ export async function fetchSwpDocuments(params: SwpTableParams) { const { page, pageSize, sortBy = "last_synced_at", sortOrder = "desc", filters } = params; const offset = (page - 1) * pageSize; try { // WHERE 조건 구성 const conditions: SQL[] = []; if (filters?.projNo) { conditions.push(like(swpDocuments.PROJ_NO, `%${filters.projNo}%`)); } if (filters?.docNo) { conditions.push(like(swpDocuments.DOC_NO, `%${filters.docNo}%`)); } if (filters?.docTitle) { conditions.push(like(swpDocuments.DOC_TITLE, `%${filters.docTitle}%`)); } if (filters?.pkgNo) { conditions.push(like(swpDocuments.PKG_NO, `%${filters.pkgNo}%`)); } if (filters?.vndrCd) { conditions.push(like(swpDocuments.VNDR_CD, `%${filters.vndrCd}%`)); } if (filters?.stage) { conditions.push(eq(swpDocuments.STAGE, filters.stage)); } const whereClause = conditions.length > 0 ? and(...conditions) : undefined; // 총 개수 조회 const totalResult = await db .select({ count: sql`count(*)::int` }) .from(swpDocuments) .where(whereClause); const total = totalResult[0]?.count || 0; // 정렬 컬럼 결정 const orderByColumn = sortBy === "DOC_NO" ? swpDocuments.DOC_NO : sortBy === "DOC_TITLE" ? swpDocuments.DOC_TITLE : sortBy === "PROJ_NO" ? swpDocuments.PROJ_NO : sortBy === "PKG_NO" ? swpDocuments.PKG_NO : sortBy === "STAGE" ? swpDocuments.STAGE : swpDocuments.last_synced_at; // 데이터 조회 (Drizzle query builder 사용) const documents = await db .select({ DOC_NO: swpDocuments.DOC_NO, DOC_TITLE: swpDocuments.DOC_TITLE, PROJ_NO: swpDocuments.PROJ_NO, PROJ_NM: swpDocuments.PROJ_NM, PKG_NO: swpDocuments.PKG_NO, VNDR_CD: swpDocuments.VNDR_CD, CPY_NM: swpDocuments.CPY_NM, LTST_REV_NO: swpDocuments.LTST_REV_NO, STAGE: swpDocuments.STAGE, LTST_ACTV_STAT: swpDocuments.LTST_ACTV_STAT, sync_status: swpDocuments.sync_status, last_synced_at: swpDocuments.last_synced_at, revision_count: sql`COUNT(DISTINCT ${swpDocumentRevisions.id})::int`, file_count: sql`COUNT(DISTINCT ${swpDocumentFiles.id})::int`, }) .from(swpDocuments) .leftJoin(swpDocumentRevisions, eq(swpDocuments.DOC_NO, swpDocumentRevisions.DOC_NO)) .leftJoin(swpDocumentFiles, eq(swpDocumentRevisions.id, swpDocumentFiles.revision_id)) .where(whereClause) .groupBy( swpDocuments.DOC_NO, swpDocuments.DOC_TITLE, swpDocuments.PROJ_NO, swpDocuments.PROJ_NM, swpDocuments.PKG_NO, swpDocuments.VNDR_CD, swpDocuments.CPY_NM, swpDocuments.LTST_REV_NO, swpDocuments.STAGE, swpDocuments.LTST_ACTV_STAT, swpDocuments.sync_status, swpDocuments.last_synced_at ) .orderBy(sortOrder === "desc" ? desc(orderByColumn) : asc(orderByColumn)) .limit(pageSize) .offset(offset); return { data: documents, total, page, pageSize, totalPages: Math.ceil(total / pageSize), }; } catch (error) { console.error("[fetchSwpDocuments] 오류:", error); throw new Error("문서 목록 조회 실패 [SWP API에서 실패가 발생했습니다. 담당자에게 문의하세요]"); } } // ============================================================================ // 서버 액션: 문서의 리비전 목록 조회 // ============================================================================ export async function fetchDocumentRevisions(docNo: string) { try { const revisions = await db .select({ id: swpDocumentRevisions.id, DOC_NO: swpDocumentRevisions.DOC_NO, REV_NO: swpDocumentRevisions.REV_NO, STAGE: swpDocumentRevisions.STAGE, ACTV_NO: swpDocumentRevisions.ACTV_NO, OFDC_NO: swpDocumentRevisions.OFDC_NO, sync_status: swpDocumentRevisions.sync_status, last_synced_at: swpDocumentRevisions.last_synced_at, file_count: sql`COUNT(DISTINCT ${swpDocumentFiles.id})::int`, }) .from(swpDocumentRevisions) .leftJoin(swpDocumentFiles, eq(swpDocumentRevisions.id, swpDocumentFiles.revision_id)) .where(eq(swpDocumentRevisions.DOC_NO, docNo)) .groupBy( swpDocumentRevisions.id, swpDocumentRevisions.DOC_NO, swpDocumentRevisions.REV_NO, swpDocumentRevisions.STAGE, swpDocumentRevisions.ACTV_NO, swpDocumentRevisions.OFDC_NO, swpDocumentRevisions.sync_status, swpDocumentRevisions.last_synced_at ) .orderBy(desc(swpDocumentRevisions.REV_NO)); return revisions; } catch (error) { console.error("[fetchDocumentRevisions] 오류:", error); throw new Error("리비전 목록 조회 실패"); } } // ============================================================================ // 서버 액션: 리비전의 파일 목록 조회 // ============================================================================ export async function fetchRevisionFiles(revisionId: number) { try { const files = await db .select({ id: swpDocumentFiles.id, FILE_NM: swpDocumentFiles.FILE_NM, FILE_SEQ: swpDocumentFiles.FILE_SEQ, FILE_SZ: swpDocumentFiles.FILE_SZ, FLD_PATH: swpDocumentFiles.FLD_PATH, STAT: swpDocumentFiles.STAT, STAT_NM: swpDocumentFiles.STAT_NM, sync_status: swpDocumentFiles.sync_status, created_at: swpDocumentFiles.created_at, }) .from(swpDocumentFiles) .where(eq(swpDocumentFiles.revision_id, revisionId)) .orderBy(asc(swpDocumentFiles.FILE_SEQ)); return files; } catch (error) { console.error("[fetchRevisionFiles] 오류:", error); throw new Error("파일 목록 조회 실패"); } } // ============================================================================ // 서버 액션: 프로젝트 동기화 // ============================================================================ export async function syncSwpProjectAction(projectNo: string, docGb: "M" | "V" = "V") { try { console.log(`[syncSwpProjectAction] 시작: ${projectNo}`); // 1. API에서 데이터 조회 const { documents, files } = await fetchSwpProjectData(projectNo, docGb); // 2. 동기화 실행 const result = await syncSwpProject(projectNo, documents, files); console.log(`[syncSwpProjectAction] 완료:`, result.stats); return result; } catch (error) { console.error("[syncSwpProjectAction] 오류:", error); throw new Error( error instanceof Error ? error.message : "동기화 실패" ); } } // ============================================================================ // 서버 액션: 프로젝트 목록 조회 (필터용) // ============================================================================ export async function fetchProjectList() { try { const projects = await db .select({ PROJ_NO: swpDocuments.PROJ_NO, PROJ_NM: swpDocuments.PROJ_NM, doc_count: sql`COUNT(DISTINCT ${swpDocuments.DOC_NO})::int`, }) .from(swpDocuments) .groupBy(swpDocuments.PROJ_NO, swpDocuments.PROJ_NM) .orderBy(desc(sql`COUNT(DISTINCT ${swpDocuments.DOC_NO})`)); return projects; } catch (error) { console.error("[fetchProjectList] 오류:", error); return []; } } // ============================================================================ // 서버 액션: 통계 조회 // ============================================================================ export async function fetchSwpStats(projNo?: string) { try { const whereClause = projNo ? eq(swpDocuments.PROJ_NO, projNo) : undefined; const stats = await db .select({ total_documents: sql`COUNT(DISTINCT ${swpDocuments.DOC_NO})::int`, total_revisions: sql`COUNT(DISTINCT ${swpDocumentRevisions.id})::int`, total_files: sql`COUNT(DISTINCT ${swpDocumentFiles.id})::int`, last_sync: sql`MAX(${swpDocuments.last_synced_at})`, }) .from(swpDocuments) .leftJoin(swpDocumentRevisions, eq(swpDocuments.DOC_NO, swpDocumentRevisions.DOC_NO)) .leftJoin(swpDocumentFiles, eq(swpDocumentRevisions.id, swpDocumentFiles.revision_id)) .where(whereClause); return stats[0] || { total_documents: 0, total_revisions: 0, total_files: 0, last_sync: null, }; } catch (error) { console.error("[fetchSwpStats] 오류:", error); return { total_documents: 0, total_revisions: 0, total_files: 0, last_sync: null, }; } } // ============================================================================ // 서버 액션: 파일 다운로드 // ============================================================================ export interface DownloadFileResult { success: boolean; data?: Uint8Array; fileName?: string; mimeType?: string; error?: string; } export async function downloadSwpFile(fileId: number): Promise { try { debugLog(`[downloadSwpFile] 다운로드 시작`, { fileId }); // 1. 파일 정보 조회 const fileInfo = await db .select({ FILE_NM: swpDocumentFiles.FILE_NM, FLD_PATH: swpDocumentFiles.FLD_PATH, }) .from(swpDocumentFiles) .where(eq(swpDocumentFiles.id, fileId)) .limit(1); if (!fileInfo || fileInfo.length === 0) { debugError(`[downloadSwpFile] 파일 정보 없음`, { fileId }); return { success: false, error: "파일 정보를 찾을 수 없습니다.", }; } const { FILE_NM, FLD_PATH } = fileInfo[0]; debugLog(`[downloadSwpFile] 파일 정보 조회 완료`, { FILE_NM, FLD_PATH }); if (!FLD_PATH || !FILE_NM) { debugError(`[downloadSwpFile] 파일 경로 또는 이름 없음`, { FILE_NM, FLD_PATH }); return { success: false, error: "파일 경로 또는 파일명이 없습니다.", }; } // 2. NFS 마운트 경로 확인 const nfsBasePath = process.env.SWP_MOUNT_DIR; if (!nfsBasePath) { console.error( '[downloadSwpFile] SWP_MOUNT_DIR 환경변수가 설정되지 않았습니다.' ); return { success: false, error: "서버 설정 오류: NFS 경로가 설정되지 않았습니다.", }; } // 3. 전체 파일 경로 생성 // FLD_PATH가 절대 경로일 수도 있고 상대 경로일 수도 있으므로 처리 // Windows 스타일 백슬래시를 리눅스 슬래시로 변환 const normalizedFldPath = FLD_PATH.replace(/\\/g, '/'); const fullPath = path.join(nfsBasePath, normalizedFldPath, FILE_NM); console.log("[downloadSwpFile] 파일 다운로드 시도:", { fileId, FILE_NM, FLD_PATH, normalizedFldPath, fullPath, }); // 4. 파일 존재 여부 확인 try { await fs.access(fullPath, fs.constants.R_OK); } catch (accessError) { console.error("[downloadSwpFile] 파일 접근 불가:", accessError); return { success: false, error: `파일을 찾을 수 없습니다: ${FILE_NM}`, }; } // 5. 파일 읽기 debugLog(`[downloadSwpFile] 파일 읽기 시작`, { fullPath }); const fileBuffer = await fs.readFile(fullPath); debugLog(`[downloadSwpFile] 파일 Buffer 읽기 완료`, { bufferLength: fileBuffer.length, isBuffer: Buffer.isBuffer(fileBuffer), bufferType: typeof fileBuffer, constructor: fileBuffer.constructor.name, first20Bytes: fileBuffer.slice(0, 20).toString('hex') }); const fileData = new Uint8Array(fileBuffer); debugLog(`[downloadSwpFile] Uint8Array 변환 완료`, { uint8ArrayLength: fileData.length, uint8ArrayType: typeof fileData, constructor: fileData.constructor.name, first20Bytes: Array.from(fileData.slice(0, 20)), jsonStringified: JSON.stringify(fileData).substring(0, 100) + '...' }); // 6. MIME 타입 결정 const mimeType = getMimeType(FILE_NM); console.log("[downloadSwpFile] 파일 다운로드 성공:", { fileName: FILE_NM, size: fileData.length, mimeType, }); debugSuccess(`[downloadSwpFile] 다운로드 성공`, { fileName: FILE_NM, dataLength: fileData.length, mimeType, returnDataType: typeof fileData, isUint8Array: fileData instanceof Uint8Array }); return { success: true, data: fileData, fileName: FILE_NM, mimeType, }; } catch (error) { console.error("[downloadSwpFile] 오류:", error); debugError(`[downloadSwpFile] 다운로드 실패`, { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined }); 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"; } // ============================================================================ // 서버 액션: 벤더 업로드 파일 목록 조회 // ============================================================================ export async function fetchVendorUploadedFiles(projNo: string, vndrCd: string) { try { debugLog(`[fetchVendorUploadedFiles] 조회 시작`, { projNo, vndrCd }); // fetchGetExternalInboxList 호출 const { fetchGetExternalInboxList } = await import("./api-client"); const files = await fetchGetExternalInboxList({ projNo, vndrCd, }); debugLog(`[fetchVendorUploadedFiles] 조회 완료`, { fileCount: files.length }); return files; } catch (error) { debugError(`[fetchVendorUploadedFiles] 조회 실패`, { error }); throw new Error( error instanceof Error ? error.message : "업로드 파일 목록 조회 실패" ); } } // ============================================================================ // 서버 액션: 벤더 업로드 파일 취소 // ============================================================================ export interface CancelUploadedFileParams { boxSeq: string; actvSeq: string; userId: string; } export async function cancelVendorUploadedFile(params: CancelUploadedFileParams) { try { debugLog(`[cancelVendorUploadedFile] 취소 시작`, params); const { callSaveInBoxListCancelStatus } = await import("./api-client"); const cancelCount = await callSaveInBoxListCancelStatus({ boxSeq: params.boxSeq, actvSeq: params.actvSeq, chgr: `evcp${params.userId}`, }); debugSuccess(`[cancelVendorUploadedFile] 취소 완료`, { ...params, cancelCount }); return { success: true, cancelCount }; } catch (error) { debugError(`[cancelVendorUploadedFile] 취소 실패`, { error }); throw new Error( error instanceof Error ? error.message : "파일 취소 실패" ); } }