diff options
| -rw-r--r-- | app/api/stage-submissions/upload/route.ts | 230 | ||||
| -rw-r--r-- | db/schema/vendorDocu.ts | 3 | ||||
| -rw-r--r-- | lib/vendor-document-list/plant/shi-buyer-system-api.ts | 356 | ||||
| -rw-r--r-- | lib/vendor-document-list/plant/upload/table.tsx | 65 |
4 files changed, 545 insertions, 109 deletions
diff --git a/app/api/stage-submissions/upload/route.ts b/app/api/stage-submissions/upload/route.ts new file mode 100644 index 00000000..b2771005 --- /dev/null +++ b/app/api/stage-submissions/upload/route.ts @@ -0,0 +1,230 @@ +// app/api/stage-submissions/upload/route.ts +import { NextRequest, NextResponse } from "next/server" +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import db from "@/db/db" +import { stageSubmissions, stageSubmissionAttachments, vendors } from "@/db/schema" +import { eq, and } from "drizzle-orm" +import { + extractRevisionNumber, + normalizeRevisionCode +} from "@/lib/vendor-document-list/plant/upload/util/filie-parser" +import { saveFileStream } from "@/lib/file-stroage" + +/** + * 단일 submission에 여러 파일을 업로드하는 API + * single-upload-dialog.tsx에서 사용 + */ +export async function POST(request: NextRequest) { + const session = await getServerSession(authOptions) + if (!session?.user?.companyId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const vendorId = session.user.companyId + const userId = session.user.id + + const vendor = await db.query.vendors.findFirst({ + where: eq(vendors.id, session.user.companyId), + columns: { + vendorName: true, + vendorCode: true, + } + }) + + try { + const formData = await request.formData() + + // 파일들 추출 + const files = formData.getAll('files') as File[] + + if (files.length === 0) { + return NextResponse.json( + { error: "No files provided" }, + { status: 400 } + ) + } + + // 메타데이터 추출 + const documentId = parseInt(formData.get('documentId') as string) + const stageId = parseInt(formData.get('stageId') as string) + const revision = formData.get('revision') as string + const description = formData.get('description') as string || "" + + if (!documentId || !stageId || !revision) { + return NextResponse.json( + { error: "Missing required fields: documentId, stageId, or revision" }, + { status: 400 } + ) + } + + // 총 용량 체크 (5GB per submission) + const totalSize = files.reduce((acc, file) => acc + file.size, 0) + const maxSize = 5 * 1024 * 1024 * 1024 // 5GB + + if (totalSize > maxSize) { + return NextResponse.json( + { error: `Total file size exceeds 5GB limit` }, + { status: 400 } + ) + } + + // 리비전 정보 추출 + const revisionNumber = extractRevisionNumber(revision) + const revisionCode = normalizeRevisionCode(revision) + + const uploadResults: Array<{ + fileName: string + success: boolean + error?: string + }> = [] + let submissionId: number | null = null + + // 트랜잭션으로 처리 + await db.transaction(async (tx) => { + // 1. 해당 스테이지의 기존 submission 찾기 + const existingSubmissions = await tx + .select() + .from(stageSubmissions) + .where( + and( + eq(stageSubmissions.stageId, stageId), + eq(stageSubmissions.documentId, documentId), + eq(stageSubmissions.revisionNumber, revisionNumber) + ) + ) + .limit(1) + + let submission + + if (existingSubmissions.length > 0) { + // 기존 submission 업데이트 + submission = existingSubmissions[0] + + await tx + .update(stageSubmissions) + .set({ + totalFiles: (submission.totalFiles || 0) + files.length, + totalFileSize: (submission.totalFileSize || 0) + totalSize, + updatedAt: new Date(), + lastModifiedBy: "EVCP", + }) + .where(eq(stageSubmissions.id, submission.id)) + + submissionId = submission.id + } else { + // 새 submission 생성 + const [newSubmission] = await tx + .insert(stageSubmissions) + .values({ + stageId, + documentId, + revisionNumber, + revisionCode, + revisionType: revisionNumber === 0 ? "INITIAL" : "RESUBMISSION", + submissionStatus: "SUBMITTED", + submittedBy: session.user.name || session.user.email || "Unknown", + submittedByEmail: session.user.email || null, + vendorId, + vendorCode: vendor?.vendorCode || null, + totalFiles: files.length, + totalFileSize: totalSize, + submissionTitle: description || `Revision ${revisionCode} Submission`, + submissionNotes: description || null, + syncStatus: "pending", + lastModifiedBy: "EVCP", + }) + .returning() + + submission = newSubmission + submissionId = newSubmission.id + } + + // 2. 각 파일 저장 및 attachment 레코드 생성 + const directory = `submissions/${documentId}/${stageId}/${revisionCode}` + + for (const file of files) { + try { + // 파일 저장 (대용량 파일은 스트리밍) + let saveResult + if (file.size > 100 * 1024 * 1024) { // 100MB 이상은 스트리밍 + saveResult = await saveFileStream({ + file, + directory, + originalName: file.name, + userId: userId.toString() + }) + } else { + const { saveFile } = await import("@/lib/file-stroage") + saveResult = await saveFile({ + file, + directory, + originalName: file.name, + userId: userId.toString() + }) + } + + if (!saveResult.success) { + throw new Error(saveResult.error || "File save failed") + } + + // attachment 레코드 생성 + const fileExtension = file.name.split('.').pop() || '' + + await tx.insert(stageSubmissionAttachments).values({ + submissionId: submissionId!, + fileName: saveResult.fileName!, + originalFileName: file.name, + fileType: file.type, + fileExtension: fileExtension, + fileSize: file.size, + storageType: "LOCAL", + storagePath: saveResult.filePath!, + storageUrl: saveResult.publicPath!, + mimeType: file.type, + uploadedBy: session.user.name || session.user.email || "Unknown", + status: "ACTIVE", + syncStatus: "pending", + lastModifiedBy: "EVCP", + }) + + uploadResults.push({ + fileName: file.name, + success: true + }) + + } catch (error) { + console.error(`Failed to upload ${file.name}:`, error) + uploadResults.push({ + fileName: file.name, + success: false, + error: error instanceof Error ? error.message : "Upload failed" + }) + throw error // 트랜잭션 롤백 + } + } + }) + + const successCount = uploadResults.filter(r => r.success).length + + return NextResponse.json({ + success: true, + submissionId, + uploaded: successCount, + failed: uploadResults.length - successCount, + results: uploadResults, + message: `Successfully uploaded ${successCount} file(s)` + }) + + } catch (error) { + console.error("Upload error:", error) + return NextResponse.json( + { + error: "Upload failed", + details: error instanceof Error ? error.message : "Unknown error" + }, + { status: 500 } + ) + } +} + diff --git a/db/schema/vendorDocu.ts b/db/schema/vendorDocu.ts index 624ce11d..de9dc7fb 100644 --- a/db/schema/vendorDocu.ts +++ b/db/schema/vendorDocu.ts @@ -1949,7 +1949,8 @@ export const stageSubmissionView = pgView("stage_submission_view", { FROM stage_documents sd LEFT JOIN projects p ON sd.project_id = p.id LEFT JOIN vendors v ON sd.vendor_id = v.id - WHERE sd.buyer_system_status = '승인(DC)' + WHERE + ( sd.buyer_system_status = '승인(DC)' OR sd.buyer_system_status = 'Completed' ) -- 승인(DC)라고 하였으나 실제 값은 Completed로 옴 AND sd.status = 'ACTIVE' ), 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 f82151cd..39336c1d 100644 --- a/lib/vendor-document-list/plant/shi-buyer-system-api.ts +++ b/lib/vendor-document-list/plant/shi-buyer-system-api.ts @@ -85,21 +85,18 @@ interface ShiApiResponse { GetDwgInfoResult: ShiDocumentResponse[] } -// InBox 파일 정보 인터페이스 추가 +// InBox 파일 정보 인터페이스 (SaveInBoxList API 요청 형식) interface InBoxFileInfo { - PROJ_NO: string - SHI_DOC_NO: string - STAGE_NAME: string - REVISION_NO: string - VNDR_CD: string - VNDR_NM: string - FILE_NAME: string - FILE_SIZE: number - CONTENT_TYPE: string - UPLOAD_DATE: string - UPLOADED_BY: string - STATUS: string - COMMENT: string + 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] } // 파일 저장용 확장 인터페이스 @@ -108,20 +105,14 @@ interface FileInfoWithBuffer extends InBoxFileInfo { attachment: { id: number; fileName: string; - mimeType?: string; - storagePath?: string; - storageUrl?: string; + mimeType: string | null; + storagePath: string | null; + storageUrl: string | null; + [key: string]: any; }; -} - -// 경로 생성용 인터페이스 (유연한 타입) -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; + // 네트워크 경로 생성을 위한 추가 정보 + _timestamp: string; + _extension: string; } // SaveInBoxList API 응답 인터페이스 @@ -138,6 +129,74 @@ interface SaveInBoxListResponse { } } +// 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' @@ -175,30 +234,51 @@ export class ShiBuyerSystemAPI { } /** - * SMB 마운트 경로에 맞는 파일 경로 생성 - * /mnt/swp-smb-dir/{proj_no}/{cpyCd}/{YYYYMMDDhhmmss}/{[파일명]}{DOC_NO}_{REV}_{STAGE}.{extension} + * SMB 마운트 경로에 맞는 파일 경로 생성 (레거시 경로 규칙) + * /mnt/swp-smb-dir/{PROJ_NO}/{CPY_CD}/{YYYYMMDDHHmmSS}/[OWN_DOC_NO]_[REV_NO]_[STAGE]_[YYYYMMDD].확장자 */ - 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; - + 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, vndrCd, timestamp, fullFileName); + 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) { @@ -237,7 +317,7 @@ export class ShiBuyerSystemAPI { } } - private async getDocumentsToSend(contractId: number) { + private async getDocumentsToSend(contractId: number): Promise<DocumentWithStages[]> { // 1. 먼저 문서 목록을 가져옴 const documents = await db .select({ @@ -276,14 +356,14 @@ export class ShiBuyerSystemAPI { return { ...doc, stages: stages || [] - } + } as DocumentWithStages }) ) return documentsWithStages } - private async sendDocumentInfo(documents: any[]) { + private async sendDocumentInfo(documents: DocumentWithStages[]) { const shiDocuments: ShiDocumentInfo[] = documents.map((doc) => ({ PROJ_NO: doc.projectCode, SHI_DOC_NO: doc.docNumber, @@ -326,7 +406,7 @@ export class ShiBuyerSystemAPI { return response.json() } - private async sendScheduleInfo(documents: any[]) { + private async sendScheduleInfo(documents: DocumentWithStages[]) { const schedules: ShiScheduleInfo[] = [] for (const doc of documents) { @@ -413,15 +493,15 @@ export class ShiBuyerSystemAPI { where: eq(contracts.id, contractId), }); - if (!contract) { - throw new Error(`계약을 찾을 수 없습니다: ${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) { + if (!project || !project.code) { throw new Error(`프로젝트를 찾을 수 없습니다: ${contract.projectId}`) } @@ -429,12 +509,12 @@ export class ShiBuyerSystemAPI { where: eq(vendors.id, contract.vendorId), }); - if (!vendor) { + if (!vendor || !vendor.vendorCode) { throw new Error(`벤더를 찾을 수 없습니다: ${contract.vendorId}`) } const shiDocuments = await this.fetchDocumentsFromSHI(project.code, { - VNDR_CD: vendor.vendorCode + VNDR_CD: vendor.vendorCode ?? undefined }) if (!shiDocuments || shiDocuments.length === 0) { @@ -508,7 +588,13 @@ export class ShiBuyerSystemAPI { ) { let updatedCount = 0 let newCount = 0 - const updatedDocuments: any[] = [] + 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) @@ -599,7 +685,13 @@ export class ShiBuyerSystemAPI { totalCount: submissionIds.length, successCount: 0, failedCount: 0, - details: [] as any[] + details: [] as Array<{ + submissionId: number + success: boolean + message?: string + syncedFiles?: number + error?: string + }> } for (const submissionId of submissionIds) { @@ -696,7 +788,7 @@ export class ShiBuyerSystemAPI { /** * 제출 정보 조회 (관련 정보 포함) */ - private async getSubmissionFullInfo(submissionId: number) { + private async getSubmissionFullInfo(submissionId: number): Promise<SubmissionFullInfo | null> { const result = await db .select({ submission: stageSubmissions, @@ -729,16 +821,18 @@ export class ShiBuyerSystemAPI { return { ...result[0], attachments - } + } as SubmissionFullInfo } /** * 파일 내용과 함께 InBox 파일 정보 준비 */ private async prepareFilesWithContent( - submissionInfo: any + submissionInfo: SubmissionFullInfo ): Promise<FileInfoWithBuffer[]> { const filesWithContent: FileInfoWithBuffer[] = []; + const timestamp = this.getTimestamp(); // 모든 파일에 동일한 타임스탬프 사용 + const cpyCode = 'C00001'; // CPY_CD는 항상 C00001 고정 (레거시 시스템 협의사항) for (const attachment of submissionInfo.attachments) { try { @@ -758,23 +852,46 @@ export class ShiBuyerSystemAPI { // 파일 읽기 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].[extension] + const fileName = extension + ? `${ownDocNo}_${revNo}_${submissionInfo.stage.stageName}.${extension}` + : `${ownDocNo}_${revNo}_${submissionInfo.stage.stageName}`; + + // 폴더 경로 생성: [ProjNo][CpyCd][YYYYMMDDHHMMSS] + const fldPath = `${submissionInfo.project.code}${cpyCode}${timestamp}`; + + // 파일 정보 생성 (새로운 API 형식) const fileInfo: FileInfoWithBuffer = { + CPY_CD: cpyCode, + FILE_NM: fileName, + OFDC_NO: null, PROJ_NO: submissionInfo.project.code, - SHI_DOC_NO: submissionInfo.document.docNumber, - STAGE_NAME: submissionInfo.stage.stageName, - REVISION_NO: String(submissionInfo.submission.revisionNumber), - VNDR_CD: submissionInfo.vendor?.vendorCode || '', - VNDR_NM: submissionInfo.vendor?.vendorName || '', - FILE_NAME: attachment.fileName, - FILE_SIZE: fileBuffer.length, // 실제 파일 크기 사용 - CONTENT_TYPE: attachment.mimeType || 'application/octet-stream', - UPLOAD_DATE: new Date().toISOString(), - UPLOADED_BY: submissionInfo.submission.submittedBy, - STATUS: 'PENDING', - COMMENT: `Revision ${submissionInfo.submission.revisionNumber} - ${submissionInfo.stage.stageName}`, + 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: attachment, + attachment: { + id: attachment.id, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + storagePath: attachment.storagePath, + storageUrl: attachment.storageUrl, + }, + // 네트워크 경로 생성을 위한 추가 정보 + _timestamp: timestamp, + _extension: extension, }; filesWithContent.push(fileInfo); @@ -794,12 +911,19 @@ export class ShiBuyerSystemAPI { private async sendToInBox( files: FileInfoWithBuffer[] ): Promise<SaveInBoxListResponse> { - // fileBuffer와 attachment를 제외한 메타데이터만 전송 - const fileMetadata = files.map( - ({ fileBuffer, attachment, ...metadata }) => metadata as InBoxFileInfo - ); + // 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 + }; - const request = { files: fileMetadata }; + console.log('SaveInBoxList 요청:', JSON.stringify(request, null, 2)); const response = await fetch(`${this.ddcUrl}/SaveInBoxList`, { method: 'POST', @@ -817,6 +941,8 @@ export class ShiBuyerSystemAPI { const data = await response.json(); + console.log('SaveInBoxList 응답:', JSON.stringify(data, null, 2)); + // 응답 구조 확인 및 처리 if (!data.SaveInBoxListResult) { return { @@ -824,11 +950,23 @@ export class ShiBuyerSystemAPI { success: true, message: '전송 완료', processedCount: files.length, - files: files.map((f) => ({ - fileName: f.FILE_NAME, - networkPath: `\\\\network\\share\\${f.PROJ_NO}\\${f.SHI_DOC_NO}\\${f.FILE_NAME}`, - status: 'READY', - })), + 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', + }; + }), }, }; } @@ -837,15 +975,23 @@ export class ShiBuyerSystemAPI { } /** - * SMB 마운트 경로에 파일 저장 (새로운 경로 규칙 적용) + * SMB 마운트 경로에 파일 저장 (레거시 경로 규칙 적용) */ private async saveFilesToNetworkPaths( filesWithContent: FileInfoWithBuffer[] ) { for (const fileInfo of filesWithContent) { try { - // 새로운 경로 규칙에 따라 마운트 경로 생성 - const targetPath = this.generateMountPath(fileInfo); + // 레거시 경로 규칙에 따라 마운트 경로 생성 + 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); @@ -854,25 +1000,37 @@ export class ShiBuyerSystemAPI { // 파일 저장 await fs.writeFile(targetPath, fileInfo.fileBuffer); - console.log(`파일 저장 완료: ${fileInfo.FILE_NAME} -> ${targetPath}`); + console.log(`파일 저장 완료: ${fileInfo.FILE_NM} -> ${targetPath}`); console.log( - `생성된 경로 구조: proj_no=${fileInfo.PROJ_NO}, cpyCd=${ - fileInfo.VNDR_CD - }, timestamp=${this.getTimestamp()}` + `생성된 경로 구조: ${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 ); - // DB에 마운트 경로 업데이트 (네트워크 경로 대신 마운트 경로 저장) + console.log(`네트워크 경로: ${networkPath}`); + console.log(`FLD_PATH (API 전송용): ${fileInfo.FLD_PATH}`); + + // DB에 경로 정보 업데이트 await db .update(stageSubmissionAttachments) .set({ - buyerSystemUrl: targetPath, // 생성된 마운트 경로 저장 + buyerSystemUrl: networkPath, // 네트워크 경로 저장 (SHI 시스템에서 접근 가능한 경로) buyerSystemStatus: 'UPLOADED', lastModifiedBy: 'EVCP' }) .where(eq(stageSubmissionAttachments.id, fileInfo.attachment.id)) } catch (error) { - console.error(`파일 저장 실패: ${fileInfo.FILE_NAME}`, error) + console.error(`파일 저장 실패: ${fileInfo.FILE_NM}`, error) // 개별 파일 실패는 전체 프로세스를 중단하지 않음 } } @@ -885,12 +1043,12 @@ export class ShiBuyerSystemAPI { submissionId: number, status: string, error?: string | null, - additionalData?: any + additionalData?: Record<string, string | number | Date | null> ) { const updateData: any = { syncStatus: status, lastSyncedAt: new Date(), - syncError: error, + syncError: error ?? null, lastModifiedBy: 'EVCP', ...additionalData } diff --git a/lib/vendor-document-list/plant/upload/table.tsx b/lib/vendor-document-list/plant/upload/table.tsx index 2247fc57..2edd9717 100644 --- a/lib/vendor-document-list/plant/upload/table.tsx +++ b/lib/vendor-document-list/plant/upload/table.tsx @@ -170,31 +170,31 @@ export function StageSubmissionsTable({ promises, selectedProjectId }: StageSubm if (type === "downloadCover") { const projectCode = row.original.projectCode; const project = projects.find(p => p.code === projectCode); - + if (!project) { toast.error("프로젝트 정보를 찾을 수 없습니다."); setRowAction(null); return; } - + (async () => { try { - const res = await fetch(`/api/projects/${project.id}/cover`, { - method: "GET" + const res = await fetch(`/api/projects/${project.id}/cover`, { + method: "GET" }); - + if (!res.ok) { const error = await res.json(); throw new Error(error.message || "커버 페이지를 가져올 수 없습니다"); } - + const { fileUrl, fileName } = await res.json(); - + // quickDownload 사용 quickDownload(fileUrl, fileName || `${projectCode}_cover.docx`); - + toast.success("커버 페이지 다운로드를 시작했습니다."); - + } catch (e) { toast.error(e instanceof Error ? e.message : "커버 페이지 다운로드에 실패했습니다."); console.error(e); @@ -202,6 +202,53 @@ export function StageSubmissionsTable({ promises, selectedProjectId }: StageSubm setRowAction(null); } })(); + } else if (type === "sync") { + // 개별 행 sync 처리 + const submissionId = row.original.latestSubmissionId; + + if (!submissionId) { + toast.error("제출물 ID를 찾을 수 없습니다."); + setRowAction(null); + return; + } + + (async () => { + try { + toast.info("동기화를 시작합니다..."); + + const response = await fetch('/api/stage-submissions/sync', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ submissionIds: [submissionId] }), + }); + + const result = await response.json(); + + if (result.success) { + toast.success("동기화가 완료되었습니다."); + + // 상세 결과 표시 + if (result.results?.details?.[0]) { + const detail = result.results.details[0]; + if (!detail.success) { + toast.warning(`동기화 실패: ${detail.error || '알 수 없는 오류'}`); + } + } + + // 테이블 새로고침 + window.location.reload(); + } else { + toast.error(result.error || "동기화에 실패했습니다."); + } + } catch (error) { + console.error("Sync error:", error); + toast.error("동기화 중 오류가 발생했습니다."); + } finally { + setRowAction(null); + } + })(); } }, [rowAction, setRowAction, projects]); |
