diff options
| author | joonhoekim <26rote@gmail.com> | 2025-10-27 17:14:44 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-10-27 17:14:44 +0900 |
| commit | 02062af723f1a3c2994c3e80148da47b07712713 (patch) | |
| tree | fa9e27d9b0d9a9f87ef7ccf05edfb9c2806c06bd | |
| parent | 2e92d5f83ae5f0f39090552b46c519982e9279c9 (diff) | |
(김준회) SWP 다운로드, 업로드 api route 처리, 옥프로 컬럼 순서 조정 처리, 환경변수 오타 수정
| -rw-r--r-- | .env.development | 2 | ||||
| -rw-r--r-- | .env.production | 2 | ||||
| -rw-r--r-- | app/api/swp/download/[fileId]/route.ts | 172 | ||||
| -rw-r--r-- | app/api/swp/upload/route.ts | 364 | ||||
| -rw-r--r-- | lib/swp/actions.ts | 373 | ||||
| -rw-r--r-- | lib/swp/table/swp-table-columns.tsx | 66 | ||||
| -rw-r--r-- | lib/swp/table/swp-table-toolbar.tsx | 38 |
7 files changed, 637 insertions, 380 deletions
diff --git a/.env.development b/.env.development index 7ef4944e..ea374be8 100644 --- a/.env.development +++ b/.env.development @@ -179,7 +179,7 @@ NEXT_PUBLIC_DEBUG=true SWP_BASE_URL=http://60.100.99.217/DDP/Services/VNDRService.svc DDC_BASE_URL=http://60.100.99.217/DDC/Services/WebService.svc -SWP_MONUT_DIR="/mnt/swp-smb-dir/" # \\60.100.91.61\SBox 경로를 마운트해두어야 함 +SWP_MOUNT_DIR="/mnt/swp-smb-dir/" # \\60.100.91.61\SBox 경로를 마운트해두어야 함 # POS (EMLS, Documentum) DOCUMENTUM_NFS="/mnt/nfs-documentum/" # 품질/운영 공통 diff --git a/.env.production b/.env.production index aeec2d08..acf8b43a 100644 --- a/.env.production +++ b/.env.production @@ -181,7 +181,7 @@ NEXT_PUBLIC_DEBUG=false SWP_BASE_URL=http://60.100.99.217/DDP/Services/VNDRService.svc DDC_BASE_URL=http://60.100.99.217/DDC/Services/WebService.svc -SWP_MONUT_DIR="/mnt/swp-smb-dir/" # \\60.100.91.61\SBox 경로를 마운트해두어야 함 +SWP_MOUNT_DIR="/mnt/swp-smb-dir/" # \\60.100.91.61\SBox 경로를 마운트해두어야 함 # POS (EMLS, Documentum) DOCUMENTUM_NFS="/mnt/nfs-documentum/" # 품질/운영 공통 diff --git a/app/api/swp/download/[fileId]/route.ts b/app/api/swp/download/[fileId]/route.ts new file mode 100644 index 00000000..3af560aa --- /dev/null +++ b/app/api/swp/download/[fileId]/route.ts @@ -0,0 +1,172 @@ +import { NextRequest, NextResponse } from "next/server"; +import * as fs from "fs/promises"; +import * as path from "path"; +import { eq } from "drizzle-orm"; +import db from "@/db/db"; +import { swpDocumentFiles } from "@/db/schema/SWP/swp-documents"; +import { debugLog, debugError, debugSuccess } from "@/lib/debug-utils"; + +// API Route 설정 +export const runtime = "nodejs"; +export const maxDuration = 60; // 1분 타임아웃 + +/** + * GET /api/swp/download/[fileId] + * 파일 다운로드 (바이너리 직접 전송) + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ fileId: string }> } +) { + try { + const { fileId: fileIdString } = await params; + const fileId = parseInt(fileIdString, 10); + + if (isNaN(fileId)) { + return NextResponse.json( + { success: false, error: "잘못된 파일 ID입니다." }, + { status: 400 } + ); + } + + debugLog(`[download] 다운로드 시작`, { 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(`[download] 파일 정보 없음`, { fileId }); + return NextResponse.json( + { success: false, error: "파일 정보를 찾을 수 없습니다." }, + { status: 404 } + ); + } + + const { FILE_NM, FLD_PATH } = fileInfo[0]; + debugLog(`[download] 파일 정보 조회 완료`, { FILE_NM, FLD_PATH }); + + if (!FLD_PATH || !FILE_NM) { + debugError(`[download] 파일 경로 또는 이름 없음`, { FILE_NM, FLD_PATH }); + return NextResponse.json( + { success: false, error: "파일 경로 또는 파일명이 없습니다." }, + { status: 404 } + ); + } + + // 2. NFS 마운트 경로 확인 + const nfsBasePath = process.env.SWP_MOUNT_DIR; + if (!nfsBasePath) { + debugError(`[download] SWP_MOUNT_DIR 환경변수가 설정되지 않았습니다.`); + return NextResponse.json( + { success: false, error: "서버 설정 오류: NFS 경로가 설정되지 않았습니다." }, + { status: 500 } + ); + } + + // 3. 전체 파일 경로 생성 + const normalizedFldPath = FLD_PATH.replace(/\\/g, '/'); + const fullPath = path.join(nfsBasePath, normalizedFldPath, FILE_NM); + + debugLog(`[download] 파일 경로`, { + fileId, + FILE_NM, + FLD_PATH, + normalizedFldPath, + fullPath, + }); + + // 4. 파일 존재 여부 확인 + try { + await fs.access(fullPath, fs.constants.R_OK); + } catch (accessError) { + debugError(`[download] 파일 접근 불가`, { fullPath, error: accessError }); + return NextResponse.json( + { success: false, error: `파일을 찾을 수 없습니다: ${FILE_NM}` }, + { status: 404 } + ); + } + + // 5. 파일 읽기 + debugLog(`[download] 파일 읽기 시작`, { fullPath }); + const fileBuffer = await fs.readFile(fullPath); + + debugLog(`[download] 파일 Buffer 읽기 완료`, { + bufferLength: fileBuffer.length, + isBuffer: Buffer.isBuffer(fileBuffer), + bufferType: typeof fileBuffer, + constructor: fileBuffer.constructor.name, + first20Bytes: fileBuffer.slice(0, 20).toString('hex') + }); + + // 6. MIME 타입 결정 + const mimeType = getMimeType(FILE_NM); + + debugSuccess(`[download] 다운로드 성공`, { + fileName: FILE_NM, + size: fileBuffer.length, + mimeType, + }); + + // 7. 바이너리 응답 반환 + return new NextResponse(fileBuffer, { + status: 200, + headers: { + "Content-Type": mimeType, + "Content-Disposition": `attachment; filename="${encodeURIComponent(FILE_NM)}"`, + "Content-Length": String(fileBuffer.length), + }, + }); + } catch (error) { + console.error("[download] 오류:", error); + debugError(`[download] 다운로드 실패`, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + }); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "파일 다운로드 실패", + }, + { status: 500 } + ); + } +} + +/** + * 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"; +} + diff --git a/app/api/swp/upload/route.ts b/app/api/swp/upload/route.ts new file mode 100644 index 00000000..d17fcff7 --- /dev/null +++ b/app/api/swp/upload/route.ts @@ -0,0 +1,364 @@ +import { NextRequest, NextResponse } from "next/server"; +import * as fs from "fs/promises"; +import * as path from "path"; +import { eq, and } from "drizzle-orm"; +import db from "@/db/db"; +import { swpDocuments } from "@/db/schema/SWP/swp-documents"; +import { fetchGetVDRDocumentList, fetchGetExternalInboxList } from "@/lib/swp/api-client"; +import { syncSwpProject } from "@/lib/swp/sync-service"; +import { debugLog, debugError, debugSuccess } from "@/lib/debug-utils"; + +// API Route 설정 +export const runtime = "nodejs"; +export const maxDuration = 300; // 5분 타임아웃 (대용량 파일 업로드 대응) + +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; +} + +/** + * 파일명 파싱: [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; + + 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; + + if (!/^\d{14}$/.test(timestamp)) { + throw new Error( + `잘못된 타임스탬프 형식입니다: ${timestamp}. ` + + `YYYYMMDDhhmmss 형식이어야 합니다.` + ); + } + + return { ownDocNo, revNo, stage, timestamp, extension }; +} + +/** + * CPY_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; +} + +/** + * 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)); + + // SaveInBoxListResult는 성공한 FLD_PATH들을 쉼표로 구분한 문자열 + // 예: "\\\\ProjNo\\\\CpyCd\\\\YYYYMMDDhhmmss, \\\\ProjNo\\\\CpyCd\\\\YYYYMMDDhhmmss" + if (!data.SaveInBoxListResult) { + throw new Error("SaveInBoxList API 실패: 응답에 SaveInBoxListResult가 없습니다."); + } + + const result = data.SaveInBoxListResult; + + // 문자열 응답인 경우 (정상) + if (typeof result === "string") { + if (result.trim().length === 0) { + throw new Error("SaveInBoxList API 실패: 빈 응답이 반환되었습니다."); + } + // 성공한 FLD_PATH 개수 로깅 + const successPaths = result.split(",").map(p => p.trim()).filter(p => p.length > 0); + console.log(`[callSaveInBoxList] 성공: ${successPaths.length}개 파일 등록 완료`); + return; + } + + // 객체 응답인 경우 (레거시 또는 에러) + if (typeof result === "object" && result !== null) { + const objResult = result as { success?: boolean; message?: string }; + if (objResult.success === false) { + throw new Error( + `SaveInBoxList API 실패: ${objResult.message || "알 수 없는 오류"}` + ); + } + } +} + +/** + * POST /api/swp/upload + * FormData로 파일 업로드 + */ +export async function POST(request: NextRequest) { + try { + const formData = await request.formData(); + + const projNo = formData.get("projNo") as string; + const vndrCd = formData.get("vndrCd") as string; + + if (!projNo || !vndrCd) { + return NextResponse.json( + { success: false, message: "projNo와 vndrCd는 필수입니다." }, + { status: 400 } + ); + } + + // CPY_CD 조회 + console.log(`[upload] CPY_CD 조회: projNo=${projNo}, vndrCd=${vndrCd}`); + const cpyCd = await getCpyCdForVendor(projNo, vndrCd); + console.log(`[upload] CPY_CD: ${cpyCd}`); + + const files = formData.getAll("files") as File[]; + + if (!files || files.length === 0) { + return NextResponse.json( + { success: false, message: "업로드할 파일이 없습니다." }, + { status: 400 } + ); + } + + const result = { + successCount: 0, + failedCount: 0, + details: [] as Array<{ fileName: string; success: boolean; error?: string; networkPath?: string }>, + }; + + const inBoxFileInfos: InBoxFileInfo[] = []; + const swpMountDir = process.env.SWP_MOUNT_DIR || "/mnt/swp-smb-dir/"; + + for (const file of files) { + try { + // 파일명 파싱 + const parsed = parseFileName(file.name); + console.log(`[upload] 파일명 파싱:`, parsed); + + // 네트워크 경로 생성 + const networkPath = path.join(swpMountDir, projNo, cpyCd, parsed.timestamp, file.name); + + // 파일 중복 체크 + try { + await fs.access(networkPath, fs.constants.F_OK); + result.failedCount++; + result.details.push({ + fileName: file.name, + success: false, + error: "파일이 이미 존재합니다.", + }); + console.warn(`[upload] 파일 중복: ${networkPath}`); + continue; + } catch { + // 파일이 존재하지 않음 (정상) + } + + // 디렉토리 생성 + const directory = path.dirname(networkPath); + await fs.mkdir(directory, { recursive: true }); + + // 파일 저장 (스트리밍 방식) + const arrayBuffer = await file.arrayBuffer(); + debugLog(`[upload] ArrayBuffer 변환 완료: ${file.name}`, { + arrayBufferSize: arrayBuffer.byteLength, + fileType: file.type, + originalSize: file.size + }); + + const buffer = Buffer.from(arrayBuffer); + debugLog(`[upload] Buffer 생성 완료: ${file.name}`, { + bufferLength: buffer.length, + bufferType: typeof buffer, + isBuffer: Buffer.isBuffer(buffer), + first20Bytes: buffer.slice(0, 20).toString('hex') + }); + + console.log(`[upload] 파일 저장: ${file.name} (${buffer.length} bytes)`); + + // 저장 전 buffer 상태 확인 + debugLog(`[upload] 저장 직전 buffer 상태`, { + constructor: buffer.constructor.name, + isBuffer: Buffer.isBuffer(buffer), + jsonStringified: JSON.stringify(buffer).substring(0, 100) + '...' + }); + + await fs.writeFile(networkPath, buffer); + debugSuccess(`[upload] 파일 저장 완료: ${networkPath}`); + + // 저장된 파일 검증 + const savedFileStats = await fs.stat(networkPath); + debugLog(`[upload] 저장된 파일 정보`, { + size: savedFileStats.size, + expectedSize: buffer.length, + sizeMatch: savedFileStats.size === buffer.length + }); + + // 저장된 파일 첫 부분 읽어서 검증 + const verifyBuffer = await fs.readFile(networkPath); + debugLog(`[upload] 저장된 파일 검증`, { + readSize: verifyBuffer.length, + first20Bytes: verifyBuffer.slice(0, 20).toString('hex'), + isBuffer: Buffer.isBuffer(verifyBuffer), + matchesOriginal: buffer.slice(0, 20).equals(verifyBuffer.slice(0, 20)) + }); + + // InBox 파일 정보 준비 + const fldPath = `\\\\${projNo}\\\\${cpyCd}\\\\${parsed.timestamp}`; + + inBoxFileInfos.push({ + CPY_CD: cpyCd, + FILE_NM: file.name, + OFDC_NO: null, + PROJ_NO: projNo, + OWN_DOC_NO: parsed.ownDocNo, + REV_NO: parsed.revNo, + STAGE: parsed.stage, + STAT: "SCW01", + FILE_SZ: String(buffer.length), + FLD_PATH: fldPath, + }); + + result.successCount++; + result.details.push({ + fileName: file.name, + success: true, + networkPath, + }); + } catch (error) { + result.failedCount++; + result.details.push({ + fileName: file.name, + success: false, + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + console.error(`[upload] 파일 처리 실패: ${file.name}`, error); + debugError(`[upload] 파일 처리 실패: ${file.name}`, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + }); + } + } + + // SaveInBoxList API 호출 + if (inBoxFileInfos.length > 0) { + console.log(`[upload] SaveInBoxList API 호출: ${inBoxFileInfos.length}개 파일`); + await callSaveInBoxList(inBoxFileInfos); + } + + // 업로드 성공 후 동기화 처리 (현재 벤더의 변경사항만) + if (result.successCount > 0) { + try { + console.log(`[upload] 동기화 시작: projNo=${projNo}, vndrCd=${vndrCd}`); + + // GetVDRDocumentList 및 GetExternalInboxList API 호출 (벤더 필터 적용) + const [documents, files] = await Promise.all([ + fetchGetVDRDocumentList({ + proj_no: projNo, + doc_gb: "V", + vndrCd: vndrCd, // 현재 벤더만 필터링 + }), + fetchGetExternalInboxList({ + projNo: projNo, + vndrCd: vndrCd, // 현재 벤더만 필터링 + }), + ]); + + console.log(`[upload] API 조회 완료: 문서 ${documents.length}개, 파일 ${files.length}개`); + + // 동기화 실행 + const syncResult = await syncSwpProject(projNo, documents, files); + + if (syncResult.success) { + console.log(`[upload] 동기화 완료:`, syncResult.stats); + } else { + console.warn(`[upload] 동기화 경고:`, syncResult.errors); + } + } catch (syncError) { + // 동기화 실패는 경고로만 처리 (업로드 자체는 성공) + console.error("[upload] 동기화 실패 (업로드는 성공):", syncError); + } + } + + // 결과 메시지 생성 + let message: string; + let success: boolean; + + if (result.failedCount === 0) { + success = true; + message = `${result.successCount}개 파일이 성공적으로 업로드 및 동기화되었습니다.`; + } else if (result.successCount === 0) { + success = false; + message = `모든 파일 업로드에 실패했습니다. (${result.failedCount}개)`; + } else { + success = true; + message = `${result.successCount}개 파일 업로드 성공, ${result.failedCount}개 실패`; + } + + console.log(`[upload] 완료:`, { success, message, result }); + + return NextResponse.json({ + success, + message, + successCount: result.successCount, + failedCount: result.failedCount, + details: result.details, + }); + } catch (error) { + console.error("[upload] 오류:", error); + debugError(`[upload] 전체 프로세스 실패`, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + }); + return NextResponse.json( + { + success: false, + message: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", + }, + { status: 500 } + ); + } +} + diff --git a/lib/swp/actions.ts b/lib/swp/actions.ts index 1faa69db..a7b4d3a3 100644 --- a/lib/swp/actions.ts +++ b/lib/swp/actions.ts @@ -7,6 +7,7 @@ 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"; // ============================================================================ // 타입 정의 @@ -317,6 +318,8 @@ export interface DownloadFileResult { export async function downloadSwpFile(fileId: number): Promise<DownloadFileResult> { try { + debugLog(`[downloadSwpFile] 다운로드 시작`, { fileId }); + // 1. 파일 정보 조회 const fileInfo = await db .select({ @@ -328,6 +331,7 @@ export async function downloadSwpFile(fileId: number): Promise<DownloadFileResul .limit(1); if (!fileInfo || fileInfo.length === 0) { + debugError(`[downloadSwpFile] 파일 정보 없음`, { fileId }); return { success: false, error: "파일 정보를 찾을 수 없습니다.", @@ -335,8 +339,10 @@ export async function downloadSwpFile(fileId: number): Promise<DownloadFileResul } 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: "파일 경로 또는 파일명이 없습니다.", @@ -344,9 +350,11 @@ export async function downloadSwpFile(fileId: number): Promise<DownloadFileResul } // 2. NFS 마운트 경로 확인 - const nfsBasePath = process.env.DOCUMENTUM_NFS; + const nfsBasePath = process.env.SWP_MOUNT_DIR; if (!nfsBasePath) { - console.error("[downloadSwpFile] DOCUMENTUM_NFS 환경변수가 설정되지 않았습니다."); + console.error( + '[downloadSwpFile] SWP_MOUNT_DIR 환경변수가 설정되지 않았습니다.' + ); return { success: false, error: "서버 설정 오류: NFS 경로가 설정되지 않았습니다.", @@ -355,12 +363,15 @@ export async function downloadSwpFile(fileId: number): Promise<DownloadFileResul // 3. 전체 파일 경로 생성 // FLD_PATH가 절대 경로일 수도 있고 상대 경로일 수도 있으므로 처리 - const fullPath = path.join(nfsBasePath, FLD_PATH, FILE_NM); + // 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, }); @@ -376,8 +387,26 @@ export async function downloadSwpFile(fileId: number): Promise<DownloadFileResul } // 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); @@ -387,6 +416,14 @@ export async function downloadSwpFile(fileId: number): Promise<DownloadFileResul size: fileData.length, mimeType, }); + + debugSuccess(`[downloadSwpFile] 다운로드 성공`, { + fileName: FILE_NM, + dataLength: fileData.length, + mimeType, + returnDataType: typeof fileData, + isUint8Array: fileData instanceof Uint8Array + }); return { success: true, @@ -396,6 +433,10 @@ export async function downloadSwpFile(fileId: number): Promise<DownloadFileResul }; } 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 : "파일 다운로드 실패", @@ -433,329 +474,3 @@ function getMimeType(fileName: string): string { 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; - } -} - diff --git a/lib/swp/table/swp-table-columns.tsx b/lib/swp/table/swp-table-columns.tsx index 9954ab73..9aecea96 100644 --- a/lib/swp/table/swp-table-columns.tsx +++ b/lib/swp/table/swp-table-columns.tsx @@ -7,7 +7,6 @@ import { ChevronDown, ChevronRight, FileIcon, Download, Loader2 } from "lucide-r import { formatDistanceToNow } from "date-fns"; import { ko } from "date-fns/locale"; import type { SwpDocumentWithStats } from "../actions"; -import { downloadSwpFile } from "../actions"; import { useState } from "react"; import { toast } from "sonner"; @@ -29,6 +28,28 @@ export const swpDocumentColumns: ColumnDef<SwpDocumentWithStats>[] = [ size: 50, }, { + accessorKey: "LTST_ACTV_STAT", + header: "상태 (최신 액티비티)", + cell: ({ row }) => { + const status = row.original.LTST_ACTV_STAT; + if (!status) return "-"; + + // 상태에 따른 색상 설정 (필요에 따라 조정 가능) + const color = + status === "Complete" ? "bg-green-100 text-green-800" : + status === "In Progress" ? "bg-blue-100 text-blue-800" : + status === "Pending" ? "bg-yellow-100 text-yellow-800" : + "bg-gray-100 text-gray-800"; + + return ( + <Badge variant="outline" className={color}> + {status} + </Badge> + ); + }, + size: 100, + }, + { accessorKey: "DOC_NO", header: "문서번호", cell: ({ row }) => ( @@ -90,12 +111,12 @@ export const swpDocumentColumns: ColumnDef<SwpDocumentWithStats>[] = [ cell: ({ row }) => { const stage = row.original.STAGE; if (!stage) return "-"; - - const color = + + const color = stage === "IFC" ? "bg-green-100 text-green-800" : stage === "IFA" ? "bg-blue-100 text-blue-800" : "bg-gray-100 text-gray-800"; - + return ( <Badge variant="outline" className={color}> {stage} @@ -123,28 +144,6 @@ export const swpDocumentColumns: ColumnDef<SwpDocumentWithStats>[] = [ size: 100, }, { - accessorKey: "LTST_ACTV_STAT", - header: "상태 (최신 액티비티)", - cell: ({ row }) => { - const status = row.original.LTST_ACTV_STAT; - if (!status) return "-"; - - // 상태에 따른 색상 설정 (필요에 따라 조정 가능) - const color = - status === "Complete" ? "bg-green-100 text-green-800" : - status === "In Progress" ? "bg-blue-100 text-blue-800" : - status === "Pending" ? "bg-yellow-100 text-yellow-800" : - "bg-gray-100 text-gray-800"; - - return ( - <Badge variant="outline" className={color}> - {status} - </Badge> - ); - }, - size: 100, - }, - { accessorKey: "last_synced_at", header: "동기화", cell: ({ row }) => ( @@ -399,26 +398,27 @@ function DownloadButton({ fileId, fileName }: DownloadButtonProps) { try { setIsDownloading(true); - // 서버 액션 호출 - const result = await downloadSwpFile(fileId); + // API Route 호출 (바이너리 직접 전송) + const response = await fetch(`/api/swp/download/${fileId}`); - if (!result.success || !result.data) { - toast.error(result.error || "파일 다운로드 실패"); + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: "다운로드 실패" })); + toast.error(errorData.error || "파일 다운로드 실패"); return; } // Blob 생성 및 다운로드 - const blob = new Blob([result.data as unknown as BlobPart], { type: result.mimeType }); + const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; - link.download = result.fileName || fileName; + link.download = fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(url); - toast.success(`파일 다운로드 완료: ${result.fileName}`); + toast.success(`파일 다운로드 완료: ${fileName}`); } catch (error) { console.error("다운로드 오류:", error); toast.error("파일 다운로드 중 오류가 발생했습니다."); diff --git a/lib/swp/table/swp-table-toolbar.tsx b/lib/swp/table/swp-table-toolbar.tsx index e7a2ef30..fefff091 100644 --- a/lib/swp/table/swp-table-toolbar.tsx +++ b/lib/swp/table/swp-table-toolbar.tsx @@ -10,7 +10,7 @@ import { } from "@/components/ui/popover"; import { Label } from "@/components/ui/label"; import { RefreshCw, Search, X, Check, ChevronsUpDown, Upload } from "lucide-react"; -import { syncSwpProjectAction, uploadSwpFilesAction, type SwpTableFilters } from "../actions"; +import { syncSwpProjectAction, type SwpTableFilters } from "../actions"; import { useToast } from "@/hooks/use-toast"; import { useRouter } from "next/navigation"; import { cn } from "@/lib/utils"; @@ -137,27 +137,33 @@ export function SwpTableToolbar({ description: `${selectedFiles.length}개 파일을 업로드합니다...`, }); - // 파일을 Buffer로 변환 - const fileInfos = await Promise.all( - Array.from(selectedFiles).map(async (file) => { - const arrayBuffer = await file.arrayBuffer(); - return { - fileName: file.name, - fileBuffer: Buffer.from(arrayBuffer), - }; - }) - ); + // FormData 생성 (바이너리 직접 전송) + const formData = new FormData(); + formData.append("projNo", projectNo); + formData.append("vndrCd", vndrCd); + + Array.from(selectedFiles).forEach((file) => { + formData.append("files", file); + }); + + // API Route 호출 + const response = await fetch("/api/swp/upload", { + method: "POST", + body: formData, + }); + + if (!response.ok) { + throw new Error(`업로드 실패: ${response.statusText}`); + } - // 서버 액션 호출 - const result = await uploadSwpFilesAction(projectNo, vndrCd, fileInfos); + const result = await response.json(); // 결과 저장 및 다이얼로그 표시 - setUploadResults(result.details); + setUploadResults(result.details || []); setShowResultDialog(true); // 성공한 파일이 있으면 페이지 새로고침 - const successCount = result.details.filter((d) => d.success).length; - if (successCount > 0) { + if (result.successCount > 0) { router.refresh(); } } catch (error) { |
