summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.env.development2
-rw-r--r--.env.production2
-rw-r--r--app/api/swp/download/[fileId]/route.ts172
-rw-r--r--app/api/swp/upload/route.ts364
-rw-r--r--lib/swp/actions.ts373
-rw-r--r--lib/swp/table/swp-table-columns.tsx66
-rw-r--r--lib/swp/table/swp-table-toolbar.tsx38
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) {