// 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 fs from 'fs/promises' import path from 'path' interface ShiDocumentInfo { PROJ_NO: string SHI_DOC_NO: string CATEGORY: string | null RESPONSIBLE_CD: string RESPONSIBLE: string VNDR_CD: string VNDR_NM: string DSN_SKL: string | null MIFP_CD: string MIFP_NM: string CG_EMPNO1: string CG_EMPNM1: string OWN_DOC_NO: string DSC: string DOC_CLASS: string COMMENT: string STATUS: string CRTER: string CRTE_DTM: string CHGR: string CHG_DTM: string } interface ShiScheduleInfo { PROJ_NO: string SHI_DOC_NO: string DDPKIND: string SCHEDULE_TYPE: string BASELINE1: string | null REVISED1: string | null FORECAST1: string | null ACTUAL1: string | null BASELINE2: string | null REVISED2: string | null FORECAST2: string | null ACTUAL2: string | null CRTER: string CRTE_DTM: string CHGR: string CHG_DTM: string } // SHI API 응답 타입 interface ShiDocumentResponse { PROJ_NO: string SHI_DOC_NO: string STATUS: string COMMENT: string | null CATEGORY?: string RESPONSIBLE_CD?: string RESPONSIBLE?: string VNDR_CD?: string VNDR_NM?: string DSN_SKL?: string MIFP_CD?: string MIFP_NM?: string CG_EMPNO1?: string CG_EMPNM1?: string OWN_DOC_NO?: string DSC?: string DOC_CLASS?: string CRTER?: string CRTE_DTM?: string CHGR?: string CHG_DTM?: string } interface ShiApiResponse { GetDwgInfoResult: ShiDocumentResponse[] } // InBox 파일 정보 인터페이스 (SaveInBoxList API 요청 형식) interface InBoxFileInfo { CPY_CD: string // 회사 코드 (항상 "C00001" 고정, VNDR_CD와는 별개) FILE_NM: string // 파일명: [OWNDOCNO]_[REVNO]_[STAGE].[extension] OFDC_NO: string | null // null 가능 PROJ_NO: string // 프로젝트 번호 OWN_DOC_NO: string // 자사 문서번호 REV_NO: string // 리비전 번호 STAGE: string // 스테이지 (예: IFA, IFB 등) STAT: string // 상태코드 (예: SCW03 - Completed) FILE_SZ: string // 파일 크기 (byte, 문자열) FLD_PATH: string // 폴더 경로: [ProjNo][CpyCd][YYYYMMDDHHMMSS] } // 파일 저장용 확장 인터페이스 interface FileInfoWithBuffer extends InBoxFileInfo { fileBuffer: Buffer; attachment: { id: number; fileName: string; mimeType: string | null; storagePath: string | null; storageUrl: string | null; [key: string]: any; }; // 네트워크 경로 생성을 위한 추가 정보 _timestamp: string; _extension: string; } // SaveInBoxList API 응답 인터페이스 interface SaveInBoxListResponse { SaveInBoxListResult: { success: boolean message: string processedCount?: number files?: Array<{ fileName: string networkPath: string status: string }> } } // SaveInBoxList API 요청 인터페이스 interface SaveInBoxListRequest { externalInboxLists: InBoxFileInfo[] } // 내부 문서 타입 (getDocumentsToSend 반환 타입) interface DocumentWithStages { documentId: number docNumber: string vendorDocNumber: string | null title: string status: string buyerSystemComment: string | null projectCode: string vendorCode: string vendorName: string docClass?: string | null stages: Array<{ id: number documentId: number stageName: string stageOrder: number | null planDate: Date | string | null actualDate: Date | string | null [key: string]: any }> } // 제출 정보 타입 (getSubmissionFullInfo 반환 타입) interface SubmissionFullInfo { submission: { id: number revisionNumber: number submittedBy: string [key: string]: any } stage: { id: number stageName: string [key: string]: any } document: { id: number docNumber: string vendorDocNumber: string | null [key: string]: any } project: { id: number code: string [key: string]: any } vendor: { id: number vendorCode: string | null vendorName: string [key: string]: any } | null attachments: Array<{ id: number fileName: string mimeType: string | null storagePath: string | null storageUrl: string | null [key: string]: any }> } 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}/{CPY_CD}/{YYYYMMDDHHmmSS}/[OWN_DOC_NO]_[REV_NO]_[STAGE]_[YYYYMMDD].확장자 */ private generateMountPath( projNo: string, cpyCode: string, timestamp: string, ownDocNo: string, revNo: string, stage: string, extension: string ): string { const dateOnly = timestamp.substring(0, 8); // YYYYMMDD만 추출 // 파일명 생성: [OWN_DOC_NO]_[REV_NO]_[STAGE]_[YYYYMMDD].확장자 const fileName = extension ? `[${ownDocNo}]_${revNo}_${stage}_${dateOnly}.${extension}` : `[${ownDocNo}]_${revNo}_${stage}_${dateOnly}`; // 전체 경로 생성 return path.join(this.swpMountDir, projNo, cpyCode, timestamp, fileName); } /** * 네트워크 경로 생성 (SHI 시스템에서 접근 가능한 경로) * \\60.100.91.61\SBox\{PROJ_NO}\{CPY_CD}\{YYYYMMDDHHmmSS}\[OWN_DOC_NO]_[REV_NO]_[STAGE]_[YYYYMMDD].확장자 */ private generateNetworkPath( projNo: string, cpyCode: string, timestamp: string, ownDocNo: string, revNo: string, stage: string, extension: string ): string { const dateOnly = timestamp.substring(0, 8); // YYYYMMDD만 추출 // 파일명 생성: [OWN_DOC_NO]_[REV_NO]_[STAGE]_[YYYYMMDD].확장자 const fileName = extension ? `[${ownDocNo}]_${revNo}_${stage}_${dateOnly}.${extension}` : `[${ownDocNo}]_${revNo}_${stage}_${dateOnly}`; // 네트워크 경로 생성 return `\\\\60.100.91.61\\SBox\\${projNo}\\${cpyCode}\\${timestamp}\\${fileName}`; } async sendToSHI(contractId: number, selectedDocumentIds?: number[]) { try { // 1. 전송할 문서 조회 const documents = await this.getDocumentsToSend(contractId, selectedDocumentIds) 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, message: `${documents.length}개 문서가 성공적으로 전송되었습니다.`, count: documents.length } } catch (error) { console.error("SHI 전송 오류:", error) // 에러 시 동기화 상태 업데이트 await this.updateSyncError( contractId, error instanceof Error ? error.message : "알 수 없는 오류" ) throw error } } private async getDocumentsToSend(contractId: number, selectedDocumentIds?: number[]): Promise { // 1. 기본 WHERE 조건 구성 const whereConditions = [ eq(stageDocuments.contractId, contractId), eq(stageDocuments.status, 'ACTIVE'), // 승인되지 않은 문서만 (null이거나 "승인(DC)"가 아닌 것) or( isNull(stageDocuments.buyerSystemStatus), ne(stageDocuments.buyerSystemStatus, "승인(DC)") ) ] // 2. 선택된 문서 ID가 있으면 추가 필터링 if (selectedDocumentIds && selectedDocumentIds.length > 0) { whereConditions.push(inArray(stageDocuments.id, selectedDocumentIds)) } // 3. 문서 목록을 가져옴 const documents = await db .select({ documentId: stageDocuments.id, docNumber: stageDocuments.docNumber, vendorDocNumber: stageDocuments.vendorDocNumber, title: stageDocuments.title, status: stageDocuments.status, buyerSystemComment: stageDocuments.buyerSystemComment, // 코멘트 필드 추가 docClass: stageDocuments.docClass, // DOC_CLASS 필드 추가 projectCode: sql`(SELECT code FROM projects WHERE id = ${stageDocuments.projectId})`, vendorCode: sql`(SELECT vendor_code FROM vendors WHERE id = ${stageDocuments.vendorId})`, vendorName: sql`(SELECT vendor_name FROM vendors WHERE id = ${stageDocuments.vendorId})`, updatedAt: stageDocuments.updatedAt, }) .from(stageDocuments) .where(and(...whereConditions)) // 2. 각 문서에 대해 스테이지 정보를 별도로 조회 const documentsWithStages = await Promise.all( documents.map(async (doc) => { const stages = await db .select() .from(stageIssueStages) .where(eq(stageIssueStages.documentId, doc.documentId)) .orderBy(stageIssueStages.stageOrder) return { ...doc, stages: stages || [] } as DocumentWithStages }) ) return documentsWithStages } private async sendDocumentInfo(documents: DocumentWithStages[]) { const shiDocuments: ShiDocumentInfo[] = documents.map((doc) => { const docInfo: ShiDocumentInfo = { PROJ_NO: doc.projectCode, SHI_DOC_NO: doc.docNumber, CATEGORY: null, // SHI 설계자가 직접 입력함 // 김준식 프로 요청으로 RESPONSIBLE_CD / RESPONSIBLE 값 변경 (251002,김준회) RESPONSIBLE_CD: 'C00001', // 고정 RESPONSIBLE: 'SHI', // 고정 VNDR_CD: doc.vendorCode || '', VNDR_NM: doc.vendorName || '', DSN_SKL: null, // SHI 설계자가 직접 입력함 MIFP_CD: '', MIFP_NM: '', CG_EMPNO1: '', CG_EMPNM1: '', OWN_DOC_NO: doc.vendorDocNumber || doc.docNumber, DSC: doc.title, DOC_CLASS: doc.docClass || '', // 선택한 DOC_CLASS 사용 COMMENT: doc.buyerSystemComment || '', // 실제 코멘트 전송 // 조민정 프로 요청으로 'ACTIVE' --> '생성요청' 값으로 변경 (251002,김준회) STATUS: '생성요청', // 고정 CRTER: 'EVCP_SYSTEM', // 고정 CRTE_DTM: new Date().toISOString(), CHGR: 'EVCP_SYSTEM', // 고정 CHG_DTM: new Date().toISOString(), }; // DOC_CLASS 값 로깅 (디버깅용) console.log(`[SHI API] 문서 ${doc.docNumber} - DOC_CLASS: "${doc.docClass}" -> 전송값: "${docInfo.DOC_CLASS}"`); return docInfo; }); // 전송 데이터 로깅 (디버깅용) console.log('[SHI API] SetDwgInfo 요청 데이터:', JSON.stringify(shiDocuments, null, 2)) const response = await fetch(`${this.baseUrl}/SetDwgInfo`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(shiDocuments) }) if (!response.ok) { const errorText = await response.text() throw new Error(`도서 정보 전송 실패: ${response.statusText} - ${errorText}`) } return response.json() } private async sendScheduleInfo(documents: DocumentWithStages[]) { const schedules: ShiScheduleInfo[] = [] for (const doc of documents) { for (const stage of doc.stages) { // 날짜에서 1은 Issue, 2는 Receipt if (stage.planDate) { schedules.push({ PROJ_NO: doc.projectCode, SHI_DOC_NO: doc.docNumber, DDPKIND: "V", SCHEDULE_TYPE: stage.stageName, BASELINE1: stage.planDate ? new Date(stage.planDate).toISOString() : null, REVISED1: null, FORECAST1: null, ACTUAL1: stage.actualDate ? new Date(stage.actualDate).toISOString() : null, BASELINE2: null, REVISED2: null, FORECAST2: null, ACTUAL2: null, CRTER: "EVCP_SYSTEM", CRTE_DTM: new Date().toISOString(), CHGR: "EVCP_SYSTEM", CHG_DTM: new Date().toISOString() }) } } } if (schedules.length === 0) { console.log("전송할 스케줄 정보가 없습니다.") return } const response = await fetch(`${this.baseUrl}/SetScheduleInfo`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(schedules) }) if (!response.ok) { const errorText = await response.text() throw new Error(`스케줄 정보 전송 실패: ${response.statusText} - ${errorText}`) } return response.json() } private async updateSyncStatus(documentIds: number[]) { if (documentIds.length === 0) return await db .update(stageDocuments) .set({ syncStatus: 'synced', lastSyncedAt: new Date(), syncError: null, syncVersion: sql`sync_version + 1`, lastModifiedBy: 'EVCP' }) .where(inArray(stageDocuments.id, documentIds)) } private async updateSyncError(contractId: number, errorMessage: string) { await db .update(stageDocuments) .set({ syncStatus: 'error', syncError: errorMessage, lastModifiedBy: 'EVCP' }) .where( and( eq(stageDocuments.contractId, contractId), eq(stageDocuments.status, 'ACTIVE') ) ) } async pullDocumentStatus(contractId: number) { try { const contract = await db.query.contracts.findFirst({ where: eq(contracts.id, contractId), }); if (!contract || !contract.projectId || !contract.vendorId) { throw new Error(`계약 정보가 올바르지 않습니다: ${contractId}`) } const project = await db.query.projects.findFirst({ where: eq(projects.id, contract.projectId), }); if (!project || !project.code) { throw new Error(`프로젝트를 찾을 수 없습니다: ${contract.projectId}`) } const vendor = await db.query.vendors.findFirst({ where: eq(vendors.id, contract.vendorId), }); if (!vendor || !vendor.vendorCode) { throw new Error(`벤더를 찾을 수 없습니다: ${contract.vendorId}`) } const shiDocuments = await this.fetchDocumentsFromSHI(project.code, { VNDR_CD: vendor.vendorCode ?? undefined }) if (!shiDocuments || shiDocuments.length === 0) { return { success: true, message: "동기화할 문서가 없습니다.", updatedCount: 0, documents: [] } } const updateResults = await this.updateLocalDocuments(project.code, shiDocuments) return { success: true, message: `${updateResults.updatedCount}개 문서의 상태가 업데이트되었습니다.`, updatedCount: updateResults.updatedCount, newCount: updateResults.newCount, documents: updateResults.documents } } catch (error) { console.error("문서 상태 풀링 오류:", error) throw error } } private async fetchDocumentsFromSHI( projectCode: string, filters?: { SHI_DOC_NO?: string CATEGORY?: string VNDR_CD?: string RESPONSIBLE_CD?: string STATUS?: string DOC_CLASS?: string CRTE_DTM_FROM?: string CRTE_DTM_TO?: string CHG_DTM_FROM?: string CHG_DTM_TO?: string } ): Promise { const params = new URLSearchParams({ PROJ_NO: projectCode }) if (filters) { Object.entries(filters).forEach(([key, value]) => { if (value) params.append(key, value) }) } const url = `${this.baseUrl}/GetDwgInfo?${params.toString()}` const response = await fetch(url, { method: 'GET', headers: { 'Accept': 'application/json' } }) if (!response.ok) { throw new Error(`문서 조회 실패: ${response.statusText}`) } const data: ShiApiResponse = await response.json() return data.GetDwgInfoResult || [] } private async updateLocalDocuments( projectCode: string, shiDocuments: ShiDocumentResponse[] ) { let updatedCount = 0 let newCount = 0 const updatedDocuments: Array<{ docNumber: string title: string status: string | null | undefined comment: string | null | undefined action: string }> = [] const project = await db.query.projects.findFirst({ where: eq(projects.code, projectCode) }) if (!project) { throw new Error(`프로젝트를 찾을 수 없습니다: ${projectCode}`) } for (const shiDoc of shiDocuments) { const localDoc = await db.query.stageDocuments.findFirst({ where: and( eq(stageDocuments.projectId, project.id), eq(stageDocuments.docNumber, shiDoc.SHI_DOC_NO) ) }) if (localDoc) { if ( localDoc.buyerSystemStatus !== shiDoc.STATUS || localDoc.buyerSystemComment !== shiDoc.COMMENT ) { await db .update(stageDocuments) .set({ buyerSystemStatus: shiDoc.STATUS, buyerSystemComment: shiDoc.COMMENT, lastSyncedAt: new Date(), syncStatus: 'synced', syncError: null, lastModifiedBy: 'BUYER_SYSTEM', syncVersion: sql`sync_version + 1` }) .where(eq(stageDocuments.id, localDoc.id)) updatedCount++ updatedDocuments.push({ docNumber: shiDoc.SHI_DOC_NO, title: shiDoc.DSC || localDoc.title, status: shiDoc.STATUS, comment: shiDoc.COMMENT, action: 'updated' }) } } else { console.log(`SHI에만 존재하는 문서: ${shiDoc.SHI_DOC_NO}`) newCount++ updatedDocuments.push({ docNumber: shiDoc.SHI_DOC_NO, title: shiDoc.DSC || 'N/A', status: shiDoc.STATUS, comment: shiDoc.COMMENT, action: 'new_in_shi' }) } } return { updatedCount, newCount, documents: updatedDocuments } } async getSyncStatus(contractId: number) { const documents = await db .select({ docNumber: stageDocuments.docNumber, title: stageDocuments.title, syncStatus: stageDocuments.syncStatus, lastSyncedAt: stageDocuments.lastSyncedAt, syncError: stageDocuments.syncError, buyerSystemStatus: stageDocuments.buyerSystemStatus, buyerSystemComment: stageDocuments.buyerSystemComment }) .from(stageDocuments) .where(eq(stageDocuments.contractId, contractId)) return documents } /** * 스테이지 제출 건들의 파일을 SHI 구매자 시스템으로 동기화 * @param submissionIds 제출 ID 배열 */ async syncSubmissionsToSHI(submissionIds: number[]) { const results = { totalCount: submissionIds.length, successCount: 0, failedCount: 0, details: [] as Array<{ submissionId: number success: boolean message?: string syncedFiles?: number error?: string }> } for (const submissionId of submissionIds) { try { const result = await this.syncSingleSubmission(submissionId) if (result.success) { results.successCount++ } else { results.failedCount++ } results.details.push(result) } catch (error) { results.failedCount++ results.details.push({ submissionId, success: false, error: error instanceof Error ? error.message : "Unknown error" }) } } return results } /** * 단일 제출 건 동기화 */ private async syncSingleSubmission(submissionId: number) { try { // 1. 제출 정보 조회 (프로젝트, 문서, 스테이지, 파일 정보 포함) const submissionInfo = await this.getSubmissionFullInfo(submissionId) if (!submissionInfo) { throw new Error(`제출 정보를 찾을 수 없습니다: ${submissionId}`) } // 2. 동기화 시작 상태 업데이트 await this.updateSubmissionSyncStatus(submissionId, 'syncing') // 3. 첨부파일들과 실제 파일 내용을 준비 const filesWithContent = await this.prepareFilesWithContent(submissionInfo) if (filesWithContent.length === 0) { await this.updateSubmissionSyncStatus(submissionId, 'synced', '전송할 파일이 없습니다') return { submissionId, success: true, message: "전송할 파일이 없습니다" } } // 4. SaveInBoxList API 호출하여 네트워크 경로 받기 const response = await this.sendToInBox(filesWithContent) // 5. SMB 마운트 경로에 파일 저장 if ( response.SaveInBoxListResult.success && response.SaveInBoxListResult.files ) { await this.saveFilesToNetworkPaths(filesWithContent) // 6. 동기화 결과 업데이트 await this.updateSubmissionSyncStatus(submissionId, 'synced', null, { syncedFilesCount: filesWithContent.length, buyerSystemStatus: 'SYNCED' }) // 개별 파일 상태 업데이트 await this.updateAttachmentsSyncStatus( submissionInfo.attachments.map(a => a.id), 'synced' ) return { submissionId, success: true, message: response.SaveInBoxListResult.message, syncedFiles: filesWithContent.length } } else { throw new Error(response.SaveInBoxListResult.message) } } catch (error) { await this.updateSubmissionSyncStatus( submissionId, 'failed', error instanceof Error ? error.message : '알 수 없는 오류' ) throw error } } /** * 제출 정보 조회 (관련 정보 포함) */ private async getSubmissionFullInfo(submissionId: number): Promise { const result = await db .select({ submission: stageSubmissions, stage: stageIssueStages, document: stageDocuments, project: projects, vendor: vendors }) .from(stageSubmissions) .innerJoin(stageIssueStages, eq(stageSubmissions.stageId, stageIssueStages.id)) .innerJoin(stageDocuments, eq(stageSubmissions.documentId, stageDocuments.id)) .innerJoin(projects, eq(stageDocuments.projectId, projects.id)) .leftJoin(vendors, eq(stageDocuments.vendorId, vendors.id)) .where(eq(stageSubmissions.id, submissionId)) .limit(1) if (result.length === 0) return null // 첨부파일 조회 - 파일 경로 포함 const attachments = await db .select() .from(stageSubmissionAttachments) .where( and( eq(stageSubmissionAttachments.submissionId, submissionId), eq(stageSubmissionAttachments.status, 'ACTIVE') ) ) return { ...result[0], attachments } as SubmissionFullInfo } /** * 파일 내용과 함께 InBox 파일 정보 준비 */ private async prepareFilesWithContent( submissionInfo: SubmissionFullInfo ): Promise { const filesWithContent: FileInfoWithBuffer[] = []; const timestamp = this.getTimestamp(); // 모든 파일에 동일한 타임스탬프 사용 const cpyCode = 'C00001'; // CPY_CD는 항상 C00001 고정 (레거시 시스템 협의사항) for (const attachment of submissionInfo.attachments) { try { // 파일 경로 결정 (storagePath 또는 storageUrl 사용) const filePath = attachment.storagePath || attachment.storageUrl; if (!filePath) { console.warn(`첨부파일 ${attachment.id}의 경로를 찾을 수 없습니다.`); continue; } // 전체 경로 생성 const fullPath = path.isAbsolute(filePath) ? filePath : path.join(this.localStoragePath, filePath); // 파일 읽기 const fileBuffer = await fs.readFile(fullPath); // 파일명 파싱 const { extension } = this.parseFileName(attachment.fileName); // OWN_DOC_NO 결정 (vendorDocNumber가 있으면 사용, 없으면 docNumber 사용) const ownDocNo = submissionInfo.document.vendorDocNumber || submissionInfo.document.docNumber; // 리비전 번호 (2자리로 패딩) const revNo = String(submissionInfo.submission.revisionNumber).padStart(2, '0'); // 파일명 생성: [OWNDOCNO]_[REVNO]_[STAGE]_[YYYYMMDDhhmmss].[extension] const fileName = extension ? `${ownDocNo}_${revNo}_${submissionInfo.stage.stageName}_${timestamp}.${extension}` : `${ownDocNo}_${revNo}_${submissionInfo.stage.stageName}_${timestamp}`; // 폴더 경로 생성: \\projNo\\cpyCd\\[OFDC_NO]\\YYYYMMDD // OFDC_NO가 없으면 빈 문자열 (\\\\로 표시됨) const dateOnly = timestamp.substring(0, 8); // YYYYMMDD만 추출 const ofdcNo = ''; // OFDC_NO는 현재 null이므로 빈 문자열 const fldPath = `\\\\${submissionInfo.project.code}\\\\${cpyCode}\\\\${ofdcNo}\\\\${dateOnly}`; // 파일 정보 생성 (새로운 API 형식) const fileInfo: FileInfoWithBuffer = { CPY_CD: cpyCode, FILE_NM: fileName, OFDC_NO: null, PROJ_NO: submissionInfo.project.code, OWN_DOC_NO: ownDocNo, REV_NO: revNo, STAGE: submissionInfo.stage.stageName, STAT: 'SCW03', // Completed 상태 FILE_SZ: String(fileBuffer.length), FLD_PATH: fldPath, fileBuffer: fileBuffer, attachment: { id: attachment.id, fileName: attachment.fileName, mimeType: attachment.mimeType, storagePath: attachment.storagePath, storageUrl: attachment.storageUrl, }, // 네트워크 경로 생성을 위한 추가 정보 _timestamp: timestamp, _extension: extension, }; filesWithContent.push(fileInfo); } catch (error) { console.error(`파일 읽기 실패: ${attachment.fileName}`, error); // 파일 읽기 실패 시 계속 진행 continue; } } return filesWithContent; } /** * SaveInBoxList API 호출 (파일 메타데이터만 전송) */ private async sendToInBox( files: FileInfoWithBuffer[] ): Promise { // fileBuffer, attachment, _timestamp, _extension을 제외한 메타데이터만 전송 const fileMetadata = files.map((file) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { fileBuffer, attachment, _timestamp, _extension, ...metadata } = file; return metadata as InBoxFileInfo; }); // 새로운 API 형식에 맞게 요청 생성 const request: SaveInBoxListRequest = { externalInboxLists: fileMetadata }; console.log('SaveInBoxList 요청:', JSON.stringify(request, null, 2)); const response = await fetch(`${this.ddcUrl}/SaveInBoxList`, { 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(`InBox 전송 실패: ${response.statusText} - ${errorText}`); } const data = await response.json(); console.log('SaveInBoxList 응답:', JSON.stringify(data, null, 2)); // 응답 구조 확인 및 처리 if (!data.SaveInBoxListResult) { return { SaveInBoxListResult: { success: true, message: '전송 완료', processedCount: files.length, files: files.map((f) => { // 레거시 네트워크 경로 생성 const networkPath = this.generateNetworkPath( f.PROJ_NO, f.CPY_CD, f._timestamp, f.OWN_DOC_NO, f.REV_NO, f.STAGE, f._extension ); return { fileName: f.FILE_NM, networkPath: networkPath, status: 'READY', }; }), }, }; } return data; } /** * SMB 마운트 경로에 파일 저장 (레거시 경로 규칙 적용) */ private async saveFilesToNetworkPaths( filesWithContent: FileInfoWithBuffer[] ) { for (const fileInfo of filesWithContent) { try { // 레거시 경로 규칙에 따라 마운트 경로 생성 const targetPath = this.generateMountPath( fileInfo.PROJ_NO, fileInfo.CPY_CD, fileInfo._timestamp, fileInfo.OWN_DOC_NO, fileInfo.REV_NO, fileInfo.STAGE, fileInfo._extension ); // 디렉토리 생성 (없는 경우) const targetDir = path.dirname(targetPath); await fs.mkdir(targetDir, { recursive: true }); // 파일 저장 await fs.writeFile(targetPath, fileInfo.fileBuffer); console.log(`파일 저장 완료: ${fileInfo.FILE_NM} -> ${targetPath}`); console.log( `생성된 경로 구조: ${fileInfo.PROJ_NO}/${fileInfo.CPY_CD}/${fileInfo._timestamp}/[${fileInfo.OWN_DOC_NO}]_${fileInfo.REV_NO}_${fileInfo.STAGE}_${fileInfo._timestamp.substring(0, 8)}.${fileInfo._extension}` ); // 네트워크 경로 생성 (레거시 형식) const networkPath = this.generateNetworkPath( fileInfo.PROJ_NO, fileInfo.CPY_CD, fileInfo._timestamp, fileInfo.OWN_DOC_NO, fileInfo.REV_NO, fileInfo.STAGE, fileInfo._extension ); console.log(`네트워크 경로: ${networkPath}`); console.log(`FLD_PATH (API 전송용): ${fileInfo.FLD_PATH}`); // DB에 경로 정보 업데이트 await db .update(stageSubmissionAttachments) .set({ buyerSystemUrl: networkPath, // 네트워크 경로 저장 (SHI 시스템에서 접근 가능한 경로) buyerSystemStatus: 'UPLOADED', lastModifiedBy: 'EVCP' }) .where(eq(stageSubmissionAttachments.id, fileInfo.attachment.id)) } catch (error) { console.error(`파일 저장 실패: ${fileInfo.FILE_NM}`, error) // 개별 파일 실패는 전체 프로세스를 중단하지 않음 } } } /** * 제출 동기화 상태 업데이트 */ private async updateSubmissionSyncStatus( submissionId: number, status: string, error?: string | null, additionalData?: Record ) { const updateData: any = { syncStatus: status, lastSyncedAt: new Date(), syncError: error ?? null, lastModifiedBy: 'EVCP', ...additionalData } if (status === 'failed') { updateData.syncRetryCount = sql`sync_retry_count + 1` updateData.nextRetryAt = new Date(Date.now() + 30 * 60 * 1000) // 30분 후 재시도 } await db .update(stageSubmissions) .set(updateData) .where(eq(stageSubmissions.id, submissionId)) } /** * 첨부파일 동기화 상태 업데이트 */ private async updateAttachmentsSyncStatus( attachmentIds: number[], status: string ) { if (attachmentIds.length === 0) return await db .update(stageSubmissionAttachments) .set({ syncStatus: status, syncCompletedAt: status === 'synced' ? new Date() : null, buyerSystemStatus: status === 'synced' ? 'UPLOADED' : 'PENDING', lastModifiedBy: 'EVCP' }) .where(inArray(stageSubmissionAttachments.id, attachmentIds)) } /** * 동기화 재시도 (실패한 건들) */ async retrySyncFailedSubmissions(contractId?: number) { const conditions = [ 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) ) ) } } const failedSubmissions = await db .select({ id: stageSubmissions.id }) .from(stageSubmissions) .where(and(...conditions)) .limit(10) // 한 번에 최대 10개씩 재시도 if (failedSubmissions.length === 0) { return { success: true, message: "재시도할 제출 건이 없습니다.", retryCount: 0 } } const submissionIds = failedSubmissions.map(s => s.id) const results = await this.syncSubmissionsToSHI(submissionIds) return { success: true, message: `${results.successCount}/${results.totalCount}개 제출 건 재시도 완료`, ...results } } }