'use server'; import db from '@/db/db'; import { rfqPrItems, rfqLastAttachments, rfqLastAttachmentRevisions } from '@/db/schema/rfqLast'; import { eq, and, ne } from 'drizzle-orm'; import { getDcmtmIdByMaterialCode } from './get-dcmtm-id'; import { getEncryptDocumentumFile } from './get-pos'; import { downloadPosFile } from './download-pos-file'; import type { PosFileSyncResult } from './types'; import path from 'path'; import fs from 'fs/promises'; import { revalidatePath } from 'next/cache'; import { debugLog, debugError, debugSuccess, debugProcess, debugWarn } from '@/lib/debug-utils'; /** * RFQ의 모든 PR Items의 MATNR로 POS 파일을 조회하고 서버에 다운로드하여 저장 */ export async function syncRfqPosFiles( rfqId: number, userId: number ): Promise { debugLog(`🚀 POS 파일 동기화 시작`, { rfqId, userId }); const result: PosFileSyncResult = { success: false, processedCount: 0, successCount: 0, failedCount: 0, errors: [], details: [] }; try { // 1. RFQ의 모든 PR Items 조회 (materialCode 중복 제거) debugProcess(`📋 RFQ PR Items 조회 시작 (RFQ ID: ${rfqId})`); const prItems = await db .selectDistinct({ materialCode: rfqPrItems.materialCode, materialDescription: rfqPrItems.materialDescription, }) .from(rfqPrItems) .where(and( eq(rfqPrItems.rfqsLastId, rfqId), // materialCode가 null이 아닌 것만 )) .then(items => items.filter(item => item.materialCode && item.materialCode.trim() !== '')); debugLog(`📦 조회된 PR Items`, { totalCount: prItems.length, materialCodes: prItems.map(item => item.materialCode) }); if (prItems.length === 0) { debugWarn(`⚠️ 처리할 자재코드가 없습니다 (RFQ ID: ${rfqId})`); result.errors.push('처리할 자재코드가 없습니다.'); return result; } result.processedCount = prItems.length; debugSuccess(`✅ 처리할 자재코드 ${prItems.length}개 발견`); // 2. 각 자재코드별로 POS 파일 처리 debugProcess(`🔄 자재코드별 POS 파일 처리 시작`); for (const prItem of prItems) { const materialCode = prItem.materialCode!; debugLog(`📋 자재코드 처리 시작: ${materialCode}`); try { // 2-1. 자재코드로 DCMTM_ID 조회 debugProcess(`🔍 DCMTM_ID 조회 시작 (자재코드: ${materialCode})`); const dcmtmResult = await getDcmtmIdByMaterialCode({ materialCode }); debugLog(`🎯 DCMTM_ID 조회 결과`, { materialCode, success: dcmtmResult.success, fileCount: dcmtmResult.files?.length || 0, files: dcmtmResult.files }); if (!dcmtmResult.success || !dcmtmResult.files || dcmtmResult.files.length === 0) { debugWarn(`⚠️ DCMTM_ID 조회 실패 또는 파일 없음 (자재코드: ${materialCode})`, dcmtmResult.error); result.details.push({ materialCode, status: 'no_files', error: dcmtmResult.error || 'POS 파일을 찾을 수 없음' }); continue; } // 여러 파일이 있을 경우 첫 번째 파일만 처리 const posFile = dcmtmResult.files[0]; debugLog(`📁 처리할 POS 파일 선택`, { materialCode, selectedFile: posFile, totalFiles: dcmtmResult.files.length }); // 2-2. POS API로 파일 경로 가져오기 debugProcess(`🌐 POS API 호출 시작 (DCMTM_ID: ${posFile.dcmtmId})`); const posResult = await getEncryptDocumentumFile({ objectID: posFile.dcmtmId }); debugLog(`🌐 POS API 호출 결과`, { materialCode, dcmtmId: posFile.dcmtmId, success: posResult.success, resultPath: posResult.result, error: posResult.error }); if (!posResult.success || !posResult.result) { debugError(`❌ POS API 호출 실패 (자재코드: ${materialCode})`, posResult.error); result.details.push({ materialCode, fileName: posFile.fileName, status: 'failed', error: posResult.error || 'POS 파일 경로 조회 실패' }); result.failedCount++; continue; } // 2-3. 내부망에서 파일 다운로드 debugProcess(`⬇️ 파일 다운로드 시작 (경로: ${posResult.result})`); const downloadResult = await downloadPosFile({ relativePath: posResult.result }); debugLog(`⬇️ 파일 다운로드 결과`, { materialCode, success: downloadResult.success, fileName: downloadResult.fileName, fileSize: downloadResult.fileBuffer?.length, mimeType: downloadResult.mimeType, error: downloadResult.error }); if (!downloadResult.success || !downloadResult.fileBuffer) { debugError(`❌ 파일 다운로드 실패 (자재코드: ${materialCode})`, downloadResult.error); result.details.push({ materialCode, fileName: posFile.fileName, status: 'failed', error: downloadResult.error || '파일 다운로드 실패' }); result.failedCount++; continue; } // 2-4. 서버 파일 시스템에 저장 debugProcess(`💾 서버 파일 저장 시작 (파일명: ${downloadResult.fileName || `${materialCode}.pdf`})`); const saveResult = await saveFileToServer( downloadResult.fileBuffer, downloadResult.fileName || `${materialCode}.pdf` ); debugLog(`💾 서버 파일 저장 결과`, { materialCode, success: saveResult.success, filePath: saveResult.filePath, fileName: saveResult.fileName, error: saveResult.error }); if (!saveResult.success) { debugError(`❌ 서버 파일 저장 실패 (자재코드: ${materialCode})`, saveResult.error); result.details.push({ materialCode, fileName: posFile.fileName, status: 'failed', error: saveResult.error || '서버 파일 저장 실패' }); result.failedCount++; continue; } // 2-5. 데이터베이스에 첨부파일 정보 저장 debugProcess(`🗄️ DB 첨부파일 정보 저장 시작 (자재코드: ${materialCode})`); const dbResult = await saveAttachmentToDatabase( rfqId, materialCode, { dcmtmId: posFile.dcmtmId, fileName: posFile.fileName }, saveResult.filePath!, saveResult.fileName!, downloadResult.fileBuffer.length, downloadResult.mimeType || 'application/pdf', userId ); debugLog(`🗄️ DB 저장 결과`, { materialCode, success: dbResult.success, error: dbResult.error }); if (!dbResult.success) { debugError(`❌ DB 저장 실패 (자재코드: ${materialCode})`, dbResult.error); result.details.push({ materialCode, fileName: posFile.fileName, status: 'failed', error: dbResult.error || 'DB 저장 실패' }); result.failedCount++; continue; } debugSuccess(`✅ 자재코드 ${materialCode} 처리 완료`); result.details.push({ materialCode, fileName: posFile.fileName, status: 'success' }); result.successCount++; } catch (error) { const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류'; debugError(`❌ 자재코드 ${materialCode} 처리 중 예외 발생`, { error: errorMessage, stack: error instanceof Error ? error.stack : undefined }); result.details.push({ materialCode, status: 'failed', error: errorMessage }); result.failedCount++; result.errors.push(`${materialCode}: ${errorMessage}`); } } result.success = result.successCount > 0; debugLog(`📊 POS 파일 동기화 최종 결과`, { rfqId, processedCount: result.processedCount, successCount: result.successCount, failedCount: result.failedCount, success: result.success, errors: result.errors }); // 캐시 무효화 debugProcess(`🔄 캐시 무효화 (경로: /evcp/rfq-last/${rfqId})`); revalidatePath(`/evcp/rfq-last/${rfqId}`); debugSuccess(`🎉 POS 파일 동기화 완료 (성공: ${result.successCount}건, 실패: ${result.failedCount}건)`); return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류'; debugError(`❌ POS 파일 동기화 전체 처리 오류`, { error: errorMessage, stack: error instanceof Error ? error.stack : undefined }); result.errors.push(`전체 처리 오류: ${errorMessage}`); return result; } } /** * 파일을 서버 파일 시스템에 저장 */ async function saveFileToServer( fileBuffer: Buffer, originalFileName: string ): Promise<{ success: boolean; filePath?: string; fileName?: string; error?: string; }> { try { // uploads/pos 디렉토리 생성 const uploadDir = path.join(process.cwd(), 'uploads', 'pos'); try { await fs.access(uploadDir); } catch { await fs.mkdir(uploadDir, { recursive: true }); } // 고유한 파일명 생성 (타임스탬프 + 원본명) const timestamp = Date.now(); const sanitizedFileName = originalFileName.replace(/[^a-zA-Z0-9.-]/g, '_'); const fileName = `${timestamp}_${sanitizedFileName}`; const filePath = path.join(uploadDir, fileName); // 파일 저장 await fs.writeFile(filePath, fileBuffer); return { success: true, filePath: `uploads/pos/${fileName}`, // 상대 경로로 저장 fileName: originalFileName }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : '파일 저장 실패' }; } } /** * 첨부파일 정보를 데이터베이스에 저장 */ async function saveAttachmentToDatabase( rfqId: number, materialCode: string, posFileInfo: { dcmtmId: string; fileName: string }, filePath: string, originalFileName: string, fileSize: number, fileType: string, userId: number ): Promise<{ success: boolean; error?: string; }> { try { await db.transaction(async (tx) => { // 1. 기존 동일한 자재코드의 설계 첨부파일이 있는지 확인 const existingAttachment = await tx .select() .from(rfqLastAttachments) .where(and( eq(rfqLastAttachments.rfqId, rfqId), eq(rfqLastAttachments.attachmentType, '설계'), eq(rfqLastAttachments.serialNo, materialCode) )) .limit(1); let attachmentId: number; if (existingAttachment.length > 0) { // 기존 첨부파일이 있으면 업데이트 attachmentId = existingAttachment[0].id; await tx .update(rfqLastAttachments) .set({ currentRevision: 'Rev.1', // 새 리비전으로 업데이트 description: `${posFileInfo.fileName} (자재코드: ${materialCode})`, updatedAt: new Date() }) .where(eq(rfqLastAttachments.id, attachmentId)); } else { // 새 첨부파일 생성 const [newAttachment] = await tx .insert(rfqLastAttachments) .values({ attachmentType: '설계', serialNo: materialCode, rfqId, currentRevision: 'Rev.0', description: `${posFileInfo.fileName} (자재코드: ${materialCode})`, createdBy: userId, createdAt: new Date(), updatedAt: new Date() }) .returning({ id: rfqLastAttachments.id }); attachmentId = newAttachment.id; } // 2. 새 리비전 생성 const [newRevision] = await tx .insert(rfqLastAttachmentRevisions) .values({ attachmentId, revisionNo: 'Rev.0', fileName: path.basename(filePath), originalFileName, filePath, fileSize, fileType, isLatest: true, revisionComment: `POS 시스템에서 자동 동기화됨 (DCMTM_ID: ${posFileInfo.dcmtmId})`, createdBy: userId }) .returning({ id: rfqLastAttachmentRevisions.id }); // 3. 첨부파일의 latestRevisionId 업데이트 await tx .update(rfqLastAttachments) .set({ latestRevisionId: newRevision.id }) .where(eq(rfqLastAttachments.id, attachmentId)); // 4. 기존 리비전들의 isLatest를 false로 업데이트 await tx .update(rfqLastAttachmentRevisions) .set({ isLatest: false }) .where(and( eq(rfqLastAttachmentRevisions.attachmentId, attachmentId), ne(rfqLastAttachmentRevisions.id, newRevision.id) )); }); return { success: true }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'DB 저장 실패' }; } }