"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"; // ============================================================================ // 타입 정의 // ============================================================================ 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; 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, 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.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 { // 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) { return { success: false, error: "파일 정보를 찾을 수 없습니다.", }; } const { FILE_NM, FLD_PATH } = fileInfo[0]; if (!FLD_PATH || !FILE_NM) { return { success: false, error: "파일 경로 또는 파일명이 없습니다.", }; } // 2. NFS 마운트 경로 확인 const nfsBasePath = process.env.DOCUMENTUM_NFS; if (!nfsBasePath) { console.error("[downloadSwpFile] DOCUMENTUM_NFS 환경변수가 설정되지 않았습니다."); return { success: false, error: "서버 설정 오류: NFS 경로가 설정되지 않았습니다.", }; } // 3. 전체 파일 경로 생성 // FLD_PATH가 절대 경로일 수도 있고 상대 경로일 수도 있으므로 처리 const fullPath = path.join(nfsBasePath, FLD_PATH, FILE_NM); console.log("[downloadSwpFile] 파일 다운로드 시도:", { fileId, FILE_NM, FLD_PATH, fullPath, }); // 4. 파일 존재 여부 확인 try { await fs.access(fullPath, fs.constants.R_OK); } catch (accessError) { console.error("[downloadSwpFile] 파일 접근 불가:", accessError); return { success: false, error: `파일을 찾을 수 없습니다: ${FILE_NM}`, }; } // 5. 파일 읽기 const fileBuffer = await fs.readFile(fullPath); const fileData = new Uint8Array(fileBuffer); // 6. MIME 타입 결정 const mimeType = getMimeType(FILE_NM); console.log("[downloadSwpFile] 파일 다운로드 성공:", { fileName: FILE_NM, size: fileData.length, mimeType, }); return { success: true, data: fileData, fileName: FILE_NM, mimeType, }; } catch (error) { console.error("[downloadSwpFile] 오류:", 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"; } // ============================================================================ // 서버 액션: 파일 업로드 (네트워크 경로 기반) // ============================================================================ export interface UploadFileInfo { fileName: string; fileBuffer: Buffer; } export interface UploadFilesResult { success: boolean; message: string; successCount: number; failedCount: number; details: Array<{ fileName: string; success: boolean; error?: string; networkPath?: string; }>; } /** * 파일명 파싱: [OWN_DOC_NO]_[REV_NO]_[STAGE]_[YYYYMMDDhhmmss].[확장자] */ function parseFileName(fileName: string) { // 확장자 분리 const lastDotIndex = fileName.lastIndexOf("."); const extension = lastDotIndex !== -1 ? fileName.substring(lastDotIndex + 1) : ""; const nameWithoutExt = lastDotIndex !== -1 ? fileName.substring(0, lastDotIndex) : fileName; // _ 기준으로 분리 (정확히 3개의 _가 있어야 함) const parts = nameWithoutExt.split("_"); if (parts.length !== 4) { throw new Error( `잘못된 파일명 형식입니다: ${fileName}. ` + `형식: [OWN_DOC_NO]_[REV_NO]_[STAGE]_[YYYYMMDDhhmmss].확장자` ); } const [ownDocNo, revNo, stage, timestamp] = parts; // 타임스탬프 검증 (14자리 숫자) if (!/^\d{14}$/.test(timestamp)) { throw new Error( `잘못된 타임스탬프 형식입니다: ${timestamp}. ` + `YYYYMMDDhhmmss 형식이어야 합니다.` ); } return { ownDocNo, revNo, stage, timestamp, extension, }; } /** * CPY_CD 조회: swpDocuments 테이블에서 PROJ_NO와 VNDR_CD로 조회 */ async function getCpyCdForVendor(projNo: string, vndrCd: string): Promise { const result = await db .select({ CPY_CD: swpDocuments.CPY_CD, }) .from(swpDocuments) .where( and( eq(swpDocuments.PROJ_NO, projNo), eq(swpDocuments.VNDR_CD, vndrCd) ) ) .limit(1); if (!result || result.length === 0 || !result[0].CPY_CD) { throw new Error( `프로젝트 ${projNo}에서 벤더 코드 ${vndrCd}에 해당하는 회사 코드(CPY_CD)를 찾을 수 없습니다.` ); } return result[0].CPY_CD; } /** * 네트워크 경로 생성 */ function generateNetworkPath( projNo: string, cpyCd: string, timestamp: string, fileName: string ): string { const swpMountDir = process.env.SWP_MOUNT_DIR || "/mnt/swp-smb-dir/"; return path.join(swpMountDir, projNo, cpyCd, timestamp, fileName); } /** * InBox 파일 정보 인터페이스 */ interface InBoxFileInfo { CPY_CD: string; FILE_NM: string; OFDC_NO: string | null; PROJ_NO: string; OWN_DOC_NO: string; REV_NO: string; STAGE: string; STAT: string; FILE_SZ: string; FLD_PATH: string; } /** * SaveInBoxList API 호출 */ async function callSaveInBoxList(fileInfos: InBoxFileInfo[]): Promise { const ddcUrl = process.env.DDC_BASE_URL || "http://60.100.99.217/DDC/Services/WebService.svc"; const url = `${ddcUrl}/SaveInBoxList`; const request = { externalInboxLists: fileInfos, }; console.log("[callSaveInBoxList] 요청:", JSON.stringify(request, null, 2)); const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify(request), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`SaveInBoxList API 호출 실패: ${response.statusText} - ${errorText}`); } const data = await response.json(); console.log("[callSaveInBoxList] 응답:", JSON.stringify(data, null, 2)); // 응답 검증 if (data.SaveInBoxListResult && !data.SaveInBoxListResult.success) { throw new Error( `SaveInBoxList API 실패: ${data.SaveInBoxListResult.message || "알 수 없는 오류"}` ); } } /** * GetExternalInboxList API 응답 인터페이스 */ interface ExternalInboxItem { DOC_NO?: string; REV_NO?: string; STAGE?: string; FILE_NM?: string; FILE_SZ?: string; [key: string]: unknown; } /** * GetExternalInboxList API 호출 */ async function callGetExternalInboxList(projNo: string, cpyCd: string): Promise { const ddcUrl = process.env.DDC_BASE_URL || "http://60.100.99.217/DDC/Services/WebService.svc"; const params = new URLSearchParams({ PROJ_NO: projNo, CPY_CD: cpyCd, }); const url = `${ddcUrl}/GetExternalInboxList?${params.toString()}`; console.log("[callGetExternalInboxList] 요청:", url); const response = await fetch(url, { method: "GET", headers: { Accept: "application/json", }, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`GetExternalInboxList API 호출 실패: ${response.statusText} - ${errorText}`); } const data = await response.json(); console.log("[callGetExternalInboxList] 응답:", JSON.stringify(data, null, 2)); return data.GetExternalInboxListResult || []; } /** * 파일 업로드 서버 액션 */ export async function uploadSwpFilesAction( projNo: string, vndrCd: string, files: UploadFileInfo[] ): Promise { const result: UploadFilesResult = { success: true, message: "", successCount: 0, failedCount: 0, details: [], }; try { // 1. CPY_CD 조회 console.log(`[uploadSwpFilesAction] CPY_CD 조회: projNo=${projNo}, vndrCd=${vndrCd}`); const cpyCd = await getCpyCdForVendor(projNo, vndrCd); console.log(`[uploadSwpFilesAction] CPY_CD: ${cpyCd}`); // 2. 각 파일 처리 const inBoxFileInfos: InBoxFileInfo[] = []; for (const file of files) { try { // 2-1. 파일명 파싱 const parsed = parseFileName(file.fileName); console.log(`[uploadSwpFilesAction] 파일명 파싱:`, parsed); // 2-2. 네트워크 경로 생성 const networkPath = generateNetworkPath( projNo, cpyCd, parsed.timestamp, file.fileName ); // 2-3. 파일 중복 체크 try { await fs.access(networkPath, fs.constants.F_OK); // 파일이 이미 존재하는 경우 result.failedCount++; result.details.push({ fileName: file.fileName, success: false, error: "파일이 이미 존재합니다.", }); console.warn(`[uploadSwpFilesAction] 파일 중복: ${networkPath}`); continue; } catch { // 파일이 존재하지 않음 (정상) } // 2-4. 디렉토리 생성 const directory = path.dirname(networkPath); await fs.mkdir(directory, { recursive: true }); // 2-5. 파일 저장 await fs.writeFile(networkPath, file.fileBuffer); console.log(`[uploadSwpFilesAction] 파일 저장 완료: ${networkPath}`); // 2-6. InBox 파일 정보 준비 const dateOnly = parsed.timestamp.substring(0, 8); // YYYYMMDD const fldPath = `\\\\${projNo}\\\\${cpyCd}\\\\${dateOnly}`; inBoxFileInfos.push({ CPY_CD: cpyCd, FILE_NM: file.fileName, OFDC_NO: null, PROJ_NO: projNo, OWN_DOC_NO: parsed.ownDocNo, REV_NO: parsed.revNo, STAGE: parsed.stage, STAT: "SCW01", FILE_SZ: String(file.fileBuffer.length), FLD_PATH: fldPath, }); result.successCount++; result.details.push({ fileName: file.fileName, success: true, networkPath, }); } catch (error) { result.failedCount++; result.details.push({ fileName: file.fileName, success: false, error: error instanceof Error ? error.message : "알 수 없는 오류", }); console.error(`[uploadSwpFilesAction] 파일 처리 실패: ${file.fileName}`, error); } } // 3. SaveInBoxList API 호출 (성공한 파일만) if (inBoxFileInfos.length > 0) { console.log(`[uploadSwpFilesAction] SaveInBoxList API 호출: ${inBoxFileInfos.length}개 파일`); await callSaveInBoxList(inBoxFileInfos); } // 4. GetExternalInboxList API 호출 console.log(`[uploadSwpFilesAction] GetExternalInboxList API 호출`); const inboxList = await callGetExternalInboxList(projNo, cpyCd); console.log(`[uploadSwpFilesAction] InBox 목록: ${inboxList.length}개`); // 5. 결과 메시지 생성 if (result.failedCount === 0) { result.message = `${result.successCount}개 파일이 성공적으로 업로드되었습니다.`; } else if (result.successCount === 0) { result.success = false; result.message = `모든 파일 업로드에 실패했습니다. (${result.failedCount}개)`; } else { result.message = `${result.successCount}개 파일 업로드 성공, ${result.failedCount}개 실패`; } console.log(`[uploadSwpFilesAction] 완료:`, result); return result; } catch (error) { console.error("[uploadSwpFilesAction] 오류:", error); result.success = false; result.message = error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다."; return result; } }