summaryrefslogtreecommitdiff
path: root/app/api
diff options
context:
space:
mode:
Diffstat (limited to 'app/api')
-rw-r--r--app/api/bulk-upload/route.ts312
-rw-r--r--app/api/cron/tags/start/route.ts14
-rw-r--r--app/api/document-download/route.ts173
-rw-r--r--app/api/oracle/[tableName]/route.ts42
-rw-r--r--app/api/oracle/columns/[tableName]/route.ts55
-rw-r--r--app/api/revision-upload/route.ts213
-rw-r--r--app/api/sync/batches/route.ts32
-rw-r--r--app/api/sync/config/route.ts85
-rw-r--r--app/api/sync/status/route.ts82
-rw-r--r--app/api/sync/trigger/route.ts42
-rw-r--r--app/api/vendor-investigations/[investigationId]/attachments/route.ts158
11 files changed, 1205 insertions, 3 deletions
diff --git a/app/api/bulk-upload/route.ts b/app/api/bulk-upload/route.ts
new file mode 100644
index 00000000..f64fb192
--- /dev/null
+++ b/app/api/bulk-upload/route.ts
@@ -0,0 +1,312 @@
+// app/api/bulk-upload/route.ts
+import { NextRequest, NextResponse } from 'next/server'
+import { writeFile } from 'fs/promises'
+import { join } from 'path'
+import { v4 as uuidv4 } from 'uuid'
+import path from 'path'
+import db from '@/db/db'
+import { documents, issueStages, revisions, documentAttachments } from '@/db/schema/vendorDocu'
+import { and, eq } from 'drizzle-orm'
+import { revalidateTag } from 'next/cache'
+
+interface UploadItem {
+ documentId: number
+ stage: string
+ revision: string
+ fileName: string
+}
+
+interface ProcessResult {
+ success: boolean
+ documentId: number
+ stage: string
+ revision: string
+ fileName: string
+ error?: string
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const formData = await request.formData()
+
+ // FormData에서 메타데이터 추출
+ const uploaderName = formData.get("uploaderName") as string | null
+ const comment = formData.get("comment") as string | null
+ const projectType = formData.get("projectType") as string | null
+ const uploadDataStr = formData.get("uploadData") as string
+ const contractId = formData.get("contractId") as string | null
+
+ if (!uploadDataStr) {
+ return NextResponse.json(
+ { error: "업로드 데이터가 없습니다." },
+ { status: 400 }
+ )
+ }
+
+ const uploadData: UploadItem[] = JSON.parse(uploadDataStr)
+
+ if (!Array.isArray(uploadData) || uploadData.length === 0) {
+ return NextResponse.json(
+ { error: "업로드할 데이터가 없습니다." },
+ { status: 400 }
+ )
+ }
+
+ // 파일들 추출 (file_0, file_1, ...)
+ const files: File[] = []
+ for (let i = 0; i < uploadData.length; i++) {
+ const file = formData.get(`file_${i}`) as File | null
+ if (file) {
+ files.push(file)
+ }
+ }
+
+ if (files.length !== uploadData.length) {
+ return NextResponse.json(
+ { error: "업로드 데이터와 파일 수가 일치하지 않습니다." },
+ { status: 400 }
+ )
+ }
+
+ // 파일 크기 검증
+ const maxFileSize = 3 * 1024 * 1024 * 1024 // 3GB
+ for (const file of files) {
+ if (file.size > maxFileSize) {
+ return NextResponse.json(
+ { error: `파일 ${file.name}이 너무 큽니다. 최대 3GB까지 허용됩니다.` },
+ { status: 400 }
+ )
+ }
+ }
+
+ console.log("✅ 일괄 업로드 시작:", {
+ itemCount: uploadData.length,
+ fileCount: files.length,
+ uploaderName,
+ projectType
+ })
+
+ const results: ProcessResult[] = []
+ let successCount = 0
+ let errorCount = 0
+
+ // 각 파일을 개별적으로 처리 (부분 실패 허용)
+ for (let i = 0; i < uploadData.length; i++) {
+ const item = uploadData[i]
+ const file = files[i]
+
+ try {
+ // 개별 파일 업로드 처리
+ const result = await processIndividualUpload(item, file, uploaderName, comment)
+ results.push({
+ success: true,
+ documentId: item.documentId,
+ stage: item.stage,
+ revision: item.revision,
+ fileName: item.fileName,
+ })
+ successCount++
+
+ console.log(`✅ 파일 업로드 성공 [${i + 1}/${uploadData.length}]:`, item.fileName)
+
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error)
+ results.push({
+ success: false,
+ documentId: item.documentId,
+ stage: item.stage,
+ revision: item.revision,
+ fileName: item.fileName,
+ error: errorMessage,
+ })
+ errorCount++
+
+ console.error(`❌ 파일 업로드 실패 [${i + 1}/${uploadData.length}]:`, item.fileName, errorMessage)
+ }
+ }
+
+ const summary = {
+ total: uploadData.length,
+ success: successCount,
+ failed: errorCount,
+ results: results,
+ }
+
+ console.log("✅ 일괄 업로드 완료:", summary)
+
+ // 부분 성공도 성공으로 처리 (일부라도 업로드되었으면)
+ if (successCount > 0) {
+ if (contractId) {
+ revalidateTag(`enhanced-documents-${contractId}`)
+ console.log(`✅ 캐시 무효화 완료: enhanced-documents-${contractId}`)
+ }
+ return NextResponse.json({
+ success: true,
+ message: `일괄 업로드 완료: ${successCount}개 성공, ${errorCount}개 실패`,
+ data: {
+ uploadedCount: successCount,
+ failedCount: errorCount,
+ ...summary,
+ },
+ })
+ } else {
+ return NextResponse.json(
+ {
+ error: '모든 파일 업로드가 실패했습니다.',
+ details: summary,
+ },
+ { status: 500 }
+ )
+ }
+
+ } catch (error) {
+ console.error('❌ 일괄 업로드 전체 오류:', error)
+
+ return NextResponse.json(
+ {
+ error: 'Failed to process bulk upload',
+ details: error instanceof Error ? error.message : String(error),
+ },
+ { status: 500 }
+ )
+ }
+}
+
+// 개별 파일 업로드 처리 함수
+async function processIndividualUpload(
+ item: UploadItem,
+ file: File,
+ uploaderName: string | null,
+ comment: string | null
+) {
+ return await db.transaction(async (tx) => {
+ const { documentId, stage, revision, fileName } = item
+
+ // (1) 문서 존재 확인
+ const documentRecord = await tx
+ .select()
+ .from(documents)
+ .where(eq(documents.id, documentId))
+ .limit(1)
+
+ if (!documentRecord.length) {
+ throw new Error(`문서 ID ${documentId}를 찾을 수 없습니다.`)
+ }
+
+ // (2) issueStageId 찾기 또는 생성
+ let issueStageId: number
+ const stageRecord = await tx
+ .select()
+ .from(issueStages)
+ .where(and(eq(issueStages.stageName, stage), eq(issueStages.documentId, documentId)))
+ .limit(1)
+
+ if (!stageRecord.length) {
+ // Stage가 없으면 새로 생성
+ const [newStage] = await tx
+ .insert(issueStages)
+ .values({
+ documentId,
+ stageName: stage,
+ updatedAt: new Date(),
+ })
+ .returning()
+
+ issueStageId = newStage.id
+ console.log(`✅ 새 스테이지 생성: ${stage} (ID: ${issueStageId})`)
+ } else {
+ issueStageId = stageRecord[0].id
+ }
+
+ // (3) Revision 찾기 또는 생성
+ let revisionId: number
+ const revisionRecord = await tx
+ .select()
+ .from(revisions)
+ .where(and(eq(revisions.issueStageId, issueStageId), eq(revisions.revision, revision)))
+ .limit(1)
+
+ const currentDate = new Date().toISOString().split('T')[0] // YYYY-MM-DD 형식
+
+ if (!revisionRecord.length) {
+ // 리비전이 없으면 새로 생성 (일괄 업로드에서는 항상 새 리비전으로 처리)
+ const [newRevision] = await tx
+ .insert(revisions)
+ .values({
+ issueStageId,
+ revision,
+ uploaderType: "vendor", // 항상 vendor로 고정
+ uploaderName: uploaderName || undefined,
+ revisionStatus: "SUBMITTED", // 기본 상태
+ submittedDate: currentDate, // 제출일 설정
+ comment: comment || undefined,
+ updatedAt: new Date(),
+ })
+ .returning()
+
+ revisionId = newRevision.id
+ console.log(`✅ 새 리비전 생성: ${revision} (ID: ${revisionId}) for ${fileName}`)
+ } else {
+ // 기존 리비전에 파일 추가
+ revisionId = revisionRecord[0].id
+
+ // 기존 리비전 정보 업데이트 (코멘트나 업로더명 변경 가능)
+ await tx
+ .update(revisions)
+ .set({
+ uploaderName: uploaderName || revisionRecord[0].uploaderName,
+ comment: comment || revisionRecord[0].comment,
+ updatedAt: new Date(),
+ })
+ .where(eq(revisions.id, revisionId))
+
+ console.log(`✅ 기존 리비전에 파일 추가: ${revision} (ID: ${revisionId}) for ${fileName}`)
+ }
+
+ // (4) 파일 저장
+ if (file.size > 0) {
+ const originalName = file.name
+ const ext = path.extname(originalName)
+ const uniqueName = uuidv4() + ext
+ const baseDir = join(process.cwd(), "public", "documents")
+ const savePath = join(baseDir, uniqueName)
+
+ // 파일 저장
+ const arrayBuffer = await file.arrayBuffer()
+ const buffer = Buffer.from(arrayBuffer)
+ await writeFile(savePath, buffer)
+
+ // DB에 첨부파일 정보 저장
+ const [attachmentRecord] = await tx
+ .insert(documentAttachments)
+ .values({
+ revisionId,
+ fileName: originalName,
+ filePath: "/documents/" + uniqueName,
+ fileSize: file.size,
+ fileType: ext.replace('.', '').toLowerCase() || undefined,
+ updatedAt: new Date(),
+ })
+ .returning()
+
+ console.log(`✅ 파일 저장 완료: ${originalName} → ${uniqueName}`)
+ } else {
+ throw new Error('파일 크기가 0입니다.')
+ }
+
+ // (5) Documents 테이블의 updatedAt 갱신
+ await tx
+ .update(documents)
+ .set({ updatedAt: new Date() })
+ .where(eq(documents.id, documentId))
+
+ return {
+ documentId,
+ issueStageId,
+ revisionId,
+ stage,
+ revision,
+ fileName: file.name,
+ }
+ })
+} \ No newline at end of file
diff --git a/app/api/cron/tags/start/route.ts b/app/api/cron/tags/start/route.ts
index b506b9a3..3312aad8 100644
--- a/app/api/cron/tags/start/route.ts
+++ b/app/api/cron/tags/start/route.ts
@@ -13,16 +13,20 @@ const syncJobs = new Map<string, {
error?: string;
progress?: number;
packageId?: number;
+ mode?: string
}>();
export async function POST(request: NextRequest) {
try {
// 요청 데이터 가져오기
let packageId: number | undefined;
-
+ let mode: string | undefined;
+
try {
const body = await request.json();
packageId = body.packageId;
+ mode = body.mode; // 모드 정보 추출
+
} catch (error) {
// 요청 본문이 없거나 JSON이 아닌 경우, URL 파라미터 확인
const searchParams = request.nextUrl.searchParams;
@@ -30,6 +34,7 @@ export async function POST(request: NextRequest) {
if (packageIdParam) {
packageId = parseInt(packageIdParam, 10);
}
+ mode = searchParams.get('mode') || undefined;
}
// 고유 ID 생성
@@ -39,7 +44,8 @@ export async function POST(request: NextRequest) {
syncJobs.set(syncId, {
status: 'queued',
startTime: new Date(),
- packageId
+ packageId,
+ mode
});
// 비동기 작업 시작 (백그라운드에서 실행)
@@ -74,6 +80,8 @@ async function processTagImport(syncId: string) {
try {
const jobInfo = syncJobs.get(syncId)!;
const packageId = jobInfo.packageId;
+ const mode = jobInfo.mode; // 모드 정보 추출
+
// 상태 업데이트: 처리 중
syncJobs.set(syncId, {
@@ -98,7 +106,7 @@ async function processTagImport(syncId: string) {
};
// 실제 태그 가져오기 실행
- const result = await importTagsFromSEDP(packageId, updateProgress);
+ const result = await importTagsFromSEDP(packageId, updateProgress, mode);
// 명시적으로 캐시 무효화
revalidateTag(`tags-${packageId}`);
diff --git a/app/api/document-download/route.ts b/app/api/document-download/route.ts
new file mode 100644
index 00000000..78e31ce2
--- /dev/null
+++ b/app/api/document-download/route.ts
@@ -0,0 +1,173 @@
+// app/api/document-download/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+import { readFile, access, constants } from 'fs/promises';
+import { join } from 'path';
+import db from '@/db/db';
+import { documentAttachments } from '@/db/schema/vendorDocu';
+import { eq } from 'drizzle-orm';
+
+export async function GET(request: NextRequest) {
+ try {
+ // 파일 경로 파라미터 받기
+ const path = request.nextUrl.searchParams.get("path");
+ const attachmentId = request.nextUrl.searchParams.get("id");
+
+ console.log(path)
+
+ if (!path && !attachmentId) {
+ return NextResponse.json(
+ { error: "File path or attachment ID is required" },
+ { status: 400 }
+ );
+ }
+
+ let dbRecord;
+
+ // ID로 조회하는 경우 (더 안전함)
+ if (attachmentId) {
+ [dbRecord] = await db
+ .select({
+ fileName: documentAttachments.fileName,
+ filePath: documentAttachments.filePath,
+ fileType: documentAttachments.fileType,
+ fileSize: documentAttachments.fileSize
+ })
+ .from(documentAttachments)
+ .where(eq(documentAttachments.id, parseInt(attachmentId)));
+ }
+ // 경로로 조회하는 경우
+ else if (path) {
+ [dbRecord] = await db
+ .select({
+ fileName: documentAttachments.fileName,
+ filePath: documentAttachments.filePath,
+ fileType: documentAttachments.fileType,
+ fileSize: documentAttachments.fileSize
+ })
+ .from(documentAttachments)
+ .where(eq(documentAttachments.filePath, path));
+ }
+
+ // 파일 정보 설정
+ let fileName;
+ let actualFilePath;
+
+ if (dbRecord) {
+ // DB에서 찾은 경우 원본 파일명과 경로 사용
+ fileName = dbRecord.fileName;
+ actualFilePath = dbRecord.filePath;
+ console.log("DB에서 파일 정보 찾음:", { fileName, filePath: actualFilePath });
+ } else {
+ // DB에서 찾지 못한 경우
+ if (!path) {
+ return NextResponse.json(
+ { error: "File not found in database" },
+ { status: 404 }
+ );
+ }
+ fileName = path.split('/').pop() || 'download';
+ actualFilePath = path;
+ }
+
+ // 파일 경로 구성
+ const storedPath = actualFilePath.replace(/^\/+/, ""); // 앞쪽 슬래시 제거
+
+ // 파일 경로 시도
+ const possiblePaths = [
+ join(process.cwd(), "public", storedPath),
+ join(process.cwd(), storedPath), // public 없이도 시도
+ ];
+
+ // 실제 파일 찾기
+ let foundPath = null;
+ for (const testPath of possiblePaths) {
+ try {
+ await access(testPath, constants.R_OK);
+ foundPath = testPath;
+ console.log("✅ 파일 찾음:", testPath);
+ break;
+ } catch (err) {
+ console.log("❌ 경로에 파일 없음:", testPath);
+ }
+ }
+
+ if (!foundPath) {
+ return NextResponse.json(
+ {
+ error: "File not found on server",
+ details: {
+ fileName: fileName,
+ path: actualFilePath,
+ triedPaths: possiblePaths
+ }
+ },
+ { status: 404 }
+ );
+ }
+
+ const fileBuffer = await readFile(foundPath);
+
+ // MIME 타입 결정
+ const fileExtension = fileName.split('.').pop()?.toLowerCase() || '';
+ let contentType = dbRecord?.fileType || 'application/octet-stream';
+
+ // DB에 타입이 없거나 generic한 경우 확장자로 추론
+ if (!contentType || contentType === 'application/octet-stream') {
+ 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',
+ 'png': 'image/png',
+ 'jpg': 'image/jpeg',
+ 'jpeg': 'image/jpeg',
+ 'gif': 'image/gif',
+ 'dwg': 'application/acad',
+ 'dxf': 'application/dxf',
+ 'zip': 'application/zip',
+ 'rar': 'application/vnd.rar',
+ };
+ contentType = mimeTypes[fileExtension] || 'application/octet-stream';
+ }
+
+ // 다운로드용 헤더 설정
+ const headers = new Headers();
+ headers.set('Content-Type', contentType);
+ headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`);
+ headers.set('Content-Length', fileBuffer.length.toString());
+
+ // 파일 크기 검증 (DB 정보와 비교)
+ if (dbRecord?.fileSize && Math.abs(fileBuffer.length - dbRecord.fileSize) > 1024) {
+ console.warn("⚠️ 파일 크기 불일치:", {
+ dbSize: dbRecord.fileSize,
+ actualSize: fileBuffer.length
+ });
+ }
+
+ console.log("✅ 파일 다운로드 성공:", {
+ fileName,
+ size: fileBuffer.length,
+ contentType
+ });
+
+ return new NextResponse(fileBuffer, {
+ status: 200,
+ headers,
+ });
+
+ } catch (error) {
+ console.error('❌ 문서 파일 다운로드 오류:', error);
+ return NextResponse.json(
+ {
+ error: 'Failed to download file',
+ details: String(error)
+ },
+ { status: 500 }
+ );
+ }
+} \ No newline at end of file
diff --git a/app/api/oracle/[tableName]/route.ts b/app/api/oracle/[tableName]/route.ts
new file mode 100644
index 00000000..0898421d
--- /dev/null
+++ b/app/api/oracle/[tableName]/route.ts
@@ -0,0 +1,42 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { getTableData } from '@/lib/oracle/db';
+
+interface Params {
+ tableName: string;
+}
+
+interface ApiError {
+ message: string;
+ error?: string;
+}
+
+/**
+ * GET 핸들러 - 테이블 데이터 가져오기
+ */
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Params }
+): Promise<NextResponse<{ data: any[] } | ApiError>> {
+ const { tableName } = params;
+
+ if (!tableName) {
+ return NextResponse.json(
+ { message: '테이블 이름이 필요합니다.' },
+ { status: 400 }
+ );
+ }
+
+ try {
+ const data = await getTableData(tableName);
+ return NextResponse.json({ data }, { status: 200 });
+ } catch (error: any) {
+ console.error('API 에러:', error);
+ return NextResponse.json(
+ {
+ message: '서버 에러가 발생했습니다.',
+ error: error.message
+ },
+ { status: 500 }
+ );
+ }
+} \ No newline at end of file
diff --git a/app/api/oracle/columns/[tableName]/route.ts b/app/api/oracle/columns/[tableName]/route.ts
new file mode 100644
index 00000000..ba62d7a8
--- /dev/null
+++ b/app/api/oracle/columns/[tableName]/route.ts
@@ -0,0 +1,55 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { getTableColumns } from '@/lib/oracle/db';
+
+interface Params {
+ tableName: string;
+}
+
+interface ApiError {
+ message: string;
+ error?: string;
+}
+
+/**
+ * GET 핸들러 - 테이블의 특정 컬럼 데이터만 가져오기
+ */
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Params }
+): Promise<NextResponse<{ data: any[] } | ApiError>> {
+ const { tableName } = params;
+
+ if (!tableName) {
+ return NextResponse.json(
+ { message: '테이블 이름이 필요합니다.' },
+ { status: 400 }
+ );
+ }
+
+ // 쿼리 파라미터에서 컬럼 목록 가져오기
+ const searchParams = request.nextUrl.searchParams;
+ const columnsParam = searchParams.get('columns');
+
+ if (!columnsParam) {
+ return NextResponse.json(
+ { message: '컬럼 목록이 필요합니다. 예: ?columns=ID,NAME,EMAIL' },
+ { status: 400 }
+ );
+ }
+
+ const columns = columnsParam.split(',').map(col => col.trim());
+
+ try {
+ const data = await getTableColumns(tableName, columns);
+ return NextResponse.json({ data }, { status: 200 });
+ } catch (error: any) {
+ console.error('API 에러:', error);
+ return NextResponse.json(
+ {
+ message: '서버 에러가 발생했습니다.',
+ error: error.message
+ },
+ { status: 500 }
+ );
+ }
+} \ No newline at end of file
diff --git a/app/api/revision-upload/route.ts b/app/api/revision-upload/route.ts
new file mode 100644
index 00000000..2138d674
--- /dev/null
+++ b/app/api/revision-upload/route.ts
@@ -0,0 +1,213 @@
+// app/api/revision-upload/route.ts
+import { NextRequest, NextResponse } from 'next/server'
+import { writeFile } from 'fs/promises'
+import { join } from 'path'
+import { v4 as uuidv4 } from 'uuid'
+import path from 'path'
+import db from '@/db/db'
+import { documents, issueStages, revisions, documentAttachments } from '@/db/schema/vendorDocu'
+import { and, eq } from 'drizzle-orm'
+
+export async function POST(request: NextRequest) {
+ try {
+ const formData = await request.formData()
+
+ // FormData에서 데이터 추출
+ const stage = formData.get("stage") as string | null
+ const revision = formData.get("revision") as string | null
+ const docIdStr = formData.get("documentId") as string
+ const docId = parseInt(docIdStr, 10)
+ const uploaderName = formData.get("uploaderName") as string | null
+ const comment = formData.get("comment") as string | null
+ const mode = formData.get("mode") as string || "new" // 'new' | 'append'
+
+ // 파일들 추출
+ const attachmentFiles = formData.getAll("attachments") as File[]
+
+ // 유효성 검증
+ if (!docId || Number.isNaN(docId)) {
+ return NextResponse.json(
+ { error: "Invalid or missing documentId" },
+ { status: 400 }
+ )
+ }
+
+ if (!stage || !revision) {
+ return NextResponse.json(
+ { error: "Missing stage or revision" },
+ { status: 400 }
+ )
+ }
+
+ if (attachmentFiles.length === 0) {
+ return NextResponse.json(
+ { error: "No files provided" },
+ { status: 400 }
+ )
+ }
+
+ // 파일 크기 검증 (각 파일 최대 3GB)
+ const maxFileSize = 3 * 1024 * 1024 * 1024 // 3GB
+ for (const file of attachmentFiles) {
+ if (file.size > maxFileSize) {
+ return NextResponse.json(
+ { error: `파일 ${file.name}이 너무 큽니다. 최대 3GB까지 허용됩니다.` },
+ { status: 400 }
+ )
+ }
+ }
+
+ // 트랜잭션 실행
+ const result = await db.transaction(async (tx) => {
+ // (1) issueStageId 찾기 또는 생성
+ let issueStageId: number
+ const stageRecord = await tx
+ .select()
+ .from(issueStages)
+ .where(and(eq(issueStages.stageName, stage), eq(issueStages.documentId, docId)))
+ .limit(1)
+
+ if (!stageRecord.length) {
+ // Stage가 없으면 새로 생성
+ const [newStage] = await tx
+ .insert(issueStages)
+ .values({
+ documentId: docId,
+ stageName: stage,
+ updatedAt: new Date(),
+ })
+ .returning()
+
+ issueStageId = newStage.id
+ } else {
+ issueStageId = stageRecord[0].id
+ }
+
+ // (2) Revision 찾기 또는 생성
+ let revisionId: number
+ const revisionRecord = await tx
+ .select()
+ .from(revisions)
+ .where(and(eq(revisions.issueStageId, issueStageId), eq(revisions.revision, revision)))
+ .limit(1)
+
+ const currentDate = new Date().toISOString().split('T')[0] // YYYY-MM-DD 형식
+
+ if (!revisionRecord.length || mode === 'new') {
+ // 새 리비전 생성
+ const [newRevision] = await tx
+ .insert(revisions)
+ .values({
+ issueStageId,
+ revision,
+ uploaderType: "vendor", // 항상 vendor로 고정
+ uploaderName: uploaderName || undefined,
+ revisionStatus: "SUBMITTED", // 기본 상태
+ submittedDate: currentDate, // 제출일 설정
+ comment: comment || undefined,
+ updatedAt: new Date(),
+ })
+ .returning()
+
+ revisionId = newRevision.id
+ console.log("✅ 새 리비전 생성:", { revisionId, revision })
+ } else {
+ // 기존 리비전에 파일 추가 (append 모드)
+ revisionId = revisionRecord[0].id
+
+ // 기존 리비전 정보 업데이트 (코멘트나 업로더명 변경 가능)
+ await tx
+ .update(revisions)
+ .set({
+ uploaderName: uploaderName || revisionRecord[0].uploaderName,
+ comment: comment || revisionRecord[0].comment,
+ updatedAt: new Date(),
+ })
+ .where(eq(revisions.id, revisionId))
+
+ console.log("✅ 기존 리비전에 파일 추가:", { revisionId, revision })
+ }
+
+ // (3) 파일들 저장 및 DB 기록
+ const uploadedFiles = []
+ const baseDir = join(process.cwd(), "public", "documents")
+
+ for (const file of attachmentFiles) {
+ if (file.size > 0) {
+ const originalName = file.name
+ const ext = path.extname(originalName)
+ const uniqueName = uuidv4() + ext
+ const savePath = join(baseDir, uniqueName)
+
+ // 파일 저장
+ const arrayBuffer = await file.arrayBuffer()
+ const buffer = Buffer.from(arrayBuffer)
+ await writeFile(savePath, buffer)
+
+ // DB에 첨부파일 정보 저장
+ const [attachmentRecord] = await tx
+ .insert(documentAttachments)
+ .values({
+ revisionId,
+ fileName: originalName,
+ filePath: "/documents/" + uniqueName,
+ fileSize: file.size,
+ fileType: ext.replace('.', '').toLowerCase() || undefined,
+ updatedAt: new Date(),
+ })
+ .returning()
+
+ uploadedFiles.push({
+ id: attachmentRecord.id,
+ fileName: originalName,
+ fileSize: file.size,
+ filePath: attachmentRecord.filePath,
+ })
+
+ console.log("✅ 파일 저장 완료:", originalName)
+ }
+ }
+
+ // (4) Documents 테이블의 updatedAt 갱신
+ await tx
+ .update(documents)
+ .set({ updatedAt: new Date() })
+ .where(eq(documents.id, docId))
+
+ return {
+ revisionId,
+ stage,
+ revision,
+ uploaderName,
+ comment,
+ uploadedFiles,
+ mode,
+ }
+ })
+
+ console.log("✅ 리비전 업로드 완료:", {
+ documentId: docId,
+ stage,
+ revision,
+ filesCount: result.uploadedFiles.length,
+ mode
+ })
+
+ return NextResponse.json({
+ success: true,
+ message: `${result.uploadedFiles.length}개 파일이 성공적으로 업로드되었습니다.`,
+ data: result,
+ })
+
+ } catch (error) {
+ console.error('❌ 리비전 업로드 오류:', error)
+
+ return NextResponse.json(
+ {
+ error: 'Failed to upload revision',
+ details: error instanceof Error ? error.message : String(error),
+ },
+ { status: 500 }
+ )
+ }
+} \ No newline at end of file
diff --git a/app/api/sync/batches/route.ts b/app/api/sync/batches/route.ts
new file mode 100644
index 00000000..a1ef8d26
--- /dev/null
+++ b/app/api/sync/batches/route.ts
@@ -0,0 +1,32 @@
+import { syncService } from "@/lib/vendor-document-list/sync-service"
+import { NextRequest, NextResponse } from "next/server"
+
+export async function GET(request: NextRequest) {
+ try {
+ const { searchParams } = new URL(request.url)
+ const contractId = searchParams.get('contractId')
+ const targetSystem = searchParams.get('targetSystem') || 'SHI'
+ const limit = parseInt(searchParams.get('limit') || '10')
+
+ if (!contractId) {
+ return NextResponse.json(
+ { error: 'Contract ID is required' },
+ { status: 400 }
+ )
+ }
+
+ const batches = await syncService.getRecentSyncBatches(
+ parseInt(contractId),
+ targetSystem,
+ limit
+ )
+
+ return NextResponse.json(batches)
+ } catch (error) {
+ console.error('Failed to get sync batches:', error)
+ return NextResponse.json(
+ { error: 'Failed to get sync batches' },
+ { status: 500 }
+ )
+ }
+} \ No newline at end of file
diff --git a/app/api/sync/config/route.ts b/app/api/sync/config/route.ts
new file mode 100644
index 00000000..e54762fc
--- /dev/null
+++ b/app/api/sync/config/route.ts
@@ -0,0 +1,85 @@
+import { NextRequest, NextResponse } from "next/server"
+import { syncService } from "@/lib/vendor-document-list/sync-service"
+import { getServerSession } from "next-auth"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+
+export async function GET(request: NextRequest) {
+ try {
+ const { searchParams } = new URL(request.url)
+ const contractId = searchParams.get('contractId')
+ const targetSystem = searchParams.get('targetSystem') || 'SHI'
+
+ if (!contractId) {
+ return NextResponse.json(
+ { error: 'Contract ID is required' },
+ { status: 400 }
+ )
+ }
+
+ const config = await syncService.getSyncConfig(
+ parseInt(contractId),
+ targetSystem
+ )
+
+ // 민감한 정보 제거
+ if (config) {
+ const { authToken, ...safeConfig } = config
+ return NextResponse.json({
+ ...safeConfig,
+ hasAuthToken: !!authToken
+ })
+ }
+
+ return NextResponse.json(null)
+ } catch (error) {
+ console.error('Failed to get sync config:', error)
+ return NextResponse.json(
+ { error: 'Failed to get sync config' },
+ { status: 500 }
+ )
+ }
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
+ }
+ const body = await request.json()
+ const {
+ contractId,
+ targetSystem,
+ endpointUrl,
+ authToken,
+ syncEnabled,
+ syncIntervalMinutes,
+ maxBatchSize
+ } = body
+
+ if (!contractId || !targetSystem || !endpointUrl) {
+ return NextResponse.json(
+ { error: 'Contract ID, target system, and endpoint URL are required' },
+ { status: 400 }
+ )
+ }
+
+ await syncService.upsertSyncConfig({
+ contractId,
+ targetSystem,
+ endpointUrl,
+ authToken,
+ syncEnabled,
+ syncIntervalMinutes,
+ maxBatchSize
+ })
+
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ console.error('Failed to update sync config:', error)
+ return NextResponse.json(
+ { error: 'Failed to update sync config' },
+ { status: 500 }
+ )
+ }
+} \ No newline at end of file
diff --git a/app/api/sync/status/route.ts b/app/api/sync/status/route.ts
new file mode 100644
index 00000000..886c14df
--- /dev/null
+++ b/app/api/sync/status/route.ts
@@ -0,0 +1,82 @@
+import { syncService } from "@/lib/vendor-document-list/sync-service"
+import { NextRequest, NextResponse } from "next/server"
+
+// JSON 직렬화 가능한 형태로 변환하는 헬퍼 함수
+function serializeForJSON(obj: any): any {
+ if (obj === null || obj === undefined) {
+ return null
+ }
+
+ if (obj instanceof Date) {
+ return obj.toISOString()
+ }
+
+ if (typeof obj === 'bigint') {
+ return obj.toString()
+ }
+
+ if (Array.isArray(obj)) {
+ return obj.map(serializeForJSON)
+ }
+
+ if (typeof obj === 'object') {
+ const serialized: any = {}
+ for (const [key, value] of Object.entries(obj)) {
+ serialized[key] = serializeForJSON(value)
+ }
+ return serialized
+ }
+
+ return obj
+}
+
+export async function GET(request: NextRequest) {
+ try {
+ const { searchParams } = new URL(request.url)
+ const contractId = searchParams.get('contractId')
+ const targetSystem = searchParams.get('targetSystem') || 'SHI'
+
+ if (!contractId) {
+ return NextResponse.json(
+ { error: 'Contract ID is required' },
+ { status: 400 }
+ )
+ }
+
+ let status
+
+ try {
+ // 실제 데이터베이스에서 조회 시도
+ status = await syncService.getSyncStatus(
+ parseInt(contractId),
+ targetSystem
+ )
+ } catch (error) {
+ console.log('Database query failed, using mock data:', error)
+
+ // ✅ 데이터베이스 조회 실패시 임시 목업 데이터 반환
+ status = {
+ contractId: parseInt(contractId),
+ targetSystem,
+ totalChanges: 15,
+ pendingChanges: 3, // 3건 대기 중 (빨간 뱃지 표시용)
+ syncedChanges: 12,
+ failedChanges: 0,
+ lastSyncAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(), // 30분 전
+ nextSyncAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), // 10분 후
+ syncEnabled: true
+ }
+ }
+
+ // JSON 직렬화 가능한 형태로 변환
+ const serializedStatus = serializeForJSON(status)
+
+ return NextResponse.json(serializedStatus)
+ } catch (error) {
+ console.error('Failed to get sync status:', error)
+ return NextResponse.json(
+ { error: 'Failed to get sync status' },
+ { status: 500 }
+ )
+ }
+} \ No newline at end of file
diff --git a/app/api/sync/trigger/route.ts b/app/api/sync/trigger/route.ts
new file mode 100644
index 00000000..3393365d
--- /dev/null
+++ b/app/api/sync/trigger/route.ts
@@ -0,0 +1,42 @@
+import { NextRequest, NextResponse } from "next/server"
+import { syncService } from "@/lib/vendor-document-list/sync-service"
+import { getServerSession } from "next-auth"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+
+export async function POST(request: NextRequest) {
+ try {
+ // 인증 확인
+
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
+ }
+
+ const body = await request.json()
+ const { contractId, targetSystem = 'SHI' } = body
+
+ if (!contractId) {
+ return NextResponse.json(
+ { error: 'Contract ID is required' },
+ { status: 400 }
+ )
+ }
+
+ const result = await syncService.syncToExternalSystem(
+ contractId,
+ targetSystem,
+ true // manual trigger
+ )
+
+ return NextResponse.json(result)
+ } catch (error) {
+ console.error('Sync trigger failed:', error)
+ return NextResponse.json(
+ {
+ error: 'Sync failed',
+ message: error instanceof Error ? error.message : 'Unknown error'
+ },
+ { status: 500 }
+ )
+ }
+} \ No newline at end of file
diff --git a/app/api/vendor-investigations/[investigationId]/attachments/route.ts b/app/api/vendor-investigations/[investigationId]/attachments/route.ts
new file mode 100644
index 00000000..80513e28
--- /dev/null
+++ b/app/api/vendor-investigations/[investigationId]/attachments/route.ts
@@ -0,0 +1,158 @@
+// app/api/vendor-investigations/[investigationId]/attachments/route.ts
+import { NextRequest, NextResponse } from "next/server"
+import { promises as fs } from "fs"
+import path from "path"
+import { v4 as uuidv4 } from "uuid"
+import db from "@/db/db"
+import { vendorInvestigationAttachments } from "@/db/schema"
+import { eq } from "drizzle-orm";
+
+export async function POST(
+ req: NextRequest,
+ { params }: { params: { investigationId: string } }
+) {
+ try {
+ const investigationId = parseInt(params.investigationId)
+ if (!investigationId) {
+ return NextResponse.json({ error: "Invalid investigation ID" }, { status: 400 })
+ }
+
+ const formData = await req.formData()
+ const file = formData.get("file") as File
+
+ if (!file || file.size === 0) {
+ return NextResponse.json({ error: "No file provided" }, { status: 400 })
+ }
+
+ // 파일 크기 제한 (10MB)
+ const maxSize = 10 * 1024 * 1024
+ if (file.size > maxSize) {
+ return NextResponse.json({
+ error: `파일 크기가 ${maxSize / 1024 / 1024}MB를 초과합니다.`
+ }, { status: 400 })
+ }
+
+ // 지원하는 파일 타입 검증
+ const allowedTypes = [
+ 'application/pdf',
+ 'application/msword',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/vnd.ms-excel',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'image/png',
+ 'image/jpeg',
+ 'image/jpg',
+ 'image/gif'
+ ]
+
+ if (!allowedTypes.includes(file.type)) {
+ return NextResponse.json({
+ error: "지원하지 않는 파일 형식입니다."
+ }, { status: 400 })
+ }
+
+ // 파일 저장
+ const arrayBuffer = await file.arrayBuffer()
+ const buffer = Buffer.from(arrayBuffer)
+
+ // 고유한 파일명 생성
+ const ext = path.extname(file.name)
+ const uniqueName = `${uuidv4()}${ext}`
+
+ // 업로드 디렉토리 생성
+ const uploadDir = path.join(process.cwd(), "public", "vendor-investigation", String(investigationId))
+ await fs.mkdir(uploadDir, { recursive: true })
+
+ const filePath = path.join(uploadDir, uniqueName)
+ await fs.writeFile(filePath, buffer)
+
+ // 파일 타입 결정
+ let attachmentType = "OTHER"
+ if (file.type.includes("pdf")) {
+ attachmentType = "REPORT"
+ } else if (file.type.includes("image")) {
+ attachmentType = "PHOTO"
+ } else if (
+ file.type.includes("word") ||
+ file.type.includes("document") ||
+ file.name.toLowerCase().includes("report")
+ ) {
+ attachmentType = "DOCUMENT"
+ }
+
+ // DB에 파일 정보 저장
+ const [attachment] = await db.insert(vendorInvestigationAttachments).values({
+ investigationId,
+ fileName: file.name,
+ filePath: `/vendor-investigation/${investigationId}/${uniqueName}`,
+ fileSize: file.size,
+ mimeType: file.type,
+ attachmentType: attachmentType as "REPORT" | "PHOTO" | "DOCUMENT" | "CERTIFICATE" | "OTHER",
+ // uploadedBy: currentUserId, // 실제 사용자 ID로 설정
+ }).returning()
+
+ return NextResponse.json({
+ success: true,
+ attachment: {
+ id: attachment.id,
+ fileName: attachment.fileName,
+ filePath: attachment.filePath,
+ fileSize: attachment.fileSize,
+ attachmentType: attachment.attachmentType
+ }
+ })
+
+ } catch (error) {
+ console.error("파일 업로드 오류:", error)
+ return NextResponse.json({
+ error: "파일 업로드 중 오류가 발생했습니다."
+ }, { status: 500 })
+ }
+}
+
+// 첨부파일 삭제
+export async function DELETE(
+ req: NextRequest,
+ { params }: { params: { investigationId: string } }
+) {
+ try {
+ const { searchParams } = new URL(req.url)
+ const attachmentId = searchParams.get("attachmentId")
+
+ if (!attachmentId) {
+ return NextResponse.json({ error: "Attachment ID required" }, { status: 400 })
+ }
+
+ // DB에서 파일 정보 조회
+ const attachment = await db.query.vendorInvestigationAttachments.findFirst({
+ where: (attachments, { eq, and }) => and(
+ eq(attachments.id, parseInt(attachmentId)),
+ eq(attachments.investigationId, parseInt(params.investigationId))
+ )
+ })
+
+ if (!attachment) {
+ return NextResponse.json({ error: "Attachment not found" }, { status: 404 })
+ }
+
+ // 실제 파일 삭제
+ const fullPath = path.join(process.cwd(), "public", attachment.filePath)
+ try {
+ await fs.unlink(fullPath)
+ } catch (error) {
+ console.warn("파일 삭제 실패 (파일이 존재하지 않을 수 있음):", error)
+ }
+
+ // DB에서 레코드 삭제
+ await db.delete(vendorInvestigationAttachments)
+ .where(eq(vendorInvestigationAttachments.id, parseInt(attachmentId)))
+
+ return NextResponse.json({ success: true })
+
+ } catch (error) {
+ console.error("파일 삭제 오류:", error)
+ return NextResponse.json({
+ error: "파일 삭제 중 오류가 발생했습니다."
+ }, { status: 500 })
+ }
+} \ No newline at end of file