diff options
Diffstat (limited to 'lib/pos')
| -rw-r--r-- | lib/pos/download-pos-file.ts | 162 | ||||
| -rw-r--r-- | lib/pos/get-dcmtm-id.ts | 134 | ||||
| -rw-r--r-- | lib/pos/get-pos.ts | 174 | ||||
| -rw-r--r-- | lib/pos/index.ts | 164 | ||||
| -rw-r--r-- | lib/pos/sync-rfq-pos-files.ts | 407 | ||||
| -rw-r--r-- | lib/pos/types.ts | 87 |
6 files changed, 1128 insertions, 0 deletions
diff --git a/lib/pos/download-pos-file.ts b/lib/pos/download-pos-file.ts new file mode 100644 index 00000000..d285c618 --- /dev/null +++ b/lib/pos/download-pos-file.ts @@ -0,0 +1,162 @@ +'use server'; + +import fs from 'fs/promises'; +import path from 'path'; +import type { DownloadPosFileParams, DownloadPosFileResult } from './types'; +import { debugLog, debugError, debugSuccess, debugProcess } from '@/lib/debug-utils'; + +const POS_BASE_PATH = '\\\\60.100.99.123\\ECM_NAS_PRM\\Download'; + +/** + * POS API에서 반환된 경로의 파일을 내부망에서 읽어서 클라이언트에게 전달 + * 내부망 파일을 직접 접근할 수 없는 클라이언트를 위한 프록시 역할 + */ +export async function downloadPosFile( + params: DownloadPosFileParams +): Promise<DownloadPosFileResult> { + try { + const { relativePath } = params; + debugLog(`⬇️ POS 파일 다운로드 시작`, { relativePath, basePath: POS_BASE_PATH }); + + if (!relativePath) { + debugError(`❌ 파일 경로가 제공되지 않음`); + return { + success: false, + error: '파일 경로가 제공되지 않았습니다.', + }; + } + + // Windows 경로 구분자를 정규화 + const normalizedRelativePath = relativePath.replace(/\\/g, path.sep); + debugLog(`📁 경로 정규화`, { original: relativePath, normalized: normalizedRelativePath }); + + // 전체 파일 경로 구성 + const fullPath = path.join(POS_BASE_PATH, normalizedRelativePath); + debugLog(`📍 전체 파일 경로 구성`, { fullPath }); + + // 경로 보안 검증 (디렉토리 탈출 방지) + const resolvedPath = path.resolve(fullPath); + const resolvedBasePath = path.resolve(POS_BASE_PATH); + debugLog(`🔒 경로 보안 검증`, { resolvedPath, resolvedBasePath, isValid: resolvedPath.startsWith(resolvedBasePath) }); + + if (!resolvedPath.startsWith(resolvedBasePath)) { + debugError(`❌ 디렉토리 탈출 시도 감지`, { resolvedPath, resolvedBasePath }); + return { + success: false, + error: '잘못된 파일 경로입니다.', + }; + } + + // 파일 존재 여부 확인 + debugProcess(`🔍 파일 존재 여부 확인 중...`); + try { + await fs.access(resolvedPath); + debugSuccess(`✅ 파일 존재 확인됨 (${resolvedPath})`); + } catch (accessError) { + debugError(`❌ 파일을 찾을 수 없음`, { resolvedPath, error: accessError }); + return { + success: false, + error: '파일을 찾을 수 없습니다.', + }; + } + + // 파일 정보 가져오기 + debugProcess(`📊 파일 정보 조회 중...`); + const stats = await fs.stat(resolvedPath); + debugLog(`📊 파일 정보`, { + resolvedPath, + size: stats.size, + isFile: stats.isFile(), + isDirectory: stats.isDirectory(), + mtime: stats.mtime + }); + + if (!stats.isFile()) { + debugError(`❌ 대상이 파일이 아님 (디렉토리)`, { resolvedPath }); + return { + success: false, + error: '디렉토리는 다운로드할 수 없습니다.', + }; + } + + // 파일 읽기 + debugProcess(`📖 파일 읽기 시작 (크기: ${stats.size} bytes)...`); + const fileBuffer = await fs.readFile(resolvedPath); + const fileName = path.basename(resolvedPath); + debugLog(`📖 파일 읽기 완료`, { + fileName, + bufferSize: fileBuffer.length, + expectedSize: stats.size, + sizeMatch: fileBuffer.length === stats.size + }); + + // MIME 타입 추정 + debugProcess(`🔍 MIME 타입 추정 중...`); + const mimeType = await getMimeType(fileName); + debugLog(`🔍 MIME 타입 추정 완료`, { fileName, mimeType }); + + debugSuccess(`✅ POS 파일 다운로드 성공`, { + fileName, + fileSize: fileBuffer.length, + mimeType + }); + + return { + success: true, + fileName, + fileBuffer, + mimeType, + }; + } catch (error) { + debugError('❌ POS 파일 다운로드 실패', { + relativePath: params.relativePath, + error: error instanceof Error ? error.message : '알 수 없는 오류', + stack: error instanceof Error ? error.stack : undefined + }); + return { + success: false, + error: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.', + }; + } +} + +/** + * 파일 확장자를 기반으로 MIME 타입 추정 + */ +export async function getMimeType(fileName: string): Promise<string> { + const ext = path.extname(fileName).toLowerCase(); + + const mimeTypes: Record<string, string> = { + '.pdf': 'application/pdf', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.bmp': 'image/bmp', + '.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', + '.dwg': 'application/acad', + '.dxf': 'application/dxf', + '.zip': 'application/zip', + '.rar': 'application/x-rar-compressed', + '.7z': 'application/x-7z-compressed', + '.txt': 'text/plain', + }; + + return mimeTypes[ext] || 'application/octet-stream'; +} + +/** + * 클라이언트에서 사용할 수 있는 다운로드 URL을 생성하는 헬퍼 함수 + * 실제 다운로드는 별도의 API 엔드포인트에서 처리 + */ +export async function createDownloadUrl(relativePath: string): Promise<string> { + const encodedPath = encodeURIComponent(relativePath); + return `/api/pos/download?path=${encodedPath}`; +} diff --git a/lib/pos/get-dcmtm-id.ts b/lib/pos/get-dcmtm-id.ts new file mode 100644 index 00000000..353c5b1d --- /dev/null +++ b/lib/pos/get-dcmtm-id.ts @@ -0,0 +1,134 @@ +'use server'; + +import { oracleKnex } from '@/lib/oracle-db/db'; +import type { PosFileInfo, GetDcmtmIdParams, GetDcmtmIdResult } from './types'; +import { debugLog, debugError, debugSuccess, debugProcess } from '@/lib/debug-utils'; + +/** + * 자재코드로 POS 파일의 DCMTM_ID를 조회하는 서버 액션 + * + * @param params - 자재코드를 포함한 조회 파라미터 + * @returns POS 파일 정보 목록 (여러 파일이 있을 수 있음) + */ +export async function getDcmtmIdByMaterialCode( + params: GetDcmtmIdParams +): Promise<GetDcmtmIdResult> { + try { + const { materialCode } = params; + debugLog(`🔍 DCMTM_ID 조회 시작`, { materialCode }); + + if (!materialCode) { + debugError(`❌ 자재코드가 제공되지 않음`); + return { + success: false, + error: '자재코드가 제공되지 않았습니다.', + }; + } + + // Oracle 쿼리 실행 + const query = ` + SELECT Y.PROJ_NO, + Y.POS_NO, + Y.POS_REV_NO, + Y.FILE_SER, + Y.FILE_NM, + Y.DCMTM_ID + FROM PLDTB_POS_ITEM X, + PLDTB_POS_ITEM_FILE Y, + PLMTB_EMLS Z + WHERE X.PROJ_NO = Y.PROJ_NO + AND X.POS_NO = Y.POS_NO + AND X.POS_REV_NO = Y.POS_REV_NO + AND X.POS_NO = Z.DOC_NO + AND NVL(X.APP_ID, ' ') <> ' ' + AND NVL(Z.PUR_REQ_NO, ' ') <> ' ' + AND Z.MAT_NO = ? + `; + + debugProcess(`🗄️ Oracle 쿼리 실행`, { materialCode, query: query.trim() }); + const results = await oracleKnex.raw(query, [materialCode]); + debugLog(`🗄️ Oracle 쿼리 결과`, { materialCode, resultType: typeof results, hasRows: results && results[0] ? results[0].length : 0 }); + + // Oracle 결과 처리 (oracledb 드라이버의 결과 구조에 따라) + const rows = results[0] || results.rows || results; + debugLog(`📊 Oracle 결과 구조 분석`, { + materialCode, + resultStructure: { + hasZeroIndex: !!results[0], + hasRowsProperty: !!results.rows, + rowsType: typeof rows, + isArray: Array.isArray(rows), + rowCount: Array.isArray(rows) ? rows.length : 0 + }, + firstRow: Array.isArray(rows) && rows.length > 0 ? rows[0] : null + }); + + if (!Array.isArray(rows) || rows.length === 0) { + debugLog(`⚠️ POS 파일을 찾을 수 없음 (자재코드: ${materialCode})`); + return { + success: false, + error: '해당 자재코드에 대한 POS 파일을 찾을 수 없습니다.', + }; + } + + // 결과를 PosFileInfo 형태로 변환 + debugProcess(`🔄 Oracle 결과를 PosFileInfo로 변환 중...`); + const files: PosFileInfo[] = rows.map((row: Record<string, unknown>, index: number) => { + const fileInfo = { + projNo: (row.PROJ_NO || row[0]) as string, + posNo: (row.POS_NO || row[1]) as string, + posRevNo: (row.POS_REV_NO || row[2]) as string, + fileSer: (row.FILE_SER || row[3]) as string, + fileName: (row.FILE_NM || row[4]) as string, + dcmtmId: (row.DCMTM_ID || row[5]) as string, + }; + debugLog(`📄 파일 ${index + 1} 변환 결과`, { materialCode, index, original: row, converted: fileInfo }); + return fileInfo; + }); + + debugSuccess(`✅ DCMTM_ID 조회 성공 (자재코드: ${materialCode}, 파일 수: ${files.length})`); + return { + success: true, + files, + }; + } catch (error) { + debugError('❌ DCMTM_ID 조회 실패', { + materialCode: params.materialCode, + error: error instanceof Error ? error.message : '알 수 없는 오류', + stack: error instanceof Error ? error.stack : undefined + }); + return { + success: false, + error: error instanceof Error ? error.message : '데이터베이스 조회 중 오류가 발생했습니다.', + }; + } +} + +/** + * 단일 DCMTM_ID만 필요한 경우를 위한 헬퍼 함수 + * 여러 파일이 있을 경우 첫 번째 파일의 DCMTM_ID를 반환 + */ +export async function getFirstDcmtmId( + params: GetDcmtmIdParams +): Promise<{ + success: boolean; + dcmtmId?: string; + fileName?: string; + error?: string; +}> { + const result = await getDcmtmIdByMaterialCode(params); + + if (!result.success || !result.files || result.files.length === 0) { + return { + success: false, + error: result.error, + }; + } + + const firstFile = result.files[0]; + return { + success: true, + dcmtmId: firstFile.dcmtmId, + fileName: firstFile.fileName, + }; +} diff --git a/lib/pos/get-pos.ts b/lib/pos/get-pos.ts new file mode 100644 index 00000000..6424b880 --- /dev/null +++ b/lib/pos/get-pos.ts @@ -0,0 +1,174 @@ +'use server'; + +import { XMLBuilder, XMLParser } from 'fast-xml-parser'; +import { withSoapLogging } from '@/lib/soap/utils'; +import type { GetEncryptDocumentumFileParams } from './types'; +import { POS_SOAP_ENDPOINT } from './types'; +import { debugLog, debugError, debugSuccess, debugProcess } from '@/lib/debug-utils'; + +/** + * 문서 암호화 파일을 서버에 다운로드하고 경로를 반환하는 POS(Documentum) SOAP 액션 + * 반환값은 서버 내 파일 다운로드 경로입니다. (예: "asd_as_2509131735233768_OP02\asd_as_2509131735233768_OP02.tif") + * 실제 파일은 \\60.100.99.123\ECM_NAS_PRM\Download\ 경로에 저장됩니다. + */ +export async function getEncryptDocumentumFile( + params: GetEncryptDocumentumFileParams +): Promise<{ + success: boolean; + result?: string; + error?: string; +}> { + try { + const { + objectID, + sabun = 'EVM0236', // context2.txt에 따라 기본값 설정 + appCode = process.env.POS_APP_CODE || 'SO13', // 환경변수 사용 + fileCreateMode = 1, // context2.txt에 따라 기본값 변경 + securityLevel = 'SedamsClassID', + isDesign = true, // context2.txt에 따라 기본값 변경 + } = params; + + debugLog(`🌐 POS SOAP API 호출 시작`, { + objectID, + sabun, + appCode, + fileCreateMode, + securityLevel, + isDesign, + endpoint: POS_SOAP_ENDPOINT + }); + + // 1. SOAP Envelope 생성 (SOAP 1.2) + debugProcess(`📄 SOAP Envelope 생성 중...`); + const envelopeObj = { + 'soap12:Envelope': { + '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', + '@_xmlns:xsd': 'http://www.w3.org/2001/XMLSchema', + '@_xmlns:soap12': 'http://www.w3.org/2003/05/soap-envelope', + 'soap12:Body': { + GetEncryptDocumentumFile: { + '@_xmlns': 'Sedams.WebServices.Documentum', + objectID, + sabun, + appCode, + fileCreateMode: String(fileCreateMode), + securityLevel, + isDesign: String(isDesign), + }, + }, + }, + }; + + const builder = new XMLBuilder({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + format: false, + suppressEmptyNode: true, + }); + + const xmlBody = builder.build(envelopeObj); + debugLog(`📄 생성된 SOAP XML`, { + objectID, + xmlLength: xmlBody.length, + envelope: envelopeObj['soap12:Envelope']['soap12:Body'] + }); + + // 2. SOAP 호출 (로그 포함) + debugProcess(`🌐 SOAP 요청 전송 중... (${POS_SOAP_ENDPOINT})`); + const responseText = await withSoapLogging( + 'OUTBOUND', + 'SEDAMS Documentum', + 'GetEncryptDocumentumFile', + xmlBody, + async () => { + const res = await fetch(POS_SOAP_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml; charset=utf-8', + SOAPAction: + '"Sedams.WebServices.Documentum/GetEncryptDocumentumFile"', + }, + body: xmlBody, + }); + + const text = await res.text(); + debugLog(`🌐 HTTP 응답 수신`, { + objectID, + status: res.status, + statusText: res.statusText, + responseLength: text.length, + headers: Object.fromEntries(res.headers.entries()) + }); + + if (!res.ok) { + debugError(`❌ HTTP 오류 응답`, { objectID, status: res.status, statusText: res.statusText, responseBody: text }); + throw new Error(`HTTP ${res.status}: ${res.statusText}`); + } + + return text; + } + ); + + debugSuccess(`✅ SOAP 응답 수신 완료 (응답 길이: ${responseText.length})`); + + // 3. 응답 XML 파싱 + debugProcess(`📝 XML 응답 파싱 중...`); + const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + }); + + const parsed = parser.parse(responseText); + debugLog(`📝 파싱된 XML 구조`, { + objectID, + parsedKeys: Object.keys(parsed), + parsed: parsed + }); + + let result: string | undefined; + + try { + // SOAP 1.2 규격 + debugProcess(`🔍 SOAP 1.2 구조에서 결과 추출 시도...`); + result = + parsed['soap:Envelope']['soap:Body'][ + 'GetEncryptDocumentumFileResponse' + ]['GetEncryptDocumentumFileResult']; + debugLog(`🎯 SOAP 1.2에서 결과 추출 성공`, { objectID, result }); + } catch (e1) { + debugLog(`⚠️ SOAP 1.2 구조 추출 실패, Fallback 시도...`, { objectID, error: e1 }); + // Fallback: SOAP 1.1 또는 다른 응답 형태 + try { + debugProcess(`🔍 SOAP 1.1 구조에서 결과 추출 시도...`); + result = + parsed['soap:Envelope']['soap:Body'][ + 'GetEncryptDocumentumFileResponse' + ]['GetEncryptDocumentumFileResult']; + debugLog(`🎯 SOAP 1.1에서 결과 추출 성공`, { objectID, result }); + } catch (e2) { + debugLog(`⚠️ SOAP 1.1 구조 추출 실패, 단순 string 시도...`, { objectID, error: e2 }); + // HTTP POST 응답 형태 (단순 string) + try { + debugProcess(`🔍 단순 string 구조에서 결과 추출 시도...`); + result = parsed.string; + debugLog(`🎯 단순 string에서 결과 추출 성공`, { objectID, result }); + } catch (e3) { + debugError(`❌ 모든 파싱 방법 실패`, { objectID, errors: [e1, e2, e3], parsedStructure: parsed }); + } + } + } + + debugSuccess(`✅ POS API 호출 성공`, { objectID, result }); + return { success: true, result }; + } catch (error) { + debugError('❌ POS SOAP 호출 실패', { + objectID: params.objectID, + error: error instanceof Error ? error.message : 'Unknown error', + stack: error instanceof Error ? error.stack : undefined + }); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} diff --git a/lib/pos/index.ts b/lib/pos/index.ts new file mode 100644 index 00000000..7611d5c5 --- /dev/null +++ b/lib/pos/index.ts @@ -0,0 +1,164 @@ +// POS 관련 모든 기능을 하나로 통합하는 인덱스 파일 + +import { getEncryptDocumentumFile } from './get-pos'; +import { createDownloadUrl } from './download-pos-file'; +import { getDcmtmIdByMaterialCode } from './get-dcmtm-id'; +import type { PosFileInfo } from './types'; + +export { + getEncryptDocumentumFile +} from './get-pos'; + +export { + downloadPosFile, + createDownloadUrl +} from './download-pos-file'; + +export { + getDcmtmIdByMaterialCode, + getFirstDcmtmId +} from './get-dcmtm-id'; + +export { + syncRfqPosFiles +} from './sync-rfq-pos-files'; + +// 타입들은 ./types 에서 export +export type * from './types'; + +/** + * POS 파일을 가져와서 다운로드 URL을 생성하는 통합 함수 + * + * @example + * ```typescript + * const result = await getPosFileAndCreateDownloadUrl({ + * objectID: "0900746983f2e12a" + * }); + * + * if (result.success && result.downloadUrl) { + * // 클라이언트에서 다운로드 링크 사용 + * window.open(result.downloadUrl, '_blank'); + * } + * ``` + */ +export async function getPosFileAndCreateDownloadUrl(params: { + objectID: string; + sabun?: string; + appCode?: string; + fileCreateMode?: number; + securityLevel?: string; + isDesign?: boolean; +}): Promise<{ + success: boolean; + downloadUrl?: string; + fileName?: string; + error?: string; +}> { + try { + // 1. POS에서 파일 경로 가져오기 + const posResult = await getEncryptDocumentumFile(params); + + if (!posResult.success || !posResult.result) { + return { + success: false, + error: posResult.error || 'POS에서 파일 정보를 가져올 수 없습니다.', + }; + } + + // 2. 다운로드 URL 생성 + const downloadUrl = await createDownloadUrl(posResult.result); + + // 파일명 추출 (경로의 마지막 부분) + const fileName = posResult.result.split(/[\\\/]/).pop() || 'unknown'; + + return { + success: true, + downloadUrl, + fileName, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} + +/** + * 자재코드부터 POS 파일 다운로드까지 전체 워크플로우를 처리하는 통합 함수 + * + * @example + * ```typescript + * const result = await getPosFileByMaterialCode({ + * materialCode: "SN2693A6410100001" + * }); + * + * if (result.success && result.downloadUrl) { + * // 클라이언트에서 다운로드 링크 사용 + * window.open(result.downloadUrl, '_blank'); + * } + * ``` + */ +export async function getPosFileByMaterialCode(params: { + materialCode: string; + /** + * 여러 파일이 있을 경우 선택할 파일 인덱스 (기본값: 0) + */ + fileIndex?: number; +}): Promise<{ + success: boolean; + downloadUrl?: string; + fileName?: string; + availableFiles?: PosFileInfo[]; + error?: string; +}> { + try { + const { materialCode, fileIndex = 0 } = params; + + // 1. 자재코드로 DCMTM_ID 조회 + const dcmtmResult = await getDcmtmIdByMaterialCode({ materialCode }); + + if (!dcmtmResult.success || !dcmtmResult.files || dcmtmResult.files.length === 0) { + return { + success: false, + error: dcmtmResult.error || '해당 자재코드에 대한 POS 파일을 찾을 수 없습니다.', + }; + } + + // 선택된 파일이 범위를 벗어나는지 확인 + if (fileIndex >= dcmtmResult.files.length) { + return { + success: false, + error: `파일 인덱스가 범위를 벗어났습니다. 사용 가능한 파일 수: ${dcmtmResult.files.length}`, + availableFiles: dcmtmResult.files, + }; + } + + const selectedFile = dcmtmResult.files[fileIndex]; + + // 2. DCMTM_ID로 POS 파일 정보 가져오기 + const posResult = await getPosFileAndCreateDownloadUrl({ + objectID: selectedFile.dcmtmId, + }); + + if (!posResult.success) { + return { + success: false, + error: posResult.error, + availableFiles: dcmtmResult.files, + }; + } + + return { + success: true, + downloadUrl: posResult.downloadUrl, + fileName: selectedFile.fileName, // 오라클에서 가져온 파일명 사용 + availableFiles: dcmtmResult.files, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} diff --git a/lib/pos/sync-rfq-pos-files.ts b/lib/pos/sync-rfq-pos-files.ts new file mode 100644 index 00000000..acb34f20 --- /dev/null +++ b/lib/pos/sync-rfq-pos-files.ts @@ -0,0 +1,407 @@ +'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<PosFileSyncResult> { + 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 저장 실패' + }; + } +} diff --git a/lib/pos/types.ts b/lib/pos/types.ts new file mode 100644 index 00000000..eb75c94b --- /dev/null +++ b/lib/pos/types.ts @@ -0,0 +1,87 @@ +// POS 관련 타입 정의 + +export interface PosFileInfo { + projNo: string; + posNo: string; + posRevNo: string; + fileSer: string; + fileName: string; + dcmtmId: string; +} + +export interface GetDcmtmIdParams { + /** + * 자재코드 (MATNR) + * 예: 'SN2693A6410100001' + */ + materialCode: string; +} + +export interface GetDcmtmIdResult { + success: boolean; + files?: PosFileInfo[]; + error?: string; +} + +export interface GetEncryptDocumentumFileParams { + objectID: string; + /** + * 사번. 기본값: 'EVM0236' (context2.txt 기준) + */ + sabun?: string; + /** + * 앱 코드. 기본값: 환경변수 POS_APP_CODE 또는 'SO13'(품질) + */ + appCode?: string; + /** + * 파일 생성 모드. 기본값: 1 (context2.txt 기준) + * 0: PDF Rendition 우선, 실패시 원본 + * 1: 원본 파일 + * 2: PDF 파일 + * 3: TIFF 파일 + */ + fileCreateMode?: number; + /** + * 보안 레벨. 기본값: 'SedamsClassID' + */ + securityLevel?: string; + /** + * 설계파일 여부. 기본값: true (context2.txt 기준) + */ + isDesign?: boolean; +} + +export interface DownloadPosFileParams { + /** + * POS API에서 반환된 상대 경로 + * 예: "asd_as_2509131735233768_OP02\asd_as_2509131735233768_OP02.tif" + */ + relativePath: string; +} + +export interface DownloadPosFileResult { + success: boolean; + fileName?: string; + fileBuffer?: Buffer; + mimeType?: string; + error?: string; +} + +export interface PosFileSyncResult { + success: boolean; + processedCount: number; + successCount: number; + failedCount: number; + errors: string[]; + details: { + materialCode: string; + fileName?: string; + status: 'success' | 'failed' | 'no_files'; + error?: string; + }[]; +} + +// POS SOAP 엔드포인트 정보 +export const POS_SOAP_SEGMENT = '/Documentum/PlmFileBroker.asmx'; +export const POS_SOAP_BASE_URL = process.env.POS_SOAP_ENDPOINT || 'http://60.100.99.122:7700'; +export const POS_SOAP_ENDPOINT = `${POS_SOAP_BASE_URL}${POS_SOAP_SEGMENT}`; |
