diff options
Diffstat (limited to 'lib/swp/actions.ts')
| -rw-r--r-- | lib/swp/actions.ts | 458 |
1 files changed, 458 insertions, 0 deletions
diff --git a/lib/swp/actions.ts b/lib/swp/actions.ts index 694936ab..7411f414 100644 --- a/lib/swp/actions.ts +++ b/lib/swp/actions.ts @@ -5,6 +5,8 @@ import { swpDocuments, swpDocumentRevisions, swpDocumentFiles } from "@/db/schem 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"; // ============================================================================ // 타입 정의 @@ -291,3 +293,459 @@ export async function fetchSwpStats(projNo?: string) { } } +// ============================================================================ +// 서버 액션: 파일 다운로드 +// ============================================================================ + +export interface DownloadFileResult { + success: boolean; + data?: Uint8Array; + fileName?: string; + mimeType?: string; + error?: string; +} + +export async function downloadSwpFile(fileId: number): Promise<DownloadFileResult> { + 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<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"; +} + +// ============================================================================ +// 서버 액션: 파일 업로드 (네트워크 경로 기반) +// ============================================================================ + +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<string> { + 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<void> { + 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<ExternalInboxItem[]> { + 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<UploadFilesResult> { + 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; + } +} + |
