diff options
| author | joonhoekim <26rote@gmail.com> | 2025-09-29 19:28:41 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-09-29 19:28:41 +0900 |
| commit | c17b495c700dcfa040abc93a210727cbe72785f1 (patch) | |
| tree | f7c6ebc45111d905c332e5aac3919f917e53ab85 /lib | |
| parent | edd6289cb0f5ce8701b4fb3a6c7fbdf4a6f8f6a2 (diff) | |
(김준회) SWP 파일 업로드 관련 구현
- \\60.100.91.61\SBox 경로를 mnt/swp-smb-dir/ 에 마운트해두어야 함
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/vendor-document-list/plant/shi-buyer-system-api.ts | 307 |
1 files changed, 197 insertions, 110 deletions
diff --git a/lib/vendor-document-list/plant/shi-buyer-system-api.ts b/lib/vendor-document-list/plant/shi-buyer-system-api.ts index 80575810..14de10c2 100644 --- a/lib/vendor-document-list/plant/shi-buyer-system-api.ts +++ b/lib/vendor-document-list/plant/shi-buyer-system-api.ts @@ -1,7 +1,15 @@ // app/lib/shi-buyer-system-api.ts -import db from "@/db/db" -import { stageDocuments, stageIssueStages, contracts, vendors, projects, stageSubmissions, stageSubmissionAttachments } from "@/db/schema" -import { eq, and, sql, ne, or, isNull, inArray } from "drizzle-orm" +import db from '@/db/db' +import { + stageDocuments, + stageIssueStages, + contracts, + vendors, + projects, + stageSubmissions, + stageSubmissionAttachments, +} from '@/db/schema' +import { eq, and, sql, ne, or, isNull, inArray } from 'drizzle-orm' import fs from 'fs/promises' import path from 'path' @@ -94,6 +102,28 @@ interface InBoxFileInfo { COMMENT: string } +// 파일 저장용 확장 인터페이스 +interface FileInfoWithBuffer extends InBoxFileInfo { + fileBuffer: Buffer; + attachment: { + id: number; + fileName: string; + mimeType?: string; + storagePath?: string; + storageUrl?: string; + }; +} + +// 경로 생성용 인터페이스 (유연한 타입) +interface PathGenerationData { + PROJ_NO: string | number; + VNDR_CD: string | number | null | undefined; + SHI_DOC_NO: string | number; + REVISION_NO: string | number; + STAGE_NAME: string | number; + FILE_NAME: string | number; +} + // SaveInBoxList API 응답 인터페이스 interface SaveInBoxListResponse { SaveInBoxListResult: { @@ -112,39 +142,97 @@ export class ShiBuyerSystemAPI { private baseUrl = process.env.SWP_BASE_URL || 'http://60.100.99.217/DDP/Services/VNDRService.svc' private ddcUrl = process.env.DDC_BASE_URL || 'http://60.100.99.217/DDC/Services/WebService.svc' private localStoragePath = process.env.NAS_PATH || './uploads' + // SMB로 마운트한 SWP 업로드 경로 (/mnt/swp-smb-dir/ 경로이며, 네트워크 경로로는 \\60.100.91.61\SBox 경로임) + private swpMountDir = process.env.SWP_MONUT_DIR || '/mnt/swp-smb-dir/'; + + /** + * 타임스탬프를 YYYYMMDDhhmmss 형식으로 생성 + */ + private getTimestamp(): string { + const now = new Date(); + return ( + now.getFullYear().toString() + + (now.getMonth() + 1).toString().padStart(2, '0') + + now.getDate().toString().padStart(2, '0') + + now.getHours().toString().padStart(2, '0') + + now.getMinutes().toString().padStart(2, '0') + + now.getSeconds().toString().padStart(2, '0') + ); + } + + /** + * 파일명에서 이름과 확장자를 분리 + */ + private parseFileName(fileName: string): { name: string; extension: string } { + const lastDotIndex = fileName.lastIndexOf('.'); + if (lastDotIndex === -1) { + return { name: fileName, extension: '' }; + } + return { + name: fileName.substring(0, lastDotIndex), + extension: fileName.substring(lastDotIndex + 1), + }; + } + + /** + * SMB 마운트 경로에 맞는 파일 경로 생성 + * /mnt/swp-smb-dir/{proj_no}/{cpyCd}/{YYYYMMDDhhmmss}/{[파일명]}{DOC_NO}_{REV}_{STAGE}.{extension} + */ + private generateMountPath(fileInfo: PathGenerationData): string { + // 모든 값들을 문자열로 변환 + const projNo: string = String(fileInfo.PROJ_NO); + const vndrCd: string = String(fileInfo.VNDR_CD || ''); + const shiDocNo: string = String(fileInfo.SHI_DOC_NO); + const revisionNo: string = String(fileInfo.REVISION_NO); + const stageName: string = String(fileInfo.STAGE_NAME); + const fileName: string = String(fileInfo.FILE_NAME); + + const timestamp = this.getTimestamp(); + const { name: fileNameWithoutExt, extension } = + this.parseFileName(fileName); + + // 새로운 파일명 생성: {[파일명]}{DOC_NO}_{REV}_{STAGE}.{extension} + const newFileName = `[${fileNameWithoutExt}]${shiDocNo}_${revisionNo}_${stageName}`; + const fullFileName = extension + ? `${newFileName}.${extension}` + : newFileName; + + // 전체 경로 생성 + return path.join(this.swpMountDir, projNo, vndrCd, timestamp, fullFileName); + } async sendToSHI(contractId: number) { try { // 1. 전송할 문서 조회 const documents = await this.getDocumentsToSend(contractId) - + if (documents.length === 0) { return { success: false, message: "전송할 문서가 없습니다." } } // 2. 도서 정보 전송 await this.sendDocumentInfo(documents) - + // 3. 스케줄 정보 전송 await this.sendScheduleInfo(documents) - + // 4. 동기화 상태 업데이트 await this.updateSyncStatus(documents.map(d => d.documentId)) - - return { - success: true, + + return { + success: true, message: `${documents.length}개 문서가 성공적으로 전송되었습니다.`, count: documents.length } } catch (error) { console.error("SHI 전송 오류:", error) - + // 에러 시 동기화 상태 업데이트 await this.updateSyncError( - contractId, + contractId, error instanceof Error ? error.message : "알 수 없는 오류" ) - + throw error } } @@ -182,7 +270,7 @@ export class ShiBuyerSystemAPI { ) ) - + return result } @@ -229,7 +317,7 @@ export class ShiBuyerSystemAPI { private async sendScheduleInfo(documents: any[]) { const schedules: ShiScheduleInfo[] = [] - + for (const doc of documents) { for (const stage of doc.stages) { if (stage.plan_date) { @@ -306,7 +394,7 @@ export class ShiBuyerSystemAPI { ) ) } - + async pullDocumentStatus(contractId: number) { try { const contract = await db.query.contracts.findFirst({ @@ -338,8 +426,8 @@ export class ShiBuyerSystemAPI { }) if (!shiDocuments || shiDocuments.length === 0) { - return { - success: true, + return { + success: true, message: "동기화할 문서가 없습니다.", updatedCount: 0, documents: [] @@ -362,7 +450,7 @@ export class ShiBuyerSystemAPI { } private async fetchDocumentsFromSHI( - projectCode: string, + projectCode: string, filters?: { SHI_DOC_NO?: string CATEGORY?: string @@ -377,7 +465,7 @@ export class ShiBuyerSystemAPI { } ): Promise<ShiDocumentResponse[]> { const params = new URLSearchParams({ PROJ_NO: projectCode }) - + if (filters) { Object.entries(filters).forEach(([key, value]) => { if (value) params.append(key, value) @@ -385,7 +473,7 @@ export class ShiBuyerSystemAPI { } const url = `${this.baseUrl}/GetDwgInfo?${params.toString()}` - + const response = await fetch(url, { method: 'GET', headers: { @@ -398,12 +486,12 @@ export class ShiBuyerSystemAPI { } const data: ShiApiResponse = await response.json() - + return data.GetDwgInfoResult || [] } private async updateLocalDocuments( - projectCode: string, + projectCode: string, shiDocuments: ShiDocumentResponse[] ) { let updatedCount = 0 @@ -486,7 +574,7 @@ export class ShiBuyerSystemAPI { }) .from(stageDocuments) .where(eq(stageDocuments.contractId, contractId)) - + return documents } @@ -531,7 +619,7 @@ export class ShiBuyerSystemAPI { try { // 1. 제출 정보 조회 (프로젝트, 문서, 스테이지, 파일 정보 포함) const submissionInfo = await this.getSubmissionFullInfo(submissionId) - + if (!submissionInfo) { throw new Error(`제출 정보를 찾을 수 없습니다: ${submissionId}`) } @@ -541,7 +629,7 @@ export class ShiBuyerSystemAPI { // 3. 첨부파일들과 실제 파일 내용을 준비 const filesWithContent = await this.prepareFilesWithContent(submissionInfo) - + if (filesWithContent.length === 0) { await this.updateSubmissionSyncStatus(submissionId, 'synced', '전송할 파일이 없습니다') return { @@ -554,10 +642,13 @@ export class ShiBuyerSystemAPI { // 4. SaveInBoxList API 호출하여 네트워크 경로 받기 const response = await this.sendToInBox(filesWithContent) - // 5. 응답받은 네트워크 경로에 파일 저장 - if (response.SaveInBoxListResult.success && response.SaveInBoxListResult.files) { - await this.saveFilesToNetworkPaths(filesWithContent, response.SaveInBoxListResult.files) - + // 5. SMB 마운트 경로에 파일 저장 + if ( + response.SaveInBoxListResult.success && + response.SaveInBoxListResult.files + ) { + await this.saveFilesToNetworkPaths(filesWithContent) + // 6. 동기화 결과 업데이트 await this.updateSubmissionSyncStatus(submissionId, 'synced', null, { syncedFilesCount: filesWithContent.length, @@ -581,11 +672,11 @@ export class ShiBuyerSystemAPI { } } catch (error) { await this.updateSubmissionSyncStatus( - submissionId, - 'failed', + submissionId, + 'failed', error instanceof Error ? error.message : '알 수 없는 오류' ) - + throw error } } @@ -632,29 +723,31 @@ export class ShiBuyerSystemAPI { /** * 파일 내용과 함께 InBox 파일 정보 준비 */ - private async prepareFilesWithContent(submissionInfo: any): Promise<Array<InBoxFileInfo & { fileBuffer: Buffer, attachment: any }>> { - const filesWithContent: Array<InBoxFileInfo & { fileBuffer: Buffer, attachment: any }> = [] - + private async prepareFilesWithContent( + submissionInfo: any + ): Promise<FileInfoWithBuffer[]> { + const filesWithContent: FileInfoWithBuffer[] = []; + for (const attachment of submissionInfo.attachments) { try { // 파일 경로 결정 (storagePath 또는 storageUrl 사용) - const filePath = attachment.storagePath || attachment.storageUrl - + const filePath = attachment.storagePath || attachment.storageUrl; + if (!filePath) { - console.warn(`첨부파일 ${attachment.id}의 경로를 찾을 수 없습니다.`) - continue + console.warn(`첨부파일 ${attachment.id}의 경로를 찾을 수 없습니다.`); + continue; } // 전체 경로 생성 - const fullPath = path.isAbsolute(filePath) - ? filePath - : path.join(this.localStoragePath, filePath) + const fullPath = path.isAbsolute(filePath) + ? filePath + : path.join(this.localStoragePath, filePath); // 파일 읽기 - const fileBuffer = await fs.readFile(fullPath) - + const fileBuffer = await fs.readFile(fullPath); + // 파일 정보 생성 - const fileInfo: InBoxFileInfo & { fileBuffer: Buffer, attachment: any } = { + const fileInfo: FileInfoWithBuffer = { PROJ_NO: submissionInfo.project.code, SHI_DOC_NO: submissionInfo.document.docNumber, STAGE_NAME: submissionInfo.stage.stageName, @@ -669,112 +762,103 @@ export class ShiBuyerSystemAPI { STATUS: 'PENDING', COMMENT: `Revision ${submissionInfo.submission.revisionNumber} - ${submissionInfo.stage.stageName}`, fileBuffer: fileBuffer, - attachment: attachment - } - - filesWithContent.push(fileInfo) + attachment: attachment, + }; + + filesWithContent.push(fileInfo); } catch (error) { - console.error(`파일 읽기 실패: ${attachment.fileName}`, error) + console.error(`파일 읽기 실패: ${attachment.fileName}`, error); // 파일 읽기 실패 시 계속 진행 - continue + continue; } } - return filesWithContent + return filesWithContent; } /** * SaveInBoxList API 호출 (파일 메타데이터만 전송) */ - private async sendToInBox(files: Array<InBoxFileInfo & { fileBuffer: Buffer }>): Promise<SaveInBoxListResponse> { - // fileBuffer를 제외한 메타데이터만 전송 - const fileMetadata = files.map(({ fileBuffer, attachment, ...metadata }) => metadata) - - const request = { files: fileMetadata } - + private async sendToInBox( + files: FileInfoWithBuffer[] + ): Promise<SaveInBoxListResponse> { + // fileBuffer와 attachment를 제외한 메타데이터만 전송 + const fileMetadata = files.map( + ({ fileBuffer, attachment, ...metadata }) => metadata as InBoxFileInfo + ); + + const request = { files: fileMetadata }; + const response = await fetch(`${this.ddcUrl}/SaveInBoxList`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Accept': 'application/json' + Accept: 'application/json', }, - body: JSON.stringify(request) - }) + body: JSON.stringify(request), + }); if (!response.ok) { - const errorText = await response.text() - throw new Error(`InBox 전송 실패: ${response.statusText} - ${errorText}`) + const errorText = await response.text(); + throw new Error(`InBox 전송 실패: ${response.statusText} - ${errorText}`); } - const data = await response.json() - + const data = await response.json(); + // 응답 구조 확인 및 처리 if (!data.SaveInBoxListResult) { return { SaveInBoxListResult: { success: true, - message: "전송 완료", + message: '전송 완료', processedCount: files.length, - files: files.map(f => ({ + files: files.map((f) => ({ fileName: f.FILE_NAME, networkPath: `\\\\network\\share\\${f.PROJ_NO}\\${f.SHI_DOC_NO}\\${f.FILE_NAME}`, - status: 'READY' - })) - } - } + status: 'READY', + })), + }, + }; } - - return data + + return data; } /** - * 네트워크 경로에 파일 저장 + * SMB 마운트 경로에 파일 저장 (새로운 경로 규칙 적용) */ private async saveFilesToNetworkPaths( - filesWithContent: Array<InBoxFileInfo & { fileBuffer: Buffer, attachment: any }>, - networkPathInfo: Array<{ fileName: string, networkPath: string, status: string }> + filesWithContent: FileInfoWithBuffer[] ) { for (const fileInfo of filesWithContent) { - const pathInfo = networkPathInfo.find(p => p.fileName === fileInfo.FILE_NAME) - - if (!pathInfo || !pathInfo.networkPath) { - console.error(`네트워크 경로를 찾을 수 없습니다: ${fileInfo.FILE_NAME}`) - continue - } - try { - // 네트워크 경로에 파일 저장 - // Windows 네트워크 경로인 경우 처리 - let targetPath = pathInfo.networkPath - - // Windows 네트워크 경로를 Node.js가 이해할 수 있는 형식으로 변환 - if (process.platform === 'win32' && targetPath.startsWith('\\\\')) { - // 그대로 사용 - } else if (process.platform !== 'win32' && targetPath.startsWith('\\\\')) { - // Linux/Mac에서는 SMB 마운트 경로로 변환 필요 - // 예: \\\\server\\share -> /mnt/server/share - targetPath = targetPath.replace(/\\\\/g, '/mnt/').replace(/\\/g, '/') - } + // 새로운 경로 규칙에 따라 마운트 경로 생성 + const targetPath = this.generateMountPath(fileInfo); // 디렉토리 생성 (없는 경우) - const targetDir = path.dirname(targetPath) - await fs.mkdir(targetDir, { recursive: true }) - + const targetDir = path.dirname(targetPath); + await fs.mkdir(targetDir, { recursive: true }); + // 파일 저장 - await fs.writeFile(targetPath, fileInfo.fileBuffer) - - console.log(`파일 저장 완료: ${fileInfo.FILE_NAME} -> ${targetPath}`) - - // DB에 네트워크 경로 업데이트 + await fs.writeFile(targetPath, fileInfo.fileBuffer); + + console.log(`파일 저장 완료: ${fileInfo.FILE_NAME} -> ${targetPath}`); + console.log( + `생성된 경로 구조: proj_no=${fileInfo.PROJ_NO}, cpyCd=${ + fileInfo.VNDR_CD + }, timestamp=${this.getTimestamp()}` + ); + + // DB에 마운트 경로 업데이트 (네트워크 경로 대신 마운트 경로 저장) await db .update(stageSubmissionAttachments) .set({ - buyerSystemUrl: pathInfo.networkPath, + buyerSystemUrl: targetPath, // 생성된 마운트 경로 저장 buyerSystemStatus: 'UPLOADED', lastModifiedBy: 'EVCP' }) .where(eq(stageSubmissionAttachments.id, fileInfo.attachment.id)) - + } catch (error) { console.error(`파일 저장 실패: ${fileInfo.FILE_NAME}`, error) // 개별 파일 실패는 전체 프로세스를 중단하지 않음 @@ -838,16 +922,19 @@ export class ShiBuyerSystemAPI { eq(stageSubmissions.syncStatus, 'failed'), sql`next_retry_at <= NOW()` ] - + if (contractId) { const documentIds = await db .select({ id: stageDocuments.id }) .from(stageDocuments) .where(eq(stageDocuments.contractId, contractId)) - + if (documentIds.length > 0) { conditions.push( - inArray(stageSubmissions.documentId, documentIds.map(d => d.id)) + inArray( + stageSubmissions.documentId, + documentIds.map((d) => d.id) + ) ) } } @@ -859,8 +946,8 @@ export class ShiBuyerSystemAPI { .limit(10) // 한 번에 최대 10개씩 재시도 if (failedSubmissions.length === 0) { - return { - success: true, + return { + success: true, message: "재시도할 제출 건이 없습니다.", retryCount: 0 } |
